diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 7092f26a99..af143afcc0 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -2594,7 +2594,7 @@ describe('Settings Loading and Merging', () => { expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( 'error', - 'There was an error saving your latest settings changes.', + 'Failed to save settings: Write failed', error, ); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index a195931803..711ff93271 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -14,6 +14,7 @@ import { FatalConfigError, GEMINI_DIR, getErrorMessage, + getFsErrorMessage, Storage, coreEvents, homedir, @@ -1072,9 +1073,10 @@ export function saveSettings(settingsFile: SettingsFile): void { settingsToSave as Record, ); } catch (error) { + const detailedErrorMessage = getFsErrorMessage(error); coreEvents.emitFeedback( 'error', - 'There was an error saving your latest settings changes.', + `Failed to save settings: ${detailedErrorMessage}`, error, ); } @@ -1087,9 +1089,10 @@ export function saveModelChange( try { loadedSettings.setValue(SettingScope.User, 'model.name', model); } catch (error) { + const detailedErrorMessage = getFsErrorMessage(error); coreEvents.emitFeedback( 'error', - 'There was an error saving your preferred model.', + `Failed to save preferred model: ${detailedErrorMessage}`, error, ); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e035dc4502..b846e2f2e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,6 +68,7 @@ export * from './utils/checks.js'; export * from './utils/headless.js'; export * from './utils/schemaValidator.js'; export * from './utils/errors.js'; +export * from './utils/fsErrorMessages.js'; export * from './utils/exitCodes.js'; export * from './utils/getFolderStructure.js'; export * from './utils/memoryDiscovery.js'; diff --git a/packages/core/src/utils/fsErrorMessages.test.ts b/packages/core/src/utils/fsErrorMessages.test.ts new file mode 100644 index 0000000000..9e1d625b67 --- /dev/null +++ b/packages/core/src/utils/fsErrorMessages.test.ts @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { getFsErrorMessage } from './fsErrorMessages.js'; + +/** + * Helper to create a mock NodeJS.ErrnoException + */ +function createNodeError( + code: string, + message: string, + path?: string, +): NodeJS.ErrnoException { + const error = new Error(message) as NodeJS.ErrnoException; + error.code = code; + if (path) { + error.path = path; + } + return error; +} + +interface FsErrorCase { + code: string; + message: string; + path?: string; + expected: string; +} + +interface FallbackErrorCase { + value: unknown; + expected: string; +} + +describe('getFsErrorMessage', () => { + describe('known filesystem error codes', () => { + const testCases: FsErrorCase[] = [ + { + code: 'EACCES', + message: 'EACCES: permission denied', + path: '/etc/gemini-cli/settings.json', + expected: + "Permission denied: cannot access '/etc/gemini-cli/settings.json'. Check file permissions or run with elevated privileges.", + }, + { + code: 'EACCES', + message: 'EACCES: permission denied', + expected: + 'Permission denied. Check file permissions or run with elevated privileges.', + }, + { + code: 'ENOENT', + message: 'ENOENT: no such file or directory', + path: '/nonexistent/file.txt', + expected: + "File or directory not found: '/nonexistent/file.txt'. Check if the path exists and is spelled correctly.", + }, + { + code: 'ENOENT', + message: 'ENOENT: no such file or directory', + expected: + 'File or directory not found. Check if the path exists and is spelled correctly.', + }, + { + code: 'ENOSPC', + message: 'ENOSPC: no space left on device', + expected: + 'No space left on device. Free up some disk space and try again.', + }, + { + code: 'EISDIR', + message: 'EISDIR: illegal operation on a directory', + path: '/some/directory', + expected: + "Path is a directory, not a file: '/some/directory'. Please provide a path to a file instead.", + }, + { + code: 'EISDIR', + message: 'EISDIR: illegal operation on a directory', + expected: + 'Path is a directory, not a file. Please provide a path to a file instead.', + }, + { + code: 'EROFS', + message: 'EROFS: read-only file system', + expected: + 'Read-only file system. Ensure the file system allows write operations.', + }, + { + code: 'EPERM', + message: 'EPERM: operation not permitted', + path: '/protected/file', + expected: + "Operation not permitted: '/protected/file'. Ensure you have the required permissions for this action.", + }, + { + code: 'EPERM', + message: 'EPERM: operation not permitted', + expected: + 'Operation not permitted. Ensure you have the required permissions for this action.', + }, + { + code: 'EEXIST', + message: 'EEXIST: file already exists', + path: '/existing/file', + expected: + "File or directory already exists: '/existing/file'. Try using a different name or path.", + }, + { + code: 'EEXIST', + message: 'EEXIST: file already exists', + expected: + 'File or directory already exists. Try using a different name or path.', + }, + { + code: 'EBUSY', + message: 'EBUSY: resource busy or locked', + path: '/locked/file', + expected: + "Resource busy or locked: '/locked/file'. Close any programs that might be using the file.", + }, + { + code: 'EBUSY', + message: 'EBUSY: resource busy or locked', + expected: + 'Resource busy or locked. Close any programs that might be using the file.', + }, + { + code: 'EMFILE', + message: 'EMFILE: too many open files', + expected: + 'Too many open files. Close some unused files or applications.', + }, + { + code: 'ENFILE', + message: 'ENFILE: file table overflow', + expected: + 'Too many open files in system. Close some unused files or applications.', + }, + ]; + + it.each(testCases)( + 'returns friendly message for $code (path: $path)', + ({ code, message, path, expected }) => { + const error = createNodeError(code, message, path); + expect(getFsErrorMessage(error)).toBe(expected); + }, + ); + }); + + describe('unknown node error codes', () => { + const testCases: FsErrorCase[] = [ + { + code: 'EUNKNOWN', + message: 'Some unknown error occurred', + expected: 'Some unknown error occurred (EUNKNOWN)', + }, + { + code: 'toString', + message: 'Unexpected error', + path: '/some/path', + expected: 'Unexpected error (toString)', + }, + ]; + + it.each(testCases)( + 'includes code in fallback message for $code', + ({ code, message, path, expected }) => { + const error = createNodeError(code, message, path); + expect(getFsErrorMessage(error)).toBe(expected); + }, + ); + }); + + describe('non-node and nullish errors', () => { + const fallbackCases: FallbackErrorCase[] = [ + { + value: new Error('Something went wrong'), + expected: 'Something went wrong', + }, + { value: 'string error', expected: 'string error' }, + { value: 12345, expected: '12345' }, + { value: null, expected: 'An unknown error occurred' }, + { value: undefined, expected: 'An unknown error occurred' }, + ]; + + it.each(fallbackCases)( + 'returns a message for $value', + ({ value, expected }) => { + expect(getFsErrorMessage(value)).toBe(expected); + }, + ); + + it.each([null, undefined] as const)( + 'uses custom default for %s', + (value) => { + expect(getFsErrorMessage(value, 'Custom default')).toBe( + 'Custom default', + ); + }, + ); + }); +}); diff --git a/packages/core/src/utils/fsErrorMessages.ts b/packages/core/src/utils/fsErrorMessages.ts new file mode 100644 index 0000000000..472cb5f9f4 --- /dev/null +++ b/packages/core/src/utils/fsErrorMessages.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isNodeError, getErrorMessage } from './errors.js'; + +/** + * Map of Node.js filesystem error codes to user-friendly message generators. + * Each function takes the path (if available) and returns a descriptive message. + */ +const errorMessageGenerators: Record string> = { + EACCES: (path) => + (path + ? `Permission denied: cannot access '${path}'. ` + : 'Permission denied. ') + + 'Check file permissions or run with elevated privileges.', + ENOENT: (path) => + (path + ? `File or directory not found: '${path}'. ` + : 'File or directory not found. ') + + 'Check if the path exists and is spelled correctly.', + ENOSPC: () => + 'No space left on device. Free up some disk space and try again.', + EISDIR: (path) => + (path + ? `Path is a directory, not a file: '${path}'. ` + : 'Path is a directory, not a file. ') + + 'Please provide a path to a file instead.', + EROFS: () => + 'Read-only file system. Ensure the file system allows write operations.', + EPERM: (path) => + (path + ? `Operation not permitted: '${path}'. ` + : 'Operation not permitted. ') + + 'Ensure you have the required permissions for this action.', + EEXIST: (path) => + (path + ? `File or directory already exists: '${path}'. ` + : 'File or directory already exists. ') + + 'Try using a different name or path.', + EBUSY: (path) => + (path + ? `Resource busy or locked: '${path}'. ` + : 'Resource busy or locked. ') + + 'Close any programs that might be using the file.', + EMFILE: () => 'Too many open files. Close some unused files or applications.', + ENFILE: () => + 'Too many open files in system. Close some unused files or applications.', +}; + +/** + * Converts a Node.js filesystem error to a user-friendly message. + * + * @param error - The error to convert + * @param defaultMessage - Optional default message if error cannot be interpreted + * @returns A user-friendly error message + */ +export function getFsErrorMessage( + error: unknown, + defaultMessage = 'An unknown error occurred', +): string { + if (error == null) { + return defaultMessage; + } + + if (isNodeError(error)) { + const code = error.code; + const path = error.path; + + if (code && Object.hasOwn(errorMessageGenerators, code)) { + return errorMessageGenerators[code](path); + } + + // For unknown error codes, include the code in the message + if (code) { + const baseMessage = error.message || defaultMessage; + return `${baseMessage} (${code})`; + } + } + + // For non-Node errors, return the error message or string representation + return getErrorMessage(error); +}