ukiryu 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ukiryu/cache.rb +6 -0
  3. data/lib/ukiryu/cache_registry.rb +64 -0
  4. data/lib/ukiryu/cli_commands/base_command.rb +6 -5
  5. data/lib/ukiryu/cli_commands/config_command.rb +7 -10
  6. data/lib/ukiryu/cli_commands/register_command.rb +27 -18
  7. data/lib/ukiryu/cli_commands/validate_command.rb +2 -2
  8. data/lib/ukiryu/command_builder.rb +83 -50
  9. data/lib/ukiryu/config.rb +13 -2
  10. data/lib/ukiryu/debug.rb +20 -9
  11. data/lib/ukiryu/definition/loader.rb +3 -3
  12. data/lib/ukiryu/errors.rb +37 -37
  13. data/lib/ukiryu/executable_locator.rb +40 -16
  14. data/lib/ukiryu/extractors/base_extractor.rb +2 -1
  15. data/lib/ukiryu/extractors/help_parser.rb +3 -0
  16. data/lib/ukiryu/logger.rb +51 -0
  17. data/lib/ukiryu/models/implementation_index.rb +2 -1
  18. data/lib/ukiryu/models/implementation_version.rb +18 -1
  19. data/lib/ukiryu/models/interface.rb +2 -1
  20. data/lib/ukiryu/models/run_environment.rb +0 -2
  21. data/lib/ukiryu/models/semantic_version.rb +174 -0
  22. data/lib/ukiryu/models/stage_metrics.rb +0 -1
  23. data/lib/ukiryu/register.rb +473 -232
  24. data/lib/ukiryu/shell/powershell.rb +209 -89
  25. data/lib/ukiryu/shell/sh.rb +4 -1
  26. data/lib/ukiryu/shell.rb +60 -2
  27. data/lib/ukiryu/tool/command_resolution.rb +2 -1
  28. data/lib/ukiryu/tool/executable_discovery.rb +14 -15
  29. data/lib/ukiryu/tool/loader.rb +543 -0
  30. data/lib/ukiryu/tool/version_detection.rb +1 -3
  31. data/lib/ukiryu/tool.rb +79 -87
  32. data/lib/ukiryu/tool_index.rb +127 -62
  33. data/lib/ukiryu/tools/base.rb +4 -2
  34. data/lib/ukiryu/type.rb +26 -15
  35. data/lib/ukiryu/version.rb +1 -1
  36. data/lib/ukiryu.rb +1 -1
  37. data/spec/fixtures/profiles/ghostscript_10.0.yaml +50 -0
  38. data/spec/fixtures/register/tools/ghostscript/default/10.0.yaml +6 -0
  39. data/spec/spec_helper.rb +10 -6
  40. data/spec/support/tool_helper.rb +2 -0
  41. data/spec/ukiryu/definition/loader_spec.rb +2 -2
  42. data/spec/ukiryu/executor_spec.rb +6 -3
  43. data/spec/ukiryu/models/execution_report_spec.rb +3 -2
  44. data/spec/ukiryu/models/semantic_version_spec.rb +284 -0
  45. data/spec/ukiryu/shell/powershell_integration_spec.rb +165 -0
  46. data/spec/ukiryu/shell/powershell_real_command_spec.rb +143 -0
  47. data/spec/ukiryu/shell/powershell_spec.rb +286 -51
  48. data/spec/ukiryu/tool/loader_spec.rb +148 -0
  49. data/spec/ukiryu/tool_index_spec.rb +110 -18
  50. data/spec/ukiryu/tools/ghostscript_spec.rb +242 -0
  51. data/spec/ukiryu/tools/imagemagick_spec.rb +2 -1
  52. data/spec/ukiryu/tools/inkscape_spec.rb +4 -2
  53. metadata +14 -2
  54. data/lib/ukiryu/register_auto_manager.rb +0 -342
data/lib/ukiryu/errors.rb CHANGED
@@ -17,9 +17,9 @@ module Ukiryu
17
17
  class UnknownShellError < Error
18
18
  def suggestions
19
19
  [
20
- "Supported shells: bash, zsh, fish, sh, dash, tcsh, powershell, cmd",
21
- "Platform groups: :unix (all Unix shells), :windows, :powershell",
22
- "Set explicitly: Ukiryu.configure { |c| c.default_shell = :bash }"
20
+ 'Supported shells: bash, zsh, fish, sh, dash, tcsh, powershell, cmd',
21
+ 'Platform groups: :unix (all Unix shells), :windows, :powershell',
22
+ 'Set explicitly: Ukiryu.configure { |c| c.default_shell = :bash }'
23
23
  ]
24
24
  end
25
25
  end
@@ -28,9 +28,9 @@ module Ukiryu
28
28
  class UnsupportedPlatformError < Error
29
29
  def suggestions
30
30
  [
31
- "Ukiryu supports: macOS, Linux, Windows",
31
+ 'Ukiryu supports: macOS, Linux, Windows',
32
32
  "Current platform: #{RUBY_PLATFORM}",
33
- "Check if running on a supported operating system"
33
+ 'Check if running on a supported operating system'
34
34
  ]
35
35
  end
36
36
  end
@@ -39,9 +39,9 @@ module Ukiryu
39
39
  class ValidationError < Error
40
40
  def suggestions
41
41
  [
42
- "Check the parameter type against the tool definition",
43
- "Verify value is within allowed range",
44
- "Ensure value is in the allowed values list"
42
+ 'Check the parameter type against the tool definition',
43
+ 'Verify value is within allowed range',
44
+ 'Ensure value is in the allowed values list'
45
45
  ]
46
46
  end
47
47
  end
@@ -50,9 +50,9 @@ module Ukiryu
50
50
  class ProfileNotFoundError < Error
51
51
  def suggestions
52
52
  [
53
- "Check tool definition has profile for your platform",
54
- "Verify tool definition has profile for your shell",
55
- "Try specifying platform/shell explicitly"
53
+ 'Check tool definition has profile for your platform',
54
+ 'Verify tool definition has profile for your shell',
55
+ 'Try specifying platform/shell explicitly'
56
56
  ]
57
57
  end
58
58
  end
@@ -60,9 +60,9 @@ module Ukiryu
60
60
  class ProfileLoadError < Error
61
61
  def suggestions
62
62
  [
63
- "Verify YAML syntax is correct",
64
- "Check profile structure matches schema",
65
- "Review error message for specific issue"
63
+ 'Verify YAML syntax is correct',
64
+ 'Check profile structure matches schema',
65
+ 'Review error message for specific issue'
66
66
  ]
67
67
  end
68
68
  end
@@ -73,9 +73,9 @@ module Ukiryu
73
73
  class DefinitionNotFoundError < DefinitionError
74
74
  def suggestions
75
75
  [
76
- "Verify file path is correct",
77
- "Check file has .yaml extension",
78
- "Use absolute path if relative path fails"
76
+ 'Verify file path is correct',
77
+ 'Check file has .yaml extension',
78
+ 'Use absolute path if relative path fails'
79
79
  ]
80
80
  end
81
81
  end
@@ -83,9 +83,9 @@ module Ukiryu
83
83
  class DefinitionLoadError < DefinitionError
84
84
  def suggestions
85
85
  [
86
- "Validate YAML syntax",
87
- "Check file is readable",
88
- "Verify file encoding is UTF-8"
86
+ 'Validate YAML syntax',
87
+ 'Check file is readable',
88
+ 'Verify file encoding is UTF-8'
89
89
  ]
90
90
  end
91
91
  end
@@ -94,8 +94,8 @@ module Ukiryu
94
94
  def suggestions
95
95
  [
96
96
  "Run 'ukiryu validate' for detailed errors",
97
- "Compare with schema definition",
98
- "Check tool definition examples"
97
+ 'Compare with schema definition',
98
+ 'Check tool definition examples'
99
99
  ]
100
100
  end
101
101
  end
@@ -107,9 +107,9 @@ module Ukiryu
107
107
  class ToolNotFoundError < Error
108
108
  def suggestions
109
109
  [
110
- "Check tool name spelling",
111
- "Verify register path is correct",
112
- "List available tools: Ukiryu::Register.tool_names"
110
+ 'Check tool name spelling',
111
+ 'Verify register path is correct',
112
+ 'List available tools: Ukiryu::Register.tool_names'
113
113
  ]
114
114
  end
115
115
  end
@@ -117,9 +117,9 @@ module Ukiryu
117
117
  class ExecutableNotFoundError < Error
118
118
  def suggestions
119
119
  [
120
- "Install the tool (e.g., brew install imagemagick)",
121
- "Add executable to PATH",
122
- "Configure search_paths in tool definition"
120
+ 'Install the tool (e.g., brew install imagemagick)',
121
+ 'Add executable to PATH',
122
+ 'Configure search_paths in tool definition'
123
123
  ]
124
124
  end
125
125
  end
@@ -128,9 +128,9 @@ module Ukiryu
128
128
  class ExecutionError < Error
129
129
  def suggestions
130
130
  [
131
- "Check e.result.exit_status for exit code",
132
- "Check e.result.stderr for error message",
133
- "Verify parameters are correct"
131
+ 'Check e.result.exit_status for exit code',
132
+ 'Check e.result.stderr for error message',
133
+ 'Verify parameters are correct'
134
134
  ]
135
135
  end
136
136
  end
@@ -145,9 +145,9 @@ module Ukiryu
145
145
 
146
146
  def suggestions
147
147
  [
148
- "Increase timeout parameter",
149
- "Check UKIRYU_TIMEOUT environment variable",
150
- "Verify tool is not hanging"
148
+ 'Increase timeout parameter',
149
+ 'Check UKIRYU_TIMEOUT environment variable',
150
+ 'Verify tool is not hanging'
151
151
  ]
152
152
  end
153
153
  end
@@ -156,9 +156,9 @@ module Ukiryu
156
156
  class VersionDetectionError < Error
157
157
  def suggestions
158
158
  [
159
- "Verify tool is installed correctly",
160
- "Check version_detection command in tool definition",
161
- "Test version command manually: tool --version"
159
+ 'Verify tool is installed correctly',
160
+ 'Check version_detection command in tool definition',
161
+ 'Test version command manually: tool --version'
162
162
  ]
163
163
  end
164
164
  end
@@ -50,23 +50,17 @@ module Ukiryu
50
50
 
51
51
  # Try primary name first
52
52
  result = DiscoveryStrategy.discover(tool_name, context)
53
- if result && (ENV['UKIRYU_DEBUG_EXECUTABLE'] || (platform == :windows && ENV['CI']))
54
- warn "[UKIRYU DEBUG ExecutableLocator] Found #{tool_name}: #{result[:path]}"
55
- end
53
+ warn "[UKIRYU DEBUG ExecutableLocator] Found #{tool_name}: #{result[:path]}" if result && (ENV['UKIRYU_DEBUG_EXECUTABLE'] || (platform == :windows && ENV['CI']))
56
54
  return result if result
57
55
 
58
56
  # Try aliases
59
57
  aliases.each do |alias_name|
60
58
  result = DiscoveryStrategy.discover(alias_name, context)
61
- if result && (ENV['UKIRYU_DEBUG_EXECUTABLE'] || (platform == :windows && ENV['CI']))
62
- warn "[UKIRYU DEBUG ExecutableLocator] Found alias #{alias_name}: #{result[:path]}"
63
- end
59
+ warn "[UKIRYU DEBUG ExecutableLocator] Found alias #{alias_name}: #{result[:path]}" if result && (ENV['UKIRYU_DEBUG_EXECUTABLE'] || (platform == :windows && ENV['CI']))
64
60
  return result if result
65
61
  end
66
62
 
67
- if ENV['UKIRYU_DEBUG_EXECUTABLE'] || (platform == :windows && ENV['CI'])
68
- warn "[UKIRYU DEBUG ExecutableLocator] NO EXECUTABLE FOUND for #{tool_name} or aliases #{aliases}"
69
- end
63
+ warn "[UKIRYU DEBUG ExecutableLocator] NO EXECUTABLE FOUND for #{tool_name} or aliases #{aliases}" if ENV['UKIRYU_DEBUG_EXECUTABLE'] || (platform == :windows && ENV['CI'])
70
64
 
71
65
  nil
72
66
  end
@@ -151,9 +145,7 @@ module Ukiryu
151
145
  # @param context [DiscoveryContext] discovery environment
152
146
  # @return [Hash, nil] discovery result or nil
153
147
  def discover(command, context)
154
- if ENV['UKIRYU_DEBUG_EXECUTABLE'] || (context.platform == :windows && ENV['CI'])
155
- warn "[UKIRYU DEBUG AliasDiscovery] Checking for alias: #{command.inspect} with shell #{context.shell_class}"
156
- end
148
+ warn "[UKIRYU DEBUG AliasDiscovery] Checking for alias: #{command.inspect} with shell #{context.shell_class}" if ENV['UKIRYU_DEBUG_EXECUTABLE'] || (context.platform == :windows && ENV['CI'])
157
149
 
158
150
  alias_info = context.shell_class.detect_alias(command)
159
151
  warn "[UKIRYU DEBUG AliasDiscovery] Alias info: #{alias_info.inspect}" if ENV['UKIRYU_DEBUG_EXECUTABLE'] || (context.platform == :windows && ENV['CI'])
@@ -162,9 +154,7 @@ module Ukiryu
162
154
  alias_target = alias_info[:target]
163
155
  path = PathScanner.find(command) || PathScanner.find(alias_target)
164
156
 
165
- if ENV['UKIRYU_DEBUG_EXECUTABLE'] || (context.platform == :windows && ENV['CI'])
166
- warn "[UKIRYU DEBUG AliasDiscovery] Alias target: #{alias_target.inspect}, path: #{path.inspect}"
167
- end
157
+ warn "[UKIRYU DEBUG AliasDiscovery] Alias target: #{alias_target.inspect}, path: #{path.inspect}" if ENV['UKIRYU_DEBUG_EXECUTABLE'] || (context.platform == :windows && ENV['CI'])
168
158
 
169
159
  DiscoveryResult.build(path, :alias, context, alias_info[:definition]) if path
170
160
  end
@@ -325,12 +315,21 @@ module Ukiryu
325
315
 
326
316
  # Handle platform-specific path extensions (.exe, .bat, etc.)
327
317
  #
318
+ # On Windows, prioritizes .exe over .com for better PowerShell compatibility.
319
+ # The .com extension is legacy and can cause issues with PowerShell's call
320
+ # operator when used with I/O redirection (hangs with Open3.capture3).
321
+ #
328
322
  # @api private
329
323
  class PathExtensions
330
324
  include Enumerable
331
325
 
326
+ # Extensions to prioritize on Windows for PowerShell compatibility
327
+ # .com files can hang PowerShell when used with I/O redirection
328
+ PREFERRED_WINDOWS_EXTENSIONS = %w[.exe .EXE].freeze
329
+
332
330
  def initialize
333
- @extensions = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
331
+ raw_extensions = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') : ['']
332
+ @extensions = prioritize_extensions(raw_extensions)
334
333
  end
335
334
 
336
335
  # Iterate over extensions
@@ -339,6 +338,31 @@ module Ukiryu
339
338
  def each(&block)
340
339
  @extensions.each(&block)
341
340
  end
341
+
342
+ private
343
+
344
+ # Prioritize .exe extensions on Windows for better PowerShell compatibility
345
+ #
346
+ # @param extensions [Array<String>] original PATHEXT extensions
347
+ # @return [Array<String>] reordered extensions with .exe first
348
+ def prioritize_extensions(extensions)
349
+ return extensions unless Platform.windows?
350
+
351
+ # Separate preferred extensions from others
352
+ preferred = []
353
+ others = []
354
+
355
+ extensions.each do |ext|
356
+ if PREFERRED_WINDOWS_EXTENSIONS.include?(ext)
357
+ preferred << ext
358
+ else
359
+ others << ext
360
+ end
361
+ end
362
+
363
+ # Return preferred first, then others in original order
364
+ preferred + others
365
+ end
342
366
  end
343
367
  end
344
368
  end
@@ -59,7 +59,8 @@ module Ukiryu
59
59
  args = command[1..]
60
60
 
61
61
  # Execute through Executor (uses Environment system internally)
62
- result = Ukiryu::Executor.execute(executable, args, env: environment, shell: shell_class, allow_failure: true, timeout: timeout)
62
+ result = Ukiryu::Executor.execute(executable, args, env: environment, shell: shell_class, allow_failure: true,
63
+ timeout: timeout)
63
64
 
64
65
  {
65
66
  stdout: result.stdout,
@@ -212,6 +212,9 @@ module Ukiryu
212
212
  /is not recognized as (?:a |the )?(?:name of a )?cmdlet/i,
213
213
  /cannot be found/i,
214
214
  /because it does not exist/i,
215
+ # Windows Start-Process error when executable not found
216
+ /The system cannot find the file specified/i,
217
+ /cannot find the file/i,
215
218
  # General
216
219
  /executable not found/i,
217
220
  /bad command or file name/i
data/lib/ukiryu/logger.rb CHANGED
@@ -10,12 +10,17 @@ module Ukiryu
10
10
  # - Colored output via Paint gem (when available)
11
11
  # - Message classification (debug, info, warn, error)
12
12
  # - Structured output for tool resolution process
13
+ # - Class-level convenience methods for quick debug output
13
14
  #
14
15
  # @example Enable debug mode
15
16
  # ENV['UKIRYU_DEBUG'] = '1'
16
17
  # logger = Ukiryu::Logger.new
17
18
  # logger.debug("Tool resolution started")
18
19
  #
20
+ # @example Class-level debug output (recommended)
21
+ # Ukiryu::Logger.debug("Message", category: :executable)
22
+ # Ukiryu::Logger.debug("Found executable", context: { path: '/usr/bin/tool' })
23
+ #
19
24
  # @example Standard logging
20
25
  # logger = Ukiryu::Logger.new
21
26
  # logger.info("Command completed successfully")
@@ -25,6 +30,52 @@ module Ukiryu
25
30
  # Log levels
26
31
  LEVELS = %i[debug info warn error].freeze
27
32
 
33
+ # Environment variable names for debug control
34
+ DEBUG_ENV_VAR = 'UKIRYU_DEBUG'
35
+ DEBUG_EXECUTABLE_ENV_VAR = 'UKIRYU_DEBUG_EXECUTABLE'
36
+
37
+ class << self
38
+ # Class-level debug output (quick access without instance)
39
+ #
40
+ # @param message [String] the debug message
41
+ # @param category [Symbol, nil] optional category (:executable for UKIRYU_DEBUG_EXECUTABLE)
42
+ # @param context [Hash] optional context data to include
43
+ def debug(message, category: nil, context: {})
44
+ return unless debug_enabled?(category)
45
+
46
+ prefix = "[UKIRYU DEBUG#{category ? " #{category.to_s.upcase}" : ''}]"
47
+ details = context.empty? ? '' : " (#{context.map { |k, v| "#{k}=#{v.inspect}" }.join(', ')})"
48
+ warn "#{prefix} #{message}#{details}"
49
+ end
50
+
51
+ # Check if debug mode is enabled for a given category
52
+ #
53
+ # @param category [Symbol, nil] the category to check
54
+ # @return [Boolean] true if debug is enabled
55
+ def debug_enabled?(category = nil)
56
+ case category
57
+ when :executable
58
+ ENV[DEBUG_EXECUTABLE_ENV_VAR] || (Platform.windows? && ENV['CI'])
59
+ else
60
+ ENV[DEBUG_ENV_VAR]
61
+ end
62
+ end
63
+
64
+ # Get the singleton instance
65
+ #
66
+ # @return [Logger] the logger instance
67
+ def instance
68
+ @instance ||= new
69
+ end
70
+
71
+ # Delegate instance methods to class level
72
+ %i[info warn error debug_resolution].each do |method|
73
+ define_method(method) do |*args, **kwargs|
74
+ instance.send(method, *args, **kwargs)
75
+ end
76
+ end
77
+ end
78
+
28
79
  attr_reader :level, :logger, :output, :paint_available
29
80
 
30
81
  # Initialize a new Logger
@@ -66,7 +66,8 @@ module Ukiryu
66
66
  # @return [ImplementationIndex] Loaded index
67
67
  def self.from_yaml(path)
68
68
  require 'psych'
69
- data = Psych.safe_load_file(path, permitted_classes: [Symbol, String, Integer, Array, Hash, TrueClass, FalseClass])
69
+ data = Psych.safe_load_file(path,
70
+ permitted_classes: [Symbol, String, Integer, Array, Hash, TrueClass, FalseClass])
70
71
  from_hash(data)
71
72
  end
72
73
 
@@ -50,6 +50,17 @@ module Ukiryu
50
50
  platforms.include?(platform_str) && shells.include?(shell_str)
51
51
  end
52
52
 
53
+ # Debug output for profile selection
54
+ if ENV['UKIRYU_DEBUG_EXECUTABLE'] || (ENV['CI'] && defined?(Ukiryu::Platform) && Ukiryu::Platform.windows?)
55
+ warn "[UKIRYU DEBUG compatible_profile] platform=#{platform_str}, shell=#{shell_str}"
56
+ warn "[UKIRYU DEBUG compatible_profile] Found profile: #{profile ? (profile[:name] || profile['name']) : 'nil'}"
57
+ if profile
58
+ warn "[UKIRYU DEBUG compatible_profile] Profile inherits: #{profile[:inherits] || profile['inherits']}"
59
+ warn "[UKIRYU DEBUG compatible_profile] Profile commands nil?: #{profile[:commands].nil?}"
60
+ warn "[UKIRYU DEBUG compatible_profile] Profile commands empty?: #{(profile[:commands] || []).empty?}"
61
+ end
62
+ end
63
+
53
64
  return nil unless profile
54
65
 
55
66
  # Resolve profile inheritance at hash level
@@ -61,11 +72,16 @@ module Ukiryu
61
72
  prof_name.to_s == inherits.to_s
62
73
  end
63
74
 
75
+ if ENV['UKIRYU_DEBUG_EXECUTABLE'] || (ENV['CI'] && defined?(Ukiryu::Platform) && Ukiryu::Platform.windows?)
76
+ warn "[UKIRYU DEBUG compatible_profile] Looking for parent '#{inherits}': #{parent_profile ? 'found' : 'not found'}"
77
+ end
78
+
64
79
  if parent_profile && (profile[:commands].nil? || profile[:commands].empty?)
65
80
  # Copy parent's commands to child profile (without modifying original)
66
81
  parent_commands = parent_profile[:commands] || parent_profile['commands']
67
82
  # Return a new hash with inherited commands
68
83
  profile = profile.dup.merge(commands: parent_commands)
84
+ warn "[UKIRYU DEBUG compatible_profile] Inherited #{parent_commands&.size || 0} commands from parent" if ENV['UKIRYU_DEBUG_EXECUTABLE'] || (ENV['CI'] && defined?(Ukiryu::Platform) && Ukiryu::Platform.windows?)
69
85
  end
70
86
  end
71
87
 
@@ -86,7 +102,8 @@ module Ukiryu
86
102
  # @return [ImplementationVersion] Loaded version
87
103
  def self.from_yaml(path)
88
104
  require 'psych'
89
- data = Psych.safe_load_file(path, permitted_classes: [Symbol, String, Integer, Array, Hash, TrueClass, FalseClass])
105
+ data = Psych.safe_load_file(path,
106
+ permitted_classes: [Symbol, String, Integer, Array, Hash, TrueClass, FalseClass])
90
107
  from_hash(data)
91
108
  end
92
109
 
@@ -63,7 +63,8 @@ module Ukiryu
63
63
  # @return [Interface] Loaded interface
64
64
  def self.from_yaml(path)
65
65
  require 'psych'
66
- data = Psych.safe_load_file(path, permitted_classes: [Symbol, String, Integer, Array, Hash, TrueClass, FalseClass])
66
+ data = Psych.safe_load_file(path,
67
+ permitted_classes: [Symbol, String, Integer, Array, Hash, TrueClass, FalseClass])
67
68
  from_hash(symbolize_keys(data))
68
69
  end
69
70
 
@@ -5,7 +5,6 @@ require 'etc'
5
5
 
6
6
  module Ukiryu
7
7
  module Models
8
-
9
8
  # Run environment information
10
9
  class RunEnvironment < Lutaml::Model::Serializable
11
10
  attribute :hostname, :string, default: ''
@@ -107,6 +106,5 @@ module Ukiryu
107
106
 
108
107
  private_class_method :detect_shell_version_for, :detect_total_memory, :os_version_string
109
108
  end
110
-
111
109
  end
112
110
  end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ukiryu
4
+ module Models
5
+ # Semantic version value object for proper version comparison.
6
+ #
7
+ # Handles version strings like "10.0", "9.5.1", "1.2.3" and provides
8
+ # proper semantic comparison (10.0 > 9.5, not alphabetical).
9
+ #
10
+ # This class ensures that version selection uses actual semantic meaning
11
+ # rather than string comparison, which would incorrectly sort "9.5" > "10.0".
12
+ #
13
+ # @example
14
+ # v1 = Ukiryu::Models::SemanticVersion.new("10.0")
15
+ # v2 = Ukiryu::Models::SemanticVersion.new("9.5")
16
+ # v1 > v2 # => true (correct)
17
+ # "10.0" > "9.5" # => false (wrong - alphabetical)
18
+ #
19
+ class SemanticVersion
20
+ include Comparable
21
+
22
+ # Parse a version string into segments
23
+ #
24
+ # @param version_string [String, nil] the version string to parse
25
+ # @return [Array<Integer>] array of numeric segments
26
+ def self.parse(version_string)
27
+ return [0] if version_string.nil? || version_string.to_s.empty?
28
+
29
+ version_string.to_s
30
+ .split('.')
31
+ .map do |part|
32
+ part.to_i
33
+ rescue StandardError
34
+ 0
35
+ end
36
+ end
37
+
38
+ # Compare two version strings directly
39
+ #
40
+ # @param version_a [String] first version string
41
+ # @param version_b [String] second version string
42
+ # @return [Integer] -1, 0, or 1
43
+ def self.compare(version_a, version_b)
44
+ segments1 = parse(version_a)
45
+ segments2 = parse(version_b)
46
+
47
+ max_length = [segments1.length, segments2.length].max
48
+ padded1 = segments1 + [0] * (max_length - segments1.length)
49
+ padded2 = segments2 + [0] * (max_length - segments2.length)
50
+
51
+ padded1 <=> padded2
52
+ end
53
+
54
+ # @return [Array<Integer>] the numeric segments of this version
55
+ attr_reader :segments
56
+
57
+ # @return [String, nil] the original version string
58
+ attr_reader :original
59
+
60
+ # Create a new SemanticVersion from a version string
61
+ #
62
+ # @param version_string [String, Integer, SemanticVersion, nil] the version
63
+ def initialize(version_string)
64
+ @original = version_string.respond_to?(:to_s) ? version_string.to_s : nil
65
+ @segments = self.class.parse(@original)
66
+ end
67
+
68
+ # Compare this version with another
69
+ #
70
+ # @param other [SemanticVersion, String, Integer, nil] the other version
71
+ # @return [Integer, nil] -1, 0, 1, or nil if not comparable
72
+ def <=>(other)
73
+ return nil unless other
74
+
75
+ other_segments = case other
76
+ when SemanticVersion
77
+ other.segments
78
+ when String, Integer
79
+ self.class.parse(other)
80
+ else
81
+ return nil
82
+ end
83
+
84
+ # Compare segment by segment
85
+ # [10, 0] <=> [9, 5] should return 1 (10.0 > 9.5)
86
+ max_length = [segments.length, other_segments.length].max
87
+
88
+ max_length.times do |i|
89
+ a = segments[i] || 0
90
+ b = other_segments[i] || 0
91
+
92
+ return a <=> b unless a == b
93
+ end
94
+
95
+ 0 # All segments equal
96
+ end
97
+
98
+ # Check equality with another version
99
+ #
100
+ # @param other [Object] the other object
101
+ # @return [Boolean]
102
+ def ==(other)
103
+ return false unless other
104
+
105
+ (self <=> other).zero?
106
+ end
107
+
108
+ # Check if this version is greater than another
109
+ #
110
+ # @param other [SemanticVersion, String, Integer, nil] the other version
111
+ # @return [Boolean]
112
+ def >(other)
113
+ (self <=> other) == 1
114
+ end
115
+
116
+ # Check if this version is less than another
117
+ #
118
+ # @param other [SemanticVersion, String, Integer, nil] the other version
119
+ # @return [Boolean]
120
+ def <(other)
121
+ (self <=> other) == -1
122
+ end
123
+
124
+ # Check if this version is greater than or equal to another
125
+ #
126
+ # @param other [SemanticVersion, String, Integer, nil] the other version
127
+ # @return [Boolean]
128
+ def >=(other)
129
+ result = self <=> other
130
+ [1, 0].include?(result)
131
+ end
132
+
133
+ # Check if this version is less than or equal to another
134
+ #
135
+ # @param other [SemanticVersion, String, Integer, nil] the other version
136
+ # @return [Boolean]
137
+ def <=(other)
138
+ result = self <=> other
139
+ [-1, 0].include?(result)
140
+ end
141
+
142
+ # Return the version as a string
143
+ #
144
+ # @return [String] the version string
145
+ def to_s
146
+ segments.join('.')
147
+ end
148
+
149
+ # Return a human-readable representation
150
+ #
151
+ # @return [String]
152
+ def inspect
153
+ "#<Ukiryu::Models::SemanticVersion #{self}>"
154
+ end
155
+
156
+ # Hash for use as hash key
157
+ #
158
+ # @return [Integer]
159
+ def hash
160
+ segments.hash
161
+ end
162
+
163
+ # Equality for hash key usage
164
+ #
165
+ # @param other [Object]
166
+ # @return [Boolean]
167
+ def eql?(other)
168
+ return false unless other.is_a?(SemanticVersion)
169
+
170
+ segments == other.segments
171
+ end
172
+ end
173
+ end
174
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
-
4
3
  module Ukiryu
5
4
  module Models
6
5
  # Metrics for a single stage of execution