1package modes
2
3import (
4 "os"
5 "path/filepath"
6 "sort"
7 "strings"
8 "testing"
9 "time"
10)
11
12func TestExpandFileChipsAtPickerShape(t *testing.T) {
13 cwd := filepath.Join(string(filepath.Separator), "repo")
14 in := "read [file:README.md] and [dir:docs/]"
15 want := "read " + filepath.Join(cwd, "README.md") + " and " + filepath.Join(cwd, "docs")
16 if got := expandFileChips(in, cwd); got != want {
17 t.Fatalf("expandFileChips() = %q, want %q", got, want)
18 }
19}
20
21func TestExpandFileChipsLeavesEditorPlaceholderShapeAlone(t *testing.T) {
22 cwd := filepath.Join(string(filepath.Separator), "repo")
23 // tui.Editor.SubmitValue should expand [file:N:name] using its
24 // private path map before modes see the text. If such a token leaks
25 // through, modes must not guess that "1:foo.txt" is a relative path.
26 in := "read [file:1:foo.txt]"
27 if got := expandFileChips(in, cwd); got != in {
28 t.Fatalf("editor placeholder was changed: %q", got)
29 }
30}
31
32// TestFileSuggesterPicksUpNewEntries pins the cache-invalidation bug
33// fix: creating a subdirectory after the picker has already scanned
34// the cwd must surface that subdirectory on the next scan, without
35// any explicit invalidation call. The cache is keyed on the dir's
36// mtime, which the OS bumps on every entry add/remove/rename.
37func TestFileSuggesterPicksUpNewEntries(t *testing.T) {
38 tmp := t.TempDir()
39 if err := os.WriteFile(filepath.Join(tmp, "existing.txt"), []byte("x"), 0o644); err != nil {
40 t.Fatal(err)
41 }
42
43 s := newFileSuggester()
44 s.SetCWD(tmp)
45
46 first := s.scan()
47 if !containsEntry(first, "existing.txt", false) {
48 t.Fatalf("first scan missing existing.txt: %#v", first)
49 }
50 if containsEntry(first, "test", true) {
51 t.Fatalf("first scan unexpectedly saw the not-yet-created test/: %#v", first)
52 }
53
54 // Sleep one filesystem tick: HFS+/APFS/ext4 all bump directory
55 // mtime with at-least 1s resolution depending on mount options.
56 // Without this sleep on coarse-resolution filesystems the mtime
57 // after Mkdir can equal the mtime captured during the first scan
58 // and the cache would (correctly) be retained.
59 time.Sleep(1100 * time.Millisecond)
60 if err := os.Mkdir(filepath.Join(tmp, "test"), 0o755); err != nil {
61 t.Fatal(err)
62 }
63
64 second := s.scan()
65 if !containsEntry(second, "test", true) {
66 t.Fatalf("second scan did not pick up the newly created test/: %#v", second)
67 }
68 if !containsEntry(second, "existing.txt", false) {
69 t.Fatalf("second scan dropped existing.txt: %#v", second)
70 }
71
72 // Directories sort before files.
73 sorted := make([]string, 0, len(second))
74 for _, e := range second {
75 sorted = append(sorted, e.name)
76 }
77 if !sort.IsSorted(byDirsFirst(second)) {
78 t.Fatalf("entries are not dirs-first / case-insensitive sorted: %v", sorted)
79 }
80}
81
82func containsEntry(entries []fileEntry, name string, isDir bool) bool {
83 for _, e := range entries {
84 if e.name == name && e.isDir == isDir {
85 return true
86 }
87 }
88 return false
89}
90
91type byDirsFirst []fileEntry
92
93func (b byDirsFirst) Len() int { return len(b) }
94func (b byDirsFirst) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
95func (b byDirsFirst) Less(i, j int) bool {
96 if b[i].isDir != b[j].isDir {
97 return b[i].isDir
98 }
99 return b[i].name < b[j].name
100}
101
102// TestFileSuggesterFuzzyMatch verifies the @-query ranks entries with
103// a fuzzy subsequence match rather than a plain substring, so a
104// non-contiguous pattern like "fsg" still finds "file_suggest.go".
105func TestFileSuggesterFuzzyMatch(t *testing.T) {
106 tmp := t.TempDir()
107 for _, name := range []string{"file_suggest.go", "interactive.go", "README.md"} {
108 if err := os.WriteFile(filepath.Join(tmp, name), []byte("x"), 0o644); err != nil {
109 t.Fatal(err)
110 }
111 }
112 s := newFileSuggester()
113 s.SetCWD(tmp)
114
115 got := s.matches("@fsg")
116 if !containsEntry(got, "file_suggest.go", false) {
117 t.Fatalf("fuzzy query @fsg did not match file_suggest.go: %#v", got)
118 }
119 if len(got) == 0 || got[0].name != "file_suggest.go" {
120 t.Fatalf("file_suggest.go not ranked first for @fsg: %#v", got)
121 }
122}
123
124// TestFileSuggesterRecursiveMatch verifies recursive mode flattens the
125// tree and matches against the cwd-relative path, so a pattern can
126// span directory boundaries.
127func TestFileSuggesterRecursiveMatch(t *testing.T) {
128 tmp := t.TempDir()
129 if err := os.MkdirAll(filepath.Join(tmp, "src", "foo"), 0o755); err != nil {
130 t.Fatal(err)
131 }
132 if err := os.WriteFile(filepath.Join(tmp, "src", "foo", "bar.go"), []byte("x"), 0o644); err != nil {
133 t.Fatal(err)
134 }
135 s := newFileSuggester()
136 s.SetCWD(tmp)
137 s.SetRecursive(true)
138
139 rel := filepath.Join("src", "foo", "bar.go")
140 got := s.matches("@foobar")
141 if !containsEntry(got, rel, false) {
142 t.Fatalf("recursive @foobar did not match %s: %#v", rel, got)
143 }
144}
145
146// TestFileSuggesterRecursiveSkipsHeavyDirs ensures the walk prunes
147// directories like .git that would otherwise dominate the budget.
148func TestFileSuggesterRecursiveSkipsHeavyDirs(t *testing.T) {
149 tmp := t.TempDir()
150 if err := os.MkdirAll(filepath.Join(tmp, ".git", "objects"), 0o755); err != nil {
151 t.Fatal(err)
152 }
153 if err := os.WriteFile(filepath.Join(tmp, ".git", "objects", "deadbeef"), []byte("x"), 0o644); err != nil {
154 t.Fatal(err)
155 }
156 if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
157 t.Fatal(err)
158 }
159 s := newFileSuggester()
160 s.SetCWD(tmp)
161 s.SetRecursive(true)
162
163 all := s.scan()
164 for _, e := range all {
165 if e.rel == ".git" || strings.HasPrefix(e.rel, ".git"+string(filepath.Separator)) {
166 t.Fatalf("recursive scan descended into .git: %#v", e)
167 }
168 }
169 if !containsEntry(all, "main.go", false) {
170 t.Fatalf("recursive scan missing main.go: %#v", all)
171 }
172}
173
174// TestFileSuggesterRecursiveHonorsGitignore ensures the recursive walk
175// prunes anything listed in the project's root .gitignore — build
176// outputs, dependency dirs, and IaC tool caches like
177// .terraform/.terragrunt-cache — while still surfacing tracked files.
178func TestFileSuggesterRecursiveHonorsGitignore(t *testing.T) {
179 tmp := t.TempDir()
180 if err := os.WriteFile(filepath.Join(tmp, ".gitignore"),
181 []byte(".terraform/\n.terragrunt-cache/\nnode_modules/\n*.log\n"), 0o644); err != nil {
182 t.Fatal(err)
183 }
184 ignored := []string{".terraform", ".terragrunt-cache", "node_modules"}
185 for _, dir := range ignored {
186 nested := filepath.Join(tmp, dir, "deep")
187 if err := os.MkdirAll(nested, 0o755); err != nil {
188 t.Fatal(err)
189 }
190 if err := os.WriteFile(filepath.Join(nested, "junk"), []byte("x"), 0o644); err != nil {
191 t.Fatal(err)
192 }
193 }
194 if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
195 t.Fatal(err)
196 }
197 if err := os.WriteFile(filepath.Join(tmp, "main.tf"), []byte("x"), 0o644); err != nil {
198 t.Fatal(err)
199 }
200 s := newFileSuggester()
201 s.SetCWD(tmp)
202 s.SetRecursive(true)
203
204 all := s.scan()
205 for _, e := range all {
206 for _, skip := range ignored {
207 if e.rel == skip || strings.HasPrefix(e.rel, skip+string(filepath.Separator)) {
208 t.Fatalf("recursive scan descended into gitignored %s: %#v", skip, e)
209 }
210 }
211 if e.rel == "debug.log" {
212 t.Fatalf("recursive scan surfaced gitignored *.log file: %#v", e)
213 }
214 }
215 if !containsEntry(all, "main.tf", false) {
216 t.Fatalf("recursive scan missing tracked main.tf: %#v", all)
217 }
218}
219
220// TestFileSuggesterFlatModeHonorsGitignore verifies the default
221// directory-by-directory browse also hides gitignored entries and
222// always hides .git.
223func TestFileSuggesterFlatModeHonorsGitignore(t *testing.T) {
224 tmp := t.TempDir()
225 if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("node_modules/\n*.log\n"), 0o644); err != nil {
226 t.Fatal(err)
227 }
228 if err := os.MkdirAll(filepath.Join(tmp, "node_modules"), 0o755); err != nil {
229 t.Fatal(err)
230 }
231 if err := os.MkdirAll(filepath.Join(tmp, ".git"), 0o755); err != nil {
232 t.Fatal(err)
233 }
234 if err := os.WriteFile(filepath.Join(tmp, "debug.log"), []byte("x"), 0o644); err != nil {
235 t.Fatal(err)
236 }
237 if err := os.WriteFile(filepath.Join(tmp, "main.go"), []byte("x"), 0o644); err != nil {
238 t.Fatal(err)
239 }
240 s := newFileSuggester()
241 s.SetCWD(tmp) // flat mode, respectGitignore on by default
242
243 all := s.scan()
244 if containsEntry(all, "node_modules", true) {
245 t.Fatal("flat scan surfaced gitignored node_modules/")
246 }
247 if containsEntry(all, ".git", true) {
248 t.Fatal("flat scan surfaced .git/")
249 }
250 if containsEntry(all, "debug.log", false) {
251 t.Fatal("flat scan surfaced gitignored *.log file")
252 }
253 if !containsEntry(all, "main.go", false) {
254 t.Fatalf("flat scan missing tracked main.go: %#v", all)
255 }
256 // .gitignore itself is not ignored, so it should remain visible.
257 if !containsEntry(all, ".gitignore", false) {
258 t.Fatalf("flat scan missing .gitignore: %#v", all)
259 }
260}
261
262// TestFileSuggesterRespectGitignoreToggle verifies disabling the
263// setting surfaces gitignored entries in both modes (while .git stays
264// hidden in recursive mode to protect the entry budget).
265func TestFileSuggesterRespectGitignoreToggle(t *testing.T) {
266 tmp := t.TempDir()
267 if err := os.WriteFile(filepath.Join(tmp, ".gitignore"), []byte("dist/\n"), 0o644); err != nil {
268 t.Fatal(err)
269 }
270 if err := os.MkdirAll(filepath.Join(tmp, "dist"), 0o755); err != nil {
271 t.Fatal(err)
272 }
273 s := newFileSuggester()
274 s.SetCWD(tmp)
275
276 // On by default: dist/ hidden.
277 if containsEntry(s.scan(), "dist", true) {
278 t.Fatal("dist/ should be hidden while respectGitignore is on")
279 }
280 // Toggle off: dist/ visible (flat mode).
281 s.SetRespectGitignore(false)
282 if !containsEntry(s.scan(), "dist", true) {
283 t.Fatal("dist/ should be visible after disabling respectGitignore (flat)")
284 }
285 // And in recursive mode.
286 s.SetRecursive(true)
287 if !containsEntry(s.scan(), "dist", true) {
288 t.Fatal("dist/ should be visible after disabling respectGitignore (recursive)")
289 }
290}
291
292// TestFileSuggesterToggleResetsCache verifies SetRecursive drops the
293// cached scan so the next matches() reflects the new mode.
294func TestFileSuggesterToggleResetsCache(t *testing.T) {
295 tmp := t.TempDir()
296 if err := os.MkdirAll(filepath.Join(tmp, "pkg"), 0o755); err != nil {
297 t.Fatal(err)
298 }
299 if err := os.WriteFile(filepath.Join(tmp, "pkg", "nested.go"), []byte("x"), 0o644); err != nil {
300 t.Fatal(err)
301 }
302 s := newFileSuggester()
303 s.SetCWD(tmp)
304
305 rel := filepath.Join("pkg", "nested.go")
306 if containsEntry(s.matches("@nested"), rel, false) {
307 t.Fatal("flat mode unexpectedly saw nested.go")
308 }
309 s.SetRecursive(true)
310 if !containsEntry(s.matches("@nested"), rel, false) {
311 t.Fatal("recursive mode did not surface nested.go after toggle")
312 }
313}