fix(core): show descriptive error messages when saving settings fails (#18095)

Co-authored-by: Dev Randalpura <devrandalpura@google.com>
This commit is contained in:
Alexander Farber
2026-03-13 17:19:56 +01:00
committed by GitHub
parent 2a7e602356
commit aa000d7d30
5 changed files with 298 additions and 3 deletions
+1 -1
View File
@@ -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,
);
});
+5 -2
View File
@@ -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,
);
}
+1
View File
@@ -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);
}