import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve, sep } from 'node:path'; import { getProjectConfigPath, getUserConfigPath, type LoadConfigOptions, loadConfig, validateConfig, validateConfigFile, } from '../src/core/config.ts'; describe('config validation', () => { let tempDir: string; let userConfigDir: string; let loadOptions: LoadConfigOptions; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'safety-net-config-')); userConfigDir = join(tempDir, '.cc-safety-net'); loadOptions = { userConfigDir }; }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); function writeProjectConfig(data: unknown): void { const path = join(tempDir, '.safety-net.json'); if (typeof data === 'string') { writeFileSync(path, data, 'utf-8'); } else { writeFileSync(path, JSON.stringify(data), 'utf-8'); } } function loadFromProject(data: unknown) { writeProjectConfig(data); return loadConfig(tempDir, loadOptions); } describe('valid configs', () => { test('minimal valid config', () => { const config = loadFromProject({ version: 1 }); expect(config.version).toBe(1); expect(config.rules).toEqual([]); }); test('valid config with rules', () => { const config = loadFromProject({ version: 1, rules: [ { name: 'block-git-add-all', command: 'git', subcommand: 'add', block_args: ['-A', '--all'], reason: 'Use specific files.', }, ], }); expect(config.rules.length).toBe(1); const rule = config.rules[0]; expect(rule?.name).toBe('block-git-add-all'); expect(rule?.command).toBe('git'); expect(rule?.subcommand).toBe('add'); expect(rule?.block_args).toEqual(['-A', '--all']); expect(rule?.reason).toBe('Use specific files.'); }); test('valid config without subcommand', () => { const config = loadFromProject({ version: 1, rules: [ { name: 'block-npm-global', command: 'npm', block_args: ['-g'], reason: 'No global installs.', }, ], }); expect(config.rules.length).toBe(1); expect(config.rules[0]?.subcommand).toBeUndefined(); }); test('valid rule name patterns', () => { const validNames = [ 'a', 'A', 'rule1', 'my-rule', 'my_rule', 'MyRule123', 'a'.repeat(64), // max length ]; for (const name of validNames) { const config = loadFromProject({ version: 1, rules: [ { name, command: 'git', block_args: ['-A'], reason: 'test', }, ], }); expect(config.rules[0]?.name).toBe(name); } }); test('unknown fields ignored', () => { const config = loadFromProject({ version: 1, future_field: 'ignored', rules: [ { name: 'test', command: 'git', block_args: ['-A'], reason: 'test', unknown_rule_field: true, }, ], }); expect(config.rules.length).toBe(1); }); }); describe('invalid configs (all return default config silently)', () => { test('validateConfig rejects non-object', () => { const result = validateConfig(null); expect(result.errors).toEqual(['Config must be an object']); }); test('invalid JSON syntax', () => { const config = loadFromProject('{ invalid json }'); expect(config.rules).toEqual([]); }); test('missing version', () => { const config = loadFromProject({ rules: [] }); expect(config.rules).toEqual([]); }); test('wrong version number', () => { const config = loadFromProject({ version: 2 }); expect(config.rules).toEqual([]); }); test('version not integer', () => { const config = loadFromProject({ version: '1' }); expect(config.rules).toEqual([]); }); test('missing required rule fields', () => { // Missing name let config = loadFromProject({ version: 1, rules: [{ command: 'git', block_args: ['-A'], reason: 'x' }], }); expect(config.rules).toEqual([]); // Missing command config = loadFromProject({ version: 1, rules: [{ name: 'test', block_args: ['-A'], reason: 'x' }], }); expect(config.rules).toEqual([]); // Missing block_args config = loadFromProject({ version: 1, rules: [{ name: 'test', command: 'git', reason: 'x' }], }); expect(config.rules).toEqual([]); // Missing reason config = loadFromProject({ version: 1, rules: [{ name: 'test', command: 'git', block_args: ['-A'] }], }); expect(config.rules).toEqual([]); }); test('invalid name patterns', () => { const invalidNames = [ '1rule', // starts with number '-rule', // starts with hyphen '_rule', // starts with underscore 'rule with space', // contains space 'rule.name', // contains dot 'a'.repeat(65), // too long '', // empty ]; for (const name of invalidNames) { const config = loadFromProject({ version: 1, rules: [ { name, command: 'git', block_args: ['-A'], reason: 'test', }, ], }); expect(config.rules).toEqual([]); } }); test('invalid command patterns', () => { const invalidCommands = [ '/usr/bin/git', // path, not just command 'git add', // contains space '1git', // starts with number '', // empty ]; for (const cmd of invalidCommands) { const config = loadFromProject({ version: 1, rules: [ { name: 'test', command: cmd, block_args: ['-A'], reason: 'test', }, ], }); expect(config.rules).toEqual([]); } }); test('invalid subcommand patterns', () => { const config = loadFromProject({ version: 1, rules: [ { name: 'test', command: 'git', subcommand: 'add files', // space block_args: ['-A'], reason: 'test', }, ], }); expect(config.rules).toEqual([]); }); test('subcommand must be string when provided', () => { const config = loadFromProject({ version: 1, rules: [ { name: 'test', command: 'git', subcommand: 123, block_args: ['-A'], reason: 'test', }, ], }); expect(config.rules).toEqual([]); }); test('duplicate rule names case insensitive', () => { const config = loadFromProject({ version: 1, rules: [ { name: 'MyRule', command: 'git', block_args: ['-A'], reason: 'test', }, { name: 'myrule', command: 'npm', block_args: ['-g'], reason: 'test', }, ], }); expect(config.rules).toEqual([]); }); test('empty block_args', () => { const config = loadFromProject({ version: 1, rules: [ { name: 'test', command: 'git', block_args: [], reason: 'test', }, ], }); expect(config.rules).toEqual([]); }); test('empty string in block_args', () => { const config = loadFromProject({ version: 1, rules: [ { name: 'test', command: 'git', block_args: ['-A', ''], reason: 'test', }, ], }); expect(config.rules).toEqual([]); }); test('non-string in block_args', () => { const config = loadFromProject({ version: 1, rules: [ { name: 'test', command: 'git', block_args: ['-A', 123], reason: 'test', }, ], }); expect(config.rules).toEqual([]); }); test('reason exceeds max length', () => { const config = loadFromProject({ version: 1, rules: [ { name: 'test', command: 'git', block_args: ['-A'], reason: 'x'.repeat(257), }, ], }); expect(config.rules).toEqual([]); }); test('empty reason', () => { const config = loadFromProject({ version: 1, rules: [ { name: 'test', command: 'git', block_args: ['-A'], reason: '', }, ], }); expect(config.rules).toEqual([]); }); test('empty config file', () => { const config = loadFromProject(''); expect(config.rules).toEqual([]); }); test('whitespace only config file', () => { const config = loadFromProject(' \n\t '); expect(config.rules).toEqual([]); }); test('config not object', () => { const config = loadFromProject('[]'); expect(config.rules).toEqual([]); }); test('rules not array', () => { const config = loadFromProject({ version: 1, rules: {} }); expect(config.rules).toEqual([]); }); test('rule not object', () => { const config = loadFromProject({ version: 1, rules: ['not an object'], }); expect(config.rules).toEqual([]); }); }); }); describe('config scope merging', () => { let tempDir: string; let userConfigDir: string; let loadOptions: LoadConfigOptions; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'safety-net-merge-')); userConfigDir = join(tempDir, '.cc-safety-net'); loadOptions = { userConfigDir }; }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); function writeUserConfig(data: object): void { mkdirSync(userConfigDir, { recursive: true }); writeFileSync(join(userConfigDir, 'config.json'), JSON.stringify(data), 'utf-8'); } function writeProjectConfig(data: object): void { writeFileSync(join(tempDir, '.safety-net.json'), JSON.stringify(data), 'utf-8'); } test('no config returns default', () => { const config = loadConfig(tempDir, loadOptions); expect(config.rules).toEqual([]); }); test('user scope only', () => { writeUserConfig({ version: 1, rules: [ { name: 'user-rule', command: 'git', block_args: ['-A'], reason: 'user', }, ], }); const config = loadConfig(tempDir, loadOptions); expect(config.rules.length).toBe(1); expect(config.rules[0]?.name).toBe('user-rule'); }); test('project scope only', () => { writeProjectConfig({ version: 1, rules: [ { name: 'project-rule', command: 'npm', block_args: ['-g'], reason: 'project', }, ], }); const config = loadConfig(tempDir, loadOptions); expect(config.rules.length).toBe(1); expect(config.rules[0]?.name).toBe('project-rule'); }); test('both scopes merged', () => { writeUserConfig({ version: 1, rules: [ { name: 'user-rule', command: 'git', block_args: ['-A'], reason: 'user', }, ], }); writeProjectConfig({ version: 1, rules: [ { name: 'project-rule', command: 'npm', block_args: ['-g'], reason: 'project', }, ], }); const config = loadConfig(tempDir, loadOptions); expect(config.rules.length).toBe(2); const ruleNames = new Set(config.rules.map((r) => r.name)); expect(ruleNames).toEqual(new Set(['user-rule', 'project-rule'])); }); test('project overrides user on duplicate', () => { writeUserConfig({ version: 1, rules: [ { name: 'shared-rule', command: 'git', block_args: ['-A'], reason: 'user version', }, ], }); writeProjectConfig({ version: 1, rules: [ { name: 'shared-rule', command: 'git', block_args: ['--all'], reason: 'project version', }, ], }); const config = loadConfig(tempDir, loadOptions); expect(config.rules.length).toBe(1); expect(config.rules[0]?.reason).toBe('project version'); expect(config.rules[0]?.block_args).toEqual(['--all']); }); test('project overrides case insensitive', () => { writeUserConfig({ version: 1, rules: [ { name: 'MyRule', command: 'git', block_args: ['-A'], reason: 'user', }, ], }); writeProjectConfig({ version: 1, rules: [ { name: 'myrule', command: 'npm', block_args: ['-g'], reason: 'project', }, ], }); const config = loadConfig(tempDir, loadOptions); expect(config.rules.length).toBe(1); expect(config.rules[0]?.name).toBe('myrule'); expect(config.rules[0]?.reason).toBe('project'); }); test('mixed override and merge', () => { writeUserConfig({ version: 1, rules: [ { name: 'shared-rule', command: 'git', block_args: ['-A'], reason: 'user shared', }, { name: 'user-only', command: 'rm', block_args: ['-rf'], reason: 'user only', }, ], }); writeProjectConfig({ version: 1, rules: [ { name: 'shared-rule', command: 'git', block_args: ['--all'], reason: 'project shared', }, { name: 'project-only', command: 'npm', block_args: ['-g'], reason: 'project only', }, ], }); const config = loadConfig(tempDir, loadOptions); expect(config.rules.length).toBe(3); const rulesByName = Object.fromEntries(config.rules.map((r) => [r.name, r])); expect(rulesByName['shared-rule']?.reason).toBe('project shared'); expect(rulesByName['user-only']?.reason).toBe('user only'); expect(rulesByName['project-only']?.reason).toBe('project only'); }); test('invalid user config ignored', () => { mkdirSync(userConfigDir, { recursive: true }); writeFileSync(join(userConfigDir, 'config.json'), '{"version": 2}', 'utf-8'); writeProjectConfig({ version: 1, rules: [ { name: 'project-rule', command: 'npm', block_args: ['-g'], reason: 'project', }, ], }); const config = loadConfig(tempDir, loadOptions); expect(config.rules.length).toBe(1); expect(config.rules[0]?.name).toBe('project-rule'); }); test('invalid project config ignored', () => { writeUserConfig({ version: 1, rules: [ { name: 'user-rule', command: 'git', block_args: ['-A'], reason: 'user', }, ], }); writeFileSync(join(tempDir, '.safety-net.json'), '{"version": 2}', 'utf-8'); const config = loadConfig(tempDir, loadOptions); expect(config.rules.length).toBe(1); expect(config.rules[0]?.name).toBe('user-rule'); }); test('both invalid returns default', () => { mkdirSync(userConfigDir, { recursive: true }); writeFileSync(join(userConfigDir, 'config.json'), '{"version": 2}', 'utf-8'); writeFileSync(join(tempDir, '.safety-net.json'), 'invalid json', 'utf-8'); const config = loadConfig(tempDir, loadOptions); expect(config.rules).toEqual([]); }); test('empty project rules still merges', () => { writeUserConfig({ version: 1, rules: [ { name: 'user-rule', command: 'git', block_args: ['-A'], reason: 'user', }, ], }); writeProjectConfig({ version: 1, rules: [] }); const config = loadConfig(tempDir, loadOptions); expect(config.rules.length).toBe(1); expect(config.rules[0]?.name).toBe('user-rule'); }); }); describe('validate config file', () => { let tempDir: string; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'safety-net-validate-')); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); test('valid file returns empty errors', () => { const path = join(tempDir, 'config.json'); writeFileSync(path, JSON.stringify({ version: 1 }), 'utf-8'); const result = validateConfigFile(path); expect(result.errors).toEqual([]); }); test('nonexistent file returns error', () => { const result = validateConfigFile('/nonexistent/config.json'); expect(result.errors.length).toBe(1); expect(result.errors[0]).toContain('not found'); }); test('invalid file returns errors', () => { const path = join(tempDir, 'config.json'); writeFileSync(path, JSON.stringify({ version: 2 }), 'utf-8'); const result = validateConfigFile(path); expect(result.errors.length).toBe(1); expect(result.errors[0]).toContain('version'); }); test('empty file returns error', () => { const path = join(tempDir, 'config.json'); writeFileSync(path, '', 'utf-8'); const result = validateConfigFile(path); expect(result.errors).toEqual(['Config file is empty']); }); }); describe('config path helpers', () => { test('getUserConfigPath returns the expected suffix', () => { const p = getUserConfigPath(); expect(p).toContain(`${sep}.cc-safety-net${sep}config.json`); }); test('getProjectConfigPath resolves cwd', () => { expect(getProjectConfigPath('/tmp')).toBe(resolve('/tmp', '.safety-net.json')); }); });