import { FileFinder, type FileItem, type GrepCursor, type GrepOptions, type GrepResult, } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import { vi } from "vite-plus/test"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; afterEach(() => { vi.restoreAllMocks(); }); function fileItem(relativePath: string): FileItem { return { relativePath, fileName: relativePath.slice(relativePath.lastIndexOf("/") + 1), size: 1, modified: 0, accessFrecencyScore: 0, modificationFrecencyScore: 0, totalFrecencyScore: 0, gitStatus: "clean", }; } it.effect("filters image searches before applying the result limit", () => Effect.scoped( Effect.gen(function* () { const items = [ ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)), fileItem("public/icon.svg"), ]; const fileSearch = vi.fn(() => ({ ok: true as const, value: { items, scores: [], totalMatched: items.length, totalFiles: items.length, }, })); const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), fileSearch, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); const resultWithoutKind = yield* searchIndex.search("", 200, undefined, true); const resultWithDirectoryKind = yield* searchIndex.search("", 200, "directory", true); expect(resultWithoutKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); expect(resultWithDirectoryKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); expect(fileSearch).toHaveBeenCalledTimes(2); expect(fileSearch).toHaveBeenCalledWith("", { pageSize: 25_002 }); }), ), ); it.effect("preserves unexpected FileFinder creation failures", () => Effect.gen(function* () { const cause = new Error("native initialization failed"); vi.spyOn(FileFinder, "create").mockImplementationOnce(() => { throw cause; }); const error = yield* Effect.flip( Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), ); expect(error).toMatchObject({ _tag: "WorkspaceSearchIndexCreateFailed", cwd: "/workspace/project", reason: "FileFinder.create threw unexpectedly.", cause, }); }), ); it.effect("keeps returned FileFinder creation diagnostics out of the cause chain", () => Effect.gen(function* () { vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: false, error: "native index rejected the directory", }); const error = yield* Effect.flip( Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), ); expect(error).toMatchObject({ _tag: "WorkspaceSearchIndexCreateFailed", cwd: "/workspace/project", reason: "native index rejected the directory", }); expect(error.cause).toBeUndefined(); }), ); it.effect("waits for the full content index warmup before returning", () => Effect.gen(function* () { const waitForIndexReady = vi.fn(async () => ({ ok: true as const, value: true })); const finder = { destroy: vi.fn(), waitForIndexReady, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")); expect(waitForIndexReady).toHaveBeenCalledWith(15_000); }), ); it.effect("preserves a full-index warmup timeout as a structured error", () => Effect.gen(function* () { const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); const error = yield* Effect.flip( Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")), ); expect(error).toMatchObject({ _tag: "WorkspaceSearchIndexScanTimedOut", cwd: "/workspace/project", timeout: "15 seconds", }); }), ); it.effect("preserves FileFinder destroy failures as structured defects", () => Effect.gen(function* () { const cause = new Error("native destroy failed"); const finder = { destroy: vi.fn(() => { throw cause; }), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); const exit = yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")).pipe( Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.hasDies(exit.cause)).toBe(true); const error = Cause.squash(exit.cause); expect(error).toBeInstanceOf(WorkspaceSearchIndex.WorkspaceSearchIndexDestroyFailed); expect(error).toMatchObject({ _tag: "WorkspaceSearchIndexDestroyFailed", cwd: "/workspace/project", cause, }); } }), ); it.effect("preserves search and refresh failures with operation context", () => Effect.scoped( Effect.gen(function* () { const searchCause = new Error("native search failed"); const refreshCause = new Error("native scan failed"); const contentSearchCause = new Error("native grep failed"); const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), mixedSearch: vi.fn(() => { throw searchCause; }), grep: vi.fn(() => { throw contentSearchCause; }), scanFiles: vi.fn(() => { throw refreshCause; }), } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); const query = "authorization: Bearer secret-token"; const searchError = yield* Effect.flip(searchIndex.search(query, 3)); const contentSearchError = yield* Effect.flip( searchIndex.searchContents({ query, limit: 3, caseSensitive: false, wholeWord: false, useRegex: false, }), ); const refreshError = yield* Effect.flip(searchIndex.refresh()); expect(searchError).toMatchObject({ _tag: "WorkspaceSearchIndexSearchFailed", cwd: "/workspace/project", queryLength: query.length, pageSize: 4, reason: "FileFinder.mixedSearch threw unexpectedly.", cause: searchCause, }); expect(searchError).not.toHaveProperty("query"); expect(searchError.message).not.toMatch(/Bearer|secret-token/); expect(contentSearchError).toMatchObject({ _tag: "WorkspaceSearchIndexSearchFailed", cwd: "/workspace/project", queryLength: query.length, pageSize: 3, reason: "FileFinder.grep threw unexpectedly.", cause: contentSearchCause, }); expect(contentSearchError).not.toHaveProperty("query"); expect(contentSearchError.message).not.toMatch(/Bearer|secret-token/); expect(refreshError).toMatchObject({ _tag: "WorkspaceSearchIndexRefreshFailed", cwd: "/workspace/project", reason: "FileFinder.scanFiles threw unexpectedly.", cause: refreshCause, }); }), ), ); it.effect("keeps returned search diagnostics out of the cause chain", () => Effect.scoped( Effect.gen(function* () { const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); const query = "authorization: Bearer secret-token"; const searchError = yield* Effect.flip(searchIndex.search(query, 3)); const refreshError = yield* Effect.flip(searchIndex.refresh()); expect(searchError).toMatchObject({ _tag: "WorkspaceSearchIndexSearchFailed", cwd: "/workspace/project", queryLength: query.length, pageSize: 4, reason: "native query rejected", }); expect(searchError).not.toHaveProperty("query"); expect(searchError.message).not.toMatch(/Bearer|secret-token/); expect(searchError.cause).toBeUndefined(); expect(refreshError).toMatchObject({ _tag: "WorkspaceSearchIndexRefreshFailed", cwd: "/workspace/project", reason: "native refresh rejected", }); expect(refreshError.cause).toBeUndefined(); }), ), ); it.effect("continues whole-word searches after a filtered grep page", () => Effect.scoped( Effect.gen(function* () { const nextCursor = { __brand: "GrepCursor", _offset: 1, } as GrepCursor; const grepResult = ( lineContent: string, matchRanges: Array<[number, number]>, cursor: GrepCursor | null, ): GrepResult => ({ items: [ { relativePath: "src/words.ts", fileName: "words.ts", gitStatus: "unmodified", size: lineContent.length, modified: 0, isBinary: false, totalFrecencyScore: 0, accessFrecencyScore: 0, modificationFrecencyScore: 0, lineNumber: 1, col: 0, byteOffset: 0, lineContent, matchRanges, }, ], totalMatched: 1, totalFilesSearched: 1, totalFiles: 1, filteredFileCount: 1, nextCursor: cursor, }); const grep = vi.fn((_query: string, options?: GrepOptions) => options?.cursor ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } : { ok: true as const, value: grepResult("needleSuffix", [[0, 6]], nextCursor), }, ); const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), grep, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content"); const result = yield* searchIndex.searchContents({ query: "needle", limit: 1, caseSensitive: true, wholeWord: true, useRegex: false, }); expect(result).toEqual({ matches: [ { path: "src/words.ts", lineNumber: 1, lineContent: "needle", matchRanges: [{ start: 0, end: 6 }], }, ], truncated: false, }); expect(grep).toHaveBeenCalledTimes(2); expect(grep.mock.calls[1]?.[1]?.cursor).toBe(nextCursor); }), ), );