@@ -0,0 +1,369 @@
1+package models
2+
3+import (
4+ "encoding/json"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ tea "github.com/charmbracelet/bubbletea/v2"
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
13+ "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/log"
15+ "github.com/charmbracelet/crush/internal/tui/exp/list"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+// execCmdML runs a tea.Cmd through the ModelListComponent's Update loop.
20+func execCmdML(t *testing.T, m *ModelListComponent, cmd tea.Cmd) {
21+ t.Helper()
22+ for cmd != nil {
23+ msg := cmd()
24+ var next tea.Cmd
25+ _, next = m.Update(msg)
26+ cmd = next
27+ }
28+}
29+
30+// readConfigJSON reads and unmarshals the JSON config file at path.
31+func readConfigJSON(t *testing.T, path string) map[string]any {
32+ t.Helper()
33+ baseDir := filepath.Dir(path)
34+ fileName := filepath.Base(path)
35+ b, err := fs.ReadFile(os.DirFS(baseDir), fileName)
36+ require.NoError(t, err)
37+ var out map[string]any
38+ require.NoError(t, json.Unmarshal(b, &out))
39+ return out
40+}
41+
42+// readRecentModels reads the recent_models section from the config file.
43+func readRecentModels(t *testing.T, path string) map[string]any {
44+ t.Helper()
45+ out := readConfigJSON(t, path)
46+ rm, ok := out["recent_models"].(map[string]any)
47+ require.True(t, ok)
48+ return rm
49+}
50+
51+func TestModelList_RecentlyUsedSectionAndPrunesInvalid(t *testing.T) {
52+ // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
53+ log.Setup(os.DevNull, false)
54+
55+ // Isolate config/data paths
56+ cfgDir := t.TempDir()
57+ dataDir := t.TempDir()
58+ t.Setenv("XDG_CONFIG_HOME", cfgDir)
59+ t.Setenv("XDG_DATA_HOME", dataDir)
60+
61+ // Pre-seed config so provider auto-update is disabled and we have recents
62+ confPath := filepath.Join(cfgDir, "crush", "crush.json")
63+ require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
64+ initial := map[string]any{
65+ "options": map[string]any{
66+ "disable_provider_auto_update": true,
67+ },
68+ "models": map[string]any{
69+ "large": map[string]any{
70+ "model": "m1",
71+ "provider": "p1",
72+ },
73+ },
74+ "recent_models": map[string]any{
75+ "large": []any{
76+ map[string]any{"model": "m2", "provider": "p1"}, // valid
77+ map[string]any{"model": "x", "provider": "unknown-provider"}, // invalid -> pruned
78+ },
79+ },
80+ }
81+ bts, err := json.Marshal(initial)
82+ require.NoError(t, err)
83+ require.NoError(t, os.WriteFile(confPath, bts, 0o644))
84+
85+ // Also create empty providers.json to prevent loading real providers
86+ dataConfDir := filepath.Join(dataDir, "crush")
87+ require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
88+ emptyProviders := []byte("[]")
89+ require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
90+
91+ // Initialize global config instance (no network due to auto-update disabled)
92+ _, err = config.Init(cfgDir, dataDir, false)
93+ require.NoError(t, err)
94+
95+ // Build a small provider set for the list component
96+ provider := catwalk.Provider{
97+ ID: catwalk.InferenceProvider("p1"),
98+ Name: "Provider One",
99+ Models: []catwalk.Model{
100+ {ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
101+ {ID: "m2", Name: "Model Two", DefaultMaxTokens: 100}, // recent
102+ },
103+ }
104+
105+ // Create and initialize the component with our provider set
106+ listKeyMap := list.DefaultKeyMap()
107+ cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
108+ cmp.providers = []catwalk.Provider{provider}
109+ execCmdML(t, cmp, cmp.Init())
110+
111+ // Find all recent items (IDs prefixed with "recent::") and verify pruning
112+ groups := cmp.list.Groups()
113+ require.NotEmpty(t, groups)
114+ var recentItems []list.CompletionItem[ModelOption]
115+ for _, g := range groups {
116+ for _, it := range g.Items {
117+ if strings.HasPrefix(it.ID(), "recent::") {
118+ recentItems = append(recentItems, it)
119+ }
120+ }
121+ }
122+ require.NotEmpty(t, recentItems, "no recent items found")
123+ // Ensure the valid recent (p1:m2) is present and the invalid one is not
124+ foundValid := false
125+ for _, it := range recentItems {
126+ if it.ID() == "recent::p1:m2" {
127+ foundValid = true
128+ }
129+ require.NotEqual(t, "recent::unknown-provider:x", it.ID(), "invalid recent should be pruned")
130+ }
131+ require.True(t, foundValid, "expected valid recent not found")
132+
133+ // Verify original config in cfgDir remains unchanged
134+ origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
135+ afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
136+ require.NoError(t, err)
137+ var origParsed map[string]any
138+ require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
139+ origRM := origParsed["recent_models"].(map[string]any)
140+ origLarge := origRM["large"].([]any)
141+ require.Len(t, origLarge, 2, "original config should be unchanged")
142+
143+ // Config should be rewritten with pruned recents in dataDir
144+ dataConf := filepath.Join(dataDir, "crush", "crush.json")
145+ rm := readRecentModels(t, dataConf)
146+ largeAny, ok := rm["large"].([]any)
147+ require.True(t, ok)
148+ // Ensure that only valid recent(s) remain and the invalid one is removed
149+ found := false
150+ for _, v := range largeAny {
151+ m := v.(map[string]any)
152+ require.NotEqual(t, "unknown-provider", m["provider"], "invalid provider should be pruned")
153+ if m["provider"] == "p1" && m["model"] == "m2" {
154+ found = true
155+ }
156+ }
157+ require.True(t, found, "persisted recents should include p1:m2")
158+}
159+
160+func TestModelList_PrunesInvalidModelWithinValidProvider(t *testing.T) {
161+ // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
162+ log.Setup(os.DevNull, false)
163+
164+ // Isolate config/data paths
165+ cfgDir := t.TempDir()
166+ dataDir := t.TempDir()
167+ t.Setenv("XDG_CONFIG_HOME", cfgDir)
168+ t.Setenv("XDG_DATA_HOME", dataDir)
169+
170+ // Pre-seed config with valid provider but one invalid model
171+ confPath := filepath.Join(cfgDir, "crush", "crush.json")
172+ require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
173+ initial := map[string]any{
174+ "options": map[string]any{
175+ "disable_provider_auto_update": true,
176+ },
177+ "models": map[string]any{
178+ "large": map[string]any{
179+ "model": "m1",
180+ "provider": "p1",
181+ },
182+ },
183+ "recent_models": map[string]any{
184+ "large": []any{
185+ map[string]any{"model": "m1", "provider": "p1"}, // valid
186+ map[string]any{"model": "missing", "provider": "p1"}, // invalid model
187+ },
188+ },
189+ }
190+ bts, err := json.Marshal(initial)
191+ require.NoError(t, err)
192+ require.NoError(t, os.WriteFile(confPath, bts, 0o644))
193+
194+ // Create empty providers.json
195+ dataConfDir := filepath.Join(dataDir, "crush")
196+ require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
197+ emptyProviders := []byte("[]")
198+ require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
199+
200+ // Initialize global config instance
201+ _, err = config.Init(cfgDir, dataDir, false)
202+ require.NoError(t, err)
203+
204+ // Build provider set that only includes m1, not "missing"
205+ provider := catwalk.Provider{
206+ ID: catwalk.InferenceProvider("p1"),
207+ Name: "Provider One",
208+ Models: []catwalk.Model{
209+ {ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
210+ },
211+ }
212+
213+ // Create and initialize component
214+ listKeyMap := list.DefaultKeyMap()
215+ cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
216+ cmp.providers = []catwalk.Provider{provider}
217+ execCmdML(t, cmp, cmp.Init())
218+
219+ // Find all recent items
220+ groups := cmp.list.Groups()
221+ require.NotEmpty(t, groups)
222+ var recentItems []list.CompletionItem[ModelOption]
223+ for _, g := range groups {
224+ for _, it := range g.Items {
225+ if strings.HasPrefix(it.ID(), "recent::") {
226+ recentItems = append(recentItems, it)
227+ }
228+ }
229+ }
230+ require.NotEmpty(t, recentItems, "valid recent should exist")
231+
232+ // Verify the valid recent is present and invalid model is not
233+ foundValid := false
234+ for _, it := range recentItems {
235+ if it.ID() == "recent::p1:m1" {
236+ foundValid = true
237+ }
238+ require.NotEqual(t, "recent::p1:missing", it.ID(), "invalid model should be pruned")
239+ }
240+ require.True(t, foundValid, "valid recent p1:m1 should be present")
241+
242+ // Verify original config in cfgDir remains unchanged
243+ origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
244+ afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
245+ require.NoError(t, err)
246+ var origParsed map[string]any
247+ require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
248+ origRM := origParsed["recent_models"].(map[string]any)
249+ origLarge := origRM["large"].([]any)
250+ require.Len(t, origLarge, 2, "original config should be unchanged")
251+
252+ // Config should be rewritten with pruned recents in dataDir
253+ dataConf := filepath.Join(dataDir, "crush", "crush.json")
254+ rm := readRecentModels(t, dataConf)
255+ largeAny, ok := rm["large"].([]any)
256+ require.True(t, ok)
257+ require.Len(t, largeAny, 1, "should only have one valid model")
258+ // Verify only p1:m1 remains
259+ m := largeAny[0].(map[string]any)
260+ require.Equal(t, "p1", m["provider"])
261+ require.Equal(t, "m1", m["model"])
262+}
263+
264+func TestModelKey_EmptyInputs(t *testing.T) {
265+ // Empty provider
266+ require.Equal(t, "", modelKey("", "model"))
267+ // Empty model
268+ require.Equal(t, "", modelKey("provider", ""))
269+ // Both empty
270+ require.Equal(t, "", modelKey("", ""))
271+ // Valid inputs
272+ require.Equal(t, "p:m", modelKey("p", "m"))
273+}
274+
275+func TestModelList_AllRecentsInvalid(t *testing.T) {
276+ // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
277+ log.Setup(os.DevNull, false)
278+
279+ // Isolate config/data paths
280+ cfgDir := t.TempDir()
281+ dataDir := t.TempDir()
282+ t.Setenv("XDG_CONFIG_HOME", cfgDir)
283+ t.Setenv("XDG_DATA_HOME", dataDir)
284+
285+ // Pre-seed config with only invalid recents
286+ confPath := filepath.Join(cfgDir, "crush", "crush.json")
287+ require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
288+ initial := map[string]any{
289+ "options": map[string]any{
290+ "disable_provider_auto_update": true,
291+ },
292+ "models": map[string]any{
293+ "large": map[string]any{
294+ "model": "m1",
295+ "provider": "p1",
296+ },
297+ },
298+ "recent_models": map[string]any{
299+ "large": []any{
300+ map[string]any{"model": "x", "provider": "unknown1"},
301+ map[string]any{"model": "y", "provider": "unknown2"},
302+ },
303+ },
304+ }
305+ bts, err := json.Marshal(initial)
306+ require.NoError(t, err)
307+ require.NoError(t, os.WriteFile(confPath, bts, 0o644))
308+
309+ // Also create empty providers.json and data config
310+ dataConfDir := filepath.Join(dataDir, "crush")
311+ require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
312+ emptyProviders := []byte("[]")
313+ require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
314+
315+ // Initialize global config instance with isolated dataDir
316+ _, err = config.Init(cfgDir, dataDir, false)
317+ require.NoError(t, err)
318+
319+ // Build provider set (doesn't include unknown1 or unknown2)
320+ provider := catwalk.Provider{
321+ ID: catwalk.InferenceProvider("p1"),
322+ Name: "Provider One",
323+ Models: []catwalk.Model{
324+ {ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
325+ },
326+ }
327+
328+ // Create and initialize component
329+ listKeyMap := list.DefaultKeyMap()
330+ cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
331+ cmp.providers = []catwalk.Provider{provider}
332+ execCmdML(t, cmp, cmp.Init())
333+
334+ // Verify no recent items exist in UI
335+ groups := cmp.list.Groups()
336+ require.NotEmpty(t, groups)
337+ var recentItems []list.CompletionItem[ModelOption]
338+ for _, g := range groups {
339+ for _, it := range g.Items {
340+ if strings.HasPrefix(it.ID(), "recent::") {
341+ recentItems = append(recentItems, it)
342+ }
343+ }
344+ }
345+ require.Empty(t, recentItems, "all invalid recents should be pruned, resulting in no recent section")
346+
347+ // Verify original config in cfgDir remains unchanged
348+ origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
349+ afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
350+ require.NoError(t, err)
351+ var origParsed map[string]any
352+ require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
353+ origRM := origParsed["recent_models"].(map[string]any)
354+ origLarge := origRM["large"].([]any)
355+ require.Len(t, origLarge, 2, "original config should be unchanged")
356+
357+ // Config should be rewritten with empty recents in dataDir
358+ dataConf := filepath.Join(dataDir, "crush", "crush.json")
359+ rm := readRecentModels(t, dataConf)
360+ // When all recents are pruned, the value may be nil or an empty array
361+ largeVal := rm["large"]
362+ if largeVal == nil {
363+ // nil is acceptable - means empty
364+ return
365+ }
366+ largeAny, ok := largeVal.([]any)
367+ require.True(t, ok, "large key should be nil or array")
368+ require.Empty(t, largeAny, "persisted recents should be empty after pruning all invalid entries")
369+}