From 1e9a7f3fa01e40ab569932e66ce37f8653bf8c97 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 30 Apr 2026 14:03:48 -0400 Subject: [PATCH] refactor(config): add config-loader-facade with memoization Single import path for all config loading. Re-exports all 26 functions from unified-config-loader and 4 from config-manager. Adds memoization for loadOrCreateUnifiedConfig via getCachedConfig() with automatic cache invalidation on write operations (mutateConfig, updateConfig). Pure structural refactor -- no existing imports modified. --- .../__tests__/config-loader-facade.test.ts | 191 ++++++++++++++++++ src/config/config-loader-facade.ts | 120 +++++++++++ 2 files changed, 311 insertions(+) create mode 100644 src/config/__tests__/config-loader-facade.test.ts create mode 100644 src/config/config-loader-facade.ts diff --git a/src/config/__tests__/config-loader-facade.test.ts b/src/config/__tests__/config-loader-facade.test.ts new file mode 100644 index 00000000..f780bcc8 --- /dev/null +++ b/src/config/__tests__/config-loader-facade.test.ts @@ -0,0 +1,191 @@ +/** + * Config Loader Facade Unit Tests + * + * Tests memoization cache behavior, cache invalidation on write ops, + * and verifies all re-exports are present from underlying modules. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; + +/** + * Helper: create a temp home dir with a minimal valid config.yaml so + * loadOrCreateUnifiedConfig succeeds without touching the real ~/.ccs. + */ +function createTestHome(): string { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-facade-test-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const configPath = path.join(ccsDir, 'config.yaml'); + fs.writeFileSync(configPath, `version: 1\n`, 'utf8'); + return tempHome; +} + +/** + * Helper: get the facade module, bypassing the import cache each time. + * We use dynamic import with a cache-busting query param so beforeEach + * re-imports get a fresh module with a clean cache state. + */ +async function importFacade(): Promise { + return import(`../config-loader-facade?cachebust=${Date.now()}`); +} + +describe('config-loader-facade', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = createTestHome(); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + // Restore env + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + // Clean up temp dir + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + describe('re-exports from unified-config-loader', () => { + it('should export all core loader functions', async () => { + const facade = await importFacade(); + + expect(typeof facade.loadUnifiedConfig).toBe('function'); + expect(typeof facade.loadOrCreateUnifiedConfig).toBe('function'); + expect(typeof facade.saveUnifiedConfig).toBe('function'); + expect(typeof facade.mutateUnifiedConfig).toBe('function'); + expect(typeof facade.updateUnifiedConfig).toBe('function'); + }); + + it('should export all path/format utilities', async () => { + const facade = await importFacade(); + + expect(typeof facade.getConfigYamlPath).toBe('function'); + expect(typeof facade.getConfigJsonPath).toBe('function'); + expect(typeof facade.hasUnifiedConfig).toBe('function'); + expect(typeof facade.hasLegacyConfig).toBe('function'); + expect(typeof facade.getConfigFormat).toBe('function'); + expect(typeof facade.isUnifiedMode).toBe('function'); + }); + + it('should export all profile getters', async () => { + const facade = await importFacade(); + + expect(typeof facade.getDefaultProfile).toBe('function'); + expect(typeof facade.setDefaultProfile).toBe('function'); + }); + + it('should export all section getters', async () => { + const facade = await importFacade(); + + expect(typeof facade.getWebSearchConfig).toBe('function'); + expect(typeof facade.getGlobalEnvConfig).toBe('function'); + expect(typeof facade.getContinuityInheritanceMap).toBe('function'); + expect(typeof facade.getCliproxySafetyConfig).toBe('function'); + expect(typeof facade.getThinkingConfig).toBe('function'); + expect(typeof facade.getOfficialChannelsConfig).toBe('function'); + expect(typeof facade.isDashboardAuthEnabled).toBe('function'); + expect(typeof facade.getDashboardAuthConfig).toBe('function'); + expect(typeof facade.getBrowserConfig).toBe('function'); + expect(typeof facade.getImageAnalysisConfig).toBe('function'); + expect(typeof facade.getLoggingConfig).toBe('function'); + expect(typeof facade.getCursorConfig).toBe('function'); + }); + }); + + describe('re-exports from config-manager', () => { + it('should export loadSettings, loadConfigSafe, readConfig, getCcsDir', async () => { + const facade = await importFacade(); + + expect(typeof facade.loadSettings).toBe('function'); + expect(typeof facade.loadConfigSafe).toBe('function'); + expect(typeof facade.readConfig).toBe('function'); + expect(typeof facade.getCcsDir).toBe('function'); + }); + }); + + describe('memoization', () => { + it('getCachedConfig returns same object on repeated calls', async () => { + const facade = await importFacade(); + + const first = facade.getCachedConfig(); + const second = facade.getCachedConfig(); + + // Same reference (cached, not re-read) + expect(first).toBe(second); + }); + + it('invalidateConfigCache forces re-read on next getCachedConfig', async () => { + const facade = await importFacade(); + + const first = facade.getCachedConfig(); + facade.invalidateConfigCache(); + const second = facade.getCachedConfig(); + + // Different reference after invalidation + expect(first).not.toBe(second); + // But same content + expect(first.version).toBe(second.version); + }); + + it('saveConfig updates cache and does not invalidate', async () => { + const facade = await importFacade(); + + const config = facade.getCachedConfig(); + config.default = 'test-profile'; + facade.saveConfig(config); + + const cached = facade.getCachedConfig(); + // Cache should hold the just-saved config (no re-read) + expect(cached).toBe(config); + expect(cached.default).toBe('test-profile'); + }); + + it('mutateConfig invalidates the cache', async () => { + const facade = await importFacade(); + + const before = facade.getCachedConfig(); + facade.mutateConfig((cfg) => { + cfg.default = 'mutated-profile'; + }); + const after = facade.getCachedConfig(); + + // Different reference (mutator may change it arbitrarily) + expect(before).not.toBe(after); + expect(after.default).toBe('mutated-profile'); + }); + + it('updateConfig invalidates the cache', async () => { + const facade = await importFacade(); + + const before = facade.getCachedConfig(); + facade.updateConfig({ default: 'updated-profile' }); + const after = facade.getCachedConfig(); + + expect(before).not.toBe(after); + expect(after.default).toBe('updated-profile'); + }); + + it('getCachedConfig returns valid config with expected fields', async () => { + const facade = await importFacade(); + + const config = facade.getCachedConfig(); + expect(config).toBeDefined(); + expect(typeof config.version).toBe('number'); + expect(config.accounts).toBeDefined(); + expect(config.profiles).toBeDefined(); + expect(config.cliproxy).toBeDefined(); + }); + }); +}); diff --git a/src/config/config-loader-facade.ts b/src/config/config-loader-facade.ts new file mode 100644 index 00000000..40b526fc --- /dev/null +++ b/src/config/config-loader-facade.ts @@ -0,0 +1,120 @@ +/** + * Config Loader Facade + * + * Single import path for all config loading operations. + * Re-exports everything from unified-config-loader and config-manager, + * and adds memoization for loadOrCreateUnifiedConfig to reduce file I/O. + * + * Usage: + * import { getCachedConfig, saveConfig, mutateConfig } from '../config/config-loader-facade'; + * import { getCcsDir, loadSettings } from '../config/config-loader-facade'; + */ + +// Re-export all functions from unified-config-loader +export { + loadUnifiedConfig, + loadOrCreateUnifiedConfig, + saveUnifiedConfig, + mutateUnifiedConfig, + updateUnifiedConfig, + getConfigYamlPath, + getConfigJsonPath, + hasUnifiedConfig, + hasLegacyConfig, + getConfigFormat, + isUnifiedMode, + getDefaultProfile, + setDefaultProfile, + getWebSearchConfig, + getGlobalEnvConfig, + getContinuityInheritanceMap, + getCliproxySafetyConfig, + getThinkingConfig, + getOfficialChannelsConfig, + isDashboardAuthEnabled, + getDashboardAuthConfig, + getBrowserConfig, + getImageAnalysisConfig, + getLoggingConfig, + getCursorConfig, +} from './unified-config-loader'; + +// Re-export types from unified-config-loader +export type { GeminiWebSearchInfo } from './unified-config-loader'; + +// Re-export selected functions from config-manager +export { loadSettings, loadConfigSafe, readConfig, getCcsDir } from '../utils/config-manager'; + +// Internal imports for memoization wrappers +import type { UnifiedConfig } from './unified-config-types'; +import { + loadOrCreateUnifiedConfig as _loadOrCreateUnifiedConfig, + saveUnifiedConfig as _saveUnifiedConfig, + mutateUnifiedConfig as _mutateUnifiedConfig, + updateUnifiedConfig as _updateUnifiedConfig, +} from './unified-config-loader'; + +// --------------------------------------------------------------------------- +// Memoization cache +// --------------------------------------------------------------------------- + +let _configCache: UnifiedConfig | null = null; + +/** + * Get the unified config with in-memory caching. + * First call reads from disk; subsequent calls return the cached object. + * + * Call invalidateConfigCache() or use mutateConfig()/updateConfig() + * to force a re-read from disk. + */ +export function getCachedConfig(): UnifiedConfig { + if (!_configCache) { + _configCache = _loadOrCreateUnifiedConfig(); + } + return _configCache; +} + +/** + * Clear the memoization cache. + * The next call to getCachedConfig() will re-read from disk. + */ +export function invalidateConfigCache(): void { + _configCache = null; +} + +/** + * Save config to disk and update the cache to the given object. + * Does NOT invalidate — the provided config IS the new cache value. + */ +export function saveConfig(config: UnifiedConfig): void { + _saveUnifiedConfig(config); + _configCache = config; +} + +/** + * Atomically mutate config (read-modify-write with lock) and invalidate cache. + * After mutation, the next getCachedConfig() call will re-read from disk. + */ +export function mutateConfig(mutator: (config: UnifiedConfig) => void): UnifiedConfig { + const result = _mutateUnifiedConfig(mutator); + _configCache = null; + return result; +} + +/** + * Partial-update config and invalidate cache. + * Shorthand for mutateConfig with Object.assign. + */ +export function updateConfig(updates: Partial): UnifiedConfig { + const result = _updateUnifiedConfig(updates); + _configCache = null; + return result; +} + +/** + * Get the current cache state (for diagnostics/testing). + * Returns true if a cached config exists, false otherwise. + */ +export function hasCachedConfig(): boolean { + return _configCache !== null; +}