import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createSystemTools } from '../src/system-tools.js';
import type { ToolDefinition } from '../src/tools.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
describe('enhanced search_content', () => {
let workspace: string;
let tools: ToolDefinition[];
let searchContent: ToolDefinition;
beforeEach(() => {
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-grep-test-'));
tools = createSystemTools(workspace);
searchContent = tools.find((t) => t.name === 'search_content')!;
// Create test files
fs.writeFileSync(
path.join(workspace, 'app.ts'),
'import React from "react";\nconst App = () => {\n return
Hello
;\n};\nexport default App;',
);
fs.writeFileSync(
path.join(workspace, 'utils.ts'),
'export function add(a: number, b: number) {\n return a + b;\n}\n\nexport function multiply(a: number, b: number) {\n return a * b;\n}',
);
fs.writeFileSync(
path.join(workspace, 'readme.md'),
'# Project\n\nThis is a sample project.\n\nIt does things.',
);
fs.writeFileSync(
path.join(workspace, 'data.json'),
'{"name": "test", "value": 42}',
);
});
afterEach(async () => {
for (let i = 0; i < 5; i++) {
try {
fs.rmSync(workspace, { recursive: true, force: true });
return;
} catch {
await new Promise((r) => setTimeout(r, 200));
}
}
});
describe('default content mode (backward compatible)', () => {
it('returns file:line: match format', async () => {
const result = await searchContent.execute({ pattern: 'Hello' });
expect(result).toContain('app.ts');
expect(result).toContain('Hello');
});
});
describe('context_before / context_after', () => {
it('shows lines before a match', async () => {
const result = await searchContent.execute({
pattern: 'return a \\+ b',
context_before: 1,
context_after: 0,
});
expect(result).toContain('function add');
expect(result).toContain('return a + b');
});
it('shows lines after a match', async () => {
const result = await searchContent.execute({
pattern: 'const App',
context_before: 0,
context_after: 1,
});
expect(result).toContain('const App');
expect(result).toContain('return Hello
');
});
it('shows context before and after', async () => {
const result = await searchContent.execute({
pattern: 'return a \\+ b',
context_before: 1,
context_after: 1,
});
expect(result).toContain('function add');
expect(result).toContain('return a + b');
expect(result).toContain('}');
});
it('marks the matching line with >', async () => {
const result = await searchContent.execute({
pattern: 'return a \\+ b',
context_before: 1,
});
// The matching line gets '>' marker, context lines get ' '
expect(result).toMatch(/:\s /); // context line with space marker
expect(result).toMatch(/:>/); // matching line with > marker
});
it('uses --- separator between context groups', async () => {
const result = await searchContent.execute({
pattern: 'return',
context_before: 1,
glob: '**/*.ts',
});
expect(result).toContain('---');
});
});
describe('output_mode: files', () => {
it('returns only unique file paths', async () => {
const result = await searchContent.execute({
pattern: 'return',
output_mode: 'files',
});
// Should contain file paths, not line content
expect(result).toContain('app.ts');
expect(result).toContain('utils.ts');
// Should not contain line numbers or content
expect(result).not.toContain(':');
});
it('deduplicates file paths', async () => {
const result = await searchContent.execute({
pattern: 'return',
output_mode: 'files',
glob: '**/*.ts',
});
const lines = result.split('\n');
const unique = new Set(lines);
expect(lines.length).toBe(unique.size);
});
});
describe('output_mode: count', () => {
it('returns match counts per file', async () => {
const result = await searchContent.execute({
pattern: 'return',
output_mode: 'count',
glob: '**/*.ts',
});
// utils.ts has 2 returns, app.ts has 1
expect(result).toContain('utils.ts: 2');
});
});
describe('file_type filter', () => {
it('filters by file extension', async () => {
const result = await searchContent.execute({
pattern: '.',
output_mode: 'files',
file_type: 'ts',
});
expect(result).toContain('app.ts');
expect(result).toContain('utils.ts');
expect(result).not.toContain('readme.md');
expect(result).not.toContain('data.json');
});
it('returns no matches for unused extension', async () => {
const result = await searchContent.execute({
pattern: '.',
file_type: 'py',
});
expect(result).toBe('No matches found.');
});
});
describe('max_results', () => {
it('limits total content results', async () => {
const result = await searchContent.execute({
pattern: 'return',
max_results: 1,
glob: '**/*.ts',
});
// Should only have 1 match, not all 3
const matchLines = result.split('\n').filter((l) => l.includes('return'));
expect(matchLines.length).toBe(1);
});
it('limits file results in files mode', async () => {
const result = await searchContent.execute({
pattern: '.',
output_mode: 'files',
max_results: 1,
});
const lines = result.split('\n').filter(Boolean);
expect(lines.length).toBe(1);
});
it('limits count results', async () => {
const result = await searchContent.execute({
pattern: '.',
output_mode: 'count',
max_results: 1,
});
const lines = result.split('\n').filter(Boolean);
expect(lines.length).toBe(1);
});
});
describe('combined options', () => {
it('file_type + output_mode: count', async () => {
const result = await searchContent.execute({
pattern: 'return',
file_type: 'ts',
output_mode: 'count',
});
expect(result).toContain('.ts');
expect(result).not.toContain('.md');
});
it('context + max_results', async () => {
const result = await searchContent.execute({
pattern: 'return',
context_before: 1,
max_results: 1,
glob: '**/*.ts',
});
// Only 1 match with context
const separators = result.split('---').length - 1;
expect(separators).toBe(1);
});
});
});