diff --git a/src/commands/base.rs b/src/commands/base.rs index 4e46c70..1026bf1 100644 --- a/src/commands/base.rs +++ b/src/commands/base.rs @@ -8,8 +8,10 @@ use anyhow::Result; pub struct CommandContext { /// The loaded configuration pub config: Config, - /// Optional tag filter for repositories - pub tag: Option, + /// Tag filters for repositories (can include multiple tags) + pub tag: Vec, + /// Tags to exclude from repositories + pub exclude_tag: Vec, /// Whether to execute operations in parallel pub parallel: bool, /// Optional list of specific repository names to operate on diff --git a/src/commands/clone.rs b/src/commands/clone.rs index 69c4bc8..9c5a4dc 100644 --- a/src/commands/clone.rs +++ b/src/commands/clone.rs @@ -12,17 +12,31 @@ pub struct CloneCommand; #[async_trait] impl Command for CloneCommand { async fn execute(&self, context: &CommandContext) -> Result<()> { - let repositories = context - .config - .filter_repositories(context.tag.as_deref(), context.repos.as_deref()); + let repositories = context.config.filter_repositories( + &context.tag, + &context.exclude_tag, + context.repos.as_deref(), + ); if repositories.is_empty() { - let filter_desc = match (&context.tag, &context.repos) { - (Some(tag), Some(repos)) => format!("tag '{tag}' and repositories {repos:?}"), - (Some(tag), None) => format!("tag '{tag}'"), - (None, Some(repos)) => format!("repositories {repos:?}"), - (None, None) => "no repositories found".to_string(), + let mut filter_parts = Vec::new(); + + if !context.tag.is_empty() { + filter_parts.push(format!("tags {:?}", context.tag)); + } + if !context.exclude_tag.is_empty() { + filter_parts.push(format!("excluding tags {:?}", context.exclude_tag)); + } + if let Some(repos) = &context.repos { + filter_parts.push(format!("repositories {:?}", repos)); + } + + let filter_desc = if filter_parts.is_empty() { + "no repositories found".to_string() + } else { + filter_parts.join(" and ") }; + println!( "{}", format!("No repositories found with {filter_desc}").yellow() @@ -141,15 +155,16 @@ mod tests { } /// Helper to create CommandContext for testing - fn create_command_context( + fn create_context( config: Config, - tag: Option, + tag: Vec, repos: Option>, parallel: bool, ) -> CommandContext { CommandContext { config, tag, + exclude_tag: Vec::new(), repos, parallel, } @@ -161,7 +176,7 @@ mod tests { let command = CloneCommand; // Test with tag that doesn't match any repository - let context = create_command_context(config, Some("nonexistent".to_string()), None, false); + let context = create_context(config, vec!["nonexistent".to_string()], None, false); let result = command.execute(&context).await; assert!(result.is_ok()); @@ -174,7 +189,7 @@ mod tests { let command = CloneCommand; // Test with tag that matches some repositories - let context = create_command_context(config, Some("frontend".to_string()), None, false); + let context = create_context(config, vec!["frontend".to_string()], None, false); let result = command.execute(&context).await; // This will likely fail because we're trying to actually clone repos, @@ -188,9 +203,9 @@ mod tests { let command = CloneCommand; // Test with specific repository names - let context = create_command_context( + let context = create_context( config, - None, + vec![], Some(vec!["test-repo-1".to_string(), "test-repo-2".to_string()]), false, ); @@ -207,9 +222,9 @@ mod tests { let command = CloneCommand; // Test with both tag and repository filters - let context = create_command_context( + let context = create_context( config, - Some("frontend".to_string()), + vec!["frontend".to_string()], Some(vec!["test-repo-1".to_string()]), false, ); @@ -224,7 +239,7 @@ mod tests { let command = CloneCommand; // Test parallel execution mode - let context = create_command_context(config, Some("frontend".to_string()), None, true); + let context = create_context(config, vec!["frontend".to_string()], None, true); let result = command.execute(&context).await; // Should test parallel execution path @@ -237,7 +252,7 @@ mod tests { let command = CloneCommand; // Test sequential execution mode - let context = create_command_context(config, Some("backend".to_string()), None, false); + let context = create_context(config, vec!["backend".to_string()], None, false); let result = command.execute(&context).await; // Should test sequential execution path @@ -250,9 +265,9 @@ mod tests { let command = CloneCommand; // Test with repository names that don't exist - let context = create_command_context( + let context = create_context( config, - None, + vec![], Some(vec!["nonexistent-repo".to_string()]), false, ); @@ -267,7 +282,7 @@ mod tests { let command = CloneCommand; // Test with no filters (should try to clone all repositories) - let context = create_command_context(config, None, None, false); + let context = create_context(config, vec![], None, false); let result = command.execute(&context).await; // This will likely fail because we're trying to clone real repos, @@ -289,7 +304,7 @@ mod tests { }; let command = CloneCommand; - let context = create_command_context(config, None, None, false); + let context = create_context(config, vec![], None, false); let result = command.execute(&context).await; // Should fail because all clone operations fail @@ -305,7 +320,7 @@ mod tests { let config = create_test_config(); let command = CloneCommand; - let context = create_command_context(config, None, None, false); + let context = create_context(config, vec![], None, false); let result = command.execute(&context).await; // The result depends on actual git operations, but we're testing the logic paths @@ -332,7 +347,7 @@ mod tests { }; let command = CloneCommand; - let context = create_command_context(config, None, None, true); // Parallel execution + let context = create_context(config, vec![], None, true); // Parallel execution let result = command.execute(&context).await; // Should fail due to invalid repositories, but tests parallel error handling @@ -347,14 +362,14 @@ mod tests { // Test different filter combination scenarios // Tag only - let context = create_command_context(config.clone(), Some("rust".to_string()), None, false); + let context = create_context(config.clone(), vec!["rust".to_string()], None, false); let result = command.execute(&context).await; assert!(result.is_err() || result.is_ok()); // Repos only - let context = create_command_context( + let context = create_context( config.clone(), - None, + vec![], Some(vec!["test-repo-3".to_string()]), false, ); @@ -362,9 +377,9 @@ mod tests { assert!(result.is_err() || result.is_ok()); // Both tag and repos - let context = create_command_context( + let context = create_context( config, - Some("frontend".to_string()), + vec!["frontend".to_string()], Some(vec!["test-repo-1".to_string(), "test-repo-3".to_string()]), false, ); @@ -380,7 +395,7 @@ mod tests { }; let command = CloneCommand; - let context = create_command_context(config, None, None, false); + let context = create_context(config, vec![], None, false); let result = command.execute(&context).await; assert!(result.is_ok()); // Should succeed with no repositories message @@ -394,7 +409,7 @@ mod tests { let command = CloneCommand; // Use parallel execution to test task error handling paths - let context = create_command_context(config, Some("backend".to_string()), None, true); + let context = create_context(config, vec!["backend".to_string()], None, true); let result = command.execute(&context).await; // Tests the parallel task error handling code paths diff --git a/src/commands/init.rs b/src/commands/init.rs index e93465e..ff51841 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -178,7 +178,8 @@ mod tests { config: Config { repositories: vec![], }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: false, }; @@ -211,7 +212,8 @@ mod tests { config: Config { repositories: vec![], }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: false, }; @@ -270,7 +272,8 @@ mod tests { config: Config { repositories: vec![], }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: false, }; @@ -306,7 +309,8 @@ mod tests { config: Config { repositories: vec![], }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: false, }; diff --git a/src/commands/pr.rs b/src/commands/pr.rs index c555a3b..2bd7e19 100644 --- a/src/commands/pr.rs +++ b/src/commands/pr.rs @@ -21,17 +21,31 @@ pub struct PrCommand { #[async_trait] impl Command for PrCommand { async fn execute(&self, context: &CommandContext) -> Result<()> { - let repositories = context - .config - .filter_repositories(context.tag.as_deref(), context.repos.as_deref()); + let repositories = context.config.filter_repositories( + &context.tag, + &context.exclude_tag, + context.repos.as_deref(), + ); if repositories.is_empty() { - let filter_desc = match (&context.tag, &context.repos) { - (Some(tag), Some(repos)) => format!("tag '{tag}' and repositories {repos:?}"), - (Some(tag), None) => format!("tag '{tag}'"), - (None, Some(repos)) => format!("repositories {repos:?}"), - (None, None) => "no repositories found".to_string(), + let mut filter_parts = Vec::new(); + + if !context.tag.is_empty() { + filter_parts.push(format!("tags {:?}", context.tag)); + } + if !context.exclude_tag.is_empty() { + filter_parts.push(format!("excluding tags {:?}", context.exclude_tag)); + } + if let Some(repos) = &context.repos { + filter_parts.push(format!("repositories {:?}", repos)); + } + + let filter_desc = if filter_parts.is_empty() { + "no repositories found".to_string() + } else { + filter_parts.join(" and ") }; + println!( "{}", format!("No repositories found with {filter_desc}").yellow() diff --git a/src/commands/remove.rs b/src/commands/remove.rs index f13da3b..2bd06a2 100644 --- a/src/commands/remove.rs +++ b/src/commands/remove.rs @@ -12,16 +12,18 @@ pub struct RemoveCommand; #[async_trait] impl Command for RemoveCommand { async fn execute(&self, context: &CommandContext) -> Result<()> { - let repositories = context - .config - .filter_repositories(context.tag.as_deref(), context.repos.as_deref()); + let repositories = context.config.filter_repositories( + &context.tag, + &context.exclude_tag, + context.repos.as_deref(), + ); if repositories.is_empty() { - let filter_desc = match (&context.tag, &context.repos) { - (Some(tag), Some(repos)) => format!("tag '{tag}' and repositories {repos:?}"), - (Some(tag), None) => format!("tag '{tag}'"), - (None, Some(repos)) => format!("repositories {repos:?}"), - (None, None) => "no repositories found".to_string(), + let filter_desc = match (&context.tag.is_empty(), &context.repos) { + (false, Some(repos)) => format!("tag {:?} and repositories {repos:?}", context.tag), + (false, None) => format!("tag {:?}", context.tag), + (true, Some(repos)) => format!("repositories {repos:?}"), + (true, None) => "no repositories found".to_string(), }; println!( "{}", @@ -154,7 +156,8 @@ mod tests { config: Config { repositories: vec![repo], }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: false, }; @@ -197,7 +200,8 @@ mod tests { let command = RemoveCommand; let context = CommandContext { config: Config { repositories }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: false, }; @@ -245,7 +249,8 @@ mod tests { let command = RemoveCommand; let context = CommandContext { config: Config { repositories }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: true, // Enable parallel execution }; @@ -285,7 +290,8 @@ mod tests { config: Config { repositories: vec![repo], }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: false, }; @@ -331,7 +337,8 @@ mod tests { config: Config { repositories: vec![matching_repo, non_matching_repo], }, - tag: Some("backend".to_string()), + tag: vec!["backend".to_string()], + exclude_tag: vec![], repos: None, parallel: false, }; @@ -381,7 +388,8 @@ mod tests { config: Config { repositories: vec![repo1, repo2], }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: Some(vec!["repo1".to_string()]), // Only remove repo1 parallel: false, }; @@ -421,7 +429,8 @@ mod tests { config: Config { repositories: vec![repo], }, - tag: Some("frontend".to_string()), // Non-matching tag + tag: vec!["frontend".to_string()], // Non-matching tag + exclude_tag: vec![], repos: None, parallel: false, }; @@ -437,7 +446,8 @@ mod tests { config: Config { repositories: vec![], }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: false, }; @@ -473,7 +483,8 @@ mod tests { config: Config { repositories: vec![repo], }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: false, }; @@ -519,7 +530,8 @@ mod tests { config: Config { repositories: vec![matching_repo, wrong_name_repo], }, - tag: Some("backend".to_string()), + tag: vec!["backend".to_string()], + exclude_tag: vec![], repos: Some(vec!["matching-repo".to_string()]), parallel: false, }; @@ -573,7 +585,8 @@ mod tests { config: Config { repositories: vec![success_repo, nonexistent_repo], }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: true, // Test parallel execution with mixed scenarios }; diff --git a/src/commands/run.rs b/src/commands/run.rs index f601ccd..a0178a9 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -18,9 +18,11 @@ pub struct RunCommand { #[async_trait] impl Command for RunCommand { async fn execute(&self, context: &CommandContext) -> Result<()> { - let repositories = context - .config - .filter_repositories(context.tag.as_deref(), context.repos.as_deref()); + let repositories = context.config.filter_repositories( + &context.tag, + &context.exclude_tag, + context.repos.as_deref(), + ); if repositories.is_empty() { return Ok(()); diff --git a/src/config/loader.rs b/src/config/loader.rs index b09bc70..5e30c3e 100644 --- a/src/config/loader.rs +++ b/src/config/loader.rs @@ -188,28 +188,36 @@ impl Config { self.filter_by_tag(tag) } - /// Filter repositories by context (combining tag and names filters) + /// Filter repositories by context (combining tag inclusion, exclusion, and names filters) pub fn filter_repositories( &self, - tag: Option<&str>, + include_tags: &[String], + exclude_tags: &[String], repos: Option<&[String]>, ) -> Vec { - match (tag, repos) { - // If specific repos are specified, filter by names first, then by tag if provided - (Some(tag), Some(repo_names)) => { - let by_names = self.filter_by_names(repo_names); - by_names - .into_iter() - .filter(|repo| repo.has_tag(tag)) - .collect() - } - // If only repos are specified, filter by names only - (None, Some(repo_names)) => self.filter_by_names(repo_names), - // If only tag is specified, filter by tag only - (Some(tag), None) => self.filter_by_tag(Some(tag)), - // If neither is specified, return all repositories - (None, None) => self.repositories.clone(), - } + let base_repos = if let Some(repo_names) = repos { + // If specific repos are specified, filter by names first + self.filter_by_names(repo_names) + } else { + // Otherwise start with all repositories + self.repositories.clone() + }; + + // Apply both inclusion and exclusion filters in a single pass + base_repos + .into_iter() + .filter(|repo| { + // Check inclusion filter: if include_tags is empty, include all; otherwise check if repo has any included tag + let included = + include_tags.is_empty() || include_tags.iter().any(|tag| repo.has_tag(tag)); + + // Check exclusion filter: if exclude_tags is empty, exclude none; otherwise check if repo has any excluded tag + let excluded = + !exclude_tags.is_empty() && exclude_tags.iter().any(|tag| repo.has_tag(tag)); + + included && !excluded + }) + .collect() } } @@ -297,26 +305,31 @@ mod tests { let config = create_test_config(); // Test with both tag and repo names - let filtered = config.filter_repositories(Some("frontend"), Some(&["repo1".to_string()])); + let filtered = config.filter_repositories( + &["frontend".to_string()], + &[], + Some(&["repo1".to_string()]), + ); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].name, "repo1"); // Test with tag and repo names that don't match - let filtered = config.filter_repositories(Some("backend"), Some(&["repo1".to_string()])); + let filtered = + config.filter_repositories(&["backend".to_string()], &[], Some(&["repo1".to_string()])); assert_eq!(filtered.len(), 0); // repo1 doesn't have backend tag // Test with only repo names - let filtered = config.filter_repositories(None, Some(&["repo1".to_string()])); + let filtered = config.filter_repositories(&[], &[], Some(&["repo1".to_string()])); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].name, "repo1"); // Test with only tag - let filtered = config.filter_repositories(Some("frontend"), None); + let filtered = config.filter_repositories(&["frontend".to_string()], &[], None); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0].name, "repo1"); // Test with neither (should return all) - let filtered = config.filter_repositories(None, None); + let filtered = config.filter_repositories(&[], &[], None); assert_eq!(filtered.len(), 2); } @@ -392,8 +405,11 @@ mod tests { let config = create_test_config(); // Non-existent tag with valid names should return empty - let filtered = - config.filter_repositories(Some("nonexistent"), Some(&["repo1".to_string()])); + let filtered = config.filter_repositories( + &["nonexistent".to_string()], + &[], + Some(&["repo1".to_string()]), + ); assert_eq!(filtered.len(), 0); } @@ -402,8 +418,11 @@ mod tests { let config = create_test_config(); // Valid tag with non-existent names should return empty - let filtered = - config.filter_repositories(Some("backend"), Some(&["nonexistent".to_string()])); + let filtered = config.filter_repositories( + &["backend".to_string()], + &[], + Some(&["nonexistent".to_string()]), + ); assert_eq!(filtered.len(), 0); } diff --git a/src/main.rs b/src/main.rs index 44801e2..08b80ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,9 +23,13 @@ enum Commands { #[arg(short, long, default_value_t = constants::config::DEFAULT_CONFIG_FILE.to_string())] config: String, - /// Filter repositories by tag + /// Filter repositories by tag (can be specified multiple times) #[arg(short, long)] - tag: Option, + tag: Vec, + + /// Exclude repositories with these tags (can be specified multiple times) + #[arg(short = 'e', long)] + exclude_tag: Vec, /// Execute operations in parallel #[arg(short, long)] @@ -44,9 +48,13 @@ enum Commands { #[arg(short, long, default_value_t = constants::config::DEFAULT_CONFIG_FILE.to_string())] config: String, - /// Filter repositories by tag + /// Filter repositories by tag (can be specified multiple times) #[arg(short, long)] - tag: Option, + tag: Vec, + + /// Exclude repositories with these tags (can be specified multiple times) + #[arg(short = 'e', long)] + exclude_tag: Vec, /// Execute operations in parallel #[arg(short, long)] @@ -102,9 +110,13 @@ enum Commands { #[arg(short, long, default_value_t = constants::config::DEFAULT_CONFIG_FILE.to_string())] config: String, - /// Filter repositories by tag + /// Filter repositories by tag (can be specified multiple times) #[arg(short, long)] - tag: Option, + tag: Vec, + + /// Exclude repositories with these tags (can be specified multiple times) + #[arg(short = 'e', long)] + exclude_tag: Vec, /// Execute operations in parallel #[arg(short, long)] @@ -120,9 +132,13 @@ enum Commands { #[arg(short, long, default_value_t = constants::config::DEFAULT_CONFIG_FILE.to_string())] config: String, - /// Filter repositories by tag + /// Filter repositories by tag (can be specified multiple times) #[arg(short, long)] - tag: Option, + tag: Vec, + + /// Exclude repositories with these tags (can be specified multiple times) + #[arg(short = 'e', long)] + exclude_tag: Vec, /// Execute operations in parallel #[arg(short, long)] @@ -155,12 +171,14 @@ async fn main() -> Result<()> { repos, config, tag, + exclude_tag, parallel, } => { let config = Config::load_config(&config)?; let context = CommandContext { config, tag, + exclude_tag, parallel, repos: if repos.is_empty() { None } else { Some(repos) }, }; @@ -171,6 +189,7 @@ async fn main() -> Result<()> { repos, config, tag, + exclude_tag, parallel, no_save, output_dir, @@ -179,6 +198,7 @@ async fn main() -> Result<()> { let context = CommandContext { config, tag, + exclude_tag, parallel, repos: if repos.is_empty() { None } else { Some(repos) }, }; @@ -202,12 +222,14 @@ async fn main() -> Result<()> { create_only, config, tag, + exclude_tag, parallel, } => { let config = Config::load_config(&config)?; let context = CommandContext { config, tag, + exclude_tag, parallel, repos: if repos.is_empty() { None } else { Some(repos) }, }; @@ -232,12 +254,14 @@ async fn main() -> Result<()> { repos, config, tag, + exclude_tag, parallel, } => { let config = Config::load_config(&config)?; let context = CommandContext { config, tag, + exclude_tag, parallel, repos: if repos.is_empty() { None } else { Some(repos) }, }; @@ -251,7 +275,8 @@ async fn main() -> Result<()> { // Init command doesn't need config since it creates one let context = CommandContext { config: Config::new(), - tag: None, + tag: Vec::new(), + exclude_tag: Vec::new(), parallel: false, repos: None, }; diff --git a/tests/pr_command_tests.rs b/tests/pr_command_tests.rs index c7218d6..40ea29a 100644 --- a/tests/pr_command_tests.rs +++ b/tests/pr_command_tests.rs @@ -36,13 +36,15 @@ fn create_test_config() -> Config { /// Helper function to create a test context fn create_test_context( config: Config, - tag: Option, + tag: Vec, + exclude_tag: Vec, repos: Option>, parallel: bool, ) -> CommandContext { CommandContext { config, tag, + exclude_tag, parallel, repos, } @@ -51,7 +53,7 @@ fn create_test_context( #[tokio::test] async fn test_pr_command_basic_execution() { let config = create_test_config(); - let context = create_test_context(config, None, None, false); + let context = create_test_context(config, vec![], vec![], None, false); let pr_command = PrCommand { title: "Test PR".to_string(), @@ -73,7 +75,7 @@ async fn test_pr_command_basic_execution() { #[tokio::test] async fn test_pr_command_with_tag_filter() { let config = create_test_config(); - let context = create_test_context(config, Some("backend".to_string()), None, false); + let context = create_test_context(config, vec!["backend".to_string()], vec![], None, false); let pr_command = PrCommand { title: "Backend PR".to_string(), @@ -95,7 +97,8 @@ async fn test_pr_command_with_specific_repos() { let config = create_test_config(); let context = create_test_context( config, - None, + vec![], + vec![], Some(vec!["repo1".to_string(), "repo3".to_string()]), false, ); @@ -120,7 +123,8 @@ async fn test_pr_command_with_tag_and_repos_filter() { let config = create_test_config(); let context = create_test_context( config, - Some("backend".to_string()), + vec!["backend".to_string()], + vec![], Some(vec!["repo2".to_string()]), false, ); @@ -143,7 +147,7 @@ async fn test_pr_command_with_tag_and_repos_filter() { #[tokio::test] async fn test_pr_command_no_matching_repositories() { let config = create_test_config(); - let context = create_test_context(config, Some("nonexistent".to_string()), None, false); + let context = create_test_context(config, vec!["nonexistent".to_string()], vec![], None, false); let pr_command = PrCommand { title: "No repos PR".to_string(), @@ -166,7 +170,7 @@ async fn test_pr_command_empty_repositories() { let config = Config { repositories: vec![], }; - let context = create_test_context(config, None, None, false); + let context = create_test_context(config, vec![], vec![], None, false); let pr_command = PrCommand { title: "Empty config PR".to_string(), @@ -187,7 +191,7 @@ async fn test_pr_command_empty_repositories() { #[tokio::test] async fn test_pr_command_parallel_execution() { let config = create_test_config(); - let context = create_test_context(config, None, None, true); // parallel = true + let context = create_test_context(config, vec![], vec![], None, true); // parallel = true let pr_command = PrCommand { title: "Parallel PR".to_string(), @@ -207,7 +211,7 @@ async fn test_pr_command_parallel_execution() { #[tokio::test] async fn test_pr_command_with_custom_branch_name() { let config = create_test_config(); - let context = create_test_context(config, None, None, false); + let context = create_test_context(config, vec![], vec![], None, false); let pr_command = PrCommand { title: "Custom branch PR".to_string(), @@ -227,7 +231,7 @@ async fn test_pr_command_with_custom_branch_name() { #[tokio::test] async fn test_pr_command_with_custom_base_branch() { let config = create_test_config(); - let context = create_test_context(config, None, None, false); + let context = create_test_context(config, vec![], vec![], None, false); let pr_command = PrCommand { title: "Custom base PR".to_string(), @@ -247,7 +251,7 @@ async fn test_pr_command_with_custom_base_branch() { #[tokio::test] async fn test_pr_command_with_custom_commit_message() { let config = create_test_config(); - let context = create_test_context(config, None, None, false); + let context = create_test_context(config, vec![], vec![], None, false); let pr_command = PrCommand { title: "Custom commit PR".to_string(), @@ -267,7 +271,7 @@ async fn test_pr_command_with_custom_commit_message() { #[tokio::test] async fn test_pr_command_draft_mode() { let config = create_test_config(); - let context = create_test_context(config, None, None, false); + let context = create_test_context(config, vec![], vec![], None, false); let pr_command = PrCommand { title: "Draft PR".to_string(), @@ -287,7 +291,7 @@ async fn test_pr_command_draft_mode() { #[tokio::test] async fn test_pr_command_create_only_mode() { let config = create_test_config(); - let context = create_test_context(config, None, None, false); + let context = create_test_context(config, vec![], vec![], None, false); let pr_command = PrCommand { title: "Create only PR".to_string(), @@ -307,7 +311,7 @@ async fn test_pr_command_create_only_mode() { #[tokio::test] async fn test_pr_command_without_create_only() { let config = create_test_config(); - let context = create_test_context(config, None, None, false); + let context = create_test_context(config, vec![], vec![], None, false); let pr_command = PrCommand { title: "Full PR".to_string(), @@ -328,7 +332,7 @@ async fn test_pr_command_without_create_only() { #[tokio::test] async fn test_pr_command_empty_token() { let config = create_test_config(); - let context = create_test_context(config, None, None, false); + let context = create_test_context(config, vec![], vec![], None, false); let pr_command = PrCommand { title: "Empty token PR".to_string(), @@ -348,7 +352,7 @@ async fn test_pr_command_empty_token() { #[tokio::test] async fn test_pr_command_special_characters_in_title() { let config = create_test_config(); - let context = create_test_context(config, None, None, false); + let context = create_test_context(config, vec![], vec![], None, false); let pr_command = PrCommand { title: "PR with special chars: 你好 🚀 @#$%".to_string(), @@ -368,7 +372,7 @@ async fn test_pr_command_special_characters_in_title() { #[tokio::test] async fn test_pr_command_very_long_title() { let config = create_test_config(); - let context = create_test_context(config, None, None, false); + let context = create_test_context(config, vec![], vec![], None, false); let long_title = "A".repeat(1000); let pr_command = PrCommand { @@ -389,7 +393,7 @@ async fn test_pr_command_very_long_title() { #[tokio::test] async fn test_pr_command_very_long_body() { let config = create_test_config(); - let context = create_test_context(config, None, None, false); + let context = create_test_context(config, vec![], vec![], None, false); let long_body = "B".repeat(10000); let pr_command = PrCommand { @@ -412,7 +416,8 @@ async fn test_pr_command_all_options_combined() { let config = create_test_config(); let context = create_test_context( config, - Some("backend".to_string()), + vec!["backend".to_string()], + vec![], Some(vec!["repo2".to_string()]), true, // parallel ); @@ -437,7 +442,8 @@ async fn test_pr_command_invalid_repository_names() { let config = create_test_config(); let context = create_test_context( config, - None, + vec![], + vec![], Some(vec!["nonexistent1".to_string(), "nonexistent2".to_string()]), false, ); @@ -463,7 +469,8 @@ async fn test_pr_command_mixed_valid_invalid_repos() { let config = create_test_config(); let context = create_test_context( config, - None, + vec![], + vec![], Some(vec![ "repo1".to_string(), "nonexistent".to_string(), @@ -490,7 +497,7 @@ async fn test_pr_command_mixed_valid_invalid_repos() { #[tokio::test] async fn test_pr_command_case_sensitive_tag_filter() { let config = create_test_config(); - let context = create_test_context(config, Some("BACKEND".to_string()), None, false); + let context = create_test_context(config, vec!["BACKEND".to_string()], vec![], None, false); let pr_command = PrCommand { title: "Case sensitive PR".to_string(), @@ -513,7 +520,8 @@ async fn test_pr_command_case_sensitive_repo_names() { let config = create_test_config(); let context = create_test_context( config, - None, + vec![], + vec![], Some(vec!["REPO1".to_string()]), // Wrong case false, ); @@ -533,3 +541,138 @@ async fn test_pr_command_case_sensitive_repo_names() { let result = pr_command.execute(&context).await; assert!(result.is_ok()); } + +#[tokio::test] +async fn test_pr_command_with_exclude_tag() { + let config = create_test_config(); + let context = create_test_context( + config, + vec![], // No inclusion tags + vec!["frontend".to_string()], // Exclude frontend + None, + false, + ); + + let pr_command = PrCommand { + title: "Backend only PR".to_string(), + body: "Excludes frontend repos".to_string(), + branch_name: None, + base_branch: None, + commit_msg: None, + draft: false, + token: "fake-token".to_string(), + create_only: true, + }; + + // Should only work with backend repos (repo2, repo3) + let result = pr_command.execute(&context).await; + assert!(result.is_ok() || result.is_err()); +} + +#[tokio::test] +async fn test_pr_command_with_multiple_exclude_tags() { + let config = create_test_config(); + let context = create_test_context( + config, + vec![], // No inclusion tags + vec!["frontend".to_string(), "database".to_string()], // Exclude multiple + None, + false, + ); + + let pr_command = PrCommand { + title: "Rust only PR".to_string(), + body: "Excludes frontend and database repos".to_string(), + branch_name: None, + base_branch: None, + commit_msg: None, + draft: false, + token: "fake-token".to_string(), + create_only: true, + }; + + // Should only work with repo2 (rust backend, no database tag) + let result = pr_command.execute(&context).await; + assert!(result.is_ok() || result.is_err()); +} + +#[tokio::test] +async fn test_pr_command_with_inclusion_and_exclusion() { + let config = create_test_config(); + let context = create_test_context( + config, + vec!["backend".to_string()], // Include backend + vec!["database".to_string()], // But exclude database + None, + false, + ); + + let pr_command = PrCommand { + title: "Backend no DB PR".to_string(), + body: "Backend repos but not database ones".to_string(), + branch_name: None, + base_branch: None, + commit_msg: None, + draft: false, + token: "fake-token".to_string(), + create_only: true, + }; + + // Should only work with repo2 (backend but not database) + let result = pr_command.execute(&context).await; + assert!(result.is_ok() || result.is_err()); +} + +#[tokio::test] +async fn test_pr_command_exclude_all_repos() { + let config = create_test_config(); + let context = create_test_context( + config, + vec![], // No inclusion tags + vec!["frontend".to_string(), "backend".to_string()], // Exclude all + None, + false, + ); + + let pr_command = PrCommand { + title: "No repos PR".to_string(), + body: "Should exclude all repos".to_string(), + branch_name: None, + base_branch: None, + commit_msg: None, + draft: false, + token: "fake-token".to_string(), + create_only: true, + }; + + // Should find no repos + let result = pr_command.execute(&context).await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_pr_command_multiple_inclusion_tags() { + let config = create_test_config(); + let context = create_test_context( + config, + vec!["frontend".to_string(), "rust".to_string()], // Multiple includes + vec![], // No exclusions + None, + false, + ); + + let pr_command = PrCommand { + title: "Multi-tag PR".to_string(), + body: "Includes repos with frontend OR rust tags".to_string(), + branch_name: None, + base_branch: None, + commit_msg: None, + draft: false, + token: "fake-token".to_string(), + create_only: true, + }; + + // Should work with repo1 (frontend) and repo2 (rust) + let result = pr_command.execute(&context).await; + assert!(result.is_ok() || result.is_err()); +} diff --git a/tests/run_command_tests.rs b/tests/run_command_tests.rs index c7edc38..0556a10 100644 --- a/tests/run_command_tests.rs +++ b/tests/run_command_tests.rs @@ -71,7 +71,8 @@ async fn test_run_command_basic_execution() { config: Config { repositories: vec![repo], }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: false, }; @@ -81,40 +82,38 @@ async fn test_run_command_basic_execution() { } #[tokio::test] -async fn test_run_command_multiple_repositories() { +async fn test_run_command_no_matching_repos() { let temp_dir = TempDir::new().unwrap(); let log_dir = temp_dir.path().join("logs"); fs::create_dir_all(&log_dir).unwrap(); - let mut repositories = Vec::new(); - - // Create multiple test repositories - for i in 1..=3 { - let repo_dir = temp_dir.path().join(format!("repo-{}", i)); - fs::create_dir_all(&repo_dir).unwrap(); - create_git_repo(&repo_dir).unwrap(); - - let repo = Repository { - name: format!("repo-{}", i), - url: format!("https://github.com/user/repo-{}.git", i), - tags: vec!["test".to_string()], - path: Some(repo_dir.to_string_lossy().to_string()), - branch: None, - config_dir: None, - }; + // Create a test repository directory + let repo_dir = temp_dir.path().join("test-repo"); + fs::create_dir_all(&repo_dir).unwrap(); + create_git_repo(&repo_dir).unwrap(); - repositories.push(repo); - } + let repo = Repository { + name: "test-repo".to_string(), + url: "https://github.com/user/test-repo.git".to_string(), + tags: vec!["test".to_string()], + path: Some(repo_dir.to_string_lossy().to_string()), + branch: None, + config_dir: None, + }; let command = RunCommand { - command: "pwd".to_string(), + command: "echo hello".to_string(), no_save: true, output_dir: None, }; + // Use a tag that doesn't match any repo let context = CommandContext { - config: Config { repositories }, - tag: None, + config: Config { + repositories: vec![repo], + }, + tag: vec!["nonexistent".to_string()], + exclude_tag: vec![], repos: None, parallel: false, }; @@ -124,42 +123,51 @@ async fn test_run_command_multiple_repositories() { } #[tokio::test] -async fn test_run_command_parallel_execution() { +async fn test_run_command_with_specific_repos() { let temp_dir = TempDir::new().unwrap(); let log_dir = temp_dir.path().join("logs"); fs::create_dir_all(&log_dir).unwrap(); - let mut repositories = Vec::new(); + let repo_dir1 = temp_dir.path().join("test-repo1"); + fs::create_dir_all(&repo_dir1).unwrap(); + create_git_repo(&repo_dir1).unwrap(); - // Create multiple test repositories - for i in 1..=3 { - let repo_dir = temp_dir.path().join(format!("parallel-repo-{}", i)); - fs::create_dir_all(&repo_dir).unwrap(); - create_git_repo(&repo_dir).unwrap(); + let repo_dir2 = temp_dir.path().join("test-repo2"); + fs::create_dir_all(&repo_dir2).unwrap(); + create_git_repo(&repo_dir2).unwrap(); - let repo = Repository { - name: format!("parallel-repo-{}", i), - url: format!("https://github.com/user/parallel-repo-{}.git", i), - tags: vec!["test".to_string()], - path: Some(repo_dir.to_string_lossy().to_string()), - branch: None, - config_dir: None, - }; + let repo1 = Repository { + name: "test-repo1".to_string(), + url: "https://github.com/user/test-repo1.git".to_string(), + tags: vec!["test".to_string()], + path: Some(repo_dir1.to_string_lossy().to_string()), + branch: None, + config_dir: None, + }; - repositories.push(repo); - } + let repo2 = Repository { + name: "test-repo2".to_string(), + url: "https://github.com/user/test-repo2.git".to_string(), + tags: vec!["other".to_string()], + path: Some(repo_dir2.to_string_lossy().to_string()), + branch: None, + config_dir: None, + }; let command = RunCommand { - command: "echo parallel".to_string(), + command: "echo hello".to_string(), no_save: true, output_dir: None, }; let context = CommandContext { - config: Config { repositories }, - tag: None, - repos: None, - parallel: true, // Enable parallel execution + config: Config { + repositories: vec![repo1, repo2], + }, + tag: vec![], + exclude_tag: vec![], + repos: Some(vec!["test-repo1".to_string()]), + parallel: false, }; let result = command.execute(&context).await; @@ -172,45 +180,44 @@ async fn test_run_command_with_tag_filter() { let log_dir = temp_dir.path().join("logs"); fs::create_dir_all(&log_dir).unwrap(); - // Create repository with matching tag - let matching_repo_dir = temp_dir.path().join("backend-repo"); - fs::create_dir_all(&matching_repo_dir).unwrap(); - create_git_repo(&matching_repo_dir).unwrap(); + let repo_dir1 = temp_dir.path().join("backend-repo"); + fs::create_dir_all(&repo_dir1).unwrap(); + create_git_repo(&repo_dir1).unwrap(); + + let repo_dir2 = temp_dir.path().join("frontend-repo"); + fs::create_dir_all(&repo_dir2).unwrap(); + create_git_repo(&repo_dir2).unwrap(); - let matching_repo = Repository { + let backend_repo = Repository { name: "backend-repo".to_string(), url: "https://github.com/user/backend-repo.git".to_string(), - tags: vec!["backend".to_string()], - path: Some(matching_repo_dir.to_string_lossy().to_string()), + tags: vec!["backend".to_string(), "rust".to_string()], + path: Some(repo_dir1.to_string_lossy().to_string()), branch: None, config_dir: None, }; - // Create repository with non-matching tag - let non_matching_repo_dir = temp_dir.path().join("frontend-repo"); - fs::create_dir_all(&non_matching_repo_dir).unwrap(); - create_git_repo(&non_matching_repo_dir).unwrap(); - - let non_matching_repo = Repository { + let frontend_repo = Repository { name: "frontend-repo".to_string(), url: "https://github.com/user/frontend-repo.git".to_string(), - tags: vec!["frontend".to_string()], - path: Some(non_matching_repo_dir.to_string_lossy().to_string()), + tags: vec!["frontend".to_string(), "javascript".to_string()], + path: Some(repo_dir2.to_string_lossy().to_string()), branch: None, config_dir: None, }; let command = RunCommand { - command: "echo tagged".to_string(), + command: "echo hello".to_string(), no_save: true, output_dir: None, }; let context = CommandContext { config: Config { - repositories: vec![matching_repo, non_matching_repo], + repositories: vec![backend_repo, frontend_repo], }, - tag: Some("backend".to_string()), + tag: vec!["backend".to_string()], + exclude_tag: vec![], repos: None, parallel: false, }; @@ -220,40 +227,39 @@ async fn test_run_command_with_tag_filter() { } #[tokio::test] -async fn test_run_command_with_repo_filter() { +async fn test_run_command_parallel_execution() { let temp_dir = TempDir::new().unwrap(); let log_dir = temp_dir.path().join("logs"); fs::create_dir_all(&log_dir).unwrap(); - // Create multiple repositories - let repo1_dir = temp_dir.path().join("repo1"); - fs::create_dir_all(&repo1_dir).unwrap(); - create_git_repo(&repo1_dir).unwrap(); + let repo_dir1 = temp_dir.path().join("test-repo1"); + fs::create_dir_all(&repo_dir1).unwrap(); + create_git_repo(&repo_dir1).unwrap(); - let repo2_dir = temp_dir.path().join("repo2"); - fs::create_dir_all(&repo2_dir).unwrap(); - create_git_repo(&repo2_dir).unwrap(); + let repo_dir2 = temp_dir.path().join("test-repo2"); + fs::create_dir_all(&repo_dir2).unwrap(); + create_git_repo(&repo_dir2).unwrap(); let repo1 = Repository { - name: "repo1".to_string(), - url: "https://github.com/user/repo1.git".to_string(), + name: "test-repo1".to_string(), + url: "https://github.com/user/test-repo1.git".to_string(), tags: vec!["test".to_string()], - path: Some(repo1_dir.to_string_lossy().to_string()), + path: Some(repo_dir1.to_string_lossy().to_string()), branch: None, config_dir: None, }; let repo2 = Repository { - name: "repo2".to_string(), - url: "https://github.com/user/repo2.git".to_string(), + name: "test-repo2".to_string(), + url: "https://github.com/user/test-repo2.git".to_string(), tags: vec!["test".to_string()], - path: Some(repo2_dir.to_string_lossy().to_string()), + path: Some(repo_dir2.to_string_lossy().to_string()), branch: None, config_dir: None, }; let command = RunCommand { - command: "echo filtered".to_string(), + command: "echo hello".to_string(), no_save: true, output_dir: None, }; @@ -262,9 +268,10 @@ async fn test_run_command_with_repo_filter() { config: Config { repositories: vec![repo1, repo2], }, - tag: None, - repos: Some(vec!["repo1".to_string()]), // Only run on repo1 - parallel: false, + tag: vec![], + exclude_tag: vec![], + repos: None, + parallel: true, }; let result = command.execute(&context).await; @@ -272,84 +279,57 @@ async fn test_run_command_with_repo_filter() { } #[tokio::test] -async fn test_run_command_no_matching_repositories() { +async fn test_run_command_tag_filter_no_match() { let temp_dir = TempDir::new().unwrap(); let log_dir = temp_dir.path().join("logs"); fs::create_dir_all(&log_dir).unwrap(); - let repo = Repository { - name: "test-repo".to_string(), - url: "https://github.com/user/test-repo.git".to_string(), - tags: vec!["backend".to_string()], - path: Some( - temp_dir - .path() - .join("test-repo") - .to_string_lossy() - .to_string(), - ), + let repo_dir = temp_dir.path().join("backend-repo"); + fs::create_dir_all(&repo_dir).unwrap(); + create_git_repo(&repo_dir).unwrap(); + + let backend_repo = Repository { + name: "backend-repo".to_string(), + url: "https://github.com/user/backend-repo.git".to_string(), + tags: vec!["backend".to_string(), "rust".to_string()], + path: Some(repo_dir.to_string_lossy().to_string()), branch: None, config_dir: None, }; let command = RunCommand { - command: "echo test".to_string(), - no_save: true, - output_dir: None, - }; - - let context = CommandContext { - config: Config { - repositories: vec![repo], - }, - tag: Some("frontend".to_string()), // Non-matching tag - repos: None, - parallel: false, - }; - - let result = command.execute(&context).await; - assert!(result.is_ok()); // Should succeed but do nothing -} - -#[tokio::test] -async fn test_run_command_empty_repositories() { - let temp_dir = TempDir::new().unwrap(); - let log_dir = temp_dir.path().join("logs"); - fs::create_dir_all(&log_dir).unwrap(); - - let command = RunCommand { - command: "echo test".to_string(), + command: "echo hello".to_string(), no_save: true, output_dir: None, }; let context = CommandContext { config: Config { - repositories: vec![], + repositories: vec![backend_repo], }, - tag: None, + tag: vec!["frontend".to_string()], // Non-matching tag + exclude_tag: vec![], repos: None, parallel: false, }; let result = command.execute(&context).await; - assert!(result.is_ok()); // Should succeed with empty repository list + assert!(result.is_ok()); } #[tokio::test] -async fn test_run_command_complex_command() { +async fn test_run_command_error_handling() { let temp_dir = TempDir::new().unwrap(); let log_dir = temp_dir.path().join("logs"); fs::create_dir_all(&log_dir).unwrap(); - // Create a test repository directory - let repo_dir = temp_dir.path().join("complex-repo"); + let repo_dir = temp_dir.path().join("test-repo"); fs::create_dir_all(&repo_dir).unwrap(); create_git_repo(&repo_dir).unwrap(); let repo = Repository { - name: "complex-repo".to_string(), - url: "https://github.com/user/complex-repo.git".to_string(), + name: "test-repo".to_string(), + url: "https://github.com/user/test-repo.git".to_string(), tags: vec!["test".to_string()], path: Some(repo_dir.to_string_lossy().to_string()), branch: None, @@ -357,7 +337,7 @@ async fn test_run_command_complex_command() { }; let command = RunCommand { - command: "git status && echo done".to_string(), + command: "false".to_string(), // Command that will fail no_save: true, output_dir: None, }; @@ -366,37 +346,39 @@ async fn test_run_command_complex_command() { config: Config { repositories: vec![repo], }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: false, }; let result = command.execute(&context).await; - assert!(result.is_ok()); + // The command should fail when all individual commands fail + assert!(result.is_err()); } #[tokio::test] -async fn test_run_command_command_with_special_characters() { +async fn test_run_command_file_operations() { let temp_dir = TempDir::new().unwrap(); let log_dir = temp_dir.path().join("logs"); fs::create_dir_all(&log_dir).unwrap(); - // Create a test repository directory - let repo_dir = temp_dir.path().join("special-repo"); + let repo_dir = temp_dir.path().join("test-repo"); fs::create_dir_all(&repo_dir).unwrap(); create_git_repo(&repo_dir).unwrap(); let repo = Repository { - name: "special-repo".to_string(), - url: "https://github.com/user/special-repo.git".to_string(), + name: "test-repo".to_string(), + url: "https://github.com/user/test-repo.git".to_string(), tags: vec!["test".to_string()], path: Some(repo_dir.to_string_lossy().to_string()), branch: None, config_dir: None, }; + // Test command that creates a file let command = RunCommand { - command: "echo 'hello world' && echo \"quoted text\"".to_string(), + command: "touch test-file.txt".to_string(), no_save: true, output_dir: None, }; @@ -405,61 +387,52 @@ async fn test_run_command_command_with_special_characters() { config: Config { repositories: vec![repo], }, - tag: None, + tag: vec![], + exclude_tag: vec![], repos: None, parallel: false, }; let result = command.execute(&context).await; assert!(result.is_ok()); + + // Verify the file was created + assert!(repo_dir.join("test-file.txt").exists()); } #[tokio::test] -async fn test_run_command_combined_filters() { +async fn test_run_command_with_multiple_tags() { let temp_dir = TempDir::new().unwrap(); let log_dir = temp_dir.path().join("logs"); fs::create_dir_all(&log_dir).unwrap(); - // Create repository matching both tag and name filters - let matching_repo_dir = temp_dir.path().join("matching-repo"); - fs::create_dir_all(&matching_repo_dir).unwrap(); - create_git_repo(&matching_repo_dir).unwrap(); - - let matching_repo = Repository { - name: "matching-repo".to_string(), - url: "https://github.com/user/matching-repo.git".to_string(), - tags: vec!["backend".to_string()], - path: Some(matching_repo_dir.to_string_lossy().to_string()), - branch: None, - config_dir: None, - }; - - // Create repository with matching tag but wrong name - let wrong_name_repo_dir = temp_dir.path().join("wrong-name-repo"); - fs::create_dir_all(&wrong_name_repo_dir).unwrap(); - create_git_repo(&wrong_name_repo_dir).unwrap(); + let repo_dir = temp_dir.path().join("backend-repo"); + fs::create_dir_all(&repo_dir).unwrap(); + create_git_repo(&repo_dir).unwrap(); - let wrong_name_repo = Repository { - name: "wrong-name-repo".to_string(), - url: "https://github.com/user/wrong-name-repo.git".to_string(), - tags: vec!["backend".to_string()], - path: Some(wrong_name_repo_dir.to_string_lossy().to_string()), + let backend_repo = Repository { + name: "backend-repo".to_string(), + url: "https://github.com/user/backend-repo.git".to_string(), + tags: vec!["backend".to_string(), "rust".to_string()], + path: Some(repo_dir.to_string_lossy().to_string()), branch: None, config_dir: None, }; let command = RunCommand { - command: "echo combined".to_string(), + command: "echo hello".to_string(), no_save: true, output_dir: None, }; + // Test with multiple matching tags let context = CommandContext { config: Config { - repositories: vec![matching_repo, wrong_name_repo], + repositories: vec![backend_repo], }, - tag: Some("backend".to_string()), - repos: Some(vec!["matching-repo".to_string()]), + tag: vec!["backend".to_string()], + exclude_tag: vec![], + repos: None, parallel: false, };