1package models
2
3import (
4 "encoding/json"
5 "io/fs"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 tea "charm.land/bubbletea/v2"
12 "git.secluded.site/crush/internal/config"
13 "git.secluded.site/crush/internal/log"
14 "git.secluded.site/crush/internal/tui/exp/list"
15 "github.com/charmbracelet/catwalk/pkg/catwalk"
16 "github.com/stretchr/testify/require"
17)
18
19// execCmdML runs a tea.Cmd through the ModelListComponent's Update loop.
20func 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.
31func 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.
43func 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
51func 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
160func 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
264func 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
275func 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}