@@ -0,0 +1,369 @@
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"
19+// execCmdML runs a tea.Cmd through the ModelListComponent's Update loop.
20+func execCmdML(t *testing.T, m *ModelListComponent, cmd tea.Cmd) {
25+ _, next = m.Update(msg)
30+// readConfigJSON reads and unmarshals the JSON config file at path.
31+func readConfigJSON(t *testing.T, path string) map[string]any {
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))
42+// readRecentModels reads the recent_models section from the config file.
43+func readRecentModels(t *testing.T, path string) map[string]any {
45+ out := readConfigJSON(t, path)
46+ rm, ok := out["recent_models"].(map[string]any)
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)
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)
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,
68+ "models": map[string]any{
69+ "large": map[string]any{
74+ "recent_models": map[string]any{
76+ map[string]any{"model": "m2", "provider": "p1"}, // valid
77+ map[string]any{"model": "x", "provider": "unknown-provider"}, // invalid -> pruned
81+ bts, err := json.Marshal(initial)
82+ require.NoError(t, err)
83+ require.NoError(t, os.WriteFile(confPath, bts, 0o644))
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))
91+ // Initialize global config instance (no network due to auto-update disabled)
92+ _, err = config.Init(cfgDir, dataDir, false)
93+ require.NoError(t, err)
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
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())
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)
122+ require.NotEmpty(t, recentItems, "no recent items found")
123+ // Ensure the valid recent (p1:m2) is present and the invalid one is not
125+ for _, it := range recentItems {
126+ if it.ID() == "recent::p1:m2" {
129+ require.NotEqual(t, "recent::unknown-provider:x", it.ID(), "invalid recent should be pruned")
131+ require.True(t, foundValid, "expected valid recent not found")
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")
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)
148+ // Ensure that only valid recent(s) remain and the invalid one is removed
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" {
157+ require.True(t, found, "persisted recents should include p1:m2")
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)
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)
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,
177+ "models": map[string]any{
178+ "large": map[string]any{
183+ "recent_models": map[string]any{
185+ map[string]any{"model": "m1", "provider": "p1"}, // valid
186+ map[string]any{"model": "missing", "provider": "p1"}, // invalid model
190+ bts, err := json.Marshal(initial)
191+ require.NoError(t, err)
192+ require.NoError(t, os.WriteFile(confPath, bts, 0o644))
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))
200+ // Initialize global config instance
201+ _, err = config.Init(cfgDir, dataDir, false)
202+ require.NoError(t, err)
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},
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())
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)
230+ require.NotEmpty(t, recentItems, "valid recent should exist")
232+ // Verify the valid recent is present and invalid model is not
234+ for _, it := range recentItems {
235+ if it.ID() == "recent::p1:m1" {
238+ require.NotEqual(t, "recent::p1:missing", it.ID(), "invalid model should be pruned")
240+ require.True(t, foundValid, "valid recent p1:m1 should be present")
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")
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)
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"])
264+func TestModelKey_EmptyInputs(t *testing.T) {
266+ require.Equal(t, "", modelKey("", "model"))
268+ require.Equal(t, "", modelKey("provider", ""))
270+ require.Equal(t, "", modelKey("", ""))
272+ require.Equal(t, "p:m", modelKey("p", "m"))
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)
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)
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,
292+ "models": map[string]any{
293+ "large": map[string]any{
298+ "recent_models": map[string]any{
300+ map[string]any{"model": "x", "provider": "unknown1"},
301+ map[string]any{"model": "y", "provider": "unknown2"},
305+ bts, err := json.Marshal(initial)
306+ require.NoError(t, err)
307+ require.NoError(t, os.WriteFile(confPath, bts, 0o644))
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))
315+ // Initialize global config instance with isolated dataDir
316+ _, err = config.Init(cfgDir, dataDir, false)
317+ require.NoError(t, err)
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},
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())
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)
345+ require.Empty(t, recentItems, "all invalid recents should be pruned, resulting in no recent section")
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")
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
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")