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); }), ),);
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);
}),
),
);