diff --git a/Example/.gitignore b/Example/.gitignore index e5a3e07..ad92b22 100644 --- a/Example/.gitignore +++ b/Example/.gitignore @@ -8,6 +8,7 @@ **/.cursor/commands/ai-rules/ **/.cursor/mcp.json **/.cursor/rules/ +**/.cursor/skills/ai-rules-generated-* **/.firebender/skills/ai-rules-generated-* **/.gemini/settings.json **/.mcp.json diff --git a/src/agents/amp.rs b/src/agents/amp.rs index 2d74881..f822971 100644 --- a/src/agents/amp.rs +++ b/src/agents/amp.rs @@ -1,12 +1,12 @@ -use crate::agents::amp_command_generator::AmpCommandGenerator; use crate::agents::command_generator::CommandGeneratorTrait; +use crate::agents::external_commands_generator::ExternalCommandsGenerator; use crate::agents::external_skills_generator::ExternalSkillsGenerator; 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, AMP_SKILLS_DIR}; +use crate::constants::{AGENTS_MD_FILENAME, AMP_COMMANDS_DIR, AMP_SKILLS_DIR}; use crate::models::SourceFile; use crate::utils::file_utils::{check_agents_md_symlink, create_symlink_to_agents_md}; use anyhow::Result; @@ -59,7 +59,7 @@ impl AgentRuleGenerator for AmpGenerator { } fn command_generator(&self) -> Option> { - Some(Box::new(AmpCommandGenerator)) + Some(Box::new(ExternalCommandsGenerator::new(AMP_COMMANDS_DIR))) } fn skills_generator(&self) -> Option> { @@ -104,13 +104,18 @@ mod tests { let generator = AmpGenerator; let cmd_gen = generator.command_generator().unwrap(); - let files = cmd_gen.generate_commands(temp_dir.path()); - assert_eq!(files.len(), 1); + // generate_command_symlinks creates symlinks (flat structure with -ai-rules.md suffix) + let symlinks = cmd_gen.generate_command_symlinks(temp_dir.path()).unwrap(); + assert_eq!(symlinks.len(), 1); - // Verify frontmatter is stripped - let (_, content) = files.iter().next().unwrap(); - assert!(!content.contains("---")); + // Verify symlink was created with correct naming + let symlink_path = temp_dir.path().join(".agents/commands/test-ai-rules.md"); + assert!(symlink_path.is_symlink()); + + // Verify symlink points to source with frontmatter preserved + let content = fs::read_to_string(&symlink_path).unwrap(); + assert!(content.contains("---")); assert!(content.contains("Command content")); } } diff --git a/src/agents/amp_command_generator.rs b/src/agents/amp_command_generator.rs deleted file mode 100644 index cd81c83..0000000 --- a/src/agents/amp_command_generator.rs +++ /dev/null @@ -1,240 +0,0 @@ -use crate::agents::command_generator::CommandGeneratorTrait; -use crate::constants::{AMP_COMMANDS_DIR, GENERATED_COMMAND_SUFFIX}; -use crate::operations::{find_command_files, get_command_body_content}; -use crate::utils::file_utils::check_directory_files_match; -use anyhow::Result; -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; - -pub struct AmpCommandGenerator; - -impl CommandGeneratorTrait for AmpCommandGenerator { - fn generate_commands(&self, current_dir: &Path) -> HashMap { - let mut files = HashMap::new(); - - let command_files = match find_command_files(current_dir) { - Ok(files) => files, - Err(_) => return files, - }; - - if command_files.is_empty() { - return files; - } - - let commands_dir = current_dir.join(AMP_COMMANDS_DIR); - - for command in command_files { - let output_name = format!("{}-{}.md", command.name, GENERATED_COMMAND_SUFFIX); - let output_path = commands_dir.join(&output_name); - - // Strip frontmatter for AMP - let content = get_command_body_content(&command); - files.insert(output_path, content); - } - - files - } - - fn clean_commands(&self, current_dir: &Path) -> Result<()> { - let commands_dir = current_dir.join(AMP_COMMANDS_DIR); - if !commands_dir.exists() { - return Ok(()); - } - - for entry in fs::read_dir(&commands_dir)? { - let entry = entry?; - let path = entry.path(); - - 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); - if name_str.ends_with(&suffix_pattern) { - fs::remove_file(&path)?; - } - } - } - } - - // Remove empty directory - if commands_dir.exists() && fs::read_dir(&commands_dir)?.next().is_none() { - fs::remove_dir(&commands_dir)?; - } - - // Remove empty parent directory (.agents) if it exists and is empty - let parent_dir = current_dir.join(".agents"); - if parent_dir.exists() && fs::read_dir(&parent_dir)?.next().is_none() { - fs::remove_dir(&parent_dir)?; - } - - Ok(()) - } - - fn check_commands(&self, current_dir: &Path) -> Result { - let command_files = find_command_files(current_dir)?; - let commands_dir = current_dir.join(AMP_COMMANDS_DIR); - - if command_files.is_empty() { - // No commands - directory should not exist or be empty of generated files - if !commands_dir.exists() { - return Ok(true); - } - for entry in fs::read_dir(&commands_dir)? { - let entry = entry?; - if let Some(name) = entry.file_name().to_str() { - let suffix_pattern = format!("-{}.md", GENERATED_COMMAND_SUFFIX); - if name.ends_with(&suffix_pattern) { - return Ok(false); - } - } - } - return Ok(true); - } - - let expected_files = self.generate_commands(current_dir); - check_directory_files_match(&commands_dir, &expected_files, GENERATED_COMMAND_SUFFIX) - } - - fn command_gitignore_patterns(&self) -> Vec { - vec![format!( - "{}/*-{}.md", - AMP_COMMANDS_DIR, GENERATED_COMMAND_SUFFIX - )] - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::constants::{AI_RULE_SOURCE_DIR, COMMANDS_DIR}; - use std::fs; - use tempfile::TempDir; - - #[test] - fn test_generate_commands_empty_when_no_commands() { - let temp_dir = TempDir::new().unwrap(); - let generator = AmpCommandGenerator; - - let files = generator.generate_commands(temp_dir.path()); - assert_eq!(files.len(), 0); - } - - #[test] - fn test_generate_commands_strips_frontmatter() { - let temp_dir = TempDir::new().unwrap(); - let commands_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR).join(COMMANDS_DIR); - fs::create_dir_all(&commands_dir).unwrap(); - - let command_content = - "---\nallowed-tools: Bash(git:*)\ndescription: Test command\n---\n\nCommand body"; - fs::write(commands_dir.join("test.md"), command_content).unwrap(); - - let generator = AmpCommandGenerator; - let files = generator.generate_commands(temp_dir.path()); - - assert_eq!(files.len(), 1); - let output_path = temp_dir - .path() - .join(AMP_COMMANDS_DIR) - .join("test-ai-rules.md"); - assert!(files.contains_key(&output_path)); - - // Verify frontmatter is stripped - let content = files.get(&output_path).unwrap(); - assert!(!content.contains("---")); - assert!(!content.contains("allowed-tools: Bash(git:*)")); - assert!(!content.contains("description: Test command")); - assert!(content.contains("Command body")); - assert_eq!(content.trim(), "Command body"); - } - - #[test] - fn test_clean_commands_removes_generated_files() { - let temp_dir = TempDir::new().unwrap(); - let commands_dir = temp_dir.path().join(AMP_COMMANDS_DIR); - fs::create_dir_all(&commands_dir).unwrap(); - - fs::write(commands_dir.join("test-ai-rules.md"), "generated").unwrap(); - fs::write(commands_dir.join("custom.md"), "user file").unwrap(); - - let generator = AmpCommandGenerator; - generator.clean_commands(temp_dir.path()).unwrap(); - - assert!(!commands_dir.join("test-ai-rules.md").exists()); - assert!(commands_dir.join("custom.md").exists()); - } - - #[test] - fn test_clean_commands_removes_empty_directories() { - let temp_dir = TempDir::new().unwrap(); - let commands_dir = temp_dir.path().join(AMP_COMMANDS_DIR); - fs::create_dir_all(&commands_dir).unwrap(); - - fs::write(commands_dir.join("test-ai-rules.md"), "generated").unwrap(); - - let generator = AmpCommandGenerator; - generator.clean_commands(temp_dir.path()).unwrap(); - - // Both .agents/commands and .agents should be removed - assert!(!commands_dir.exists()); - assert!(!temp_dir.path().join(".agents").exists()); - } - - #[test] - fn test_check_commands_in_sync() { - let temp_dir = TempDir::new().unwrap(); - let source_commands_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR).join(COMMANDS_DIR); - fs::create_dir_all(&source_commands_dir).unwrap(); - - fs::write(source_commands_dir.join("test.md"), "Test command").unwrap(); - - let generator = AmpCommandGenerator; - - // Not in sync initially - assert!(!generator.check_commands(temp_dir.path()).unwrap()); - - // Generate files - let files = generator.generate_commands(temp_dir.path()); - for (path, content) in files { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap(); - } - fs::write(&path, &content).unwrap(); - } - - // Now in sync - assert!(generator.check_commands(temp_dir.path()).unwrap()); - } - - #[test] - fn test_check_commands_detects_extra_files() { - let temp_dir = TempDir::new().unwrap(); - let source_commands_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR).join(COMMANDS_DIR); - let target_commands_dir = temp_dir.path().join(AMP_COMMANDS_DIR); - fs::create_dir_all(&source_commands_dir).unwrap(); - fs::create_dir_all(&target_commands_dir).unwrap(); - - fs::write(source_commands_dir.join("test.md"), "Test").unwrap(); - - let generator = AmpCommandGenerator; - let files = generator.generate_commands(temp_dir.path()); - for (path, content) in files { - fs::write(&path, &content).unwrap(); - } - - // Add extra generated file - fs::write(target_commands_dir.join("extra-ai-rules.md"), "extra").unwrap(); - - // Should detect out of sync - assert!(!generator.check_commands(temp_dir.path()).unwrap()); - } - - #[test] - fn test_command_gitignore_patterns() { - let generator = AmpCommandGenerator; - let patterns = generator.command_gitignore_patterns(); - - assert_eq!(patterns.len(), 1); - assert_eq!(patterns[0], ".agents/commands/*-ai-rules.md"); - } -} diff --git a/src/agents/claude.rs b/src/agents/claude.rs index ee1962f..4fd9caa 100644 --- a/src/agents/claude.rs +++ b/src/agents/claude.rs @@ -1,10 +1,13 @@ -use crate::agents::claude_command_generator::ClaudeCommandGenerator; 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::{ExternalMcpGenerator, McpGeneratorTrait}; use crate::agents::rule_generator::AgentRuleGenerator; use crate::agents::skills_generator::SkillsGeneratorTrait; -use crate::constants::{CLAUDE_MCP_JSON, CLAUDE_SKILLS_DIR, GENERATED_FILE_PREFIX}; +use crate::constants::{ + CLAUDE_COMMANDS_DIR, CLAUDE_COMMANDS_SUBDIR, CLAUDE_MCP_JSON, CLAUDE_SKILLS_DIR, + GENERATED_FILE_PREFIX, +}; use crate::models::source_file::SourceFile; use crate::operations::{ claude_skills, generate_all_rule_references, generate_required_rule_references, @@ -137,7 +140,10 @@ impl AgentRuleGenerator for ClaudeGenerator { } fn command_generator(&self) -> Option> { - Some(Box::new(ClaudeCommandGenerator)) + Some(Box::new(ExternalCommandsGenerator::with_subdir( + CLAUDE_COMMANDS_DIR, + CLAUDE_COMMANDS_SUBDIR, + ))) } fn skills_generator(&self) -> Option> { diff --git a/src/agents/claude_command_generator.rs b/src/agents/claude_command_generator.rs deleted file mode 100644 index 91297b2..0000000 --- a/src/agents/claude_command_generator.rs +++ /dev/null @@ -1,232 +0,0 @@ -use crate::agents::command_generator::CommandGeneratorTrait; -use crate::constants::{CLAUDE_COMMANDS_DIR, GENERATED_COMMANDS_SUBDIR}; -use crate::operations::find_command_files; -use crate::utils::file_utils::ensure_trailing_newline; -use anyhow::Result; -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; - -pub struct ClaudeCommandGenerator; - -impl CommandGeneratorTrait for ClaudeCommandGenerator { - fn generate_commands(&self, current_dir: &Path) -> HashMap { - let mut files = HashMap::new(); - - let command_files = match find_command_files(current_dir) { - Ok(files) => files, - Err(_) => return files, - }; - - if command_files.is_empty() { - return files; - } - - let commands_dir = current_dir - .join(CLAUDE_COMMANDS_DIR) - .join(GENERATED_COMMANDS_SUBDIR); - - for command in command_files { - let output_name = format!("{}.md", command.name); - let output_path = commands_dir.join(&output_name); - - // Preserve full content including frontmatter for Claude - let content = ensure_trailing_newline(command.raw_content.clone()); - files.insert(output_path, content); - } - - files - } - - fn clean_commands(&self, current_dir: &Path) -> Result<()> { - let commands_subdir = current_dir - .join(CLAUDE_COMMANDS_DIR) - .join(GENERATED_COMMANDS_SUBDIR); - if commands_subdir.exists() { - fs::remove_dir_all(&commands_subdir)?; - } - Ok(()) - } - - fn check_commands(&self, current_dir: &Path) -> Result { - let command_files = find_command_files(current_dir)?; - let commands_subdir = current_dir - .join(CLAUDE_COMMANDS_DIR) - .join(GENERATED_COMMANDS_SUBDIR); - - if command_files.is_empty() { - // No commands - subfolder should not exist - return Ok(!commands_subdir.exists()); - } - - // Check all expected files exist with correct content - let expected_files = self.generate_commands(current_dir); - for (path, expected_content) in &expected_files { - if !path.exists() { - return Ok(false); - } - let actual_content = fs::read_to_string(path)?; - if actual_content != *expected_content { - return Ok(false); - } - } - - // Check no extra files exist in subfolder - if commands_subdir.exists() { - for entry in fs::read_dir(&commands_subdir)? { - let entry = entry?; - let path = entry.path(); - if path.is_file() && !expected_files.contains_key(&path) { - return Ok(false); - } - } - } - - Ok(true) - } - - fn command_gitignore_patterns(&self) -> Vec { - vec![format!( - "{}/{}/", - CLAUDE_COMMANDS_DIR, GENERATED_COMMANDS_SUBDIR - )] - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::constants::{AI_RULE_SOURCE_DIR, COMMANDS_DIR}; - use std::fs; - use tempfile::TempDir; - - #[test] - fn test_generate_commands_empty_when_no_commands() { - let temp_dir = TempDir::new().unwrap(); - let generator = ClaudeCommandGenerator; - - let files = generator.generate_commands(temp_dir.path()); - assert_eq!(files.len(), 0); - } - - #[test] - fn test_generate_commands_with_frontmatter() { - let temp_dir = TempDir::new().unwrap(); - let commands_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR).join(COMMANDS_DIR); - fs::create_dir_all(&commands_dir).unwrap(); - - let command_content = - "---\nallowed-tools: Bash(git:*)\ndescription: Test command\n---\n\nCommand body"; - fs::write(commands_dir.join("test.md"), command_content).unwrap(); - - let generator = ClaudeCommandGenerator; - let files = generator.generate_commands(temp_dir.path()); - - assert_eq!(files.len(), 1); - let output_path = temp_dir - .path() - .join(CLAUDE_COMMANDS_DIR) - .join("ai-rules") - .join("test.md"); - assert!(files.contains_key(&output_path)); - - // Verify frontmatter is preserved - let content = files.get(&output_path).unwrap(); - assert!(content.contains("---")); - assert!(content.contains("allowed-tools: Bash(git:*)")); - assert!(content.contains("description: Test command")); - assert!(content.contains("Command body")); - } - - #[test] - fn test_clean_commands_removes_generated_files() { - let temp_dir = TempDir::new().unwrap(); - let commands_dir = temp_dir.path().join(CLAUDE_COMMANDS_DIR); - let ai_rules_subdir = commands_dir.join("ai-rules"); - fs::create_dir_all(&ai_rules_subdir).unwrap(); - - fs::write(ai_rules_subdir.join("test.md"), "generated").unwrap(); - fs::write(commands_dir.join("custom.md"), "user file").unwrap(); - - let generator = ClaudeCommandGenerator; - generator.clean_commands(temp_dir.path()).unwrap(); - - assert!(!ai_rules_subdir.exists()); - assert!(commands_dir.join("custom.md").exists()); - } - - #[test] - fn test_clean_commands_removes_empty_directory() { - let temp_dir = TempDir::new().unwrap(); - let ai_rules_subdir = temp_dir.path().join(CLAUDE_COMMANDS_DIR).join("ai-rules"); - fs::create_dir_all(&ai_rules_subdir).unwrap(); - - fs::write(ai_rules_subdir.join("test.md"), "generated").unwrap(); - - let generator = ClaudeCommandGenerator; - generator.clean_commands(temp_dir.path()).unwrap(); - - assert!(!ai_rules_subdir.exists()); - } - - #[test] - fn test_check_commands_in_sync() { - let temp_dir = TempDir::new().unwrap(); - let source_commands_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR).join(COMMANDS_DIR); - fs::create_dir_all(&source_commands_dir).unwrap(); - - fs::write(source_commands_dir.join("test.md"), "Test command").unwrap(); - - let generator = ClaudeCommandGenerator; - - // Not in sync initially - assert!(!generator.check_commands(temp_dir.path()).unwrap()); - - // Generate files - let files = generator.generate_commands(temp_dir.path()); - for (path, content) in files { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap(); - } - fs::write(&path, &content).unwrap(); - } - - // Now in sync - assert!(generator.check_commands(temp_dir.path()).unwrap()); - } - - #[test] - fn test_check_commands_detects_extra_files() { - let temp_dir = TempDir::new().unwrap(); - let source_commands_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR).join(COMMANDS_DIR); - let target_commands_subdir = temp_dir.path().join(CLAUDE_COMMANDS_DIR).join("ai-rules"); - fs::create_dir_all(&source_commands_dir).unwrap(); - fs::create_dir_all(&target_commands_subdir).unwrap(); - - fs::write(source_commands_dir.join("test.md"), "Test").unwrap(); - - let generator = ClaudeCommandGenerator; - let files = generator.generate_commands(temp_dir.path()); - for (path, content) in files { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap(); - } - fs::write(&path, &content).unwrap(); - } - - // Add extra generated file - fs::write(target_commands_subdir.join("extra.md"), "extra").unwrap(); - - // Should detect out of sync - assert!(!generator.check_commands(temp_dir.path()).unwrap()); - } - - #[test] - fn test_command_gitignore_patterns() { - let generator = ClaudeCommandGenerator; - let patterns = generator.command_gitignore_patterns(); - - assert_eq!(patterns.len(), 1); - assert_eq!(patterns[0], ".claude/commands/ai-rules/"); - } -} diff --git a/src/agents/command_generator.rs b/src/agents/command_generator.rs index c9098f4..536093b 100644 --- a/src/agents/command_generator.rs +++ b/src/agents/command_generator.rs @@ -1,19 +1,17 @@ use anyhow::Result; -use std::collections::HashMap; use std::path::{Path, PathBuf}; pub trait CommandGeneratorTrait { - /// Generate command files for this agent - /// Returns HashMap of output path -> content - fn generate_commands(&self, current_dir: &Path) -> HashMap; + /// Generate command symlinks for this agent + /// Returns Vec of created symlink paths + fn generate_command_symlinks(&self, current_dir: &Path) -> Result>; - /// Clean generated command files + /// Clean generated command files/symlinks fn clean_commands(&self, current_dir: &Path) -> Result<()>; - /// Check if command files are in sync + /// Check if command files/symlinks are in sync fn check_commands(&self, current_dir: &Path) -> Result; /// Get gitignore patterns for generated commands - #[allow(dead_code)] fn command_gitignore_patterns(&self) -> Vec; } diff --git a/src/agents/cursor.rs b/src/agents/cursor.rs index 89ef2d5..60b7978 100644 --- a/src/agents/cursor.rs +++ b/src/agents/cursor.rs @@ -1,10 +1,13 @@ use crate::agents::command_generator::CommandGeneratorTrait; -use crate::agents::cursor_command_generator::CursorCommandGenerator; +use crate::agents::external_commands_generator::ExternalCommandsGenerator; use crate::agents::external_skills_generator::ExternalSkillsGenerator; use crate::agents::mcp_generator::{ExternalMcpGenerator, McpGeneratorTrait}; use crate::agents::rule_generator::AgentRuleGenerator; use crate::agents::skills_generator::SkillsGeneratorTrait; -use crate::constants::{AGENTS_MD_FILENAME, CURSOR_SKILLS_DIR, GENERATED_FILE_PREFIX, MCP_JSON}; +use crate::constants::{ + AGENTS_MD_FILENAME, CURSOR_COMMANDS_DIR, CURSOR_COMMANDS_SUBDIR, CURSOR_SKILLS_DIR, + GENERATED_FILE_PREFIX, MCP_JSON, +}; use crate::models::SourceFile; use crate::utils::file_utils::{ check_agents_md_symlink, check_directory_exact_match, create_symlink_to_agents_md, @@ -110,7 +113,10 @@ impl AgentRuleGenerator for CursorGenerator { } fn command_generator(&self) -> Option> { - Some(Box::new(CursorCommandGenerator)) + Some(Box::new(ExternalCommandsGenerator::with_subdir( + CURSOR_COMMANDS_DIR, + CURSOR_COMMANDS_SUBDIR, + ))) } fn skills_generator(&self) -> Option> { diff --git a/src/agents/cursor_command_generator.rs b/src/agents/cursor_command_generator.rs deleted file mode 100644 index 6703dc4..0000000 --- a/src/agents/cursor_command_generator.rs +++ /dev/null @@ -1,232 +0,0 @@ -use crate::agents::command_generator::CommandGeneratorTrait; -use crate::constants::{CURSOR_COMMANDS_DIR, GENERATED_COMMANDS_SUBDIR}; -use crate::operations::{find_command_files, get_command_body_content}; -use anyhow::Result; -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; - -pub struct CursorCommandGenerator; - -impl CommandGeneratorTrait for CursorCommandGenerator { - fn generate_commands(&self, current_dir: &Path) -> HashMap { - let mut files = HashMap::new(); - - let command_files = match find_command_files(current_dir) { - Ok(files) => files, - Err(_) => return files, - }; - - if command_files.is_empty() { - return files; - } - - let commands_dir = current_dir - .join(CURSOR_COMMANDS_DIR) - .join(GENERATED_COMMANDS_SUBDIR); - - for command in command_files { - let output_name = format!("{}.md", command.name); - let output_path = commands_dir.join(&output_name); - - // Strip frontmatter for Cursor - let content = get_command_body_content(&command); - files.insert(output_path, content); - } - - files - } - - fn clean_commands(&self, current_dir: &Path) -> Result<()> { - let commands_subdir = current_dir - .join(CURSOR_COMMANDS_DIR) - .join(GENERATED_COMMANDS_SUBDIR); - if commands_subdir.exists() { - fs::remove_dir_all(&commands_subdir)?; - } - Ok(()) - } - - fn check_commands(&self, current_dir: &Path) -> Result { - let command_files = find_command_files(current_dir)?; - let commands_subdir = current_dir - .join(CURSOR_COMMANDS_DIR) - .join(GENERATED_COMMANDS_SUBDIR); - - if command_files.is_empty() { - // No commands - subfolder should not exist - return Ok(!commands_subdir.exists()); - } - - // Check all expected files exist with correct content - let expected_files = self.generate_commands(current_dir); - for (path, expected_content) in &expected_files { - if !path.exists() { - return Ok(false); - } - let actual_content = fs::read_to_string(path)?; - if actual_content != *expected_content { - return Ok(false); - } - } - - // Check no extra files exist in subfolder - if commands_subdir.exists() { - for entry in fs::read_dir(&commands_subdir)? { - let entry = entry?; - let path = entry.path(); - if path.is_file() && !expected_files.contains_key(&path) { - return Ok(false); - } - } - } - - Ok(true) - } - - fn command_gitignore_patterns(&self) -> Vec { - vec![format!( - "{}/{}/", - CURSOR_COMMANDS_DIR, GENERATED_COMMANDS_SUBDIR - )] - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::constants::{AI_RULE_SOURCE_DIR, COMMANDS_DIR}; - use std::fs; - use tempfile::TempDir; - - #[test] - fn test_generate_commands_empty_when_no_commands() { - let temp_dir = TempDir::new().unwrap(); - let generator = CursorCommandGenerator; - - let files = generator.generate_commands(temp_dir.path()); - assert_eq!(files.len(), 0); - } - - #[test] - fn test_generate_commands_strips_frontmatter() { - let temp_dir = TempDir::new().unwrap(); - let commands_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR).join(COMMANDS_DIR); - fs::create_dir_all(&commands_dir).unwrap(); - - let command_content = - "---\nallowed-tools: Bash(git:*)\ndescription: Test command\n---\n\nCommand body"; - fs::write(commands_dir.join("test.md"), command_content).unwrap(); - - let generator = CursorCommandGenerator; - let files = generator.generate_commands(temp_dir.path()); - - assert_eq!(files.len(), 1); - let output_path = temp_dir - .path() - .join(CURSOR_COMMANDS_DIR) - .join("ai-rules") - .join("test.md"); - assert!(files.contains_key(&output_path)); - - // Verify frontmatter is stripped - let content = files.get(&output_path).unwrap(); - assert!(!content.contains("---")); - assert!(!content.contains("allowed-tools: Bash(git:*)")); - assert!(!content.contains("description: Test command")); - assert!(content.contains("Command body")); - assert_eq!(content.trim(), "Command body"); - } - - #[test] - fn test_clean_commands_removes_generated_files() { - let temp_dir = TempDir::new().unwrap(); - let commands_dir = temp_dir.path().join(CURSOR_COMMANDS_DIR); - let ai_rules_subdir = commands_dir.join("ai-rules"); - fs::create_dir_all(&ai_rules_subdir).unwrap(); - - fs::write(ai_rules_subdir.join("test.md"), "generated").unwrap(); - fs::write(commands_dir.join("custom.md"), "user file").unwrap(); - - let generator = CursorCommandGenerator; - generator.clean_commands(temp_dir.path()).unwrap(); - - assert!(!ai_rules_subdir.exists()); - assert!(commands_dir.join("custom.md").exists()); - } - - #[test] - fn test_clean_commands_removes_empty_directory() { - let temp_dir = TempDir::new().unwrap(); - let ai_rules_subdir = temp_dir.path().join(CURSOR_COMMANDS_DIR).join("ai-rules"); - fs::create_dir_all(&ai_rules_subdir).unwrap(); - - fs::write(ai_rules_subdir.join("test.md"), "generated").unwrap(); - - let generator = CursorCommandGenerator; - generator.clean_commands(temp_dir.path()).unwrap(); - - assert!(!ai_rules_subdir.exists()); - } - - #[test] - fn test_check_commands_in_sync() { - let temp_dir = TempDir::new().unwrap(); - let source_commands_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR).join(COMMANDS_DIR); - fs::create_dir_all(&source_commands_dir).unwrap(); - - fs::write(source_commands_dir.join("test.md"), "Test command").unwrap(); - - let generator = CursorCommandGenerator; - - // Not in sync initially - assert!(!generator.check_commands(temp_dir.path()).unwrap()); - - // Generate files - let files = generator.generate_commands(temp_dir.path()); - for (path, content) in files { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap(); - } - fs::write(&path, &content).unwrap(); - } - - // Now in sync - assert!(generator.check_commands(temp_dir.path()).unwrap()); - } - - #[test] - fn test_check_commands_detects_extra_files() { - let temp_dir = TempDir::new().unwrap(); - let source_commands_dir = temp_dir.path().join(AI_RULE_SOURCE_DIR).join(COMMANDS_DIR); - let target_commands_subdir = temp_dir.path().join(CURSOR_COMMANDS_DIR).join("ai-rules"); - fs::create_dir_all(&source_commands_dir).unwrap(); - fs::create_dir_all(&target_commands_subdir).unwrap(); - - fs::write(source_commands_dir.join("test.md"), "Test").unwrap(); - - let generator = CursorCommandGenerator; - let files = generator.generate_commands(temp_dir.path()); - for (path, content) in files { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).unwrap(); - } - fs::write(&path, &content).unwrap(); - } - - // Add extra generated file - fs::write(target_commands_subdir.join("extra.md"), "extra").unwrap(); - - // Should detect out of sync - assert!(!generator.check_commands(temp_dir.path()).unwrap()); - } - - #[test] - fn test_command_gitignore_patterns() { - let generator = CursorCommandGenerator; - let patterns = generator.command_gitignore_patterns(); - - assert_eq!(patterns.len(), 1); - assert_eq!(patterns[0], ".cursor/commands/ai-rules/"); - } -} diff --git a/src/agents/external_commands_generator.rs b/src/agents/external_commands_generator.rs new file mode 100644 index 0000000..af9a9cc --- /dev/null +++ b/src/agents/external_commands_generator.rs @@ -0,0 +1,291 @@ +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, +}; +use anyhow::Result; +use std::path::{Path, PathBuf}; + +pub struct ExternalCommandsGenerator { + target_dir: String, + /// 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, +} + +impl ExternalCommandsGenerator { + /// Create a generator with flat structure (name-ai-rules.md) + pub fn new(target_dir: &str) -> Self { + Self { + target_dir: target_dir.to_string(), + subdir: None, + } + } + + /// Create a generator with subfolder structure (subdir/name.md) + pub fn with_subdir(target_dir: &str, subdir: &str) -> Self { + Self { + target_dir: target_dir.to_string(), + subdir: Some(subdir.to_string()), + } + } +} + +impl CommandGeneratorTrait for ExternalCommandsGenerator { + fn generate_command_symlinks(&self, current_dir: &Path) -> Result> { + match &self.subdir { + Some(subdir) => { + create_command_symlinks_in_subdir(current_dir, &self.target_dir, subdir) + } + None => create_command_symlinks(current_dir, &self.target_dir), + } + } + + fn clean_commands(&self, current_dir: &Path) -> Result<()> { + match &self.subdir { + Some(subdir) => { + remove_command_symlinks_in_subdir(current_dir, &self.target_dir, subdir) + } + None => remove_generated_command_symlinks(current_dir, &self.target_dir), + } + } + + fn check_commands(&self, current_dir: &Path) -> Result { + match &self.subdir { + 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), + } + } + + 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), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::{AI_RULE_SOURCE_DIR, COMMANDS_DIR, GENERATED_COMMAND_SUFFIX}; + use std::fs; + use tempfile::TempDir; + + fn create_command_file(temp_dir: &Path, command_name: &str, content: &str) -> PathBuf { + let command_dir = temp_dir.join(AI_RULE_SOURCE_DIR).join(COMMANDS_DIR); + fs::create_dir_all(&command_dir).unwrap(); + let file_path = command_dir.join(format!("{}.md", command_name)); + fs::write(&file_path, content).unwrap(); + file_path + } + + // === Flat structure tests (AMP, Cursor) === + + #[test] + fn test_flat_generator_target_dir() { + let generator = ExternalCommandsGenerator::new(".agents/commands"); + assert_eq!(generator.target_dir, ".agents/commands"); + assert!(generator.subdir.is_none()); + } + + #[test] + fn test_flat_generator_generate_symlinks() { + let temp_dir = TempDir::new().unwrap(); + let generator = ExternalCommandsGenerator::new(".agents/commands"); + + create_command_file(temp_dir.path(), "my-command", "command content"); + + let result = generator.generate_command_symlinks(temp_dir.path()); + assert!(result.is_ok()); + + let symlinks = result.unwrap(); + assert_eq!(symlinks.len(), 1); + + let symlink_path = temp_dir + .path() + .join(".agents/commands") + .join(format!("my-command-{}.md", GENERATED_COMMAND_SUFFIX)); + assert!(symlink_path.is_symlink()); + } + + #[test] + fn test_flat_generator_clean() { + let temp_dir = TempDir::new().unwrap(); + let generator = ExternalCommandsGenerator::new(".agents/commands"); + + create_command_file(temp_dir.path(), "my-command", "command content"); + generator + .generate_command_symlinks(temp_dir.path()) + .unwrap(); + + // Create user command (real file, not symlink) + let user_command = temp_dir.path().join(".agents/commands/custom.md"); + fs::write(&user_command, "user content").unwrap(); + + generator.clean_commands(temp_dir.path()).unwrap(); + + // Generated symlink should be gone + let generated = temp_dir + .path() + .join(".agents/commands") + .join(format!("my-command-{}.md", GENERATED_COMMAND_SUFFIX)); + assert!(!generated.exists()); + + // User command should remain + assert!(user_command.exists()); + } + + #[test] + fn test_flat_generator_check_in_sync() { + let temp_dir = TempDir::new().unwrap(); + let generator = ExternalCommandsGenerator::new(".agents/commands"); + + create_command_file(temp_dir.path(), "my-command", "command content"); + + // Not in sync before generating + assert!(!generator.check_commands(temp_dir.path()).unwrap()); + + generator + .generate_command_symlinks(temp_dir.path()) + .unwrap(); + + // Now in sync + assert!(generator.check_commands(temp_dir.path()).unwrap()); + } + + #[test] + fn test_flat_generator_gitignore_patterns() { + let generator = ExternalCommandsGenerator::new(".agents/commands"); + let patterns = generator.command_gitignore_patterns(); + assert_eq!( + patterns, + vec![format!( + ".agents/commands/*-{}.md", + GENERATED_COMMAND_SUFFIX + )] + ); + } + + // === Subfolder structure tests (Claude) === + + #[test] + fn test_subdir_generator_target_dir() { + 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())); + } + + #[test] + fn test_subdir_generator_generate_symlinks() { + let temp_dir = TempDir::new().unwrap(); + let generator = ExternalCommandsGenerator::with_subdir(".claude/commands", "ai-rules"); + + create_command_file(temp_dir.path(), "my-command", "command content"); + + let result = generator.generate_command_symlinks(temp_dir.path()); + assert!(result.is_ok()); + + let symlinks = result.unwrap(); + assert_eq!(symlinks.len(), 1); + + // Subfolder uses original name without suffix + let symlink_path = temp_dir + .path() + .join(".claude/commands/ai-rules/my-command.md"); + assert!(symlink_path.is_symlink()); + + // Verify content is accessible through symlink + let content = fs::read_to_string(&symlink_path).unwrap(); + assert_eq!(content, "command content"); + } + + #[test] + fn test_subdir_generator_clean() { + let temp_dir = TempDir::new().unwrap(); + let generator = ExternalCommandsGenerator::with_subdir(".claude/commands", "ai-rules"); + + create_command_file(temp_dir.path(), "my-command", "command content"); + generator + .generate_command_symlinks(temp_dir.path()) + .unwrap(); + + // Create user command in parent directory (should be preserved) + let user_command = temp_dir.path().join(".claude/commands/custom.md"); + fs::write(&user_command, "user content").unwrap(); + + generator.clean_commands(temp_dir.path()).unwrap(); + + // Subfolder should be removed entirely + let subdir_path = temp_dir.path().join(".claude/commands/ai-rules"); + assert!(!subdir_path.exists()); + + // User command in parent should remain + assert!(user_command.exists()); + } + + #[test] + fn test_subdir_generator_check_in_sync() { + let temp_dir = TempDir::new().unwrap(); + let generator = ExternalCommandsGenerator::with_subdir(".claude/commands", "ai-rules"); + + create_command_file(temp_dir.path(), "my-command", "command content"); + + // Not in sync before generating + assert!(!generator.check_commands(temp_dir.path()).unwrap()); + + generator + .generate_command_symlinks(temp_dir.path()) + .unwrap(); + + // Now in sync + assert!(generator.check_commands(temp_dir.path()).unwrap()); + } + + #[test] + fn test_subdir_generator_check_no_commands() { + let temp_dir = TempDir::new().unwrap(); + let generator = ExternalCommandsGenerator::with_subdir(".claude/commands", "ai-rules"); + + // No commands - should be in sync + assert!(generator.check_commands(temp_dir.path()).unwrap()); + } + + #[test] + fn test_subdir_generator_gitignore_patterns() { + let generator = ExternalCommandsGenerator::with_subdir(".claude/commands", "ai-rules"); + let patterns = generator.command_gitignore_patterns(); + assert_eq!(patterns, vec![".claude/commands/ai-rules/"]); + } + + #[test] + fn test_different_generators_for_different_agents() { + // Claude uses subfolder + let claude_gen = ExternalCommandsGenerator::with_subdir(".claude/commands", "ai-rules"); + assert_eq!( + claude_gen.command_gitignore_patterns(), + vec![".claude/commands/ai-rules/"] + ); + + // Cursor uses subfolder + let cursor_gen = ExternalCommandsGenerator::with_subdir(".cursor/commands", "ai-rules"); + assert_eq!( + cursor_gen.command_gitignore_patterns(), + vec![".cursor/commands/ai-rules/"] + ); + + // AMP uses flat + let amp_gen = ExternalCommandsGenerator::new(".agents/commands"); + assert_eq!( + amp_gen.command_gitignore_patterns(), + vec![format!( + ".agents/commands/*-{}.md", + GENERATED_COMMAND_SUFFIX + )] + ); + } +} diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 57ac0ff..2976771 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -1,11 +1,9 @@ pub mod amp; -pub mod amp_command_generator; pub mod claude; -pub mod claude_command_generator; pub mod codex; pub mod command_generator; pub mod cursor; -pub mod cursor_command_generator; +pub mod external_commands_generator; pub mod external_skills_generator; pub mod firebender; pub mod gemini; diff --git a/src/commands/generate.rs b/src/commands/generate.rs index 604d8ea..e9cc27b 100644 --- a/src/commands/generate.rs +++ b/src/commands/generate.rs @@ -95,21 +95,17 @@ fn generate_files( } write_directory_files(&mcp_files_to_write)?; - // Generate command files - use command_agents instead of agents - let mut command_files_to_write: HashMap = HashMap::new(); + // Generate command symlinks - use command_agents instead of agents for agent in command_agents { if let Some(tool) = registry.get_tool(agent) { if let Some(cmd_gen) = tool.command_generator() { - // Generate new command files - let cmd_files = cmd_gen.generate_commands(current_dir); - for path in cmd_files.keys() { - result.add_file(agent, path.clone()); + let command_symlinks = cmd_gen.generate_command_symlinks(current_dir)?; + for symlink_path in command_symlinks { + result.add_file(agent, symlink_path); } - command_files_to_write.extend(cmd_files); } } } - write_directory_files(&command_files_to_write)?; // Generate skill symlinks for agent in agents { @@ -822,9 +818,18 @@ Optional content"#, assert_file_exists(temp_dir.path(), "AGENTS.md"); assert_file_not_exists(temp_dir.path(), "CLAUDE.md"); - // Command files: both Claude and AMP - assert_file_exists(temp_dir.path(), ".claude/commands/ai-rules/my-command.md"); - assert_file_exists(temp_dir.path(), ".agents/commands/my-command-ai-rules.md"); + // Command symlinks: Claude uses subfolder, AMP uses flat structure + let claude_cmd = temp_dir + .path() + .join(".claude/commands/ai-rules/my-command.md"); + let amp_cmd = temp_dir + .path() + .join(".agents/commands/my-command-ai-rules.md"); + assert!( + claude_cmd.is_symlink(), + "Claude command should be a symlink" + ); + assert!(amp_cmd.is_symlink(), "AMP command should be a symlink"); } #[test] @@ -850,7 +855,16 @@ Optional content"#, // Both rules and commands for claude only assert_file_exists(temp_dir.path(), "CLAUDE.md"); - assert_file_exists(temp_dir.path(), ".claude/commands/ai-rules/my-command.md"); + + // Command symlink: Claude uses subfolder structure + let claude_cmd = temp_dir + .path() + .join(".claude/commands/ai-rules/my-command.md"); + assert!( + claude_cmd.is_symlink(), + "Claude command should be a symlink" + ); + assert_file_not_exists(temp_dir.path(), "AGENTS.md"); assert_file_not_exists(temp_dir.path(), ".agents/commands/my-command-ai-rules.md"); } diff --git a/src/commands/status.rs b/src/commands/status.rs index 3818265..91dd65a 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -629,18 +629,12 @@ Test command body"#; create_file(temp_dir.path(), "CLAUDE.md", claude_content); } - // Note: These tests are ignored because command generator implementations - // (ClaudeCommandGenerator, etc.) are in separate branches that haven't been - // merged yet. Once those branches are merged, remove the #[ignore] attributes. - // See branches: jonandersen/claude-command-folder, jonandersen/cursor-folder-command - #[test] - #[ignore = "Requires command generator implementation to be merged"] fn test_status_reports_command_out_of_sync() { let temp_dir = TempDir::new().unwrap(); setup_claude_with_command_source(&temp_dir); - // Create wrong command file + // Create wrong command file (not a symlink) create_file( temp_dir.path(), ".claude/commands/ai-rules/test-cmd.md", @@ -659,12 +653,11 @@ Test command body"#; assert!(status.has_ai_rules); assert!(!status.body_files_out_of_sync); - // Claude should be marked out of sync because command file is wrong + // Claude should be marked out of sync because command file is wrong (not a symlink) assert!(!status.agent_statuses["claude"]); } #[test] - #[ignore = "Requires command generator implementation to be merged"] fn test_status_with_missing_command_files() { let temp_dir = TempDir::new().unwrap(); setup_claude_with_command_source(&temp_dir); @@ -744,7 +737,6 @@ Test command body"#; } #[test] - #[ignore = "Requires command generator implementation to be merged"] fn test_status_with_commands_in_sync() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/constants.rs b/src/constants.rs index 3198b22..52b44d3 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -6,7 +6,6 @@ pub const AGENTS_MD_FILENAME: &str = "AGENTS.md"; pub const AI_RULE_CONFIG_FILENAME: &str = "ai-rules-config.yaml"; pub const GENERATED_FILE_PREFIX: &str = "ai-rules-generated-"; pub const GENERATED_COMMAND_SUFFIX: &str = "ai-rules"; -pub const GENERATED_COMMANDS_SUBDIR: &str = "ai-rules"; pub const CLAUDE_SKILLS_DIR: &str = ".claude/skills"; #[allow(dead_code)] @@ -26,15 +25,12 @@ pub const MCP_JSON: &str = "mcp.json"; pub const CLAUDE_MCP_JSON: &str = ".mcp.json"; pub const MCP_SERVERS_FIELD: &str = "mcpServers"; -#[allow(dead_code)] pub const COMMANDS_DIR: &str = "commands"; -#[allow(dead_code)] pub const CLAUDE_COMMANDS_DIR: &str = ".claude/commands"; -#[allow(dead_code)] +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"; -#[allow(dead_code)] -pub const FIREBENDER_COMMANDS_FIELD: &str = "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 903adcb..6044bd1 100644 --- a/src/operations/command_reader.rs +++ b/src/operations/command_reader.rs @@ -1,39 +1,19 @@ -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; +use anyhow::Result; use std::path::{Path, PathBuf}; use crate::constants::{AI_RULE_SOURCE_DIR, COMMANDS_DIR, GENERATED_COMMAND_SUFFIX, MD_EXTENSION}; use crate::utils::file_utils::{ - calculate_relative_path, create_relative_symlink, ensure_trailing_newline, - find_files_by_extension, + calculate_relative_path, create_relative_symlink, find_files_by_extension, }; -use crate::utils::frontmatter::{parse_frontmatter, ParsedContent}; - -#[allow(dead_code)] -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CommandFrontMatter { - #[serde(default)] - #[serde(rename = "allowed-tools")] - pub allowed_tools: Option, - #[serde(default)] - pub description: Option, - #[serde(default)] - pub model: Option, -} -#[allow(dead_code)] #[derive(Debug, Clone)] pub struct CommandFile { pub name: String, pub relative_path: PathBuf, pub full_path: PathBuf, - pub front_matter: Option, - pub body: String, - pub raw_content: String, } /// Finds all command markdown files in ai-rules/commands/ directory -#[allow(dead_code)] pub fn find_command_files(current_dir: &Path) -> Result> { let commands_dir = current_dir.join(AI_RULE_SOURCE_DIR).join(COMMANDS_DIR); @@ -47,11 +27,6 @@ pub fn find_command_files(current_dir: &Path) -> Result> { for path in command_paths { if let Some(file_stem) = path.file_stem() { if let Some(name) = file_stem.to_str() { - let raw_content = std::fs::read_to_string(&path) - .with_context(|| format!("Failed to read command file: {}", path.display()))?; - - let parsed: ParsedContent = parse_frontmatter(&raw_content); - let relative_path = PathBuf::from(AI_RULE_SOURCE_DIR) .join(COMMANDS_DIR) .join(path.file_name().unwrap()); @@ -60,9 +35,6 @@ pub fn find_command_files(current_dir: &Path) -> Result> { name: name.to_string(), relative_path, full_path: path, - front_matter: parsed.frontmatter, - body: parsed.body, - raw_content: parsed.raw_content, }); } } @@ -72,7 +44,6 @@ pub fn find_command_files(current_dir: &Path) -> Result> { } /// Creates individual symlinks for each command file in the target directory -#[allow(dead_code)] pub fn create_command_symlinks(current_dir: &Path, target_dir: &str) -> Result> { let command_files = find_command_files(current_dir)?; if command_files.is_empty() { @@ -95,7 +66,6 @@ pub fn create_command_symlinks(current_dir: &Path, target_dir: &str) -> Result Result<()> { use std::fs; @@ -122,7 +92,6 @@ pub fn remove_generated_command_symlinks(current_dir: &Path, target_dir: &str) - } /// Checks if generated command symlinks are in sync -#[allow(dead_code)] pub fn check_command_symlinks_in_sync(current_dir: &Path, target_dir: &str) -> Result { use std::fs; @@ -181,15 +150,118 @@ pub fn check_command_symlinks_in_sync(current_dir: &Path, target_dir: &str) -> R } /// Returns gitignore patterns for generated command symlinks -#[allow(dead_code)] pub fn get_command_gitignore_patterns(target_dir: &str) -> Vec { vec![format!("{}/*-{}.md", target_dir, GENERATED_COMMAND_SUFFIX)] } -/// Returns the body content of a command (without frontmatter) with trailing newline -#[allow(dead_code)] -pub fn get_command_body_content(command: &CommandFile) -> String { - ensure_trailing_newline(command.body.clone()) +// === Subfolder-based command symlinks (for Claude) === + +/// Creates symlinks for commands in a subdirectory (e.g., .claude/commands/ai-rules/) +pub fn create_command_symlinks_in_subdir( + current_dir: &Path, + target_dir: &str, + subdir: &str, +) -> Result> { + let command_files = find_command_files(current_dir)?; + if command_files.is_empty() { + return Ok(Vec::new()); + } + + let mut created_symlinks = Vec::new(); + + for command_file in command_files { + // Use original name in subfolder (e.g., ai-rules/commit.md) + let symlink_name = format!("{}.md", command_file.name); + let from_path = PathBuf::from(target_dir).join(subdir).join(&symlink_name); + let relative_source = calculate_relative_path(&from_path, &command_file.relative_path); + let symlink_path = current_dir.join(&from_path); + + create_relative_symlink(&symlink_path, &relative_source)?; + created_symlinks.push(symlink_path); + } + + Ok(created_symlinks) +} + +/// Removes command symlinks from a subdirectory +pub fn remove_command_symlinks_in_subdir( + current_dir: &Path, + target_dir: &str, + subdir: &str, +) -> Result<()> { + use std::fs; + + let subdir_path = current_dir.join(target_dir).join(subdir); + if subdir_path.exists() { + fs::remove_dir_all(&subdir_path)?; + } + Ok(()) +} + +/// Checks if command symlinks in subdirectory are in sync +pub fn check_command_symlinks_in_subdir_in_sync( + current_dir: &Path, + target_dir: &str, + subdir: &str, +) -> Result { + use std::fs; + + let command_files = find_command_files(current_dir)?; + let subdir_path = current_dir.join(target_dir).join(subdir); + + if command_files.is_empty() { + // No commands - subfolder should not exist + return Ok(!subdir_path.exists()); + } + + if !subdir_path.exists() { + return Ok(false); + } + + // Check all expected symlinks exist and point to correct targets + for command_file in &command_files { + let symlink_name = format!("{}.md", command_file.name); + let symlink_path = subdir_path.join(&symlink_name); + + if !symlink_path.is_symlink() { + return Ok(false); + } + + let actual_target = fs::read_link(&symlink_path)?; + let resolved_target = if actual_target.is_absolute() { + actual_target + } else { + let symlink_parent = symlink_path.parent().unwrap_or(current_dir); + symlink_parent.join(&actual_target) + }; + + let resolved_canonical = resolved_target.canonicalize().unwrap_or(resolved_target); + let expected_canonical = command_file + .full_path + .canonicalize() + .unwrap_or(command_file.full_path.clone()); + + if resolved_canonical != expected_canonical { + return Ok(false); + } + } + + // Check no extra files exist in subfolder + let mut expected_count = 0; + for entry in fs::read_dir(&subdir_path)? { + let entry = entry?; + let path = entry.path(); + if path.is_symlink() || path.is_file() { + expected_count += 1; + } + } + + Ok(expected_count == command_files.len()) +} + +/// Returns gitignore patterns for subfolder-based command symlinks +pub fn get_command_gitignore_patterns_subdir(target_dir: &str, subdir: &str) -> Vec { + vec![format!("{}/{}/", target_dir, subdir)] } #[cfg(test)] diff --git a/src/operations/mod.rs b/src/operations/mod.rs index 87fad28..981a665 100644 --- a/src/operations/mod.rs +++ b/src/operations/mod.rs @@ -15,9 +15,7 @@ pub use body_generator::{ }; pub use cleaner::clean_generated_files; #[allow(unused_imports)] -pub use command_reader::{ - find_command_files, get_command_body_content, CommandFile, CommandFrontMatter, -}; +pub use command_reader::{find_command_files, CommandFile}; pub use generation_result::GenerationResult; pub use gitignore_updater::{remove_gitignore_section, update_project_gitignore}; #[allow(unused_imports)] diff --git a/src/utils/file_utils.rs b/src/utils/file_utils.rs index a7e243f..4825285 100644 --- a/src/utils/file_utils.rs +++ b/src/utils/file_utils.rs @@ -186,43 +186,6 @@ pub fn check_directory_exact_match( Ok(true) } -/// Check if generated files in directory match expected content -/// Only checks files with the given suffix pattern -pub fn check_directory_files_match( - dir: &Path, - expected: &HashMap, - suffix: &str, -) -> Result { - if !dir.exists() { - return Ok(expected.is_empty()); - } - - // Check all expected files exist with correct content - for (path, expected_content) in expected { - if !path.exists() { - return Ok(false); - } - let actual_content = fs::read_to_string(path)?; - if actual_content != *expected_content { - return Ok(false); - } - } - - // Check no extra generated files exist - let suffix_pattern = format!("-{}.md", suffix); - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - if name.ends_with(&suffix_pattern) && !expected.contains_key(&path) { - return Ok(false); - } - } - } - - Ok(true) -} - const EXCLUDED_DIRECTORIES: &[&str] = &[ "ai-rules", "target", diff --git a/src/utils/frontmatter.rs b/src/utils/frontmatter.rs index 72f432b..7eea86a 100644 --- a/src/utils/frontmatter.rs +++ b/src/utils/frontmatter.rs @@ -5,6 +5,7 @@ use serde::de::DeserializeOwned; pub const FRONTMATTER_DELIMITER: &str = "---"; /// Result of parsing frontmatter from content +#[allow(dead_code)] #[derive(Debug, Clone)] pub struct ParsedContent { pub frontmatter: Option, @@ -40,6 +41,7 @@ pub fn split_frontmatter(content: &str) -> (Option<&str>, &str) { /// Parses content with optional YAML frontmatter into a typed struct. /// Returns ParsedContent with frontmatter (if valid YAML) and body. +#[allow(dead_code)] pub fn parse_frontmatter(content: &str) -> ParsedContent { let (frontmatter_str, body) = split_frontmatter(content);