diff --git a/src/cmd/shim/main.go b/src/cmd/shim/main.go index a91fd6a..4bb9d2d 100644 --- a/src/cmd/shim/main.go +++ b/src/cmd/shim/main.go @@ -59,6 +59,20 @@ func runShim() error { } ui.Debug("Resolved version: %s", version) + // 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 + // version is one of them. This catches the case where reshim created + // the shim because *some* installed runtime version provides it, but + // the currently-active version does not. + if shimName != runtimeName { + if entry, ok := shim.Lookup(shimName); ok && len(entry.Versions) > 0 { + if !versionProvides(entry.Versions, version) { + ui.Debug("Active version %s not in providing-versions list %v", version, entry.Versions) + return notAvailableInVersionError(shimName, runtimeName, provider.DisplayName(), version, entry.Versions) + } + } + } + // Check if the version is installed installed, err := provider.IsInstalled(version) if err != nil { @@ -228,9 +242,9 @@ func mapShimToRuntime(shimName string) string { // secondaryExecutableError formats a user-facing error explaining that a // secondary executable shim (e.g., uv, pip) exists but the binary cannot -// be located in the active runtime version. This typically happens when -// the shim was created by a `dtvem reshim` that scanned a different -// installed version which had the executable available. +// be located in the active runtime version. This is the catch-all when +// the shim-map cache has no version coverage data (e.g., legacy cache, +// or the shim entered the cache without a recorded version). func secondaryExecutableError(shimName, displayName, version string) error { ui.Error("'%s' is not available in %s %s", shimName, displayName, version) ui.Info("This shim exists because another installed %s version provides it.", displayName) @@ -238,6 +252,39 @@ func secondaryExecutableError(shimName, displayName, version string) error { return fmt.Errorf("%s not available in %s %s", shimName, displayName, version) } +// notAvailableInVersionError formats a richer error using the providing- +// versions data recorded in the shim-map cache. Unlike +// secondaryExecutableError, this can tell the user *which* installed +// versions actually provide the executable so they can switch to one. +func notAvailableInVersionError(shimName, runtimeName, displayName, activeVersion string, providingVersions []string) error { + ui.Error("'%s' is not available in %s %s", shimName, displayName, activeVersion) + + // "Available in: Python 3.9.9, Python 3.10.0" + labeled := make([]string, len(providingVersions)) + for i, v := range providingVersions { + labeled[i] = fmt.Sprintf("%s %s", displayName, v) + } + ui.Info("Available in: %s", strings.Join(labeled, ", ")) + + if len(providingVersions) == 1 { + ui.Info("Switch with: dtvem global %s %s", runtimeName, providingVersions[0]) + } else { + ui.Info("Switch with 'dtvem global %s ' or set a local version.", runtimeName) + } + + return fmt.Errorf("%s not available in %s %s", shimName, displayName, activeVersion) +} + +// versionProvides reports whether version is in the providing-versions list. +func versionProvides(providingVersions []string, version string) bool { + for _, v := range providingVersions { + if v == version { + return true + } + } + return false +} + // executeCommand executes a command with the given arguments and provider environment func executeCommand(execPath string, args []string, providerEnv map[string]string) error { // Build full args (executable name + arguments) diff --git a/src/cmd/which.go b/src/cmd/which.go index f00c854..94febe3 100644 --- a/src/cmd/which.go +++ b/src/cmd/which.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" goruntime "runtime" + "strings" "github.com/CodingWithCalvin/dtvem.cli/src/internal/config" "github.com/CodingWithCalvin/dtvem.cli/src/internal/constants" @@ -70,6 +71,20 @@ Examples: return } + // If this is a secondary executable and the shim-map cache knows + // which versions provide it, verify the active version is one of + // them. This gives the user an informed "available in: X" message + // instead of a generic "not found" when they're on a version that + // doesn't include the command. + if commandName != runtimeName { + if entry, ok := shim.Lookup(commandName); ok && len(entry.Versions) > 0 { + if !versionInList(version, entry.Versions) { + reportNotAvailableInVersion(commandName, runtimeName, provider.DisplayName(), version, entry.Versions) + return + } + } + } + // Get the base executable path baseExecPath, err := provider.ExecutablePath(version) if err != nil { @@ -102,15 +117,17 @@ Examples: }, } -// mapCommandToRuntime maps a command name to its runtime +// mapCommandToRuntime maps a command name to its runtime. It first consults +// the shim-map cache (which records dynamically-installed packages such as +// uv, tsc, black) and falls back to the registered providers' core shim +// lists when the cache has no entry. func mapCommandToRuntime(commandName string) string { - // Get all registered runtimes - runtimes := runtime.List() + if runtimeName, ok := shim.LookupRuntime(commandName); ok { + return runtimeName + } - // Check each runtime's shims - for _, rt := range runtimes { - shims := shim.RuntimeShims(rt) - for _, shimName := range shims { + for _, rt := range runtime.List() { + for _, shimName := range shim.RuntimeShims(rt) { if shimName == commandName { return rt } @@ -120,6 +137,35 @@ func mapCommandToRuntime(commandName string) string { return "" } +// versionInList reports whether version is in the providing-versions list. +func versionInList(version string, providingVersions []string) bool { + for _, v := range providingVersions { + if v == version { + return true + } + } + return false +} + +// reportNotAvailableInVersion prints the user-facing "not available in this +// runtime version" error for `dtvem which`, including the list of versions +// that DO provide the executable so the user can switch to one. +func reportNotAvailableInVersion(commandName, runtimeName, displayName, activeVersion string, providingVersions []string) { + ui.Error("'%s' is not available in %s %s", commandName, displayName, activeVersion) + + labeled := make([]string, len(providingVersions)) + for i, v := range providingVersions { + labeled[i] = fmt.Sprintf("%s %s", displayName, v) + } + ui.Info("Available in: %s", strings.Join(labeled, ", ")) + + if len(providingVersions) == 1 { + ui.Info("Switch with: dtvem global %s %s", runtimeName, providingVersions[0]) + } else { + ui.Info("Switch with 'dtvem global %s ' or set a local version.", runtimeName) + } +} + func init() { rootCmd.AddCommand(whichCmd) } diff --git a/src/internal/shim/cache.go b/src/internal/shim/cache.go index a7f9d24..f02155c 100644 --- a/src/internal/shim/cache.go +++ b/src/internal/shim/cache.go @@ -3,16 +3,38 @@ package shim import ( "encoding/json" + "fmt" "os" "sync" "github.com/CodingWithCalvin/dtvem.cli/src/internal/config" ) -// ShimMap represents the shim-to-runtime mapping cache -// The map key is the shim name (e.g., "tsc", "npm", "black") -// The map value is the runtime name (e.g., "node", "python") -type ShimMap map[string]string +// ShimEntry is the per-shim record stored in the shim-map cache. It binds +// a shim name to the runtime that owns it AND to the set of installed +// runtime versions that actually provide the executable on disk. +// +// The version data lets the shim distinguish between "command shimmed for +// this runtime, also present in the active version" and "command shimmed +// for this runtime, but the active version doesn't have it" — for example +// when `uv` is installed via `pip install uv` against Python 3.9 but the +// user switches to a 3.8 install that never received uv. +type ShimEntry struct { + // Runtime is the runtime name (e.g., "python", "node", "ruby"). + Runtime string `json:"runtime"` + + // Versions is the set of installed runtime versions that provide the + // executable. Empty / nil means "version coverage unknown" — typically + // because the cache was loaded from the legacy schema or because the + // caller did not supply version data — and callers should treat empty + // as "skip the version check" rather than "no providing versions". + Versions []string `json:"versions,omitempty"` +} + +// ShimMap represents the shim-to-runtime mapping cache. The map key is the +// shim name (e.g., "tsc", "npm", "uv"); the value is a ShimEntry binding +// it to a runtime and (optionally) the set of versions that provide it. +type ShimMap map[string]ShimEntry var ( shimMapCache ShimMap @@ -30,7 +52,11 @@ func LoadShimMap() (ShimMap, error) { return shimMapCache, shimMapCacheErr } -// loadShimMapFromDisk reads the shim map cache file from disk +// loadShimMapFromDisk reads the shim map cache file from disk, tolerating +// both the current schema (ShimEntry values with versions) and the legacy +// flat schema (string values mapping shim → runtime). Legacy entries are +// converted with empty Versions, signaling "version coverage unknown" to +// callers; a subsequent `dtvem reshim` will re-populate the version data. func loadShimMapFromDisk() (ShimMap, error) { cachePath := config.ShimMapPath() @@ -39,12 +65,40 @@ func loadShimMapFromDisk() (ShimMap, error) { return nil, err } - var shimMap ShimMap - if err := json.Unmarshal(data, &shimMap); err != nil { - return nil, err + // Try the current schema first. + var current ShimMap + if err := json.Unmarshal(data, ¤t); err == nil && schemaIsCurrent(current) { + return current, nil + } + + // Fall back to the legacy schema (shim → runtime name). + var legacy map[string]string + if err := json.Unmarshal(data, &legacy); err == nil { + converted := make(ShimMap, len(legacy)) + for shim, runtime := range legacy { + converted[shim] = ShimEntry{Runtime: runtime} + } + return converted, nil } - return shimMap, nil + return nil, fmt.Errorf("shim-map cache at %s is in an unrecognized format", cachePath) +} + +// schemaIsCurrent reports whether a successfully-unmarshalled ShimMap was +// actually serialized with the current schema. A legacy file like +// `{"uv": "python"}` will technically unmarshal into ShimMap (every entry +// becomes a zero-valued ShimEntry), so a Runtime field that is empty for +// every entry indicates the input was actually legacy. +func schemaIsCurrent(m ShimMap) bool { + if len(m) == 0 { + return true + } + for _, entry := range m { + if entry.Runtime != "" { + return true + } + } + return false } // SaveShimMap writes the shim-to-runtime mapping to the cache file. @@ -69,8 +123,11 @@ func SaveShimMap(shimMap ShimMap) error { // MergeShimMap merges the given entries into the on-disk shim map and persists it. // // If the cache does not exist yet (first-time install), a new map is created. -// Existing entries with matching keys are overwritten. The in-memory cache is -// reset so subsequent LoadShimMap calls read the updated state from disk. +// For shims already in the cache, the runtime is overwritten with the new +// value (typically the same) and the providing-versions set is unioned — +// so installing a second runtime version that provides the same executable +// extends the version list rather than clobbering it. The in-memory cache +// is reset so subsequent LoadShimMap calls read the updated state from disk. // // This is the preferred path for install-time shim registration, where the // caller knows only the shims it just created and wants to register them @@ -83,8 +140,14 @@ func MergeShimMap(entries ShimMap) error { existing = make(ShimMap, len(entries)) } - for shim, runtime := range entries { - existing[shim] = runtime + for shim, entry := range entries { + if cur, ok := existing[shim]; ok { + cur.Runtime = entry.Runtime + cur.Versions = unionVersions(cur.Versions, entry.Versions) + existing[shim] = cur + } else { + existing[shim] = entry + } } // Force the next LoadShimMap to re-read from disk so the merged entries @@ -94,16 +157,52 @@ func MergeShimMap(entries ShimMap) error { return SaveShimMap(existing) } -// LookupRuntime looks up the runtime for a given shim name using the cache. -// Returns the runtime name and true if found, or empty string and false if not. -func LookupRuntime(shimName string) (string, bool) { +// unionVersions returns a slice containing every distinct version from a +// and b, preserving the order in which versions are first seen. +func unionVersions(a, b []string) []string { + if len(a) == 0 && len(b) == 0 { + return nil + } + seen := make(map[string]struct{}, len(a)+len(b)) + out := make([]string, 0, len(a)+len(b)) + for _, v := range a { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + for _, v := range b { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +// Lookup returns the full ShimEntry (runtime + providing versions) for a +// shim, or zero-value and false if the shim is not in the cache. Use this +// when you need the version coverage data; for callers that only need the +// runtime name, use LookupRuntime. +func Lookup(shimName string) (ShimEntry, bool) { shimMap, err := LoadShimMap() if err != nil { - return "", false + return ShimEntry{}, false } + entry, ok := shimMap[shimName] + return entry, ok +} - runtime, ok := shimMap[shimName] - return runtime, ok +// LookupRuntime looks up the runtime for a given shim name using the cache. +// Returns the runtime name and true if found, or empty string and false if not. +// +// This is a convenience wrapper around Lookup for callers that don't need +// the providing-versions data. +func LookupRuntime(shimName string) (string, bool) { + entry, ok := Lookup(shimName) + return entry.Runtime, ok } // ResetShimMapCache resets the cached shim map, forcing a reload on next access. diff --git a/src/internal/shim/cache_test.go b/src/internal/shim/cache_test.go index 1974439..94e843b 100644 --- a/src/internal/shim/cache_test.go +++ b/src/internal/shim/cache_test.go @@ -1,112 +1,88 @@ package shim import ( + "encoding/json" "os" "path/filepath" + "reflect" + "sort" "testing" "github.com/CodingWithCalvin/dtvem.cli/src/internal/config" ) -func TestSaveAndLoadShimMap(t *testing.T) { - // Create a temporary directory for the test +// withTempCache sets up DTVEM_ROOT to a temp dir, creates the cache dir, +// and resets the various caches so each test starts from a clean slate. +// The returned cleanup must be deferred by the caller. +func withTempCache(t *testing.T) (cacheDir string, cleanup func()) { + t.Helper() tempDir := t.TempDir() - // Set DTVEM_ROOT to use our temp directory originalRoot := os.Getenv("DTVEM_ROOT") _ = os.Setenv("DTVEM_ROOT", tempDir) - defer func() { _ = os.Setenv("DTVEM_ROOT", originalRoot) }() - - // Reset the paths cache to pick up new DTVEM_ROOT config.ResetPathsCache() - defer config.ResetPathsCache() - - // Reset the shim map cache ResetShimMapCache() - defer ResetShimMapCache() - // Create the cache directory - cacheDir := filepath.Join(tempDir, "cache") + cacheDir = filepath.Join(tempDir, "cache") if err := os.MkdirAll(cacheDir, 0755); err != nil { t.Fatalf("Failed to create cache directory: %v", err) } - // Create a test shim map + cleanup = func() { + _ = os.Setenv("DTVEM_ROOT", originalRoot) + config.ResetPathsCache() + ResetShimMapCache() + } + return cacheDir, cleanup +} + +// entry is a small constructor for ShimEntry test data. +func entry(runtime string, versions ...string) ShimEntry { + return ShimEntry{Runtime: runtime, Versions: versions} +} + +func TestSaveAndLoadShimMap(t *testing.T) { + _, cleanup := withTempCache(t) + defer cleanup() + testMap := ShimMap{ - "node": "node", - "npm": "node", - "npx": "node", - "tsc": "node", - "eslint": "node", - "python": "python", - "pip": "python", - "black": "python", + "node": entry("node", "22.0.0"), + "npm": entry("node", "22.0.0"), + "python": entry("python", "3.11.0"), + "pip": entry("python", "3.11.0"), } - // Save the map if err := SaveShimMap(testMap); err != nil { t.Fatalf("Failed to save shim map: %v", err) } - // Verify the file was created - cachePath := config.ShimMapPath() - if _, err := os.Stat(cachePath); os.IsNotExist(err) { - t.Fatalf("Shim map cache file was not created at %s", cachePath) + if _, err := os.Stat(config.ShimMapPath()); os.IsNotExist(err) { + t.Fatalf("Shim map cache file was not created at %s", config.ShimMapPath()) } - // Load the map - loadedMap, err := LoadShimMap() + loaded, err := LoadShimMap() if err != nil { t.Fatalf("Failed to load shim map: %v", err) } - // Verify all entries - for shimName, expectedRuntime := range testMap { - if loadedRuntime, ok := loadedMap[shimName]; !ok { - t.Errorf("Shim %q not found in loaded map", shimName) - } else if loadedRuntime != expectedRuntime { - t.Errorf("Shim %q: expected runtime %q, got %q", shimName, expectedRuntime, loadedRuntime) - } + if !reflect.DeepEqual(loaded, testMap) { + t.Errorf("loaded map does not match saved map\n got: %#v\nwant: %#v", loaded, testMap) } } func TestLookupRuntime(t *testing.T) { - // Create a temporary directory for the test - tempDir := t.TempDir() + _, cleanup := withTempCache(t) + defer cleanup() - // Set DTVEM_ROOT to use our temp directory - originalRoot := os.Getenv("DTVEM_ROOT") - _ = os.Setenv("DTVEM_ROOT", tempDir) - defer func() { _ = os.Setenv("DTVEM_ROOT", originalRoot) }() - - // Reset the paths cache to pick up new DTVEM_ROOT - config.ResetPathsCache() - defer config.ResetPathsCache() - - // Reset the shim map cache - ResetShimMapCache() - defer ResetShimMapCache() - - // Create the cache directory - cacheDir := filepath.Join(tempDir, "cache") - if err := os.MkdirAll(cacheDir, 0755); err != nil { - t.Fatalf("Failed to create cache directory: %v", err) - } - - // Create and save a test shim map testMap := ShimMap{ - "node": "node", - "npm": "node", - "tsc": "node", - "python": "python", - "black": "python", + "node": entry("node", "22.0.0"), + "npm": entry("node", "22.0.0"), + "python": entry("python", "3.11.0"), } - if err := SaveShimMap(testMap); err != nil { t.Fatalf("Failed to save shim map: %v", err) } - // Test lookups tests := []struct { shimName string expectedRuntime string @@ -114,9 +90,7 @@ func TestLookupRuntime(t *testing.T) { }{ {"node", "node", true}, {"npm", "node", true}, - {"tsc", "node", true}, {"python", "python", true}, - {"black", "python", true}, {"unknown", "", false}, {"", "", false}, } @@ -125,33 +99,48 @@ func TestLookupRuntime(t *testing.T) { t.Run(tc.shimName, func(t *testing.T) { runtime, found := LookupRuntime(tc.shimName) if found != tc.expectedFound { - t.Errorf("LookupRuntime(%q): expected found=%v, got found=%v", tc.shimName, tc.expectedFound, found) + t.Errorf("LookupRuntime(%q): expected found=%v, got %v", tc.shimName, tc.expectedFound, found) } if runtime != tc.expectedRuntime { - t.Errorf("LookupRuntime(%q): expected runtime=%q, got runtime=%q", tc.shimName, tc.expectedRuntime, runtime) + t.Errorf("LookupRuntime(%q): expected runtime=%q, got %q", tc.shimName, tc.expectedRuntime, runtime) } }) } } -func TestLookupRuntimeNoCacheFile(t *testing.T) { - // Create a temporary directory for the test (empty, no cache file) - tempDir := t.TempDir() +func TestLookup_ReturnsFullEntry(t *testing.T) { + _, cleanup := withTempCache(t) + defer cleanup() + + testMap := ShimMap{ + "uv": entry("python", "3.9.9", "3.10.0"), + } + if err := SaveShimMap(testMap); err != nil { + t.Fatalf("Failed to save shim map: %v", err) + } - // Set DTVEM_ROOT to use our temp directory + got, ok := Lookup("uv") + if !ok { + t.Fatalf("Lookup(\"uv\") not found") + } + if got.Runtime != "python" { + t.Errorf("Runtime: got %q, want %q", got.Runtime, "python") + } + if !reflect.DeepEqual(got.Versions, []string{"3.9.9", "3.10.0"}) { + t.Errorf("Versions: got %v, want %v", got.Versions, []string{"3.9.9", "3.10.0"}) + } +} + +func TestLookupNoCacheFile(t *testing.T) { + tempDir := t.TempDir() originalRoot := os.Getenv("DTVEM_ROOT") _ = os.Setenv("DTVEM_ROOT", tempDir) defer func() { _ = os.Setenv("DTVEM_ROOT", originalRoot) }() - - // Reset the paths cache to pick up new DTVEM_ROOT config.ResetPathsCache() defer config.ResetPathsCache() - - // Reset the shim map cache ResetShimMapCache() defer ResetShimMapCache() - // Lookup should return not found when cache doesn't exist runtime, found := LookupRuntime("node") if found { t.Errorf("LookupRuntime should return found=false when cache doesn't exist") @@ -161,84 +150,83 @@ func TestLookupRuntimeNoCacheFile(t *testing.T) { } } -func TestShimMapCacheOnlyLoadsOnce(t *testing.T) { - // Create a temporary directory for the test - tempDir := t.TempDir() +func TestLoadShimMap_LegacySchemaFallback(t *testing.T) { + cacheDir, cleanup := withTempCache(t) + defer cleanup() - // Set DTVEM_ROOT to use our temp directory - originalRoot := os.Getenv("DTVEM_ROOT") - _ = os.Setenv("DTVEM_ROOT", tempDir) - defer func() { _ = os.Setenv("DTVEM_ROOT", originalRoot) }() - - // Reset the paths cache to pick up new DTVEM_ROOT - config.ResetPathsCache() - defer config.ResetPathsCache() + // Hand-write a legacy-schema cache file: shim → runtime name (string), + // no version coverage data. Loaders must tolerate it so users who + // upgrade dtvem don't see broken shims until they next run reshim. + legacy := []byte(`{ + "uv": "python", + "npm": "node" +}`) + cachePath := filepath.Join(cacheDir, "shim-map.json") + if err := os.WriteFile(cachePath, legacy, 0644); err != nil { + t.Fatalf("write legacy cache: %v", err) + } - // Reset the shim map cache - ResetShimMapCache() - defer ResetShimMapCache() + loaded, err := LoadShimMap() + if err != nil { + t.Fatalf("LoadShimMap legacy: %v", err) + } - // Create the cache directory - cacheDir := filepath.Join(tempDir, "cache") - if err := os.MkdirAll(cacheDir, 0755); err != nil { - t.Fatalf("Failed to create cache directory: %v", err) + if got := loaded["uv"].Runtime; got != "python" { + t.Errorf("uv runtime: got %q, want python", got) + } + if got := loaded["npm"].Runtime; got != "node" { + t.Errorf("npm runtime: got %q, want node", got) } + // Legacy entries should have no version coverage data — that's the + // signal callers use to skip the version check. + if got := loaded["uv"].Versions; len(got) != 0 { + t.Errorf("uv versions on legacy load: got %v, want empty", got) + } +} + +func TestShimMapCacheOnlyLoadsOnce(t *testing.T) { + _, cleanup := withTempCache(t) + defer cleanup() - // Create and save initial shim map - initialMap := ShimMap{"node": "node"} - if err := SaveShimMap(initialMap); err != nil { + if err := SaveShimMap(ShimMap{"node": entry("node", "22.0.0")}); err != nil { t.Fatalf("Failed to save initial shim map: %v", err) } - // Load the map - map1, err := LoadShimMap() + first, err := LoadShimMap() if err != nil { t.Fatalf("Failed to load shim map: %v", err) } - // Modify the file on disk - modifiedMap := ShimMap{"node": "modified", "new": "entry"} - if err := SaveShimMap(modifiedMap); err != nil { + // Mutate on disk; LoadShimMap should still return the originally cached map. + if err := SaveShimMap(ShimMap{"node": entry("modified"), "new": entry("entry")}); err != nil { t.Fatalf("Failed to save modified shim map: %v", err) } - // Load again - should return cached version (sync.Once) - map2, err := LoadShimMap() + second, err := LoadShimMap() if err != nil { t.Fatalf("Failed to load shim map second time: %v", err) } - // Both should be the same (initial map, not modified) - if map1["node"] != map2["node"] { - t.Errorf("Cache should return same map: map1[node]=%q, map2[node]=%q", map1["node"], map2["node"]) + if first["node"].Runtime != second["node"].Runtime { + t.Errorf("cache should return same map: first=%q second=%q", + first["node"].Runtime, second["node"].Runtime) } - - // The modified entry should not be present (cache wasn't reloaded) - if _, ok := map2["new"]; ok { - t.Errorf("Cache should not have reloaded - 'new' entry should not exist") + if _, ok := second["new"]; ok { + t.Errorf("cache should not have reloaded - 'new' entry should not exist") } } func TestMergeShimMap_CreatesWhenNoExistingCache(t *testing.T) { - tempDir := t.TempDir() - - originalRoot := os.Getenv("DTVEM_ROOT") - _ = os.Setenv("DTVEM_ROOT", tempDir) - defer func() { _ = os.Setenv("DTVEM_ROOT", originalRoot) }() + _, cleanup := withTempCache(t) + defer cleanup() - config.ResetPathsCache() - defer config.ResetPathsCache() + // Wipe the cache directory to simulate a truly fresh install. + _ = os.RemoveAll(filepath.Dir(config.ShimMapPath())) - ResetShimMapCache() - defer ResetShimMapCache() - - // No cache directory pre-existing — MergeShimMap must create it from scratch. entries := ShimMap{ - "node": "node", - "npm": "node", - "npx": "node", + "node": entry("node", "22.0.0"), + "npm": entry("node", "22.0.0"), } - if err := MergeShimMap(entries); err != nil { t.Fatalf("MergeShimMap returned error on fresh install: %v", err) } @@ -247,48 +235,28 @@ func TestMergeShimMap_CreatesWhenNoExistingCache(t *testing.T) { if err != nil { t.Fatalf("LoadShimMap after MergeShimMap failed: %v", err) } - - if len(loaded) != len(entries) { - t.Errorf("expected %d entries, got %d (%v)", len(entries), len(loaded), loaded) - } - for shim, runtime := range entries { - if got := loaded[shim]; got != runtime { - t.Errorf("entry %q: expected runtime %q, got %q", shim, runtime, got) - } + if !reflect.DeepEqual(loaded, entries) { + t.Errorf("loaded mismatch\n got: %#v\nwant: %#v", loaded, entries) } } func TestMergeShimMap_MergesIntoExistingCache(t *testing.T) { - tempDir := t.TempDir() - - originalRoot := os.Getenv("DTVEM_ROOT") - _ = os.Setenv("DTVEM_ROOT", tempDir) - defer func() { _ = os.Setenv("DTVEM_ROOT", originalRoot) }() - - config.ResetPathsCache() - defer config.ResetPathsCache() - - ResetShimMapCache() - defer ResetShimMapCache() - - cacheDir := filepath.Join(tempDir, "cache") - if err := os.MkdirAll(cacheDir, 0755); err != nil { - t.Fatalf("Failed to create cache directory: %v", err) - } + _, cleanup := withTempCache(t) + defer cleanup() // Seed an existing cache (simulates a prior install). initial := ShimMap{ - "python": "python", - "pip": "python", + "python": entry("python", "3.11.0"), + "pip": entry("python", "3.11.0"), } if err := SaveShimMap(initial); err != nil { t.Fatalf("seed SaveShimMap failed: %v", err) } - // Merge in a disjoint set of entries (simulates installing a second runtime). + // Merge in disjoint entries (simulates installing a second runtime). added := ShimMap{ - "node": "node", - "npm": "node", + "node": entry("node", "22.0.0"), + "npm": entry("node", "22.0.0"), } if err := MergeShimMap(added); err != nil { t.Fatalf("MergeShimMap failed: %v", err) @@ -299,47 +267,57 @@ func TestMergeShimMap_MergesIntoExistingCache(t *testing.T) { t.Fatalf("LoadShimMap failed: %v", err) } - // All four entries should now be present. - wantAll := ShimMap{ - "python": "python", - "pip": "python", - "node": "node", - "npm": "node", + want := ShimMap{ + "python": entry("python", "3.11.0"), + "pip": entry("python", "3.11.0"), + "node": entry("node", "22.0.0"), + "npm": entry("node", "22.0.0"), } - for shim, runtime := range wantAll { - if got := loaded[shim]; got != runtime { - t.Errorf("entry %q: expected runtime %q, got %q", shim, runtime, got) - } + if !reflect.DeepEqual(loaded, want) { + t.Errorf("merged mismatch\n got: %#v\nwant: %#v", loaded, want) } } -func TestMergeShimMap_OverwritesExistingKeys(t *testing.T) { - tempDir := t.TempDir() +func TestMergeShimMap_UnionsVersionsForSameShim(t *testing.T) { + _, cleanup := withTempCache(t) + defer cleanup() - originalRoot := os.Getenv("DTVEM_ROOT") - _ = os.Setenv("DTVEM_ROOT", tempDir) - defer func() { _ = os.Setenv("DTVEM_ROOT", originalRoot) }() + // First install records version 3.9.9 for python's "uv" shim. + if err := MergeShimMap(ShimMap{"uv": entry("python", "3.9.9")}); err != nil { + t.Fatalf("first MergeShimMap failed: %v", err) + } - config.ResetPathsCache() - defer config.ResetPathsCache() + // Second install records version 3.10.0 for the same "uv" shim. The + // cache must end up with both versions, not just the latest. + if err := MergeShimMap(ShimMap{"uv": entry("python", "3.10.0")}); err != nil { + t.Fatalf("second MergeShimMap failed: %v", err) + } - ResetShimMapCache() - defer ResetShimMapCache() + loaded, err := LoadShimMap() + if err != nil { + t.Fatalf("LoadShimMap failed: %v", err) + } - cacheDir := filepath.Join(tempDir, "cache") - if err := os.MkdirAll(cacheDir, 0755); err != nil { - t.Fatalf("Failed to create cache directory: %v", err) + got := loaded["uv"].Versions + sort.Strings(got) + want := []string{"3.10.0", "3.9.9"} + if !reflect.DeepEqual(got, want) { + t.Errorf("versions union: got %v, want %v", got, want) } +} - // Seed with a stale mapping (e.g., a shim that was previously attributed - // to the wrong runtime by some prior state). - stale := ShimMap{"corepack": "wrong"} - if err := SaveShimMap(stale); err != nil { +func TestMergeShimMap_OverwritesRuntimeName(t *testing.T) { + _, cleanup := withTempCache(t) + defer cleanup() + + // Seed with a stale mapping (e.g., a shim previously misattributed). + if err := SaveShimMap(ShimMap{"corepack": entry("wrong", "1.0.0")}); err != nil { t.Fatalf("seed SaveShimMap failed: %v", err) } - // Merge should overwrite with the correct runtime. - if err := MergeShimMap(ShimMap{"corepack": "node"}); err != nil { + // A subsequent install with the correct attribution should overwrite + // the runtime name. + if err := MergeShimMap(ShimMap{"corepack": entry("node", "22.0.0")}); err != nil { t.Fatalf("MergeShimMap failed: %v", err) } @@ -348,40 +326,25 @@ func TestMergeShimMap_OverwritesExistingKeys(t *testing.T) { t.Fatalf("LoadShimMap failed: %v", err) } - if got := loaded["corepack"]; got != "node" { + if got := loaded["corepack"].Runtime; got != "node" { t.Errorf("expected corepack remapped to node, got %q", got) } } func TestMergeShimMap_ResetsInMemoryCache(t *testing.T) { - tempDir := t.TempDir() + _, cleanup := withTempCache(t) + defer cleanup() - originalRoot := os.Getenv("DTVEM_ROOT") - _ = os.Setenv("DTVEM_ROOT", tempDir) - defer func() { _ = os.Setenv("DTVEM_ROOT", originalRoot) }() - - config.ResetPathsCache() - defer config.ResetPathsCache() - - ResetShimMapCache() - defer ResetShimMapCache() - - cacheDir := filepath.Join(tempDir, "cache") - if err := os.MkdirAll(cacheDir, 0755); err != nil { - t.Fatalf("Failed to create cache directory: %v", err) - } - - // Prime the in-memory cache with an initial map. - if err := SaveShimMap(ShimMap{"node": "node"}); err != nil { + if err := SaveShimMap(ShimMap{"node": entry("node", "22.0.0")}); err != nil { t.Fatalf("SaveShimMap failed: %v", err) } if _, err := LoadShimMap(); err != nil { t.Fatalf("initial LoadShimMap failed: %v", err) } - // Without ResetShimMapCache, the next Load would return the cached copy. - // MergeShimMap is supposed to reset it so callers see merged state. - if err := MergeShimMap(ShimMap{"npm": "node"}); err != nil { + // MergeShimMap must invalidate the in-memory cache so the merged entry + // is visible to the next caller in the same process. + if err := MergeShimMap(ShimMap{"npm": entry("node", "22.0.0")}); err != nil { t.Fatalf("MergeShimMap failed: %v", err) } @@ -389,8 +352,31 @@ func TestMergeShimMap_ResetsInMemoryCache(t *testing.T) { if err != nil { t.Fatalf("post-merge LoadShimMap failed: %v", err) } - if _, ok := loaded["npm"]; !ok { t.Error("expected in-memory cache to be reset so the merged 'npm' entry is visible") } } + +func TestSaveShimMap_OmitsEmptyVersions(t *testing.T) { + cacheDir, cleanup := withTempCache(t) + defer cleanup() + + // An entry with no Versions should serialize without the field, so + // that legacy-style or version-less records stay compact. + if err := SaveShimMap(ShimMap{"uv": {Runtime: "python"}}); err != nil { + t.Fatalf("SaveShimMap failed: %v", err) + } + + data, err := os.ReadFile(filepath.Join(cacheDir, "shim-map.json")) + if err != nil { + t.Fatalf("read cache: %v", err) + } + + var parsed map[string]map[string]any + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal: %v\n%s", err, data) + } + if _, hasVersions := parsed["uv"]["versions"]; hasVersions { + t.Errorf("expected versions field to be omitted when empty, got: %s", data) + } +} diff --git a/src/internal/shim/manager.go b/src/internal/shim/manager.go index a7b0731..591b7ed 100644 --- a/src/internal/shim/manager.go +++ b/src/internal/shim/manager.go @@ -109,7 +109,7 @@ func (m *Manager) CreateShims(shimNames []string) error { } // CreateShimsForRuntime creates shim files for the given names and registers -// them in the shim map under the given runtime name. +// them in the shim map under the given runtime name and version. // // This is the preferred path for install-time shim creation (e.g., from a // runtime provider's post-install hook). Bare CreateShims only writes the @@ -117,14 +117,22 @@ func (m *Manager) CreateShims(shimNames []string) error { // subsequent shim invocations have to fall back to the provider registry // lookup instead of the O(1) cache hit. Calling CreateShimsForRuntime keeps // the shim files and the cache in sync from the moment they are created. -func (m *Manager) CreateShimsForRuntime(runtimeName string, shimNames []string) error { +// +// The version is recorded in each shim's ShimEntry.Versions so the shim can +// detect at invocation time when the active runtime version is not one +// that provides the executable, and surface a clear error rather than +// silently running the runtime binary. +func (m *Manager) CreateShimsForRuntime(runtimeName, version string, shimNames []string) error { if err := m.CreateShims(shimNames); err != nil { return err } entries := make(ShimMap, len(shimNames)) for _, name := range shimNames { - entries[name] = runtimeName + entries[name] = ShimEntry{ + Runtime: runtimeName, + Versions: []string{version}, + } } return MergeShimMap(entries) @@ -255,27 +263,36 @@ func (m *Manager) RehashWithCallback(callback RehashCallback) (*RehashResult, er callback(runtimeName, displayName) } - // For each installed version, scan for executables + // For each installed version, scan for executables and record which + // versions provide each shim so the runtime version check at + // shim-invocation time can give an informed error. + recordShim := func(shimName, version string) { + entry := shimMap[shimName] + entry.Runtime = runtimeName + entry.Versions = appendUnique(entry.Versions, version) + shimMap[shimName] = entry + shimsByRuntime[runtimeName] = appendUnique(shimsByRuntime[runtimeName], shimName) + } + for _, versionEntry := range versionEntries { if !versionEntry.IsDir() { continue } - versionDir := filepath.Join(runtimeVersionsDir, versionEntry.Name()) - - // First, add core runtime shims (from provider) - coreShims := RuntimeShims(runtimeName) - for _, shimName := range coreShims { - shimMap[shimName] = runtimeName - shimsByRuntime[runtimeName] = appendUnique(shimsByRuntime[runtimeName], shimName) - } - - // Then, scan bin directory for globally installed packages - binDir := filepath.Join(versionDir, "bin") - if execs, err := findExecutables(binDir); err == nil { + version := versionEntry.Name() + versionDir := filepath.Join(runtimeVersionsDir, version) + + // Scan the directories where runtime and package executables + // live. Anything found is recorded as a shim provided by this + // version. We deliberately do NOT pre-populate the provider's + // declared core shims (e.g. python3, pip3) without checking + // the filesystem: an embeddable Windows install may ship only + // python.exe, and asserting that 3.8.9 "provides pip" when it + // doesn't would corrupt the version-coverage data the shim + // uses to give users an informed error. + if execs, err := findExecutables(filepath.Join(versionDir, "bin")); err == nil { for _, exec := range execs { - shimMap[exec] = runtimeName - shimsByRuntime[runtimeName] = appendUnique(shimsByRuntime[runtimeName], exec) + recordShim(exec, version) } } @@ -284,16 +301,12 @@ func (m *Manager) RehashWithCallback(callback RehashCallback) (*RehashResult, er if runtime.GOOS == constants.OSWindows { if execs, err := findExecutables(versionDir); err == nil { for _, exec := range execs { - shimMap[exec] = runtimeName - shimsByRuntime[runtimeName] = appendUnique(shimsByRuntime[runtimeName], exec) + recordShim(exec, version) } } - // Check Scripts directory for Python pip packages - scriptsDir := filepath.Join(versionDir, "Scripts") - if execs, err := findExecutables(scriptsDir); err == nil { + if execs, err := findExecutables(filepath.Join(versionDir, "Scripts")); err == nil { for _, exec := range execs { - shimMap[exec] = runtimeName - shimsByRuntime[runtimeName] = appendUnique(shimsByRuntime[runtimeName], exec) + recordShim(exec, version) } } } diff --git a/src/runtimes/node/provider.go b/src/runtimes/node/provider.go index c137981..f42cb34 100644 --- a/src/runtimes/node/provider.go +++ b/src/runtimes/node/provider.go @@ -119,7 +119,7 @@ func (p *Provider) Install(version string) error { // Create shims with spinner shimSpinner := ui.NewSpinner("Creating shims...") shimSpinner.Start() - if err := p.createShims(); err != nil { + if err := p.createShims(version); err != nil { shimSpinner.Error("Failed to create shims") return fmt.Errorf("failed to create shims: %w", err) } @@ -154,8 +154,10 @@ func (p *Provider) getDownloadURL(version string) (string, string, error) { // createShims creates shims for Node.js executables and registers them in the // shim-map cache so subsequent shim invocations resolve via O(1) lookup rather -// than falling back to the provider registry. -func (p *Provider) createShims() error { +// than falling back to the provider registry. The version is recorded in the +// cache so the shim can detect when an active runtime version is one that +// does not provide a given executable. +func (p *Provider) createShims(version string) error { manager, err := shim.NewManager() if err != nil { return err @@ -165,7 +167,7 @@ func (p *Provider) createShims() error { shimNames := shim.RuntimeShims("node") // Create each shim AND record them in the shim map cache - return manager.CreateShimsForRuntime("node", shimNames) + return manager.CreateShimsForRuntime("node", version, shimNames) } // Uninstall removes an installed version diff --git a/src/runtimes/python/provider.go b/src/runtimes/python/provider.go index 5952d3c..5133b37 100644 --- a/src/runtimes/python/provider.go +++ b/src/runtimes/python/provider.go @@ -176,7 +176,7 @@ func (p *Provider) Install(version string) error { // Create shims (after pip is installed, all executables now exist) shimSpinner := ui.NewSpinner("Creating shims...") shimSpinner.Start() - if err := p.createShims(); err != nil { + if err := p.createShims(version); err != nil { shimSpinner.Error("Failed to create shims") return fmt.Errorf("failed to create shims: %w", err) } @@ -211,8 +211,10 @@ func (p *Provider) getDownloadURL(version string) (string, string, error) { // createShims creates shims for Python executables and registers them in the // shim-map cache so subsequent shim invocations resolve via O(1) lookup rather -// than falling back to the provider registry. -func (p *Provider) createShims() error { +// than falling back to the provider registry. The version is recorded in the +// cache so the shim can detect when an active runtime version is one that +// does not provide a given executable. +func (p *Provider) createShims(version string) error { manager, err := shim.NewManager() if err != nil { return err @@ -222,7 +224,7 @@ func (p *Provider) createShims() error { shimNames := shim.RuntimeShims("python") // Create each shim AND record them in the shim map cache - return manager.CreateShimsForRuntime("python", shimNames) + return manager.CreateShimsForRuntime("python", version, shimNames) } // installPip ensures pip is properly installed with working executables. diff --git a/src/runtimes/ruby/provider.go b/src/runtimes/ruby/provider.go index 305b4f7..daa553e 100644 --- a/src/runtimes/ruby/provider.go +++ b/src/runtimes/ruby/provider.go @@ -95,7 +95,7 @@ func (p *Provider) Install(version string) error { // Create shims shimSpinner := ui.NewSpinner("Creating shims...") shimSpinner.Start() - if err := p.createShims(); err != nil { + if err := p.createShims(version); err != nil { shimSpinner.Error("Failed to create shims") return fmt.Errorf("failed to create shims: %w", err) } @@ -235,8 +235,10 @@ func (p *Provider) getDownloadURL(version string) (string, string, error) { // createShims creates shims for Ruby executables and registers them in the // shim-map cache so subsequent shim invocations resolve via O(1) lookup rather -// than falling back to the provider registry. -func (p *Provider) createShims() error { +// than falling back to the provider registry. The version is recorded in the +// cache so the shim can detect when an active runtime version is one that +// does not provide a given executable. +func (p *Provider) createShims(version string) error { manager, err := shim.NewManager() if err != nil { return err @@ -246,7 +248,7 @@ func (p *Provider) createShims() error { shimNames := shim.RuntimeShims("ruby") // Create each shim AND record them in the shim map cache - return manager.CreateShimsForRuntime("ruby", shimNames) + return manager.CreateShimsForRuntime("ruby", version, shimNames) } // Uninstall removes an installed version