Class: Makit::Version

Inherits:
Object
  • Object
show all
Defined in:
lib/makit/version.rb,
lib/makit/version_util.rb

Overview

Version utility class for handling version comparisons

Class Method Summary collapse

Class Method Details

.bumpString

Bump the patch version in the SSOT version file

Finds the Single Source of Truth (SSOT) version file in the project root, reads the current version, increments the patch version, and updates the file.

Examples:

# Current version: 1.2.3
Makit::Version.bump
# => "1.2.4"

With pre-release suffix

# Current version: 1.2.3-alpha
Makit::Version.bump
# => "1.2.4" (removes pre-release suffix when bumping)

Returns:

  • (String)

    The new version string after bumping

Raises:

  • (RuntimeError)

    If project root cannot be determined, no version file is found, or version cannot be parsed/updated



368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/makit/version.rb', line 368

def self.bump
  # Find the SSOT version file
  project_root = begin
    require_relative "directories" unless defined?(Makit::Directories)
    Makit::Directories::PROJECT_ROOT
  rescue NameError, LoadError
    find_project_root(Dir.pwd)
  end

  raise "Project root not found" if project_root.nil? || !Dir.exist?(project_root)

  version_file = find_ssot_version_file(project_root)
  if version_file.nil?
    warn "  Warning: No version file found in project root: #{project_root}"
    warn "  You may define a constant VERSION_FILE to manually set the version file path"
    raise "Cannot bump version: no version file found"
  end

  # Read current version
  current_version = extract_version_from_ssot_file(version_file)
  raise "Version not found in #{version_file}" if current_version.nil?

  # Parse and bump patch version
  parsed = parse(current_version)
  new_version = "#{parsed[:major]}.#{parsed[:minor]}.#{parsed[:patch] + 1}"

  # Update the version file
  set_version_in_file(version_file, new_version)

  # Verify the update
  updated_version = extract_version_from_ssot_file(version_file)
  if updated_version != new_version
    raise "Version bump failed: expected #{new_version}, got #{updated_version}"
  end

  new_version
end

.detect_from_file(filename, regex) ⇒ String?

Detect version using a regex pattern in a specific file

Parameters:

  • filename (String)

    Path to the file to search

  • regex (Regexp)

    Regular expression pattern to match version

Returns:

  • (String, nil)

    The extracted version or nil if no match found

Raises:

  • (RuntimeError)

    If file doesn’t exist



225
226
227
228
229
230
# File 'lib/makit/version.rb', line 225

def self.detect_from_file(filename, regex)
  raise "unable to find version in #{filename}" unless File.exist?(filename)

  match = File.read(filename).match(regex)
  match.captures[0] if !match.nil? && match.captures.length.positive?
end

.extract_version_from_ssot_file(file_path) ⇒ Object

Extract version from SSOT file based on file type



462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
# File 'lib/makit/version.rb', line 462

def self.extract_version_from_ssot_file(file_path)
  case File.basename(file_path)
  when /\.gemspec$/
    # Extract from gemspec: spec.version = "x.y.z"
    content = File.read(file_path)
    match = content.match(/spec\.version\s*=\s*["']([^"']+)["']/)
    match ? match[1] : nil
  when "Directory.Build.props"
    # Extract from Directory.Build.props: <Version>x.y.z</Version>
    content = File.read(file_path)
    match = content.match(%r{<Version>([^<]+)</Version>})
    match ? match[1] : nil
  when "Cargo.toml"
    # Extract from Cargo.toml: version = "x.y.z"
    content = File.read(file_path)
    match = content.match(/version\s*=\s*["']([^"']+)["']/)
    match ? match[1] : nil
  when "package.json"
    # Extract from package.json: "version": "x.y.z"
    require "json"
    json = JSON.parse(File.read(file_path))
    json["version"]
  when "pyproject.toml"
    # Extract from pyproject.toml: version = "x.y.z" (in [project] or [tool.poetry] section)
    content = File.read(file_path)
    # Try [project] section first
    match = content.match(/\[project\]\s*version\s*=\s*["']([^"']+)["']/)
    return match[1] if match
    # Try [tool.poetry] section
    match = content.match(/\[tool\.poetry\]\s*version\s*=\s*["']([^"']+)["']/)
    match ? match[1] : nil
  when "pom.xml"
    # Extract from pom.xml: <version>x.y.z</version>
    content = File.read(file_path)
    match = content.match(%r{<version>([^<]+)</version>})
    match ? match[1] : nil
  else
    nil
  end
end

.find_project_root(start_dir) ⇒ Object

Find project root by looking for common markers



448
449
450
451
452
453
454
455
456
457
458
459
# File 'lib/makit/version.rb', line 448

def self.find_project_root(start_dir)
  current = File.expand_path(start_dir)
  root = File.expand_path("/")

  while current != root
    markers = ["Rakefile", "rakefile.rb", ".gitignore", ".git"]
    return current if markers.any? { |marker| File.exist?(File.join(current, marker)) }
    current = File.dirname(current)
  end

  nil
end

.find_ssot_version_file(project_root) ⇒ String?

Find the SSOT version file in the project root

Checks for VERSION_FILE constant first (if defined), then searches for common version files.

Parameters:

  • project_root (String)

    Path to the project root directory

Returns:

  • (String, nil)

    Path to the version file, or nil if not found



414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
# File 'lib/makit/version.rb', line 414

def self.find_ssot_version_file(project_root)
  # Normalize project_root for file operations (Ruby's File methods work with forward slashes)
  normalized_root = project_root.gsub(/\\/, "/")
  
  # Check for manually defined VERSION_FILE constant first
  if defined?(VERSION_FILE) && !VERSION_FILE.nil?
    version_file = File.expand_path(VERSION_FILE, normalized_root)
    return version_file if File.exist?(version_file)
    warn "  Warning: VERSION_FILE constant points to non-existent file: #{VERSION_FILE}"
  end

  # Priority order for version files (SSOT)
  version_file_patterns = [
    "*.gemspec",                    # Ruby gems
    "Directory.Build.props",        # .NET projects
    "Cargo.toml",                   # Rust projects
    "package.json",                 # Node.js projects
    "pyproject.toml",               # Python projects
    "pom.xml"                       # Maven/Java projects
  ]

  version_file_patterns.each do |pattern|
    matches = Dir.glob(File.join(normalized_root, pattern))
    next if matches.empty?

    # For gemspec, prefer the one matching the project name or take the first
    version_file = matches.first
    return version_file if version_file
  end

  nil
end

.get_highest_version(versions) ⇒ String?

Get the highest version from a list of version strings

Parameters:

  • versions (Array<String>)

    array of version strings

Returns:

  • (String, nil)

    highest version string or nil if empty



187
188
189
# File 'lib/makit/version.rb', line 187

def self.get_highest_version(versions)
  versions.max { |a, b| Gem::Version.new(a) <=> Gem::Version.new(b) }
end

.get_version_from_file(path) ⇒ String

Extract version number from a file based on its extension

Supports multiple file formats:

  • .csproj files: ‘<Version>x.y.z</Version>`

  • .wxs files: ‘Version=“x.y.z”`

  • .yml files: ‘VERSION: “x.y.z”`

Parameters:

  • path (String)

    Path to the file containing version information

Returns:

  • (String)

    The extracted version string

Raises:

  • (RuntimeError)

    If file doesn’t exist or has unrecognized extension



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/makit/version.rb', line 201

def self.get_version_from_file(path)
  raise "file #{path}does not exist" unless File.exist?(path)

  extension = File.extname(path)
  case extension
  when ".csproj"
    Makit::Version.detect_from_file(path, /<Version>([-\w\d.]+)</)
  when ".wxs"
    Makit::Version.detect_from_file(path, / Version="([\d.]+)"/)
  when ".yml"
    Makit::Version.detect_from_file(path, /VERSION:\s*["']?([\d.]+)["']?/)
  when ".rb"
    Makit::Version.detect_from_file(path, /VERSION = "([\d.]+)"/)
  else
    raise "unrecognized file type"
  end
end

.infonil

Display version information for the current project

Finds the Single Source of Truth (SSOT) version file in the project root and displays the file path and current version value.

Searches for common version files in priority order:

  • *.gemspec (Ruby gems)

  • Directory.Build.props (.NET projects)

  • Cargo.toml (Rust projects)

  • package.json (Node.js projects)

  • pyproject.toml (Python projects)

  • pom.xml (Maven/Java projects)

If no version file is found, issues a warning and suggests defining a VERSION_FILE constant to manually specify the version file path.

Examples:

Output format

Version File: makit.gemspec
Version: 0.0.147

With VERSION_FILE constant defined

VERSION_FILE = "custom/version.txt"
Makit::Version.info
# Uses the file specified by VERSION_FILE constant

Returns:

  • (nil)

    Outputs version information to stdout, or returns early with warning if no file found

Raises:

  • (RuntimeError)

    If project root cannot be determined or version cannot be extracted from file



315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'lib/makit/version.rb', line 315

def self.info
  # Access Directories lazily to avoid circular dependency
  project_root = begin
    require_relative "directories" unless defined?(Makit::Directories)
    Makit::Directories::PROJECT_ROOT
  rescue NameError, LoadError
    # Fallback: try to find project root from current directory
    find_project_root(Dir.pwd)
  end

  raise "Project root not found" if project_root.nil? || !Dir.exist?(project_root)

  version_file = find_ssot_version_file(project_root)
  
  if version_file.nil?
    warn "  Warning: No version file found in project root: #{project_root}"
    warn "  You may define a constant VERSION_FILE to manually set the version file path"
    return
  end

  # Extract version based on file type
  version = extract_version_from_ssot_file(version_file)

  raise "Version not found in #{version_file}" if version.nil?

  # Display information with relative path (Windows-safe path handling)
  # Normalize both paths to forward slashes for comparison, then convert back if needed
  normalized_version_file = version_file.gsub(/\\/, "/")
  normalized_project_root = project_root.gsub(/\\/, "/")
  relative_path = normalized_version_file.sub(normalized_project_root + "/", "")
  puts "  Version File: #{relative_path}"
  puts "  Version: #{version}"
end

.parse(version_string) ⇒ Hash

Parse a semantic version string into its components

Parses a version string following Semantic Versioning (SemVer) format: MAJOR.MINOR.PATCH

Examples:

Basic version

Makit::Version.parse("1.2.3")
# => { major: 1, minor: 2, patch: 3, suffix: "" }

Version with pre-release suffix

Makit::Version.parse("1.2.3-alpha")
# => { major: 1, minor: 2, patch: 3, suffix: "-alpha" }

Invalid format

Makit::Version.parse("1.2")
# => RuntimeError: Invalid version format: 1.2. Expected format: Major.Minor.Patch

Parameters:

  • version_string (String)

    Version string in SemVer format (e.g., “1.2.3” or “1.2.3-alpha.1”)

Returns:

  • (Hash)

    Hash with keys: :major, :minor, :patch, :suffix

    • :major [Integer] Major version number

    • :minor [Integer] Minor version number

    • :patch [Integer] Patch version number

    • :suffix [String] Pre-release suffix (e.g., “-alpha.1”) or empty string

Raises:

  • (RuntimeError)

    If version string doesn’t match MAJOR.MINOR.PATCH format



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/makit/version.rb', line 146

def self.parse(version_string)
  parts = version_string.split(".")
  if parts.length < 3
    raise "Invalid version format: #{version_string}. Expected format: Major.Minor.Patch"
  end

  major = parts[0].to_i
  minor = parts[1].to_i
  patch = parts[2].to_i

  # Handle pre-release suffixes (e.g., "0.1.0-preview" -> patch = 0, suffix = "-preview")
  # Note: If suffix contains dots (e.g., "1.2.3-preview.1"), the split by "." will separate
  # the patch and suffix parts, so only the part before the first "-" in parts[2] is used as patch.
  patch_part = parts[2]
  if patch_part.include?("-")
    patch, suffix = patch_part.split("-", 2)
    patch = patch.to_i
    suffix = "-#{suffix}"
  else
    suffix = ""
  end

  { major: major, minor: minor, patch: patch, suffix: suffix }
end

.set_version_in_file(filename, version) ⇒ nil

Update version number in a file based on its extension

Supports updating versions in multiple file formats:

  • .yml files

  • .gemspec files

  • .csproj files

  • .nuspec files

  • .wxs files

  • .toml files

Parameters:

  • filename (String)

    Path to the file to update

  • version (String)

    New version string to set

Returns:

  • (nil)


245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/makit/version.rb', line 245

def self.set_version_in_file(filename, version)
  text = File.read(filename)
  #   VERSION = "0.0.138rake" (.rb file)
  new_text = text
  new_text = new_text.gsub(/VERSION:\s?['|"]([.\d]+)['|"]/, "VERSION: \"#{version}\"") if filename.include?(".yml")
  new_text = new_text.gsub(/spec\.version\s*=\s*['"]([^'"]+)['"]/, "spec.version = '#{version}'") if filename.include?(".gemspec")
  # Handle Directory.Build.props and .csproj files (both use <Version> tag)
  if filename.include?("Directory.Build.props") || filename.include?(".csproj")
    new_text = new_text.gsub(/<Version>([-\w\d.]+)</, "<Version>#{version}<")
  end
  new_text = new_text.gsub(/<version>([-\w\d.]+)</, "<version>#{version}<") if filename.include?(".nuspec")
  new_text = new_text.gsub(/ Version="([\d.]+)"/, " Version=\"#{version}\"") if filename.include?(".wxs")
  new_text = new_text.gsub(/VERSION = "([\d.]+)"/, "VERSION = \"#{version}\"") if filename.include?(".rb")
  # Handle Cargo.toml, pyproject.toml, and other .toml files
  if filename.include?(".toml")
    new_text = new_text.gsub(/version\s+=\s+['"]([\w.]+)['"]/, "version=\"#{version}\"")
  end
  # Handle package.json
  if filename.include?("package.json")
    require "json"
    json = JSON.parse(new_text)
    json["version"] = version
    new_text = JSON.pretty_generate(json)
  end
  # Handle pom.xml
  if filename.include?("pom.xml")
    new_text = new_text.gsub(%r{<version>([^<]+)</version>}, "<version>#{version}</version>")
  end
  File.write(filename, new_text) if new_text != text
end

.set_version_in_files(glob_pattern, version) ⇒ nil

Update version number in multiple files matching a glob pattern

Parameters:

  • glob_pattern (String)

    Glob pattern to match files (e.g., ‘*/.csproj’)

  • version (String)

    New version string to set in all matching files

Returns:

  • (nil)


281
282
283
284
285
# File 'lib/makit/version.rb', line 281

def self.set_version_in_files(glob_pattern, version)
  Dir.glob(glob_pattern).each do |filename|
    set_version_in_file(filename, version)
  end
end

.versionString

Attempt to detect the version from the SSOT (Single Source of Truth) version file Uses the same logic as Makit::Version.info to find version files in priority order: *.gemspec, Directory.Build.props, Cargo.toml, package.json, pyproject.toml, pom.xml Falls back to makit.gemspec only if we’re in the makit gem directory

Returns:

  • (String)

    The version string, or “0.0.0” if no version file is found

Raises:

  • (RuntimeError)

    If a version file is found but version cannot be detected



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/makit/version.rb', line 71

def self.version
  # Try to find version file in project root using SSOT logic (same as Makit::Version.info)
  project_root = begin
    require_relative "directories" unless defined?(Makit::Directories)
    Makit::Directories::PROJECT_ROOT
  rescue NameError, LoadError
    find_project_root(Dir.pwd)
  end

  # Use SSOT version file detection (supports multiple file types)
  if project_root && Dir.exist?(project_root)
    version_file = find_ssot_version_file(project_root)
    if version_file && File.exist?(version_file)
      version = extract_version_from_ssot_file(version_file)
      return version if version
    end
  end

  # Fallback to makit.gemspec (for the makit gem itself)
  # Check if makit.gemspec exists relative to this file
  makit_gemspec = File.join(File.dirname(__FILE__), "..", "..", "makit.gemspec")
  makit_gemspec = File.expand_path(makit_gemspec)
  
  # Use makit.gemspec if:
  # 1. It exists, AND
  # 2. Either we're in the makit gem directory (project_root matches), OR
  #    project_root is nil (running outside a project context, likely from installed gem)
  if File.exist?(makit_gemspec)
    makit_gem_dir = File.dirname(makit_gemspec)
    use_makit_gemspec = if project_root
      # If we have a project root, only use makit.gemspec if we're in the makit gem directory
      File.expand_path(project_root) == File.expand_path(makit_gem_dir)
    else
      # If no project root, check if current directory is makit gem or if makit.gemspec is nearby
      # This handles the case when running from installed gem or outside project context
      Dir.pwd == makit_gem_dir || File.expand_path(Dir.pwd).start_with?(File.expand_path(makit_gem_dir))
    end
    
    if use_makit_gemspec
      gemspec_content = File.read(makit_gemspec)
      match = gemspec_content.match(/spec\.version\s*=\s*["']([^"']+)["']/)
      raise "Version not found in gemspec file" if match.nil?
      return match[1]
    end
  end

  # If no version file found, return default version (for non-gem projects)
  "0.0.0"
end