Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -64,6 +67,37 @@ export function registerInitCommand(
`Config already exists at ${CONFIG_DIR}/${CONFIG_FILENAME}:`,
));
console.log(content);

let parsed: Record<string, unknown>;
try {
parsed = parseToml(content) as Record<string, unknown>;
} 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;
}

Expand All @@ -76,3 +110,36 @@ export function registerInitCommand(
));
});
}

function findConfigDiff(parsed: Record<string, unknown>): { 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<string, unknown>;
const userSection = parsed[section];

if (!userSection || typeof userSection !== 'object') {
missing.push(`[${section}] section`);
continue;
}

const sectionData = userSection as Record<string, unknown>;
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 };
}

172 changes: 172 additions & 0 deletions tests/unit/commands/init.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});

});
Loading