import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { analyzeCommand } from '../src/core/analyze.ts'; import { loadConfig } from '../src/core/config.ts'; function writeConfig(dir: string, data: object): void { const path = join(dir, '.safety-net.json'); writeFileSync(path, JSON.stringify(data), 'utf-8'); } function runGuard(command: string, cwd?: string): string | null { const config = loadConfig(cwd); return analyzeCommand(command, { cwd, config })?.reason ?? null; } function assertBlocked(command: string, reasonContains: string, cwd?: string): void { const result = runGuard(command, cwd); expect(result).not.toBeNull(); expect(result).toContain(reasonContains); } function assertAllowed(command: string, cwd?: string): void { const result = runGuard(command, cwd); expect(result).toBeNull(); } describe('custom rules integration', () => { let tempDir: string; beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'safety-net-custom-rules-')); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); test('custom rule blocks command', () => { writeConfig(tempDir, { version: 1, rules: [ { name: 'block-git-add-all', command: 'git', subcommand: 'add', block_args: ['-A', '--all', '.'], reason: 'Use specific files.', }, ], }); assertBlocked('git add -A', '[block-git-add-all] Use specific files.', tempDir); }); test('custom rule blocks with dot', () => { writeConfig(tempDir, { version: 1, rules: [ { name: 'block-git-add-all', command: 'git', subcommand: 'add', block_args: ['-A', '--all', '.'], reason: 'Use specific files.', }, ], }); assertBlocked('git add .', '[block-git-add-all]', tempDir); }); test('custom rule allows non-matching command', () => { writeConfig(tempDir, { version: 1, rules: [ { name: 'block-git-add-all', command: 'git', subcommand: 'add', block_args: ['-A'], reason: 'Use specific files.', }, ], }); assertAllowed('git add file.txt', tempDir); }); test('builtin rule takes precedence', () => { writeConfig(tempDir, { version: 1, rules: [ { name: 'custom-reset-rule', command: 'git', subcommand: 'reset', block_args: ['--soft'], reason: 'Custom reason.', }, ], }); // Built-in rule blocks git reset --hard, not custom rule assertBlocked('git reset --hard', 'git reset --hard destroys', tempDir); }); test('multiple custom rules - any match triggers block', () => { writeConfig(tempDir, { version: 1, rules: [ { name: 'block-git-add-all', command: 'git', subcommand: 'add', block_args: ['-A'], reason: 'No blanket add.', }, { name: 'block-npm-global', command: 'npm', subcommand: 'install', block_args: ['-g'], reason: 'No global installs.', }, ], }); assertBlocked('git add -A', '[block-git-add-all]', tempDir); assertBlocked('npm install -g pkg', '[block-npm-global]', tempDir); }); test('rule without subcommand matches any invocation', () => { writeConfig(tempDir, { version: 1, rules: [ { name: 'block-npm-global', command: 'npm', block_args: ['-g', '--global'], reason: 'No global.', }, ], }); assertBlocked('npm install -g pkg', '[block-npm-global]', tempDir); assertBlocked('npm uninstall -g pkg', '[block-npm-global]', tempDir); }); test('no config uses builtin only', () => { // tempDir has no config file assertBlocked('git reset --hard', 'git reset --hard destroys', tempDir); assertAllowed('git add -A', tempDir); }); test('empty rules list uses builtin only', () => { writeConfig(tempDir, { version: 1, rules: [] }); assertBlocked('git reset --hard', 'git reset --hard destroys', tempDir); assertAllowed('git add -A', tempDir); }); test('invalid config uses builtin only', () => { const path = join(tempDir, '.safety-net.json'); writeFileSync(path, '{"version": 2}', 'utf-8'); assertBlocked('git reset --hard', 'git reset --hard destroys', tempDir); assertAllowed('echo hello', tempDir); }); test('custom rules not applied to embedded commands', () => { writeConfig(tempDir, { version: 1, rules: [ { name: 'block-git-add-all', command: 'git', subcommand: 'add', block_args: ['-A'], reason: 'No blanket add.', }, ], }); // Direct command is blocked assertBlocked('git add -A', '[block-git-add-all]', tempDir); // Embedded in bash -c is NOT blocked by custom rule (per spec) assertAllowed("bash -c 'git add -A'", tempDir); }); test('custom rules apply to xargs', () => { writeConfig(tempDir, { version: 1, rules: [ { name: 'block-xargs-grep', command: 'xargs', block_args: ['grep'], reason: 'Use ripgrep instead.', }, ], }); assertBlocked('find . | xargs grep pattern', '[block-xargs-grep]', tempDir); }); test('custom rules apply to parallel', () => { writeConfig(tempDir, { version: 1, rules: [ { name: 'block-parallel-curl', command: 'parallel', block_args: ['curl'], reason: 'No parallel curl.', }, ], }); assertBlocked('parallel curl ::: url1 url2', '[block-parallel-curl]', tempDir); }); test('attached option value not false positive', () => { writeConfig(tempDir, { version: 1, rules: [ { name: 'block-p-flag', command: 'git', block_args: ['-p'], reason: 'No -p allowed.', }, ], }); // -C/path/to/project contains 'p' in the path, but should NOT match -p assertAllowed('git -C/path/to/project status', tempDir); }); });