1import {
2 FileFinder,
3 type FileItem,
4 type GrepCursor,
5 type GrepOptions,
6 type GrepResult,
7} from "@ff-labs/fff-node";
8import { afterEach, expect, it } from "@effect/vitest";
9import * as Cause from "effect/Cause";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import { vi } from "vite-plus/test";
13
14import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts";
15
16afterEach(() => {
17 vi.restoreAllMocks();
18});
19
20function fileItem(relativePath: string): FileItem {
21 return {
22 relativePath,
23 fileName: relativePath.slice(relativePath.lastIndexOf("/") + 1),
24 size: 1,
25 modified: 0,
26 accessFrecencyScore: 0,
27 modificationFrecencyScore: 0,
28 totalFrecencyScore: 0,
29 gitStatus: "clean",
30 };
31}
32
33it.effect("filters image searches before applying the result limit", () =>
34 Effect.scoped(
35 Effect.gen(function* () {
36 const items = [
37 ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)),
38 fileItem("public/icon.svg"),
39 ];
40 const fileSearch = vi.fn(() => ({
41 ok: true as const,
42 value: {
43 items,
44 scores: [],
45 totalMatched: items.length,
46 totalFiles: items.length,
47 },
48 }));
49 const finder = {
50 destroy: vi.fn(),
51 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
52 fileSearch,
53 } as unknown as FileFinder;
54 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
55
56 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");
57 const resultWithoutKind = yield* searchIndex.search("", 200, undefined, true);
58 const resultWithDirectoryKind = yield* searchIndex.search("", 200, "directory", true);
59
60 expect(resultWithoutKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]);
61 expect(resultWithDirectoryKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]);
62 expect(fileSearch).toHaveBeenCalledTimes(2);
63 expect(fileSearch).toHaveBeenCalledWith("", { pageSize: 25_002 });
64 }),
65 ),
66);
67
68it.effect("preserves unexpected FileFinder creation failures", () =>
69 Effect.gen(function* () {
70 const cause = new Error("native initialization failed");
71 vi.spyOn(FileFinder, "create").mockImplementationOnce(() => {
72 throw cause;
73 });
74
75 const error = yield* Effect.flip(
76 Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")),
77 );
78
79 expect(error).toMatchObject({
80 _tag: "WorkspaceSearchIndexCreateFailed",
81 cwd: "/workspace/project",
82 reason: "FileFinder.create threw unexpectedly.",
83 cause,
84 });
85 }),
86);
87
88it.effect("keeps returned FileFinder creation diagnostics out of the cause chain", () =>
89 Effect.gen(function* () {
90 vi.spyOn(FileFinder, "create").mockReturnValueOnce({
91 ok: false,
92 error: "native index rejected the directory",
93 });
94
95 const error = yield* Effect.flip(
96 Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")),
97 );
98
99 expect(error).toMatchObject({
100 _tag: "WorkspaceSearchIndexCreateFailed",
101 cwd: "/workspace/project",
102 reason: "native index rejected the directory",
103 });
104 expect(error.cause).toBeUndefined();
105 }),
106);
107
108it.effect("waits for the full content index warmup before returning", () =>
109 Effect.gen(function* () {
110 const waitForIndexReady = vi.fn(async () => ({ ok: true as const, value: true }));
111 const finder = {
112 destroy: vi.fn(),
113 waitForIndexReady,
114 } as unknown as FileFinder;
115 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
116
117 yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content"));
118
119 expect(waitForIndexReady).toHaveBeenCalledWith(15_000);
120 }),
121);
122
123it.effect("preserves a full-index warmup timeout as a structured error", () =>
124 Effect.gen(function* () {
125 const finder = {
126 destroy: vi.fn(),
127 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })),
128 } as unknown as FileFinder;
129 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
130
131 const error = yield* Effect.flip(
132 Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")),
133 );
134
135 expect(error).toMatchObject({
136 _tag: "WorkspaceSearchIndexScanTimedOut",
137 cwd: "/workspace/project",
138 timeout: "15 seconds",
139 });
140 }),
141);
142
143it.effect("returns partial path results when the initial scan times out", () =>
144 Effect.scoped(
145 Effect.gen(function* () {
146 const mixedSearch = vi.fn(() => ({
147 ok: true as const,
148 value: {
149 items: [
150 {
151 type: "directory" as const,
152 item: { relativePath: "src", fileName: "src", fileCount: 1 },
153 },
154 { type: "file" as const, item: fileItem("src/index.ts") },
155 ],
156 scores: [],
157 totalMatched: 2,
158 totalFiles: 1,
159 },
160 }));
161 let scanning = true;
162 const finder = {
163 destroy: vi.fn(),
164 isScanning: vi.fn(() => scanning),
165 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })),
166 mixedSearch,
167 } as unknown as FileFinder;
168 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
169
170 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "paths");
171 const list = yield* searchIndex.list();
172 const search = yield* searchIndex.search("src", 10);
173 scanning = true;
174 mixedSearch.mockImplementationOnce(() => {
175 scanning = false;
176 return {
177 ok: true as const,
178 value: {
179 items: [
180 {
181 type: "directory" as const,
182 item: { relativePath: "src", fileName: "src", fileCount: 1 },
183 },
184 { type: "file" as const, item: fileItem("src/index.ts") },
185 ],
186 scores: [],
187 totalMatched: 2,
188 totalFiles: 1,
189 },
190 };
191 });
192 const searchCompletingDuringQuery = yield* searchIndex.search("src", 10);
193 scanning = false;
194 const completedSearch = yield* searchIndex.search("src", 10);
195
196 expect(list).toEqual({
197 entries: [
198 { kind: "directory", path: "src" },
199 { kind: "file", path: "src/index.ts" },
200 ],
201 truncated: true,
202 });
203 expect(search.truncated).toBe(true);
204 expect(searchCompletingDuringQuery.truncated).toBe(true);
205 expect(completedSearch.truncated).toBe(false);
206 expect(mixedSearch).toHaveBeenCalledTimes(4);
207 }),
208 ),
209);
210
211it.effect("preserves FileFinder destroy failures as structured defects", () =>
212 Effect.gen(function* () {
213 const cause = new Error("native destroy failed");
214 const finder = {
215 destroy: vi.fn(() => {
216 throw cause;
217 }),
218 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
219 } as unknown as FileFinder;
220 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
221
222 const exit = yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")).pipe(
223 Effect.exit,
224 );
225
226 expect(Exit.isFailure(exit)).toBe(true);
227 if (Exit.isFailure(exit)) {
228 expect(Cause.hasDies(exit.cause)).toBe(true);
229 const error = Cause.squash(exit.cause);
230 expect(error).toBeInstanceOf(WorkspaceSearchIndex.WorkspaceSearchIndexDestroyFailed);
231 expect(error).toMatchObject({
232 _tag: "WorkspaceSearchIndexDestroyFailed",
233 cwd: "/workspace/project",
234 cause,
235 });
236 }
237 }),
238);
239
240it.effect("preserves search and refresh failures with operation context", () =>
241 Effect.scoped(
242 Effect.gen(function* () {
243 const searchCause = new Error("native search failed");
244 const refreshCause = new Error("native scan failed");
245 const contentSearchCause = new Error("native grep failed");
246 const finder = {
247 destroy: vi.fn(),
248 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
249 mixedSearch: vi.fn(() => {
250 throw searchCause;
251 }),
252 grep: vi.fn(() => {
253 throw contentSearchCause;
254 }),
255 scanFiles: vi.fn(() => {
256 throw refreshCause;
257 }),
258 } as unknown as FileFinder;
259 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
260
261 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");
262 const query = "authorization: Bearer secret-token";
263 const searchError = yield* Effect.flip(searchIndex.search(query, 3));
264 const contentSearchError = yield* Effect.flip(
265 searchIndex.searchContents({
266 query,
267 limit: 3,
268 caseSensitive: false,
269 wholeWord: false,
270 useRegex: false,
271 }),
272 );
273 const refreshError = yield* Effect.flip(searchIndex.refresh());
274
275 expect(searchError).toMatchObject({
276 _tag: "WorkspaceSearchIndexSearchFailed",
277 cwd: "/workspace/project",
278 queryLength: query.length,
279 pageSize: 4,
280 reason: "FileFinder.mixedSearch threw unexpectedly.",
281 cause: searchCause,
282 });
283 expect(searchError).not.toHaveProperty("query");
284 expect(searchError.message).not.toMatch(/Bearer|secret-token/);
285 expect(contentSearchError).toMatchObject({
286 _tag: "WorkspaceSearchIndexSearchFailed",
287 cwd: "/workspace/project",
288 queryLength: query.length,
289 pageSize: 3,
290 reason: "FileFinder.grep threw unexpectedly.",
291 cause: contentSearchCause,
292 });
293 expect(contentSearchError).not.toHaveProperty("query");
294 expect(contentSearchError.message).not.toMatch(/Bearer|secret-token/);
295 expect(refreshError).toMatchObject({
296 _tag: "WorkspaceSearchIndexRefreshFailed",
297 cwd: "/workspace/project",
298 reason: "FileFinder.scanFiles threw unexpectedly.",
299 cause: refreshCause,
300 });
301 }),
302 ),
303);
304
305it.effect("keeps returned search diagnostics out of the cause chain", () =>
306 Effect.scoped(
307 Effect.gen(function* () {
308 const finder = {
309 destroy: vi.fn(),
310 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
311 mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })),
312 scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })),
313 } as unknown as FileFinder;
314 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
315
316 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project");
317 const query = "authorization: Bearer secret-token";
318 const searchError = yield* Effect.flip(searchIndex.search(query, 3));
319 const refreshError = yield* Effect.flip(searchIndex.refresh());
320
321 expect(searchError).toMatchObject({
322 _tag: "WorkspaceSearchIndexSearchFailed",
323 cwd: "/workspace/project",
324 queryLength: query.length,
325 pageSize: 4,
326 reason: "native query rejected",
327 });
328 expect(searchError).not.toHaveProperty("query");
329 expect(searchError.message).not.toMatch(/Bearer|secret-token/);
330 expect(searchError.cause).toBeUndefined();
331 expect(refreshError).toMatchObject({
332 _tag: "WorkspaceSearchIndexRefreshFailed",
333 cwd: "/workspace/project",
334 reason: "native refresh rejected",
335 });
336 expect(refreshError.cause).toBeUndefined();
337 }),
338 ),
339);
340
341it.effect("continues whole-word searches after a filtered grep page", () =>
342 Effect.scoped(
343 Effect.gen(function* () {
344 const nextCursor = {
345 __brand: "GrepCursor",
346 _offset: 1,
347 } as GrepCursor;
348 const grepResult = (
349 lineContent: string,
350 matchRanges: Array<[number, number]>,
351 cursor: GrepCursor | null,
352 ): GrepResult => ({
353 items: [
354 {
355 relativePath: "src/words.ts",
356 fileName: "words.ts",
357 gitStatus: "unmodified",
358 size: lineContent.length,
359 modified: 0,
360 isBinary: false,
361 totalFrecencyScore: 0,
362 accessFrecencyScore: 0,
363 modificationFrecencyScore: 0,
364 lineNumber: 1,
365 col: 0,
366 byteOffset: 0,
367 lineContent,
368 matchRanges,
369 },
370 ],
371 totalMatched: 1,
372 totalFilesSearched: 1,
373 totalFiles: 1,
374 filteredFileCount: 1,
375 nextCursor: cursor,
376 });
377 const grep = vi.fn((_query: string, options?: GrepOptions) =>
378 options?.cursor
379 ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) }
380 : {
381 ok: true as const,
382 value: grepResult("needleSuffix", [[0, 6]], nextCursor),
383 },
384 );
385 const finder = {
386 destroy: vi.fn(),
387 waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })),
388 grep,
389 } as unknown as FileFinder;
390 vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder });
391
392 const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content");
393 const result = yield* searchIndex.searchContents({
394 query: "needle",
395 limit: 1,
396 caseSensitive: true,
397 wholeWord: true,
398 useRegex: false,
399 });
400
401 expect(result).toEqual({
402 matches: [
403 {
404 path: "src/words.ts",
405 lineNumber: 1,
406 lineContent: "needle",
407 matchRanges: [{ start: 0, end: 6 }],
408 },
409 ],
410 truncated: false,
411 });
412 expect(grep).toHaveBeenCalledTimes(2);
413 expect(grep.mock.calls[1]?.[1]?.cursor).toBe(nextCursor);
414 }),
415 ),
416);