@@ -3,16 +3,38 @@ package shim
33
44import (
55 "encoding/json"
6+ "fmt"
67 "os"
78 "sync"
89
910 "github.com/CodingWithCalvin/dtvem.cli/src/internal/config"
1011)
1112
12- // ShimMap represents the shim-to-runtime mapping cache
13- // The map key is the shim name (e.g., "tsc", "npm", "black")
14- // The map value is the runtime name (e.g., "node", "python")
15- type ShimMap map [string ]string
13+ // ShimEntry is the per-shim record stored in the shim-map cache. It binds
14+ // a shim name to the runtime that owns it AND to the set of installed
15+ // runtime versions that actually provide the executable on disk.
16+ //
17+ // The version data lets the shim distinguish between "command shimmed for
18+ // this runtime, also present in the active version" and "command shimmed
19+ // for this runtime, but the active version doesn't have it" — for example
20+ // when `uv` is installed via `pip install uv` against Python 3.9 but the
21+ // user switches to a 3.8 install that never received uv.
22+ type ShimEntry struct {
23+ // Runtime is the runtime name (e.g., "python", "node", "ruby").
24+ Runtime string `json:"runtime"`
25+
26+ // Versions is the set of installed runtime versions that provide the
27+ // executable. Empty / nil means "version coverage unknown" — typically
28+ // because the cache was loaded from the legacy schema or because the
29+ // caller did not supply version data — and callers should treat empty
30+ // as "skip the version check" rather than "no providing versions".
31+ Versions []string `json:"versions,omitempty"`
32+ }
33+
34+ // ShimMap represents the shim-to-runtime mapping cache. The map key is the
35+ // shim name (e.g., "tsc", "npm", "uv"); the value is a ShimEntry binding
36+ // it to a runtime and (optionally) the set of versions that provide it.
37+ type ShimMap map [string ]ShimEntry
1638
1739var (
1840 shimMapCache ShimMap
@@ -30,7 +52,11 @@ func LoadShimMap() (ShimMap, error) {
3052 return shimMapCache , shimMapCacheErr
3153}
3254
33- // loadShimMapFromDisk reads the shim map cache file from disk
55+ // loadShimMapFromDisk reads the shim map cache file from disk, tolerating
56+ // both the current schema (ShimEntry values with versions) and the legacy
57+ // flat schema (string values mapping shim → runtime). Legacy entries are
58+ // converted with empty Versions, signaling "version coverage unknown" to
59+ // callers; a subsequent `dtvem reshim` will re-populate the version data.
3460func loadShimMapFromDisk () (ShimMap , error ) {
3561 cachePath := config .ShimMapPath ()
3662
@@ -39,12 +65,40 @@ func loadShimMapFromDisk() (ShimMap, error) {
3965 return nil , err
4066 }
4167
42- var shimMap ShimMap
43- if err := json .Unmarshal (data , & shimMap ); err != nil {
44- return nil , err
68+ // Try the current schema first.
69+ var current ShimMap
70+ if err := json .Unmarshal (data , & current ); err == nil && schemaIsCurrent (current ) {
71+ return current , nil
72+ }
73+
74+ // Fall back to the legacy schema (shim → runtime name).
75+ var legacy map [string ]string
76+ if err := json .Unmarshal (data , & legacy ); err == nil {
77+ converted := make (ShimMap , len (legacy ))
78+ for shim , runtime := range legacy {
79+ converted [shim ] = ShimEntry {Runtime : runtime }
80+ }
81+ return converted , nil
4582 }
4683
47- return shimMap , nil
84+ return nil , fmt .Errorf ("shim-map cache at %s is in an unrecognized format" , cachePath )
85+ }
86+
87+ // schemaIsCurrent reports whether a successfully-unmarshalled ShimMap was
88+ // actually serialized with the current schema. A legacy file like
89+ // `{"uv": "python"}` will technically unmarshal into ShimMap (every entry
90+ // becomes a zero-valued ShimEntry), so a Runtime field that is empty for
91+ // every entry indicates the input was actually legacy.
92+ func schemaIsCurrent (m ShimMap ) bool {
93+ if len (m ) == 0 {
94+ return true
95+ }
96+ for _ , entry := range m {
97+ if entry .Runtime != "" {
98+ return true
99+ }
100+ }
101+ return false
48102}
49103
50104// SaveShimMap writes the shim-to-runtime mapping to the cache file.
@@ -69,8 +123,11 @@ func SaveShimMap(shimMap ShimMap) error {
69123// MergeShimMap merges the given entries into the on-disk shim map and persists it.
70124//
71125// If the cache does not exist yet (first-time install), a new map is created.
72- // Existing entries with matching keys are overwritten. The in-memory cache is
73- // reset so subsequent LoadShimMap calls read the updated state from disk.
126+ // For shims already in the cache, the runtime is overwritten with the new
127+ // value (typically the same) and the providing-versions set is unioned —
128+ // so installing a second runtime version that provides the same executable
129+ // extends the version list rather than clobbering it. The in-memory cache
130+ // is reset so subsequent LoadShimMap calls read the updated state from disk.
74131//
75132// This is the preferred path for install-time shim registration, where the
76133// caller knows only the shims it just created and wants to register them
@@ -83,8 +140,14 @@ func MergeShimMap(entries ShimMap) error {
83140 existing = make (ShimMap , len (entries ))
84141 }
85142
86- for shim , runtime := range entries {
87- existing [shim ] = runtime
143+ for shim , entry := range entries {
144+ if cur , ok := existing [shim ]; ok {
145+ cur .Runtime = entry .Runtime
146+ cur .Versions = unionVersions (cur .Versions , entry .Versions )
147+ existing [shim ] = cur
148+ } else {
149+ existing [shim ] = entry
150+ }
88151 }
89152
90153 // Force the next LoadShimMap to re-read from disk so the merged entries
@@ -94,16 +157,52 @@ func MergeShimMap(entries ShimMap) error {
94157 return SaveShimMap (existing )
95158}
96159
97- // LookupRuntime looks up the runtime for a given shim name using the cache.
98- // Returns the runtime name and true if found, or empty string and false if not.
99- func LookupRuntime (shimName string ) (string , bool ) {
160+ // unionVersions returns a slice containing every distinct version from a
161+ // and b, preserving the order in which versions are first seen.
162+ func unionVersions (a , b []string ) []string {
163+ if len (a ) == 0 && len (b ) == 0 {
164+ return nil
165+ }
166+ seen := make (map [string ]struct {}, len (a )+ len (b ))
167+ out := make ([]string , 0 , len (a )+ len (b ))
168+ for _ , v := range a {
169+ if _ , ok := seen [v ]; ok {
170+ continue
171+ }
172+ seen [v ] = struct {}{}
173+ out = append (out , v )
174+ }
175+ for _ , v := range b {
176+ if _ , ok := seen [v ]; ok {
177+ continue
178+ }
179+ seen [v ] = struct {}{}
180+ out = append (out , v )
181+ }
182+ return out
183+ }
184+
185+ // Lookup returns the full ShimEntry (runtime + providing versions) for a
186+ // shim, or zero-value and false if the shim is not in the cache. Use this
187+ // when you need the version coverage data; for callers that only need the
188+ // runtime name, use LookupRuntime.
189+ func Lookup (shimName string ) (ShimEntry , bool ) {
100190 shimMap , err := LoadShimMap ()
101191 if err != nil {
102- return "" , false
192+ return ShimEntry {} , false
103193 }
194+ entry , ok := shimMap [shimName ]
195+ return entry , ok
196+ }
104197
105- runtime , ok := shimMap [shimName ]
106- return runtime , ok
198+ // LookupRuntime looks up the runtime for a given shim name using the cache.
199+ // Returns the runtime name and true if found, or empty string and false if not.
200+ //
201+ // This is a convenience wrapper around Lookup for callers that don't need
202+ // the providing-versions data.
203+ func LookupRuntime (shimName string ) (string , bool ) {
204+ entry , ok := Lookup (shimName )
205+ return entry .Runtime , ok
107206}
108207
109208// ResetShimMapCache resets the cached shim map, forcing a reload on next access.
0 commit comments