makit 0.0.157 → 0.0.158

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 (177) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -41
  3. data/exe/makit +5 -5
  4. data/lib/makit/apache.rb +28 -28
  5. data/lib/makit/auto.rb +48 -48
  6. data/lib/makit/azure/blob_storage.rb +257 -257
  7. data/lib/makit/azure/cli.rb +284 -284
  8. data/lib/makit/cli/base.rb +17 -17
  9. data/lib/makit/cli/build_commands.rb +500 -500
  10. data/lib/makit/cli/generators/base_generator.rb +74 -74
  11. data/lib/makit/cli/generators/dotnet_generator.rb +50 -50
  12. data/lib/makit/cli/generators/generator_factory.rb +49 -49
  13. data/lib/makit/cli/generators/node_generator.rb +50 -50
  14. data/lib/makit/cli/generators/ruby_generator.rb +77 -77
  15. data/lib/makit/cli/generators/rust_generator.rb +50 -50
  16. data/lib/makit/cli/generators/templates/dotnet_templates.rb +167 -167
  17. data/lib/makit/cli/generators/templates/node_templates.rb +161 -161
  18. data/lib/makit/cli/generators/templates/ruby/gemfile.rb +26 -26
  19. data/lib/makit/cli/generators/templates/ruby/gemspec.rb +41 -41
  20. data/lib/makit/cli/generators/templates/ruby/main_lib.rb +33 -33
  21. data/lib/makit/cli/generators/templates/ruby/rakefile.rb +35 -35
  22. data/lib/makit/cli/generators/templates/ruby/readme.rb +63 -63
  23. data/lib/makit/cli/generators/templates/ruby/test.rb +39 -39
  24. data/lib/makit/cli/generators/templates/ruby/test_helper.rb +29 -29
  25. data/lib/makit/cli/generators/templates/ruby/version.rb +29 -29
  26. data/lib/makit/cli/generators/templates/rust_templates.rb +128 -128
  27. data/lib/makit/cli/main.rb +78 -78
  28. data/lib/makit/cli/pipeline_commands.rb +311 -311
  29. data/lib/makit/cli/project_commands.rb +868 -868
  30. data/lib/makit/cli/repository_commands.rb +661 -661
  31. data/lib/makit/cli/strategy_commands.rb +207 -207
  32. data/lib/makit/cli/utility_commands.rb +521 -521
  33. data/lib/makit/commands/factory.rb +359 -359
  34. data/lib/makit/commands/middleware/base.rb +73 -73
  35. data/lib/makit/commands/middleware/cache.rb +248 -248
  36. data/lib/makit/commands/middleware/command_logger.rb +312 -312
  37. data/lib/makit/commands/middleware/validator.rb +269 -269
  38. data/lib/makit/commands/request.rb +316 -316
  39. data/lib/makit/commands/result.rb +323 -323
  40. data/lib/makit/commands/runner.rb +386 -386
  41. data/lib/makit/commands/strategies/base.rb +171 -171
  42. data/lib/makit/commands/strategies/child_process.rb +162 -162
  43. data/lib/makit/commands/strategies/factory.rb +136 -136
  44. data/lib/makit/commands/strategies/synchronous.rb +139 -139
  45. data/lib/makit/commands.rb +50 -50
  46. data/lib/makit/configuration/dotnet_project.rb +48 -48
  47. data/lib/makit/configuration/gitlab_helper.rb +61 -61
  48. data/lib/makit/configuration/project.rb +292 -292
  49. data/lib/makit/configuration/rakefile_helper.rb +43 -43
  50. data/lib/makit/configuration/step.rb +34 -34
  51. data/lib/makit/configuration/timeout.rb +74 -74
  52. data/lib/makit/configuration.rb +21 -21
  53. data/lib/makit/content/default_gitignore.rb +7 -7
  54. data/lib/makit/content/default_gitignore.txt +225 -225
  55. data/lib/makit/content/default_rakefile.rb +13 -13
  56. data/lib/makit/content/gem_rakefile.rb +16 -16
  57. data/lib/makit/context.rb +1 -1
  58. data/lib/makit/data.rb +49 -49
  59. data/lib/makit/directories.rb +170 -170
  60. data/lib/makit/directory.rb +262 -262
  61. data/lib/makit/docs/files.rb +89 -89
  62. data/lib/makit/docs/rake.rb +102 -102
  63. data/lib/makit/dotnet/cli.rb +69 -69
  64. data/lib/makit/dotnet/project.rb +217 -217
  65. data/lib/makit/dotnet/solution.rb +38 -38
  66. data/lib/makit/dotnet/solution_classlib.rb +239 -239
  67. data/lib/makit/dotnet/solution_console.rb +264 -264
  68. data/lib/makit/dotnet/solution_maui.rb +354 -354
  69. data/lib/makit/dotnet/solution_wasm.rb +275 -275
  70. data/lib/makit/dotnet/solution_wpf.rb +304 -304
  71. data/lib/makit/dotnet.rb +102 -102
  72. data/lib/makit/email.rb +90 -90
  73. data/lib/makit/environment.rb +142 -142
  74. data/lib/makit/examples/runner.rb +370 -370
  75. data/lib/makit/exceptions.rb +45 -45
  76. data/lib/makit/fileinfo.rb +32 -32
  77. data/lib/makit/files.rb +43 -43
  78. data/lib/makit/gems.rb +40 -40
  79. data/lib/makit/git/cli.rb +78 -54
  80. data/lib/makit/git/repository.rb +100 -100
  81. data/lib/makit/git.rb +104 -104
  82. data/lib/makit/gitlab/pipeline.rb +857 -857
  83. data/lib/makit/gitlab/pipeline_service_impl.rb +1535 -1535
  84. data/lib/makit/gitlab_runner.rb +59 -59
  85. data/lib/makit/humanize.rb +218 -218
  86. data/lib/makit/indexer.rb +47 -47
  87. data/lib/makit/io/filesystem.rb +111 -111
  88. data/lib/makit/io/filesystem_service_impl.rb +337 -337
  89. data/lib/makit/lint.rb +212 -212
  90. data/lib/makit/logging/configuration.rb +309 -309
  91. data/lib/makit/logging/format_registry.rb +84 -84
  92. data/lib/makit/logging/formatters/base.rb +39 -39
  93. data/lib/makit/logging/formatters/console_formatter.rb +140 -140
  94. data/lib/makit/logging/formatters/json_formatter.rb +65 -65
  95. data/lib/makit/logging/formatters/plain_text_formatter.rb +71 -71
  96. data/lib/makit/logging/formatters/text_formatter.rb +64 -64
  97. data/lib/makit/logging/log_request.rb +119 -119
  98. data/lib/makit/logging/logger.rb +199 -199
  99. data/lib/makit/logging/sinks/base.rb +91 -91
  100. data/lib/makit/logging/sinks/console.rb +72 -72
  101. data/lib/makit/logging/sinks/file_sink.rb +92 -92
  102. data/lib/makit/logging/sinks/structured.rb +123 -123
  103. data/lib/makit/logging/sinks/unified_file_sink.rb +296 -296
  104. data/lib/makit/logging.rb +578 -578
  105. data/lib/makit/markdown.rb +75 -75
  106. data/lib/makit/mp/basic_object_mp.rb +17 -17
  107. data/lib/makit/mp/command_mp.rb +13 -13
  108. data/lib/makit/mp/command_request.mp.rb +17 -17
  109. data/lib/makit/mp/project_mp.rb +199 -199
  110. data/lib/makit/mp/string_mp.rb +205 -205
  111. data/lib/makit/nuget.rb +74 -74
  112. data/lib/makit/podman/podman.rb +458 -458
  113. data/lib/makit/podman/podman_service_impl.rb +1081 -1081
  114. data/lib/makit/port.rb +32 -32
  115. data/lib/makit/process.rb +377 -377
  116. data/lib/makit/protoc.rb +112 -112
  117. data/lib/makit/rake/cli.rb +196 -196
  118. data/lib/makit/rake/trace_controller.rb +174 -174
  119. data/lib/makit/rake.rb +81 -81
  120. data/lib/makit/ruby/cli.rb +185 -185
  121. data/lib/makit/ruby.rb +25 -25
  122. data/lib/makit/rubygems.rb +137 -0
  123. data/lib/makit/secrets/azure_key_vault.rb +322 -322
  124. data/lib/makit/secrets/azure_secrets.rb +183 -183
  125. data/lib/makit/secrets/local_secrets.rb +72 -72
  126. data/lib/makit/secrets/secrets_manager.rb +105 -105
  127. data/lib/makit/secrets.rb +16 -16
  128. data/lib/makit/serializer.rb +130 -130
  129. data/lib/makit/services/builder.rb +186 -186
  130. data/lib/makit/services/error_handler.rb +226 -226
  131. data/lib/makit/services/repository_manager.rb +367 -367
  132. data/lib/makit/services/validator.rb +112 -112
  133. data/lib/makit/setup/classlib.rb +101 -101
  134. data/lib/makit/setup/gem.rb +268 -268
  135. data/lib/makit/setup/pages.rb +11 -11
  136. data/lib/makit/setup/razorclasslib.rb +101 -101
  137. data/lib/makit/setup/runner.rb +54 -54
  138. data/lib/makit/setup.rb +5 -5
  139. data/lib/makit/show.rb +110 -110
  140. data/lib/makit/storage.rb +126 -126
  141. data/lib/makit/symbols.rb +175 -175
  142. data/lib/makit/task_info.rb +130 -130
  143. data/lib/makit/tasks/at_exit.rb +15 -15
  144. data/lib/makit/tasks/build.rb +22 -22
  145. data/lib/makit/tasks/bump.rb +7 -7
  146. data/lib/makit/tasks/clean.rb +13 -13
  147. data/lib/makit/tasks/configure.rb +10 -10
  148. data/lib/makit/tasks/format.rb +10 -10
  149. data/lib/makit/tasks/hook_manager.rb +443 -443
  150. data/lib/makit/tasks/info.rb +368 -368
  151. data/lib/makit/tasks/init.rb +49 -49
  152. data/lib/makit/tasks/integrate.rb +60 -56
  153. data/lib/makit/tasks/pull_incoming.rb +13 -13
  154. data/lib/makit/tasks/secrets.rb +7 -7
  155. data/lib/makit/tasks/setup.rb +16 -16
  156. data/lib/makit/tasks/sync.rb +14 -17
  157. data/lib/makit/tasks/tag.rb +27 -27
  158. data/lib/makit/tasks/task_monkey_patch.rb +81 -81
  159. data/lib/makit/tasks/test.rb +22 -22
  160. data/lib/makit/tasks/update.rb +18 -18
  161. data/lib/makit/tasks/version.rb +6 -6
  162. data/lib/makit/tasks.rb +24 -24
  163. data/lib/makit/test_cache.rb +239 -239
  164. data/lib/makit/tree.rb +37 -37
  165. data/lib/makit/v1/configuration/project_service_impl.rb +370 -370
  166. data/lib/makit/v1/git/git_repository_service_impl.rb +295 -295
  167. data/lib/makit/v1/makit.v1_pb.rb +35 -35
  168. data/lib/makit/v1/makit.v1_services_pb.rb +27 -27
  169. data/lib/makit/v1/services/repository_manager_service_impl.rb +572 -572
  170. data/lib/makit/version.rb +661 -503
  171. data/lib/makit/version_util.rb +21 -21
  172. data/lib/makit/wix.rb +95 -95
  173. data/lib/makit/yaml.rb +29 -29
  174. data/lib/makit/zip.rb +17 -17
  175. data/lib/makit copy.rb +44 -44
  176. data/lib/makit.rb +115 -114
  177. metadata +3 -2
data/lib/makit/version.rb CHANGED
@@ -1,503 +1,661 @@
1
- # frozen_string_literal: true
2
-
3
- module Makit
4
- # Static version for now to avoid circular dependency issues
5
- #VERSION = "0.0.157"
6
-
7
- # Version management utilities for various file formats
8
- #
9
- # This class provides methods for detecting, extracting, and updating version
10
- # numbers in various file formats including .csproj, .wxs, .yml, .gemspec,
11
- # .nuspec, and .toml files.
12
- #
13
- # == Semantic Versioning
14
- #
15
- # This class follows Semantic Versioning (SemVer) specification (https://semver.org/).
16
- # Version numbers use the format: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]
17
- #
18
- # === Version Number Components
19
- #
20
- # * MAJOR: Incremented for incompatible API changes that break backward compatibility.
21
- # Example: 1.0.0 -> 2.0.0 indicates breaking changes.
22
- #
23
- # * MINOR: Incremented for new functionality added in a backward-compatible manner.
24
- # Example: 1.0.0 -> 1.1.0 indicates new features that don't break existing code.
25
- #
26
- # * PATCH: Incremented for backward-compatible bug fixes.
27
- # Example: 1.0.0 -> 1.0.1 indicates bug fixes only.
28
- #
29
- # === Pre-release Versions
30
- #
31
- # Pre-release versions can be appended with a hyphen and identifier(s):
32
- # * 1.0.0-alpha.1 (alpha release)
33
- # * 1.0.0-beta.2 (beta release)
34
- # * 1.0.0-rc.1 (release candidate)
35
- # * 1.0.0-preview (preview release)
36
- #
37
- # Pre-release versions have lower precedence than normal versions:
38
- # * 1.0.0-alpha.1 < 1.0.0
39
- # * 1.0.0-beta.1 < 1.0.0-rc.1
40
- #
41
- # === Version Comparison
42
- #
43
- # Versions are compared using semantic versioning rules:
44
- # * Compare MAJOR first, then MINOR, then PATCH
45
- # * Pre-release versions are always less than normal versions
46
- # * Numeric identifiers are compared numerically
47
- # * Non-numeric identifiers are compared lexicographically
48
- #
49
- # Examples:
50
- # * 1.0.0 < 2.0.0 (major version difference)
51
- # * 1.0.0 < 1.1.0 (minor version difference)
52
- # * 1.0.0 < 1.0.1 (patch version difference)
53
- # * 1.0.0-alpha < 1.0.0 (pre-release is less than release)
54
- #
55
- # === Usage in This Class
56
- #
57
- # * {parse} - Parses a version string into MAJOR, MINOR, PATCH, and suffix components
58
- # * {get_highest_version} - Compares versions using semantic versioning rules to find the highest
59
- # * {version} - Reads the current version from the gemspec file
60
- # * {get_version_from_file} - Extracts version from various file formats
61
- # * {set_version_in_file} - Updates version in various file formats
62
- #
63
- class Version
64
-
65
- # Attempt to detect the version from the SSOT (Single Source of Truth) version file
66
- # Uses the same logic as Makit::Version.info to find version files in priority order:
67
- # *.gemspec, Directory.Build.props, Cargo.toml, package.json, pyproject.toml, pom.xml
68
- # Falls back to makit.gemspec only if we're in the makit gem directory
69
- # @return [String] The version string, or "0.0.0" if no version file is found
70
- # @raise [RuntimeError] If a version file is found but version cannot be detected
71
- def self.version
72
- # Try to find version file in project root using SSOT logic (same as Makit::Version.info)
73
- project_root = begin
74
- require_relative "directories" unless defined?(Makit::Directories)
75
- Makit::Directories::PROJECT_ROOT
76
- rescue NameError, LoadError
77
- find_project_root(Dir.pwd)
78
- end
79
-
80
- # Use SSOT version file detection (supports multiple file types)
81
- if project_root && Dir.exist?(project_root)
82
- version_file = find_ssot_version_file(project_root)
83
- if version_file && File.exist?(version_file)
84
- version = extract_version_from_ssot_file(version_file)
85
- return version if version
86
- end
87
- end
88
-
89
- # Fallback to makit.gemspec (for the makit gem itself)
90
- # Check if makit.gemspec exists relative to this file
91
- makit_gemspec = File.join(File.dirname(__FILE__), "..", "..", "makit.gemspec")
92
- makit_gemspec = File.expand_path(makit_gemspec)
93
-
94
- # Use makit.gemspec if:
95
- # 1. It exists, AND
96
- # 2. Either we're in the makit gem directory (project_root matches), OR
97
- # project_root is nil (running outside a project context, likely from installed gem)
98
- if File.exist?(makit_gemspec)
99
- makit_gem_dir = File.dirname(makit_gemspec)
100
- use_makit_gemspec = if project_root
101
- # If we have a project root, only use makit.gemspec if we're in the makit gem directory
102
- File.expand_path(project_root) == File.expand_path(makit_gem_dir)
103
- else
104
- # If no project root, check if current directory is makit gem or if makit.gemspec is nearby
105
- # This handles the case when running from installed gem or outside project context
106
- Dir.pwd == makit_gem_dir || File.expand_path(Dir.pwd).start_with?(File.expand_path(makit_gem_dir))
107
- end
108
-
109
- if use_makit_gemspec
110
- gemspec_content = File.read(makit_gemspec)
111
- match = gemspec_content.match(/spec\.version\s*=\s*["']([^"']+)["']/)
112
- raise "Version not found in gemspec file" if match.nil?
113
- return match[1]
114
- end
115
- end
116
-
117
- # If no version file found, return default version (for non-gem projects)
118
- "0.0.0"
119
- end
120
-
121
- # Parse a semantic version string into its components
122
- #
123
- # Parses a version string following Semantic Versioning (SemVer) format:
124
- # MAJOR.MINOR.PATCH[-PRERELEASE]
125
- #
126
- # @param version_string [String] Version string in SemVer format (e.g., "1.2.3" or "1.2.3-alpha.1")
127
- # @return [Hash] Hash with keys: :major, :minor, :patch, :suffix
128
- # - :major [Integer] Major version number
129
- # - :minor [Integer] Minor version number
130
- # - :patch [Integer] Patch version number
131
- # - :suffix [String] Pre-release suffix (e.g., "-alpha.1") or empty string
132
- # @raise [RuntimeError] If version string doesn't match MAJOR.MINOR.PATCH format
133
- #
134
- # @example Basic version
135
- # Makit::Version.parse("1.2.3")
136
- # # => { major: 1, minor: 2, patch: 3, suffix: "" }
137
- #
138
- # @example Version with pre-release suffix
139
- # Makit::Version.parse("1.2.3-alpha")
140
- # # => { major: 1, minor: 2, patch: 3, suffix: "-alpha" }
141
- #
142
- # @example Invalid format
143
- # Makit::Version.parse("1.2")
144
- # # => RuntimeError: Invalid version format: 1.2. Expected format: Major.Minor.Patch
145
- #
146
- def self.parse(version_string)
147
- parts = version_string.split(".")
148
- if parts.length < 3
149
- raise "Invalid version format: #{version_string}. Expected format: Major.Minor.Patch"
150
- end
151
-
152
- major = parts[0].to_i
153
- minor = parts[1].to_i
154
- patch = parts[2].to_i
155
-
156
- # Handle pre-release suffixes (e.g., "0.1.0-preview" -> patch = 0, suffix = "-preview")
157
- # Note: If suffix contains dots (e.g., "1.2.3-preview.1"), the split by "." will separate
158
- # the patch and suffix parts, so only the part before the first "-" in parts[2] is used as patch.
159
- patch_part = parts[2]
160
- if patch_part.include?("-")
161
- patch, suffix = patch_part.split("-", 2)
162
- patch = patch.to_i
163
- suffix = "-#{suffix}"
164
- else
165
- suffix = ""
166
- end
167
-
168
- { major: major, minor: minor, patch: patch, suffix: suffix }
169
- end
170
-
171
- # Find the highest version from an array of version strings
172
- #
173
- # Compares versions using semantic versioning rules (see class documentation).
174
- # Uses Ruby's Gem::Version for comparison, which follows SemVer specification.
175
- #
176
- # @param versions [Array<String>] Array of version strings to compare
177
- # @return [String] The highest version string using semantic versioning comparison
178
- #
179
- # @example
180
- # Makit::Version.get_highest_version(["1.0.0", "2.0.0", "1.5.0", "1.0.1"])
181
- # # => "2.0.0"
182
- #
183
- # @example Pre-release versions
184
- # Makit::Version.get_highest_version(["1.0.0-alpha", "1.0.0", "1.0.0-beta"])
185
- # # => "1.0.0" (pre-release versions are lower than release versions)
186
- #
187
- def self.get_highest_version(versions)
188
- versions.max { |a, b| Gem::Version.new(a) <=> Gem::Version.new(b) }
189
- end
190
-
191
- # Extract version number from a file based on its extension
192
- #
193
- # Supports multiple file formats:
194
- # - .csproj files: `<Version>x.y.z</Version>`
195
- # - .wxs files: `Version="x.y.z"`
196
- # - .yml files: `VERSION: "x.y.z"`
197
- #
198
- # @param path [String] Path to the file containing version information
199
- # @return [String] The extracted version string
200
- # @raise [RuntimeError] If file doesn't exist or has unrecognized extension
201
- def self.get_version_from_file(path)
202
- raise "file #{path}does not exist" unless File.exist?(path)
203
-
204
- extension = File.extname(path)
205
- case extension
206
- when ".csproj"
207
- Makit::Version.detect_from_file(path, /<Version>([-\w\d.]+)</)
208
- when ".wxs"
209
- Makit::Version.detect_from_file(path, / Version="([\d.]+)"/)
210
- when ".yml"
211
- Makit::Version.detect_from_file(path, /VERSION:\s*["']?([\d.]+)["']?/)
212
- when ".rb"
213
- Makit::Version.detect_from_file(path, /VERSION = "([\d.]+)"/)
214
- else
215
- raise "unrecognized file type"
216
- end
217
- end
218
-
219
- # Detect version using a regex pattern in a specific file
220
- #
221
- # @param filename [String] Path to the file to search
222
- # @param regex [Regexp] Regular expression pattern to match version
223
- # @return [String, nil] The extracted version or nil if no match found
224
- # @raise [RuntimeError] If file doesn't exist
225
- def self.detect_from_file(filename, regex)
226
- raise "unable to find version in #{filename}" unless File.exist?(filename)
227
-
228
- match = File.read(filename).match(regex)
229
- match.captures[0] if !match.nil? && match.captures.length.positive?
230
- end
231
-
232
- # Update version number in a file based on its extension
233
- #
234
- # Supports updating versions in multiple file formats:
235
- # - .yml files
236
- # - .gemspec files
237
- # - .csproj files
238
- # - .nuspec files
239
- # - .wxs files
240
- # - .toml files
241
- #
242
- # @param filename [String] Path to the file to update
243
- # @param version [String] New version string to set
244
- # @return [nil]
245
- def self.set_version_in_file(filename, version)
246
- text = File.read(filename)
247
- # VERSION = "0.0.138rake" (.rb file)
248
- new_text = text
249
- new_text = new_text.gsub(/VERSION:\s?['|"]([.\d]+)['|"]/, "VERSION: \"#{version}\"") if filename.include?(".yml")
250
- new_text = new_text.gsub(/spec\.version\s*=\s*['"]([^'"]+)['"]/, "spec.version = '#{version}'") if filename.include?(".gemspec")
251
- # Handle Directory.Build.props and .csproj files (both use <Version> tag)
252
- if filename.include?("Directory.Build.props") || filename.include?(".csproj")
253
- new_text = new_text.gsub(/<Version>([-\w\d.]+)</, "<Version>#{version}<")
254
- end
255
- new_text = new_text.gsub(/<version>([-\w\d.]+)</, "<version>#{version}<") if filename.include?(".nuspec")
256
- new_text = new_text.gsub(/ Version="([\d.]+)"/, " Version=\"#{version}\"") if filename.include?(".wxs")
257
- new_text = new_text.gsub(/VERSION = "([\d.]+)"/, "VERSION = \"#{version}\"") if filename.include?(".rb")
258
- # Handle Cargo.toml, pyproject.toml, and other .toml files
259
- if filename.include?(".toml")
260
- new_text = new_text.gsub(/version\s+=\s+['"]([\w.]+)['"]/, "version=\"#{version}\"")
261
- end
262
- # Handle package.json
263
- if filename.include?("package.json")
264
- require "json"
265
- json = JSON.parse(new_text)
266
- json["version"] = version
267
- new_text = JSON.pretty_generate(json)
268
- end
269
- # Handle pom.xml
270
- if filename.include?("pom.xml")
271
- new_text = new_text.gsub(%r{<version>([^<]+)</version>}, "<version>#{version}</version>")
272
- end
273
- File.write(filename, new_text) if new_text != text
274
- end
275
-
276
- # Update version number in multiple files matching a glob pattern
277
- #
278
- # @param glob_pattern [String] Glob pattern to match files (e.g., '**/*.csproj')
279
- # @param version [String] New version string to set in all matching files
280
- # @return [nil]
281
- def self.set_version_in_files(glob_pattern, version)
282
- Dir.glob(glob_pattern).each do |filename|
283
- set_version_in_file(filename, version)
284
- end
285
- end
286
-
287
- # Display version information for the current project
288
- #
289
- # Finds the Single Source of Truth (SSOT) version file in the project root
290
- # and displays the file path and current version value.
291
- #
292
- # Searches for common version files in priority order:
293
- # * *.gemspec (Ruby gems)
294
- # * Directory.Build.props (.NET projects)
295
- # * Cargo.toml (Rust projects)
296
- # * package.json (Node.js projects)
297
- # * pyproject.toml (Python projects)
298
- # * pom.xml (Maven/Java projects)
299
- #
300
- # If no version file is found, issues a warning and suggests defining a VERSION_FILE
301
- # constant to manually specify the version file path.
302
- #
303
- # @return [nil] Outputs version information to stdout, or returns early with warning if no file found
304
- # @raise [RuntimeError] If project root cannot be determined or version cannot be extracted from file
305
- #
306
- # @example Output format
307
- # Version File: makit.gemspec
308
- # Version: 0.0.147
309
- #
310
- # @example With VERSION_FILE constant defined
311
- # VERSION_FILE = "custom/version.txt"
312
- # Makit::Version.info
313
- # # Uses the file specified by VERSION_FILE constant
314
- #
315
- def self.info
316
- # Access Directories lazily to avoid circular dependency
317
- project_root = begin
318
- require_relative "directories" unless defined?(Makit::Directories)
319
- Makit::Directories::PROJECT_ROOT
320
- rescue NameError, LoadError
321
- # Fallback: try to find project root from current directory
322
- find_project_root(Dir.pwd)
323
- end
324
-
325
- raise "Project root not found" if project_root.nil? || !Dir.exist?(project_root)
326
-
327
- version_file = find_ssot_version_file(project_root)
328
-
329
- if version_file.nil?
330
- warn " Warning: No version file found in project root: #{project_root}"
331
- warn " You may define a constant VERSION_FILE to manually set the version file path"
332
- return
333
- end
334
-
335
- # Extract version based on file type
336
- version = extract_version_from_ssot_file(version_file)
337
-
338
- raise "Version not found in #{version_file}" if version.nil?
339
-
340
- # Display information with relative path (Windows-safe path handling)
341
- # Normalize both paths to forward slashes for comparison, then convert back if needed
342
- normalized_version_file = version_file.gsub(/\\/, "/")
343
- normalized_project_root = project_root.gsub(/\\/, "/")
344
- relative_path = normalized_version_file.sub(normalized_project_root + "/", "")
345
- puts " Version File: #{relative_path}"
346
- puts " Version: #{version}"
347
- end
348
-
349
- # Bump the patch version in the SSOT version file
350
- #
351
- # Finds the Single Source of Truth (SSOT) version file in the project root,
352
- # reads the current version, increments the patch version, and updates the file.
353
- #
354
- # @return [String] The new version string after bumping
355
- # @raise [RuntimeError] If project root cannot be determined, no version file is found,
356
- # or version cannot be parsed/updated
357
- #
358
- # @example
359
- # # Current version: 1.2.3
360
- # Makit::Version.bump
361
- # # => "1.2.4"
362
- #
363
- # @example With pre-release suffix
364
- # # Current version: 1.2.3-alpha
365
- # Makit::Version.bump
366
- # # => "1.2.4" (removes pre-release suffix when bumping)
367
- #
368
- def self.bump
369
- # Find the SSOT version file
370
- project_root = begin
371
- require_relative "directories" unless defined?(Makit::Directories)
372
- Makit::Directories::PROJECT_ROOT
373
- rescue NameError, LoadError
374
- find_project_root(Dir.pwd)
375
- end
376
-
377
- raise "Project root not found" if project_root.nil? || !Dir.exist?(project_root)
378
-
379
- version_file = find_ssot_version_file(project_root)
380
- if version_file.nil?
381
- warn " Warning: No version file found in project root: #{project_root}"
382
- warn " You may define a constant VERSION_FILE to manually set the version file path"
383
- raise "Cannot bump version: no version file found"
384
- end
385
-
386
- # Read current version
387
- current_version = extract_version_from_ssot_file(version_file)
388
- raise "Version not found in #{version_file}" if current_version.nil?
389
-
390
- # Parse and bump patch version
391
- parsed = parse(current_version)
392
- new_version = "#{parsed[:major]}.#{parsed[:minor]}.#{parsed[:patch] + 1}"
393
-
394
- # Update the version file
395
- set_version_in_file(version_file, new_version)
396
-
397
- # Verify the update
398
- updated_version = extract_version_from_ssot_file(version_file)
399
- if updated_version != new_version
400
- raise "Version bump failed: expected #{new_version}, got #{updated_version}"
401
- end
402
-
403
- new_version
404
- end
405
-
406
- private
407
-
408
- # Find the SSOT version file in the project root
409
- #
410
- # Checks for VERSION_FILE constant first (if defined), then searches for common version files.
411
- #
412
- # @param project_root [String] Path to the project root directory
413
- # @return [String, nil] Path to the version file, or nil if not found
414
- def self.find_ssot_version_file(project_root)
415
- # Normalize project_root for file operations (Ruby's File methods work with forward slashes)
416
- normalized_root = project_root.gsub(/\\/, "/")
417
-
418
- # Check for manually defined VERSION_FILE constant first
419
- if defined?(VERSION_FILE) && !VERSION_FILE.nil?
420
- version_file = File.expand_path(VERSION_FILE, normalized_root)
421
- return version_file if File.exist?(version_file)
422
- warn " Warning: VERSION_FILE constant points to non-existent file: #{VERSION_FILE}"
423
- end
424
-
425
- # Priority order for version files (SSOT)
426
- version_file_patterns = [
427
- "*.gemspec", # Ruby gems
428
- "Directory.Build.props", # .NET projects
429
- "Cargo.toml", # Rust projects
430
- "package.json", # Node.js projects
431
- "pyproject.toml", # Python projects
432
- "pom.xml" # Maven/Java projects
433
- ]
434
-
435
- version_file_patterns.each do |pattern|
436
- matches = Dir.glob(File.join(normalized_root, pattern))
437
- next if matches.empty?
438
-
439
- # For gemspec, prefer the one matching the project name or take the first
440
- version_file = matches.first
441
- return version_file if version_file
442
- end
443
-
444
- nil
445
- end
446
-
447
- # Find project root by looking for common markers
448
- def self.find_project_root(start_dir)
449
- current = File.expand_path(start_dir)
450
- root = File.expand_path("/")
451
-
452
- while current != root
453
- markers = ["Rakefile", "rakefile.rb", ".gitignore", ".git"]
454
- return current if markers.any? { |marker| File.exist?(File.join(current, marker)) }
455
- current = File.dirname(current)
456
- end
457
-
458
- nil
459
- end
460
-
461
- # Extract version from SSOT file based on file type
462
- def self.extract_version_from_ssot_file(file_path)
463
- case File.basename(file_path)
464
- when /\.gemspec$/
465
- # Extract from gemspec: spec.version = "x.y.z"
466
- content = File.read(file_path)
467
- match = content.match(/spec\.version\s*=\s*["']([^"']+)["']/)
468
- match ? match[1] : nil
469
- when "Directory.Build.props"
470
- # Extract from Directory.Build.props: <Version>x.y.z</Version>
471
- content = File.read(file_path)
472
- match = content.match(%r{<Version>([^<]+)</Version>})
473
- match ? match[1] : nil
474
- when "Cargo.toml"
475
- # Extract from Cargo.toml: version = "x.y.z"
476
- content = File.read(file_path)
477
- match = content.match(/version\s*=\s*["']([^"']+)["']/)
478
- match ? match[1] : nil
479
- when "package.json"
480
- # Extract from package.json: "version": "x.y.z"
481
- require "json"
482
- json = JSON.parse(File.read(file_path))
483
- json["version"]
484
- when "pyproject.toml"
485
- # Extract from pyproject.toml: version = "x.y.z" (in [project] or [tool.poetry] section)
486
- content = File.read(file_path)
487
- # Try [project] section first
488
- match = content.match(/\[project\]\s*version\s*=\s*["']([^"']+)["']/)
489
- return match[1] if match
490
- # Try [tool.poetry] section
491
- match = content.match(/\[tool\.poetry\]\s*version\s*=\s*["']([^"']+)["']/)
492
- match ? match[1] : nil
493
- when "pom.xml"
494
- # Extract from pom.xml: <version>x.y.z</version>
495
- content = File.read(file_path)
496
- match = content.match(%r{<version>([^<]+)</version>})
497
- match ? match[1] : nil
498
- else
499
- nil
500
- end
501
- end
502
- end
503
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Makit
4
+ # Static version for now to avoid circular dependency issues
5
+ #VERSION = "0.0.158"
6
+
7
+ # Version management utilities for various file formats
8
+ #
9
+ # This class provides methods for detecting, extracting, and updating version
10
+ # numbers in various file formats including .csproj, .wxs, .yml, .gemspec,
11
+ # .nuspec, and .toml files.
12
+ #
13
+ # == Semantic Versioning
14
+ #
15
+ # This class follows Semantic Versioning (SemVer) specification (https://semver.org/).
16
+ # Version numbers use the format: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]
17
+ #
18
+ # === Version Number Components
19
+ #
20
+ # * MAJOR: Incremented for incompatible API changes that break backward compatibility.
21
+ # Example: 1.0.0 -> 2.0.0 indicates breaking changes.
22
+ #
23
+ # * MINOR: Incremented for new functionality added in a backward-compatible manner.
24
+ # Example: 1.0.0 -> 1.1.0 indicates new features that don't break existing code.
25
+ #
26
+ # * PATCH: Incremented for backward-compatible bug fixes.
27
+ # Example: 1.0.0 -> 1.0.1 indicates bug fixes only.
28
+ #
29
+ # === Pre-release Versions
30
+ #
31
+ # Pre-release versions can be appended with a hyphen and identifier(s):
32
+ # * 1.0.0-alpha.1 (alpha release)
33
+ # * 1.0.0-beta.2 (beta release)
34
+ # * 1.0.0-rc.1 (release candidate)
35
+ # * 1.0.0-preview (preview release)
36
+ #
37
+ # Pre-release versions have lower precedence than normal versions:
38
+ # * 1.0.0-alpha.1 < 1.0.0
39
+ # * 1.0.0-beta.1 < 1.0.0-rc.1
40
+ #
41
+ # === Version Comparison
42
+ #
43
+ # Versions are compared using semantic versioning rules:
44
+ # * Compare MAJOR first, then MINOR, then PATCH
45
+ # * Pre-release versions are always less than normal versions
46
+ # * Numeric identifiers are compared numerically
47
+ # * Non-numeric identifiers are compared lexicographically
48
+ #
49
+ # Examples:
50
+ # * 1.0.0 < 2.0.0 (major version difference)
51
+ # * 1.0.0 < 1.1.0 (minor version difference)
52
+ # * 1.0.0 < 1.0.1 (patch version difference)
53
+ # * 1.0.0-alpha < 1.0.0 (pre-release is less than release)
54
+ #
55
+ # === Usage in This Class
56
+ #
57
+ # * {parse} - Parses a version string into MAJOR, MINOR, PATCH, and suffix components
58
+ # * {get_highest_version} - Compares versions using semantic versioning rules to find the highest
59
+ # * {version} - Reads the current version from the gemspec file
60
+ # * {get_version_from_file} - Extracts version from various file formats
61
+ # * {set_version_in_file} - Updates version in various file formats
62
+ #
63
+ class Version
64
+
65
+ # Attempt to detect the version from the SSOT (Single Source of Truth) version file
66
+ # Uses the same logic as Makit::Version.info to find version files in priority order:
67
+ # *.gemspec, Directory.Build.props, Cargo.toml, package.json, pyproject.toml, pom.xml
68
+ # Falls back to makit.gemspec only if we're in the makit gem directory
69
+ # @return [String] The version string, or "0.0.0" if no version file is found
70
+ # @raise [RuntimeError] If a version file is found but version cannot be detected
71
+ def self.version
72
+ # Try to find version file in project root using SSOT logic (same as Makit::Version.info)
73
+ project_root = begin
74
+ require_relative "directories" unless defined?(Makit::Directories)
75
+ Makit::Directories::PROJECT_ROOT
76
+ rescue NameError, LoadError
77
+ find_project_root(Dir.pwd)
78
+ end
79
+
80
+ # Use SSOT version file detection (supports multiple file types)
81
+ if project_root && Dir.exist?(project_root)
82
+ version_file = find_ssot_version_file(project_root)
83
+ if version_file && File.exist?(version_file)
84
+ version = extract_version_from_ssot_file(version_file)
85
+ return version if version
86
+ end
87
+ end
88
+
89
+ # Fallback to makit.gemspec (for the makit gem itself)
90
+ # Check if makit.gemspec exists relative to this file
91
+ makit_gemspec = File.join(File.dirname(__FILE__), "..", "..", "makit.gemspec")
92
+ makit_gemspec = File.expand_path(makit_gemspec)
93
+
94
+ # Use makit.gemspec if:
95
+ # 1. It exists, AND
96
+ # 2. Either we're in the makit gem directory (project_root matches), OR
97
+ # project_root is nil (running outside a project context, likely from installed gem)
98
+ if File.exist?(makit_gemspec)
99
+ makit_gem_dir = File.dirname(makit_gemspec)
100
+ use_makit_gemspec = if project_root
101
+ # If we have a project root, only use makit.gemspec if we're in the makit gem directory
102
+ File.expand_path(project_root) == File.expand_path(makit_gem_dir)
103
+ else
104
+ # If no project root, check if current directory is makit gem or if makit.gemspec is nearby
105
+ # This handles the case when running from installed gem or outside project context
106
+ Dir.pwd == makit_gem_dir || File.expand_path(Dir.pwd).start_with?(File.expand_path(makit_gem_dir))
107
+ end
108
+
109
+ if use_makit_gemspec
110
+ gemspec_content = File.read(makit_gemspec)
111
+ match = gemspec_content.match(/spec\.version\s*=\s*["']([^"']+)["']/)
112
+ raise "Version not found in gemspec file" if match.nil?
113
+ return match[1]
114
+ end
115
+ end
116
+
117
+ # If no version file found, return default version (for non-gem projects)
118
+ "0.0.0"
119
+ end
120
+
121
+ # Parse a semantic version string into its components
122
+ #
123
+ # Parses a version string following Semantic Versioning (SemVer) format:
124
+ # MAJOR.MINOR.PATCH[-PRERELEASE]
125
+ #
126
+ # @param version_string [String] Version string in SemVer format (e.g., "1.2.3" or "1.2.3-alpha.1")
127
+ # @return [Hash] Hash with keys: :major, :minor, :patch, :suffix
128
+ # - :major [Integer] Major version number
129
+ # - :minor [Integer] Minor version number
130
+ # - :patch [Integer] Patch version number
131
+ # - :suffix [String] Pre-release suffix (e.g., "-alpha.1") or empty string
132
+ # @raise [RuntimeError] If version string doesn't match MAJOR.MINOR.PATCH format
133
+ #
134
+ # @example Basic version
135
+ # Makit::Version.parse("1.2.3")
136
+ # # => { major: 1, minor: 2, patch: 3, suffix: "" }
137
+ #
138
+ # @example Version with pre-release suffix
139
+ # Makit::Version.parse("1.2.3-alpha")
140
+ # # => { major: 1, minor: 2, patch: 3, suffix: "-alpha" }
141
+ #
142
+ # @example Invalid format
143
+ # Makit::Version.parse("1.2")
144
+ # # => RuntimeError: Invalid version format: 1.2. Expected format: Major.Minor.Patch
145
+ #
146
+ def self.parse(version_string)
147
+ parts = version_string.split(".")
148
+ if parts.length < 3
149
+ raise "Invalid version format: #{version_string}. Expected format: Major.Minor.Patch"
150
+ end
151
+
152
+ major = parts[0].to_i
153
+ minor = parts[1].to_i
154
+ patch = parts[2].to_i
155
+
156
+ # Handle pre-release suffixes (e.g., "0.1.0-preview" -> patch = 0, suffix = "-preview")
157
+ # Note: If suffix contains dots (e.g., "1.2.3-preview.1"), the split by "." will separate
158
+ # the patch and suffix parts, so only the part before the first "-" in parts[2] is used as patch.
159
+ patch_part = parts[2]
160
+ if patch_part.include?("-")
161
+ patch, suffix = patch_part.split("-", 2)
162
+ patch = patch.to_i
163
+ suffix = "-#{suffix}"
164
+ else
165
+ suffix = ""
166
+ end
167
+
168
+ { major: major, minor: minor, patch: patch, suffix: suffix }
169
+ end
170
+
171
+ # Find the highest version from an array of version strings
172
+ #
173
+ # Compares versions using semantic versioning rules (see class documentation).
174
+ # Uses Ruby's Gem::Version for comparison, which follows SemVer specification.
175
+ #
176
+ # @param versions [Array<String>] Array of version strings to compare
177
+ # @return [String] The highest version string using semantic versioning comparison
178
+ #
179
+ # @example
180
+ # Makit::Version.get_highest_version(["1.0.0", "2.0.0", "1.5.0", "1.0.1"])
181
+ # # => "2.0.0"
182
+ #
183
+ # @example Pre-release versions
184
+ # Makit::Version.get_highest_version(["1.0.0-alpha", "1.0.0", "1.0.0-beta"])
185
+ # # => "1.0.0" (pre-release versions are lower than release versions)
186
+ #
187
+ def self.get_highest_version(versions)
188
+ versions.max { |a, b| Gem::Version.new(a) <=> Gem::Version.new(b) }
189
+ end
190
+
191
+ # Extract version number from a file based on its extension
192
+ #
193
+ # Supports multiple file formats:
194
+ # - .csproj files: `<Version>x.y.z</Version>`
195
+ # - .wxs files: `Version="x.y.z"`
196
+ # - .yml files: `VERSION: "x.y.z"`
197
+ #
198
+ # @param path [String] Path to the file containing version information
199
+ # @return [String] The extracted version string
200
+ # @raise [RuntimeError] If file doesn't exist or has unrecognized extension
201
+ def self.get_version_from_file(path)
202
+ raise "file #{path}does not exist" unless File.exist?(path)
203
+
204
+ extension = File.extname(path)
205
+ case extension
206
+ when ".csproj"
207
+ Makit::Version.detect_from_file(path, /<Version>([-\w\d.]+)</)
208
+ when ".wxs"
209
+ Makit::Version.detect_from_file(path, / Version="([\d.]+)"/)
210
+ when ".yml"
211
+ Makit::Version.detect_from_file(path, /VERSION:\s*["']?([\d.]+)["']?/)
212
+ when ".rb"
213
+ Makit::Version.detect_from_file(path, /VERSION = "([\d.]+)"/)
214
+ else
215
+ raise "unrecognized file type"
216
+ end
217
+ end
218
+
219
+ # Detect version using a regex pattern in a specific file
220
+ #
221
+ # @param filename [String] Path to the file to search
222
+ # @param regex [Regexp] Regular expression pattern to match version
223
+ # @return [String, nil] The extracted version or nil if no match found
224
+ # @raise [RuntimeError] If file doesn't exist
225
+ def self.detect_from_file(filename, regex)
226
+ raise "unable to find version in #{filename}" unless File.exist?(filename)
227
+
228
+ match = File.read(filename).match(regex)
229
+ match.captures[0] if !match.nil? && match.captures.length.positive?
230
+ end
231
+
232
+ # Update version number in a file based on its extension
233
+ #
234
+ # Supports updating versions in multiple file formats:
235
+ # - .yml files
236
+ # - .gemspec files
237
+ # - .csproj files
238
+ # - .nuspec files
239
+ # - .wxs files
240
+ # - .toml files
241
+ # - Directory.Build.props files (.NET projects)
242
+ #
243
+ # @param filename [String] Path to the file to update
244
+ # @param version [String] New version string to set
245
+ # @return [nil]
246
+ def self.set_version_in_file(filename, version)
247
+ # Handle Directory.Build.props files with special logic
248
+ if filename.include?("Directory.Build.props")
249
+ set_version_in_directory_build_props(filename, version)
250
+ return
251
+ end
252
+
253
+ text = File.read(filename)
254
+ # VERSION = "0.0.138rake" (.rb file)
255
+ new_text = text
256
+ new_text = new_text.gsub(/VERSION:\s?['|"]([.\d]+)['|"]/, "VERSION: \"#{version}\"") if filename.include?(".yml")
257
+ new_text = new_text.gsub(/spec\.version\s*=\s*['"]([^'"]+)['"]/, "spec.version = '#{version}'") if filename.include?(".gemspec")
258
+ # Handle Directory.Build.props and .csproj files (both use <Version> tag)
259
+ if filename.include?("Directory.Build.props") || filename.include?(".csproj")
260
+ new_text = new_text.gsub(/<Version>([-\w\d.]+)</, "<Version>#{version}<")
261
+ end
262
+ new_text = new_text.gsub(/<version>([-\w\d.]+)</, "<version>#{version}<") if filename.include?(".nuspec")
263
+ new_text = new_text.gsub(/ Version="([\d.]+)"/, " Version=\"#{version}\"") if filename.include?(".wxs")
264
+ new_text = new_text.gsub(/VERSION = "([\d.]+)"/, "VERSION = \"#{version}\"") if filename.include?(".rb")
265
+ # Handle Cargo.toml, pyproject.toml, and other .toml files
266
+ if filename.include?(".toml")
267
+ new_text = new_text.gsub(/version\s+=\s+['"]([\w.]+)['"]/, "version=\"#{version}\"")
268
+ end
269
+ # Handle package.json
270
+ if filename.include?("package.json")
271
+ require "json"
272
+ json = JSON.parse(new_text)
273
+ json["version"] = version
274
+ new_text = JSON.pretty_generate(json)
275
+ end
276
+ # Handle pom.xml
277
+ if filename.include?("pom.xml")
278
+ new_text = new_text.gsub(%r{<version>([^<]+)</version>}, "<version>#{version}</version>")
279
+ end
280
+ File.write(filename, new_text) if new_text != text
281
+ end
282
+
283
+ # Update version number in multiple files matching a glob pattern
284
+ #
285
+ # @param glob_pattern [String] Glob pattern to match files (e.g., '**/*.csproj')
286
+ # @param version [String] New version string to set in all matching files
287
+ # @return [nil]
288
+ def self.set_version_in_files(glob_pattern, version)
289
+ Dir.glob(glob_pattern).each do |filename|
290
+ set_version_in_file(filename, version)
291
+ end
292
+ end
293
+
294
+ # Display version information for the current project
295
+ #
296
+ # Finds the Single Source of Truth (SSOT) version file in the project root
297
+ # and displays the file path and current version value.
298
+ #
299
+ # Searches for common version files in priority order:
300
+ # * *.gemspec (Ruby gems)
301
+ # * Directory.Build.props (.NET projects)
302
+ # * Cargo.toml (Rust projects)
303
+ # * package.json (Node.js projects)
304
+ # * pyproject.toml (Python projects)
305
+ # * pom.xml (Maven/Java projects)
306
+ #
307
+ # If no version file is found, issues a warning and suggests defining a VERSION_FILE
308
+ # constant to manually specify the version file path.
309
+ #
310
+ # @return [nil] Outputs version information to stdout, or returns early with warning if no file found
311
+ # @raise [RuntimeError] If project root cannot be determined or version cannot be extracted from file
312
+ #
313
+ # @example Output format
314
+ # Version File: makit.gemspec
315
+ # Version: 0.0.147
316
+ #
317
+ # @example With VERSION_FILE constant defined
318
+ # VERSION_FILE = "custom/version.txt"
319
+ # Makit::Version.info
320
+ # # Uses the file specified by VERSION_FILE constant
321
+ #
322
+ def self.info
323
+ # Access Directories lazily to avoid circular dependency
324
+ project_root = begin
325
+ require_relative "directories" unless defined?(Makit::Directories)
326
+ Makit::Directories::PROJECT_ROOT
327
+ rescue NameError, LoadError
328
+ # Fallback: try to find project root from current directory
329
+ find_project_root(Dir.pwd)
330
+ end
331
+
332
+ raise "Project root not found" if project_root.nil? || !Dir.exist?(project_root)
333
+
334
+ version_file = find_ssot_version_file(project_root)
335
+
336
+ if version_file.nil?
337
+ warn " Warning: No version file found in project root: #{project_root}"
338
+ warn " You may define a constant VERSION_FILE to manually set the version file path"
339
+ return
340
+ end
341
+
342
+ # Extract version based on file type
343
+ version = extract_version_from_ssot_file(version_file)
344
+
345
+ raise "Version not found in #{version_file}" if version.nil?
346
+
347
+ # Display information with relative path (Windows-safe path handling)
348
+ # Normalize both paths to forward slashes for comparison, then convert back if needed
349
+ normalized_version_file = version_file.gsub(/\\/, "/")
350
+ normalized_project_root = project_root.gsub(/\\/, "/")
351
+ relative_path = normalized_version_file.sub(normalized_project_root + "/", "")
352
+ puts " Version File: #{relative_path}"
353
+ puts " Version: #{version}"
354
+ end
355
+
356
+ # Bump the patch version in the SSOT version file
357
+ #
358
+ # Finds the Single Source of Truth (SSOT) version file in the project root,
359
+ # reads the current version, increments the patch version, and updates the file.
360
+ #
361
+ # @return [String] The new version string after bumping
362
+ # @raise [RuntimeError] If project root cannot be determined, no version file is found,
363
+ # or version cannot be parsed/updated
364
+ #
365
+ # @example
366
+ # # Current version: 1.2.3
367
+ # Makit::Version.bump
368
+ # # => "1.2.4"
369
+ #
370
+ # @example With pre-release suffix
371
+ # # Current version: 1.2.3-alpha
372
+ # Makit::Version.bump
373
+ # # => "1.2.4" (removes pre-release suffix when bumping)
374
+ #
375
+ def self.bump
376
+ # Find the SSOT version file
377
+ project_root = begin
378
+ require_relative "directories" unless defined?(Makit::Directories)
379
+ Makit::Directories::PROJECT_ROOT
380
+ rescue NameError, LoadError
381
+ find_project_root(Dir.pwd)
382
+ end
383
+
384
+ raise "Project root not found" if project_root.nil? || !Dir.exist?(project_root)
385
+
386
+ version_file = find_ssot_version_file(project_root)
387
+ if version_file.nil?
388
+ warn " Warning: No version file found in project root: #{project_root}"
389
+ warn " You may define a constant VERSION_FILE to manually set the version file path"
390
+ raise "Cannot bump version: no version file found"
391
+ end
392
+
393
+ # Read current version
394
+ current_version = extract_version_from_ssot_file(version_file)
395
+ raise "Version not found in #{version_file}" if current_version.nil?
396
+
397
+ # Parse and bump patch version
398
+ parsed = parse(current_version)
399
+ new_version = "#{parsed[:major]}.#{parsed[:minor]}.#{parsed[:patch] + 1}"
400
+
401
+ # Update the version file
402
+ set_version_in_file(version_file, new_version)
403
+
404
+ # Verify the update
405
+ updated_version = extract_version_from_ssot_file(version_file)
406
+ if updated_version != new_version
407
+ raise "Version bump failed: expected #{new_version}, got #{updated_version}"
408
+ end
409
+
410
+ new_version
411
+ end
412
+
413
+ private
414
+
415
+ # Find the SSOT version file in the project root
416
+ #
417
+ # Checks for VERSION_FILE constant first (if defined), then searches for common version files.
418
+ #
419
+ # @param project_root [String] Path to the project root directory
420
+ # @return [String, nil] Path to the version file, or nil if not found
421
+ def self.find_ssot_version_file(project_root)
422
+ # Normalize project_root for file operations (Ruby's File methods work with forward slashes)
423
+ normalized_root = project_root.gsub(/\\/, "/")
424
+
425
+ # Check for manually defined VERSION_FILE constant first
426
+ if defined?(VERSION_FILE) && !VERSION_FILE.nil?
427
+ version_file = File.expand_path(VERSION_FILE, normalized_root)
428
+ return version_file if File.exist?(version_file)
429
+ warn " Warning: VERSION_FILE constant points to non-existent file: #{VERSION_FILE}"
430
+ end
431
+
432
+ # Priority order for version files (SSOT)
433
+ version_file_patterns = [
434
+ "*.gemspec", # Ruby gems
435
+ "Directory.Build.props", # .NET projects
436
+ "Cargo.toml", # Rust projects
437
+ "package.json", # Node.js projects
438
+ "pyproject.toml", # Python projects
439
+ "pom.xml" # Maven/Java projects
440
+ ]
441
+
442
+ version_file_patterns.each do |pattern|
443
+ matches = Dir.glob(File.join(normalized_root, pattern))
444
+ next if matches.empty?
445
+
446
+ # For gemspec, prefer the one matching the project name or take the first
447
+ version_file = matches.first
448
+ return version_file if version_file
449
+ end
450
+
451
+ nil
452
+ end
453
+
454
+ # Find project root by looking for common markers
455
+ def self.find_project_root(start_dir)
456
+ current = File.expand_path(start_dir)
457
+ root = File.expand_path("/")
458
+
459
+ while current != root
460
+ markers = ["Rakefile", "rakefile.rb", ".gitignore", ".git"]
461
+ return current if markers.any? { |marker| File.exist?(File.join(current, marker)) }
462
+ current = File.dirname(current)
463
+ end
464
+
465
+ nil
466
+ end
467
+
468
+ # Normalize version string for parsing (strip metadata, handle 3/4-part)
469
+ #
470
+ # Strips build metadata (+abc123), handles 3-part and 4-part versions,
471
+ # and trims whitespace.
472
+ #
473
+ # @param version_string [String] Version string to normalize
474
+ # @return [String] Normalized version string
475
+ #
476
+ # @example
477
+ # normalize_version_for_parsing("0.1.1+abc123") # => "0.1.1"
478
+ # normalize_version_for_parsing("0.1.1.0") # => "0.1.1.0"
479
+ # normalize_version_for_parsing(" 0.1.1 ") # => "0.1.1"
480
+ def self.normalize_version_for_parsing(version_string)
481
+ return nil if version_string.nil?
482
+
483
+ # Trim whitespace
484
+ normalized = version_string.strip
485
+
486
+ # Strip build metadata (e.g., "+abc123")
487
+ normalized = normalized.split("+").first if normalized.include?("+")
488
+
489
+ normalized
490
+ end
491
+
492
+ # Extract SemVer (3-part) from version string (handles 4-part)
493
+ #
494
+ # If version is 4-part (x.y.z.w), extracts first 3 parts (x.y.z).
495
+ # If 3-part, returns as-is. Strips pre-release and build metadata.
496
+ #
497
+ # @param version_string [String] Version string (3-part or 4-part)
498
+ # @return [String] 3-part SemVer version string
499
+ #
500
+ # @example
501
+ # extract_semver_from_version("0.1.1.0") # => "0.1.1"
502
+ # extract_semver_from_version("0.1.1") # => "0.1.1"
503
+ # extract_semver_from_version("0.1.1-alpha") # => "0.1.1"
504
+ def self.extract_semver_from_version(version_string)
505
+ normalized = normalize_version_for_parsing(version_string)
506
+ return nil if normalized.nil?
507
+
508
+ # Strip pre-release suffix (e.g., "-alpha", "-beta.1")
509
+ normalized = normalized.split("-").first if normalized.include?("-")
510
+
511
+ # Split by dots to check if 4-part
512
+ parts = normalized.split(".")
513
+
514
+ # If 4-part, extract first 3 parts
515
+ if parts.length >= 4
516
+ "#{parts[0]}.#{parts[1]}.#{parts[2]}"
517
+ elsif parts.length == 3
518
+ normalized
519
+ else
520
+ # Invalid format, return as-is (will be caught by parse method)
521
+ normalized
522
+ end
523
+ end
524
+
525
+ # Convert SemVer to 4-part format
526
+ #
527
+ # Converts "x.y.z" to "x.y.z.0". Handles pre-release suffixes by stripping them.
528
+ #
529
+ # @param version_string [String] SemVer version string (x.y.z)
530
+ # @return [String] 4-part version string (x.y.z.0)
531
+ #
532
+ # @example
533
+ # semver_to_four_part("0.1.2") # => "0.1.2.0"
534
+ # semver_to_four_part("0.1.2-alpha") # => "0.1.2.0"
535
+ def self.semver_to_four_part(version_string)
536
+ # Extract SemVer first (handles pre-release suffixes)
537
+ semver = extract_semver_from_version(version_string)
538
+ return nil if semver.nil?
539
+
540
+ # Ensure it's 3-part, then append .0
541
+ parts = semver.split(".")
542
+ if parts.length == 3
543
+ "#{parts[0]}.#{parts[1]}.#{parts[2]}.0"
544
+ else
545
+ # If already 4-part or invalid, return as-is
546
+ semver
547
+ end
548
+ end
549
+
550
+ # Update Directory.Build.props file with new version
551
+ #
552
+ # Reads file, updates <Version>, <AssemblyVersion>, and <FileVersion> elements
553
+ # in all PropertyGroups, preserves XML structure, and performs atomic operation.
554
+ #
555
+ # @param filename [String] Path to Directory.Build.props file
556
+ # @param new_version [String] New SemVer version string (x.y.z)
557
+ # @return [nil]
558
+ # @raise [RuntimeError] If <Version> element is missing or update fails
559
+ def self.set_version_in_directory_build_props(filename, new_version)
560
+ # Read file content
561
+ content = File.read(filename)
562
+
563
+ # Validate that <Version> element exists
564
+ unless content.match(%r{<Version>([^<]+)</Version>})
565
+ raise "Directory.Build.props file does not contain <Version> element: #{filename}"
566
+ end
567
+
568
+ # Extract SemVer from new_version (in case it's 4-part or has metadata)
569
+ semver_version = extract_semver_from_version(new_version)
570
+ raise "Invalid version format: #{new_version}" if semver_version.nil?
571
+
572
+ # Convert to 4-part format for AssemblyVersion and FileVersion
573
+ four_part_version = semver_to_four_part(semver_version)
574
+ raise "Failed to convert version to 4-part format: #{semver_version}" if four_part_version.nil?
575
+
576
+ # Prepare updated content (start with original)
577
+ updated_content = content.dup
578
+
579
+ # Update <Version> element (preserve XML structure)
580
+ # Pattern: captures opening tag, whitespace, content, whitespace, closing tag
581
+ version_pattern = %r{(<Version>)\s*([^<]+?)\s*(</Version>)}
582
+ if updated_content.match(version_pattern)
583
+ updated_content = updated_content.gsub(version_pattern) do |match|
584
+ "#{$1}#{semver_version}#{$3}"
585
+ end
586
+ else
587
+ raise "Failed to update <Version> element in #{filename}"
588
+ end
589
+
590
+ # Update <AssemblyVersion> element if present (preserve XML structure)
591
+ assembly_version_pattern = %r{(<AssemblyVersion>)\s*([^<]+?)\s*(</AssemblyVersion>)}
592
+ if updated_content.match(assembly_version_pattern)
593
+ updated_content = updated_content.gsub(assembly_version_pattern) do |match|
594
+ "#{$1}#{four_part_version}#{$3}"
595
+ end
596
+ end
597
+ # Note: If AssemblyVersion doesn't exist, we don't add it (per FR-006)
598
+
599
+ # Update <FileVersion> element if present (preserve XML structure)
600
+ file_version_pattern = %r{(<FileVersion>)\s*([^<]+?)\s*(</FileVersion>)}
601
+ if updated_content.match(file_version_pattern)
602
+ updated_content = updated_content.gsub(file_version_pattern) do |match|
603
+ "#{$1}#{four_part_version}#{$3}"
604
+ end
605
+ end
606
+ # Note: If FileVersion doesn't exist, we don't add it (per FR-006)
607
+
608
+ # Atomic write: only write if content changed
609
+ if updated_content != content
610
+ File.write(filename, updated_content)
611
+ end
612
+ end
613
+
614
+ # Extract version from SSOT file based on file type
615
+ def self.extract_version_from_ssot_file(file_path)
616
+ case File.basename(file_path)
617
+ when /\.gemspec$/
618
+ # Extract from gemspec: spec.version = "x.y.z"
619
+ content = File.read(file_path)
620
+ match = content.match(/spec\.version\s*=\s*["']([^"']+)["']/)
621
+ match ? match[1] : nil
622
+ when "Directory.Build.props"
623
+ # Extract from Directory.Build.props: <Version>x.y.z</Version>
624
+ content = File.read(file_path)
625
+ match = content.match(%r{<Version>([^<]+)</Version>})
626
+ if match
627
+ # Trim whitespace and normalize (strip build metadata)
628
+ normalize_version_for_parsing(match[1])
629
+ else
630
+ nil
631
+ end
632
+ when "Cargo.toml"
633
+ # Extract from Cargo.toml: version = "x.y.z"
634
+ content = File.read(file_path)
635
+ match = content.match(/version\s*=\s*["']([^"']+)["']/)
636
+ match ? match[1] : nil
637
+ when "package.json"
638
+ # Extract from package.json: "version": "x.y.z"
639
+ require "json"
640
+ json = JSON.parse(File.read(file_path))
641
+ json["version"]
642
+ when "pyproject.toml"
643
+ # Extract from pyproject.toml: version = "x.y.z" (in [project] or [tool.poetry] section)
644
+ content = File.read(file_path)
645
+ # Try [project] section first
646
+ match = content.match(/\[project\]\s*version\s*=\s*["']([^"']+)["']/)
647
+ return match[1] if match
648
+ # Try [tool.poetry] section
649
+ match = content.match(/\[tool\.poetry\]\s*version\s*=\s*["']([^"']+)["']/)
650
+ match ? match[1] : nil
651
+ when "pom.xml"
652
+ # Extract from pom.xml: <version>x.y.z</version>
653
+ content = File.read(file_path)
654
+ match = content.match(%r{<version>([^<]+)</version>})
655
+ match ? match[1] : nil
656
+ else
657
+ nil
658
+ end
659
+ end
660
+ end
661
+ end