diff --git a/README.md b/README.md index 1862ad1..4106ff7 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ The `apply` command looks for `.ruler/` in the current directory tree, reading t | Option | Description | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--project-root ` | Path to your project's root (default: current directory) | -| `--agents ` | Comma-separated list of agent names to target (amp, copilot, claude, codex, cursor, windsurf, cline, aider, firebase, gemini-cli, junie, augmentcode, kilocode) | +| `--agents ` | Comma-separated list of agent names to target (amp, copilot, claude, codex, cursor, windsurf, cline, aider, firebase, gemini-cli, junie, augmentcode, kilocode, opencode, crush, goose) | | `--config ` | Path to a custom `ruler.toml` configuration file | | `--mcp` / `--with-mcp` | Enable applying MCP server configurations (default: true) | | `--no-mcp` | Disable applying MCP server configurations | @@ -160,6 +160,7 @@ The `apply` command looks for `.ruler/` in the current directory tree, reading t | `--no-gitignore` | Disable automatic .gitignore updates | | `--local-only` | Do not look for configuration in `$XDG_CONFIG_HOME` | | `--verbose` / `-v` | Display detailed output during execution | +| `--disable-backup` | Disable creation of `.bak` backup files when applying rules (default: false, configurable via `disable_backup` in `ruler.toml`) | ### Common Examples @@ -199,6 +200,12 @@ ruler apply --verbose ruler apply --no-mcp --no-gitignore ``` +**Apply rules without creating backup files:** + +```bash +ruler apply --disable-backup +``` + ## Usage: The `revert` Command The `revert` command safely undoes all changes made by `ruler apply`, restoring your project to its pre-ruler state. It intelligently restores files from backups (`.bak` files) when available, or removes generated files that didn't exist before. @@ -275,6 +282,10 @@ Defaults to `.ruler/ruler.toml` in the project root. Override with `--config` CL # Uses case-insensitive substring matching default_agents = ["copilot", "claude", "aider"] +# Global backup setting - disable creation of .bak backup files +# (default: false, meaning backups are enabled by default) +disable_backup = false + # --- Global MCP Server Configuration --- [mcp] # Enable/disable MCP propagation globally (default: true) diff --git a/src/agents/AiderAgent.ts b/src/agents/AiderAgent.ts index 006f3af..b857770 100644 --- a/src/agents/AiderAgent.ts +++ b/src/agents/AiderAgent.ts @@ -25,7 +25,7 @@ export class AiderAgent implements IAgent { const mdPath = agentConfig?.outputPathInstructions ?? this.getDefaultOutputPath(projectRoot).instructions; - await backupFile(mdPath); + await backupFile(mdPath, agentConfig?.disableBackup); await writeGeneratedFile(mdPath, concatenatedRules); const cfgPath = @@ -38,7 +38,7 @@ export class AiderAgent implements IAgent { let doc: AiderConfig = {} as AiderConfig; try { await fs.access(cfgPath); - await backupFile(cfgPath); + await backupFile(cfgPath, agentConfig?.disableBackup); const raw = await fs.readFile(cfgPath, 'utf8'); doc = (yaml.load(raw) || {}) as AiderConfig; } catch { diff --git a/src/agents/AugmentCodeAgent.ts b/src/agents/AugmentCodeAgent.ts index 09c41fb..9e61b67 100644 --- a/src/agents/AugmentCodeAgent.ts +++ b/src/agents/AugmentCodeAgent.ts @@ -30,12 +30,12 @@ export class AugmentCodeAgent implements IAgent { ): Promise { const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot); - await backupFile(output); + await backupFile(output, agentConfig?.disableBackup); await writeGeneratedFile(output, concatenatedRules); if (rulerMcpJson) { const settingsPath = getVSCodeSettingsPath(projectRoot); - await backupFile(settingsPath); + await backupFile(settingsPath, agentConfig?.disableBackup); const existingSettings = await readVSCodeSettings(settingsPath); const augmentServers = transformRulerToAugmentMcp(rulerMcpJson); diff --git a/src/agents/ClaudeAgent.ts b/src/agents/ClaudeAgent.ts index eeac6ce..903501b 100644 --- a/src/agents/ClaudeAgent.ts +++ b/src/agents/ClaudeAgent.ts @@ -22,7 +22,7 @@ export class ClaudeAgent implements IAgent { ): Promise { const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot); - await backupFile(output); + await backupFile(output, agentConfig?.disableBackup); await writeGeneratedFile(output, concatenatedRules); } getDefaultOutputPath(projectRoot: string): string { diff --git a/src/agents/ClineAgent.ts b/src/agents/ClineAgent.ts index 912f928..1782d9f 100644 --- a/src/agents/ClineAgent.ts +++ b/src/agents/ClineAgent.ts @@ -22,7 +22,7 @@ export class ClineAgent implements IAgent { ): Promise { const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot); - await backupFile(output); + await backupFile(output, agentConfig?.disableBackup); await writeGeneratedFile(output, concatenatedRules); } getDefaultOutputPath(projectRoot: string): string { diff --git a/src/agents/CodexCliAgent.ts b/src/agents/CodexCliAgent.ts index 153378e..d67e00e 100644 --- a/src/agents/CodexCliAgent.ts +++ b/src/agents/CodexCliAgent.ts @@ -34,7 +34,7 @@ export class CodexCliAgent implements IAgent { defaults.instructions; // Write the instructions file - await backupFile(instructionsPath); + await backupFile(instructionsPath, agentConfig?.disableBackup); await writeGeneratedFile(instructionsPath, concatenatedRules); // Handle MCP configuration if enabled diff --git a/src/agents/CopilotAgent.ts b/src/agents/CopilotAgent.ts index a05f968..8c878bb 100644 --- a/src/agents/CopilotAgent.ts +++ b/src/agents/CopilotAgent.ts @@ -27,7 +27,7 @@ export class CopilotAgent implements IAgent { const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot); await ensureDirExists(path.dirname(output)); - await backupFile(output); + await backupFile(output, agentConfig?.disableBackup); await writeGeneratedFile(output, concatenatedRules); } getDefaultOutputPath(projectRoot: string): string { diff --git a/src/agents/CursorAgent.ts b/src/agents/CursorAgent.ts index e3195b1..9af3f49 100644 --- a/src/agents/CursorAgent.ts +++ b/src/agents/CursorAgent.ts @@ -33,7 +33,7 @@ export class CursorAgent implements IAgent { const content = `${frontMatter}${concatenatedRules.trimStart()}`; await ensureDirExists(path.dirname(output)); - await backupFile(output); + await backupFile(output, agentConfig?.disableBackup); await writeGeneratedFile(output, content); } getDefaultOutputPath(projectRoot: string): string { diff --git a/src/agents/FirebaseAgent.ts b/src/agents/FirebaseAgent.ts index a04d883..bfadb28 100644 --- a/src/agents/FirebaseAgent.ts +++ b/src/agents/FirebaseAgent.ts @@ -22,7 +22,7 @@ export class FirebaseAgent implements IAgent { ): Promise { const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot); - await backupFile(output); + await backupFile(output, agentConfig?.disableBackup); await writeGeneratedFile(output, concatenatedRules); } diff --git a/src/agents/GooseAgent.ts b/src/agents/GooseAgent.ts index e2bed17..8a44cdf 100644 --- a/src/agents/GooseAgent.ts +++ b/src/agents/GooseAgent.ts @@ -27,7 +27,7 @@ export class GooseAgent implements IAgent { this.getDefaultOutputPath(projectRoot); // Write rules to .goosehints - await backupFile(hintsPath); + await backupFile(hintsPath, agentConfig?.disableBackup); await writeGeneratedFile(hintsPath, concatenatedRules); } diff --git a/src/agents/IAgent.ts b/src/agents/IAgent.ts index 4b3e399..ec1e137 100644 --- a/src/agents/IAgent.ts +++ b/src/agents/IAgent.ts @@ -16,6 +16,8 @@ export interface IAgentConfig { outputPathConfig?: string; /** MCP propagation config for this agent. */ mcp?: McpConfig; + /** Disable backup file creation for this agent */ + disableBackup?: boolean; } export interface IAgent { diff --git a/src/agents/JunieAgent.ts b/src/agents/JunieAgent.ts index 2dd5ccc..5d43fac 100644 --- a/src/agents/JunieAgent.ts +++ b/src/agents/JunieAgent.ts @@ -27,7 +27,7 @@ export class JunieAgent implements IAgent { const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot); await ensureDirExists(path.dirname(output)); - await backupFile(output); + await backupFile(output, agentConfig?.disableBackup); await writeGeneratedFile(output, concatenatedRules); } getDefaultOutputPath(projectRoot: string): string { diff --git a/src/agents/KiloCodeAgent.ts b/src/agents/KiloCodeAgent.ts index 2c8c7f5..920110f 100644 --- a/src/agents/KiloCodeAgent.ts +++ b/src/agents/KiloCodeAgent.ts @@ -28,7 +28,7 @@ export class KiloCodeAgent implements IAgent { const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot); await ensureDirExists(path.dirname(output)); - await backupFile(output); + await backupFile(output, agentConfig?.disableBackup); await writeGeneratedFile(output, concatenatedRules); } diff --git a/src/agents/OpenCodeAgent.ts b/src/agents/OpenCodeAgent.ts index 62259cb..29d0e6a 100644 --- a/src/agents/OpenCodeAgent.ts +++ b/src/agents/OpenCodeAgent.ts @@ -20,7 +20,7 @@ export class OpenCodeAgent implements IAgent { const outputPath = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot); const absolutePath = path.resolve(projectRoot, outputPath); - await backupFile(absolutePath); + await backupFile(absolutePath, agentConfig?.disableBackup); await writeGeneratedFile(absolutePath, concatenatedRules); } diff --git a/src/agents/OpenHandsAgent.ts b/src/agents/OpenHandsAgent.ts index 03be5b5..3cf8c0b 100644 --- a/src/agents/OpenHandsAgent.ts +++ b/src/agents/OpenHandsAgent.ts @@ -22,7 +22,7 @@ export class OpenHandsAgent implements IAgent { const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot); await ensureDirExists(path.dirname(output)); - await backupFile(output); + await backupFile(output, agentConfig?.disableBackup); await writeGeneratedFile(output, concatenatedRules); } getDefaultOutputPath(projectRoot: string): string { diff --git a/src/agents/WindsurfAgent.ts b/src/agents/WindsurfAgent.ts index ab21e7f..5525788 100644 --- a/src/agents/WindsurfAgent.ts +++ b/src/agents/WindsurfAgent.ts @@ -27,7 +27,7 @@ export class WindsurfAgent implements IAgent { const output = agentConfig?.outputPath ?? this.getDefaultOutputPath(projectRoot); await ensureDirExists(path.dirname(output)); - await backupFile(output); + await backupFile(output, agentConfig?.disableBackup); await writeGeneratedFile(output, concatenatedRules); } getDefaultOutputPath(projectRoot: string): string { diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 22a99d2..cfa5682 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -65,6 +65,12 @@ export function run(): void { 'Only search for local .ruler directories, ignore global config', default: false, }); + y.option('disable-backup', { + type: 'boolean', + description: + 'Disable creation of backup files before applying changes', + default: false, + }); }, async (argv) => { const projectRoot = argv['project-root'] as string; @@ -79,6 +85,13 @@ export function run(): void { const verbose = argv.verbose as boolean; const dryRun = argv['dry-run'] as boolean; const localOnly = argv['local-only'] as boolean; + // Determine backup disable preference: CLI > TOML > Default (false) + let backupDisablePreference: boolean | undefined; + if (argv['disable-backup'] !== undefined) { + backupDisablePreference = argv['disable-backup'] as boolean; + } else { + backupDisablePreference = undefined; // Let TOML/default decide + } // Determine gitignore preference: CLI > TOML > Default (enabled) // yargs handles --no-gitignore by setting gitignore to false @@ -99,6 +112,7 @@ export function run(): void { verbose, dryRun, localOnly, + backupDisablePreference, ); console.log('Ruler apply completed successfully.'); } catch (err: unknown) { diff --git a/src/core/ConfigLoader.ts b/src/core/ConfigLoader.ts index 8eae7cd..971738c 100644 --- a/src/core/ConfigLoader.ts +++ b/src/core/ConfigLoader.ts @@ -41,6 +41,7 @@ const rulerConfigSchema = z.object({ enabled: z.boolean().optional(), }) .optional(), + disable_backup: z.boolean().optional(), }); /** @@ -69,6 +70,8 @@ export interface LoadedConfig { mcp?: GlobalMcpConfig; /** Gitignore configuration section. */ gitignore?: GitignoreConfig; + /** Global disable backup setting. */ + disableBackup?: boolean; } /** @@ -207,11 +210,16 @@ export async function loadConfig( gitignoreConfig.enabled = rawGitignoreSection.enabled; } + // Parse global disable_backup setting + const disableBackup = + typeof raw.disable_backup === 'boolean' ? raw.disable_backup : undefined; + return { defaultAgents, agentConfigs, cliAgents, mcp: globalMcpConfig, gitignore: gitignoreConfig, + disableBackup, }; } diff --git a/src/core/FileSystemUtils.ts b/src/core/FileSystemUtils.ts index f55b56b..27b38d2 100644 --- a/src/core/FileSystemUtils.ts +++ b/src/core/FileSystemUtils.ts @@ -94,8 +94,16 @@ export async function writeGeneratedFile( /** * Creates a backup of the given filePath by copying it to filePath.bak if it exists. + * @param filePath The file to backup + * @param disableBackup If true, skip creating the backup */ -export async function backupFile(filePath: string): Promise { +export async function backupFile( + filePath: string, + disableBackup: boolean = false, +): Promise { + if (disableBackup) { + return; // Skip backup if disabled + } try { await fs.access(filePath); await fs.copyFile(filePath, `${filePath}.bak`); diff --git a/src/lib.ts b/src/lib.ts index be77c1d..285a076 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -116,6 +116,7 @@ export async function applyAllAgentConfigs( verbose = false, dryRun = false, localOnly = false, + cliDisableBackup?: boolean, ): Promise { // Load configuration (default_agents, per-agent overrides, CLI filters) logVerbose( @@ -275,6 +276,17 @@ export async function applyAllAgentConfigs( ); } + // Handle backup disable setting + // Configuration precedence: CLI > TOML > Default (false) + let disableBackup: boolean; + if (cliDisableBackup !== undefined) { + disableBackup = cliDisableBackup; + } else if (config.disableBackup !== undefined) { + disableBackup = config.disableBackup; + } else { + disableBackup = false; // Default disabled (backups enabled) + } + // Collect all generated file paths for .gitignore const generatedPaths: string[] = []; let agentsMdWritten = false; @@ -312,7 +324,8 @@ export async function applyAllAgentConfigs( } agentsMdWritten = true; } - let finalAgentConfig = agentConfig; + // Propagate disableBackup to agent config + let finalAgentConfig = { ...agentConfig, disableBackup }; if (agent.getIdentifier() === 'augmentcode' && rulerMcpJson) { const resolvedStrategy = cliMcpStrategy ?? @@ -321,7 +334,7 @@ export async function applyAllAgentConfigs( 'merge'; finalAgentConfig = { - ...agentConfig, + ...finalAgentConfig, mcp: { ...agentConfig?.mcp, strategy: resolvedStrategy, diff --git a/tests/apply-disable-backup.toml.test.ts b/tests/apply-disable-backup.toml.test.ts new file mode 100644 index 0000000..3ee78fd --- /dev/null +++ b/tests/apply-disable-backup.toml.test.ts @@ -0,0 +1,91 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import os from 'os'; +import { execSync } from 'child_process'; + +describe('apply-disable-backup.toml', () => { + let tmpDir: string; + let rulerDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ruler-backup-')); + rulerDir = path.join(tmpDir, '.ruler'); + await fs.mkdir(rulerDir, { recursive: true }); + + // Create a simple instruction file + await fs.writeFile( + path.join(rulerDir, 'instructions.md'), + '# Test Instructions\n\nThis is a test.', + ); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('does not create backup files when disable_backup=true in TOML', async () => { + const toml = `disable_backup = true +default_agents = ["Claude"] +`; + await fs.writeFile(path.join(rulerDir, 'ruler.toml'), toml); + + execSync('npm run build', { stdio: 'inherit' }); + execSync(`node dist/cli/index.js apply --project-root ${tmpDir}`, { + stdio: 'inherit', + }); + + // Check that no backup files were created + const claudeFile = path.join(tmpDir, 'CLAUDE.md'); + const backupFile = path.join(tmpDir, 'CLAUDE.md.bak'); + + expect(await fs.access(claudeFile).then(() => true).catch(() => false)).toBe(true); + expect(await fs.access(backupFile).then(() => true).catch(() => false)).toBe(false); + }); + + it('creates backup files when disable_backup=false in TOML', async () => { + const toml = `disable_backup = false +default_agents = ["Claude"] +`; + await fs.writeFile(path.join(rulerDir, 'ruler.toml'), toml); + + // Create a pre-existing file to back up + const claudeFile = path.join(tmpDir, 'CLAUDE.md'); + await fs.writeFile(claudeFile, '# Existing content\n'); + + execSync('npm run build', { stdio: 'inherit' }); + execSync(`node dist/cli/index.js apply --project-root ${tmpDir}`, { + stdio: 'inherit', + }); + + // Check that backup file was created + const backupFile = path.join(tmpDir, 'CLAUDE.md.bak'); + + expect(await fs.access(claudeFile).then(() => true).catch(() => false)).toBe(true); + expect(await fs.access(backupFile).then(() => true).catch(() => false)).toBe(true); + + const backupContent = await fs.readFile(backupFile, 'utf8'); + expect(backupContent).toBe('# Existing content\n'); + }); + + it('CLI --disable-backup overrides TOML disable_backup=false', async () => { + const toml = `disable_backup = false +default_agents = ["Claude"] +`; + await fs.writeFile(path.join(rulerDir, 'ruler.toml'), toml); + + // Create a pre-existing file to back up + const claudeFile = path.join(tmpDir, 'CLAUDE.md'); + await fs.writeFile(claudeFile, '# Existing content\n'); + + execSync('npm run build', { stdio: 'inherit' }); + execSync(`node dist/cli/index.js apply --disable-backup --project-root ${tmpDir}`, { + stdio: 'inherit', + }); + + // Check that no backup file was created despite TOML setting + const backupFile = path.join(tmpDir, 'CLAUDE.md.bak'); + + expect(await fs.access(claudeFile).then(() => true).catch(() => false)).toBe(true); + expect(await fs.access(backupFile).then(() => true).catch(() => false)).toBe(false); + }); +}); \ No newline at end of file diff --git a/tests/unit/agents/AgentAdapters.test.ts b/tests/unit/agents/AgentAdapters.test.ts index 5a2fa92..4e60367 100644 --- a/tests/unit/agents/AgentAdapters.test.ts +++ b/tests/unit/agents/AgentAdapters.test.ts @@ -24,7 +24,7 @@ describe('Agent Adapters', () => { }); describe('CopilotAgent', () => { - it('backs up and writes copilot-instructions.md', async () => { + it('backs up and writes copilot-instructions.md', async () => { const agent = new CopilotAgent(); const githubDir = path.join(tmpDir, '.github'); await fs.mkdir(githubDir, { recursive: true }); @@ -36,17 +36,29 @@ describe('Agent Adapters', () => { expect(backup).toBe('old copilot'); expect(content).toBe('new copilot'); }); - }); - it('uses custom outputPath when provided', async () => { - const agent = new CopilotAgent(); - const custom = path.join(tmpDir, 'custom_copilot.md'); - await fs.mkdir(path.dirname(custom), { recursive: true }); - await agent.applyRulerConfig('custom data', tmpDir, null, { outputPath: custom }); - expect(await fs.readFile(custom, 'utf8')).toBe('custom data'); + it('writes copilot-instructions.md without backup when cli flag is used', async () => { + const agent = new CopilotAgent(); + const githubDir = path.join(tmpDir, '.github'); + await fs.mkdir(githubDir, { recursive: true }); + const target = path.join(githubDir, 'copilot-instructions.md'); + await fs.writeFile(target, 'old copilot'); + await agent.applyRulerConfig('new copilot', tmpDir, null, { disableBackup: true }); + await expect(fs.readFile(`${target}.bak`, 'utf8')).rejects.toThrow(); + const content = await fs.readFile(target, 'utf8'); + expect(content).toBe('new copilot'); + }); + it('uses custom outputPath when provided', async () => { + const agent = new CopilotAgent(); + const custom = path.join(tmpDir, 'custom_copilot.md'); + await fs.mkdir(path.dirname(custom), { recursive: true }); + await agent.applyRulerConfig('custom data', tmpDir, null, { outputPath: custom }); + expect(await fs.readFile(custom, 'utf8')).toBe('custom data'); + }); }); + describe('ClaudeAgent', () => { - it('backs up and writes CLAUDE.md', async () => { + it('backs up and writes CLAUDE.md', async () => { const agent = new ClaudeAgent(); const target = path.join(tmpDir, 'CLAUDE.md'); await fs.writeFile(target, 'old claude'); @@ -54,17 +66,26 @@ describe('Agent Adapters', () => { expect(await fs.readFile(`${target}.bak`, 'utf8')).toBe('old claude'); expect(await fs.readFile(target, 'utf8')).toBe('new claude'); }); - }); - it('uses custom outputPath when provided', async () => { - const agent = new ClaudeAgent(); - const custom = path.join(tmpDir, 'CUSTOM_CLAUDE.md'); - await fs.mkdir(path.dirname(custom), { recursive: true }); - await agent.applyRulerConfig('x', tmpDir, null, { outputPath: custom }); - expect(await fs.readFile(custom, 'utf8')).toBe('x'); + it('writes CLAUDE.md without backup when cli flag is used', async () => { + const agent = new ClaudeAgent(); + const target = path.join(tmpDir, 'CLAUDE.md'); + await fs.writeFile(target, 'old claude'); + await agent.applyRulerConfig('new claude', tmpDir, null, { disableBackup: true }); + await expect(fs.readFile(`${target}.bak`, 'utf8')).rejects.toThrow(); + const content = await fs.readFile(target, 'utf8'); + expect(content).toBe('new claude'); + }); + it('uses custom outputPath when provided', async () => { + const agent = new ClaudeAgent(); + const custom = path.join(tmpDir, 'CUSTOM_CLAUDE.md'); + await fs.mkdir(path.dirname(custom), { recursive: true }); + await agent.applyRulerConfig('x', tmpDir, null, { outputPath: custom }); + expect(await fs.readFile(custom, 'utf8')).toBe('x'); + }); }); describe('CodexCliAgent', () => { - it('backs up and writes AGENTS.md', async () => { + it('backs up and writes AGENTS.md', async () => { const agent = new CodexCliAgent(); const target = path.join(tmpDir, 'AGENTS.md'); await fs.writeFile(target, 'old codex'); @@ -72,17 +93,26 @@ describe('Agent Adapters', () => { expect(await fs.readFile(`${target}.bak`, 'utf8')).toBe('old codex'); expect(await fs.readFile(target, 'utf8')).toBe('new codex'); }); - }); - it('uses custom outputPath when provided', async () => { - const agent = new CodexCliAgent(); - const custom = path.join(tmpDir, 'CUSTOM_AGENTS.md'); - await fs.mkdir(path.dirname(custom), { recursive: true }); - await agent.applyRulerConfig('y', tmpDir, null, { outputPath: custom }); - expect(await fs.readFile(custom, 'utf8')).toBe('y'); + it('writes AGENTS.md without backup when cli flag is used', async () => { + const agent = new CodexCliAgent(); + const target = path.join(tmpDir, 'AGENTS.md'); + await fs.writeFile(target, 'old codex'); + await agent.applyRulerConfig('new codex', tmpDir, null, { disableBackup: true }); + await expect(fs.readFile(`${target}.bak`, 'utf8')).rejects.toThrow(); + const content = await fs.readFile(target, 'utf8'); + expect(content).toBe('new codex'); + }); + it('uses custom outputPath when provided', async () => { + const agent = new CodexCliAgent(); + const custom = path.join(tmpDir, 'CUSTOM_AGENTS.md'); + await fs.mkdir(path.dirname(custom), { recursive: true }); + await agent.applyRulerConfig('y', tmpDir, null, { outputPath: custom }); + expect(await fs.readFile(custom, 'utf8')).toBe('y'); + }); }); describe('CursorAgent', () => { - it('backs up and writes ruler_cursor_instructions.mdc', async () => { + it('backs up and writes ruler_cursor_instructions.mdc', async () => { const agent = new CursorAgent(); const rulesDir = path.join(tmpDir, '.cursor', 'rules'); await fs.mkdir(rulesDir, { recursive: true }); @@ -92,21 +122,32 @@ describe('Agent Adapters', () => { expect(await fs.readFile(`${target}.bak`, 'utf8')).toBe('old cursor'); const content = await fs.readFile(target, 'utf8'); expect(content).toContain('new cursor'); - }); - }); - it('uses custom outputPath when provided', async () => { - const agent = new CursorAgent(); - const customDir = path.join(tmpDir, '.cursor', 'rules'); - await fs.mkdir(customDir, { recursive: true }); - const custom = path.join(tmpDir, 'custom_cursor.mdc'); - await fs.mkdir(path.dirname(custom), { recursive: true }); - await agent.applyRulerConfig('z', tmpDir, null, { outputPath: custom }); - const content = await fs.readFile(custom, 'utf8'); - expect(content).toContain('z'); + }); + it('writes ruler_cursor_instructions.mdc without backup when cli flag is used', async () => { + const agent = new CursorAgent(); + const rulesDir = path.join(tmpDir, '.cursor', 'rules'); + await fs.mkdir(rulesDir, { recursive: true }); + const target = path.join(rulesDir, 'ruler_cursor_instructions.mdc'); + await fs.writeFile(target, 'old cursor'); + await agent.applyRulerConfig('new cursor', tmpDir, null, { disableBackup: true }); + await expect(fs.readFile(`${target}.bak`, 'utf8')).rejects.toThrow(); + const content = await fs.readFile(target, 'utf8'); + expect(content).toContain('new cursor'); + }); + it('uses custom outputPath when provided', async () => { + const agent = new CursorAgent(); + const customDir = path.join(tmpDir, '.cursor', 'rules'); + await fs.mkdir(customDir, { recursive: true }); + const custom = path.join(tmpDir, 'custom_cursor.mdc'); + await fs.mkdir(path.dirname(custom), { recursive: true }); + await agent.applyRulerConfig('z', tmpDir, null, { outputPath: custom }); + const content = await fs.readFile(custom, 'utf8'); + expect(content).toContain('z'); + }); }); describe('WindsurfAgent', () => { - it('backs up and writes ruler_windsurf_instructions.md', async () => { + it('backs up and writes ruler_windsurf_instructions.md', async () => { const agent = new WindsurfAgent(); const rulesDir = path.join(tmpDir, '.windsurf', 'rules'); await fs.mkdir(rulesDir, { recursive: true }); @@ -116,19 +157,30 @@ describe('Agent Adapters', () => { expect(await fs.readFile(`${target}.bak`, 'utf8')).toBe('old windsurf'); expect(await fs.readFile(target, 'utf8')).toBe('new windsurf'); }); - }); - it('uses custom outputPath when provided', async () => { - const agent = new WindsurfAgent(); - const customDir = path.join(tmpDir, '.windsurf', 'rules'); - await fs.mkdir(customDir, { recursive: true }); - const custom = path.join(tmpDir, 'custom_windsurf.md'); - await fs.mkdir(path.dirname(custom), { recursive: true }); - await agent.applyRulerConfig('w', tmpDir, null, { outputPath: custom }); - expect(await fs.readFile(custom, 'utf8')).toBe('w'); + it('writes ruler_windsurf_instructions.md without backup when cli flag is used', async () => { + const agent = new WindsurfAgent(); + const rulesDir = path.join(tmpDir, '.windsurf', 'rules'); + await fs.mkdir(rulesDir, { recursive: true }); + const target = path.join(rulesDir, 'ruler_windsurf_instructions.md'); + await fs.writeFile(target, 'old windsurf'); + await agent.applyRulerConfig('new windsurf', tmpDir, null, { disableBackup: true }); + await expect(fs.readFile(`${target}.bak`, 'utf8')).rejects.toThrow(); + const content = await fs.readFile(target, 'utf8'); + expect(content).toBe('new windsurf'); + }); + it('uses custom outputPath when provided', async () => { + const agent = new WindsurfAgent(); + const customDir = path.join(tmpDir, '.windsurf', 'rules'); + await fs.mkdir(customDir, { recursive: true }); + const custom = path.join(tmpDir, 'custom_windsurf.md'); + await fs.mkdir(path.dirname(custom), { recursive: true }); + await agent.applyRulerConfig('w', tmpDir, null, { outputPath: custom }); + expect(await fs.readFile(custom, 'utf8')).toBe('w'); + }); }); describe('ClineAgent', () => { - it('backs up and writes .clinerules', async () => { + it('backs up and writes .clinerules', async () => { const agent = new ClineAgent(); const target = path.join(tmpDir, '.clinerules'); await fs.writeFile(target, 'old cline'); @@ -136,17 +188,26 @@ describe('Agent Adapters', () => { expect(await fs.readFile(`${target}.bak`, 'utf8')).toBe('old cline'); expect(await fs.readFile(target, 'utf8')).toBe('new cline'); }); - }); - it('uses custom outputPath when provided', async () => { - const agent = new ClineAgent(); - const custom = path.join(tmpDir, 'custom_cline'); - await fs.mkdir(path.dirname(custom), { recursive: true }); - await agent.applyRulerConfig('c', tmpDir, null, { outputPath: custom }); - expect(await fs.readFile(custom, 'utf8')).toBe('c'); + it('writes .clinerules without backup when cli flag is used', async () => { + const agent = new ClineAgent(); + const target = path.join(tmpDir, '.clinerules'); + await fs.writeFile(target, 'old cline'); + await agent.applyRulerConfig('new cline', tmpDir, null, { disableBackup: true }); + await expect(fs.readFile(`${target}.bak`, 'utf8')).rejects.toThrow(); + const content = await fs.readFile(target, 'utf8'); + expect(content).toBe('new cline'); + }); + it('uses custom outputPath when provided', async () => { + const agent = new ClineAgent(); + const custom = path.join(tmpDir, 'custom_cline'); + await fs.mkdir(path.dirname(custom), { recursive: true }); + await agent.applyRulerConfig('c', tmpDir, null, { outputPath: custom }); + expect(await fs.readFile(custom, 'utf8')).toBe('c'); + }); }); describe('AiderAgent', () => { - it('creates and updates .aider.conf.yml', async () => { + it('creates and updates .aider.conf.yml', async () => { const agent = new AiderAgent(); // No existing config await agent.applyRulerConfig('aider rules', tmpDir, null); @@ -163,21 +224,21 @@ describe('Agent Adapters', () => { expect(Array.isArray(updated.read)).toBe(true); expect(updated.read).toContain('ruler_aider_instructions.md'); }); - }); - it('uses custom outputPathInstructions when provided', async () => { - const agent = new AiderAgent(); - const customMd = path.join(tmpDir, 'custom_aider.md'); - await fs.mkdir(path.dirname(customMd), { recursive: true }); - await agent.applyRulerConfig('aider data', tmpDir, null, { outputPathInstructions: customMd }); - expect(await fs.readFile(customMd, 'utf8')).toBe('aider data'); - const cfg = yaml.load( - await fs.readFile(path.join(tmpDir, '.aider.conf.yml'), 'utf8'), - ) as any; - expect(cfg.read).toContain('custom_aider.md'); + it('uses custom outputPathInstructions when provided', async () => { + const agent = new AiderAgent(); + const customMd = path.join(tmpDir, 'custom_aider.md'); + await fs.mkdir(path.dirname(customMd), { recursive: true }); + await agent.applyRulerConfig('aider data', tmpDir, null, { outputPathInstructions: customMd }); + expect(await fs.readFile(customMd, 'utf8')).toBe('aider data'); + const cfg = yaml.load( + await fs.readFile(path.join(tmpDir, '.aider.conf.yml'), 'utf8'), + ) as any; + expect(cfg.read).toContain('custom_aider.md'); + }); }); describe('FirebaseAgent', () => { - it('backs up and writes .idx/airules.md', async () => { + it('backs up and writes .idx/airules.md', async () => { const agent = new FirebaseAgent(); const idxDir = path.join(tmpDir, '.idx'); await fs.mkdir(idxDir, { recursive: true }); @@ -187,17 +248,28 @@ describe('Agent Adapters', () => { expect(await fs.readFile(`${target}.bak`, 'utf8')).toBe('old firebase'); expect(await fs.readFile(target, 'utf8')).toBe('new firebase'); }); - }); - it('uses custom outputPath when provided', async () => { - const agent = new FirebaseAgent(); - const custom = path.join(tmpDir, 'custom_firebase.md'); - await fs.mkdir(path.dirname(custom), { recursive: true }); - await agent.applyRulerConfig('firebase rules', tmpDir, null, { outputPath: custom }); - expect(await fs.readFile(custom, 'utf8')).toBe('firebase rules'); + it('writes .idx/airules.md without backup when cli flag is used', async () => { + const agent = new FirebaseAgent(); + const idxDir = path.join(tmpDir, '.idx'); + await fs.mkdir(idxDir, { recursive: true }); + const target = path.join(idxDir, 'airules.md'); + await fs.writeFile(target, 'old firebase'); + await agent.applyRulerConfig('new firebase', tmpDir, null, { disableBackup: true }); + await expect(fs.readFile(`${target}.bak`, 'utf8')).rejects.toThrow(); + const content = await fs.readFile(target, 'utf8'); + expect(content).toBe('new firebase'); + }); + it('uses custom outputPath when provided', async () => { + const agent = new FirebaseAgent(); + const custom = path.join(tmpDir, 'custom_firebase.md'); + await fs.mkdir(path.dirname(custom), { recursive: true }); + await agent.applyRulerConfig('firebase rules', tmpDir, null, { outputPath: custom }); + expect(await fs.readFile(custom, 'utf8')).toBe('firebase rules'); + }); }); describe('JunieAgent', () => { - it('backs up and writes .junie/guidelines.md', async () => { + it('backs up and writes .junie/guidelines.md', async () => { const agent = new JunieAgent(); const junieDir = path.join(tmpDir, '.junie'); await fs.mkdir(junieDir, { recursive: true }); @@ -207,13 +279,24 @@ describe('Agent Adapters', () => { expect(await fs.readFile(`${target}.bak`, 'utf8')).toBe('old junie'); expect(await fs.readFile(target, 'utf8')).toBe('new junie'); }); - }); - it('uses custom outputPath when provided', async () => { - const agent = new JunieAgent(); - const custom = path.join(tmpDir, 'custom_junie.md'); - await fs.mkdir(path.dirname(custom), { recursive: true }); - await agent.applyRulerConfig('junie rules', tmpDir, null, { outputPath: custom }); - expect(await fs.readFile(custom, 'utf8')).toBe('junie rules'); + it('writes .junie/guidelines.md without backup when cli flag is used', async () => { + const agent = new JunieAgent(); + const junieDir = path.join(tmpDir, '.junie'); + await fs.mkdir(junieDir, { recursive: true }); + const target = path.join(junieDir, 'guidelines.md'); + await fs.writeFile(target, 'old junie'); + await agent.applyRulerConfig('new junie', tmpDir, null, { disableBackup: true }); + await expect(fs.readFile(`${target}.bak`, 'utf8')).rejects.toThrow(); + const content = await fs.readFile(target, 'utf8'); + expect(content).toBe('new junie'); + }); + it('uses custom outputPath when provided', async () => { + const agent = new JunieAgent(); + const custom = path.join(tmpDir, 'custom_junie.md'); + await fs.mkdir(path.dirname(custom), { recursive: true }); + await agent.applyRulerConfig('junie rules', tmpDir, null, { outputPath: custom }); + expect(await fs.readFile(custom, 'utf8')).toBe('junie rules'); + }); }); describe('AugmentCodeAgent', () => { @@ -226,6 +309,16 @@ describe('Agent Adapters', () => { expect(await fs.readFile(`${target}.bak`, 'utf8')).toBe('old augment'); expect(await fs.readFile(target, 'utf8')).toBe('new augment'); }); + it('writes ruler_augment_instructions.md without backup when cli flag is used', async () => { + const agent = new AugmentCodeAgent(); + const target = path.join(tmpDir, '.augment', 'rules', 'ruler_augment_instructions.md'); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, 'old augment'); + await agent.applyRulerConfig('new augment', tmpDir, null, { disableBackup: true }); + await expect(fs.readFile(`${target}.bak`, 'utf8')).rejects.toThrow(); + const content = await fs.readFile(target, 'utf8'); + expect(content).toBe('new augment'); + }); it('uses custom outputPath when provided', async () => { const agent = new AugmentCodeAgent(); diff --git a/tests/unit/core/ConfigLoader.test.ts b/tests/unit/core/ConfigLoader.test.ts index 1d45d42..9eb90a4 100644 --- a/tests/unit/core/ConfigLoader.test.ts +++ b/tests/unit/core/ConfigLoader.test.ts @@ -154,4 +154,33 @@ it('loads config from custom path via configPath option', async () => { expect(config.gitignore?.enabled).toBeUndefined(); }); }); + + describe('disable_backup configuration', () => { + it('parses disable_backup = true', async () => { + const content = `disable_backup = true`; + await fs.writeFile(path.join(rulerDir, 'ruler.toml'), content); + const config = await loadConfig({ projectRoot: tmpDir }); + expect(config.disableBackup).toBe(true); + }); + + it('parses disable_backup = false', async () => { + const content = `disable_backup = false`; + await fs.writeFile(path.join(rulerDir, 'ruler.toml'), content); + const config = await loadConfig({ projectRoot: tmpDir }); + expect(config.disableBackup).toBe(false); + }); + + it('handles missing disable_backup key', async () => { + const content = `default_agents = ["A"]`; + await fs.writeFile(path.join(rulerDir, 'ruler.toml'), content); + const config = await loadConfig({ projectRoot: tmpDir }); + expect(config.disableBackup).toBeUndefined(); + }); + + it('handles empty config file for disable_backup', async () => { + await fs.writeFile(path.join(rulerDir, 'ruler.toml'), ''); + const config = await loadConfig({ projectRoot: tmpDir }); + expect(config.disableBackup).toBeUndefined(); + }); + }); }); \ No newline at end of file