Skip to content

Commit cf3e9a6

Browse files
authored
feat(install): add partial version number support (#189)
1 parent f80577f commit cf3e9a6

6 files changed

Lines changed: 672 additions & 14 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Integration Tests - Partial Versions
2+
3+
on:
4+
workflow_dispatch:
5+
# Manual trigger for testing partial version resolution
6+
push:
7+
branches: [main]
8+
paths:
9+
- 'src/internal/version/**'
10+
- 'src/cmd/install.go'
11+
- '.github/workflows/integration-test-partial-versions.yml'
12+
pull_request:
13+
branches: [main]
14+
paths:
15+
- 'src/internal/version/**'
16+
- 'src/cmd/install.go'
17+
- '.github/workflows/integration-test-partial-versions.yml'
18+
19+
permissions:
20+
contents: read
21+
22+
jobs:
23+
partial-versions:
24+
name: Partial Version Resolution
25+
uses: CodingWithCalvin/.github/.github/workflows/dtvem-integration-test-partial-versions.yml@main
26+
with:
27+
node_major: '22'
28+
node_major_minor: '20.18'
29+
python_major: '3'
30+
python_major_minor: '3.12'
31+
ruby_major: '3'
32+
ruby_major_minor: '3.3'

.github/workflows/integration-test.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ jobs:
3535
version1: '3.3.6'
3636
version2: '3.4.1'
3737

38+
# ==========================================================================
39+
# Partial Version Resolution Tests
40+
# ==========================================================================
41+
partial-versions:
42+
name: Partial Version Resolution
43+
uses: CodingWithCalvin/.github/.github/workflows/dtvem-integration-test-partial-versions.yml@main
44+
with:
45+
node_major: '22'
46+
node_major_minor: '20.18'
47+
python_major: '3'
48+
python_major_minor: '3.12'
49+
ruby_major: '3'
50+
ruby_major_minor: '3.3'
51+
3852
# ==========================================================================
3953
# Migration Tests
4054
# ==========================================================================

src/cmd/install.go

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/CodingWithCalvin/dtvem.cli/src/internal/constants"
1010
"github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime"
1111
"github.com/CodingWithCalvin/dtvem.cli/src/internal/ui"
12+
"github.com/CodingWithCalvin/dtvem.cli/src/internal/version"
1213
"github.com/spf13/cobra"
1314
)
1415

@@ -21,10 +22,14 @@ var installCmd = &cobra.Command{
2122
Short: "Install runtime version(s)",
2223
Long: `Install a specific version of a runtime, or install all runtimes from .dtvem/runtimes.json.
2324
24-
Single install:
25+
Single install (exact version):
2526
dtvem install python 3.11.0
2627
dtvem install node 18.16.0
2728
29+
Single install (partial version - resolves to latest match):
30+
dtvem install node 22 # Installs latest 22.x.x (e.g., 22.15.0)
31+
dtvem install python 3.12 # Installs latest 3.12.x (e.g., 3.12.1)
32+
2833
Bulk install (reads .dtvem/runtimes.json):
2934
dtvem install
3035
dtvem install --yes # Skip confirmation prompt`,
@@ -51,8 +56,8 @@ func init() {
5156
}
5257

5358
// installSingle installs a single runtime/version
54-
func installSingle(runtimeName, version string) {
55-
ui.Debug("Installing single runtime: %s version %s", runtimeName, version)
59+
func installSingle(runtimeName, versionInput string) {
60+
ui.Debug("Installing single runtime: %s version %s", runtimeName, versionInput)
5661

5762
provider, err := runtime.Get(runtimeName)
5863
if err != nil {
@@ -64,20 +69,33 @@ func installSingle(runtimeName, version string) {
6469

6570
ui.Debug("Using provider: %s (%s)", provider.Name(), provider.DisplayName())
6671

67-
if err := provider.Install(version); err != nil {
72+
// Resolve partial version to full version if needed
73+
resolvedVersion, err := resolveVersionForProvider(provider, versionInput)
74+
if err != nil {
75+
ui.Debug("Version resolution failed: %v", err)
76+
ui.Error("%v", err)
77+
os.Exit(1)
78+
}
79+
80+
// Inform user if version was resolved from partial input
81+
if resolvedVersion != versionInput {
82+
ui.Info("Resolved %s to %s", versionInput, resolvedVersion)
83+
}
84+
85+
if err := provider.Install(resolvedVersion); err != nil {
6886
ui.Debug("Installation failed: %v", err)
6987
ui.Error("%v", err)
7088
os.Exit(1)
7189
}
7290

73-
ui.Success("Successfully installed %s %s", provider.DisplayName(), version)
91+
ui.Success("Successfully installed %s %s", provider.DisplayName(), resolvedVersion)
7492

7593
// Auto-set global version if no global version is currently configured
76-
autoSetGlobalIfNeeded(provider, version)
94+
autoSetGlobalIfNeeded(provider, resolvedVersion)
7795
}
7896

7997
// autoSetGlobalIfNeeded sets the installed version as global if no global version exists
80-
func autoSetGlobalIfNeeded(provider runtime.Provider, version string) {
98+
func autoSetGlobalIfNeeded(provider runtime.Provider, ver string) {
8199
currentGlobal, err := provider.GlobalVersion()
82100
if err != nil || currentGlobal != "" {
83101
// Either an error occurred or a global version is already set
@@ -86,7 +104,7 @@ func autoSetGlobalIfNeeded(provider runtime.Provider, version string) {
86104
}
87105

88106
// No global version configured, auto-set it
89-
if err := provider.SetGlobalVersion(version); err != nil {
107+
if err := provider.SetGlobalVersion(ver); err != nil {
90108
ui.Debug("Failed to auto-set global version: %v", err)
91109
ui.Warning("Could not auto-set global version: %v", err)
92110
return
@@ -95,6 +113,36 @@ func autoSetGlobalIfNeeded(provider runtime.Provider, version string) {
95113
ui.Info("Set as global version (first install)")
96114
}
97115

116+
// resolveVersionForProvider resolves a partial version input to a full version.
117+
// If the input is already a full version (3 components), it's returned as-is.
118+
// For partial versions (1-2 components), it finds the highest matching version.
119+
func resolveVersionForProvider(provider runtime.Provider, input string) (string, error) {
120+
// If it's already a full version, return as-is
121+
if !version.IsPartialVersion(input) {
122+
return strings.TrimPrefix(input, "v"), nil
123+
}
124+
125+
// Get available versions from the provider
126+
available, err := provider.ListAvailable()
127+
if err != nil {
128+
return "", fmt.Errorf("failed to fetch available versions: %w", err)
129+
}
130+
131+
// Extract version strings
132+
versionStrings := make([]string, len(available))
133+
for i, av := range available {
134+
versionStrings[i] = av.Version.Raw
135+
}
136+
137+
// Resolve the partial version
138+
resolved, err := version.ResolvePartialVersion(input, versionStrings)
139+
if err != nil {
140+
return "", fmt.Errorf("no %s version matching %q found", provider.DisplayName(), input)
141+
}
142+
143+
return resolved, nil
144+
}
145+
98146
// installBulk installs all runtimes from .dtvem/runtimes.json
99147
// installTask represents a runtime version to be installed
100148
type installTask struct {

src/cmd/install_test.go

Lines changed: 152 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import (
88

99
// mockProvider implements runtime.Provider for testing
1010
type mockProvider struct {
11-
name string
12-
displayName string
13-
globalVersion string
14-
globalSetError error
15-
setGlobalCalls []string
11+
name string
12+
displayName string
13+
globalVersion string
14+
globalSetError error
15+
setGlobalCalls []string
16+
availableVersions []runtime.AvailableVersion
17+
listAvailableErr error
1618
}
1719

1820
func (m *mockProvider) Name() string { return m.name }
@@ -27,7 +29,7 @@ func (m *mockProvider) ListInstalled() ([]runtime.InstalledVersion, error) {
2729
return nil, nil
2830
}
2931
func (m *mockProvider) ListAvailable() ([]runtime.AvailableVersion, error) {
30-
return nil, nil
32+
return m.availableVersions, m.listAvailableErr
3133
}
3234
func (m *mockProvider) InstallPath(version string) (string, error) { return "", nil }
3335
func (m *mockProvider) LocalVersion() (string, error) { return "", nil }
@@ -114,3 +116,147 @@ func TestAutoSetGlobalIfNeeded_MultipleInstalls(t *testing.T) {
114116
t.Errorf("Expected second install to not change global, got %d calls total", len(provider.setGlobalCalls))
115117
}
116118
}
119+
120+
// Helper to create AvailableVersion from a version string
121+
func makeAvailableVersion(v string) runtime.AvailableVersion {
122+
return runtime.AvailableVersion{
123+
Version: runtime.NewVersion(v),
124+
}
125+
}
126+
127+
func TestResolveVersionForProvider_FullVersion(t *testing.T) {
128+
provider := &mockProvider{
129+
name: "node",
130+
displayName: "Node.js",
131+
availableVersions: []runtime.AvailableVersion{
132+
makeAvailableVersion("22.15.0"),
133+
makeAvailableVersion("22.0.0"),
134+
},
135+
}
136+
137+
// Full version should pass through unchanged
138+
result, err := resolveVersionForProvider(provider, "22.15.0")
139+
if err != nil {
140+
t.Errorf("resolveVersionForProvider returned error: %v", err)
141+
}
142+
if result != "22.15.0" {
143+
t.Errorf("Expected 22.15.0, got %q", result)
144+
}
145+
}
146+
147+
func TestResolveVersionForProvider_FullVersionWithVPrefix(t *testing.T) {
148+
provider := &mockProvider{
149+
name: "node",
150+
displayName: "Node.js",
151+
availableVersions: []runtime.AvailableVersion{
152+
makeAvailableVersion("22.15.0"),
153+
},
154+
}
155+
156+
// Full version with v prefix should have prefix stripped
157+
result, err := resolveVersionForProvider(provider, "v22.15.0")
158+
if err != nil {
159+
t.Errorf("resolveVersionForProvider returned error: %v", err)
160+
}
161+
if result != "22.15.0" {
162+
t.Errorf("Expected 22.15.0, got %q", result)
163+
}
164+
}
165+
166+
func TestResolveVersionForProvider_MajorOnly(t *testing.T) {
167+
provider := &mockProvider{
168+
name: "node",
169+
displayName: "Node.js",
170+
availableVersions: []runtime.AvailableVersion{
171+
makeAvailableVersion("22.0.0"),
172+
makeAvailableVersion("22.5.0"),
173+
makeAvailableVersion("22.15.0"),
174+
makeAvailableVersion("22.15.1"),
175+
makeAvailableVersion("21.0.0"),
176+
},
177+
}
178+
179+
// Major-only should resolve to highest 22.x.x
180+
result, err := resolveVersionForProvider(provider, "22")
181+
if err != nil {
182+
t.Errorf("resolveVersionForProvider returned error: %v", err)
183+
}
184+
if result != "22.15.1" {
185+
t.Errorf("Expected 22.15.1 (highest 22.x.x), got %q", result)
186+
}
187+
}
188+
189+
func TestResolveVersionForProvider_MajorMinor(t *testing.T) {
190+
provider := &mockProvider{
191+
name: "node",
192+
displayName: "Node.js",
193+
availableVersions: []runtime.AvailableVersion{
194+
makeAvailableVersion("14.21.0"),
195+
makeAvailableVersion("14.21.3"),
196+
makeAvailableVersion("14.20.0"),
197+
makeAvailableVersion("14.20.1"),
198+
},
199+
}
200+
201+
// Major.minor should resolve to highest 14.21.x
202+
result, err := resolveVersionForProvider(provider, "14.21")
203+
if err != nil {
204+
t.Errorf("resolveVersionForProvider returned error: %v", err)
205+
}
206+
if result != "14.21.3" {
207+
t.Errorf("Expected 14.21.3 (highest 14.21.x), got %q", result)
208+
}
209+
}
210+
211+
func TestResolveVersionForProvider_NoMatch(t *testing.T) {
212+
provider := &mockProvider{
213+
name: "node",
214+
displayName: "Node.js",
215+
availableVersions: []runtime.AvailableVersion{
216+
makeAvailableVersion("22.0.0"),
217+
makeAvailableVersion("21.0.0"),
218+
},
219+
}
220+
221+
// No matching version should return error
222+
_, err := resolveVersionForProvider(provider, "99")
223+
if err == nil {
224+
t.Error("Expected error for non-matching version, got nil")
225+
}
226+
}
227+
228+
func TestResolveVersionForProvider_PythonVersions(t *testing.T) {
229+
provider := &mockProvider{
230+
name: "python",
231+
displayName: "Python",
232+
availableVersions: []runtime.AvailableVersion{
233+
makeAvailableVersion("3.9.18"),
234+
makeAvailableVersion("3.10.13"),
235+
makeAvailableVersion("3.11.7"),
236+
makeAvailableVersion("3.12.0"),
237+
makeAvailableVersion("3.12.1"),
238+
},
239+
}
240+
241+
tests := []struct {
242+
input string
243+
expected string
244+
}{
245+
{"3", "3.12.1"}, // Latest 3.x.x
246+
{"3.11", "3.11.7"}, // Latest 3.11.x
247+
{"3.12", "3.12.1"}, // Latest 3.12.x
248+
}
249+
250+
for _, tt := range tests {
251+
t.Run(tt.input, func(t *testing.T) {
252+
result, err := resolveVersionForProvider(provider, tt.input)
253+
if err != nil {
254+
t.Errorf("resolveVersionForProvider(%q) returned error: %v", tt.input, err)
255+
return
256+
}
257+
if result != tt.expected {
258+
t.Errorf("resolveVersionForProvider(%q) = %q, want %q", tt.input, result, tt.expected)
259+
}
260+
})
261+
}
262+
}

0 commit comments

Comments
 (0)