diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts index 3004023aec..7c47275b49 100644 --- a/packages/core/src/tools/ripGrep.test.ts +++ b/packages/core/src/tools/ripGrep.test.ts @@ -14,7 +14,7 @@ import { type Mock, } from 'vitest'; import type { RipGrepToolParams } from './ripGrep.js'; -import { canUseRipgrep, RipGrepTool } from './ripGrep.js'; +import { canUseRipgrep, RipGrepTool, ensureRgPath } from './ripGrep.js'; import path from 'node:path'; import fs from 'node:fs/promises'; import os, { EOL } from 'node:os'; @@ -97,6 +97,49 @@ describe('canUseRipgrep', () => { }); }); +describe('ensureRgPath', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return rg path if ripgrep already exists', async () => { + (fileExists as Mock).mockResolvedValue(true); + const rgPath = await ensureRgPath(); + expect(rgPath).toBe(path.join('/mock/bin/dir', 'rg')); + expect(fileExists).toHaveBeenCalledOnce(); + expect(downloadRipGrep).not.toHaveBeenCalled(); + }); + + it('should return rg path if ripgrep is downloaded successfully', async () => { + (fileExists as Mock) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + (downloadRipGrep as Mock).mockResolvedValue(undefined); + const rgPath = await ensureRgPath(); + expect(rgPath).toBe(path.join('/mock/bin/dir', 'rg')); + expect(downloadRipGrep).toHaveBeenCalledOnce(); + expect(fileExists).toHaveBeenCalledTimes(2); + }); + + it('should throw an error if ripgrep cannot be used after download attempt', async () => { + (fileExists as Mock).mockResolvedValue(false); + (downloadRipGrep as Mock).mockResolvedValue(undefined); + await expect(ensureRgPath()).rejects.toThrow('Cannot use ripgrep.'); + expect(downloadRipGrep).toHaveBeenCalledOnce(); + expect(fileExists).toHaveBeenCalledTimes(2); + }); + + it('should propagate errors from downloadRipGrep', async () => { + const error = new Error('Download failed'); + (fileExists as Mock).mockResolvedValue(false); + (downloadRipGrep as Mock).mockRejectedValue(error); + + await expect(ensureRgPath()).rejects.toThrow(error); + expect(fileExists).toHaveBeenCalledTimes(1); + expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir'); + }); +}); + // Helper function to create mock spawn implementations function createMockSpawn( options: { @@ -158,6 +201,8 @@ describe('RipGrepTool', () => { beforeEach(async () => { vi.clearAllMocks(); + (downloadRipGrep as Mock).mockResolvedValue(undefined); + (fileExists as Mock).mockResolvedValue(true); mockSpawn.mockClear(); tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-')); grepTool = new RipGrepTool(mockConfig); @@ -504,6 +549,20 @@ describe('RipGrepTool', () => { /params must have required property 'pattern'/, ); }); + + it('should throw an error if ripgrep is not available', async () => { + // Make ensureRgPath throw + (fileExists as Mock).mockResolvedValue(false); + (downloadRipGrep as Mock).mockResolvedValue(undefined); + + const params: RipGrepToolParams = { pattern: 'world' }; + const invocation = grepTool.build(params); + + expect(await invocation.execute(abortSignal)).toStrictEqual({ + llmContent: 'Error during grep search operation: Cannot use ripgrep.', + returnDisplay: 'Error: Cannot use ripgrep.', + }); + }); }); describe('multi-directory workspace', () => { diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 4bb386d5fb..269fb37993 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -20,7 +20,7 @@ import { Storage } from '../config/storage.js'; const DEFAULT_TOTAL_MAX_MATCHES = 20000; -function getRgPath() { +function getRgPath(): string { return path.join(Storage.getGlobalBinDir(), 'rg'); } @@ -36,6 +36,16 @@ export async function canUseRipgrep(): Promise { return await fileExists(getRgPath()); } +/** + * Ensures `rg` is downloaded, or throws. + */ +export async function ensureRgPath(): Promise { + if (await canUseRipgrep()) { + return getRgPath(); + } + throw new Error('Cannot use ripgrep.'); +} + /** * Parameters for the GrepTool */ @@ -310,8 +320,9 @@ class GrepToolInvocation extends BaseToolInvocation< rgArgs.push(absolutePath); try { + const rgPath = await ensureRgPath(); const output = await new Promise((resolve, reject) => { - const child = spawn(getRgPath(), rgArgs, { + const child = spawn(rgPath, rgArgs, { windowsHide: true, });