makit 0.0.168 → 0.0.169

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 (179) 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/azure-pipelines.rb +187 -187
  9. data/lib/makit/cli/base.rb +17 -17
  10. data/lib/makit/cli/build_commands.rb +500 -500
  11. data/lib/makit/cli/generators/base_generator.rb +74 -74
  12. data/lib/makit/cli/generators/dotnet_generator.rb +50 -50
  13. data/lib/makit/cli/generators/generator_factory.rb +49 -49
  14. data/lib/makit/cli/generators/node_generator.rb +50 -50
  15. data/lib/makit/cli/generators/ruby_generator.rb +77 -77
  16. data/lib/makit/cli/generators/rust_generator.rb +50 -50
  17. data/lib/makit/cli/generators/templates/dotnet_templates.rb +167 -167
  18. data/lib/makit/cli/generators/templates/node_templates.rb +161 -161
  19. data/lib/makit/cli/generators/templates/ruby/gemfile.rb +26 -26
  20. data/lib/makit/cli/generators/templates/ruby/gemspec.rb +41 -41
  21. data/lib/makit/cli/generators/templates/ruby/main_lib.rb +33 -33
  22. data/lib/makit/cli/generators/templates/ruby/rakefile.rb +35 -35
  23. data/lib/makit/cli/generators/templates/ruby/readme.rb +63 -63
  24. data/lib/makit/cli/generators/templates/ruby/test.rb +39 -39
  25. data/lib/makit/cli/generators/templates/ruby/test_helper.rb +29 -29
  26. data/lib/makit/cli/generators/templates/ruby/version.rb +29 -29
  27. data/lib/makit/cli/generators/templates/rust_templates.rb +128 -128
  28. data/lib/makit/cli/main.rb +78 -78
  29. data/lib/makit/cli/pipeline_commands.rb +311 -311
  30. data/lib/makit/cli/project_commands.rb +868 -868
  31. data/lib/makit/cli/repository_commands.rb +661 -661
  32. data/lib/makit/cli/strategy_commands.rb +207 -207
  33. data/lib/makit/cli/utility_commands.rb +521 -521
  34. data/lib/makit/commands/factory.rb +359 -359
  35. data/lib/makit/commands/middleware/base.rb +73 -73
  36. data/lib/makit/commands/middleware/cache.rb +248 -248
  37. data/lib/makit/commands/middleware/command_logger.rb +312 -312
  38. data/lib/makit/commands/middleware/validator.rb +269 -269
  39. data/lib/makit/commands/request.rb +316 -316
  40. data/lib/makit/commands/result.rb +323 -323
  41. data/lib/makit/commands/runner.rb +386 -386
  42. data/lib/makit/commands/strategies/base.rb +171 -171
  43. data/lib/makit/commands/strategies/child_process.rb +162 -162
  44. data/lib/makit/commands/strategies/factory.rb +136 -136
  45. data/lib/makit/commands/strategies/synchronous.rb +139 -139
  46. data/lib/makit/commands.rb +50 -50
  47. data/lib/makit/configuration/dotnet_project.rb +48 -48
  48. data/lib/makit/configuration/gitlab_helper.rb +61 -61
  49. data/lib/makit/configuration/project.rb +292 -292
  50. data/lib/makit/configuration/rakefile_helper.rb +43 -43
  51. data/lib/makit/configuration/step.rb +34 -34
  52. data/lib/makit/configuration/timeout.rb +74 -74
  53. data/lib/makit/configuration.rb +21 -21
  54. data/lib/makit/content/default_gitignore.rb +7 -7
  55. data/lib/makit/content/default_gitignore.txt +225 -225
  56. data/lib/makit/content/default_rakefile.rb +13 -13
  57. data/lib/makit/content/gem_rakefile.rb +16 -16
  58. data/lib/makit/context.rb +1 -1
  59. data/lib/makit/data.rb +49 -49
  60. data/lib/makit/directories.rb +170 -170
  61. data/lib/makit/directory.rb +262 -262
  62. data/lib/makit/docs/files.rb +89 -89
  63. data/lib/makit/docs/rake.rb +102 -102
  64. data/lib/makit/dotnet/cli.rb +224 -224
  65. data/lib/makit/dotnet/project.rb +217 -217
  66. data/lib/makit/dotnet/solution.rb +38 -38
  67. data/lib/makit/dotnet/solution_classlib.rb +239 -239
  68. data/lib/makit/dotnet/solution_console.rb +264 -264
  69. data/lib/makit/dotnet/solution_maui.rb +354 -354
  70. data/lib/makit/dotnet/solution_wasm.rb +275 -275
  71. data/lib/makit/dotnet/solution_wpf.rb +304 -304
  72. data/lib/makit/dotnet.rb +110 -110
  73. data/lib/makit/email.rb +90 -90
  74. data/lib/makit/environment.rb +142 -142
  75. data/lib/makit/examples/runner.rb +370 -370
  76. data/lib/makit/exceptions.rb +45 -45
  77. data/lib/makit/fileinfo.rb +32 -32
  78. data/lib/makit/files.rb +43 -43
  79. data/lib/makit/gems.rb +49 -49
  80. data/lib/makit/git/cli.rb +103 -103
  81. data/lib/makit/git/repository.rb +100 -100
  82. data/lib/makit/git.rb +104 -104
  83. data/lib/makit/github_actions.rb +202 -202
  84. data/lib/makit/gitlab/pipeline.rb +857 -857
  85. data/lib/makit/gitlab/pipeline_service_impl.rb +1535 -1535
  86. data/lib/makit/gitlab_runner.rb +59 -59
  87. data/lib/makit/humanize.rb +218 -218
  88. data/lib/makit/indexer.rb +47 -47
  89. data/lib/makit/io/filesystem.rb +111 -111
  90. data/lib/makit/io/filesystem_service_impl.rb +337 -337
  91. data/lib/makit/lint.rb +212 -212
  92. data/lib/makit/logging/configuration.rb +309 -309
  93. data/lib/makit/logging/format_registry.rb +84 -84
  94. data/lib/makit/logging/formatters/base.rb +39 -39
  95. data/lib/makit/logging/formatters/console_formatter.rb +140 -140
  96. data/lib/makit/logging/formatters/json_formatter.rb +65 -65
  97. data/lib/makit/logging/formatters/plain_text_formatter.rb +71 -71
  98. data/lib/makit/logging/formatters/text_formatter.rb +64 -64
  99. data/lib/makit/logging/log_request.rb +119 -119
  100. data/lib/makit/logging/logger.rb +199 -199
  101. data/lib/makit/logging/sinks/base.rb +91 -91
  102. data/lib/makit/logging/sinks/console.rb +72 -72
  103. data/lib/makit/logging/sinks/file_sink.rb +92 -92
  104. data/lib/makit/logging/sinks/structured.rb +123 -123
  105. data/lib/makit/logging/sinks/unified_file_sink.rb +296 -296
  106. data/lib/makit/logging.rb +578 -578
  107. data/lib/makit/markdown.rb +75 -75
  108. data/lib/makit/mp/basic_object_mp.rb +17 -17
  109. data/lib/makit/mp/command_mp.rb +13 -13
  110. data/lib/makit/mp/command_request.mp.rb +17 -17
  111. data/lib/makit/mp/project_mp.rb +199 -199
  112. data/lib/makit/mp/string_mp.rb +205 -205
  113. data/lib/makit/nuget.rb +460 -458
  114. data/lib/makit/podman/podman.rb +458 -458
  115. data/lib/makit/podman/podman_service_impl.rb +1081 -1081
  116. data/lib/makit/port.rb +32 -32
  117. data/lib/makit/process.rb +377 -377
  118. data/lib/makit/protoc.rb +112 -112
  119. data/lib/makit/rake/cli.rb +196 -196
  120. data/lib/makit/rake/trace_controller.rb +174 -174
  121. data/lib/makit/rake.rb +81 -81
  122. data/lib/makit/ruby/cli.rb +185 -185
  123. data/lib/makit/ruby.rb +25 -25
  124. data/lib/makit/rubygems.rb +137 -137
  125. data/lib/makit/secrets/azure_key_vault.rb +322 -322
  126. data/lib/makit/secrets/azure_secrets.rb +221 -221
  127. data/lib/makit/secrets/local_secrets.rb +72 -72
  128. data/lib/makit/secrets/secrets_manager.rb +105 -105
  129. data/lib/makit/secrets.rb +96 -96
  130. data/lib/makit/serializer.rb +130 -130
  131. data/lib/makit/services/builder.rb +186 -186
  132. data/lib/makit/services/error_handler.rb +226 -226
  133. data/lib/makit/services/repository_manager.rb +367 -367
  134. data/lib/makit/services/validator.rb +112 -112
  135. data/lib/makit/setup/classlib.rb +101 -101
  136. data/lib/makit/setup/gem.rb +268 -268
  137. data/lib/makit/setup/pages.rb +11 -11
  138. data/lib/makit/setup/razorclasslib.rb +101 -101
  139. data/lib/makit/setup/runner.rb +54 -54
  140. data/lib/makit/setup.rb +5 -5
  141. data/lib/makit/show.rb +110 -110
  142. data/lib/makit/storage.rb +126 -126
  143. data/lib/makit/symbols.rb +175 -175
  144. data/lib/makit/task_info.rb +130 -130
  145. data/lib/makit/tasks/at_exit.rb +15 -15
  146. data/lib/makit/tasks/build.rb +22 -22
  147. data/lib/makit/tasks/bump.rb +7 -7
  148. data/lib/makit/tasks/clean.rb +13 -13
  149. data/lib/makit/tasks/configure.rb +10 -10
  150. data/lib/makit/tasks/format.rb +10 -10
  151. data/lib/makit/tasks/hook_manager.rb +443 -443
  152. data/lib/makit/tasks/info.rb +368 -368
  153. data/lib/makit/tasks/init.rb +49 -49
  154. data/lib/makit/tasks/integrate.rb +60 -60
  155. data/lib/makit/tasks/pull_incoming.rb +13 -13
  156. data/lib/makit/tasks/secrets.rb +7 -7
  157. data/lib/makit/tasks/setup.rb +16 -16
  158. data/lib/makit/tasks/sync.rb +14 -14
  159. data/lib/makit/tasks/tag.rb +27 -27
  160. data/lib/makit/tasks/task_monkey_patch.rb +81 -81
  161. data/lib/makit/tasks/test.rb +22 -22
  162. data/lib/makit/tasks/update.rb +21 -21
  163. data/lib/makit/tasks/version.rb +6 -6
  164. data/lib/makit/tasks.rb +24 -24
  165. data/lib/makit/test_cache.rb +239 -239
  166. data/lib/makit/tree.rb +37 -37
  167. data/lib/makit/v1/configuration/project_service_impl.rb +370 -370
  168. data/lib/makit/v1/git/git_repository_service_impl.rb +295 -295
  169. data/lib/makit/v1/makit.v1_pb.rb +35 -35
  170. data/lib/makit/v1/makit.v1_services_pb.rb +27 -27
  171. data/lib/makit/v1/services/repository_manager_service_impl.rb +572 -572
  172. data/lib/makit/version.rb +661 -661
  173. data/lib/makit/version_util.rb +21 -21
  174. data/lib/makit/wix.rb +95 -95
  175. data/lib/makit/yaml.rb +29 -29
  176. data/lib/makit/zip.rb +17 -17
  177. data/lib/makit copy.rb +44 -44
  178. data/lib/makit.rb +121 -121
  179. metadata +2 -2
data/lib/makit/version.rb CHANGED
@@ -1,661 +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.168"
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
1
+ # frozen_string_literal: true
2
+
3
+ module Makit
4
+ # Static version for now to avoid circular dependency issues
5
+ #VERSION = "0.0.169"
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