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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,11 @@ The shim mappings are automatically registered via `Shims()`.
## Version Resolution

**Priority order:**
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`)
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`; Python: `.python-version`)
2. **Global**: `~/.dtvem/config/runtimes.json`
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).
**Ecosystem version files** hold an exact (`22.12.0`) or partial (`22`, `22.12`) version, with the runtime's optional prefix (`v` for Node.js, `python-` for Python); blank lines and `#` comments are skipped, and the first word of the first remaining line is used (as pyenv and rbenv do). A partial version resolves to the highest installed match. Anything else (e.g. `.nvmrc` aliases like `lts/*`, pyenv's `system` or virtualenv names), 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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, plus `.node-version` and `.nvmrc` for Node.js
✅ **Per-Directory Versions**: `.dtvem/runtimes.json` for project-specific versions, plus `.node-version` / `.nvmrc` for Node.js and `.python-version` for Python

✅ **No Shell Hooks**: Works in cmd.exe, PowerShell, bash, zsh, fish, etc.

Expand Down
7 changes: 4 additions & 3 deletions src/cmd/current.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ 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.
Local settings are read from .dtvem/runtimes.json and from each runtime's own
version files (Node.js: .node-version and .nvmrc; Python: .python-version), 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
Expand Down
5 changes: 3 additions & 2 deletions src/internal/config/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,10 @@ func resolveInDir(dir, runtimeName string) (Resolution, bool, error) {
return Resolution{Version: version, Source: runtimesFile}, true, nil
}

for _, name := range versionFiles[runtimeName] {
rules := versionFiles[runtimeName]
for _, name := range rules.names {
path := filepath.Join(dir, name)
spec, err := readVersionSpec(path)
spec, err := readVersionSpec(path, rules)
if errors.Is(err, fs.ErrNotExist) {
continue
}
Expand Down
63 changes: 39 additions & 24 deletions src/internal/config/versionfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,22 @@ import (
"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"},
// versionFiles describes, for each runtime, the files other tools in its
// ecosystem use to pin a project's version.
var versionFiles = map[string]versionFileRules{
"node": {names: []string{".node-version", ".nvmrc"}, prefix: "v"},
"python": {names: []string{".python-version"}, prefix: "python-"},
}

// versionFileRules describes a runtime's version files and how versions are
// written in them.
type versionFileRules struct {
// names are the files, consulted after .dtvem/runtimes.json in the
// order listed when several are in one directory.
names []string
// prefix is an optional prefix written before a version, such as the
// "v" in "v22.12.0" or the "python-" in "python-3.12.4".
prefix string
}

// VersionFileError reports a version file whose contents dtvem cannot use.
Expand All @@ -26,45 +37,49 @@ 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)
return fmt.Sprintf("%s asks for %q: dtvem supports only version numbers (x, x.y or x.y.z)", 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
// readVersionSpec reads a version file such as .node-version or
// .python-version 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) {
func readVersionSpec(path string, rules versionFileRules) (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))
return parseVersionSpec(path, string(data), rules)
}

// 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) {
// file at path. It skips blank lines and # comments and takes the first
// word of the first remaining line, as pyenv and rbenv do. See
// versionFileRules.version for the forms accepted.
func parseVersionSpec(path, content string, rules versionFileRules) (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}
if fields := strings.Fields(line); len(fields) > 0 {
return rules.version(path, fields[0])
}
return spec, nil
}

return "", &VersionFileError{Path: path}
}

// version returns the version raw asks for, without the runtime's prefix:
// an exact ("22.12.0") or partial ("22", "22.12") version number. Anything
// else, such as an .nvmrc alias like "lts/*" or pyenv's "system", is a
// *VersionFileError for the file at path.
func (r versionFileRules) version(path, raw string) (string, error) {
spec := strings.TrimPrefix(raw, r.prefix)
if !isVersionNumber(spec) {
return "", &VersionFileError{Path: path, Value: raw}
}
return spec, nil
}

// isVersionNumber reports whether s is one to three dot-separated runs of
// digits.
func isVersionNumber(s string) bool {
Expand Down
125 changes: 119 additions & 6 deletions src/internal/config/versionfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ func TestParseVersionSpec(t *testing.T) {
{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"},
{name: "first word of the line wins", content: "22.12.0 20.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)
got, err := parseVersionSpec(".node-version", tt.content, versionFiles["node"])
if err != nil {
t.Fatalf("parseVersionSpec(%q) unexpected error: %v", tt.content, err)
}
Expand Down Expand Up @@ -57,11 +58,12 @@ func TestParseVersionSpec_Unsupported(t *testing.T) {
{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"},
{name: "another runtime's prefix", content: "python-22", wantValue: "python-22"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := parseVersionSpec(".nvmrc", tt.content)
_, err := parseVersionSpec(".nvmrc", tt.content, versionFiles["node"])

var fileErr *VersionFileError
if !errors.As(err, &fileErr) {
Expand All @@ -79,7 +81,7 @@ func TestParseVersionSpec_Unsupported(t *testing.T) {

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 {
if want := `.nvmrc asks for "lts/*": dtvem supports only version numbers (x, x.y or x.y.z)`; alias != want {
t.Errorf("Error() = %q, want %q", alias, want)
}

Expand All @@ -89,6 +91,63 @@ func TestVersionFileError_Message(t *testing.T) {
}
}

func TestParseVersionSpec_Python(t *testing.T) {
tests := []struct {
name string
content string
want string
}{
{name: "exact version", content: "3.12.4\n", want: "3.12.4"},
{name: "partial version", content: "3.12", want: "3.12"},
{name: "python- prefix", content: "python-3.12.4", want: "3.12.4"},
{name: "comment and blank lines", content: "# pinned by uv\n\n3.12.4\n", want: "3.12.4"},
{name: "first of several versions", content: "3.12.4\n3.11.9\n", want: "3.12.4"},
{name: "CRLF line endings", content: "3.12.4\r\n", want: "3.12.4"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseVersionSpec(".python-version", tt.content, versionFiles["python"])
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_PythonUnsupported(t *testing.T) {
tests := []struct {
name string
content string
wantValue string
}{
{name: "system", content: "system\n", wantValue: "system"},
{name: "pyenv-virtualenv pointer", content: "3.10/envs/venv", wantValue: "3.10/envs/venv"},
{name: "virtualenv name", content: "my-venv", wantValue: "my-venv"},
{name: "uv implementation request", content: "pypy", wantValue: "pypy"},
{name: "uv implementation@version request", content: "cpython@3.12", wantValue: "cpython@3.12"},
{name: "v prefix is not Python's", content: "v3.12.4", wantValue: "v3.12.4"},
{name: "empty file", content: "\n", wantValue: ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := parseVersionSpec(".python-version", tt.content, versionFiles["python"])

var fileErr *VersionFileError
if !errors.As(err, &fileErr) {
t.Fatalf("parseVersionSpec(%q) error = %v, want *VersionFileError", tt.content, err)
}
if fileErr.Value != tt.wantValue {
t.Errorf("VersionFileError.Value = %q, want %q", fileErr.Value, tt.wantValue)
}
})
}
}

// versionFixture is a dtvem root and a project directory to resolve from.
type versionFixture struct {
t *testing.T
Expand Down Expand Up @@ -400,12 +459,66 @@ func TestResolve_VersionFilesAreRuntimeSpecific(t *testing.T) {
f := newVersionFixture(t)
f.write(".node-version", "22.12.0")
f.write(".nvmrc", "lts/*")
f.write(".python-version", "system")
f.chdir(".")

_, err := Resolve("python")
if !errors.Is(err, ErrNoVersion) {
t.Errorf("Resolve(\"python\") error = %v, want ErrNoVersion", err)
if _, err := Resolve("ruby"); !errors.Is(err, ErrNoVersion) {
t.Errorf("Resolve(\"ruby\") error = %v, want ErrNoVersion", err)
}

res, err := Resolve("node")
if err != nil {
t.Fatalf("Resolve(\"node\") error: %v", err)
}
if res.Version != "22.12.0" {
t.Errorf("Resolve(\"node\") = %q, want %q", res.Version, "22.12.0")
}

var fileErr *VersionFileError
if _, err := Resolve("python"); !errors.As(err, &fileErr) || filepath.Base(fileErr.Path) != ".python-version" {
t.Errorf("Resolve(\"python\") error = %v, want a *VersionFileError for .python-version", err)
}
}

func TestResolve_PythonVersionFile(t *testing.T) {
f := newVersionFixture(t)
f.install("python", "3.11.9", "3.12.4", "3.12.12", "3.13.1")
source := f.write(".python-version", "3.12\n")
f.chdir(filepath.Join("src", "pkg"))

res, err := Resolve("python")
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
assertResolution(t, res, "3.12.12", source)
}

func TestResolve_PythonPrecedence(t *testing.T) {
t.Run("runtimes.json before .python-version", func(t *testing.T) {
f := newVersionFixture(t)
source := f.write(filepath.Join(".dtvem", "runtimes.json"), `{"python": "3.11.9"}`)
f.write(".python-version", "3.12.4")
f.chdir(".")

res, err := Resolve("python")
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
assertResolution(t, res, "3.11.9", source)
})

t.Run("nearer .python-version over parent runtimes.json", func(t *testing.T) {
f := newVersionFixture(t)
f.write(filepath.Join(".dtvem", "runtimes.json"), `{"python": "3.11.9"}`)
source := f.write(filepath.Join("app", ".python-version"), "python-3.12.4")
f.chdir("app")

res, err := Resolve("python")
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
assertResolution(t, res, "3.12.4", source)
})
}

func TestResolveVersion_UsesVersionFiles(t *testing.T) {
Expand Down
Loading