/** * KHAELOR * File: tests/tools/grep.test.ts * Description: Unit tests for the grep tool — fallback and ripgrep paths, ignore behavior, caps and spill. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { execSync } from "node:child_process"; import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createGrepTool } from "../../src/tools/index.js"; import type { TestHarness } from "./helpers.js"; import { fixture, makeHarness } from "./helpers.js"; let h: TestHarness; const grep = createGrepTool({ ripgrep: "never" }); function hasRipgrep(): boolean { try { execSync("command -v rg", { stdio: "ignore" }); return true; } catch { return false; } } beforeEach(() => { h = makeHarness(); }); afterEach(() => { rmSync(h.dir, { recursive: true, force: true }); }); describe("grep tool (pure-JS fallback)", () => { it("finds matches grouped by file with line numbers", async () => { fixture(h, "src/engine.ts", "export class ContextEngine {\n private x = 1;\n}\n"); fixture(h, "src/agent.ts", "import { ContextEngine } from './engine.js';\n"); const result = await grep.execute({ pattern: "ContextEngine" }, h.ctx); expect(result.isError).toBeUndefined(); expect(result.content).toContain('2 matches in 2 files for "ContextEngine":'); expect(result.content).toContain("src/engine.ts"); expect(result.content).toContain(" 1: export class ContextEngine {"); expect(result.content).toContain("src/agent.ts"); expect(result.metadata?.matches).toBe(2); expect(result.metadata?.files).toBe(2); }); it("filters with include globs", async () => { fixture(h, "a.ts", "needle\n"); fixture(h, "b.py", "needle\n"); const result = await grep.execute({ pattern: "needle", include: "*.ts" }, h.ctx); expect(result.content).toContain("1 match in 1 file"); expect(result.content).toContain("a.ts"); expect(result.content).not.toContain("b.py"); }); it("honors .gitignore", async () => { fixture(h, ".gitignore", "generated/\n"); fixture(h, "generated/out.ts", "needle\n"); fixture(h, "src/in.ts", "needle\n"); const result = await grep.execute({ pattern: "needle" }, h.ctx); expect(result.content).toContain("src/in.ts"); expect(result.content).not.toContain("generated/out.ts"); expect(result.metadata?.matches).toBe(1); }); it("skips binary files instead of failing", async () => { fixture(h, "ok.ts", "needle\n"); const abs = `${h.dir}/bin.dat`; const bytes = Buffer.alloc(64); bytes.write("needle", 10, "utf8"); writeFileSync(abs, bytes); const result = await grep.execute({ pattern: "needle" }, h.ctx); expect(result.isError).toBeUndefined(); expect(result.metadata?.matches).toBe(1); }); it("reports zero matches with guidance", async () => { fixture(h, "a.ts", "nothing here\n"); const result = await grep.execute({ pattern: "qzqzqz" }, h.ctx); expect(result.isError).toBeUndefined(); expect(result.content).toBe( `No matches for "qzqzqz" in ${h.dir}. Check the regex or broaden the scope with path/include.`, ); }); it("suggests the case-insensitive hit when casing is the only difference", async () => { fixture(h, "a.ts", "const contextEngine = 1;\n"); const result = await grep.execute({ pattern: "ContextEngine" }, h.ctx); expect(result.content).toContain('did you mean "contextEngine"?'); }); it("caps at 100 matching lines and spills the full results", async () => { const body = Array.from({ length: 150 }, (_, i) => `needle ${i}`).join("\n"); fixture(h, "many.txt", `${body}\n`); const result = await grep.execute({ pattern: "needle" }, h.ctx); expect(result.content).toContain('Showing first 100 of 150 matching lines for "needle":'); expect(result.content).toContain("[Results truncated. Full results: "); expect(result.content).toContain("or use a more specific pattern, path, or include filter.]"); const spill = result.metadata?.truncation?.spillPath; expect(spill).toBeDefined(); expect(readFileSync(spill!, "utf8").split("\n")).toHaveLength(150); expect(result.metadata?.truncation?.omittedLines).toBe(50); }); it("rejects invalid regular expressions before executing", async () => { const result = await grep.execute({ pattern: "([unclosed" }, h.ctx); expect(result.isError).toBe(true); expect(result.content).toContain('Invalid regular expression "([unclosed"'); }); it("searches a specific subdirectory via path", async () => { fixture(h, "src/x.ts", "needle\n"); fixture(h, "docs/y.md", "needle\n"); const result = await grep.execute({ pattern: "needle", path: "src" }, h.ctx); expect(result.metadata?.matches).toBe(1); expect(result.content).toContain("src/x.ts"); }); }); describe.runIf(hasRipgrep())("grep tool (ripgrep path)", () => { const rgGrep = createGrepTool({ ripgrep: "auto" }); it("finds matches through the rg binary", async () => { fixture(h, "src/engine.ts", "export class ContextEngine {\n"); const result = await rgGrep.execute({ pattern: "ContextEngine" }, h.ctx); expect(result.isError).toBeUndefined(); expect(result.content).toContain('1 match in 1 file for "ContextEngine":'); expect(result.content).toContain("src/engine.ts"); expect(result.content).toContain(" 1: export class ContextEngine {"); }); it("honors .gitignore through rg", async () => { // rg only honors .gitignore inside a git repository — mark the root as one. mkdirSync(`${h.dir}/.git`, { recursive: true }); fixture(h, ".gitignore", "generated/\n"); fixture(h, "generated/out.ts", "needle\n"); fixture(h, "src/in.ts", "needle\n"); const result = await rgGrep.execute({ pattern: "needle" }, h.ctx); expect(result.content).toContain("src/in.ts"); expect(result.content).not.toContain("generated/out.ts"); }); });