diff --git a/package-lock.json b/package-lock.json index bf34da4ac2..5c92215838 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "packages/*" ], "dependencies": { - "@lvce-editor/ripgrep": "^1.6.0", "simple-git": "^3.28.0" }, "bin": { @@ -1684,6 +1683,28 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@joshua.litt/get-ripgrep": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@joshua.litt/get-ripgrep/-/get-ripgrep-0.0.2.tgz", + "integrity": "sha512-cSHA+H+HEkOXeiCxrNvGj/pgv2Y0bfp4GbH3R87zr7Vob2pDUZV3BkUL9ucHMoDFID4GteSy5z5niN/lF9QeuQ==", + "dependencies": { + "@lvce-editor/verror": "^1.6.0", + "execa": "^9.5.2", + "extract-zip": "^2.0.1", + "fs-extra": "^11.3.0", + "got": "^14.4.5", + "path-exists": "^5.0.0", + "xdg-basedir": "^5.1.0" + } + }, + "node_modules/@joshua.litt/get-ripgrep/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -1819,32 +1840,6 @@ "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", "license": "MIT" }, - "node_modules/@lvce-editor/ripgrep": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@lvce-editor/ripgrep/-/ripgrep-1.6.0.tgz", - "integrity": "sha512-880taWBVULNXmcPHXdxnFUI0FvLErBOjY9OigMXEsLZ2Q1rjcm6LixOkaccKWC8qFMpzm/ldkO7WOMK+ZRfk5Q==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@lvce-editor/verror": "^1.6.0", - "execa": "^9.5.2", - "extract-zip": "^2.0.1", - "fs-extra": "^11.3.0", - "got": "^14.4.5", - "path-exists": "^5.0.0", - "tempy": "^3.1.0", - "xdg-basedir": "^5.1.0" - } - }, - "node_modules/@lvce-editor/ripgrep/node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, "node_modules/@lvce-editor/verror": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@lvce-editor/verror/-/verror-1.7.0.tgz", @@ -6475,33 +6470,6 @@ "node": ">= 8" } }, - "node_modules/crypto-random-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", - "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", - "license": "MIT", - "dependencies": { - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/crypto-random-string/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -14353,45 +14321,6 @@ "node": ">= 6" } }, - "node_modules/temp-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", - "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/tempy": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz", - "integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==", - "license": "MIT", - "dependencies": { - "is-stream": "^3.0.0", - "temp-dir": "^3.0.0", - "type-fest": "^2.12.2", - "unique-string": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tempy/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", @@ -15027,21 +14956,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/unique-string": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", - "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", - "license": "MIT", - "dependencies": { - "crypto-random-string": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -16572,7 +16486,7 @@ "version": "0.3.4", "dependencies": { "@google/genai": "1.16.0", - "@lvce-editor/ripgrep": "^1.6.0", + "@joshua.litt/get-ripgrep": "^0.0.2", "@modelcontextprotocol/sdk": "^1.11.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0", diff --git a/package.json b/package.json index 0284e5a3a3..fc767203d8 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,6 @@ "yargs": "^17.7.2" }, "dependencies": { - "@lvce-editor/ripgrep": "^1.6.0", "simple-git": "^3.28.0" }, "optionalDependencies": { diff --git a/packages/core/package.json b/packages/core/package.json index b44400e180..e97f720707 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,7 +21,7 @@ ], "dependencies": { "@google/genai": "1.16.0", - "@lvce-editor/ripgrep": "^1.6.0", + "@joshua.litt/get-ripgrep": "^0.0.2", "@modelcontextprotocol/sdk": "^1.11.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0", diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 459d42a40a..13e051c0b9 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -25,6 +25,11 @@ import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js' import { ShellTool } from '../tools/shell.js'; import { ReadFileTool } from '../tools/read-file.js'; +import { GrepTool } from '../tools/grep.js'; +import { RipGrepTool, canUseRipgrep } from '../tools/ripGrep.js'; +import { logRipgrepFallback } from '../telemetry/loggers.js'; +import { RipgrepFallbackEvent } from '../telemetry/types.js'; +import { ToolRegistry } from '../tools/tool-registry.js'; vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); @@ -56,7 +61,11 @@ vi.mock('../utils/memoryDiscovery.js', () => ({ // Mock individual tools if their constructors are complex or have side effects vi.mock('../tools/ls'); vi.mock('../tools/read-file'); -vi.mock('../tools/grep'); +vi.mock('../tools/grep.js'); +vi.mock('../tools/ripGrep.js', () => ({ + canUseRipgrep: vi.fn(), + RipGrepTool: class MockRipGrepTool {}, +})); vi.mock('../tools/glob'); vi.mock('../tools/edit'); vi.mock('../tools/shell'); @@ -88,6 +97,15 @@ vi.mock('../telemetry/index.js', async (importOriginal) => { }; }); +vi.mock('../telemetry/loggers.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + logRipgrepFallback: vi.fn(), + }; +}); + vi.mock('../services/gitService.js', () => { const GitServiceMock = vi.fn(); GitServiceMock.prototype.initialize = vi.fn(); @@ -666,4 +684,93 @@ describe('setApprovalMode with folder trust', () => { expect(() => config.setApprovalMode(ApprovalMode.AUTO_EDIT)).not.toThrow(); expect(() => config.setApprovalMode(ApprovalMode.DEFAULT)).not.toThrow(); }); + + describe('registerCoreTools', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should register RipGrepTool when useRipgrep is true and it is available', async () => { + (canUseRipgrep as Mock).mockResolvedValue(true); + const config = new Config({ ...baseParams, useRipgrep: true }); + await config.initialize(); + + const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls; + const wasRipGrepRegistered = calls.some( + (call) => call[0] instanceof vi.mocked(RipGrepTool), + ); + const wasGrepRegistered = calls.some( + (call) => call[0] instanceof vi.mocked(GrepTool), + ); + + expect(wasRipGrepRegistered).toBe(true); + expect(wasGrepRegistered).toBe(false); + expect(logRipgrepFallback).not.toHaveBeenCalled(); + }); + + it('should register GrepTool as a fallback when useRipgrep is true but it is not available', async () => { + (canUseRipgrep as Mock).mockResolvedValue(false); + const config = new Config({ ...baseParams, useRipgrep: true }); + await config.initialize(); + + const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls; + const wasRipGrepRegistered = calls.some( + (call) => call[0] instanceof vi.mocked(RipGrepTool), + ); + const wasGrepRegistered = calls.some( + (call) => call[0] instanceof vi.mocked(GrepTool), + ); + + expect(wasRipGrepRegistered).toBe(false); + expect(wasGrepRegistered).toBe(true); + expect(logRipgrepFallback).toHaveBeenCalledWith( + config, + expect.any(RipgrepFallbackEvent), + ); + const event = (logRipgrepFallback as Mock).mock.calls[0][1]; + expect(event.error).toBeUndefined(); + }); + + it('should register GrepTool as a fallback when canUseRipgrep throws an error', async () => { + const error = new Error('ripGrep check failed'); + (canUseRipgrep as Mock).mockRejectedValue(error); + const config = new Config({ ...baseParams, useRipgrep: true }); + await config.initialize(); + + const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls; + const wasRipGrepRegistered = calls.some( + (call) => call[0] instanceof vi.mocked(RipGrepTool), + ); + const wasGrepRegistered = calls.some( + (call) => call[0] instanceof vi.mocked(GrepTool), + ); + + expect(wasRipGrepRegistered).toBe(false); + expect(wasGrepRegistered).toBe(true); + expect(logRipgrepFallback).toHaveBeenCalledWith( + config, + expect.any(RipgrepFallbackEvent), + ); + const event = (logRipgrepFallback as Mock).mock.calls[0][1]; + expect(event.error).toBe(String(error)); + }); + + it('should register GrepTool when useRipgrep is false', async () => { + const config = new Config({ ...baseParams, useRipgrep: false }); + await config.initialize(); + + const calls = (ToolRegistry.prototype.registerTool as Mock).mock.calls; + const wasRipGrepRegistered = calls.some( + (call) => call[0] instanceof vi.mocked(RipGrepTool), + ); + const wasGrepRegistered = calls.some( + (call) => call[0] instanceof vi.mocked(GrepTool), + ); + + expect(wasRipGrepRegistered).toBe(false); + expect(wasGrepRegistered).toBe(true); + expect(canUseRipgrep).not.toHaveBeenCalled(); + expect(logRipgrepFallback).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index fbc22f73e5..746f1051b2 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -20,7 +20,7 @@ import { ToolRegistry } from '../tools/tool-registry.js'; import { LSTool } from '../tools/ls.js'; import { ReadFileTool } from '../tools/read-file.js'; import { GrepTool } from '../tools/grep.js'; -import { RipGrepTool } from '../tools/ripGrep.js'; +import { canUseRipgrep, RipGrepTool } from '../tools/ripGrep.js'; import { GlobTool } from '../tools/glob.js'; import { EditTool } from '../tools/edit.js'; import { SmartEditTool } from '../tools/smart-edit.js'; @@ -50,8 +50,16 @@ import { IdeClient } from '../ide/ide-client.js'; import { ideContext } from '../ide/ideContext.js'; import type { FileSystemService } from '../services/fileSystemService.js'; import { StandardFileSystemService } from '../services/fileSystemService.js'; -import { logCliConfiguration, logIdeConnection } from '../telemetry/loggers.js'; -import { IdeConnectionEvent, IdeConnectionType } from '../telemetry/types.js'; +import { + logCliConfiguration, + logIdeConnection, + logRipgrepFallback, +} from '../telemetry/loggers.js'; +import { + IdeConnectionEvent, + IdeConnectionType, + RipgrepFallbackEvent, +} from '../telemetry/types.js'; import type { FallbackModelHandler } from '../fallback/types.js'; // Re-export OAuth config type @@ -892,7 +900,19 @@ export class Config { registerCoreTool(ReadFileTool, this); if (this.getUseRipgrep()) { - registerCoreTool(RipGrepTool, this); + let useRipgrep = false; + let errorString: undefined | string = undefined; + try { + useRipgrep = await canUseRipgrep(); + } catch (error: unknown) { + errorString = String(error); + } + if (useRipgrep) { + registerCoreTool(RipGrepTool, this); + } else { + logRipgrepFallback(this, new RipgrepFallbackEvent(errorString)); + registerCoreTool(GrepTool, this); + } } else { registerCoreTool(GrepTool, this); } diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index 3b2cbef947..e93bb375c8 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -52,4 +52,9 @@ describe('Storage – additional helpers', () => { ); expect(Storage.getMcpOAuthTokensPath()).toBe(expected); }); + + it('getGlobalBinDir returns ~/.gemini/tmp/bin', () => { + const expected = path.join(os.homedir(), '.gemini', 'tmp', 'bin'); + expect(Storage.getGlobalBinDir()).toBe(expected); + }); }); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 6442b87c87..354d51f1c1 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -13,6 +13,7 @@ export const GEMINI_DIR = '.gemini'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; export const OAUTH_FILE = 'oauth_creds.json'; const TMP_DIR_NAME = 'tmp'; +const BIN_DIR_NAME = 'bin'; export class Storage { private readonly targetDir: string; @@ -57,6 +58,10 @@ export class Storage { return path.join(Storage.getGlobalGeminiDir(), TMP_DIR_NAME); } + static getGlobalBinDir(): string { + return path.join(Storage.getGlobalTempDir(), BIN_DIR_NAME); + } + getGeminiDir(): string { return path.join(this.targetDir, GEMINI_DIR); } diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts index eddc9a59cf..666adcbe36 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts @@ -392,6 +392,24 @@ describe('ClearcutLogger', () => { }); }); + describe('logRipgrepFallbackEvent', () => { + it('logs an event with the proper name', () => { + const { logger } = setup(); + // Spy on flushToClearcut to prevent it from clearing the queue + const flushSpy = vi + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(logger!, 'flushToClearcut' as any) + .mockResolvedValue({ nextRequestWaitMs: 0 }); + + logger?.logRipgrepFallbackEvent(); + + const events = getEvents(logger!); + expect(events.length).toBe(1); + expect(events[0]).toHaveEventName(EventNames.RIPGREP_FALLBACK); + expect(flushSpy).toHaveBeenCalledOnce(); + }); + }); + describe('enqueueLogEvent', () => { it('should add events to the queue', () => { const { logger } = setup(); diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index bcdea047cb..994d23cbb6 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -44,6 +44,7 @@ export enum EventNames { API_ERROR = 'api_error', END_SESSION = 'end_session', FLASH_FALLBACK = 'flash_fallback', + RIPGREP_FALLBACK = 'ripgrep_fallback', LOOP_DETECTED = 'loop_detected', NEXT_SPEAKER_CHECK = 'next_speaker_check', SLASH_COMMAND = 'slash_command', @@ -631,6 +632,13 @@ export class ClearcutLogger { }); } + logRipgrepFallbackEvent(): void { + this.enqueueLogEvent(this.createLogEvent(EventNames.RIPGREP_FALLBACK, [])); + this.flushToClearcut().catch((error) => { + console.debug('Error flushing to Clearcut:', error); + }); + } + logLoopDetectedEvent(event: LoopDetectedEvent): void { const data: EventValue[] = [ { diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index 6b62b6deed..2e06dacd4f 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -13,6 +13,7 @@ export const EVENT_API_ERROR = 'gemini_cli.api_error'; export const EVENT_API_RESPONSE = 'gemini_cli.api_response'; export const EVENT_CLI_CONFIG = 'gemini_cli.config'; export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback'; +export const EVENT_RIPGREP_FALLBACK = 'gemini_cli.ripgrep_fallback'; export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check'; export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command'; export const EVENT_IDE_CONNECTION = 'gemini_cli.ide_connection'; diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index b6138796bb..60e191249d 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -30,6 +30,7 @@ import { EVENT_FLASH_FALLBACK, EVENT_MALFORMED_JSON_RESPONSE, EVENT_FILE_OPERATION, + EVENT_RIPGREP_FALLBACK, } from './constants.js'; import { logApiRequest, @@ -41,6 +42,7 @@ import { logChatCompression, logMalformedJsonResponse, logFileOperation, + logRipgrepFallback, } from './loggers.js'; import { ToolCallDecision } from './tool-call-decision.js'; import { @@ -50,6 +52,7 @@ import { ToolCallEvent, UserPromptEvent, FlashFallbackEvent, + RipgrepFallbackEvent, MalformedJsonResponseEvent, makeChatCompressionEvent, FileOperationEvent, @@ -453,6 +456,59 @@ describe('loggers', () => { }); }); + describe('logRipgrepFallback', () => { + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + } as unknown as Config; + + beforeEach(() => { + vi.spyOn(ClearcutLogger.prototype, 'logRipgrepFallbackEvent'); + }); + + it('should log ripgrep fallback event', () => { + const event = new RipgrepFallbackEvent(); + + logRipgrepFallback(mockConfig, event); + + expect( + ClearcutLogger.prototype.logRipgrepFallbackEvent, + ).toHaveBeenCalled(); + + const emittedEvent = mockLogger.emit.mock.calls[0][0]; + expect(emittedEvent.body).toBe('Switching to grep as fallback.'); + expect(emittedEvent.attributes).toEqual( + expect.objectContaining({ + 'session.id': 'test-session-id', + 'user.email': 'test-user@example.com', + 'event.name': EVENT_RIPGREP_FALLBACK, + error: undefined, + }), + ); + }); + + it('should log ripgrep fallback event with an error', () => { + const event = new RipgrepFallbackEvent('rg not found'); + + logRipgrepFallback(mockConfig, event); + + expect( + ClearcutLogger.prototype.logRipgrepFallbackEvent, + ).toHaveBeenCalled(); + + const emittedEvent = mockLogger.emit.mock.calls[0][0]; + expect(emittedEvent.body).toBe('Switching to grep as fallback.'); + expect(emittedEvent.attributes).toEqual( + expect.objectContaining({ + 'session.id': 'test-session-id', + 'user.email': 'test-user@example.com', + 'event.name': EVENT_RIPGREP_FALLBACK, + error: 'rg not found', + }), + ); + }); + }); + describe('logToolCall', () => { const cfg1 = { getSessionId: () => 'test-session-id', diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 06476d3a81..d87fd99fb5 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -27,6 +27,7 @@ import { EVENT_CONTENT_RETRY, EVENT_CONTENT_RETRY_FAILURE, EVENT_FILE_OPERATION, + EVENT_RIPGREP_FALLBACK, } from './constants.js'; import type { ApiErrorEvent, @@ -48,6 +49,7 @@ import type { InvalidChunkEvent, ContentRetryEvent, ContentRetryFailureEvent, + RipgrepFallbackEvent, } from './types.js'; import { recordApiErrorMetrics, @@ -268,6 +270,28 @@ export function logFlashFallback( logger.emit(logRecord); } +export function logRipgrepFallback( + config: Config, + event: RipgrepFallbackEvent, +): void { + ClearcutLogger.getInstance(config)?.logRipgrepFallbackEvent(); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_RIPGREP_FALLBACK, + 'event.timestamp': new Date().toISOString(), + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Switching to grep as fallback.`, + attributes, + }; + logger.emit(logRecord); +} + export function logApiError(config: Config, event: ApiErrorEvent): void { const uiEvent = { ...event, diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index e99778457b..ac816bdba8 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -281,6 +281,16 @@ export class FlashFallbackEvent implements BaseTelemetryEvent { } } +export class RipgrepFallbackEvent implements BaseTelemetryEvent { + 'event.name': 'ripgrep_fallback'; + 'event.timestamp': string; + + constructor(public error?: string) { + this['event.name'] = 'ripgrep_fallback'; + this['event.timestamp'] = new Date().toISOString(); + } +} + export enum LoopType { CONSECUTIVE_IDENTICAL_TOOL_CALLS = 'consecutive_identical_tool_calls', CHANTING_IDENTICAL_SENTENCES = 'chanting_identical_sentences', diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts index 06cc4ccce5..3004023aec 100644 --- a/packages/core/src/tools/ripGrep.test.ts +++ b/packages/core/src/tools/ripGrep.test.ts @@ -4,9 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, + type Mock, +} from 'vitest'; import type { RipGrepToolParams } from './ripGrep.js'; -import { RipGrepTool } from './ripGrep.js'; +import { canUseRipgrep, RipGrepTool } from './ripGrep.js'; import path from 'node:path'; import fs from 'node:fs/promises'; import os, { EOL } from 'node:os'; @@ -14,10 +22,24 @@ import type { Config } from '../config/config.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import type { ChildProcess } from 'node:child_process'; import { spawn } from 'node:child_process'; +import { downloadRipGrep } from '@joshua.litt/get-ripgrep'; +import { fileExists } from '../utils/fileUtils.js'; -// Mock @lvce-editor/ripgrep for testing -vi.mock('@lvce-editor/ripgrep', () => ({ - rgPath: '/mock/rg/path', +// Mock dependencies for canUseRipgrep +vi.mock('@joshua.litt/get-ripgrep', () => ({ + downloadRipGrep: vi.fn(), +})); +vi.mock('../utils/fileUtils.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fileExists: vi.fn(), + }; +}); +vi.mock('../config/storage.js', () => ({ + Storage: { + getGlobalBinDir: vi.fn().mockReturnValue('/mock/bin/dir'), + }, })); // Mock child_process for ripgrep calls @@ -27,6 +49,54 @@ vi.mock('child_process', () => ({ const mockSpawn = vi.mocked(spawn); +describe('canUseRipgrep', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return true if ripgrep already exists', async () => { + (fileExists as Mock).mockResolvedValue(true); + const result = await canUseRipgrep(); + expect(result).toBe(true); + expect(fileExists).toHaveBeenCalledWith(path.join('/mock/bin/dir', 'rg')); + expect(downloadRipGrep).not.toHaveBeenCalled(); + }); + + it('should download ripgrep and return true if it does not exist initially', async () => { + (fileExists as Mock) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + (downloadRipGrep as Mock).mockResolvedValue(undefined); + + const result = await canUseRipgrep(); + + expect(result).toBe(true); + expect(fileExists).toHaveBeenCalledTimes(2); + expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir'); + }); + + it('should return false if download fails and file does not exist', async () => { + (fileExists as Mock).mockResolvedValue(false); + (downloadRipGrep as Mock).mockResolvedValue(undefined); + + const result = await canUseRipgrep(); + + expect(result).toBe(false); + expect(fileExists).toHaveBeenCalledTimes(2); + expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir'); + }); + + 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(canUseRipgrep()).rejects.toThrow(error); + expect(fileExists).toHaveBeenCalledTimes(1); + expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir'); + }); +}); + // Helper function to create mock spawn implementations function createMockSpawn( options: { diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index b851c2cd1e..4bb386d5fb 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -8,16 +8,34 @@ import fs from 'node:fs'; import path from 'node:path'; import { EOL } from 'node:os'; import { spawn } from 'node:child_process'; -import { rgPath } from '@lvce-editor/ripgrep'; +import { downloadRipGrep } from '@joshua.litt/get-ripgrep'; import type { ToolInvocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js'; import type { Config } from '../config/config.js'; +import { fileExists } from '../utils/fileUtils.js'; +import { Storage } from '../config/storage.js'; const DEFAULT_TOTAL_MAX_MATCHES = 20000; +function getRgPath() { + return path.join(Storage.getGlobalBinDir(), 'rg'); +} + +/** + * Checks if `rg` exists, if not then attempt to download it. + */ +export async function canUseRipgrep(): Promise { + if (await fileExists(getRgPath())) { + return true; + } + + await downloadRipGrep(Storage.getGlobalBinDir()); + return await fileExists(getRgPath()); +} + /** * Parameters for the GrepTool */ @@ -293,7 +311,7 @@ class GrepToolInvocation extends BaseToolInvocation< try { const output = await new Promise((resolve, reject) => { - const child = spawn(rgPath, rgArgs, { + const child = spawn(getRgPath(), rgArgs, { windowsHide: true, }); diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index fe6860f1f1..dd1ad6e62c 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -28,6 +28,7 @@ import { processSingleFileContent, detectBOM, readFileWithEncoding, + fileExists, } from './fileUtils.js'; import { StandardFileSystemService } from '../services/fileSystemService.js'; @@ -133,6 +134,25 @@ describe('fileUtils', () => { }); }); + describe('fileExists', () => { + it('should return true if the file exists', async () => { + const testFile = path.join(tempRootDir, 'exists.txt'); + actualNodeFs.writeFileSync(testFile, 'content'); + await expect(fileExists(testFile)).resolves.toBe(true); + }); + + it('should return false if the file does not exist', async () => { + const testFile = path.join(tempRootDir, 'does-not-exist.txt'); + await expect(fileExists(testFile)).resolves.toBe(false); + }); + + it('should return true for a directory that exists', async () => { + const testDir = path.join(tempRootDir, 'exists-dir'); + actualNodeFs.mkdirSync(testDir); + await expect(fileExists(testDir)).resolves.toBe(true); + }); + }); + describe('isBinaryFile', () => { let filePathForBinaryTest: string; diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index f623ae58c8..8525c3b913 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -5,6 +5,7 @@ */ import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; import path from 'node:path'; import type { PartUnion } from '@google/genai'; // eslint-disable-next-line import/no-internal-modules @@ -467,3 +468,12 @@ export async function processSingleFileContent( }; } } + +export async function fileExists(filePath: string): Promise { + try { + await fsPromises.access(filePath, fs.constants.F_OK); + return true; + } catch (_: unknown) { + return false; + } +}