From 3bfc53fc4ee63c7bbea63f0e84912d8a3225c718 Mon Sep 17 00:00:00 2001 From: Jon Andersen Date: Thu, 26 Mar 2026 10:12:20 -0400 Subject: [PATCH] Add AGENTS.md support for Firebender Generate Firebender rules through AGENTS.md in both standard\nand symlink modes so it matches the rest of the shared agent\nflow.\n\nKeep firebender.json as supplemental MCP and overlay config,\nreuse the shared .agents/skills path for Firebender skills,\nand generate Firebender commands as .mdc symlinks.\n\nAlso update the docs, tests, and example gitignore entries to\nmatch the new Firebender layout. Signed-off-by: Jon Andersen --- Example/.gitignore | 2 +- docs/agents.md | 6 +- docs/commands-and-skills.md | 3 +- docs/mcp.md | 2 +- docs/project-structure.md | 12 +- docs/rule-format.md | 2 - src/agents/external_commands_generator.rs | 55 +- src/agents/firebender.rs | 1049 ++++++--------------- src/commands/generate.rs | 1 + src/constants.rs | 3 +- src/operations/command_reader.rs | 51 +- 11 files changed, 379 insertions(+), 807 deletions(-) diff --git a/Example/.gitignore b/Example/.gitignore index 4397d0e..10f2e64 100644 --- a/Example/.gitignore +++ b/Example/.gitignore @@ -8,7 +8,7 @@ **/.cursor/commands/ai-rules/ **/.cursor/mcp.json **/.cursor/skills/ai-rules-generated-* -**/.firebender/skills/ai-rules-generated-* +**/.firebender/commands/*-ai-rules.mdc **/.gemini/settings.json **/.mcp.json **/.roo/mcp.json diff --git a/docs/agents.md b/docs/agents.md index 7b52e10..62dc639 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -9,8 +9,8 @@ | **Cline** | `.clinerules/AGENTS.md` -> inlined file | `.clinerules/AGENTS.md` -> `../ai-rules/AGENTS.md` | - | | | **Codex** | `AGENTS.md` -> inlined file | `AGENTS.md` -> `ai-rules/AGENTS.md` | - | | | **Copilot** | `AGENTS.md` -> inlined file | `AGENTS.md` -> `ai-rules/AGENTS.md` | - | | -| **Cursor** | `AGENTS.md` -> inlined file | `AGENTS.md` -> `ai-rules/AGENTS.md` | `.cursor/mcp.json` | | -| **Firebender** | `firebender.json` | `firebender.json` (references `ai-rules/AGENTS.md`) | Embedded in `firebender.json` | Supports overlay files | +| **Cursor** | `.cursor/rules/*.mdc` | `AGENTS.md` -> `ai-rules/AGENTS.md` | `.cursor/mcp.json` | Symlink mode: only project root level | +| **Firebender** | `AGENTS.md` -> inlined file | `AGENTS.md` -> `ai-rules/AGENTS.md` | `firebender.json` | Commands live in `.firebender/`; skills reuse `.agents/skills`; overlay supported | | **Gemini** | `GEMINI.md` -> inlined file | `GEMINI.md` -> `ai-rules/AGENTS.md` | Embedded in `.gemini/settings.json` | | | **Goose** | `AGENTS.md` -> inlined file | `AGENTS.md` -> `ai-rules/AGENTS.md` | - | | | **Kilocode** | `.kilocode/rules/AGENTS.md` -> inlined file | `.kilocode/rules/AGENTS.md` -> `../../ai-rules/AGENTS.md` | - | | @@ -23,4 +23,6 @@ Firebender supports overlay configuration files. To customize your configuration 1. Create `ai-rules/firebender-overlay.json` in the same parent directory as your generated `firebender.json` 2. Any values defined in the overlay file will be merged into the base configuration, with overlay values taking precedence +`firebender.json` is generated as supplemental config when `ai-rules/mcp.json` and/or `ai-rules/firebender-overlay.json` exists. + **MCP Integration:** If you have `ai-rules/mcp.json`, the MCP servers are merged into `firebender.json` first, then the overlay is applied. This allows you to override MCP configurations in the overlay if needed. diff --git a/docs/commands-and-skills.md b/docs/commands-and-skills.md index c437e33..11dcc0e 100644 --- a/docs/commands-and-skills.md +++ b/docs/commands-and-skills.md @@ -31,7 +31,7 @@ Command files support optional YAML frontmatter: | **AMP** | `.agents/commands/{name}-ai-rules.md` | Stripped | | **Claude Code** | `.claude/commands/ai-rules/*.md` | Preserved | | **Cursor** | `.cursor/commands/ai-rules/*.md` | Stripped | -| **Firebender** | `firebender.json` (commands array) | Stripped | +| **Firebender** | `.firebender/commands/{name}-ai-rules.mdc` | Preserved | ### Documentation @@ -79,5 +79,6 @@ When you run `ai-rules generate`, symlinks are created: | Claude | `.claude/skills/ai-rules-generated-debugging` -> `../../ai-rules/skills/debugging` | | Codex | `.codex/skills/ai-rules-generated-debugging` -> `../../ai-rules/skills/debugging` | | Cursor | `.cursor/skills/ai-rules-generated-debugging` -> `../../ai-rules/skills/debugging` | +| Firebender | `.agents/skills/ai-rules-generated-debugging` -> `../../ai-rules/skills/debugging` | Skill folders without a `SKILL.md` file are skipped with a warning. diff --git a/docs/mcp.md b/docs/mcp.md index 8f2c6a8..51d7e0e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -36,6 +36,6 @@ See the [Supported Agents](agents.md) table for which agents support MCP and the |-------|-------------------| | Claude Code | `.mcp.json` | | Cursor | `.cursor/mcp.json` | -| Firebender | Embedded in `firebender.json` | +| Firebender | `firebender.json` (generated when MCP and/or overlay config exists) | | Gemini | Embedded in `.gemini/settings.json` | | Roo | `.roo/mcp.json` | diff --git a/docs/project-structure.md b/docs/project-structure.md index dc77361..8dd87a2 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -40,8 +40,12 @@ monorepo/ ├── CLAUDE.md # Symlink -> ai-rules/.generated-ai-rules/ai-rules-generated-AGENTS.md ├── AGENTS.md # Symlink -> ai-rules/.generated-ai-rules/ai-rules-generated-AGENTS.md ├── .clinerules/ # Root Cline rules (symlink) +├── .firebender/ +│ ├── commands/ # Firebender commands (generated symlinks) +├── .agents/ +│ └── skills/ # Shared AMP/Firebender skills (generated symlinks) ├── .mcp.json # Root MCP config (generated) -└── firebender.json # Root Firebender config (generated) +└── firebender.json # Root Firebender supplemental config (generated when needed) ``` ## Symlink Mode @@ -62,7 +66,11 @@ project/ ├── CLAUDE.md # Symlink -> ai-rules/AGENTS.md ├── GEMINI.md # Symlink -> ai-rules/AGENTS.md ├── AGENTS.md # Symlink -> ai-rules/AGENTS.md -├── firebender.json # References ai-rules/AGENTS.md +├── .firebender/ +│ ├── commands/ # Firebender commands (generated symlinks) +├── .agents/ +│ └── skills/ # Shared AMP/Firebender skills (generated symlinks) +├── firebender.json # Supplemental Firebender config (if mcp.json or overlay exists) ├── .clinerules/ │ └── AGENTS.md # Symlink -> ../ai-rules/AGENTS.md ├── .cursor/ diff --git a/docs/rule-format.md b/docs/rule-format.md index d64a982..1b1d06b 100644 --- a/docs/rule-format.md +++ b/docs/rule-format.md @@ -38,8 +38,6 @@ In Standard Mode, `ai-rules generate` produces a single inlined file at `ai-rule Most agent output files (e.g., `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`) are created as **symlinks** pointing to this inlined file. This ensures every agent reads the same complete content without needing `@` file expansion support. -Firebender generates its own JSON format and does not use the inlined file. Cursor uses the inlined `AGENTS.md` path. - ## Symlink Mode Use Symlink Mode for simple setups where all agents share the same rules. diff --git a/src/agents/external_commands_generator.rs b/src/agents/external_commands_generator.rs index af9a9cc..cd67b90 100644 --- a/src/agents/external_commands_generator.rs +++ b/src/agents/external_commands_generator.rs @@ -1,9 +1,9 @@ use crate::agents::command_generator::CommandGeneratorTrait; use crate::operations::command_reader::{ - check_command_symlinks_in_subdir_in_sync, check_command_symlinks_in_sync, - create_command_symlinks, create_command_symlinks_in_subdir, get_command_gitignore_patterns, - get_command_gitignore_patterns_subdir, remove_command_symlinks_in_subdir, - remove_generated_command_symlinks, + check_command_symlinks_in_subdir_in_sync, check_command_symlinks_in_sync_with_extension, + create_command_symlinks_in_subdir, create_command_symlinks_with_extension, + get_command_gitignore_patterns_subdir, get_command_gitignore_patterns_with_extension, + remove_command_symlinks_in_subdir, remove_generated_command_symlinks_with_extension, }; use anyhow::Result; use std::path::{Path, PathBuf}; @@ -13,6 +13,7 @@ pub struct ExternalCommandsGenerator { /// Optional subdirectory for symlinks (e.g., "ai-rules" for .claude/commands/ai-rules/) /// When None, uses flat structure with -ai-rules.md suffix subdir: Option, + extension: String, } impl ExternalCommandsGenerator { @@ -21,6 +22,16 @@ impl ExternalCommandsGenerator { Self { target_dir: target_dir.to_string(), subdir: None, + extension: "md".to_string(), + } + } + + /// Create a generator with flat structure and a custom extension. + pub fn with_extension(target_dir: &str, extension: &str) -> Self { + Self { + target_dir: target_dir.to_string(), + subdir: None, + extension: extension.to_string(), } } @@ -29,6 +40,7 @@ impl ExternalCommandsGenerator { Self { target_dir: target_dir.to_string(), subdir: Some(subdir.to_string()), + extension: "md".to_string(), } } } @@ -39,7 +51,11 @@ impl CommandGeneratorTrait for ExternalCommandsGenerator { Some(subdir) => { create_command_symlinks_in_subdir(current_dir, &self.target_dir, subdir) } - None => create_command_symlinks(current_dir, &self.target_dir), + None => create_command_symlinks_with_extension( + current_dir, + &self.target_dir, + &self.extension, + ), } } @@ -48,7 +64,11 @@ impl CommandGeneratorTrait for ExternalCommandsGenerator { Some(subdir) => { remove_command_symlinks_in_subdir(current_dir, &self.target_dir, subdir) } - None => remove_generated_command_symlinks(current_dir, &self.target_dir), + None => remove_generated_command_symlinks_with_extension( + current_dir, + &self.target_dir, + &self.extension, + ), } } @@ -57,14 +77,20 @@ impl CommandGeneratorTrait for ExternalCommandsGenerator { Some(subdir) => { check_command_symlinks_in_subdir_in_sync(current_dir, &self.target_dir, subdir) } - None => check_command_symlinks_in_sync(current_dir, &self.target_dir), + None => check_command_symlinks_in_sync_with_extension( + current_dir, + &self.target_dir, + &self.extension, + ), } } fn command_gitignore_patterns(&self) -> Vec { match &self.subdir { Some(subdir) => get_command_gitignore_patterns_subdir(&self.target_dir, subdir), - None => get_command_gitignore_patterns(&self.target_dir), + None => { + get_command_gitignore_patterns_with_extension(&self.target_dir, &self.extension) + } } } } @@ -91,6 +117,7 @@ mod tests { let generator = ExternalCommandsGenerator::new(".agents/commands"); assert_eq!(generator.target_dir, ".agents/commands"); assert!(generator.subdir.is_none()); + assert_eq!(generator.extension, "md"); } #[test] @@ -178,6 +205,7 @@ mod tests { let generator = ExternalCommandsGenerator::with_subdir(".claude/commands", "ai-rules"); assert_eq!(generator.target_dir, ".claude/commands"); assert_eq!(generator.subdir, Some("ai-rules".to_string())); + assert_eq!(generator.extension, "md"); } #[test] @@ -287,5 +315,16 @@ mod tests { GENERATED_COMMAND_SUFFIX )] ); + + // Firebender uses flat structure with .mdc files + let firebender_gen = + ExternalCommandsGenerator::with_extension(".firebender/commands", "mdc"); + assert_eq!( + firebender_gen.command_gitignore_patterns(), + vec![format!( + ".firebender/commands/*-{}.mdc", + GENERATED_COMMAND_SUFFIX + )] + ); } } diff --git a/src/agents/firebender.rs b/src/agents/firebender.rs index 654df4a..2be3fb5 100644 --- a/src/agents/firebender.rs +++ b/src/agents/firebender.rs @@ -1,38 +1,42 @@ -//! Firebender agent implementation for generating firebender.json configuration files. +//! Firebender agent implementation using AGENTS.md plus supplemental firebender.json config. +use crate::agents::command_generator::CommandGeneratorTrait; +use crate::agents::external_commands_generator::ExternalCommandsGenerator; use crate::agents::external_skills_generator::ExternalSkillsGenerator; +use crate::agents::mcp_generator::McpGeneratorTrait; use crate::agents::rule_generator::AgentRuleGenerator; +use crate::agents::single_file_based::{ + check_in_sync, clean_generated_files, generate_agent_file_contents, +}; use crate::agents::skills_generator::SkillsGeneratorTrait; use crate::constants::{ - AGENTS_MD_FILENAME, AI_RULE_SOURCE_DIR, FIREBENDER_JSON, FIREBENDER_OVERLAY_JSON, - FIREBENDER_SKILLS_DIR, FIREBENDER_USE_CURSOR_RULES_FIELD, MCP_SERVERS_FIELD, - OPTIONAL_RULES_FILENAME, + AGENTS_MD_FILENAME, AI_RULE_SOURCE_DIR, AMP_SKILLS_DIR, FIREBENDER_COMMANDS_DIR, + FIREBENDER_JSON, FIREBENDER_OVERLAY_JSON, MCP_SERVERS_FIELD, }; use crate::models::SourceFile; -use crate::operations::body_generator::generated_body_file_reference_path; -use crate::operations::find_command_files; use crate::operations::mcp_reader::extract_mcp_servers_for_firebender; -use crate::utils::file_utils::ensure_trailing_newline; +use crate::utils::file_utils::{ + check_agents_md_symlink, check_inlined_file_symlink, create_symlink_to_agents_md, + create_symlink_to_inlined_file, ensure_trailing_newline, +}; use anyhow::{Context, Result}; -use serde_json::{json, Map, Value}; +use serde_json::{json, Value}; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; pub struct FirebenderGenerator; +#[derive(Clone)] +struct FirebenderConfigGenerator; + impl AgentRuleGenerator for FirebenderGenerator { fn name(&self) -> &str { "firebender" } fn clean(&self, current_dir: &Path) -> Result<()> { - let firebender_file = current_dir.join(FIREBENDER_JSON); - if firebender_file.exists() { - fs::remove_file(&firebender_file) - .with_context(|| format!("Failed to remove {}", firebender_file.display()))?; - } - Ok(()) + clean_generated_files(current_dir, AGENTS_MD_FILENAME) } fn generate_agent_contents( @@ -40,24 +44,7 @@ impl AgentRuleGenerator for FirebenderGenerator { source_files: &[SourceFile], current_dir: &Path, ) -> HashMap { - let mut agent_files = HashMap::new(); - - if source_files.is_empty() { - return agent_files; - } - - let firebender_file_path = current_dir.join(FIREBENDER_JSON); - - match generate_firebender_json_with_overlay(source_files, Some(current_dir)) { - Ok(content) => { - agent_files.insert(firebender_file_path, content); - } - Err(e) => { - eprintln!("Warning: Failed to generate firebender.json: {e}"); - } - } - - agent_files + generate_agent_file_contents(source_files, current_dir, AGENTS_MD_FILENAME) } fn check_agent_contents( @@ -65,172 +52,130 @@ impl AgentRuleGenerator for FirebenderGenerator { source_files: &[SourceFile], current_dir: &Path, ) -> Result { - let firebender_file = current_dir.join(FIREBENDER_JSON); - - if source_files.is_empty() { - return Ok(!firebender_file.exists()); - } - - let expected_files = self.generate_agent_contents(source_files, current_dir); - let Some(expected_content) = expected_files.get(&firebender_file) else { - return Ok(false); - }; - - file_matches_expected(&firebender_file, expected_content) + check_in_sync(source_files, current_dir, AGENTS_MD_FILENAME) } fn check_symlink(&self, current_dir: &Path) -> Result { - let firebender_file = current_dir.join(FIREBENDER_JSON); - if !firebender_file.exists() { - return Ok(false); - } - - let agents_md = current_dir - .join(AI_RULE_SOURCE_DIR) - .join(AGENTS_MD_FILENAME); - if !agents_md.exists() { - return Ok(false); - } - - let expected_content = generate_firebender_symlink_content(current_dir)?; - - file_matches_expected(&firebender_file, &expected_content) + let output_file = current_dir.join(AGENTS_MD_FILENAME); + check_agents_md_symlink(current_dir, &output_file) } fn gitignore_patterns(&self) -> Vec { - vec![FIREBENDER_JSON.to_string()] + vec![AGENTS_MD_FILENAME.to_string()] } fn generate_symlink(&self, current_dir: &Path) -> Result> { - let agents_md = current_dir - .join(AI_RULE_SOURCE_DIR) - .join(AGENTS_MD_FILENAME); - if !agents_md.exists() { - return Ok(vec![]); + let success = create_symlink_to_agents_md(current_dir, Path::new(AGENTS_MD_FILENAME))?; + if success { + Ok(vec![current_dir.join(AGENTS_MD_FILENAME)]) + } else { + Ok(vec![]) } + } - let firebender_path = current_dir.join(FIREBENDER_JSON); - let content = generate_firebender_symlink_content(current_dir)?; + fn uses_inlined_symlink(&self) -> bool { + true + } - fs::write(&firebender_path, content) - .with_context(|| format!("Failed to write {}", firebender_path.display()))?; + fn generate_inlined_symlink(&self, current_dir: &Path) -> Result> { + let success = create_symlink_to_inlined_file(current_dir, Path::new(AGENTS_MD_FILENAME))?; + if success { + Ok(vec![current_dir.join(AGENTS_MD_FILENAME)]) + } else { + Ok(vec![]) + } + } - Ok(vec![firebender_path]) + fn check_inlined_symlink(&self, current_dir: &Path) -> Result { + let output_file = current_dir.join(AGENTS_MD_FILENAME); + check_inlined_file_symlink(current_dir, &output_file) } - fn skills_generator(&self) -> Option> { - Some(Box::new(ExternalSkillsGenerator::new( - FIREBENDER_SKILLS_DIR, + fn mcp_generator(&self) -> Option> { + Some(Box::new(FirebenderConfigGenerator)) + } + + fn command_generator(&self) -> Option> { + Some(Box::new(ExternalCommandsGenerator::with_extension( + FIREBENDER_COMMANDS_DIR, + "mdc", ))) } + + fn skills_generator(&self) -> Option> { + Some(Box::new(ExternalSkillsGenerator::new(AMP_SKILLS_DIR))) + } } -/// Generates `firebender.json`, merging the optional overlay if present. -fn generate_firebender_json_with_overlay( - source_files: &[SourceFile], - current_dir: Option<&Path>, -) -> Result { - let mut rules: Vec = Vec::new(); - - for source_file in source_files { - let body_file_name = source_file.get_body_file_name(); - let generated_path = generated_body_file_reference_path(&body_file_name); - - let mut rule_entry = Map::new(); - rule_entry.insert( - "rulesPaths".to_string(), - json!(generated_path.display().to_string()), - ); +impl McpGeneratorTrait for FirebenderConfigGenerator { + fn generate_mcp(&self, current_dir: &Path) -> HashMap { + let mut files = HashMap::new(); - if source_file.front_matter.always_apply { - rules.push(Value::Object(rule_entry)); - } else if let Some(patterns) = &source_file.front_matter.file_matching_patterns { - if !patterns.is_empty() { - rule_entry.insert("filePathMatches".to_string(), json!(patterns)); - rules.push(Value::Object(rule_entry)); - } + if let Ok(Some(config)) = generate_firebender_config(current_dir) { + files.insert(current_dir.join(FIREBENDER_JSON), config); } - } - - let has_optional_rules = source_files.iter().any(|f| !f.front_matter.always_apply); - if has_optional_rules { - let optional_path = generated_body_file_reference_path(OPTIONAL_RULES_FILENAME); - rules.push(json!({ - "rulesPaths": optional_path.display().to_string() - })); + files } - let mut firebender_config = json!({ - "rules": rules, - FIREBENDER_USE_CURSOR_RULES_FIELD: false - }); - - // Add commands if present - if let Some(dir) = current_dir { - if let Ok(command_files) = find_command_files(dir) { - if !command_files.is_empty() { - let commands: Vec = command_files - .iter() - .map(|cmd| { - json!({ - "name": cmd.name, - "path": cmd.relative_path.display().to_string() - }) - }) - .collect(); - firebender_config["commands"] = json!(commands); - } + fn clean_mcp(&self, current_dir: &Path) -> Result<()> { + let firebender_file = current_dir.join(FIREBENDER_JSON); + if firebender_file.exists() { + fs::remove_file(&firebender_file) + .with_context(|| format!("Failed to remove {}", firebender_file.display()))?; } + Ok(()) } - finalize_firebender_config(firebender_config, current_dir) -} + fn check_mcp(&self, current_dir: &Path) -> Result { + let firebender_file = current_dir.join(FIREBENDER_JSON); -fn generate_firebender_symlink_content(current_dir: &Path) -> Result { - let rules = vec![json!({ - "rulesPaths": Path::new(AI_RULE_SOURCE_DIR) - .join(AGENTS_MD_FILENAME) - .display() - .to_string() - })]; + match generate_firebender_config(current_dir)? { + Some(expected_content) => file_matches_expected(&firebender_file, &expected_content), + None => Ok(!firebender_file.exists()), + } + } - let firebender_config = json!({ - "rules": rules, - FIREBENDER_USE_CURSOR_RULES_FIELD: false - }); + fn mcp_gitignore_patterns(&self) -> Vec { + vec![FIREBENDER_JSON.to_string()] + } - finalize_firebender_config(firebender_config, Some(current_dir)) + fn box_clone(&self) -> Box { + Box::new(self.clone()) + } } -fn finalize_firebender_config( - mut firebender_config: Value, - current_dir: Option<&Path>, -) -> Result { - if let Some(dir) = current_dir { - if let Some(mcp_servers) = extract_mcp_servers_for_firebender(dir)? { - firebender_config[MCP_SERVERS_FIELD] = mcp_servers; - } +fn generate_firebender_config(current_dir: &Path) -> Result> { + let mut firebender_config = json!({}); + let mut has_content = false; - let overlay_path = dir.join(AI_RULE_SOURCE_DIR).join(FIREBENDER_OVERLAY_JSON); - if overlay_path.exists() { - let overlay_content = fs::read_to_string(&overlay_path).with_context(|| { - format!("Failed to read overlay file: {}", overlay_path.display()) - })?; + if let Some(mcp_servers) = extract_mcp_servers_for_firebender(current_dir)? { + firebender_config[MCP_SERVERS_FIELD] = mcp_servers; + has_content = true; + } - let overlay_json: Value = - serde_json::from_str(&overlay_content).with_context(|| { - format!("Invalid JSON in overlay file: {}", overlay_path.display()) - })?; + let overlay_path = current_dir + .join(AI_RULE_SOURCE_DIR) + .join(FIREBENDER_OVERLAY_JSON); + if overlay_path.exists() { + let overlay_content = fs::read_to_string(&overlay_path) + .with_context(|| format!("Failed to read overlay file: {}", overlay_path.display()))?; - merge_json_objects(&mut firebender_config, &overlay_json); - } + let overlay_json: Value = serde_json::from_str(&overlay_content) + .with_context(|| format!("Invalid JSON in overlay file: {}", overlay_path.display()))?; + + merge_json_objects(&mut firebender_config, &overlay_json); + has_content = true; + } + + if !has_content { + return Ok(None); } let json_string = serde_json::to_string_pretty(&firebender_config) .with_context(|| "Failed to serialize firebender configuration to JSON")?; - Ok(ensure_trailing_newline(json_string)) + Ok(Some(ensure_trailing_newline(json_string))) } /// Recursively merges JSON objects, giving precedence to values in `overlay`. @@ -262,10 +207,11 @@ fn file_matches_expected(file_path: &Path, expected_content: &str) -> Result TempDir { - let temp_dir = TempDir::new().unwrap(); - std::fs::create_dir_all(temp_dir.path().join(AI_RULE_SOURCE_DIR)).unwrap(); - temp_dir - } - - fn write_agents_md(temp_dir: &TempDir) { - let ai_rules_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR); - create_file(&ai_rules_dir, AGENTS_MD_FILENAME, "# Agents\n"); - } - - fn write_overlay(temp_dir: &TempDir, overlay: &Value) { - let ai_rules_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR); - create_file( - &ai_rules_dir, - FIREBENDER_OVERLAY_JSON, - &serde_json::to_string_pretty(overlay).unwrap(), - ); - } - - #[test] - fn test_generate_firebender_json_required_only() { - let source_files = vec![ - create_test_source_file( - "rule1", - "Always apply rule 1", - true, - vec!["**/*.ts".to_string(), "**/*.tsx".to_string()], - "rule1 body", - ), - create_test_source_file( - "rule2", - "Always apply rule 2", - true, - vec!["**/*.js".to_string()], - "rule2 body", - ), - ]; - - let result = generate_firebender_json_with_overlay(&source_files, None).unwrap(); - let parsed: Value = serde_json::from_str(&result).unwrap(); - - let rules = parsed["rules"].as_array().unwrap(); - assert_eq!(rules.len(), 2); - - assert_eq!( - rules[0]["rulesPaths"].as_str().unwrap(), - "ai-rules/.generated-ai-rules/ai-rules-generated-rule1.md".to_string() - ); - assert!(rules[0]["filePathMatches"].is_null()); - - assert_eq!( - rules[1]["rulesPaths"].as_str().unwrap(), - "ai-rules/.generated-ai-rules/ai-rules-generated-rule2.md".to_string() - ); - assert!(rules[1]["filePathMatches"].is_null()); - assert!(!parsed[FIREBENDER_USE_CURSOR_RULES_FIELD].as_bool().unwrap()); - } - - #[test] - fn test_generate_firebender_json_optional_only() { - let source_files = vec![create_test_source_file( - "rule1", - "Optional rule 1", - false, - vec!["**/*.ts".to_string()], - "rule1 body", - )]; - - let result = generate_firebender_json_with_overlay(&source_files, None).unwrap(); - let parsed: Value = serde_json::from_str(&result).unwrap(); - - let rules = parsed["rules"].as_array().unwrap(); - assert_eq!(rules.len(), 2); - - assert_eq!( - rules[0]["rulesPaths"].as_str().unwrap(), - "ai-rules/.generated-ai-rules/ai-rules-generated-rule1.md".to_string() - ); - let matches = rules[0]["filePathMatches"].as_array().unwrap(); - assert_eq!(matches.len(), 1); - assert_eq!(matches[0].as_str().unwrap(), "**/*.ts"); - - assert_eq!( - rules[1]["rulesPaths"].as_str().unwrap(), - "ai-rules/.generated-ai-rules/ai-rules-generated-optional.md".to_string() - ); - assert!(rules[1]["filePathMatches"].is_null()); - assert!(!parsed[FIREBENDER_USE_CURSOR_RULES_FIELD].as_bool().unwrap()); - } - - #[test] - fn test_generate_firebender_json_mixed() { - let source_files = vec![ - create_test_source_file( - "always1", - "Always apply rule", - true, - vec!["**/*.ts".to_string()], - "always1 body", - ), - create_test_source_file( - "optional1", - "Optional rule", - false, - vec!["**/*.js".to_string()], - "optional1 body", - ), - ]; - - let result = generate_firebender_json_with_overlay(&source_files, None).unwrap(); - let parsed: Value = serde_json::from_str(&result).unwrap(); - - let rules = parsed["rules"].as_array().unwrap(); - assert_eq!(rules.len(), 3); - - assert_eq!( - rules[0]["rulesPaths"].as_str().unwrap(), - "ai-rules/.generated-ai-rules/ai-rules-generated-always1.md".to_string() - ); - assert!(rules[0]["filePathMatches"].is_null()); - - assert_eq!( - rules[1]["rulesPaths"].as_str().unwrap(), - "ai-rules/.generated-ai-rules/ai-rules-generated-optional1.md".to_string() - ); - let matches_optional = rules[1]["filePathMatches"].as_array().unwrap(); - assert_eq!(matches_optional.len(), 1); - assert_eq!(matches_optional[0].as_str().unwrap(), "**/*.js"); - - assert_eq!( - rules[2]["rulesPaths"].as_str().unwrap(), - "ai-rules/.generated-ai-rules/ai-rules-generated-optional.md".to_string() - ); - assert!(rules[2]["filePathMatches"].is_null()); - assert!(!parsed[FIREBENDER_USE_CURSOR_RULES_FIELD].as_bool().unwrap()); + const TEST_MCP_CONFIG: &str = r#"{ + "mcpServers": { + "test-server": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-test"] } + } +}"#; #[test] - fn test_generate_agent_contents() { + fn test_generate_agent_contents_uses_agents_md() { let generator = FirebenderGenerator; let temp_dir = TempDir::new().unwrap(); let source_files = vec![create_standard_test_source_file()]; @@ -426,283 +243,130 @@ mod tests { let result = generator.generate_agent_contents(&source_files, temp_dir.path()); assert_eq!(result.len(), 1); - let expected_path = temp_dir.path().join(FIREBENDER_JSON); - assert!(result.contains_key(&expected_path)); - + let expected_path = temp_dir.path().join(AGENTS_MD_FILENAME); let content = result.get(&expected_path).unwrap(); - let parsed: Value = serde_json::from_str(content).unwrap(); - assert!(parsed["rules"].is_array()); - assert!(!parsed[FIREBENDER_USE_CURSOR_RULES_FIELD].as_bool().unwrap()); - } - - #[test] - fn test_clean_non_existing_file() { - let generator = FirebenderGenerator; - let temp_dir = TempDir::new().unwrap(); - - let result = generator.clean(temp_dir.path()); - - assert!(result.is_ok()); - assert_file_not_exists(temp_dir.path(), FIREBENDER_JSON); + assert_eq!( + content, + "@ai-rules/.generated-ai-rules/ai-rules-generated-test.md\n" + ); } #[test] - fn test_clean_existing_file() { + fn test_generate_symlink_creates_agents_md_symlink() { let generator = FirebenderGenerator; let temp_dir = TempDir::new().unwrap(); - create_file(temp_dir.path(), FIREBENDER_JSON, "test content"); - assert_file_exists(temp_dir.path(), FIREBENDER_JSON); - - let result = generator.clean(temp_dir.path()); + create_file(temp_dir.path(), "ai-rules/AGENTS.md", "# Shared rules"); - assert!(result.is_ok()); - assert_file_not_exists(temp_dir.path(), FIREBENDER_JSON); - } - - #[test] - fn test_check_empty_source_files_no_file() { - let generator = FirebenderGenerator; - let temp_dir = TempDir::new().unwrap(); - - let result = generator - .check_agent_contents(&[], temp_dir.path()) - .unwrap(); + let result = generator.generate_symlink(temp_dir.path()).unwrap(); - assert!(result); + assert_eq!(result, vec![temp_dir.path().join(AGENTS_MD_FILENAME)]); + assert!(temp_dir.path().join(AGENTS_MD_FILENAME).is_symlink()); } #[test] - fn test_check_empty_source_files_with_file() { + fn test_clean_removes_agents_md() { let generator = FirebenderGenerator; let temp_dir = TempDir::new().unwrap(); - create_file(temp_dir.path(), FIREBENDER_JSON, "stale content"); - - let result = generator - .check_agent_contents(&[], temp_dir.path()) - .unwrap(); + create_file(temp_dir.path(), AGENTS_MD_FILENAME, "generated content"); + generator.clean(temp_dir.path()).unwrap(); - assert!(!result); + assert_file_not_exists(temp_dir.path(), AGENTS_MD_FILENAME); } #[test] - fn test_check_with_matching_content() { - let generator = FirebenderGenerator; + fn test_generate_firebender_config_without_sources_returns_none() { let temp_dir = TempDir::new().unwrap(); - let source_file = create_standard_test_source_file(); - - let expected_content = generate_firebender_json_with_overlay( - slice::from_ref(&source_file), - Some(temp_dir.path()), - ) - .unwrap(); - create_file(temp_dir.path(), FIREBENDER_JSON, &expected_content); - let result = generator - .check_agent_contents(&[source_file], temp_dir.path()) - .unwrap(); + let result = generate_firebender_config(temp_dir.path()).unwrap(); - assert!(result); + assert_eq!(result, None); } #[test] - fn test_check_with_incorrect_content() { - let generator = FirebenderGenerator; + fn test_generate_firebender_config_with_mcp() { let temp_dir = TempDir::new().unwrap(); - let source_file = create_standard_test_source_file(); - - create_file(temp_dir.path(), FIREBENDER_JSON, "wrong content"); + create_file(temp_dir.path(), "ai-rules/mcp.json", TEST_MCP_CONFIG); - let result = generator - .check_agent_contents(&[source_file], temp_dir.path()) + let result = generate_firebender_config(temp_dir.path()) + .unwrap() .unwrap(); + let parsed: Value = serde_json::from_str(&result).unwrap(); - assert!(!result); + assert!(parsed["mcpServers"]["test-server"].is_object()); + assert_eq!(parsed["mcpServers"]["test-server"]["command"], "npx"); } #[test] - fn test_generate_firebender_json_with_overlay() { + fn test_generate_firebender_config_with_overlay_only() { let temp_dir = TempDir::new().unwrap(); - let source_files = vec![create_test_source_file( - "rule1", - "Always apply rule", - true, - vec!["**/*.ts".to_string()], - "rule1 body", - )]; - - let overlay_content = json!({ - "backgroundAgent": { - "copyFiles": ["local.properties", "settings.gradle"] - }, - "customField": "customValue" - }); - let ai_rules_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR); - std::fs::create_dir_all(&ai_rules_dir).unwrap(); create_file( - &ai_rules_dir, - FIREBENDER_OVERLAY_JSON, - &serde_json::to_string_pretty(&overlay_content).unwrap(), + temp_dir.path(), + &format!("{AI_RULE_SOURCE_DIR}/{FIREBENDER_OVERLAY_JSON}"), + r#"{ + "backgroundAgent": { + "copyFiles": ["local.properties"] + } +}"#, ); - let result = - generate_firebender_json_with_overlay(&source_files, Some(temp_dir.path())).unwrap(); + let result = generate_firebender_config(temp_dir.path()) + .unwrap() + .unwrap(); let parsed: Value = serde_json::from_str(&result).unwrap(); - let rules = parsed["rules"].as_array().unwrap(); - assert_eq!(rules.len(), 1); assert_eq!( - rules[0]["rulesPaths"].as_str().unwrap(), - "ai-rules/.generated-ai-rules/ai-rules-generated-rule1.md".to_string() + parsed["backgroundAgent"]["copyFiles"].as_array().unwrap()[0], + "local.properties" ); - assert!(rules[0]["filePathMatches"].is_null()); - assert!(!parsed[FIREBENDER_USE_CURSOR_RULES_FIELD].as_bool().unwrap()); - - assert_eq!(parsed["customField"].as_str().unwrap(), "customValue"); - let background_agent = &parsed["backgroundAgent"]; - let copy_files = background_agent["copyFiles"].as_array().unwrap(); - assert_eq!(copy_files.len(), 2); - assert_eq!(copy_files[0].as_str().unwrap(), "local.properties"); - assert_eq!(copy_files[1].as_str().unwrap(), "settings.gradle"); + assert!(parsed["mcpServers"].is_null()); } #[test] - fn test_generate_firebender_json_without_overlay() { + fn test_generate_firebender_config_merges_overlay_with_mcp() { let temp_dir = TempDir::new().unwrap(); - let source_files = vec![create_test_source_file( - "rule1", - "Always apply rule", - true, - vec!["**/*.ts".to_string()], - "rule1 body", - )]; - - let result = - generate_firebender_json_with_overlay(&source_files, Some(temp_dir.path())).unwrap(); - let parsed: Value = serde_json::from_str(&result).unwrap(); - - let rules = parsed["rules"].as_array().unwrap(); - assert_eq!(rules.len(), 1); - assert!(!parsed[FIREBENDER_USE_CURSOR_RULES_FIELD].as_bool().unwrap()); - assert!(rules[0]["filePathMatches"].is_null()); - - assert!(parsed["backgroundAgent"].is_null()); - assert!(parsed["customField"].is_null()); + create_file(temp_dir.path(), "ai-rules/mcp.json", TEST_MCP_CONFIG); + create_file( + temp_dir.path(), + &format!("{AI_RULE_SOURCE_DIR}/{FIREBENDER_OVERLAY_JSON}"), + r#"{ + "mcpServers": { + "overlay-server": { + "command": "python", + "args": ["-m", "overlay_mcp"] } + }, + "backgroundAgent": { + "copyFiles": ["settings.gradle"] + } +}"#, + ); - #[test] - fn test_generate_symlink_creates_firebender_json() { - let generator = FirebenderGenerator; - let temp_dir = setup_symlink_project(); - write_agents_md(&temp_dir); - - let generated_paths = generator.generate_symlink(temp_dir.path()).unwrap(); - - let firebender_path = temp_dir.path().join(FIREBENDER_JSON); - assert_eq!(generated_paths, vec![firebender_path.clone()]); - assert_file_exists(temp_dir.path(), FIREBENDER_JSON); - - let content = std::fs::read_to_string(&firebender_path).unwrap(); - let parsed: Value = serde_json::from_str(&content).unwrap(); + let result = generate_firebender_config(temp_dir.path()) + .unwrap() + .unwrap(); + let parsed: Value = serde_json::from_str(&result).unwrap(); - let rules = parsed["rules"].as_array().unwrap(); - assert_eq!(rules.len(), 1); + assert_eq!(parsed["mcpServers"]["test-server"]["command"], "npx"); + assert_eq!(parsed["mcpServers"]["overlay-server"]["command"], "python"); assert_eq!( - rules[0]["rulesPaths"].as_str().unwrap(), - format!("{AI_RULE_SOURCE_DIR}/{AGENTS_MD_FILENAME}") + parsed["backgroundAgent"]["copyFiles"].as_array().unwrap()[0], + "settings.gradle" ); - assert!(!parsed[FIREBENDER_USE_CURSOR_RULES_FIELD].as_bool().unwrap()); - } - - #[test] - fn test_generate_symlink_applies_overlay() { - let generator = FirebenderGenerator; - let temp_dir = setup_symlink_project(); - write_agents_md(&temp_dir); - - let overlay_content = json!({ "custom": "value" }); - write_overlay(&temp_dir, &overlay_content); - - let generated_paths = generator.generate_symlink(temp_dir.path()).unwrap(); - assert_eq!(generated_paths.len(), 1); - - let content = std::fs::read_to_string(&generated_paths[0]).unwrap(); - let parsed: Value = serde_json::from_str(&content).unwrap(); - assert_eq!(parsed["custom"].as_str().unwrap(), "value"); } #[test] - fn test_generate_symlink_without_agents_md() { - let generator = FirebenderGenerator; - let temp_dir = setup_symlink_project(); - - let result = generator.generate_symlink(temp_dir.path()).unwrap(); - - assert!(result.is_empty()); - assert_file_not_exists(temp_dir.path(), FIREBENDER_JSON); - } - - #[test] - fn test_check_symlink_with_generated_content() { - let generator = FirebenderGenerator; - let temp_dir = setup_symlink_project(); - write_agents_md(&temp_dir); - - generator.generate_symlink(temp_dir.path()).unwrap(); - - let in_sync = generator.check_symlink(temp_dir.path()).unwrap(); - - assert!(in_sync); - } - - #[test] - fn test_merge_json_objects() { - let mut base = json!({ - "rules": ["rule1"], - "useCursorRules": false, - "nested": { - "field1": "value1" - } - }); - - let overlay = json!({ - "newField": "newValue", - "nested": { - "field2": "value2" - } - }); - - merge_json_objects(&mut base, &overlay); - - assert_eq!(base["newField"].as_str().unwrap(), "newValue"); - - assert!(!base[FIREBENDER_USE_CURSOR_RULES_FIELD].as_bool().unwrap()); - - assert_eq!(base["nested"]["field1"].as_str().unwrap(), "value1"); - assert_eq!(base["nested"]["field2"].as_str().unwrap(), "value2"); - } - - #[test] - fn test_gitignore_patterns_excludes_overlay() { - let generator = FirebenderGenerator; - let patterns = generator.gitignore_patterns(); - - assert_eq!(patterns.len(), 1); - assert!(patterns.contains(&FIREBENDER_JSON.to_string())); - assert!(!patterns.contains(&format!("**/{FIREBENDER_OVERLAY_JSON}"))); - } - - #[test] - fn test_generate_with_malformed_overlay_json() { + fn test_generate_firebender_config_invalid_overlay_errors() { let temp_dir = TempDir::new().unwrap(); - let source_files = vec![create_standard_test_source_file()]; + create_file( + temp_dir.path(), + &format!("{AI_RULE_SOURCE_DIR}/{FIREBENDER_OVERLAY_JSON}"), + "{ invalid json", + ); - let ai_rules_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR); - std::fs::create_dir_all(&ai_rules_dir).unwrap(); - create_file(&ai_rules_dir, FIREBENDER_OVERLAY_JSON, "{ invalid json"); + let result = generate_firebender_config(temp_dir.path()); - let result = generate_firebender_json_with_overlay(&source_files, Some(temp_dir.path())); assert!(result.is_err()); assert!(result .unwrap_err() @@ -711,317 +375,150 @@ mod tests { } #[test] - fn test_clean_with_nonexistent_directory() { - let generator = FirebenderGenerator; - - let nonexistent_path = Path::new("/nonexistent/directory/that/should/not/exist"); - - let result = generator.clean(nonexistent_path); - assert!(result.is_ok()); - } - - #[test] - fn test_generate_agent_contents_with_generation_failure() { - let generator = FirebenderGenerator; - let temp_dir = TempDir::new().unwrap(); - let source_files = vec![create_standard_test_source_file()]; - - let ai_rules_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR); - std::fs::create_dir_all(&ai_rules_dir).unwrap(); - create_file(&ai_rules_dir, FIREBENDER_OVERLAY_JSON, "{ malformed json"); - - let result = generator.generate_agent_contents(&source_files, temp_dir.path()); - - assert!(result.is_empty()); - } - - #[test] - fn test_overlay_merge_with_complex_nesting() { - let mut base = json!({ - "rules": ["rule1"], - FIREBENDER_USE_CURSOR_RULES_FIELD: false, - "nested": { - "level1": { - "field1": "value1", - "field2": "value2" - }, - "array": [1, 2, 3] - } - }); - - let overlay = json!({ - "newTopLevel": "newValue", - "nested": { - "level1": { - "field2": "overridden", - "field3": "added" - }, - "newLevel": { - "newField": "newValue" - } - } - }); - - merge_json_objects(&mut base, &overlay); - - assert_eq!(base["newTopLevel"].as_str().unwrap(), "newValue"); - - assert_eq!( - base["nested"]["level1"]["field1"].as_str().unwrap(), - "value1" - ); - assert_eq!( - base["nested"]["level1"]["field2"].as_str().unwrap(), - "overridden" - ); - assert_eq!( - base["nested"]["level1"]["field3"].as_str().unwrap(), - "added" - ); - assert_eq!( - base["nested"]["newLevel"]["newField"].as_str().unwrap(), - "newValue" - ); - - assert_eq!( - base["nested"]["array"].as_array().unwrap(), - &vec![json!(1), json!(2), json!(3)] - ); - } - - const TEST_MCP_CONFIG: &str = r#"{ - "mcpServers": { - "test-server": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-test"] - } - } -}"#; - - #[test] - fn test_generate_firebender_json_with_mcp() { + fn test_firebender_mcp_generator_generates_firebender_json() { let temp_dir = TempDir::new().unwrap(); let generator = FirebenderGenerator; + let mcp_gen = generator.mcp_generator().unwrap(); create_file(temp_dir.path(), "ai-rules/mcp.json", TEST_MCP_CONFIG); - let source_files = vec![create_standard_test_source_file()]; - - let result = generator.generate_agent_contents(&source_files, temp_dir.path()); - - assert_eq!(result.len(), 1); - let firebender_path = temp_dir.path().join("firebender.json"); - let content = result.get(&firebender_path).unwrap(); - - let json: serde_json::Value = serde_json::from_str(content).unwrap(); + let files = mcp_gen.generate_mcp(temp_dir.path()); - assert!(json["rules"].is_array()); - - // Debug: print the JSON to see what's there - eprintln!( - "Generated JSON: {}", - serde_json::to_string_pretty(&json).unwrap() - ); - - assert!(json["mcpServers"].is_object()); - assert!(json["mcpServers"]["test-server"].is_object()); - assert_eq!(json["mcpServers"]["test-server"]["command"], "npx"); + assert_eq!(files.len(), 1); + let expected_path = temp_dir.path().join(FIREBENDER_JSON); + assert!(files.contains_key(&expected_path)); } #[test] - fn test_generate_firebender_json_without_mcp() { + fn test_firebender_mcp_generator_omits_firebender_json_without_mcp_or_overlay() { let temp_dir = TempDir::new().unwrap(); let generator = FirebenderGenerator; + let mcp_gen = generator.mcp_generator().unwrap(); - let source_files = vec![create_standard_test_source_file()]; - - let result = generator.generate_agent_contents(&source_files, temp_dir.path()); - - assert_eq!(result.len(), 1); - let firebender_path = temp_dir.path().join("firebender.json"); - let content = result.get(&firebender_path).unwrap(); + let files = mcp_gen.generate_mcp(temp_dir.path()); - let json: serde_json::Value = serde_json::from_str(content).unwrap(); - - assert!(json["rules"].is_array()); - - assert!( - json["mcpServers"].is_null() || !json.as_object().unwrap().contains_key("mcpServers") - ); + assert!(files.is_empty()); } #[test] - fn test_generate_firebender_json_mcp_with_overlay() { + fn test_firebender_mcp_generator_check_in_sync() { let temp_dir = TempDir::new().unwrap(); let generator = FirebenderGenerator; + let mcp_gen = generator.mcp_generator().unwrap(); - // Create MCP config create_file(temp_dir.path(), "ai-rules/mcp.json", TEST_MCP_CONFIG); + let expected = generate_firebender_config(temp_dir.path()) + .unwrap() + .unwrap(); + create_file(temp_dir.path(), FIREBENDER_JSON, &expected); - // Create overlay that adds additional MCP server - let overlay = r#"{ - "mcpServers": { - "overlay-server": { - "command": "python", - "args": ["-m", "overlay_mcp"] + assert!(mcp_gen.check_mcp(temp_dir.path()).unwrap()); } - } -}"#; - create_file(temp_dir.path(), "ai-rules/firebender-overlay.json", overlay); - - let source_files = vec![create_standard_test_source_file()]; - - let result = generator.generate_agent_contents(&source_files, temp_dir.path()); - let firebender_path = temp_dir.path().join("firebender.json"); - let content = result.get(&firebender_path).unwrap(); - let json: serde_json::Value = serde_json::from_str(content).unwrap(); - - assert!(json["mcpServers"]["test-server"].is_object()); - assert_eq!(json["mcpServers"]["test-server"]["command"], "npx"); + #[test] + fn test_firebender_has_command_generator() { + let generator = FirebenderGenerator; + let cmd_gen = generator.command_generator().unwrap(); - assert!(json["mcpServers"]["overlay-server"].is_object()); - assert_eq!(json["mcpServers"]["overlay-server"]["command"], "python"); + assert_eq!( + cmd_gen.command_gitignore_patterns(), + vec![".firebender/commands/*-ai-rules.mdc"] + ); } #[test] - fn test_generate_symlink_with_mcp() { + fn test_firebender_command_generator_creates_symlinks() { let temp_dir = TempDir::new().unwrap(); let generator = FirebenderGenerator; + let cmd_gen = generator.command_generator().unwrap(); create_file( temp_dir.path(), - "ai-rules/AGENTS.md", - "# Pure markdown content\n\nNo frontmatter here.", + "ai-rules/commands/commit.md", + "# Commit command", ); - create_file(temp_dir.path(), "ai-rules/mcp.json", TEST_MCP_CONFIG); - let result = generator.generate_symlink(temp_dir.path()).unwrap(); - - assert_eq!(result.len(), 1); - let firebender_path = temp_dir.path().join("firebender.json"); - assert!(firebender_path.exists()); - - let content = std::fs::read_to_string(&firebender_path).unwrap(); - let json: serde_json::Value = serde_json::from_str(&content).unwrap(); - - assert!(json["rules"].as_array().unwrap()[0]["rulesPaths"] - .as_str() - .unwrap() - .contains("AGENTS.md")); + let symlinks = cmd_gen.generate_command_symlinks(temp_dir.path()).unwrap(); - assert!(json["mcpServers"].is_object()); - assert!(json["mcpServers"]["test-server"].is_object()); + assert_eq!(symlinks.len(), 1); + assert!(temp_dir + .path() + .join(".firebender/commands/commit-ai-rules.mdc") + .is_symlink()); } #[test] - fn test_generate_firebender_json_with_commands() { - let temp_dir = TempDir::new().unwrap(); - let source_files = vec![create_standard_test_source_file()]; - - // Create commands directory and command files - let commands_dir = temp_dir.path().join("ai-rules/commands"); - std::fs::create_dir_all(&commands_dir).unwrap(); - create_file( - &commands_dir, - "commit.md", - "# Commit command\n\nCreate a git commit", - ); - create_file( - &commands_dir, - "review.md", - "# Review command\n\nReview the code", - ); - - let result = - generate_firebender_json_with_overlay(&source_files, Some(temp_dir.path())).unwrap(); - let parsed: Value = serde_json::from_str(&result).unwrap(); - - // Verify rules are present - let rules = parsed["rules"].as_array().unwrap(); - assert_eq!(rules.len(), 1); - - // Verify commands array is present - let commands = parsed["commands"].as_array().unwrap(); - assert_eq!(commands.len(), 2); - - // Check first command - assert_eq!(commands[0]["name"].as_str().unwrap(), "commit"); - assert_eq!( - commands[0]["path"].as_str().unwrap(), - "ai-rules/commands/commit.md" - ); + fn test_firebender_has_skills_generator() { + let generator = FirebenderGenerator; + assert!(generator.skills_generator().is_some()); + } - // Check second command - assert_eq!(commands[1]["name"].as_str().unwrap(), "review"); + #[test] + fn test_firebender_skills_gitignore_patterns() { + let generator = FirebenderGenerator; + let skills_gen = generator.skills_generator().unwrap(); assert_eq!( - commands[1]["path"].as_str().unwrap(), - "ai-rules/commands/review.md" + skills_gen.skills_gitignore_patterns(), + vec![".agents/skills/ai-rules-generated-*"] ); } #[test] - fn test_generate_firebender_json_without_commands() { + fn test_firebender_skills_generator_creates_symlinks() { let temp_dir = TempDir::new().unwrap(); - let source_files = vec![create_standard_test_source_file()]; + let generator = FirebenderGenerator; + let skills_gen = generator.skills_generator().unwrap(); - let result = - generate_firebender_json_with_overlay(&source_files, Some(temp_dir.path())).unwrap(); - let parsed: Value = serde_json::from_str(&result).unwrap(); + let skill_dir = temp_dir + .path() + .join(AI_RULE_SOURCE_DIR) + .join(SKILLS_DIR) + .join("debugging"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write(skill_dir.join(SKILL_FILENAME), "# Debugging").unwrap(); - // Verify rules are present - let rules = parsed["rules"].as_array().unwrap(); - assert_eq!(rules.len(), 1); + let symlinks = skills_gen.generate_skills(temp_dir.path()).unwrap(); - // Verify commands array is not present when no commands exist - assert!( - parsed["commands"].is_null() || !parsed.as_object().unwrap().contains_key("commands") - ); + assert_eq!(symlinks.len(), 1); + assert!(temp_dir + .path() + .join(".agents/skills") + .join(format!("{GENERATED_FILE_PREFIX}debugging")) + .is_symlink()); } #[test] - fn test_generate_firebender_json_with_commands_and_frontmatter() { - let temp_dir = TempDir::new().unwrap(); - let source_files = vec![create_standard_test_source_file()]; - - // Create command with frontmatter - let commands_dir = temp_dir.path().join("ai-rules/commands"); - std::fs::create_dir_all(&commands_dir).unwrap(); - let command_with_frontmatter = r#"--- -description: Create a git commit -model: claude-3-5-haiku-20241022 ---- - -# Commit Command + fn test_merge_json_objects_merges_nested_objects() { + let mut base = json!({ + "mcpServers": { + "server-a": { + "command": "npx" + } + }, + "backgroundAgent": { + "copyFiles": ["a"] + } + }); -Create a git commit with proper formatting."#; - create_file(&commands_dir, "commit.md", command_with_frontmatter); + let overlay = json!({ + "mcpServers": { + "server-b": { + "command": "python" + } + }, + "backgroundAgent": { + "otherSetting": true + } + }); - let result = - generate_firebender_json_with_overlay(&source_files, Some(temp_dir.path())).unwrap(); - let parsed: Value = serde_json::from_str(&result).unwrap(); + merge_json_objects(&mut base, &overlay); - // Verify commands array includes the command - let commands = parsed["commands"].as_array().unwrap(); - assert_eq!(commands.len(), 1); - assert_eq!(commands[0]["name"].as_str().unwrap(), "commit"); + assert_eq!(base["mcpServers"]["server-a"]["command"], "npx"); + assert_eq!(base["mcpServers"]["server-b"]["command"], "python"); assert_eq!( - commands[0]["path"].as_str().unwrap(), - "ai-rules/commands/commit.md" + base["backgroundAgent"]["copyFiles"].as_array().unwrap()[0], + "a" ); - } - - #[test] - fn test_firebender_has_skills_generator() { - let generator = FirebenderGenerator; - assert!(generator.skills_generator().is_some()); - } - - #[test] - fn test_firebender_skills_gitignore_patterns() { - let generator = FirebenderGenerator; - let skills_gen = generator.skills_generator().unwrap(); - let patterns = skills_gen.skills_gitignore_patterns(); - assert_eq!(patterns, vec![".firebender/skills/ai-rules-generated-*"]); + assert_eq!(base["backgroundAgent"]["otherSetting"], true); } } diff --git a/src/commands/generate.rs b/src/commands/generate.rs index 87d7841..006963a 100644 --- a/src/commands/generate.rs +++ b/src/commands/generate.rs @@ -713,6 +713,7 @@ New body content"#; let result = run_generate(temp_dir.path(), args); assert!(result.is_ok()); + assert_file_exists(temp_dir.path(), "AGENTS.md"); assert_file_exists(temp_dir.path(), "firebender.json"); assert_file_not_exists(temp_dir.path(), ".mcp.json"); diff --git a/src/constants.rs b/src/constants.rs index 4c58e41..bac4197 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -12,13 +12,11 @@ pub const CLAUDE_SKILLS_DIR: &str = ".claude/skills"; pub const CODEX_SKILLS_DIR: &str = ".codex/skills"; pub const AMP_SKILLS_DIR: &str = ".agents/skills"; pub const CURSOR_SKILLS_DIR: &str = ".cursor/skills"; -pub const FIREBENDER_SKILLS_DIR: &str = ".firebender/skills"; pub const SKILL_FILENAME: &str = "SKILL.md"; pub const SKILLS_DIR: &str = "skills"; pub const FIREBENDER_JSON: &str = "firebender.json"; pub const FIREBENDER_OVERLAY_JSON: &str = "firebender-overlay.json"; -pub const FIREBENDER_USE_CURSOR_RULES_FIELD: &str = "useCursorRules"; pub const MCP_JSON: &str = "mcp.json"; pub const CLAUDE_MCP_JSON: &str = ".mcp.json"; @@ -30,6 +28,7 @@ pub const CLAUDE_COMMANDS_SUBDIR: &str = "ai-rules"; pub const CURSOR_COMMANDS_DIR: &str = ".cursor/commands"; pub const CURSOR_COMMANDS_SUBDIR: &str = "ai-rules"; pub const AMP_COMMANDS_DIR: &str = ".agents/commands"; +pub const FIREBENDER_COMMANDS_DIR: &str = ".firebender/commands"; // Embedded template content (compile-time inclusion) pub const OPTIONAL_RULES_TEMPLATE: &str = include_str!("templates/optional_rules.md"); diff --git a/src/operations/command_reader.rs b/src/operations/command_reader.rs index 6044bd1..d1e7cc2 100644 --- a/src/operations/command_reader.rs +++ b/src/operations/command_reader.rs @@ -44,7 +44,11 @@ pub fn find_command_files(current_dir: &Path) -> Result> { } /// Creates individual symlinks for each command file in the target directory -pub fn create_command_symlinks(current_dir: &Path, target_dir: &str) -> Result> { +pub fn create_command_symlinks_with_extension( + current_dir: &Path, + target_dir: &str, + extension: &str, +) -> Result> { let command_files = find_command_files(current_dir)?; if command_files.is_empty() { return Ok(Vec::new()); @@ -53,7 +57,10 @@ pub fn create_command_symlinks(current_dir: &Path, target_dir: &str) -> Result Result Result<()> { +pub fn remove_generated_command_symlinks_with_extension( + current_dir: &Path, + target_dir: &str, + extension: &str, +) -> Result<()> { use std::fs; let target_path = current_dir.join(target_dir); @@ -80,7 +91,7 @@ pub fn remove_generated_command_symlinks(current_dir: &Path, target_dir: &str) - if let Some(file_name) = path.file_name() { if let Some(name_str) = file_name.to_str() { - let suffix_pattern = format!("-{}.md", GENERATED_COMMAND_SUFFIX); + let suffix_pattern = format!("-{}.{}", GENERATED_COMMAND_SUFFIX, extension); if name_str.ends_with(&suffix_pattern) && path.is_symlink() { fs::remove_file(&path)?; } @@ -92,7 +103,11 @@ pub fn remove_generated_command_symlinks(current_dir: &Path, target_dir: &str) - } /// Checks if generated command symlinks are in sync -pub fn check_command_symlinks_in_sync(current_dir: &Path, target_dir: &str) -> Result { +pub fn check_command_symlinks_in_sync_with_extension( + current_dir: &Path, + target_dir: &str, + extension: &str, +) -> Result { use std::fs; let command_files = find_command_files(current_dir)?; @@ -109,7 +124,7 @@ pub fn check_command_symlinks_in_sync(current_dir: &Path, target_dir: &str) -> R if let Some(file_name) = path.file_name() { if let Some(name_str) = file_name.to_str() { - let suffix_pattern = format!("-{}.md", GENERATED_COMMAND_SUFFIX); + let suffix_pattern = format!("-{}.{}", GENERATED_COMMAND_SUFFIX, extension); if name_str.ends_with(&suffix_pattern) && path.is_symlink() { return Ok(false); } @@ -120,7 +135,10 @@ pub fn check_command_symlinks_in_sync(current_dir: &Path, target_dir: &str) -> R } for command_file in command_files { - let symlink_name = format!("{}-{}.md", command_file.name, GENERATED_COMMAND_SUFFIX); + let symlink_name = format!( + "{}-{}.{}", + command_file.name, GENERATED_COMMAND_SUFFIX, extension + ); let symlink_path = target_path.join(&symlink_name); if !symlink_path.is_symlink() { @@ -150,8 +168,14 @@ pub fn check_command_symlinks_in_sync(current_dir: &Path, target_dir: &str) -> R } /// Returns gitignore patterns for generated command symlinks -pub fn get_command_gitignore_patterns(target_dir: &str) -> Vec { - vec![format!("{}/*-{}.md", target_dir, GENERATED_COMMAND_SUFFIX)] +pub fn get_command_gitignore_patterns_with_extension( + target_dir: &str, + extension: &str, +) -> Vec { + vec![format!( + "{}/*-{}.{}", + target_dir, GENERATED_COMMAND_SUFFIX, extension + )] } // === Subfolder-based command symlinks (for Claude) === @@ -303,7 +327,9 @@ mod tests { fs::write(commands_dir.join("commit.md"), "Commit command").unwrap(); fs::write(commands_dir.join("review.md"), "Review command").unwrap(); - let symlinks = create_command_symlinks(temp_dir.path(), ".claude/commands").unwrap(); + let symlinks = + create_command_symlinks_with_extension(temp_dir.path(), ".claude/commands", "md") + .unwrap(); assert_eq!(symlinks.len(), 2); let commit_symlink = temp_dir @@ -323,12 +349,13 @@ mod tests { fs::create_dir_all(&commands_dir).unwrap(); fs::write(commands_dir.join("test.md"), "Test").unwrap(); - create_command_symlinks(temp_dir.path(), ".claude/commands").unwrap(); + create_command_symlinks_with_extension(temp_dir.path(), ".claude/commands", "md").unwrap(); let commands_path = temp_dir.path().join(".claude/commands"); fs::write(commands_path.join("custom.md"), "User's custom command").unwrap(); - remove_generated_command_symlinks(temp_dir.path(), ".claude/commands").unwrap(); + remove_generated_command_symlinks_with_extension(temp_dir.path(), ".claude/commands", "md") + .unwrap(); let generated = commands_path.join(format!("test-{}.md", GENERATED_COMMAND_SUFFIX)); assert!(!generated.exists());