diff --git a/src/main.rs b/src/main.rs index b7beca9..4268f54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -462,8 +462,15 @@ fn main() -> Result<()> { log::debug!("cli.json = {}", cli.json); - // Check for orphaned nested meta repo and warn the user - check_and_warn_orphan(); + // Cargo/Rust namespace help must not inspect workspace configuration. + // Other command paths retain the existing orphan warning behavior. + let skip_orphan_check = matches!( + cli.command.as_ref(), + Some(Commands::External(args)) if is_cargo_namespace_help(args, cli.help) + ); + if !skip_orphan_check { + check_and_warn_orphan(); + } // Discover plugins early to handle --help requests and plugin listing let mut subprocess_plugins = SubprocessPluginManager::new(); @@ -532,17 +539,24 @@ fn main() -> Result<()> { handle_command_dispatch(args.command, &cli, &subprocess_plugins, true) } Some(Commands::External(args)) => { - // clap doesn't capture global flags that appear after an external - // subcommand name. Extract long-form global flags here so they - // work in both positions (before and after the subcommand). + // clap captures every token after an external subcommand name. + // Apply namespace-specific ownership before routing the command. let mut args = args; extract_global_flags(&mut args, &mut cli); + let is_cargo_namespace = is_cargo_namespace(&args); + let has_forwarded_help = contains_help_before_separator(&args); + if cli.help && !is_cargo_namespace && !has_forwarded_help { + print_help_with_plugins(&subprocess_plugins, false); + return Ok(()); + } + // Keep root plugin help fast and plugin-aware, but let nested help // requests reach the matched plugin command implementation. if let Some(first) = args.first() { - let wants_help = args.iter().any(|a| a == "--help" || a == "-h"); let is_bare = args.len() == 1; + let is_prefix_meta_help = cli.help && is_cargo_namespace; + let wants_help = is_prefix_meta_help || has_forwarded_help; let is_root_help = wants_help && args.len() == 2 && matches!(args.get(1).map(String::as_str), Some("--help" | "-h")); @@ -554,7 +568,10 @@ fn main() -> Result<()> { .collect(); let is_promoted = promoted_commands.contains(&first.to_string()); - if is_root_help || (is_bare && !is_promoted) { + if is_prefix_meta_help + || is_root_help + || (is_bare && (!is_promoted || is_cargo_namespace)) + { if let Some(help_text) = subprocess_plugins.get_plugin_help(first) { println!("{help_text}"); return Ok(()); @@ -785,10 +802,11 @@ fn handle_command_dispatch( return Ok(()); } - // No config found — degraded legacy path with warning + // No config found — worktree paths are still authoritative for + // plugin dispatch, but config-backed tags/dependencies are unavailable. if cli.verbose { eprintln!( - "{} No .meta config found for worktree '{}'. Tags, plugins, and dependency features unavailable.", + "{} No .meta config found for worktree '{}'. Tags and dependency features unavailable.", "warning:".yellow().bold(), task_name ); @@ -801,10 +819,10 @@ fn handle_command_dispatch( let exclude_opt = none_if_empty(exclude_filters); let config = loop_lib::LoopConfig { - directories, + directories: directories.clone(), ignore: vec![], - include_filters: include_opt, - exclude_filters: exclude_opt, + include_filters: include_opt.clone(), + exclude_filters: exclude_opt.clone(), verbose: cli.verbose, silent: cli.silent, parallel, // Use the determined parallel mode, not hardcoded false @@ -817,7 +835,36 @@ fn handle_command_dispatch( root_dir: None, // Worktree paths don't use "." convention }; - run(&config, &command_str)?; + let subprocess_options = PluginRequestOptions { + json_output: cli.json, + verbose: cli.verbose, + parallel, + dry_run, + silent: cli.silent, + recursive, + depth, + include_filters: include_opt, + exclude_filters: exclude_opt, + strict: cli.strict, + }; + + if plugins.execute( + &command_str, + &command_args, + &directories, + subprocess_options, + )? { + if cli.verbose { + println!( + "{}", + "Command handled by subprocess plugin (worktree without config).".green() + ); + } + } else if is_explicit_exec { + run(&config, &command_str)?; + } else { + unrecognized_command_error(&command_args, &command_str, plugins); + } return Ok(()); } } @@ -920,10 +967,11 @@ fn handle_command_dispatch( strict: cli.strict, }; - if plugins.execute( + if plugins.execute_with_root( &command_str, &command_args, &project_paths, + Some(meta_dir), subprocess_options, )? { log::info!("Command was handled by subprocess plugin"); @@ -1245,18 +1293,193 @@ fn handle_plugin_command( // === Helpers === -/// Extract meta-only global flags from external subcommand args. +/// Whether an external command explicitly enters Cargo's namespace. +fn is_cargo_namespace(args: &[String]) -> bool { + matches!(args.first().map(String::as_str), Some("cargo" | "rust")) +} + +/// Result of parsing a documented Cargo short-option cluster. +enum CargoShortOption { + Complete, + NeedsValue, + Terminal, +} + +/// Parse Cargo's supported leading short options conservatively. +/// +/// `v` may repeat, while `q` cannot repeat or combine with `v`. `C` and `Z` +/// consume the rest of the cluster as their value, or the following token when +/// they end the cluster. Help and version are terminal, and unknown flags +/// reject the whole cluster. +fn cargo_short_option(argument: &str) -> Option { + let flags = argument.strip_prefix('-')?; + if flags.is_empty() || flags.starts_with('-') { + return None; + } + + let mut saw_quiet = false; + let mut saw_verbose = false; + for (offset, flag) in flags.char_indices() { + match flag { + 'v' if !saw_quiet => saw_verbose = true, + 'q' if !saw_quiet && !saw_verbose => saw_quiet = true, + 'h' | 'V' => return Some(CargoShortOption::Terminal), + 'C' | 'Z' => { + return Some(if offset + flag.len_utf8() < flags.len() { + CargoShortOption::Complete + } else { + CargoShortOption::NeedsValue + }); + } + _ => return None, + } + } + + Some(CargoShortOption::Complete) +} + +fn is_cargo_color(value: &str) -> bool { + matches!(value, "auto" | "always" | "never") +} + +/// Locate Cargo's subcommand after its leading global options. +/// +/// Cargo accepts an optional rustup `+toolchain` selector followed by global +/// flags before the command name. This parser recognizes only that documented +/// leading grammar and returns `None` for terminal, malformed, or unknown +/// forms, keeping ambiguous `--recursive` tokens Cargo-owned. It never scans +/// beyond Cargo's `--` separator. +fn cargo_subcommand_index(args: &[String]) -> Option { + if !is_cargo_namespace(args) { + return None; + } + + let separator = args + .iter() + .position(|arg| arg == "--") + .unwrap_or(args.len()); + let mut index = 1; + + if index < separator + && args[index] + .strip_prefix('+') + .is_some_and(|toolchain| !toolchain.is_empty()) + { + index += 1; + } + + while index < separator { + let argument = args[index].as_str(); + match argument { + // Global modifiers that do not consume a value. + "--locked" | "--offline" | "--frozen" | "--verbose" | "-q" | "--quiet" => { + index += 1; + } + // These modes exit without dispatching a Cargo subcommand. + "-V" | "--version" | "--list" | "--explain" | "-h" | "--help" => { + return None; + } + "--color" => { + if index + 1 >= separator || !is_cargo_color(&args[index + 1]) { + return None; + } + index += 2; + } + // Global options whose value is the following token. + "--config" => { + if index + 1 >= separator || args[index + 1].starts_with('-') { + return None; + } + index += 2; + } + _ if argument + .strip_prefix("--color=") + .is_some_and(is_cargo_color) => + { + index += 1; + } + _ if argument.starts_with("--color=") => return None, + _ if argument.starts_with("--config=") => index += 1, + _ if argument.starts_with("--explain=") => return None, + _ if argument.starts_with('-') => match cargo_short_option(argument) { + Some(CargoShortOption::Complete) => index += 1, + Some(CargoShortOption::NeedsValue) => { + if index + 1 >= separator + || args[index + 1].is_empty() + || args[index + 1].starts_with('-') + { + return None; + } + index += 2; + } + Some(CargoShortOption::Terminal) | None => return None, + }, + _ => return Some(index), + } + } + + None +} + +/// Whether a help flag appears before the command's `--` separator. +fn contains_help_before_separator(args: &[String]) -> bool { + args.iter() + .take_while(|arg| arg.as_str() != "--") + .any(|arg| matches!(arg.as_str(), "--help" | "-h")) +} + +/// Cargo/Rust help paths that must not inspect Meta workspace configuration. +fn is_cargo_namespace_help(args: &[String], prefix_help: bool) -> bool { + is_cargo_namespace(args) + && (prefix_help || args.len() == 1 || contains_help_before_separator(args)) +} + +/// Extract Meta-owned global flags from external subcommand args. /// /// clap's `external_subcommand` captures all tokens after the first unrecognized -/// subcommand, including global flags like `--json`. This function pulls them -/// out and applies them to the CLI struct so they work regardless of position. +/// subcommand, including global flags like `--json`. For non-Cargo namespaces, +/// this function pulls Meta-owned flags out and applies them to the CLI struct. /// -/// Only extracts flags that are meta-global and NOT reused by plugin subcommands. -/// Flags like `--dry-run` and `--parallel` are left in args because plugin -/// subcommands (e.g. `worktree prune --dry-run`, `worktree exec --parallel`) -/// define their own versions and need to see them. +/// Cargo and Rust explicitly own all options after their namespace. The sole +/// compatibility exception is postfix `--recursive` for build, test, and clean, +/// which continues to select nested Meta projects. No arguments after a `--` +/// separator are inspected. Other namespaces retain the existing extraction +/// behavior for Meta-only global flags. fn extract_global_flags(args: &mut Vec, cli: &mut Cli) { + if is_cargo_namespace(args) { + let subcommand_index = cargo_subcommand_index(args); + let supports_recursive_compat = subcommand_index + .is_some_and(|index| matches!(args[index].as_str(), "build" | "test" | "clean")); + + if let (true, Some(subcommand_index)) = (supports_recursive_compat, subcommand_index) { + let separator = args + .iter() + .position(|arg| arg == "--") + .unwrap_or(args.len()); + let mut index = 0; + args.retain(|arg| { + let remove = + index > subcommand_index && index < separator && arg.as_str() == "--recursive"; + index += 1; + if remove { + cli.recursive = true; + } + !remove + }); + } + return; + } + + let mut after_separator = false; args.retain(|arg| { + if after_separator { + return true; + } + if arg == "--" { + after_separator = true; + return true; + } + match arg.as_str() { "--json" => { cli.json = true; @@ -1423,6 +1646,330 @@ mod tests { use std::io::Write; use tempfile::NamedTempFile; + fn empty_cli() -> Cli { + Cli::try_parse_from(["meta"]).unwrap() + } + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn test_cargo_recursive_compatibility_is_narrow() { + for namespace in ["cargo", "rust"] { + for subcommand in ["build", "test", "clean"] { + let mut cli = empty_cli(); + let mut args = strings(&[namespace, subcommand, "--all", "--recursive"]); + + extract_global_flags(&mut args, &mut cli); + + assert!(cli.recursive, "{namespace} {subcommand}"); + assert_eq!(args, strings(&[namespace, subcommand, "--all"])); + } + } + + for subcommand in ["update", "nextest"] { + let mut cli = empty_cli(); + let mut args = strings(&["cargo", subcommand, "--recursive"]); + let expected = args.clone(); + + extract_global_flags(&mut args, &mut cli); + + assert!(!cli.recursive, "cargo {subcommand}"); + assert_eq!(args, expected); + } + } + + #[test] + fn test_cargo_recursive_compatibility_finds_the_actual_subcommand() { + for (input, expected) in [ + ( + &["cargo", "--locked", "clean", "--recursive"][..], + &["cargo", "--locked", "clean"][..], + ), + ( + &["rust", "+nightly", "--offline", "test", "--recursive"][..], + &["rust", "+nightly", "--offline", "test"][..], + ), + ( + &["cargo", "--color", "always", "build", "--recursive"][..], + &["cargo", "--color", "always", "build"][..], + ), + ( + &[ + "cargo", + "-Zunstable-options", + "-C", + "crate", + "clean", + "--recursive", + ][..], + &["cargo", "-Zunstable-options", "-C", "crate", "clean"][..], + ), + ] { + let mut cli = empty_cli(); + let mut args = strings(input); + + extract_global_flags(&mut args, &mut cli); + + assert!(cli.recursive, "{input:?}"); + assert_eq!(args, strings(expected), "{input:?}"); + } + } + + #[test] + fn test_cargo_color_values_are_validated_before_recursive_compatibility() { + for color in ["auto", "always", "never"] { + let attached = format!("--color={color}"); + for mut args in [ + strings(&["cargo", "--color", color, "build", "--recursive"]), + vec![ + "cargo".to_string(), + attached.clone(), + "build".to_string(), + "--recursive".to_string(), + ], + ] { + let mut cli = empty_cli(); + let mut expected = args.clone(); + expected.pop(); + + extract_global_flags(&mut args, &mut cli); + + assert!(cli.recursive, "{args:?}"); + assert_eq!(args, expected); + } + } + + for input in [ + &["cargo", "--color", "sometimes", "build", "--recursive"][..], + &["cargo", "--color=sometimes", "build", "--recursive"][..], + &["cargo", "--color", "", "build", "--recursive"][..], + &["cargo", "--color=", "build", "--recursive"][..], + &["cargo", "--color"][..], + ] { + let mut cli = empty_cli(); + let mut args = strings(input); + let expected = args.clone(); + + extract_global_flags(&mut args, &mut cli); + + assert!(!cli.recursive, "{input:?}"); + assert_eq!(args, expected, "{input:?}"); + } + } + + #[test] + fn test_cargo_script_mode_continues_to_the_manifest_path() { + for (input, expected_index) in [ + (&["cargo", "-Z", "script", "./tool.rs"][..], 3), + (&["cargo", "-Zscript", "./tool.rs"][..], 2), + ] { + let mut args = strings(input); + assert_eq!(cargo_subcommand_index(&args), Some(expected_index)); + + args.push("--recursive".to_string()); + let expected = args.clone(); + let mut cli = empty_cli(); + + extract_global_flags(&mut args, &mut cli); + + assert!(!cli.recursive, "{input:?}"); + assert_eq!(args, expected, "{input:?}"); + } + } + + #[test] + fn test_cargo_short_option_clusters_preserve_recursive_compatibility() { + for input in [ + &["cargo", "-vC.", "build", "--recursive"][..], + &["cargo", "-qZunstable-options", "clean", "--recursive"][..], + &["cargo", "-vC", ".", "test", "--recursive"][..], + &["cargo", "-qZ", "unstable-options", "build", "--recursive"][..], + ] { + let mut cli = empty_cli(); + let mut args = strings(input); + let mut expected = args.clone(); + expected.pop(); + + extract_global_flags(&mut args, &mut cli); + + assert!(cli.recursive, "{input:?}"); + assert_eq!(args, expected, "{input:?}"); + } + } + + #[test] + fn test_cargo_subcommand_detection_is_conservative() { + for input in [ + &["cargo", "--config", "clean", "update", "--recursive"][..], + &["cargo", "--config", "--locked", "clean", "--recursive"][..], + &["cargo", "--color", "--locked", "clean", "--recursive"][..], + &["cargo", "-C", "--locked", "clean", "--recursive"][..], + &["cargo", "-Z", "--locked", "clean", "--recursive"][..], + &["cargo", "--locked", "--", "clean", "--recursive"][..], + &["cargo", "--recursive", "clean"][..], + &["cargo", "-vX", "clean", "--recursive"][..], + &["cargo", "-qZ", "--locked", "clean", "--recursive"][..], + &["cargo", "-vC", "--locked", "clean", "--recursive"][..], + &["cargo", "-Vv", "clean", "--recursive"][..], + &["cargo", "-vh", "clean", "--recursive"][..], + &["cargo", "-qq", "build", "--recursive"][..], + &["cargo", "-vq", "clean", "--recursive"][..], + &["cargo", "-qv", "clean", "--recursive"][..], + ] { + let mut cli = empty_cli(); + let mut args = strings(input); + let expected = args.clone(); + + extract_global_flags(&mut args, &mut cli); + + assert!(!cli.recursive, "{input:?}"); + assert_eq!(args, expected, "{input:?}"); + } + } + + #[test] + fn test_cargo_postfix_flags_and_separator_payload_are_cargo_owned() { + let mut cli = empty_cli(); + let mut args = strings(&[ + "cargo", + "test", + "--verbose", + "--json", + "--", + "--recursive", + "--help", + "--silent", + "--primary", + "--strict", + ]); + let expected = args.clone(); + + extract_global_flags(&mut args, &mut cli); + + assert_eq!(args, expected); + assert!(!cli.recursive); + assert!(!cli.verbose); + assert!(!cli.json); + assert!(!cli.silent); + assert!(!cli.primary); + assert!(!cli.strict); + assert!(!contains_help_before_separator(&args)); + } + + #[test] + fn test_non_cargo_extraction_stops_at_separator() { + let mut cli = empty_cli(); + let mut args = strings(&[ + "git", + "status", + "--verbose", + "--recursive", + "--", + "--json", + "--silent", + "--primary", + "--strict", + ]); + + extract_global_flags(&mut args, &mut cli); + + assert_eq!( + args, + strings(&[ + "git", + "status", + "--", + "--json", + "--silent", + "--primary", + "--strict", + ]) + ); + assert!(cli.verbose); + assert!(cli.recursive); + assert!(!cli.json); + assert!(!cli.silent); + assert!(!cli.primary); + assert!(!cli.strict); + } + + #[test] + fn test_non_cargo_global_flag_extraction_remains_compatible() { + let mut cli = empty_cli(); + let mut args = strings(&[ + "git", + "status", + "--json", + "--verbose", + "--silent", + "--primary", + "--recursive", + "--strict", + ]); + + extract_global_flags(&mut args, &mut cli); + + assert_eq!(args, strings(&["git", "status"])); + assert!(cli.json); + assert!(cli.verbose); + assert!(cli.silent); + assert!(cli.primary); + assert!(cli.recursive); + assert!(cli.strict); + } + + #[test] + fn test_cargo_help_classification_stops_at_separator() { + assert!(is_cargo_namespace_help(&strings(&["cargo"]), false)); + assert!(is_cargo_namespace_help(&strings(&["rust"]), false)); + assert!(is_cargo_namespace_help( + &strings(&["cargo", "check", "--help"]), + false + )); + assert!(is_cargo_namespace_help( + &strings(&["rust", "nextest", "-h"]), + false + )); + assert!(is_cargo_namespace_help(&strings(&["cargo", "check"]), true)); + + assert!(!is_cargo_namespace_help( + &strings(&["cargo", "test", "--", "--help"]), + false + )); + assert!(!is_cargo_namespace_help( + &strings(&["cargo", "check"]), + false + )); + assert!(!is_cargo_namespace_help( + &strings(&["git", "status", "--help"]), + false + )); + } + + #[test] + fn test_meta_controls_before_cargo_namespace_remain_global() { + let cli = Cli::try_parse_from([ + "meta", + "--dry-run", + "--verbose", + "cargo", + "check", + "--verbose", + ]) + .unwrap(); + + assert!(cli.dry_run); + assert!(cli.verbose); + match cli.command { + Some(Commands::External(args)) => { + assert_eq!(args, strings(&["cargo", "check", "--verbose"])); + } + _ => panic!("expected external Cargo command"), + } + } + #[test] fn test_parse_meta_config_valid_simple_format() { let mut file = NamedTempFile::new().unwrap(); diff --git a/src/subprocess_plugins.rs b/src/subprocess_plugins.rs index ed8a2f9..c5f893d 100644 --- a/src/subprocess_plugins.rs +++ b/src/subprocess_plugins.rs @@ -27,6 +27,44 @@ pub struct SubprocessPluginManager { verbose: bool, } +/// Options used after a plugin has returned an execution plan. +/// +/// The Rust plugin's Cargo/Rust namespace implementation applies +/// include/exclude filters while planning so it can distinguish a selected +/// scope with no Cargo projects from an unfiltered scope. Reapplying those +/// string filters to its normalized Windows paths can drop commands whose +/// original path spelling used a short name or different casing. Other and +/// older plugins continue to rely on the host execution layer for filtering. +fn options_for_plan_execution( + plugin: &PluginInfo, + command: &str, + options: &PluginRequestOptions, +) -> PluginRequestOptions { + let mut execution_options = options.clone(); + let namespace = command.split_whitespace().next().unwrap_or_default(); + let owns_namespace_root = plugin + .commands + .iter() + .any(|registered| registered == namespace); + + if is_rust_namespace_command(plugin, command) && owns_namespace_root { + execution_options.include_filters = None; + execution_options.exclude_filters = None; + } + + execution_options +} + +/// Whether trusted Rust-plugin routing explicitly entered a Cargo namespace. +/// +/// This uses plugin identity and the matched command rather than inspecting a +/// returned shell plan. It also covers legacy multi-word Rust registrations so +/// Cargo remains authoritative when older plugins are installed. +fn is_rust_namespace_command(plugin: &PluginInfo, command: &str) -> bool { + let namespace = command.split_whitespace().next().unwrap_or_default(); + plugin.name == "rust" && matches!(namespace, "cargo" | "rust") +} + impl Default for SubprocessPluginManager { fn default() -> Self { Self::new() @@ -202,6 +240,18 @@ impl SubprocessPluginManager { args: &[String], projects: &[String], options: PluginRequestOptions, + ) -> Result { + self.execute_with_root(command, args, projects, None, options) + } + + /// Execute a command while preserving the caller's actual Meta root. + pub fn execute_with_root( + &self, + command: &str, + args: &[String], + projects: &[String], + root_dir: Option<&Path>, + options: PluginRequestOptions, ) -> Result { let cmd_parts: Vec<&str> = command.split_whitespace().collect(); if cmd_parts.is_empty() { @@ -236,7 +286,7 @@ impl SubprocessPluginManager { } if let Some((plugin, matched_cmd)) = best_match { - return self.execute_plugin(plugin, matched_cmd, args, projects, &options); + return self.execute_plugin(plugin, matched_cmd, args, projects, root_dir, &options); } Ok(false) @@ -249,6 +299,7 @@ impl SubprocessPluginManager { command: &str, args: &[String], projects: &[String], + root_dir: Option<&Path>, options: &PluginRequestOptions, ) -> Result { // Extract the remaining args after the matched command @@ -313,7 +364,14 @@ impl SubprocessPluginManager { match serde_json::from_str::(&stdout_str) { Ok(response) => { // Plugin returned an execution plan - execute it via loop_lib - self.execute_plan(&response.plan, options) + let execution_options = options_for_plan_execution(&plugin.info, command, options); + let expand_loop_aliases = !is_rust_namespace_command(&plugin.info, command); + self.execute_plan( + &response.plan, + &execution_options, + root_dir, + expand_loop_aliases, + ) } Err(_) => { // Couldn't parse as our protocol - print output as-is (legacy behavior) @@ -324,8 +382,21 @@ impl SubprocessPluginManager { } /// Execute an execution plan via loop_lib - fn execute_plan(&self, plan: &ExecutionPlan, options: &PluginRequestOptions) -> Result { - use loop_lib::{run_commands, DirCommand, LoopConfig}; + fn execute_plan( + &self, + plan: &ExecutionPlan, + options: &PluginRequestOptions, + root_dir: Option<&Path>, + expand_loop_aliases: bool, + ) -> Result { + use loop_lib::{run_commands, run_commands_without_loop_aliases, DirCommand, LoopConfig}; + + let run_plan_commands: fn(&LoopConfig, &[DirCommand]) -> Result<()> = if expand_loop_aliases + { + run_commands + } else { + run_commands_without_loop_aliases + }; // Phase 1: Run pre_commands sequentially (setup tasks like SSH ControlMaster) if !plan.pre_commands.is_empty() { @@ -359,7 +430,7 @@ impl SubprocessPluginManager { }; // Ignore failures for pre_commands (e.g., SSH socket already exists) // The main commands will fail if setup was actually needed - if let Err(e) = run_commands(&pre_config, &[cmd]) { + if let Err(e) = run_plan_commands(&pre_config, &[cmd]) { if options.verbose { eprintln!("Pre-command failed (continuing): {e}"); } @@ -379,9 +450,6 @@ impl SubprocessPluginManager { }) .collect(); - // The first command's directory is the meta root (should display as ".") - let root_dir = commands.first().map(|c| PathBuf::from(&c.dir)); - let config = LoopConfig { directories: vec![], ignore: vec![], @@ -396,10 +464,10 @@ impl SubprocessPluginManager { spawn_stagger_ms: plan.spawn_stagger_ms.unwrap_or(0), env: None, max_parallel: plan.max_parallel, - root_dir, + root_dir: root_dir.map(Path::to_path_buf), }; - run_commands(&config, &commands)?; + run_plan_commands(&config, &commands)?; } // Phase 3: Run post_commands sequentially (cleanup tasks) @@ -427,7 +495,7 @@ impl SubprocessPluginManager { cmd: post_cmd.cmd.clone(), env: post_cmd.env.clone(), }; - if let Err(e) = run_commands(&post_config, &[cmd]) { + if let Err(e) = run_plan_commands(&post_config, &[cmd]) { if options.verbose { eprintln!("Post-command failed: {e}"); } @@ -673,6 +741,19 @@ fn is_executable(path: &Path) -> bool { mod tests { use super::*; + fn plugin_info(name: &str, commands: &[&str]) -> PluginInfo { + PluginInfo { + name: name.to_string(), + version: "1.0.0".to_string(), + commands: commands + .iter() + .map(|command| (*command).to_string()) + .collect(), + description: None, + help: None, + } + } + #[test] fn test_plugin_manager_new() { let manager = SubprocessPluginManager::new(); @@ -741,6 +822,100 @@ mod tests { assert!(options.exclude_filters.is_none()); } + #[test] + fn test_only_rust_namespace_commands_disable_loop_aliases() { + let rust = plugin_info("rust", &["cargo", "rust"]); + for command in ["cargo", "cargo clean", "rust", "rust test"] { + assert!(is_rust_namespace_command(&rust, command), "{command}"); + } + + // Legacy exact registrations still represent the same Cargo authority + // boundary even though they do not own a namespace root. + let legacy_rust = plugin_info("rust", &["cargo build", "rust test"]); + assert!(is_rust_namespace_command(&legacy_rust, "cargo build")); + assert!(is_rust_namespace_command(&legacy_rust, "rust test")); + + for (plugin, command) in [ + (plugin_info("git", &["git status"]), "git status"), + (plugin_info("cargo-wrapper", &["cargo"]), "cargo"), + (plugin_info("rust", &["build"]), "build"), + ] { + assert!(!is_rust_namespace_command(&plugin, command), "{command}"); + } + } + + #[test] + fn test_rust_plan_execution_does_not_reapply_directory_filters() { + let options = PluginRequestOptions { + json_output: true, + parallel: true, + dry_run: true, + include_filters: Some(vec!["included".to_string()]), + exclude_filters: Some(vec!["excluded".to_string()]), + ..Default::default() + }; + let plugin = plugin_info("rust", &["cargo", "rust"]); + + for namespace in ["cargo", "rust"] { + let execution = options_for_plan_execution(&plugin, namespace, &options); + + assert!(execution.include_filters.is_none()); + assert!(execution.exclude_filters.is_none()); + assert!(execution.json_output); + assert!(execution.parallel); + assert!(execution.dry_run); + } + + // Planning still receives the original options unchanged. + assert_eq!( + options.include_filters.as_deref(), + Some(&["included".to_string()][..]) + ); + assert_eq!( + options.exclude_filters.as_deref(), + Some(&["excluded".to_string()][..]) + ); + } + + #[test] + fn test_other_plugin_plans_keep_directory_filters() { + let options = PluginRequestOptions { + include_filters: Some(vec!["included".to_string()]), + exclude_filters: Some(vec!["excluded".to_string()]), + ..Default::default() + }; + + for (plugin, command) in [ + (plugin_info("git", &["git status"]), "git status"), + (plugin_info("cargo-wrapper", &["cargo"]), "cargo"), + ] { + let execution = options_for_plan_execution(&plugin, command, &options); + + assert_eq!(execution.include_filters, options.include_filters); + assert_eq!(execution.exclude_filters, options.exclude_filters); + } + + let non_namespace = + options_for_plan_execution(&plugin_info("rust", &["cargo", "rust"]), "build", &options); + assert_eq!(non_namespace.include_filters, options.include_filters); + assert_eq!(non_namespace.exclude_filters, options.exclude_filters); + } + + #[test] + fn test_legacy_rust_plugin_exact_commands_keep_directory_filters() { + let options = PluginRequestOptions { + include_filters: Some(vec!["included".to_string()]), + exclude_filters: Some(vec!["excluded".to_string()]), + ..Default::default() + }; + let legacy = plugin_info("rust", &["cargo build", "cargo test"]); + + let execution = options_for_plan_execution(&legacy, "cargo build", &options); + + assert_eq!(execution.include_filters, options.include_filters); + assert_eq!(execution.exclude_filters, options.exclude_filters); + } + #[test] fn test_handles_command_matching() { let mut manager = SubprocessPluginManager::new(); diff --git a/tests/command_forwarding.rs b/tests/command_forwarding.rs new file mode 100644 index 0000000..b429946 --- /dev/null +++ b/tests/command_forwarding.rs @@ -0,0 +1,118 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Command; +use tempfile::tempdir; + +fn write_executable(path: &Path, contents: &str) { + fs::write(path, contents).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); +} + +#[test] +fn prefix_help_does_not_execute_non_cargo_plugin() { + let temp = tempdir().unwrap(); + let plugin_dir = temp.path().join("bin"); + fs::create_dir(&plugin_dir).unwrap(); + let marker = temp.path().join("plugin-executed"); + + write_executable( + &plugin_dir.join("meta-git"), + r#"#!/bin/sh +if [ "$1" = "--meta-plugin-info" ]; then + printf '%s\n' '{"name":"git","version":"1.0.0","commands":["git"]}' + exit 0 +fi +if [ "$1" = "--meta-plugin-exec" ]; then + IFS= read -r request || : + : > "$META_TEST_MARKER" + printf '%s\n' '{"plan":{"commands":[]}}' + exit 0 +fi +exit 1 +"#, + ); + + let run = |args: &[&str]| { + Command::new(assert_cmd::cargo::cargo_bin!("meta")) + .current_dir(temp.path()) + .env("PATH", &plugin_dir) + .env("HOME", temp.path()) + .env("META_DATA_DIR", temp.path().join("meta-data")) + .env("META_TEST_MARKER", &marker) + .args(args) + .output() + .unwrap() + }; + + for args in [ + &["--help", "git", "pull"][..], + &["--help", "git", "clone", "https://example.invalid/repo.git"][..], + ] { + let output = run(args); + assert!(output.status.success(), "args: {args:?}"); + assert!(String::from_utf8_lossy(&output.stdout).contains("Usage:")); + assert!(!marker.exists(), "prefix help executed {args:?}"); + } + + fs::write(temp.path().join(".meta"), r#"{"projects":{}}"#).unwrap(); + let output = run(&["--help", "git", "pull"]); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stdout).contains("Usage:")); + assert!(!marker.exists(), "prefix help executed in a Meta workspace"); +} + +#[test] +fn child_only_plugin_plan_keeps_the_actual_meta_root_label() { + let temp = tempdir().unwrap(); + let plugin_dir = temp.path().join("bin"); + let child = temp.path().join("child"); + fs::create_dir(&plugin_dir).unwrap(); + fs::create_dir(&child).unwrap(); + fs::write( + temp.path().join(".meta"), + r#"{"projects":{"child":{"repo":"https://example.invalid/child.git","path":"child"}}}"#, + ) + .unwrap(); + + write_executable( + &plugin_dir.join("meta-rust"), + r#"#!/bin/sh +if [ "$1" = "--meta-plugin-info" ]; then + printf '%s\n' '{"name":"rust","version":"1.0.0","commands":["cargo","rust"]}' + exit 0 +fi +if [ "$1" = "--meta-plugin-exec" ]; then + IFS= read -r request || : + printf '{"plan":{"commands":[{"dir":"%s","cmd":"printf child-output"}],"parallel":false}}\n' "$META_TEST_CHILD" + exit 0 +fi +exit 1 +"#, + ); + + let data_dir = temp.path().join("meta-data"); + fs::create_dir(&data_dir).unwrap(); + let output = Command::new(assert_cmd::cargo::cargo_bin!("meta")) + .current_dir(temp.path()) + .env("PATH", &plugin_dir) + .env("HOME", temp.path()) + .env("META_DATA_DIR", data_dir) + .env("META_TEST_CHILD", &child) + .args(["--sequential", "--include", "child", "cargo", "check"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("✓ child"), "stdout: {stdout}"); + assert!(!stdout.contains("✓ . (child)"), "stdout: {stdout}"); +}