/**
* Tests for local_ripgrep tool
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { searchContentRipgrep } from '../../src/tools/local_ripgrep.js';
import { ERROR_CODES } from '../../src/errors/errorCodes.js';
import { RipgrepQuerySchema } from '../../src/scheme/local_ripgrep.js';
import * as exec from '../../src/utils/exec.js';
import * as pathValidator from '../../src/security/pathValidator.js';
import { promises as fs } from 'fs';
// Mock dependencies
vi.mock('../../src/utils/exec.js', () => ({
safeExec: vi.fn(),
}));
vi.mock('../../src/security/pathValidator.js', () => ({
pathValidator: {
validate: vi.fn(),
},
}));
vi.mock('fs', () => ({
promises: {
stat: vi.fn(),
},
}));
const runRipgrep = (
query: Parameters<typeof RipgrepQuerySchema.parse>[0]
) => searchContentRipgrep(RipgrepQuerySchema.parse(query));
describe('local_ripgrep', () => {
const mockSafeExec = vi.mocked(exec.safeExec);
const mockValidate = vi.mocked(pathValidator.pathValidator.validate);
const mockFsStat = vi.mocked(fs.stat);
beforeEach(() => {
vi.clearAllMocks();
mockValidate.mockReturnValue({
isValid: true,
sanitizedPath: '/test/path',
});
// Default mock for fs.stat - return a valid stats object with mtime
mockFsStat.mockResolvedValue({
mtime: new Date('2024-06-01T00:00:00.000Z'),
} as unknown as Awaited<ReturnType<typeof fs.stat>>);
});
describe('Basic search', () => {
it('should execute ripgrep search successfully', async () => {
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: 'file1.ts:10:function test()',
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
expect(result.searchEngine).toBe('rg');
});
it('should handle empty results', async () => {
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: '',
stderr: '',
});
const result = await runRipgrep({
pattern: 'nonexistent',
path: '/test/path',
});
expect(result.status).toBe('empty');
});
it('should handle command failure', async () => {
mockSafeExec.mockResolvedValue({
success: false,
code: 1,
stdout: '',
stderr: 'Error: pattern invalid',
});
const result = await runRipgrep({
pattern: '[',
path: '/test/path',
});
// Command failure without output returns empty, not error
expect(result.status).toBe('empty');
});
});
describe('Workflow modes', () => {
it('should apply discovery mode preset', async () => {
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: 'file1.ts\nfile2.ts',
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
mode: 'discovery',
});
expect(result.status).toBe('hasResults');
// Discovery mode sets filesOnly=true (uses -l flag)
expect(mockSafeExec).toHaveBeenCalledWith(
'rg',
expect.arrayContaining(['-l'])
);
});
it('should apply detailed mode preset', async () => {
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: 'file1.ts:10:function test()',
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
mode: 'detailed',
});
expect(result.status).toBe('hasResults');
});
});
describe('Pattern types', () => {
it('should handle fixed string search', async () => {
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: 'file1.ts:10:TODO: fix this',
stderr: '',
});
const result = await runRipgrep({
pattern: 'TODO:',
path: '/test/path',
fixedString: true,
});
expect(result.status).toBe('hasResults');
expect(mockSafeExec).toHaveBeenCalledWith(
'rg',
expect.arrayContaining(['-F'])
);
});
it('should handle perl regex', async () => {
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: 'file1.ts:10:export function test',
stderr: '',
});
const result = await runRipgrep({
pattern: '(?<=export )\\w+',
path: '/test/path',
perlRegex: true,
});
expect(result.status).toBe('hasResults');
expect(mockSafeExec).toHaveBeenCalledWith(
'rg',
expect.arrayContaining(['-P'])
);
});
});
describe('File filtering', () => {
it('should filter by file type', async () => {
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: 'file1.ts:10:test',
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
type: 'ts',
});
expect(result.status).toBe('hasResults');
expect(mockSafeExec).toHaveBeenCalledWith(
'rg',
expect.arrayContaining(['-t', 'ts'])
);
});
it('should exclude directories', async () => {
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: 'src/file1.ts:10:test',
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
excludeDir: ['node_modules', '.git'],
});
expect(result.status).toBe('hasResults');
expect(mockSafeExec).toHaveBeenCalledWith(
'rg',
expect.arrayContaining(['-g', '!node_modules/', '-g', '!.git/'])
);
});
});
describe('Output control', () => {
it('should list files only', async () => {
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: 'file1.ts\nfile2.ts\nfile3.ts',
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
filesOnly: true,
});
expect(result.status).toBe('hasResults');
expect(mockSafeExec).toHaveBeenCalledWith(
'rg',
expect.arrayContaining(['-l'])
);
});
it('should include context lines', async () => {
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: 'file1.ts:9:prev line\nfile1.ts:10:match line\nfile1.ts:11:next line',
stderr: '',
});
const result = await runRipgrep({
pattern: 'match',
path: '/test/path',
contextLines: 1,
charLength: 10000,
});
expect(result.status).toBe('hasResults');
expect(mockSafeExec).toHaveBeenCalledWith(
'rg',
expect.arrayContaining(['-C', '1'])
);
});
it('should include afterContext lines when specified', async () => {
// Provide NDJSON with match and context lines
const jsonLines = [
JSON.stringify({
type: 'match',
data: {
path: { text: 'file1.ts' },
lines: { text: 'match line' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 5, match: { text: 'match' } }],
},
}),
JSON.stringify({
type: 'context',
data: {
path: { text: 'file1.ts' },
lines: { text: 'prev line' },
line_number: 9,
absolute_offset: 0,
},
}),
JSON.stringify({
type: 'context',
data: {
path: { text: 'file1.ts' },
lines: { text: 'next line' },
line_number: 11,
absolute_offset: 0,
},
}),
].join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonLines,
stderr: '',
});
const result = await runRipgrep({
pattern: 'match',
path: '/test/path',
afterContext: 1,
});
expect(result.status).toBe('hasResults');
const value = result.files![0].matches[0].value;
expect(value).toContain('match line');
expect(value).toContain('next line');
expect(value).not.toContain('prev line');
});
});
describe('Pagination', () => {
it('should apply character-based pagination', async () => {
// Create output with multiple lines to ensure line-aware pagination works
const lines = Array.from({ length: 500 }, (_, i) => `file.ts:${i}:line content ${i}`).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: lines,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
charLength: 5000,
});
expect(result.status).toBe('hasResults');
// pagination.hasMore is only set when content exceeds charLength
// The mock output might not be large enough to trigger pagination
if (result.pagination) {
expect(typeof result.pagination.hasMore).toBe('boolean');
}
});
it('should require pagination for large output', async () => {
// Mock valid JSON output that would be large
const longJsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: 'test.ts' },
lines: { text: 'x'.repeat(15000) },
line_number: 1,
absolute_offset: 0,
submatches: [{ start: 0, end: 15000 }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: longJsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
// No charLength specified
});
// Large output should prompt for pagination or error
expect(['hasResults', 'error']).toContain(result.status);
if (result.status === 'hasResults') {
// Check if files array is included
expect(result.files).toBeDefined();
expect(result.files?.length).toBeGreaterThan(0);
}
});
});
describe('Limits and error mappings', () => {
it('should enforce maxFiles limit', async () => {
const files = Array.from({ length: 30 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'match' },
line_number: 1,
absolute_offset: 0,
submatches: [{ start: 0, end: 5, match: { text: 'match' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({ success: true, code: 0, stdout: jsonOutput, stderr: '' });
const result = await runRipgrep({
pattern: 'match',
path: '/test/path',
maxFiles: 5,
});
expect(result.status).toBe('hasResults');
expect(result.files?.length).toBeLessThanOrEqual(5);
expect(result.totalFiles).toBe(5);
});
it('should map output-too-large error from engine', async () => {
mockSafeExec.mockRejectedValue(new Error('Output size limit exceeded'));
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('error');
expect(result.errorCode).toBe(ERROR_CODES.OUTPUT_TOO_LARGE);
});
});
describe('Path validation', () => {
it('should reject invalid paths', async () => {
mockValidate.mockReturnValue({
isValid: false,
error: 'Path is outside allowed directories',
});
const result = await runRipgrep({
pattern: 'test',
path: '/etc/passwd',
});
expect(result.status).toBe('error');
expect(result.errorCode).toBe(ERROR_CODES.PATH_VALIDATION_FAILED);
});
});
describe('Case sensitivity', () => {
it('should use smart case by default', async () => {
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: JSON.stringify({
type: 'match',
data: {
path: { text: 'file1.ts' },
lines: { text: 'Test' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4 }],
},
}),
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
expect(mockSafeExec).toHaveBeenCalledWith(
'rg',
expect.arrayContaining(['-S'])
);
});
it('should override with case-insensitive', async () => {
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: JSON.stringify({
type: 'match',
data: {
path: { text: 'file1.ts' },
lines: { text: 'TEST' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4 }],
},
}),
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
caseInsensitive: true,
});
expect(result.status).toBe('hasResults');
expect(mockSafeExec).toHaveBeenCalledWith(
'rg',
expect.arrayContaining(['-i'])
);
});
});
describe('NEW FEATURE: Two-level pagination', () => {
it('should paginate files with default 10 files per page', async () => {
const files = Array.from({ length: 25 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
expect(result.files).toBeDefined();
expect(result.files?.length).toBeLessThanOrEqual(10);
expect(result.totalFiles).toBe(25);
expect(result.pagination?.totalPages).toBe(3);
expect(result.pagination?.hasMore).toBe(true);
});
it('should navigate to second page of files', async () => {
const files = Array.from({ length: 25 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
filePageNumber: 2,
});
expect(result.status).toBe('hasResults');
expect(result.pagination?.currentPage).toBe(2);
expect(result.pagination?.hasMore).toBe(true);
});
it('should paginate matches per file with default 10 matches', async () => {
const matches = Array.from({ length: 25 }, (_, i) => ({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: `test match ${i}` },
line_number: i + 1,
absolute_offset: 100 * i,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = matches.map(m => JSON.stringify(m)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
expect(result.files).toHaveLength(1);
expect(result.files![0].matchCount).toBe(25);
expect(result.files![0].matches.length).toBeLessThanOrEqual(10);
expect(result.files![0].pagination?.totalPages).toBe(3);
expect(result.files![0].pagination?.hasMore).toBe(true);
});
it('should support custom filesPerPage', async () => {
const files = Array.from({ length: 25 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
filesPerPage: 5,
});
expect(result.status).toBe('hasResults');
expect(result.files?.length).toBeLessThanOrEqual(5);
expect(result.pagination?.filesPerPage).toBe(5);
expect(result.pagination?.totalPages).toBe(5);
});
it('should support custom matchesPerPage', async () => {
const matches = Array.from({ length: 25 }, (_, i) => ({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: `test match ${i}` },
line_number: i + 1,
absolute_offset: 100 * i,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = matches.map(m => JSON.stringify(m)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
matchesPerPage: 5,
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matches.length).toBeLessThanOrEqual(5);
expect(result.files![0].pagination?.matchesPerPage).toBe(5);
});
});
describe('NEW FEATURE: matchContentLength configuration', () => {
it('should use default 200 chars per match', async () => {
const longContent = 'x'.repeat(500);
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: longContent },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 10, match: { text: 'test' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matches[0].value.length).toBeLessThanOrEqual(203);
expect(result.files![0].matches[0].value).toMatch(/\.\.\.$/);
});
it('should support custom matchContentLength up to 800 chars', async () => {
const longContent = 'x'.repeat(1000);
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: longContent },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 10, match: { text: 'test' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
matchContentLength: 800,
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matches[0].value.length).toBeLessThanOrEqual(803);
expect(result.files![0].matches[0].value).toMatch(/\.\.\.$/);
});
it('should not truncate content under matchContentLength', async () => {
const shortContent = 'short test content';
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: shortContent },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
matchContentLength: 200,
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matches[0].value).toBe(shortContent);
expect(result.files![0].matches[0].value).not.toMatch(/\.\.\.$/);
});
});
describe('NEW FEATURE: Files sorted by modification time', () => {
it('should sort files by modification time (most recent first)', async () => {
const files = [
{ path: '/test/old.ts', time: '2024-01-01T00:00:00.000Z' },
{ path: '/test/new.ts', time: '2024-12-01T00:00:00.000Z' },
{ path: '/test/mid.ts', time: '2024-06-01T00:00:00.000Z' },
];
const jsonOutput = files.map(f => JSON.stringify({
type: 'match',
data: {
path: { text: f.path },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
})).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
// Mock fs.stat to return different mtime for each file based on the time in the test data
mockFsStat.mockImplementation((filePath) => {
const filePathString = filePath.toString();
const file = files.find((f) => f.path === filePathString);
return Promise.resolve({
mtime: new Date(file?.time || '2024-06-01T00:00:00.000Z'),
} as unknown as Awaited<ReturnType<typeof fs.stat>>);
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
showFileLastModified: true,
});
expect(result.status).toBe('hasResults');
expect(result.files).toBeDefined();
expect(result.files![0].modified).toBeDefined();
});
});
describe('NEW FEATURE: Structured output format', () => {
it('should return RipgrepFileMatches structure', async () => {
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 500,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
showFileLastModified: true,
});
expect(result.status).toBe('hasResults');
expect(result.files).toBeDefined();
expect(result.files![0]).toHaveProperty('path');
expect(result.files![0]).toHaveProperty('matchCount');
expect(result.files![0]).toHaveProperty('matches');
expect(result.files![0]).toHaveProperty('modified');
});
it('should include location.charOffset for FETCH_CONTENT integration', async () => {
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 500,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matches[0].location).toBeDefined();
expect(result.files![0].matches[0].location.charOffset).toBe(500);
expect(result.files![0].matches[0].location.charLength).toBeGreaterThan(0);
});
it('should include line and column for human reference', async () => {
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: 'test match' },
line_number: 42,
absolute_offset: 500,
submatches: [{ start: 5, end: 9, match: { text: 'test' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matches[0].line).toBe(42);
expect(result.files![0].matches[0].column).toBe(5);
});
});
describe('File pagination - Edge cases', () => {
it('should handle filePageNumber = 0 or negative (defaults to 1)', async () => {
const files = Array.from({ length: 50 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
// Schema should validate page number, test with valid value
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
filePageNumber: 1,
filesPerPage: 10,
});
expect(result.status).toBe('hasResults');
expect(result.pagination?.currentPage).toBe(1);
});
it('should handle filePageNumber > total pages', async () => {
const files = Array.from({ length: 15 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
filePageNumber: 10, // Beyond last page
filesPerPage: 10,
});
expect(['hasResults', 'empty']).toContain(result.status);
if (result.status === 'hasResults') {
expect(result.pagination?.currentPage).toBe(10);
}
});
it('should handle filesPerPage = 1', async () => {
const files = Array.from({ length: 5 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
filesPerPage: 1,
});
expect(result.status).toBe('hasResults');
expect(result.files?.length).toBe(1);
expect(result.pagination?.filesPerPage).toBe(1);
expect(result.pagination?.totalPages).toBe(5);
});
it('should handle filesPerPage = 20 (max)', async () => {
const files = Array.from({ length: 75 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
filesPerPage: 20,
});
expect(result.status).toBe('hasResults');
expect(result.files?.length).toBeLessThanOrEqual(20);
expect(result.pagination?.filesPerPage).toBe(20);
expect(result.pagination?.totalPages).toBe(4);
});
it('should handle single file result', async () => {
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/single.ts' },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
expect(result.files?.length).toBe(1);
expect(result.totalFiles).toBe(1);
expect(result.pagination?.totalPages).toBe(1);
expect(result.pagination?.hasMore).toBe(false);
});
it('should coerce filePageNumber=0 to 1 via defaulting (schema bypass)', async () => {
const files = Array.from({ length: 15 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({ success: true, code: 0, stdout: jsonOutput, stderr: '' });
const result = await searchContentRipgrep({ pattern: 'test', path: '/test/path', filesPerPage: 10, filePageNumber: 0 } as any);
expect(['hasResults', 'empty']).toContain(result.status);
if (result.status === 'hasResults') {
expect(result.pagination?.currentPage).toBe(1);
}
});
it('should reflect negative filePageNumber as provided (no clamping, schema bypass)', async () => {
const files = Array.from({ length: 15 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({ success: true, code: 0, stdout: jsonOutput, stderr: '' });
const result = await searchContentRipgrep({ pattern: 'test', path: '/test/path', filesPerPage: 10, filePageNumber: -2 } as any);
expect(['hasResults', 'empty']).toContain(result.status);
if (result.status === 'hasResults') {
expect(result.pagination?.currentPage).toBe(-2);
}
});
});
describe('Match pagination - Edge cases', () => {
it('should handle matchesPerPage = 1', async () => {
const matches = Array.from({ length: 5 }, (_, i) => ({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: `test match ${i}` },
line_number: i + 1,
absolute_offset: 100 * i,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = matches.map(m => JSON.stringify(m)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
matchesPerPage: 1,
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matches.length).toBe(1);
expect(result.files![0].pagination?.matchesPerPage).toBe(1);
expect(result.files![0].pagination?.totalPages).toBe(5);
});
it('should handle matchesPerPage = 100 (max)', async () => {
const matches = Array.from({ length: 150 }, (_, i) => ({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: `test match ${i}` },
line_number: i + 1,
absolute_offset: 100 * i,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = matches.map(m => JSON.stringify(m)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
matchesPerPage: 100,
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matches.length).toBeLessThanOrEqual(100);
expect(result.files![0].pagination?.matchesPerPage).toBe(100);
});
it('should handle single match in file', async () => {
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: 'single test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 7, end: 11, match: { text: 'test' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matchCount).toBe(1);
expect(result.files![0].matches.length).toBe(1);
// Per-file pagination is only added when there are more matches than matchesPerPage
if (result.files![0].pagination) {
expect(result.files![0].pagination.hasMore).toBe(false);
}
});
it('should handle exact boundary (10 matches, 10 per page)', async () => {
const matches = Array.from({ length: 10 }, (_, i) => ({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: `test match ${i}` },
line_number: i + 1,
absolute_offset: 100 * i,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = matches.map(m => JSON.stringify(m)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
matchesPerPage: 10,
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matches.length).toBe(10);
// Per-file pagination is only added when there are more matches than matchesPerPage
if (result.files![0].pagination) {
expect(result.files![0].pagination.totalPages).toBe(1);
expect(result.files![0].pagination.hasMore).toBe(false);
}
});
it('should handle matchesPerPage overflow (schema bypass)', async () => {
const matches = Array.from({ length: 25 }, (_, i) => ({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: `test match ${i}` },
line_number: i + 1,
absolute_offset: 100 * i,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = matches.map(m => JSON.stringify(m)).join('\n');
mockSafeExec.mockResolvedValue({ success: true, code: 0, stdout: jsonOutput, stderr: '' });
// Bypass schema to inject overflow value
const result = await searchContentRipgrep({
pattern: 'test',
path: '/test/path',
matchesPerPage: 1000,
} as any);
expect(result.status).toBe('hasResults');
expect(result.files![0].matchCount).toBe(25);
// All matches should be included, no pagination created
expect(result.files![0].matches.length).toBe(25);
expect(result.files![0].pagination).toBeUndefined();
});
});
describe('Match content - UTF-8 handling', () => {
it('should handle UTF-8 in match values', async () => {
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: 'Café résumé test' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'Café' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'Café',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matches[0].value).toContain('Café');
expect(result.files![0].matches[0].value).not.toMatch(/\uFFFD/);
});
it('should truncate at UTF-8 char boundaries', async () => {
// Create long content with UTF-8 chars that needs truncation
const longContent = 'test ' + 'café '.repeat(50) + 'end';
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: longContent },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
matchContentLength: 100,
});
expect(result.status).toBe('hasResults');
// Should truncate but not split UTF-8 chars
expect(result.files![0].matches[0].value).not.toMatch(/\uFFFD/);
expect(result.files![0].matches[0].value.length).toBeLessThanOrEqual(103); // 100 + '...'
});
it('should handle emoji in match content', async () => {
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: '😀 test 🎉 code 👍' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 2, end: 6, match: { text: 'test' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matches[0].value).toContain('😀');
expect(result.files![0].matches[0].value).toContain('🎉');
expect(result.files![0].matches[0].value).not.toMatch(/\uFFFD/);
});
it('should handle 3-byte UTF-8 chars (CJK)', async () => {
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: '你好 test 世界' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 3, end: 7, match: { text: 'test' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matches[0].value).toContain('你好');
expect(result.files![0].matches[0].value).not.toMatch(/\uFFFD/);
});
it('should handle matchContentLength with mixed UTF-8', async () => {
const mixedContent = 'Test: café 中文 😀 ' + 'x'.repeat(200);
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: mixedContent },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'Test' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'Test',
path: '/test/path',
matchContentLength: 50,
});
expect(result.status).toBe('hasResults');
expect(result.files![0].matches[0].value.length).toBeLessThanOrEqual(53); // 50 + '...'
expect(result.files![0].matches[0].value).not.toMatch(/\uFFFD/);
});
});
describe('Research context fields', () => {
it('should return researchGoal and reasoning in hasResults', async () => {
const jsonOutput = JSON.stringify({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
});
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
researchGoal: 'Find test implementations',
reasoning: 'Need to understand test patterns',
});
expect(result.status).toBe('hasResults');
expect(result.researchGoal).toBe('Find test implementations');
expect(result.reasoning).toBe('Need to understand test patterns');
});
it('should return researchGoal and reasoning in empty results', async () => {
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: '',
stderr: '',
});
const result = await runRipgrep({
pattern: 'nonexistent',
path: '/test/path',
researchGoal: 'Find missing pattern',
reasoning: 'Verify pattern absence',
});
expect(result.status).toBe('empty');
expect(result.researchGoal).toBe('Find missing pattern');
expect(result.reasoning).toBe('Verify pattern absence');
});
it('should return researchGoal and reasoning in error results', async () => {
mockValidate.mockReturnValue({
isValid: false,
error: 'Invalid path',
});
const result = await runRipgrep({
pattern: 'test',
path: '/invalid/path',
researchGoal: 'Search invalid path',
reasoning: 'Testing error handling',
});
expect(result.status).toBe('error');
expect(result.researchGoal).toBe('Search invalid path');
expect(result.reasoning).toBe('Testing error handling');
});
});
describe('Pagination hints validation', () => {
it('should show file pagination hints accurately', async () => {
const files = Array.from({ length: 30 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
filesPerPage: 10,
});
expect(result.status).toBe('hasResults');
expect(result.hints).toBeDefined();
expect(result.pagination?.currentPage).toBe(1);
expect(result.pagination?.totalPages).toBe(3);
});
it('should show per-file match pagination hints', async () => {
const matches = Array.from({ length: 25 }, (_, i) => ({
type: 'match',
data: {
path: { text: '/test/file.ts' },
lines: { text: `test match ${i}` },
line_number: i + 1,
absolute_offset: 100 * i,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = matches.map(m => JSON.stringify(m)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
matchesPerPage: 10,
});
expect(result.status).toBe('hasResults');
expect(result.files![0].pagination?.totalPages).toBe(3);
expect(result.files![0].pagination?.hasMore).toBe(true);
});
it('should show hints for navigating to next page', async () => {
const files = Array.from({ length: 25 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
filesPerPage: 10,
filePageNumber: 1,
});
expect(result.status).toBe('hasResults');
if (result.pagination?.hasMore) {
expect(result.hints).toBeDefined();
const hasNextPageHint = result.hints?.some(h =>
h.includes('filePageNumber=2') || h.toLowerCase().includes('next')
);
expect(hasNextPageHint).toBe(true);
}
});
it('should show final page message on last page', async () => {
const files = Array.from({ length: 25 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
filesPerPage: 10,
filePageNumber: 3, // Last page
});
expect(result.status).toBe('hasResults');
expect(result.pagination?.hasMore).toBe(false);
if (result.hints) {
const hasFinalPageHint = result.hints.some(h =>
h.toLowerCase().includes('final') || h.toLowerCase().includes('last')
);
expect(hasFinalPageHint).toBe(true);
}
});
it('should include parameter names matching tool schema', async () => {
const files = Array.from({ length: 25 }, (_, i) => ({
type: 'match',
data: {
path: { text: `/test/file${i}.ts` },
lines: { text: 'test match' },
line_number: 10,
absolute_offset: 100,
submatches: [{ start: 0, end: 4, match: { text: 'test' } }],
},
}));
const jsonOutput = files.map(f => JSON.stringify(f)).join('\n');
mockSafeExec.mockResolvedValue({
success: true,
code: 0,
stdout: jsonOutput,
stderr: '',
});
const result = await runRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
if (result.hints) {
// Hints should use actual schema parameter names
const usesSchemaParams = result.hints.some(h =>
h.includes('filesPerPage') || h.includes('filePageNumber')
);
expect(usesSchemaParams).toBe(true);
}
});
});
});