mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 10:34:35 -07:00
fix(core): show descriptive error messages when saving settings fails (#18095)
Co-authored-by: Dev Randalpura <devrandalpura@google.com>
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
);
|
||||
} 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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, (path?: string) => 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);
|
||||
}
|
||||
Reference in New Issue
Block a user