Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/integration-test-partial-versions.yml
Original file line number Diff line number Diff line change
@@ -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'
14 changes: 14 additions & 0 deletions .github/workflows/integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ==========================================================================
Expand Down
64 changes: 56 additions & 8 deletions src/cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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`,
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down
158 changes: 152 additions & 6 deletions src/cmd/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
Expand Down Expand Up @@ -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)
}
})
}
}
Loading