diff options
Diffstat (limited to 'packages/cli/src/utils')
| -rw-r--r-- | packages/cli/src/utils/dialogScopeUtils.ts | 64 | ||||
| -rw-r--r-- | packages/cli/src/utils/settingsUtils.test.ts | 797 | ||||
| -rw-r--r-- | packages/cli/src/utils/settingsUtils.ts | 473 |
3 files changed, 1334 insertions, 0 deletions
diff --git a/packages/cli/src/utils/dialogScopeUtils.ts b/packages/cli/src/utils/dialogScopeUtils.ts new file mode 100644 index 00000000..c175f9c8 --- /dev/null +++ b/packages/cli/src/utils/dialogScopeUtils.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SettingScope, LoadedSettings } from '../config/settings.js'; +import { settingExistsInScope } from './settingsUtils.js'; + +/** + * Shared scope labels for dialog components that need to display setting scopes + */ +export const SCOPE_LABELS = { + [SettingScope.User]: 'User Settings', + [SettingScope.Workspace]: 'Workspace Settings', + [SettingScope.System]: 'System Settings', +} as const; + +/** + * Helper function to get scope items for radio button selects + */ +export function getScopeItems() { + return [ + { label: SCOPE_LABELS[SettingScope.User], value: SettingScope.User }, + { + label: SCOPE_LABELS[SettingScope.Workspace], + value: SettingScope.Workspace, + }, + { label: SCOPE_LABELS[SettingScope.System], value: SettingScope.System }, + ]; +} + +/** + * Generate scope message for a specific setting + */ +export function getScopeMessageForSetting( + settingKey: string, + selectedScope: SettingScope, + settings: LoadedSettings, +): string { + const otherScopes = Object.values(SettingScope).filter( + (scope) => scope !== selectedScope, + ); + + const modifiedInOtherScopes = otherScopes.filter((scope) => { + const scopeSettings = settings.forScope(scope).settings; + return settingExistsInScope(settingKey, scopeSettings); + }); + + if (modifiedInOtherScopes.length === 0) { + return ''; + } + + const modifiedScopesStr = modifiedInOtherScopes.join(', '); + const currentScopeSettings = settings.forScope(selectedScope).settings; + const existsInCurrentScope = settingExistsInScope( + settingKey, + currentScopeSettings, + ); + + return existsInCurrentScope + ? `(Also modified in ${modifiedScopesStr})` + : `(Modified in ${modifiedScopesStr})`; +} diff --git a/packages/cli/src/utils/settingsUtils.test.ts b/packages/cli/src/utils/settingsUtils.test.ts new file mode 100644 index 00000000..2aeb1da3 --- /dev/null +++ b/packages/cli/src/utils/settingsUtils.test.ts @@ -0,0 +1,797 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + // Schema utilities + getSettingsByCategory, + getSettingDefinition, + requiresRestart, + getDefaultValue, + getRestartRequiredSettings, + getEffectiveValue, + getAllSettingKeys, + getSettingsByType, + getSettingsRequiringRestart, + isValidSettingKey, + getSettingCategory, + shouldShowInDialog, + getDialogSettingsByCategory, + getDialogSettingsByType, + getDialogSettingKeys, + // Business logic utilities + getSettingValue, + isSettingModified, + settingExistsInScope, + setPendingSettingValue, + hasRestartRequiredSettings, + getRestartRequiredFromModified, + getDisplayValue, + isDefaultValue, + isValueInherited, + getEffectiveDisplayValue, +} from './settingsUtils.js'; + +describe('SettingsUtils', () => { + describe('Schema Utilities', () => { + describe('getSettingsByCategory', () => { + it('should group settings by category', () => { + const categories = getSettingsByCategory(); + + expect(categories).toHaveProperty('General'); + expect(categories).toHaveProperty('Accessibility'); + expect(categories).toHaveProperty('Checkpointing'); + expect(categories).toHaveProperty('File Filtering'); + expect(categories).toHaveProperty('UI'); + expect(categories).toHaveProperty('Mode'); + expect(categories).toHaveProperty('Updates'); + }); + + it('should include key property in grouped settings', () => { + const categories = getSettingsByCategory(); + + Object.entries(categories).forEach(([_category, settings]) => { + settings.forEach((setting) => { + expect(setting.key).toBeDefined(); + }); + }); + }); + }); + + describe('getSettingDefinition', () => { + it('should return definition for valid setting', () => { + const definition = getSettingDefinition('showMemoryUsage'); + expect(definition).toBeDefined(); + expect(definition?.label).toBe('Show Memory Usage'); + }); + + it('should return undefined for invalid setting', () => { + const definition = getSettingDefinition('invalidSetting'); + expect(definition).toBeUndefined(); + }); + }); + + describe('requiresRestart', () => { + it('should return true for settings that require restart', () => { + expect(requiresRestart('autoConfigureMaxOldSpaceSize')).toBe(true); + expect(requiresRestart('checkpointing.enabled')).toBe(true); + }); + + it('should return false for settings that do not require restart', () => { + expect(requiresRestart('showMemoryUsage')).toBe(false); + expect(requiresRestart('hideTips')).toBe(false); + }); + + it('should return false for invalid settings', () => { + expect(requiresRestart('invalidSetting')).toBe(false); + }); + }); + + describe('getDefaultValue', () => { + it('should return correct default values', () => { + expect(getDefaultValue('showMemoryUsage')).toBe(false); + expect(getDefaultValue('fileFiltering.enableRecursiveFileSearch')).toBe( + true, + ); + }); + + it('should return undefined for invalid settings', () => { + expect(getDefaultValue('invalidSetting')).toBeUndefined(); + }); + }); + + describe('getRestartRequiredSettings', () => { + it('should return all settings that require restart', () => { + const restartSettings = getRestartRequiredSettings(); + expect(restartSettings).toContain('autoConfigureMaxOldSpaceSize'); + expect(restartSettings).toContain('checkpointing.enabled'); + expect(restartSettings).not.toContain('showMemoryUsage'); + }); + }); + + describe('getEffectiveValue', () => { + it('should return value from settings when set', () => { + const settings = { showMemoryUsage: true }; + const mergedSettings = { showMemoryUsage: false }; + + const value = getEffectiveValue( + 'showMemoryUsage', + settings, + mergedSettings, + ); + expect(value).toBe(true); + }); + + it('should return value from merged settings when not set in current scope', () => { + const settings = {}; + const mergedSettings = { showMemoryUsage: true }; + + const value = getEffectiveValue( + 'showMemoryUsage', + settings, + mergedSettings, + ); + expect(value).toBe(true); + }); + + it('should return default value when not set anywhere', () => { + const settings = {}; + const mergedSettings = {}; + + const value = getEffectiveValue( + 'showMemoryUsage', + settings, + mergedSettings, + ); + expect(value).toBe(false); // default value + }); + + it('should handle nested settings correctly', () => { + const settings = { + accessibility: { disableLoadingPhrases: true }, + }; + const mergedSettings = { + accessibility: { disableLoadingPhrases: false }, + }; + + const value = getEffectiveValue( + 'accessibility.disableLoadingPhrases', + settings, + mergedSettings, + ); + expect(value).toBe(true); + }); + + it('should return undefined for invalid settings', () => { + const settings = {}; + const mergedSettings = {}; + + const value = getEffectiveValue( + 'invalidSetting', + settings, + mergedSettings, + ); + expect(value).toBeUndefined(); + }); + }); + + describe('getAllSettingKeys', () => { + it('should return all setting keys', () => { + const keys = getAllSettingKeys(); + expect(keys).toContain('showMemoryUsage'); + expect(keys).toContain('accessibility.disableLoadingPhrases'); + expect(keys).toContain('checkpointing.enabled'); + }); + }); + + describe('getSettingsByType', () => { + it('should return only boolean settings', () => { + const booleanSettings = getSettingsByType('boolean'); + expect(booleanSettings.length).toBeGreaterThan(0); + booleanSettings.forEach((setting) => { + expect(setting.type).toBe('boolean'); + }); + }); + }); + + describe('getSettingsRequiringRestart', () => { + it('should return only settings that require restart', () => { + const restartSettings = getSettingsRequiringRestart(); + expect(restartSettings.length).toBeGreaterThan(0); + restartSettings.forEach((setting) => { + expect(setting.requiresRestart).toBe(true); + }); + }); + }); + + describe('isValidSettingKey', () => { + it('should return true for valid setting keys', () => { + expect(isValidSettingKey('showMemoryUsage')).toBe(true); + expect(isValidSettingKey('accessibility.disableLoadingPhrases')).toBe( + true, + ); + }); + + it('should return false for invalid setting keys', () => { + expect(isValidSettingKey('invalidSetting')).toBe(false); + expect(isValidSettingKey('')).toBe(false); + }); + }); + + describe('getSettingCategory', () => { + it('should return correct category for valid settings', () => { + expect(getSettingCategory('showMemoryUsage')).toBe('UI'); + expect(getSettingCategory('accessibility.disableLoadingPhrases')).toBe( + 'Accessibility', + ); + }); + + it('should return undefined for invalid settings', () => { + expect(getSettingCategory('invalidSetting')).toBeUndefined(); + }); + }); + + describe('shouldShowInDialog', () => { + it('should return true for settings marked to show in dialog', () => { + expect(shouldShowInDialog('showMemoryUsage')).toBe(true); + expect(shouldShowInDialog('vimMode')).toBe(true); + expect(shouldShowInDialog('hideWindowTitle')).toBe(true); + expect(shouldShowInDialog('usageStatisticsEnabled')).toBe(true); + }); + + it('should return false for settings marked to hide from dialog', () => { + expect(shouldShowInDialog('selectedAuthType')).toBe(false); + expect(shouldShowInDialog('coreTools')).toBe(false); + expect(shouldShowInDialog('customThemes')).toBe(false); + expect(shouldShowInDialog('theme')).toBe(false); // Changed to false + expect(shouldShowInDialog('preferredEditor')).toBe(false); // Changed to false + }); + + it('should return true for invalid settings (default behavior)', () => { + expect(shouldShowInDialog('invalidSetting')).toBe(true); + }); + }); + + describe('getDialogSettingsByCategory', () => { + it('should only return settings marked for dialog display', async () => { + const categories = getDialogSettingsByCategory(); + + // Should include UI settings that are marked for dialog + expect(categories['UI']).toBeDefined(); + const uiSettings = categories['UI']; + const uiKeys = uiSettings.map((s) => s.key); + expect(uiKeys).toContain('showMemoryUsage'); + expect(uiKeys).toContain('hideWindowTitle'); + expect(uiKeys).not.toContain('customThemes'); // This is marked false + expect(uiKeys).not.toContain('theme'); // This is now marked false + }); + + it('should not include Advanced category settings', () => { + const categories = getDialogSettingsByCategory(); + + // Advanced settings should be filtered out + expect(categories['Advanced']).toBeUndefined(); + }); + + it('should include settings with showInDialog=true', () => { + const categories = getDialogSettingsByCategory(); + + const allSettings = Object.values(categories).flat(); + const allKeys = allSettings.map((s) => s.key); + + expect(allKeys).toContain('vimMode'); + expect(allKeys).toContain('ideMode'); + expect(allKeys).toContain('disableAutoUpdate'); + expect(allKeys).toContain('showMemoryUsage'); + expect(allKeys).toContain('usageStatisticsEnabled'); + expect(allKeys).not.toContain('selectedAuthType'); + expect(allKeys).not.toContain('coreTools'); + expect(allKeys).not.toContain('theme'); // Now hidden + expect(allKeys).not.toContain('preferredEditor'); // Now hidden + }); + }); + + describe('getDialogSettingsByType', () => { + it('should return only boolean dialog settings', () => { + const booleanSettings = getDialogSettingsByType('boolean'); + + const keys = booleanSettings.map((s) => s.key); + expect(keys).toContain('showMemoryUsage'); + expect(keys).toContain('vimMode'); + expect(keys).toContain('hideWindowTitle'); + expect(keys).toContain('usageStatisticsEnabled'); + expect(keys).not.toContain('selectedAuthType'); // Advanced setting + expect(keys).not.toContain('useExternalAuth'); // Advanced setting + }); + + it('should return only string dialog settings', () => { + const stringSettings = getDialogSettingsByType('string'); + + const keys = stringSettings.map((s) => s.key); + // Note: theme and preferredEditor are now hidden from dialog + expect(keys).not.toContain('theme'); // Now marked false + expect(keys).not.toContain('preferredEditor'); // Now marked false + expect(keys).not.toContain('selectedAuthType'); // Advanced setting + + // Most string settings are now hidden, so let's just check they exclude advanced ones + expect(keys.every((key) => !key.startsWith('tool'))).toBe(true); // No tool-related settings + }); + }); + + describe('getDialogSettingKeys', () => { + it('should return only settings marked for dialog display', () => { + const dialogKeys = getDialogSettingKeys(); + + // Should include settings marked for dialog + expect(dialogKeys).toContain('showMemoryUsage'); + expect(dialogKeys).toContain('vimMode'); + expect(dialogKeys).toContain('hideWindowTitle'); + expect(dialogKeys).toContain('usageStatisticsEnabled'); + expect(dialogKeys).toContain('ideMode'); + expect(dialogKeys).toContain('disableAutoUpdate'); + + // Should include nested settings marked for dialog + expect(dialogKeys).toContain('fileFiltering.respectGitIgnore'); + expect(dialogKeys).toContain('fileFiltering.respectGeminiIgnore'); + expect(dialogKeys).toContain('fileFiltering.enableRecursiveFileSearch'); + + // Should NOT include settings marked as hidden + expect(dialogKeys).not.toContain('theme'); // Hidden + expect(dialogKeys).not.toContain('customThemes'); // Hidden + expect(dialogKeys).not.toContain('preferredEditor'); // Hidden + expect(dialogKeys).not.toContain('selectedAuthType'); // Advanced + expect(dialogKeys).not.toContain('coreTools'); // Advanced + expect(dialogKeys).not.toContain('mcpServers'); // Advanced + expect(dialogKeys).not.toContain('telemetry'); // Advanced + }); + + it('should return fewer keys than getAllSettingKeys', () => { + const allKeys = getAllSettingKeys(); + const dialogKeys = getDialogSettingKeys(); + + expect(dialogKeys.length).toBeLessThan(allKeys.length); + expect(dialogKeys.length).toBeGreaterThan(0); + }); + + it('should handle nested settings display correctly', () => { + // Test the specific issue with fileFiltering.respectGitIgnore + const key = 'fileFiltering.respectGitIgnore'; + const initialSettings = {}; + const pendingSettings = {}; + + // Set the nested setting to true + const updatedPendingSettings = setPendingSettingValue( + key, + true, + pendingSettings, + ); + + // Check if the setting exists in pending settings + const existsInPending = settingExistsInScope( + key, + updatedPendingSettings, + ); + expect(existsInPending).toBe(true); + + // Get the value from pending settings + const valueFromPending = getSettingValue( + key, + updatedPendingSettings, + {}, + ); + expect(valueFromPending).toBe(true); + + // Test getDisplayValue should show the pending change + const displayValue = getDisplayValue( + key, + initialSettings, + {}, + new Set(), + updatedPendingSettings, + ); + expect(displayValue).toBe('true*'); // Should show true with * indicating change + + // Test that modified settings also show the * indicator + const modifiedSettings = new Set([key]); + const displayValueWithModified = getDisplayValue( + key, + initialSettings, + {}, + modifiedSettings, + {}, + ); + expect(displayValueWithModified).toBe('true*'); // Should show true* because it's in modified settings and default is true + }); + }); + }); + + describe('Business Logic Utilities', () => { + describe('getSettingValue', () => { + it('should return value from settings when set', () => { + const settings = { showMemoryUsage: true }; + const mergedSettings = { showMemoryUsage: false }; + + const value = getSettingValue( + 'showMemoryUsage', + settings, + mergedSettings, + ); + expect(value).toBe(true); + }); + + it('should return value from merged settings when not set in current scope', () => { + const settings = {}; + const mergedSettings = { showMemoryUsage: true }; + + const value = getSettingValue( + 'showMemoryUsage', + settings, + mergedSettings, + ); + expect(value).toBe(true); + }); + + it('should return default value for invalid setting', () => { + const settings = {}; + const mergedSettings = {}; + + const value = getSettingValue( + 'invalidSetting', + settings, + mergedSettings, + ); + expect(value).toBe(false); // Default fallback + }); + }); + + describe('isSettingModified', () => { + it('should return true when value differs from default', () => { + expect(isSettingModified('showMemoryUsage', true)).toBe(true); + expect( + isSettingModified('fileFiltering.enableRecursiveFileSearch', false), + ).toBe(true); + }); + + it('should return false when value matches default', () => { + expect(isSettingModified('showMemoryUsage', false)).toBe(false); + expect( + isSettingModified('fileFiltering.enableRecursiveFileSearch', true), + ).toBe(false); + }); + }); + + describe('settingExistsInScope', () => { + it('should return true for top-level settings that exist', () => { + const settings = { showMemoryUsage: true }; + expect(settingExistsInScope('showMemoryUsage', settings)).toBe(true); + }); + + it('should return false for top-level settings that do not exist', () => { + const settings = {}; + expect(settingExistsInScope('showMemoryUsage', settings)).toBe(false); + }); + + it('should return true for nested settings that exist', () => { + const settings = { + accessibility: { disableLoadingPhrases: true }, + }; + expect( + settingExistsInScope('accessibility.disableLoadingPhrases', settings), + ).toBe(true); + }); + + it('should return false for nested settings that do not exist', () => { + const settings = {}; + expect( + settingExistsInScope('accessibility.disableLoadingPhrases', settings), + ).toBe(false); + }); + + it('should return false when parent exists but child does not', () => { + const settings = { accessibility: {} }; + expect( + settingExistsInScope('accessibility.disableLoadingPhrases', settings), + ).toBe(false); + }); + }); + + describe('setPendingSettingValue', () => { + it('should set top-level setting value', () => { + const pendingSettings = {}; + const result = setPendingSettingValue( + 'showMemoryUsage', + true, + pendingSettings, + ); + + expect(result.showMemoryUsage).toBe(true); + }); + + it('should set nested setting value', () => { + const pendingSettings = {}; + const result = setPendingSettingValue( + 'accessibility.disableLoadingPhrases', + true, + pendingSettings, + ); + + expect(result.accessibility?.disableLoadingPhrases).toBe(true); + }); + + it('should preserve existing nested settings', () => { + const pendingSettings = { + accessibility: { disableLoadingPhrases: false }, + }; + const result = setPendingSettingValue( + 'accessibility.disableLoadingPhrases', + true, + pendingSettings, + ); + + expect(result.accessibility?.disableLoadingPhrases).toBe(true); + }); + + it('should not mutate original settings', () => { + const pendingSettings = {}; + setPendingSettingValue('showMemoryUsage', true, pendingSettings); + + expect(pendingSettings).toEqual({}); + }); + }); + + describe('hasRestartRequiredSettings', () => { + it('should return true when modified settings require restart', () => { + const modifiedSettings = new Set<string>([ + 'autoConfigureMaxOldSpaceSize', + 'showMemoryUsage', + ]); + expect(hasRestartRequiredSettings(modifiedSettings)).toBe(true); + }); + + it('should return false when no modified settings require restart', () => { + const modifiedSettings = new Set<string>([ + 'showMemoryUsage', + 'hideTips', + ]); + expect(hasRestartRequiredSettings(modifiedSettings)).toBe(false); + }); + + it('should return false for empty set', () => { + const modifiedSettings = new Set<string>(); + expect(hasRestartRequiredSettings(modifiedSettings)).toBe(false); + }); + }); + + describe('getRestartRequiredFromModified', () => { + it('should return only settings that require restart', () => { + const modifiedSettings = new Set<string>([ + 'autoConfigureMaxOldSpaceSize', + 'showMemoryUsage', + 'checkpointing.enabled', + ]); + const result = getRestartRequiredFromModified(modifiedSettings); + + expect(result).toContain('autoConfigureMaxOldSpaceSize'); + expect(result).toContain('checkpointing.enabled'); + expect(result).not.toContain('showMemoryUsage'); + }); + + it('should return empty array when no settings require restart', () => { + const modifiedSettings = new Set<string>([ + 'showMemoryUsage', + 'hideTips', + ]); + const result = getRestartRequiredFromModified(modifiedSettings); + + expect(result).toEqual([]); + }); + }); + + describe('getDisplayValue', () => { + it('should show value without * when setting matches default', () => { + const settings = { showMemoryUsage: false }; // false matches default, so no * + const mergedSettings = { showMemoryUsage: false }; + const modifiedSettings = new Set<string>(); + + const result = getDisplayValue( + 'showMemoryUsage', + settings, + mergedSettings, + modifiedSettings, + ); + expect(result).toBe('false'); // matches default, no * + }); + + it('should show default value when setting is not in scope', () => { + const settings = {}; // no setting in scope + const mergedSettings = { showMemoryUsage: false }; + const modifiedSettings = new Set<string>(); + + const result = getDisplayValue( + 'showMemoryUsage', + settings, + mergedSettings, + modifiedSettings, + ); + expect(result).toBe('false'); // shows default value + }); + + it('should show value with * when changed from default', () => { + const settings = { showMemoryUsage: true }; // true is different from default (false) + const mergedSettings = { showMemoryUsage: true }; + const modifiedSettings = new Set<string>(); + + const result = getDisplayValue( + 'showMemoryUsage', + settings, + mergedSettings, + modifiedSettings, + ); + expect(result).toBe('true*'); + }); + + it('should show default value without * when setting does not exist in scope', () => { + const settings = {}; // setting doesn't exist in scope, show default + const mergedSettings = { showMemoryUsage: false }; + const modifiedSettings = new Set<string>(); + + const result = getDisplayValue( + 'showMemoryUsage', + settings, + mergedSettings, + modifiedSettings, + ); + expect(result).toBe('false'); // default value (false) without * + }); + + it('should show value with * when user changes from default', () => { + const settings = {}; // setting doesn't exist in scope originally + const mergedSettings = { showMemoryUsage: false }; + const modifiedSettings = new Set<string>(['showMemoryUsage']); + const pendingSettings = { showMemoryUsage: true }; // user changed to true + + const result = getDisplayValue( + 'showMemoryUsage', + settings, + mergedSettings, + modifiedSettings, + pendingSettings, + ); + expect(result).toBe('true*'); // changed from default (false) to true + }); + }); + + describe('isDefaultValue', () => { + it('should return true when setting does not exist in scope', () => { + const settings = {}; // setting doesn't exist + + const result = isDefaultValue('showMemoryUsage', settings); + expect(result).toBe(true); + }); + + it('should return false when setting exists in scope', () => { + const settings = { showMemoryUsage: true }; // setting exists + + const result = isDefaultValue('showMemoryUsage', settings); + expect(result).toBe(false); + }); + + it('should return true when nested setting does not exist in scope', () => { + const settings = {}; // nested setting doesn't exist + + const result = isDefaultValue( + 'accessibility.disableLoadingPhrases', + settings, + ); + expect(result).toBe(true); + }); + + it('should return false when nested setting exists in scope', () => { + const settings = { accessibility: { disableLoadingPhrases: true } }; // nested setting exists + + const result = isDefaultValue( + 'accessibility.disableLoadingPhrases', + settings, + ); + expect(result).toBe(false); + }); + }); + + describe('isValueInherited', () => { + it('should return false for top-level settings that exist in scope', () => { + const settings = { showMemoryUsage: true }; + const mergedSettings = { showMemoryUsage: true }; + + const result = isValueInherited( + 'showMemoryUsage', + settings, + mergedSettings, + ); + expect(result).toBe(false); + }); + + it('should return true for top-level settings that do not exist in scope', () => { + const settings = {}; + const mergedSettings = { showMemoryUsage: true }; + + const result = isValueInherited( + 'showMemoryUsage', + settings, + mergedSettings, + ); + expect(result).toBe(true); + }); + + it('should return false for nested settings that exist in scope', () => { + const settings = { + accessibility: { disableLoadingPhrases: true }, + }; + const mergedSettings = { + accessibility: { disableLoadingPhrases: true }, + }; + + const result = isValueInherited( + 'accessibility.disableLoadingPhrases', + settings, + mergedSettings, + ); + expect(result).toBe(false); + }); + + it('should return true for nested settings that do not exist in scope', () => { + const settings = {}; + const mergedSettings = { + accessibility: { disableLoadingPhrases: true }, + }; + + const result = isValueInherited( + 'accessibility.disableLoadingPhrases', + settings, + mergedSettings, + ); + expect(result).toBe(true); + }); + }); + + describe('getEffectiveDisplayValue', () => { + it('should return value from settings when available', () => { + const settings = { showMemoryUsage: true }; + const mergedSettings = { showMemoryUsage: false }; + + const result = getEffectiveDisplayValue( + 'showMemoryUsage', + settings, + mergedSettings, + ); + expect(result).toBe(true); + }); + + it('should return value from merged settings when not in scope', () => { + const settings = {}; + const mergedSettings = { showMemoryUsage: true }; + + const result = getEffectiveDisplayValue( + 'showMemoryUsage', + settings, + mergedSettings, + ); + expect(result).toBe(true); + }); + + it('should return default value for undefined values', () => { + const settings = {}; + const mergedSettings = {}; + + const result = getEffectiveDisplayValue( + 'showMemoryUsage', + settings, + mergedSettings, + ); + expect(result).toBe(false); // Default value + }); + }); + }); +}); diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts new file mode 100644 index 00000000..f4363400 --- /dev/null +++ b/packages/cli/src/utils/settingsUtils.ts @@ -0,0 +1,473 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Settings, SettingScope, LoadedSettings } from '../config/settings.js'; +import { + SETTINGS_SCHEMA, + SettingDefinition, + SettingsSchema, +} from '../config/settingsSchema.js'; + +// The schema is now nested, but many parts of the UI and logic work better +// with a flattened structure and dot-notation keys. This section flattens the +// schema into a map for easier lookups. + +function flattenSchema( + schema: SettingsSchema, + prefix = '', +): Record<string, SettingDefinition & { key: string }> { + let result: Record<string, SettingDefinition & { key: string }> = {}; + for (const key in schema) { + const newKey = prefix ? `${prefix}.${key}` : key; + const definition = schema[key]; + result[newKey] = { ...definition, key: newKey }; + if (definition.properties) { + result = { ...result, ...flattenSchema(definition.properties, newKey) }; + } + } + return result; +} + +const FLATTENED_SCHEMA = flattenSchema(SETTINGS_SCHEMA); + +/** + * Get all settings grouped by category + */ +export function getSettingsByCategory(): Record< + string, + Array<SettingDefinition & { key: string }> +> { + const categories: Record< + string, + Array<SettingDefinition & { key: string }> + > = {}; + + Object.values(FLATTENED_SCHEMA).forEach((definition) => { + const category = definition.category; + if (!categories[category]) { + categories[category] = []; + } + categories[category].push(definition); + }); + + return categories; +} + +/** + * Get a setting definition by key + */ +export function getSettingDefinition( + key: string, +): (SettingDefinition & { key: string }) | undefined { + return FLATTENED_SCHEMA[key]; +} + +/** + * Check if a setting requires restart + */ +export function requiresRestart(key: string): boolean { + return FLATTENED_SCHEMA[key]?.requiresRestart ?? false; +} + +/** + * Get the default value for a setting + */ +export function getDefaultValue(key: string): SettingDefinition['default'] { + return FLATTENED_SCHEMA[key]?.default; +} + +/** + * Get all setting keys that require restart + */ +export function getRestartRequiredSettings(): string[] { + return Object.values(FLATTENED_SCHEMA) + .filter((definition) => definition.requiresRestart) + .map((definition) => definition.key); +} + +/** + * Recursively gets a value from a nested object using a key path array. + */ +function getNestedValue(obj: Record<string, unknown>, path: string[]): unknown { + const [first, ...rest] = path; + if (!first || !(first in obj)) { + return undefined; + } + const value = obj[first]; + if (rest.length === 0) { + return value; + } + if (value && typeof value === 'object' && value !== null) { + return getNestedValue(value as Record<string, unknown>, rest); + } + return undefined; +} + +/** + * Get the effective value for a setting, considering inheritance from higher scopes + * Always returns a value (never undefined) - falls back to default if not set anywhere + */ +export function getEffectiveValue( + key: string, + settings: Settings, + mergedSettings: Settings, +): SettingDefinition['default'] { + const definition = getSettingDefinition(key); + if (!definition) { + return undefined; + } + + const path = key.split('.'); + + // Check the current scope's settings first + let value = getNestedValue(settings as Record<string, unknown>, path); + if (value !== undefined) { + return value as SettingDefinition['default']; + } + + // Check the merged settings for an inherited value + value = getNestedValue(mergedSettings as Record<string, unknown>, path); + if (value !== undefined) { + return value as SettingDefinition['default']; + } + + // Return default value if no value is set anywhere + return definition.default; +} + +/** + * Get all setting keys from the schema + */ +export function getAllSettingKeys(): string[] { + return Object.keys(FLATTENED_SCHEMA); +} + +/** + * Get settings by type + */ +export function getSettingsByType( + type: SettingDefinition['type'], +): Array<SettingDefinition & { key: string }> { + return Object.values(FLATTENED_SCHEMA).filter( + (definition) => definition.type === type, + ); +} + +/** + * Get settings that require restart + */ +export function getSettingsRequiringRestart(): Array< + SettingDefinition & { + key: string; + } +> { + return Object.values(FLATTENED_SCHEMA).filter( + (definition) => definition.requiresRestart, + ); +} + +/** + * Validate if a setting key exists in the schema + */ +export function isValidSettingKey(key: string): boolean { + return key in FLATTENED_SCHEMA; +} + +/** + * Get the category for a setting + */ +export function getSettingCategory(key: string): string | undefined { + return FLATTENED_SCHEMA[key]?.category; +} + +/** + * Check if a setting should be shown in the settings dialog + */ +export function shouldShowInDialog(key: string): boolean { + return FLATTENED_SCHEMA[key]?.showInDialog ?? true; // Default to true for backward compatibility +} + +/** + * Get all settings that should be shown in the dialog, grouped by category + */ +export function getDialogSettingsByCategory(): Record< + string, + Array<SettingDefinition & { key: string }> +> { + const categories: Record< + string, + Array<SettingDefinition & { key: string }> + > = {}; + + Object.values(FLATTENED_SCHEMA) + .filter((definition) => definition.showInDialog !== false) + .forEach((definition) => { + const category = definition.category; + if (!categories[category]) { + categories[category] = []; + } + categories[category].push(definition); + }); + + return categories; +} + +/** + * Get settings by type that should be shown in the dialog + */ +export function getDialogSettingsByType( + type: SettingDefinition['type'], +): Array<SettingDefinition & { key: string }> { + return Object.values(FLATTENED_SCHEMA).filter( + (definition) => + definition.type === type && definition.showInDialog !== false, + ); +} + +/** + * Get all setting keys that should be shown in the dialog + */ +export function getDialogSettingKeys(): string[] { + return Object.values(FLATTENED_SCHEMA) + .filter((definition) => definition.showInDialog !== false) + .map((definition) => definition.key); +} + +// ============================================================================ +// BUSINESS LOGIC UTILITIES (Higher-level utilities for setting operations) +// ============================================================================ + +/** + * Get the current value for a setting in a specific scope + * Always returns a value (never undefined) - falls back to default if not set anywhere + */ +export function getSettingValue( + key: string, + settings: Settings, + mergedSettings: Settings, +): boolean { + const definition = getSettingDefinition(key); + if (!definition) { + return false; // Default fallback for invalid settings + } + + const value = getEffectiveValue(key, settings, mergedSettings); + // Ensure we return a boolean value, converting from the more general type + if (typeof value === 'boolean') { + return value; + } + // Fall back to default value, ensuring it's a boolean + const defaultValue = definition.default; + if (typeof defaultValue === 'boolean') { + return defaultValue; + } + return false; // Final fallback +} + +/** + * Check if a setting value is modified from its default + */ +export function isSettingModified(key: string, value: boolean): boolean { + const defaultValue = getDefaultValue(key); + // Handle type comparison properly + if (typeof defaultValue === 'boolean') { + return value !== defaultValue; + } + // If default is not a boolean, consider it modified if value is true + return value === true; +} + +/** + * Check if a setting exists in the original settings file for a scope + */ +export function settingExistsInScope( + key: string, + scopeSettings: Settings, +): boolean { + const path = key.split('.'); + const value = getNestedValue(scopeSettings as Record<string, unknown>, path); + return value !== undefined; +} + +/** + * Recursively sets a value in a nested object using a key path array. + */ +function setNestedValue( + obj: Record<string, unknown>, + path: string[], + value: unknown, +): Record<string, unknown> { + const [first, ...rest] = path; + if (!first) { + return obj; + } + + if (rest.length === 0) { + obj[first] = value; + return obj; + } + + if (!obj[first] || typeof obj[first] !== 'object') { + obj[first] = {}; + } + + setNestedValue(obj[first] as Record<string, unknown>, rest, value); + return obj; +} + +/** + * Set a setting value in the pending settings + */ +export function setPendingSettingValue( + key: string, + value: boolean, + pendingSettings: Settings, +): Settings { + const path = key.split('.'); + const newSettings = JSON.parse(JSON.stringify(pendingSettings)); + setNestedValue(newSettings, path, value); + return newSettings; +} + +/** + * Check if any modified settings require a restart + */ +export function hasRestartRequiredSettings( + modifiedSettings: Set<string>, +): boolean { + return Array.from(modifiedSettings).some((key) => requiresRestart(key)); +} + +/** + * Get the restart required settings from a set of modified settings + */ +export function getRestartRequiredFromModified( + modifiedSettings: Set<string>, +): string[] { + return Array.from(modifiedSettings).filter((key) => requiresRestart(key)); +} + +/** + * Save modified settings to the appropriate scope + */ +export function saveModifiedSettings( + modifiedSettings: Set<string>, + pendingSettings: Settings, + loadedSettings: LoadedSettings, + scope: SettingScope, +): void { + modifiedSettings.forEach((settingKey) => { + const path = settingKey.split('.'); + const value = getNestedValue( + pendingSettings as Record<string, unknown>, + path, + ); + + if (value === undefined) { + return; + } + + const existsInOriginalFile = settingExistsInScope( + settingKey, + loadedSettings.forScope(scope).settings, + ); + + const isDefaultValue = value === getDefaultValue(settingKey); + + if (existsInOriginalFile || !isDefaultValue) { + // This is tricky because setValue only works on top-level keys. + // We need to set the whole parent object. + const [parentKey] = path; + if (parentKey) { + // Ensure value is a boolean for setPendingSettingValue + const booleanValue = typeof value === 'boolean' ? value : false; + const newParentValue = setPendingSettingValue( + settingKey, + booleanValue, + loadedSettings.forScope(scope).settings, + )[parentKey as keyof Settings]; + + loadedSettings.setValue( + scope, + parentKey as keyof Settings, + newParentValue, + ); + } + } + }); +} + +/** + * Get the display value for a setting, showing current scope value with default change indicator + */ +export function getDisplayValue( + key: string, + settings: Settings, + _mergedSettings: Settings, + modifiedSettings: Set<string>, + pendingSettings?: Settings, +): string { + // Prioritize pending changes if user has modified this setting + let value: boolean; + if (pendingSettings && settingExistsInScope(key, pendingSettings)) { + // Show the value from the pending (unsaved) edits when it exists + value = getSettingValue(key, pendingSettings, {}); + } else if (settingExistsInScope(key, settings)) { + // Show the value defined at the current scope if present + value = getSettingValue(key, settings, {}); + } else { + // Fall back to the schema default when the key is unset in this scope + const defaultValue = getDefaultValue(key); + value = typeof defaultValue === 'boolean' ? defaultValue : false; + } + + const valueString = String(value); + + // Check if value is different from default OR if it's in modified settings OR if there are pending changes + const defaultValue = getDefaultValue(key); + const isChangedFromDefault = + typeof defaultValue === 'boolean' ? value !== defaultValue : value === true; + const isInModifiedSettings = modifiedSettings.has(key); + const hasPendingChanges = + pendingSettings && settingExistsInScope(key, pendingSettings); + + // Add * indicator when value differs from default, is in modified settings, or has pending changes + if (isChangedFromDefault || isInModifiedSettings || hasPendingChanges) { + return `${valueString}*`; // * indicates changed from default value + } + + return valueString; +} + +/** + * Check if a setting doesn't exist in current scope (should be greyed out) + */ +export function isDefaultValue(key: string, settings: Settings): boolean { + return !settingExistsInScope(key, settings); +} + +/** + * Check if a setting value is inherited (not set at current scope) + */ +export function isValueInherited( + key: string, + settings: Settings, + _mergedSettings: Settings, +): boolean { + return !settingExistsInScope(key, settings); +} + +/** + * Get the effective value for display, considering inheritance + * Always returns a boolean value (never undefined) + */ +export function getEffectiveDisplayValue( + key: string, + settings: Settings, + mergedSettings: Settings, +): boolean { + return getSettingValue(key, settings, mergedSettings); +} |
