/**
* Tests for local_ripgrep tool
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { searchContentRipgrep } from '../../src/tools/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(),
},
}));
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 any);
});
describe('Basic search', () => {
it('should execute ripgrep search successfully', async () => {
mockSafeExec.mockResolvedValue({
success: true,
stdout: 'file1.ts:10:function test()',
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: '',
stderr: '',
});
const result = await searchContentRipgrep({
pattern: 'nonexistent',
path: '/test/path',
});
expect(result.status).toBe('empty');
});
it('should handle command failure', async () => {
mockSafeExec.mockResolvedValue({
success: false,
stdout: '',
stderr: 'Error: pattern invalid',
});
const result = await searchContentRipgrep({
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,
stdout: 'file1.ts\nfile2.ts',
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: 'file1.ts:10:function test()',
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: 'file1.ts:10:TODO: fix this',
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: 'file1.ts:10:export function test',
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: 'file1.ts:10:test',
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: 'src/file1.ts:10:test',
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: 'file1.ts\nfile2.ts\nfile3.ts',
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: 'file1.ts:9:prev line\nfile1.ts:10:match line\nfile1.ts:11:next line',
stderr: '',
});
const result = await searchContentRipgrep({
pattern: 'match',
path: '/test/path',
contextLines: 1,
charLength: 10000,
});
expect(result.status).toBe('hasResults');
expect(mockSafeExec).toHaveBeenCalledWith(
'rg',
expect.arrayContaining(['-C', '1'])
);
});
});
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,
stdout: lines,
stderr: '',
});
const result = await searchContentRipgrep({
pattern: 'test',
path: '/test/path',
charLength: 5000,
});
expect(result.status).toBe('hasResults');
// If output is larger than charLength, hasMore should be true
if (result.result && result.result.length < lines.length) {
expect(result.pagination?.hasMore).toBe(true);
}
});
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,
stdout: longJsonOutput,
stderr: '',
});
const result = await searchContentRipgrep({
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('Path validation', () => {
it('should reject invalid paths', async () => {
mockValidate.mockReturnValue({
isValid: false,
error: 'Path is outside allowed directories',
});
const result = await searchContentRipgrep({
pattern: 'test',
path: '/etc/passwd',
});
expect(result.status).toBe('error');
expect(result.error).toContain('Path is outside allowed directories');
});
});
describe('Case sensitivity', () => {
it('should use smart case by default', async () => {
mockSafeExec.mockResolvedValue({
success: true,
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 searchContentRipgrep({
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,
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 searchContentRipgrep({
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,
stdout: jsonOutput,
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: jsonOutput,
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: jsonOutput,
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: jsonOutput,
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: jsonOutput,
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: jsonOutput,
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: jsonOutput,
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: jsonOutput,
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: jsonOutput,
stderr: '',
});
// Mock fs.stat to return different mtime for each file based on the time in the test data
mockFsStat.mockImplementation((filePath: string) => {
const file = files.find(f => f.path === filePath);
return Promise.resolve({
mtime: new Date(file?.time || '2024-06-01T00:00:00.000Z'),
} as any);
});
const result = await searchContentRipgrep({
pattern: 'test',
path: '/test/path',
});
expect(result.status).toBe('hasResults');
expect(result.files).toBeDefined();
expect(result.files![0].modifiedTime).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,
stdout: jsonOutput,
stderr: '',
});
const result = await searchContentRipgrep({
pattern: 'test',
path: '/test/path',
});
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('modifiedTime');
});
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,
stdout: jsonOutput,
stderr: '',
});
const result = await searchContentRipgrep({
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,
stdout: jsonOutput,
stderr: '',
});
const result = await searchContentRipgrep({
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);
});
});
});