use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::time::Duration;
use clap::{Args, Parser};
#[derive(Parser, Debug)]
#[command(name = "cargo", bin_name = "cargo")]
pub(crate) enum CargoCli {
Each(EachArgs),
}
#[derive(Args, Debug, Clone)]
#[command(
version,
about = "Run a command over a cargo-style selection of workspace members",
long_about = "Resolve a cargo-style package selection (-p/--package, --workspace, --exclude), \
optionally filter it with Boolean expressions over Cargo metadata, and run a command over the result \
— once per member, once per matching Cargo target, or once for the whole set."
)]
#[expect(clippy::struct_excessive_bools, reason = "each bool is an independent clap CLI flag")]
pub(crate) struct EachArgs {
#[arg(short = 'p', long = "package", value_name = "SPEC")]
pub(crate) packages: Vec<String>,
#[arg(long = "package-file", value_name = "PATH")]
pub(crate) package_files: Vec<PathBuf>,
#[arg(long, visible_alias = "all")]
pub(crate) workspace: bool,
#[arg(long, value_name = "SPEC", requires = "workspace")]
pub(crate) exclude: Vec<String>,
#[arg(long)]
pub(crate) none: bool,
#[arg(long = "filter", value_name = "EXPR")]
pub(crate) filters: Vec<String>,
#[arg(long = "exclude-filter", value_name = "EXPR")]
pub(crate) exclude_filters: Vec<String>,
#[arg(long)]
pub(crate) once: bool,
#[arg(long = "each-target", value_name = "KIND", conflicts_with = "once")]
pub(crate) each_targets: Vec<String>,
#[arg(long, value_name = "FEATURE", requires = "each_targets")]
pub(crate) target_required_feature: Vec<String>,
#[arg(long)]
pub(crate) chdir: bool,
#[arg(long)]
pub(crate) keep_going: bool,
#[arg(long, default_value_t = NonZeroUsize::MIN, value_name = "N|auto", value_parser = parse_jobs)]
pub(crate) jobs: NonZeroUsize,
#[arg(long, value_name = "DURATION", value_parser = parse_duration)]
pub(crate) timeout: Option<Duration>,
#[arg(long)]
pub(crate) dry_run: bool,
#[arg(long, value_name = "PATH")]
pub(crate) manifest_path: Option<PathBuf>,
#[arg(last = true, required = true, value_name = "COMMAND")]
pub(crate) command: Vec<String>,
}
fn parse_jobs(value: &str) -> Result<NonZeroUsize, String> {
parse_jobs_with(value, std::thread::available_parallelism)
}
fn parse_jobs_with(value: &str, available_parallelism: impl FnOnce() -> std::io::Result<NonZeroUsize>) -> Result<NonZeroUsize, String> {
if value == "auto" {
available_parallelism().map_err(|error| format!("failed to detect available parallelism for `--jobs auto`: {error}"))
} else {
value
.parse()
.map_err(|error| format!("expected a positive integer or `auto`: {error}"))
}
}
fn parse_duration(value: &str) -> Result<Duration, String> {
enum Unit {
Milliseconds,
Seconds,
Minutes,
}
let (digits, unit) = if let Some(digits) = value.strip_suffix("ms") {
(digits, Unit::Milliseconds)
} else if let Some(digits) = value.strip_suffix('s') {
(digits, Unit::Seconds)
} else if let Some(digits) = value.strip_suffix('m') {
(digits, Unit::Minutes)
} else {
return Err("expected a positive integer followed by `ms`, `s`, or `m`".to_owned());
};
if digits.is_empty() {
return Err("expected a positive integer followed by `ms`, `s`, or `m`".to_owned());
}
if !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("expected a positive integer followed by `ms`, `s`, or `m`".to_owned());
}
let amount = digits.parse::<u64>().map_err(|error| format!("duration is too large: {error}"))?;
if amount == 0 {
return Err("duration must be greater than zero".to_owned());
}
match unit {
Unit::Milliseconds => Ok(Duration::from_millis(amount)),
Unit::Seconds => Ok(Duration::from_secs(amount)),
Unit::Minutes => amount
.checked_mul(60)
.map(Duration::from_secs)
.ok_or_else(|| "duration is too large".to_owned()),
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use clap::{CommandFactory, Parser as _};
use super::*;
#[test]
fn cli_definition_is_well_formed() {
CargoCli::command().debug_assert();
}
#[test]
fn jobs_default_is_one() {
let CargoCli::Each(args) =
CargoCli::try_parse_from(["cargo", "each", "--", "echo"]).expect("documented minimal invocation must parse");
assert_eq!(args.jobs, NonZeroUsize::MIN);
}
#[test]
fn parses_positive_and_auto_jobs() {
assert_eq!(
parse_jobs_with("7", || panic!("numeric jobs must not detect parallelism")),
Ok(NonZeroUsize::new(7).expect("literal seven is nonzero"))
);
let mut detections = 0;
let jobs = parse_jobs_with("auto", || {
detections += 1;
Ok(NonZeroUsize::new(8).expect("literal eight is nonzero"))
});
assert_eq!(jobs, Ok(NonZeroUsize::new(8).expect("literal eight is nonzero")));
assert_eq!(detections, 1);
}
#[test]
fn rejects_invalid_jobs() {
for value in ["0", "-1", "bogus", "AUTO"] {
assert!(parse_jobs(value).is_err(), "{value}");
}
}
#[test]
fn reports_auto_jobs_detection_failure() {
let error = parse_jobs_with("auto", || Err(std::io::Error::other("parallelism unavailable")))
.expect_err("detection failure must not fall back");
assert_eq!(
error,
"failed to detect available parallelism for `--jobs auto`: parallelism unavailable"
);
}
#[test]
fn parses_documented_durations() {
assert_eq!(parse_duration("250ms"), Ok(Duration::from_millis(250)));
assert_eq!(parse_duration("30s"), Ok(Duration::from_secs(30)));
assert_eq!(parse_duration("2m"), Ok(Duration::from_mins(2)));
}
#[test]
fn rejects_zero_malformed_and_overflowing_durations() {
for value in ["0s", "1", "1h", "-1s", "1.5s", "ms", "18446744073709551615m"] {
assert!(parse_duration(value).is_err(), "{value}");
}
}
#[test]
fn malformed_duration_uses_the_grammar_diagnostic() {
let expected = Err("expected a positive integer followed by `ms`, `s`, or `m`".to_owned());
assert_eq!(parse_duration("ms"), expected);
assert_eq!(parse_duration("1.5s"), expected);
}
}