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());