diff --git a/src/commands/init.ts b/src/commands/init.ts index ec30e840..1cb8a599 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -2,7 +2,10 @@ import type { Command } from 'commander'; import type { IOutputFormatter } from '../interfaces/output-formatter.js'; import { mkdir, readFile, writeFile, access } from 'node:fs/promises'; import { join } from 'node:path'; +import { parse as parseToml } from 'smol-toml'; import { CONFIG_DIR, CONFIG_FILENAME } from '../util/constants.js'; +import { DEFAULT_CONFIG, type LoreConfig } from '../types/config.js'; +import { LoreError } from '../util/errors.js'; const DEFAULT_CONFIG_CONTENT = `[protocol] version = "1.0" @@ -33,7 +36,7 @@ update_check = true /** * Register the `lore init` command. * Creates .lore/config.toml with default content. - * If the config file already exists, prints its content and exits. + * If the config file already exists, checks for corruption and missing options. */ export function registerInitCommand( program: Command, @@ -64,6 +67,37 @@ export function registerInitCommand( `Config already exists at ${CONFIG_DIR}/${CONFIG_FILENAME}:`, )); console.log(content); + + let parsed: Record; + try { + parsed = parseToml(content) as Record; + } catch (err) { + const message = `Your configuration file is corrupted and cannot be parsed: ${err instanceof Error ? err.message : String(err)}\n\nPlease fix the TOML syntax or reset your config.\nExample: mv ${CONFIG_DIR}/${CONFIG_FILENAME} ${CONFIG_DIR}/${CONFIG_FILENAME}.corrupted && lore init`; + throw new LoreError(message, 1); + } + + const { missing, customized } = findConfigDiff(parsed); + + if (missing.length > 0) { + console.log('\n' + formatter.formatSuccess( + 'Your configuration is missing new options:', + )); + for (const item of missing) { + console.log(` - ${item}`); + } + + if (customized.length === 0) { + console.log('\nNotice: You are using default settings. You can safely reset your config to get the latest options.'); + console.log(`Example: rm ${CONFIG_DIR}/${CONFIG_FILENAME} && lore init`); + } else { + console.log('\nNotice: You have customized the following options:'); + for (const item of customized) { + console.log(` - ${item}`); + } + console.log('\nTo update: Rename your current config, run `lore init` again, and manually merge your changes.'); + console.log(`Example: mv ${CONFIG_DIR}/${CONFIG_FILENAME} ${CONFIG_DIR}/${CONFIG_FILENAME}.bak && lore init`); + } + } return; } @@ -76,3 +110,36 @@ export function registerInitCommand( )); }); } + +function findConfigDiff(parsed: Record): { missing: string[]; customized: string[] } { + const missing: string[] = []; + const customized: string[] = []; + + const configKeys = Object.keys(DEFAULT_CONFIG) as (keyof LoreConfig)[]; + + for (const section of configKeys) { + const defaults = DEFAULT_CONFIG[section] as Record; + const userSection = parsed[section]; + + if (!userSection || typeof userSection !== 'object') { + missing.push(`[${section}] section`); + continue; + } + + const sectionData = userSection as Record; + for (const [key, defaultValue] of Object.entries(defaults)) { + // Convert camelCase from LoreConfig type to snake_case for TOML comparison + const snakeKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); + const userValue = sectionData[snakeKey] ?? sectionData[key]; + + if (userValue === undefined) { + missing.push(`${section}.${snakeKey}`); + } else if (JSON.stringify(userValue) !== JSON.stringify(defaultValue)) { + customized.push(`${section}.${snakeKey}`); + } + } + } + + return { missing, customized }; +} + diff --git a/tests/unit/commands/init.test.ts b/tests/unit/commands/init.test.ts new file mode 100644 index 00000000..f039f2e7 --- /dev/null +++ b/tests/unit/commands/init.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Command } from 'commander'; +import { registerInitCommand } from '../../../src/commands/init.js'; +import type { IOutputFormatter } from '../../../src/interfaces/output-formatter.js'; +import * as fs from 'node:fs/promises'; +import { join } from 'node:path'; +import { CONFIG_DIR, CONFIG_FILENAME } from '../../../src/util/constants.js'; + +vi.mock('node:fs/promises'); + +describe('registerInitCommand', () => { + const formatter: IOutputFormatter = { + formatSuccess: vi.fn((msg) => `SUCCESS: ${msg}`), + formatError: vi.fn((code, messages) => `ERROR: ${messages[0].message} (code ${code})`), + formatQueryResult: vi.fn(), + formatValidationResult: vi.fn(), + formatStalenessResult: vi.fn(), + formatTraceResult: vi.fn(), + formatDoctorResult: vi.fn(), + }; + + let consoleLogSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + // Default: file does not exist + vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + }); + + it('creates a new config file if one does not exist', async () => { + const program = new Command(); + registerInitCommand(program, { getFormatter: () => formatter }); + + await program.parseAsync(['node', 'lore', 'init']); + + expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining(CONFIG_DIR), { recursive: true }); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining(join(CONFIG_DIR, CONFIG_FILENAME)), + expect.stringContaining('[protocol]'), + 'utf-8' + ); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Created .lore/config.toml')); + }); + + it('reports an existing valid config with no gaps', async () => { + const fullConfig = `[protocol] +version = "1.0" +[trailers] +required = [] +custom = [] +[validation] +strict = false +max_message_lines = 50 +intent_max_length = 72 +[stale] +older_than = "6m" +drift_threshold = 20 +[output] +default_format = "text" +[follow] +max_depth = 3 +[cli] +update_check = true +`; + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(fullConfig); + + const program = new Command(); + registerInitCommand(program, { getFormatter: () => formatter }); + + await program.parseAsync(['node', 'lore', 'init']); + + expect(fs.writeFile).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Config already exists')); + // Should NOT have warning about missing options + expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining('WARNING: Your configuration is missing new options:')); + }); + + it('reports missing options and suggests safe reset if no customizations exist', async () => { + const minimalConfig = `[protocol] + version = "1.0" + `; + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(minimalConfig); + + const program = new Command(); + registerInitCommand(program, { getFormatter: () => formatter }); + + await program.parseAsync(['node', 'lore', 'init']); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('SUCCESS: Your configuration is missing new options:')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('- [trailers] section')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Notice: You are using default settings. You can safely reset your config')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('rm .lore/config.toml && lore init')); + }); + + it('reports missing options and suggests manual merge if customizations exist', async () => { + const customizedConfig = `[protocol] + version = "1.0" + [validation] + strict = true + `; + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(customizedConfig); + + const program = new Command(); + registerInitCommand(program, { getFormatter: () => formatter }); + + await program.parseAsync(['node', 'lore', 'init']); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('SUCCESS: Your configuration is missing new options:')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('- [trailers] section')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Notice: You have customized the following options:')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('- validation.strict')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('To update: Rename your current config')); + }); + + it('reports individual missing keys when section exists', async () => { + + const configWithMissingKey = `[protocol] + version = "1.0" + [trailers] + required = [] + custom = [] + [validation] + strict = false + max_message_lines = 50 + intent_max_length = 72 + [stale] + older_than = "6m" + drift_threshold = 20 + [output] + default_format = "text" + [follow] + # max_depth is missing + [cli] + update_check = true + `; + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(configWithMissingKey); + + const program = new Command(); + registerInitCommand(program, { getFormatter: () => formatter }); + + await program.parseAsync(['node', 'lore', 'init']); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('SUCCESS: Your configuration is missing new options:')); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('- follow.max_depth')); + }); + + it('reports corruption if the existing config is invalid TOML', async () => { + const corruptedConfig = `[protocol] + version = "1.0 + invalid = [missing bracket + `; + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue(corruptedConfig); + + const program = new Command(); + program.exitOverride(); + registerInitCommand(program, { getFormatter: () => formatter }); + + await expect(program.parseAsync(['node', 'lore', 'init'])) + .rejects.toThrow(/Your configuration file is corrupted/); + }); + +});