From 062456cec007d6b2bd3e4344fc071e24a5c4d65c Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Thu, 8 Jan 2026 10:46:18 -0500 Subject: [PATCH] feat(install): add partial version number support Enable install command to resolve partial version specifications: - Major-only: dtvem install node 22 -> installs latest 22.x.x - Major.minor: dtvem install python 3.12 -> installs latest 3.12.x Closes #188 --- .../integration-test-partial-versions.yml | 32 ++ .github/workflows/integration-test.yml | 14 + src/cmd/install.go | 64 +++- src/cmd/install_test.go | 158 +++++++++- src/internal/version/matcher.go | 141 +++++++++ src/internal/version/matcher_test.go | 277 ++++++++++++++++++ 6 files changed, 672 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/integration-test-partial-versions.yml create mode 100644 src/internal/version/matcher.go create mode 100644 src/internal/version/matcher_test.go diff --git a/.github/workflows/integration-test-partial-versions.yml b/.github/workflows/integration-test-partial-versions.yml new file mode 100644 index 0000000..c95d293 --- /dev/null +++ b/.github/workflows/integration-test-partial-versions.yml @@ -0,0 +1,32 @@ +name: Integration Tests - Partial Versions + +on: + workflow_dispatch: + # Manual trigger for testing partial version resolution + push: + branches: [main] + paths: + - 'src/internal/version/**' + - 'src/cmd/install.go' + - '.github/workflows/integration-test-partial-versions.yml' + pull_request: + branches: [main] + paths: + - 'src/internal/version/**' + - 'src/cmd/install.go' + - '.github/workflows/integration-test-partial-versions.yml' + +permissions: + contents: read + +jobs: + partial-versions: + name: Partial Version Resolution + uses: CodingWithCalvin/.github/.github/workflows/dtvem-integration-test-partial-versions.yml@main + with: + node_major: '22' + node_major_minor: '20.18' + python_major: '3' + python_major_minor: '3.12' + ruby_major: '3' + ruby_major_minor: '3.3' diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 0a84a28..5207da5 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -35,6 +35,20 @@ jobs: version1: '3.3.6' version2: '3.4.1' + # ========================================================================== + # Partial Version Resolution Tests + # ========================================================================== + partial-versions: + name: Partial Version Resolution + uses: CodingWithCalvin/.github/.github/workflows/dtvem-integration-test-partial-versions.yml@main + with: + node_major: '22' + node_major_minor: '20.18' + python_major: '3' + python_major_minor: '3.12' + ruby_major: '3' + ruby_major_minor: '3.3' + # ========================================================================== # Migration Tests # ========================================================================== diff --git a/src/cmd/install.go b/src/cmd/install.go index 55e1cc5..e45736a 100644 --- a/src/cmd/install.go +++ b/src/cmd/install.go @@ -9,6 +9,7 @@ import ( "github.com/CodingWithCalvin/dtvem.cli/src/internal/constants" "github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime" "github.com/CodingWithCalvin/dtvem.cli/src/internal/ui" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/version" "github.com/spf13/cobra" ) @@ -21,10 +22,14 @@ var installCmd = &cobra.Command{ Short: "Install runtime version(s)", Long: `Install a specific version of a runtime, or install all runtimes from .dtvem/runtimes.json. -Single install: +Single install (exact version): dtvem install python 3.11.0 dtvem install node 18.16.0 +Single install (partial version - resolves to latest match): + dtvem install node 22 # Installs latest 22.x.x (e.g., 22.15.0) + dtvem install python 3.12 # Installs latest 3.12.x (e.g., 3.12.1) + Bulk install (reads .dtvem/runtimes.json): dtvem install dtvem install --yes # Skip confirmation prompt`, @@ -51,8 +56,8 @@ func init() { } // installSingle installs a single runtime/version -func installSingle(runtimeName, version string) { - ui.Debug("Installing single runtime: %s version %s", runtimeName, version) +func installSingle(runtimeName, versionInput string) { + ui.Debug("Installing single runtime: %s version %s", runtimeName, versionInput) provider, err := runtime.Get(runtimeName) if err != nil { @@ -64,20 +69,33 @@ func installSingle(runtimeName, version string) { ui.Debug("Using provider: %s (%s)", provider.Name(), provider.DisplayName()) - if err := provider.Install(version); err != nil { + // Resolve partial version to full version if needed + resolvedVersion, err := resolveVersionForProvider(provider, versionInput) + if err != nil { + ui.Debug("Version resolution failed: %v", err) + ui.Error("%v", err) + os.Exit(1) + } + + // Inform user if version was resolved from partial input + if resolvedVersion != versionInput { + ui.Info("Resolved %s to %s", versionInput, resolvedVersion) + } + + if err := provider.Install(resolvedVersion); err != nil { ui.Debug("Installation failed: %v", err) ui.Error("%v", err) os.Exit(1) } - ui.Success("Successfully installed %s %s", provider.DisplayName(), version) + ui.Success("Successfully installed %s %s", provider.DisplayName(), resolvedVersion) // Auto-set global version if no global version is currently configured - autoSetGlobalIfNeeded(provider, version) + autoSetGlobalIfNeeded(provider, resolvedVersion) } // autoSetGlobalIfNeeded sets the installed version as global if no global version exists -func autoSetGlobalIfNeeded(provider runtime.Provider, version string) { +func autoSetGlobalIfNeeded(provider runtime.Provider, ver string) { currentGlobal, err := provider.GlobalVersion() if err != nil || currentGlobal != "" { // Either an error occurred or a global version is already set @@ -86,7 +104,7 @@ func autoSetGlobalIfNeeded(provider runtime.Provider, version string) { } // No global version configured, auto-set it - if err := provider.SetGlobalVersion(version); err != nil { + if err := provider.SetGlobalVersion(ver); err != nil { ui.Debug("Failed to auto-set global version: %v", err) ui.Warning("Could not auto-set global version: %v", err) return @@ -95,6 +113,36 @@ func autoSetGlobalIfNeeded(provider runtime.Provider, version string) { ui.Info("Set as global version (first install)") } +// resolveVersionForProvider resolves a partial version input to a full version. +// If the input is already a full version (3 components), it's returned as-is. +// For partial versions (1-2 components), it finds the highest matching version. +func resolveVersionForProvider(provider runtime.Provider, input string) (string, error) { + // If it's already a full version, return as-is + if !version.IsPartialVersion(input) { + return strings.TrimPrefix(input, "v"), nil + } + + // Get available versions from the provider + available, err := provider.ListAvailable() + if err != nil { + return "", fmt.Errorf("failed to fetch available versions: %w", err) + } + + // Extract version strings + versionStrings := make([]string, len(available)) + for i, av := range available { + versionStrings[i] = av.Version.Raw + } + + // Resolve the partial version + resolved, err := version.ResolvePartialVersion(input, versionStrings) + if err != nil { + return "", fmt.Errorf("no %s version matching %q found", provider.DisplayName(), input) + } + + return resolved, nil +} + // installBulk installs all runtimes from .dtvem/runtimes.json // installTask represents a runtime version to be installed type installTask struct { diff --git a/src/cmd/install_test.go b/src/cmd/install_test.go index 3b87e99..9c105ba 100644 --- a/src/cmd/install_test.go +++ b/src/cmd/install_test.go @@ -8,11 +8,13 @@ import ( // mockProvider implements runtime.Provider for testing type mockProvider struct { - name string - displayName string - globalVersion string - globalSetError error - setGlobalCalls []string + name string + displayName string + globalVersion string + globalSetError error + setGlobalCalls []string + availableVersions []runtime.AvailableVersion + listAvailableErr error } func (m *mockProvider) Name() string { return m.name } @@ -27,7 +29,7 @@ func (m *mockProvider) ListInstalled() ([]runtime.InstalledVersion, error) { return nil, nil } func (m *mockProvider) ListAvailable() ([]runtime.AvailableVersion, error) { - return nil, nil + return m.availableVersions, m.listAvailableErr } func (m *mockProvider) InstallPath(version string) (string, error) { return "", nil } func (m *mockProvider) LocalVersion() (string, error) { return "", nil } @@ -114,3 +116,147 @@ func TestAutoSetGlobalIfNeeded_MultipleInstalls(t *testing.T) { t.Errorf("Expected second install to not change global, got %d calls total", len(provider.setGlobalCalls)) } } + +// Helper to create AvailableVersion from a version string +func makeAvailableVersion(v string) runtime.AvailableVersion { + return runtime.AvailableVersion{ + Version: runtime.NewVersion(v), + } +} + +func TestResolveVersionForProvider_FullVersion(t *testing.T) { + provider := &mockProvider{ + name: "node", + displayName: "Node.js", + availableVersions: []runtime.AvailableVersion{ + makeAvailableVersion("22.15.0"), + makeAvailableVersion("22.0.0"), + }, + } + + // Full version should pass through unchanged + result, err := resolveVersionForProvider(provider, "22.15.0") + if err != nil { + t.Errorf("resolveVersionForProvider returned error: %v", err) + } + if result != "22.15.0" { + t.Errorf("Expected 22.15.0, got %q", result) + } +} + +func TestResolveVersionForProvider_FullVersionWithVPrefix(t *testing.T) { + provider := &mockProvider{ + name: "node", + displayName: "Node.js", + availableVersions: []runtime.AvailableVersion{ + makeAvailableVersion("22.15.0"), + }, + } + + // Full version with v prefix should have prefix stripped + result, err := resolveVersionForProvider(provider, "v22.15.0") + if err != nil { + t.Errorf("resolveVersionForProvider returned error: %v", err) + } + if result != "22.15.0" { + t.Errorf("Expected 22.15.0, got %q", result) + } +} + +func TestResolveVersionForProvider_MajorOnly(t *testing.T) { + provider := &mockProvider{ + name: "node", + displayName: "Node.js", + availableVersions: []runtime.AvailableVersion{ + makeAvailableVersion("22.0.0"), + makeAvailableVersion("22.5.0"), + makeAvailableVersion("22.15.0"), + makeAvailableVersion("22.15.1"), + makeAvailableVersion("21.0.0"), + }, + } + + // Major-only should resolve to highest 22.x.x + result, err := resolveVersionForProvider(provider, "22") + if err != nil { + t.Errorf("resolveVersionForProvider returned error: %v", err) + } + if result != "22.15.1" { + t.Errorf("Expected 22.15.1 (highest 22.x.x), got %q", result) + } +} + +func TestResolveVersionForProvider_MajorMinor(t *testing.T) { + provider := &mockProvider{ + name: "node", + displayName: "Node.js", + availableVersions: []runtime.AvailableVersion{ + makeAvailableVersion("14.21.0"), + makeAvailableVersion("14.21.3"), + makeAvailableVersion("14.20.0"), + makeAvailableVersion("14.20.1"), + }, + } + + // Major.minor should resolve to highest 14.21.x + result, err := resolveVersionForProvider(provider, "14.21") + if err != nil { + t.Errorf("resolveVersionForProvider returned error: %v", err) + } + if result != "14.21.3" { + t.Errorf("Expected 14.21.3 (highest 14.21.x), got %q", result) + } +} + +func TestResolveVersionForProvider_NoMatch(t *testing.T) { + provider := &mockProvider{ + name: "node", + displayName: "Node.js", + availableVersions: []runtime.AvailableVersion{ + makeAvailableVersion("22.0.0"), + makeAvailableVersion("21.0.0"), + }, + } + + // No matching version should return error + _, err := resolveVersionForProvider(provider, "99") + if err == nil { + t.Error("Expected error for non-matching version, got nil") + } +} + +func TestResolveVersionForProvider_PythonVersions(t *testing.T) { + provider := &mockProvider{ + name: "python", + displayName: "Python", + availableVersions: []runtime.AvailableVersion{ + makeAvailableVersion("3.9.18"), + makeAvailableVersion("3.10.13"), + makeAvailableVersion("3.11.7"), + makeAvailableVersion("3.12.0"), + makeAvailableVersion("3.12.1"), + }, + } + + tests := []struct { + input string + expected string + }{ + {"3", "3.12.1"}, // Latest 3.x.x + {"3.11", "3.11.7"}, // Latest 3.11.x + {"3.12", "3.12.1"}, // Latest 3.12.x + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result, err := resolveVersionForProvider(provider, tt.input) + if err != nil { + t.Errorf("resolveVersionForProvider(%q) returned error: %v", tt.input, err) + return + } + if result != tt.expected { + t.Errorf("resolveVersionForProvider(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/src/internal/version/matcher.go b/src/internal/version/matcher.go new file mode 100644 index 0000000..60a8114 --- /dev/null +++ b/src/internal/version/matcher.go @@ -0,0 +1,141 @@ +// Package version provides utilities for resolving partial version specifications +// to full semantic versions from a list of available versions. +package version + +import ( + "fmt" + "sort" + "strconv" + "strings" +) + +// ResolvePartialVersion finds the latest version matching a partial input. +// Examples: +// - "22" matches "22.0.0", "22.15.0" → returns highest "22.x.x" +// - "14.21" matches "14.21.0", "14.21.3" → returns highest "14.21.x" +// - "22.0.0" with 3 components → returns "22.0.0" (exact match expected) +// +// Returns an error if no matching version is found. +func ResolvePartialVersion(input string, available []string) (string, error) { + input = strings.TrimPrefix(input, "v") + + // Parse input into components + inputParts := strings.Split(input, ".") + + // If it's a full semver (3 components), return as-is without validation + // The caller is responsible for checking if this exact version exists + if len(inputParts) >= 3 { + return input, nil + } + + // Find all versions that match the partial specification + var matches []string + for _, v := range available { + if matchesPartial(v, inputParts) { + matches = append(matches, v) + } + } + + if len(matches) == 0 { + return "", fmt.Errorf("no version matching %q found", input) + } + + // Sort matches by semantic version (descending) and return the highest + sortVersionsDesc(matches) + return matches[0], nil +} + +// IsPartialVersion returns true if the input has fewer than 3 components. +// Examples: +// - "22" → true (1 component) +// - "22.15" → true (2 components) +// - "22.15.0" → false (3 components) +// - "v22" → true (1 component, after stripping v prefix) +func IsPartialVersion(input string) bool { + input = strings.TrimPrefix(input, "v") + parts := strings.Split(input, ".") + return len(parts) < 3 +} + +// matchesPartial checks if a version matches the partial specification. +// The version must have the same numeric values in the positions specified. +func matchesPartial(version string, partialParts []string) bool { + version = strings.TrimPrefix(version, "v") + versionParts := strings.Split(version, ".") + + // Version must have at least as many parts as the partial + if len(versionParts) < len(partialParts) { + return false + } + + // Check each partial component matches + for i, partial := range partialParts { + // Parse both as integers for numeric comparison + partialNum, partialErr := strconv.Atoi(partial) + versionNum, versionErr := strconv.Atoi(versionParts[i]) + + if partialErr != nil || versionErr != nil { + // Fall back to string comparison if not numeric + if partial != versionParts[i] { + return false + } + } else if partialNum != versionNum { + return false + } + } + + return true +} + +// sortVersionsDesc sorts version strings by semantic version in descending order. +func sortVersionsDesc(versions []string) { + sort.Slice(versions, func(i, j int) bool { + return compareVersionStrings(versions[i], versions[j]) > 0 + }) +} + +// compareVersionStrings compares two version strings semantically. +// Returns >0 if a > b, <0 if a < b, 0 if equal. +func compareVersionStrings(a, b string) int { + aParts := parseVersionParts(a) + bParts := parseVersionParts(b) + + maxLen := len(aParts) + if len(bParts) > maxLen { + maxLen = len(bParts) + } + + for i := 0; i < maxLen; i++ { + var aVal, bVal int + if i < len(aParts) { + aVal = aParts[i] + } + if i < len(bParts) { + bVal = bParts[i] + } + + if aVal != bVal { + return aVal - bVal + } + } + + return 0 +} + +// parseVersionParts splits a version string into numeric parts. +func parseVersionParts(version string) []int { + version = strings.TrimPrefix(version, "v") + + parts := strings.FieldsFunc(version, func(c rune) bool { + return c == '.' || c == '-' + }) + + var result []int + for _, part := range parts { + if val, err := strconv.Atoi(part); err == nil { + result = append(result, val) + } + } + + return result +} diff --git a/src/internal/version/matcher_test.go b/src/internal/version/matcher_test.go new file mode 100644 index 0000000..4a7481b --- /dev/null +++ b/src/internal/version/matcher_test.go @@ -0,0 +1,277 @@ +package version + +import ( + "testing" +) + +func TestIsPartialVersion(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + // Single component (partial) + {"22", true}, + {"v22", true}, + {"3", true}, + + // Two components (partial) + {"22.15", true}, + {"v22.15", true}, + {"14.21", true}, + + // Three components (full) + {"22.15.0", false}, + {"v22.15.0", false}, + {"3.11.0", false}, + + // More than three components (still considered full) + {"22.15.0.1", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := IsPartialVersion(tt.input) + if result != tt.expected { + t.Errorf("IsPartialVersion(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestResolvePartialVersion_MajorOnly(t *testing.T) { + available := []string{ + "22.0.0", + "22.5.0", + "22.15.0", + "22.15.1", + "21.0.0", + "20.10.0", + } + + tests := []struct { + input string + expected string + }{ + {"22", "22.15.1"}, // Should find highest 22.x.x + {"v22", "22.15.1"}, // Should handle v prefix + {"21", "21.0.0"}, // Single match + {"20", "20.10.0"}, // Different major + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result, err := ResolvePartialVersion(tt.input, available) + if err != nil { + t.Errorf("ResolvePartialVersion(%q) returned error: %v", tt.input, err) + return + } + if result != tt.expected { + t.Errorf("ResolvePartialVersion(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestResolvePartialVersion_MajorMinor(t *testing.T) { + available := []string{ + "14.21.0", + "14.21.3", + "14.20.0", + "14.20.1", + "14.19.0", + } + + tests := []struct { + input string + expected string + }{ + {"14.21", "14.21.3"}, // Should find highest 14.21.x + {"14.20", "14.20.1"}, // Different minor + {"14.19", "14.19.0"}, // Single match + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result, err := ResolvePartialVersion(tt.input, available) + if err != nil { + t.Errorf("ResolvePartialVersion(%q) returned error: %v", tt.input, err) + return + } + if result != tt.expected { + t.Errorf("ResolvePartialVersion(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestResolvePartialVersion_FullVersion(t *testing.T) { + available := []string{ + "22.15.0", + "22.15.1", + } + + // Full version should be returned as-is (passthrough) + tests := []struct { + input string + expected string + }{ + {"22.15.0", "22.15.0"}, + {"v22.15.0", "22.15.0"}, // v prefix stripped + {"99.99.99", "99.99.99"}, // Not in available list, but still returned (caller validates) + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result, err := ResolvePartialVersion(tt.input, available) + if err != nil { + t.Errorf("ResolvePartialVersion(%q) returned error: %v", tt.input, err) + return + } + if result != tt.expected { + t.Errorf("ResolvePartialVersion(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestResolvePartialVersion_NoMatch(t *testing.T) { + available := []string{ + "22.0.0", + "21.0.0", + } + + tests := []struct { + input string + }{ + {"99"}, // Major not found + {"22.99"}, // Minor not found + {"20"}, // Major not found + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + _, err := ResolvePartialVersion(tt.input, available) + if err == nil { + t.Errorf("ResolvePartialVersion(%q) expected error, got nil", tt.input) + } + }) + } +} + +func TestResolvePartialVersion_EmptyList(t *testing.T) { + _, err := ResolvePartialVersion("22", []string{}) + if err == nil { + t.Error("ResolvePartialVersion with empty list expected error, got nil") + } +} + +func TestResolvePartialVersion_PythonVersions(t *testing.T) { + // Test with Python-style versions + available := []string{ + "3.9.0", + "3.9.18", + "3.10.0", + "3.10.13", + "3.11.0", + "3.11.7", + "3.12.0", + "3.12.1", + } + + tests := []struct { + input string + expected string + }{ + {"3", "3.12.1"}, // Latest 3.x.x + {"3.11", "3.11.7"}, // Latest 3.11.x + {"3.9", "3.9.18"}, // Latest 3.9.x + {"3.12", "3.12.1"}, // Latest 3.12.x + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result, err := ResolvePartialVersion(tt.input, available) + if err != nil { + t.Errorf("ResolvePartialVersion(%q) returned error: %v", tt.input, err) + return + } + if result != tt.expected { + t.Errorf("ResolvePartialVersion(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestMatchesPartial(t *testing.T) { + tests := []struct { + version string + partialParts []string + expected bool + }{ + {"22.15.0", []string{"22"}, true}, + {"22.15.0", []string{"22", "15"}, true}, + {"22.15.0", []string{"22", "15", "0"}, true}, + {"22.15.0", []string{"22", "16"}, false}, + {"22.15.0", []string{"21"}, false}, + {"v22.15.0", []string{"22"}, true}, // v prefix handled + } + + for _, tt := range tests { + t.Run(tt.version, func(t *testing.T) { + result := matchesPartial(tt.version, tt.partialParts) + if result != tt.expected { + t.Errorf("matchesPartial(%q, %v) = %v, want %v", tt.version, tt.partialParts, result, tt.expected) + } + }) + } +} + +func TestSortVersionsDesc(t *testing.T) { + versions := []string{ + "22.0.0", + "22.15.1", + "21.0.0", + "22.15.0", + "22.5.0", + } + + sortVersionsDesc(versions) + + expected := []string{ + "22.15.1", + "22.15.0", + "22.5.0", + "22.0.0", + "21.0.0", + } + + for i, v := range versions { + if v != expected[i] { + t.Errorf("sortVersionsDesc: position %d = %q, want %q", i, v, expected[i]) + } + } +} + +func TestCompareVersionStrings(t *testing.T) { + tests := []struct { + a string + b string + expected int // >0, <0, or 0 + }{ + {"22.15.1", "22.15.0", 1}, // a > b + {"22.15.0", "22.15.1", -1}, // a < b + {"22.15.0", "22.15.0", 0}, // equal + {"22.15.0", "22.5.0", 1}, // 15 > 5 + {"3.10.0", "3.9.0", 1}, // 10 > 9 + {"22.0.0", "21.99.99", 1}, // major takes precedence + } + + for _, tt := range tests { + t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) { + result := compareVersionStrings(tt.a, tt.b) + if (tt.expected > 0 && result <= 0) || (tt.expected < 0 && result >= 0) || (tt.expected == 0 && result != 0) { + t.Errorf("compareVersionStrings(%q, %q) = %d, want sign of %d", tt.a, tt.b, result, tt.expected) + } + }) + } +}