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 Cargo workspace members or JSON records",
long_about = "Resolve either a cargo-style package selection (-p/--package, --workspace, --exclude) \
with optional Cargo metadata filters, or JSON Lines records, then run a command once per member, \
matching Cargo target, whole selected set, or JSON record."
)]
#[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 = "json-lines", value_name = "JSONL")]
pub(crate) json_lines: Vec<String>,
#[arg(long = "json-lines-file", value_name = "PATH")]
pub(crate) json_lines_files: Vec<PathBuf>,
#[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() {
let parse_overflow = format!("{}s", u128::from(u64::MAX) + 1);
let multiplication_overflow = format!("{}m", u64::MAX);
for (value, expected) in [
("0s", "duration must be greater than zero"),
("1", "expected a positive integer followed by `ms`, `s`, or `m`"),
("1h", "expected a positive integer followed by `ms`, `s`, or `m`"),
("-1s", "expected a positive integer followed by `ms`, `s`, or `m`"),
("1.5s", "expected a positive integer followed by `ms`, `s`, or `m`"),
("ms", "expected a positive integer followed by `ms`, `s`, or `m`"),
(&parse_overflow, "duration is too large: number too large to fit in target type"),
(&multiplication_overflow, "duration is too large"),
] {
assert_eq!(parse_duration(value).expect_err(value), expected, "{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);
}
}