diff --git a/CLAUDE.md b/CLAUDE.md index 9cdfa8a..1e46721 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -223,9 +223,11 @@ The shim mappings are automatically registered via `Shims()`. ## Version Resolution **Priority order:** -1. **Local**: Walk up from `pwd` looking for `.dtvem/runtimes.json` (stops at filesystem root) +1. **Local**: Walk up from `pwd` to the filesystem root; the nearest directory that sets a version wins. Within one directory, `.dtvem/runtimes.json` comes first, then the runtime's ecosystem version files (`versionFiles` in `internal/config/versionfile.go`; Node.js: `.node-version`, then `.nvmrc`) 2. **Global**: `~/.dtvem/config/runtimes.json` -3. **Error**: No version configured +3. **Error**: No version configured (`config.ErrNoVersion`; the shim falls back to the system PATH) + +**Ecosystem version files** hold an exact (`22.12.0`) or partial (`22`, `22.12`) version, with an optional leading `v`; blank lines and `#` comments are skipped. A partial version resolves to the highest installed match. Anything else (e.g. `.nvmrc` aliases like `lts/*`), or an empty file, is a `*config.VersionFileError`: the shim reports it instead of falling back to another version. `config.Resolve` returns the version and the file that set it (`dtvem current` shows it). **Config format** (both local and global): ```json diff --git a/README.md b/README.md index afa3dd4..c3d55a5 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Managing multiple versions of Python, Node.js, Ruby, and other runtimes across d ✅ **Migration Tool**: Import existing installations from nvm, pyenv, etc. -✅ **Per-Directory Versions**: `.dtvem/runtimes.json` for project-specific versions +✅ **Per-Directory Versions**: `.dtvem/runtimes.json` for project-specific versions, plus `.node-version` and `.nvmrc` for Node.js ✅ **No Shell Hooks**: Works in cmd.exe, PowerShell, bash, zsh, fish, etc. diff --git a/src/cmd/current.go b/src/cmd/current.go index 6ac8ea8..2b2c536 100644 --- a/src/cmd/current.go +++ b/src/cmd/current.go @@ -1,8 +1,10 @@ package cmd import ( + "errors" "fmt" + "github.com/CodingWithCalvin/dtvem.cli/src/internal/config" "github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime" "github.com/CodingWithCalvin/dtvem.cli/src/internal/tui" "github.com/CodingWithCalvin/dtvem.cli/src/internal/ui" @@ -18,6 +20,7 @@ var ( type runtimeStatus struct { provider runtime.Provider version string + source string // file that set the version installed bool } @@ -26,6 +29,9 @@ var currentCmd = &cobra.Command{ Short: "Show the currently active version(s)", Long: `Show the currently active version for a specific runtime or all runtimes. The active version is determined by checking local settings first, then global settings. +Local settings are read from .dtvem/runtimes.json and, for Node.js, from +.node-version and .nvmrc, in the current directory or the nearest parent that +has one. The Source column shows which file set each version. Examples: dtvem current # Show all active versions @@ -54,35 +60,44 @@ func showAllVersions(yes, noInstall bool) { // Collect status for all configured runtimes var configured []runtimeStatus + failed := false for _, provider := range providers { - version, err := provider.CurrentVersion() - if err != nil { + res, err := config.Resolve(provider.Name()) + if errors.Is(err, config.ErrNoVersion) { // Not configured - skip it continue } - installed, _ := provider.IsInstalled(version) + if err != nil { + ui.Error("%s: %v", provider.DisplayName(), err) + failed = true + continue + } + installed, _ := provider.IsInstalled(res.Version) configured = append(configured, runtimeStatus{ provider: provider, - version: version, + version: res.Version, + source: res.Source, installed: installed, }) } if len(configured) == 0 { - ui.Info("No runtimes configured") + if !failed { + ui.Info("No runtimes configured") + } return } // Display all configured versions - table := tui.NewTable("Runtime", "Version", "Status") + table := tui.NewTable("Runtime", "Version", "Status", "Source") table.SetTitle("Active Versions") var missing []runtimeStatus for _, rs := range configured { if rs.installed { - table.AddActiveRow(rs.provider.DisplayName(), rs.version, tui.CheckMark+" installed") + table.AddActiveRow(rs.provider.DisplayName(), rs.version, tui.CheckMark+" installed", rs.source) } else { - table.AddRow(rs.provider.DisplayName(), rs.version, tui.CrossMark+" not installed") + table.AddRow(rs.provider.DisplayName(), rs.version, tui.CrossMark+" not installed", rs.source) missing = append(missing, rs) } } @@ -100,12 +115,7 @@ func showAllVersions(yes, noInstall bool) { shouldInstall := yes || ui.PromptInstallMissing(missing) if shouldInstall { for _, rs := range missing { - ui.Info("Installing %s %s...", rs.provider.DisplayName(), rs.version) - if err := rs.provider.Install(rs.version); err != nil { - ui.Error("Failed to install %s %s: %v", rs.provider.DisplayName(), rs.version, err) - } else { - ui.Success("%s %s installed successfully", rs.provider.DisplayName(), rs.version) - } + installVersion(rs.provider, rs.version) } } } @@ -122,23 +132,24 @@ func showSingleVersion(runtimeName string, yes, noInstall bool) { return } - version, err := provider.CurrentVersion() + res, err := config.Resolve(provider.Name()) if err != nil { ui.Error("%v", err) return } + version := res.Version installed, _ := provider.IsInstalled(version) - table := tui.NewTable("Runtime", "Version", "Status") + table := tui.NewTable("Runtime", "Version", "Status", "Source") if installed { - table.AddActiveRow(provider.DisplayName(), version, tui.CheckMark+" installed") + table.AddActiveRow(provider.DisplayName(), version, tui.CheckMark+" installed", res.Source) fmt.Println(table.Render()) return } // Not installed - show with warning and prompt - table.AddRow(provider.DisplayName(), version, tui.CrossMark+" not installed") + table.AddRow(provider.DisplayName(), version, tui.CrossMark+" not installed", res.Source) fmt.Println(table.Render()) // Skip install prompts if --no-install flag is set @@ -149,12 +160,26 @@ func showSingleVersion(runtimeName string, yes, noInstall bool) { fmt.Println() shouldInstall := yes || ui.PromptInstall(provider.DisplayName(), version) if shouldInstall { - if err := provider.Install(version); err != nil { - ui.Error("Failed to install %s %s: %v", provider.DisplayName(), version, err) - return - } - ui.Success("%s %s installed successfully", provider.DisplayName(), version) + installVersion(provider, version) + } +} + +// installVersion installs a configured version that is not installed yet. +// A version file can ask for a partial version ("22"), which is resolved to +// the latest available match first. +func installVersion(provider runtime.Provider, version string) { + resolved, err := resolveVersionForProvider(provider, version) + if err != nil { + ui.Error("Failed to install %s %s: %v", provider.DisplayName(), version, err) + return + } + + ui.Info("Installing %s %s...", provider.DisplayName(), resolved) + if err := provider.Install(resolved); err != nil { + ui.Error("Failed to install %s %s: %v", provider.DisplayName(), resolved, err) + return } + ui.Success("%s %s installed successfully", provider.DisplayName(), resolved) } func init() { diff --git a/src/cmd/current_test.go b/src/cmd/current_test.go new file mode 100644 index 0000000..2681c62 --- /dev/null +++ b/src/cmd/current_test.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "slices" + "testing" + + "github.com/CodingWithCalvin/dtvem.cli/src/internal/runtime" +) + +func TestInstallVersion(t *testing.T) { + available := []runtime.AvailableVersion{ + makeAvailableVersion("22.15.1"), + makeAvailableVersion("22.15.0"), + makeAvailableVersion("22.0.0"), + makeAvailableVersion("20.18.0"), + } + + tests := []struct { + name string + version string + want []string + }{ + {name: "exact version installs as is", version: "22.0.0", want: []string{"22.0.0"}}, + {name: "partial version installs latest match", version: "22", want: []string{"22.15.1"}}, + {name: "partial major.minor installs latest patch", version: "22.15", want: []string{"22.15.1"}}, + {name: "partial with no match installs nothing", version: "21", want: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + provider := &mockProvider{ + name: "node", + displayName: "Node.js", + availableVersions: available, + } + + installVersion(provider, tt.version) + + if !slices.Equal(provider.installCalls, tt.want) { + t.Errorf("installVersion(%q) installed %v, want %v", tt.version, provider.installCalls, tt.want) + } + }) + } +} diff --git a/src/cmd/install_test.go b/src/cmd/install_test.go index b9fb367..ac490de 100644 --- a/src/cmd/install_test.go +++ b/src/cmd/install_test.go @@ -17,6 +17,7 @@ type mockProvider struct { setGlobalCalls []string availableVersions []runtime.AvailableVersion listAvailableErr error + installCalls []string } func (m *mockProvider) Name() string { return m.name } @@ -25,8 +26,11 @@ func (m *mockProvider) Shims() []string { func (m *mockProvider) ExecutablePath(version string) (string, error) { return "", nil } func (m *mockProvider) IsInstalled(version string) (bool, error) { return false, nil } func (m *mockProvider) ShouldReshimAfter(shimName string, args []string) bool { return false } -func (m *mockProvider) Install(version string) error { return nil } -func (m *mockProvider) Uninstall(version string) error { return nil } +func (m *mockProvider) Install(version string) error { + m.installCalls = append(m.installCalls, version) + return nil +} +func (m *mockProvider) Uninstall(version string) error { return nil } func (m *mockProvider) ListInstalled() ([]runtime.InstalledVersion, error) { return nil, nil } diff --git a/src/cmd/shim/main.go b/src/cmd/shim/main.go index d66efee..36d7df1 100644 --- a/src/cmd/shim/main.go +++ b/src/cmd/shim/main.go @@ -69,13 +69,21 @@ func runShim() error { } // Resolve which version to use - version, err := config.ResolveVersion(runtimeName) - if err != nil { + resolution, err := config.Resolve(runtimeName) + if errors.Is(err, config.ErrNoVersion) { ui.Debug("Version resolution failed: %v", err) // No dtvem version configured - try to fallback to system PATH return handleNoConfiguredVersion(shimName, runtimeName, provider) } - ui.Debug("Resolved version: %s", version) + if err != nil { + // A version file could not be read or asks for something dtvem + // cannot use. Running another version in its place would hide + // that the project's pin is not being honored. + ui.Error("%v", err) + return fmt.Errorf("could not resolve the %s version", provider.DisplayName()) + } + version := resolution.Version + ui.Debug("Resolved version: %s (from %s)", version, resolution.Source) // If this is a secondary executable (e.g. uv mapped to python) and the // shim-map cache knows which versions provide it, verify the active @@ -100,6 +108,7 @@ func runShim() error { if !installed { ui.Debug("Version %s is not installed", version) ui.Error("%s %s is configured but not installed", provider.DisplayName(), version) + ui.Info("Set by: %s", resolution.Source) ui.Info("To install, run: dtvem install %s %s", runtimeName, version) return fmt.Errorf("version not installed") } diff --git a/src/cmd/where.go b/src/cmd/where.go index a1e0236..421cf3a 100644 --- a/src/cmd/where.go +++ b/src/cmd/where.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" @@ -39,6 +40,10 @@ Examples: // If version not specified, use current version if len(args) == 1 { version, err = provider.CurrentVersion() + if err != nil && !errors.Is(err, config.ErrNoVersion) { + ui.Error("%v", err) + return + } if err != nil { ui.Error("No version configured for %s", runtimeName) ui.Info("Set a version with: dtvem global %s ", runtimeName) diff --git a/src/cmd/which.go b/src/cmd/which.go index 94febe3..6fc1946 100644 --- a/src/cmd/which.go +++ b/src/cmd/which.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" "path/filepath" @@ -65,6 +66,10 @@ Examples: // Get the current version version, err := provider.CurrentVersion() + if err != nil && !errors.Is(err, config.ErrNoVersion) { + ui.Error("%v", err) + return + } if err != nil { ui.Error("No version configured for %s", runtimeName) ui.Info("Set a version with: dtvem global %s ", runtimeName) diff --git a/src/internal/config/version.go b/src/internal/config/version.go index 5050ad5..30f466a 100644 --- a/src/internal/config/version.go +++ b/src/internal/config/version.go @@ -2,7 +2,9 @@ package config import ( "encoding/json" + "errors" "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -15,58 +17,90 @@ type RuntimesConfig map[string]string // SchemaURL is the URL to the runtimes.json schema const SchemaURL = "https://raw.githubusercontent.com/CodingWithCalvin/dtvem.cli/main/schemas/runtimes.schema.json" -// ResolveVersion finds the version to use for a runtime -// Priority: local dtvem.config.json file (walking up directory tree) > global config -func ResolveVersion(runtimeName string) (string, error) { - // First, try to find local version - localVersion, err := findLocalVersion(runtimeName) - if err == nil && localVersion != "" { - return localVersion, nil +// ErrNoVersion reports that no version is configured for a runtime, either +// for the working directory or globally. +var ErrNoVersion = errors.New("no version configured") + +// Resolution is the version a runtime resolves to and where it came from. +type Resolution struct { + // Version is the version to use. When a version file asks for a partial + // version ("22"), it is the highest installed match, or the partial + // version itself if nothing installed matches. + Version string + // Source is the path of the file that set the version. + Source string +} + +// Resolve finds the version to use for a runtime and the file that sets it. +// A version set for the working directory or one of its parents (see +// findLocal) wins over the global one. The error wraps ErrNoVersion when +// neither sets a version, and is a *VersionFileError when the nearest +// version file asks for something dtvem cannot use. Any other error means +// the working directory or a version file could not be read. +func Resolve(runtimeName string) (Resolution, error) { + if res, found, err := findLocal(runtimeName); err != nil || found { + return res, err } - // Fall back to global version - globalVersion, err := GlobalVersion(runtimeName) - if err == nil && globalVersion != "" { - return globalVersion, nil + if globalVersion, err := GlobalVersion(runtimeName); err == nil && globalVersion != "" { + return Resolution{Version: globalVersion, Source: GlobalConfigPath()}, nil } - return "", fmt.Errorf("no version configured for %s", runtimeName) + return Resolution{}, fmt.Errorf("%w for %s", ErrNoVersion, runtimeName) +} + +// ResolveVersion finds the version to use for a runtime. See Resolve. +func ResolveVersion(runtimeName string) (string, error) { + res, err := Resolve(runtimeName) + return res.Version, err } -// findLocalVersion walks up the directory tree looking for .dtvem/runtimes.json file -// Stops at filesystem root -func findLocalVersion(runtimeName string) (string, error) { - // Start from current working directory +// findLocal walks up from the working directory to the filesystem root and +// returns the version set by the nearest directory that sets one for +// runtimeName. The bool is false when no directory does. +func findLocal(runtimeName string) (Resolution, bool, error) { currentDir, err := os.Getwd() if err != nil { - return "", err + return Resolution{}, false, fmt.Errorf("could not determine the working directory: %w", err) } - // Walk up the directory tree for { - configDir := filepath.Join(currentDir, LocalConfigDirName) - versionFile := filepath.Join(configDir, RuntimesFileName) - - // Check if .dtvem/runtimes.json exists - if _, err := os.Stat(versionFile); err == nil { - version, err := readVersionFile(versionFile, runtimeName) - if err == nil && version != "" { - return version, nil - } + if res, found, err := resolveInDir(currentDir, runtimeName); err != nil || found { + return res, found, err } - // Move up one directory parent := filepath.Dir(currentDir) - - // Stop if we've reached the filesystem root if parent == currentDir { - break + return Resolution{}, false, nil } - currentDir = parent } +} + +// resolveInDir returns the version dir sets for runtimeName, if any. It +// checks .dtvem/runtimes.json first, then the runtime's version files (see +// versionFiles). A runtimes.json that is unreadable or lacks the runtime is +// passed over; a version file that exists but cannot be used is an error, +// so a project's pin is never silently replaced by another version. +func resolveInDir(dir, runtimeName string) (Resolution, bool, error) { + runtimesFile := filepath.Join(dir, LocalConfigDirName, RuntimesFileName) + if version, err := readVersionFile(runtimesFile, runtimeName); err == nil && version != "" { + return Resolution{Version: version, Source: runtimesFile}, true, nil + } + + for _, name := range versionFiles[runtimeName] { + path := filepath.Join(dir, name) + spec, err := readVersionSpec(path) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return Resolution{}, false, err + } + return Resolution{Version: resolveSpec(runtimeName, spec), Source: path}, true, nil + } - return "", fmt.Errorf("no local version file found") + return Resolution{}, false, nil } // readVersionFile reads a JSON config file and extracts the version for a runtime @@ -138,9 +172,17 @@ func FindLocalRuntimesFile() (string, error) { return "", fmt.Errorf("no .dtvem/runtimes.json file found") } -// LocalVersion reads the local version for a runtime by walking up the directory tree +// LocalVersion reads the local version for a runtime by walking up the +// directory tree. See findLocal. func LocalVersion(runtimeName string) (string, error) { - return findLocalVersion(runtimeName) + res, found, err := findLocal(runtimeName) + if err != nil { + return "", err + } + if !found { + return "", fmt.Errorf("no local version file found") + } + return res.Version, nil } // GlobalVersion reads the global version for a runtime. diff --git a/src/internal/config/versionfile.go b/src/internal/config/versionfile.go new file mode 100644 index 0000000..0be01bc --- /dev/null +++ b/src/internal/config/versionfile.go @@ -0,0 +1,119 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/CodingWithCalvin/dtvem.cli/src/internal/version" +) + +// versionFiles lists, for each runtime, the files other tools in its +// ecosystem use to pin a project's version. Within one directory they are +// consulted after .dtvem/runtimes.json, in the order listed. +var versionFiles = map[string][]string{ + "node": {".node-version", ".nvmrc"}, +} + +// VersionFileError reports a version file whose contents dtvem cannot use. +type VersionFileError struct { + Path string // the version file + Value string // the version it asks for; "" when it names none +} + +func (e *VersionFileError) Error() string { + if e.Value == "" { + return fmt.Sprintf("%s does not contain a version", e.Path) + } + return fmt.Sprintf("%s asks for %q: only version numbers (x, x.y or x.y.z) are supported, not aliases", e.Path, e.Value) +} + +// readVersionSpec reads a version file such as .node-version or .nvmrc and +// returns the version it asks for. The error satisfies +// errors.Is(err, fs.ErrNotExist) when the file does not exist. +func readVersionSpec(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("could not read %s: %w", path, err) + } + return parseVersionSpec(path, string(data)) +} + +// parseVersionSpec extracts the version from the contents of the version +// file at path. It skips blank lines and # comments, takes the first +// remaining line, and drops a leading "v". The result is an exact +// ("22.12.0") or partial ("22", "22.12") version number; anything else, +// such as an .nvmrc alias like "lts/*", is a *VersionFileError. +func parseVersionSpec(path, content string) (string, error) { + for _, line := range strings.Split(content, "\n") { + if i := strings.IndexByte(line, '#'); i >= 0 { + line = line[:i] + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + + spec := strings.TrimPrefix(line, "v") + if !isVersionNumber(spec) { + return "", &VersionFileError{Path: path, Value: line} + } + return spec, nil + } + + return "", &VersionFileError{Path: path} +} + +// isVersionNumber reports whether s is one to three dot-separated runs of +// digits. +func isVersionNumber(s string) bool { + parts := strings.Split(s, ".") + if len(parts) > 3 { + return false + } + for _, part := range parts { + if part == "" { + return false + } + for _, c := range part { + if c < '0' || c > '9' { + return false + } + } + } + return true +} + +// resolveSpec returns the version a version file's spec selects: the spec +// itself when it is exact, or the highest installed version it matches when +// it is partial. A partial spec that matches nothing installed is returned +// unchanged, so callers report it as not installed. +func resolveSpec(runtimeName, spec string) string { + if !version.IsPartialVersion(spec) { + return spec + } + + resolved, err := version.ResolvePartialVersion(spec, installedVersions(runtimeName)) + if err != nil { + return spec + } + return resolved +} + +// installedVersions returns the names of the installed versions of a +// runtime, or nil if none are installed. +func installedVersions(runtimeName string) []string { + entries, err := os.ReadDir(filepath.Join(DefaultPaths().Versions, runtimeName)) + if err != nil { + return nil + } + + var versions []string + for _, entry := range entries { + if entry.IsDir() { + versions = append(versions, entry.Name()) + } + } + return versions +} diff --git a/src/internal/config/versionfile_test.go b/src/internal/config/versionfile_test.go new file mode 100644 index 0000000..688bbf6 --- /dev/null +++ b/src/internal/config/versionfile_test.go @@ -0,0 +1,453 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestParseVersionSpec(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + {name: "exact version", content: "22.12.0", want: "22.12.0"}, + {name: "exact version with v prefix", content: "v22.12.0", want: "22.12.0"}, + {name: "major only", content: "22", want: "22"}, + {name: "major and minor", content: "22.12", want: "22.12"}, + {name: "partial with v prefix", content: "v22", want: "22"}, + {name: "trailing newline", content: "22.12.0\n", want: "22.12.0"}, + {name: "CRLF line endings", content: "22.12.0\r\n", want: "22.12.0"}, + {name: "surrounding whitespace", content: " \t22.12.0 \n", want: "22.12.0"}, + {name: "leading blank lines", content: "\n\n \n22\n", want: "22"}, + {name: "comment line before version", content: "# pinned for CI\n22.12.0\n", want: "22.12.0"}, + {name: "inline comment", content: "22 # current LTS\n", want: "22"}, + {name: "first version line wins", content: "22.12.0\n20.0.0\n", want: "22.12.0"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseVersionSpec(".node-version", tt.content) + if err != nil { + t.Fatalf("parseVersionSpec(%q) unexpected error: %v", tt.content, err) + } + if got != tt.want { + t.Errorf("parseVersionSpec(%q) = %q, want %q", tt.content, got, tt.want) + } + }) + } +} + +func TestParseVersionSpec_Unsupported(t *testing.T) { + tests := []struct { + name string + content string + wantValue string + }{ + {name: "empty file", content: "", wantValue: ""}, + {name: "whitespace only", content: " \n\t\n", wantValue: ""}, + {name: "comment only", content: "# no version here\n", wantValue: ""}, + {name: "lts alias", content: "lts/*\n", wantValue: "lts/*"}, + {name: "named lts alias", content: "lts/jod", wantValue: "lts/jod"}, + {name: "node alias", content: "node", wantValue: "node"}, + {name: "wildcard minor", content: "22.x", wantValue: "22.x"}, + {name: "too many components", content: "22.12.0.1", wantValue: "22.12.0.1"}, + {name: "empty component", content: "22..1", wantValue: "22..1"}, + {name: "bare v", content: "v", wantValue: "v"}, + {name: "prerelease", content: "23.0.0-rc.1", wantValue: "23.0.0-rc.1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := parseVersionSpec(".nvmrc", tt.content) + + var fileErr *VersionFileError + if !errors.As(err, &fileErr) { + t.Fatalf("parseVersionSpec(%q) error = %v, want *VersionFileError", tt.content, err) + } + if fileErr.Path != ".nvmrc" { + t.Errorf("VersionFileError.Path = %q, want %q", fileErr.Path, ".nvmrc") + } + if fileErr.Value != tt.wantValue { + t.Errorf("VersionFileError.Value = %q, want %q", fileErr.Value, tt.wantValue) + } + }) + } +} + +func TestVersionFileError_Message(t *testing.T) { + alias := (&VersionFileError{Path: ".nvmrc", Value: "lts/*"}).Error() + if want := `.nvmrc asks for "lts/*": only version numbers (x, x.y or x.y.z) are supported, not aliases`; alias != want { + t.Errorf("Error() = %q, want %q", alias, want) + } + + empty := (&VersionFileError{Path: ".node-version"}).Error() + if want := ".node-version does not contain a version"; empty != want { + t.Errorf("Error() = %q, want %q", empty, want) + } +} + +// versionFixture is a dtvem root and a project directory to resolve from. +type versionFixture struct { + t *testing.T + root string // DTVEM_ROOT + project string // a directory outside the dtvem root +} + +// newVersionFixture points DTVEM_ROOT at a fresh directory and creates an +// empty project directory beside it. The working directory is restored when +// the test ends. +func newVersionFixture(t *testing.T) *versionFixture { + t.Helper() + + tmp := t.TempDir() + f := &versionFixture{ + t: t, + root: filepath.Join(tmp, "dtvem"), + project: filepath.Join(tmp, "project"), + } + if err := os.MkdirAll(f.project, 0755); err != nil { + t.Fatalf("Failed to create project directory: %v", err) + } + + t.Setenv("DTVEM_ROOT", f.root) + ResetPathsCache() + t.Cleanup(ResetPathsCache) + + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(originalDir) }) + + return f +} + +// write creates a file under the project directory. +func (f *versionFixture) write(rel, content string) string { + f.t.Helper() + path := filepath.Join(f.project, rel) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + f.t.Fatalf("Failed to create directory for %s: %v", rel, err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + f.t.Fatalf("Failed to write %s: %v", rel, err) + } + return path +} + +// install creates empty installed-version directories for a runtime. +func (f *versionFixture) install(runtimeName string, versions ...string) { + f.t.Helper() + for _, v := range versions { + if err := os.MkdirAll(filepath.Join(f.root, "versions", runtimeName, v), 0755); err != nil { + f.t.Fatalf("Failed to create installed version %s: %v", v, err) + } + } +} + +// chdir changes into a directory under the project, creating it if needed. +func (f *versionFixture) chdir(rel string) { + f.t.Helper() + dir := filepath.Join(f.project, rel) + if err := os.MkdirAll(dir, 0755); err != nil { + f.t.Fatalf("Failed to create %s: %v", rel, err) + } + if err := os.Chdir(dir); err != nil { + f.t.Fatalf("Failed to change directory: %v", err) + } +} + +// assertResolution checks a Resolution against the expected version and +// source file, comparing paths with symlinks resolved (macOS reports +// /var/... temp directories as /private/var/...). +func assertResolution(t *testing.T, got Resolution, wantVersion, wantSource string) { + t.Helper() + if got.Version != wantVersion { + t.Errorf("Version = %q, want %q", got.Version, wantVersion) + } + gotSource, err := filepath.EvalSymlinks(got.Source) + if err != nil { + t.Fatalf("Source %q does not exist: %v", got.Source, err) + } + wantSource, err = filepath.EvalSymlinks(wantSource) + if err != nil { + t.Fatalf("expected source %q does not exist: %v", wantSource, err) + } + if gotSource != wantSource { + t.Errorf("Source = %q, want %q", gotSource, wantSource) + } +} + +func TestResolve_NodeVersionFile(t *testing.T) { + f := newVersionFixture(t) + source := f.write(".node-version", "22.12.0\n") + f.chdir(".") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "22.12.0", source) +} + +func TestResolve_NvmrcFile(t *testing.T) { + f := newVersionFixture(t) + source := f.write(".nvmrc", "v20.11.1\n") + f.chdir(".") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "20.11.1", source) +} + +func TestResolve_SubdirectoryInheritsVersionFile(t *testing.T) { + f := newVersionFixture(t) + source := f.write(".node-version", "22.12.0") + f.chdir(filepath.Join("packages", "app", "src")) + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "22.12.0", source) +} + +func TestResolve_PrecedenceWithinDirectory(t *testing.T) { + t.Run("runtimes.json before .node-version", func(t *testing.T) { + f := newVersionFixture(t) + source := f.write(filepath.Join(".dtvem", "runtimes.json"), `{"node": "20.0.0"}`) + f.write(".node-version", "22.12.0") + f.write(".nvmrc", "18.0.0") + f.chdir(".") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "20.0.0", source) + }) + + t.Run(".node-version before .nvmrc", func(t *testing.T) { + f := newVersionFixture(t) + source := f.write(".node-version", "22.12.0") + f.write(".nvmrc", "18.0.0") + f.chdir(".") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "22.12.0", source) + }) + + t.Run("runtimes.json without node falls through to .node-version", func(t *testing.T) { + f := newVersionFixture(t) + f.write(filepath.Join(".dtvem", "runtimes.json"), `{"python": "3.12.0"}`) + source := f.write(".node-version", "22.12.0") + f.chdir(".") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "22.12.0", source) + }) +} + +func TestResolve_NearestDirectoryWins(t *testing.T) { + t.Run("version file in child over runtimes.json in parent", func(t *testing.T) { + f := newVersionFixture(t) + f.write(filepath.Join(".dtvem", "runtimes.json"), `{"node": "20.0.0"}`) + source := f.write(filepath.Join("app", ".node-version"), "22.12.0") + f.chdir("app") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "22.12.0", source) + }) + + t.Run("runtimes.json in child over version file in parent", func(t *testing.T) { + f := newVersionFixture(t) + f.write(".nvmrc", "22.12.0") + source := f.write(filepath.Join("app", ".dtvem", "runtimes.json"), `{"node": "20.0.0"}`) + f.chdir("app") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "20.0.0", source) + }) +} + +func TestResolve_PartialVersion(t *testing.T) { + tests := []struct { + name string + spec string + want string + }{ + {name: "major picks highest installed", spec: "22", want: "22.12.0"}, + {name: "major.minor picks highest patch", spec: "22.9", want: "22.9.1"}, + {name: "v prefix", spec: "v20", want: "20.18.0"}, + {name: "numeric not lexical match", spec: "2", want: "2"}, + {name: "no installed match stays partial", spec: "21", want: "21"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := newVersionFixture(t) + f.install("node", "22.1.0", "22.9.0", "22.9.1", "22.12.0", "20.18.0", "220.0.0") + source := f.write(".node-version", tt.spec) + f.chdir(".") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, tt.want, source) + }) + } +} + +func TestResolve_ExactVersionNotInstalled(t *testing.T) { + f := newVersionFixture(t) + f.install("node", "22.12.0") + source := f.write(".node-version", "22.0.0") + f.chdir(".") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "22.0.0", source) +} + +func TestResolve_UnsupportedVersionFileDoesNotFallBack(t *testing.T) { + f := newVersionFixture(t) + if err := SetGlobalVersion("node", "22.12.0"); err != nil { + t.Fatalf("SetGlobalVersion() error: %v", err) + } + // A valid pin further up must not be used either. + f.write(".node-version", "20.0.0") + f.write(filepath.Join("app", ".nvmrc"), "lts/*\n") + f.chdir("app") + + _, err := Resolve("node") + + var fileErr *VersionFileError + if !errors.As(err, &fileErr) { + t.Fatalf("Resolve() error = %v, want *VersionFileError", err) + } + if fileErr.Value != "lts/*" { + t.Errorf("VersionFileError.Value = %q, want %q", fileErr.Value, "lts/*") + } + if filepath.Base(fileErr.Path) != ".nvmrc" { + t.Errorf("VersionFileError.Path = %q, want an .nvmrc path", fileErr.Path) + } + if errors.Is(err, ErrNoVersion) { + t.Error("Resolve() error should not wrap ErrNoVersion for an unsupported version file") + } +} + +func TestResolve_EmptyVersionFile(t *testing.T) { + f := newVersionFixture(t) + f.write(".node-version", "\n") + f.chdir(".") + + _, err := Resolve("node") + + var fileErr *VersionFileError + if !errors.As(err, &fileErr) { + t.Fatalf("Resolve() error = %v, want *VersionFileError", err) + } + if fileErr.Value != "" { + t.Errorf("VersionFileError.Value = %q, want empty", fileErr.Value) + } +} + +func TestResolve_GlobalFallback(t *testing.T) { + f := newVersionFixture(t) + if err := SetGlobalVersion("node", "22.12.0"); err != nil { + t.Fatalf("SetGlobalVersion() error: %v", err) + } + f.chdir(".") + + res, err := Resolve("node") + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + assertResolution(t, res, "22.12.0", GlobalConfigPath()) +} + +func TestResolve_NoVersion(t *testing.T) { + f := newVersionFixture(t) + f.chdir(".") + + _, err := Resolve("node") + if !errors.Is(err, ErrNoVersion) { + t.Errorf("Resolve() error = %v, want ErrNoVersion", err) + } +} + +func TestResolve_VersionFilesAreRuntimeSpecific(t *testing.T) { + f := newVersionFixture(t) + f.write(".node-version", "22.12.0") + f.write(".nvmrc", "lts/*") + f.chdir(".") + + _, err := Resolve("python") + if !errors.Is(err, ErrNoVersion) { + t.Errorf("Resolve(\"python\") error = %v, want ErrNoVersion", err) + } +} + +func TestResolveVersion_UsesVersionFiles(t *testing.T) { + f := newVersionFixture(t) + f.install("node", "22.12.0") + f.write(".nvmrc", "22") + f.chdir(".") + + got, err := ResolveVersion("node") + if err != nil { + t.Fatalf("ResolveVersion() error: %v", err) + } + if got != "22.12.0" { + t.Errorf("ResolveVersion() = %q, want %q", got, "22.12.0") + } +} + +func TestLocalVersion_UsesVersionFiles(t *testing.T) { + f := newVersionFixture(t) + if err := SetGlobalVersion("node", "20.0.0"); err != nil { + t.Fatalf("SetGlobalVersion() error: %v", err) + } + f.write(".node-version", "22.12.0") + f.chdir(".") + + got, err := LocalVersion("node") + if err != nil { + t.Fatalf("LocalVersion() error: %v", err) + } + if got != "22.12.0" { + t.Errorf("LocalVersion() = %q, want %q", got, "22.12.0") + } +} + +func TestLocalVersion_IgnoresGlobal(t *testing.T) { + f := newVersionFixture(t) + if err := SetGlobalVersion("node", "20.0.0"); err != nil { + t.Fatalf("SetGlobalVersion() error: %v", err) + } + f.chdir(".") + + if got, err := LocalVersion("node"); err == nil { + t.Errorf("LocalVersion() = %q, want an error when only a global version is set", got) + } +}