use clap::{Args, Parser, Subcommand};
use std::ffi::OsString;
use std::path::PathBuf;
fn parse_clean_age(value: &str) -> Result<std::time::Duration, &'static str> {
let unit_start = value
.find(|character: char| !character.is_ascii_digit())
.ok_or("expected a whole number followed by s, m, h, or d (e.g. 24h)")?;
let (amount, unit) = value.split_at(unit_start);
let multiplier = match unit {
"s" => 1,
"m" => 60,
"h" => 60 * 60,
"d" => 24 * 60 * 60,
_ => return Err("expected a whole number followed by s, m, h, or d (e.g. 24h)"),
};
if amount.is_empty() {
return Err("expected a whole number followed by s, m, h, or d (e.g. 24h)");
}
let amount: u64 = amount.parse().map_err(|_| "duration is too large")?;
let seconds = amount
.checked_mul(multiplier)
.ok_or("duration is too large")?;
Ok(std::time::Duration::from_secs(seconds))
}
fn parse_test_filter(value: &str) -> Result<String, String> {
regex::Regex::new(value)
.map(|_| value.to_string())
.map_err(|error| format!("invalid test-name regex: {error}"))
}
fn reject_filterset(_: &str) -> Result<String, String> {
Err(
"filtersets are not supported; pass one or more positional REGEX filters instead"
.to_string(),
)
}
#[derive(Debug, Parser)]
#[command(
name = "corgi",
version,
about = "Cargo-compatible builds with a shared cache",
flatten_help = true
)]
pub struct Cli {
#[arg(short = 'C', long, global = true, value_name = "DIR")]
pub dir: Option<PathBuf>,
#[arg(long, global = true, value_name = "PATH", conflicts_with = "dir")]
pub manifest_path: Option<PathBuf>,
#[arg(short, long, global = true)]
pub verbose: bool,
#[command(subcommand)]
pub command: Option<Command>,
}
pub fn invocation_directory(argv: &[OsString]) -> Option<PathBuf> {
let mut directory = None;
let mut arguments = argv.iter().skip(1);
while let Some(argument) = arguments.next() {
if argument == "--" {
break;
}
if argument == "-C" || argument == "--dir" {
directory = arguments.next().map(PathBuf::from);
} else if argument == "--manifest-path" {
directory = arguments
.next()
.and_then(|value| std::path::Path::new(value).parent())
.map(PathBuf::from);
} else if let Some(value) = argument
.to_str()
.and_then(|argument| argument.strip_prefix("--manifest-path="))
{
directory = std::path::Path::new(value).parent().map(PathBuf::from);
} else if let Some(value) = argument
.to_str()
.and_then(|argument| argument.strip_prefix("--dir="))
{
directory = Some(PathBuf::from(value));
} else if let Some(value) = argument
.to_str()
.and_then(|argument| argument.strip_prefix("-C"))
.filter(|value| !value.is_empty())
{
directory = Some(PathBuf::from(value));
}
}
directory
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(visible_alias = "b")]
Build(WorkspaceBuildArgs),
Bench(BenchArgs),
#[command(visible_alias = "c")]
Check(WorkspaceBuildArgs),
Clippy(ClippyArgs),
#[command(visible_alias = "r")]
Run(RunArgs),
#[command(visible_alias = "t")]
Test(TestArgs),
Fmt(FmtArgs),
Audit(AuditArgs),
Clean(CleanArgs),
Pin,
}
#[derive(Debug, Args, Default)]
pub struct BuildArgs {
#[arg(long, conflicts_with = "profile")]
pub release: bool,
#[arg(long, value_name = "PROFILE")]
pub profile: Option<String>,
#[arg(short = 'p', long = "package", value_name = "PACKAGE")]
pub packages: Vec<String>,
#[arg(short = 'F', long, value_name = "FEATURES", value_delimiter = ',')]
pub features: Vec<String>,
#[arg(long)]
pub all_features: bool,
#[arg(long, value_name = "TRIPLE")]
pub target: Option<String>,
#[arg(long, value_name = "DIR")]
pub target_dir: Option<PathBuf>,
#[arg(long, value_name = "NAME")]
pub root: Option<String>,
#[arg(long)]
pub timings: bool,
#[arg(long)]
pub no_incremental: bool,
}
#[derive(Debug, Args, Default)]
pub struct TargetSelectionArgs {
#[arg(long = "bin", value_name = "NAME")]
pub named_bins: Vec<String>,
#[arg(long = "test", value_name = "NAME")]
pub named_tests: Vec<String>,
#[arg(long = "bench", value_name = "NAME")]
pub named_benches: Vec<String>,
#[arg(long = "example", value_name = "NAME")]
pub named_examples: Vec<String>,
#[arg(long)]
pub lib: bool,
#[arg(long)]
pub bins: bool,
#[arg(long)]
pub tests: bool,
#[arg(long = "benches")]
pub all_benches: bool,
#[arg(long)]
pub examples: bool,
#[arg(long)]
pub all_targets: bool,
}
#[derive(Debug, Args, Default)]
pub struct WorkspaceBuildArgs {
#[command(flatten)]
pub build: BuildArgs,
#[command(flatten)]
pub targets: TargetSelectionArgs,
#[arg(long, conflicts_with_all = ["packages", "root"])]
pub workspace: bool,
}
#[derive(Debug, Args)]
pub struct ClippyArgs {
#[command(flatten)]
pub build: WorkspaceBuildArgs,
#[arg(last = true, value_name = "CLIPPY_ARGS")]
pub clippy_args: Vec<String>,
}
#[derive(Debug, Args)]
pub struct RunArgs {
#[command(flatten)]
pub build: BuildArgs,
#[arg(long, value_name = "NAME", conflicts_with = "example")]
pub bin: Option<String>,
#[arg(long, value_name = "NAME")]
pub example: Option<String>,
#[arg(last = true, value_name = "ARGS")]
pub exec_args: Vec<String>,
}
#[derive(Debug, Args)]
pub struct TestArgs {
#[command(flatten)]
pub build: BuildArgs,
#[command(flatten)]
pub targets: TargetSelectionArgs,
#[arg(long, conflicts_with_all = ["packages", "root"])]
pub workspace: bool,
#[arg(short = 'f', long, visible_alias = "no-cache")]
pub force: bool,
#[arg(long, value_name = "SECONDS")]
pub timeout: Option<u64>,
#[arg(value_name = "REGEX", value_parser = parse_test_filter)]
pub filters: Vec<String>,
#[arg(short = 'E', value_name = "FILTERSET", value_parser = reject_filterset, hide = true)]
pub filterset: Option<String>,
#[arg(last = true, value_name = "ARGS")]
pub exec_args: Vec<String>,
}
#[derive(Debug, Args)]
pub struct BenchArgs {
#[command(flatten)]
pub build: WorkspaceBuildArgs,
#[arg(value_name = "BENCHNAME")]
pub filter: Option<String>,
#[arg(last = true, value_name = "ARGS")]
pub exec_args: Vec<String>,
}
#[derive(Debug, Args)]
pub struct FmtArgs {
#[arg(long, visible_alias = "all", conflicts_with = "packages")]
pub workspace: bool,
#[arg(short = 'p', long = "package", value_name = "PACKAGE")]
pub packages: Vec<String>,
#[arg(
value_name = "FMT_ARGS",
num_args = 0..,
trailing_var_arg = true,
allow_hyphen_values = true
)]
pub args: Vec<String>,
}
#[derive(Debug, Args)]
pub struct AuditArgs {
#[arg(long)]
pub release: bool,
#[arg(long, value_name = "TRIPLE")]
pub target: Option<String>,
#[arg(long, value_name = "NAME")]
pub root: Option<String>,
}
#[derive(Debug, Args)]
pub struct CleanArgs {
#[arg(long)]
pub cache: bool,
#[arg(long, value_name = "DURATION", value_parser = parse_clean_age, conflicts_with = "cache")]
pub older_than: Option<std::time::Duration>,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn flattened_help_is_the_complete_cheat_sheet() {
let mut output = Vec::new();
Cli::command().write_long_help(&mut output).unwrap();
let help = String::from_utf8(output).unwrap();
for expected in [
"corgi build:",
"corgi bench:",
"corgi check:",
"corgi clippy:",
"corgi run:",
"corgi test:",
"corgi fmt:",
"corgi audit:",
"corgi clean:",
"corgi pin:",
"--no-incremental",
"--all-targets",
"--all-features",
"--lib",
"--bins",
"--tests",
"--benches",
"--examples",
"--features",
"--timeout",
"--cache",
"--older-than",
] {
assert!(help.contains(expected), "help omitted {expected}");
}
}
#[test]
fn test_timeout_is_optional_and_measured_in_seconds() {
let cli = Cli::try_parse_from(["corgi", "test"]).unwrap();
let Some(Command::Test(args)) = cli.command else {
panic!("test command not parsed");
};
assert_eq!(args.timeout, None);
let cli = Cli::try_parse_from(["corgi", "test", "--timeout", "15"]).unwrap();
let Some(Command::Test(args)) = cli.command else {
panic!("test command not parsed");
};
assert_eq!(args.timeout, Some(15));
}
#[test]
fn test_filters_are_repeatable_regular_expressions() {
let cli = Cli::try_parse_from(["corgi", "test", "^parser::", "empty$"]).unwrap();
let Some(Command::Test(args)) = cli.command else {
panic!("test command not parsed");
};
assert_eq!(args.filters, ["^parser::", "empty$"]);
let error = Cli::try_parse_from(["corgi", "test", "["]).unwrap_err();
assert!(error.to_string().contains("invalid test-name regex"));
}
#[test]
fn nextest_filtersets_point_to_positional_regexes() {
let error = Cli::try_parse_from(["corgi", "test", "-E", "test(foo)"]).unwrap_err();
assert!(error
.to_string()
.contains("pass one or more positional REGEX filters instead"));
}
#[test]
fn no_command_is_available_for_default_help_and_globals_work_on_subcommands() {
let cli = Cli::try_parse_from(["corgi"]).unwrap();
assert!(cli.command.is_none());
let cli = Cli::try_parse_from(["corgi", "build", "-C", "project", "-v"]).unwrap();
assert_eq!(cli.dir.as_deref(), Some(std::path::Path::new("project")));
assert!(cli.verbose);
assert!(matches!(cli.command, Some(Command::Build(_))));
}
#[test]
fn partial_parse_finds_directory_before_newer_syntax() {
for arguments in [
vec!["corgi", "build", "-C", "project", "--future-option"],
vec!["corgi", "build", "--future-option", "-C", "project"],
vec!["corgi", "build", "-Cproject", "--future-option"],
vec!["corgi", "build", "--dir=project", "--future-option"],
vec![
"corgi",
"build",
"--manifest-path",
"project/Cargo.toml",
"--future-option",
],
vec![
"corgi",
"build",
"--manifest-path=project/Cargo.toml",
"--future-option",
],
] {
let argv = arguments
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>();
assert_eq!(
invocation_directory(&argv).as_deref(),
Some(std::path::Path::new("project"))
);
}
}
#[test]
fn partial_parse_ignores_child_arguments_after_delimiter() {
let argv = ["corgi", "run", "--", "-C", "child-directory"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>();
assert_eq!(invocation_directory(&argv), None);
}
#[test]
fn clean_uses_a_normal_long_cache_flag() {
let cli = Cli::try_parse_from(["corgi", "clean", "--cache"]).unwrap();
let Some(Command::Clean(args)) = cli.command else {
panic!("clean command not parsed");
};
assert!(args.cache);
assert!(Cli::try_parse_from(["corgi", "clean", "-cache"]).is_err());
}
#[test]
fn clean_age_requires_explicit_units_and_checked_whole_numbers() {
for (value, seconds) in [
("0s", 0),
("30m", 1800),
("24h", 86400),
("7d", 604800),
("18446744073709551615s", u64::MAX),
] {
let cli = Cli::try_parse_from(["corgi", "clean", "--older-than", value]).unwrap();
let Some(Command::Clean(args)) = cli.command else {
panic!("clean command not parsed");
};
assert_eq!(
args.older_than,
Some(std::time::Duration::from_secs(seconds))
);
}
for value in [
"",
"24",
"-1h",
"+1h",
"1.5h",
"h",
"1w",
"1H",
"1h30m",
" 1h",
"18446744073709551616s",
"18446744073709551615d",
] {
assert!(
Cli::try_parse_from(["corgi", "clean", "--older-than", value]).is_err(),
"accepted invalid duration {value:?}"
);
}
let cli = Cli::try_parse_from(["corgi", "clean"]).unwrap();
let Some(Command::Clean(args)) = cli.command else {
panic!("clean command not parsed");
};
assert_eq!(args.older_than, None);
assert!(Cli::try_parse_from(["corgi", "clean", "--cache", "--older-than", "24h"]).is_err());
assert!(Cli::try_parse_from(["corgi", "clean", "--older-than", "24h", "--cache"]).is_err());
}
#[test]
fn pin_has_no_arguments() {
assert!(matches!(
Cli::try_parse_from(["corgi", "pin"]).unwrap().command,
Some(Command::Pin)
));
assert!(Cli::try_parse_from(["corgi", "pin", "1.2.3"]).is_err());
}
#[test]
fn workspace_conflicts_are_parser_errors() {
assert!(
Cli::try_parse_from(["corgi", "build", "--workspace", "--package", "app"]).is_err()
);
assert!(Cli::try_parse_from(["corgi", "run", "--workspace"]).is_err());
}
#[test]
fn features_accept_repeated_and_comma_separated_values() {
let cli = Cli::try_parse_from([
"corgi",
"build",
"-p",
"app",
"--features",
"alpha,beta",
"-F",
"gamma",
])
.unwrap();
let Some(Command::Build(args)) = cli.command else {
panic!("build command not parsed");
};
assert_eq!(args.build.features, ["alpha", "beta", "gamma"]);
}
#[test]
fn package_selection_accepts_repeated_values() {
let cli =
Cli::try_parse_from(["corgi", "check", "-p", "app", "--package", "server"]).unwrap();
let Some(Command::Check(args)) = cli.command else {
panic!("check command not parsed");
};
assert_eq!(args.build.packages, ["app", "server"]);
}
#[test]
fn no_cache_forces_test_execution() {
for argument in ["--force", "--no-cache"] {
let cli = Cli::try_parse_from(["corgi", "test", argument]).unwrap();
let Some(Command::Test(args)) = cli.command else {
panic!("test command not parsed");
};
assert!(args.force);
}
}
#[test]
fn build_accepts_profile_and_binary_selection() {
let cli = Cli::try_parse_from([
"corgi",
"build",
"--profile",
"runner-dev",
"--bin",
"runner",
])
.unwrap();
let Some(Command::Build(args)) = cli.command else {
panic!("build command not parsed");
};
assert_eq!(args.build.profile.as_deref(), Some("runner-dev"));
assert_eq!(args.targets.named_bins, ["runner"]);
}
#[test]
fn target_selection_is_shared_by_build_check_clippy_and_test() {
let cli = Cli::try_parse_from([
"corgi",
"check",
"--lib",
"--bins",
"--tests",
"--benches",
"--examples",
"--all-targets",
])
.unwrap();
let Some(Command::Check(args)) = cli.command else {
panic!("check command not parsed");
};
assert!(args.targets.lib);
assert!(args.targets.bins);
assert!(args.targets.tests);
assert!(args.targets.all_benches);
assert!(args.targets.examples);
assert!(args.targets.all_targets);
let cli = Cli::try_parse_from(["corgi", "build", "--examples"]).unwrap();
let Some(Command::Build(args)) = cli.command else {
panic!("build command not parsed");
};
assert!(args.targets.examples);
let cli = Cli::try_parse_from(["corgi", "clippy", "--tests"]).unwrap();
let Some(Command::Clippy(args)) = cli.command else {
panic!("clippy command not parsed");
};
assert!(args.build.targets.tests);
let cli = Cli::try_parse_from(["corgi", "test", "--lib"]).unwrap();
let Some(Command::Test(args)) = cli.command else {
panic!("test command not parsed");
};
assert!(args.targets.lib);
}
#[test]
fn all_features_is_parsed_for_the_actionable_runtime_error() {
let cli = Cli::try_parse_from(["corgi", "clippy", "--all-features"]).unwrap();
let Some(Command::Clippy(args)) = cli.command else {
panic!("clippy command not parsed");
};
assert!(args.build.build.all_features);
}
#[test]
fn run_selects_exactly_one_binary_or_example() {
for selector in ["--bin", "--example"] {
let cli =
Cli::try_parse_from(["corgi", "run", selector, "demo", "--", "argument"]).unwrap();
let Some(Command::Run(args)) = cli.command else {
panic!("run command not parsed");
};
if selector == "--bin" {
assert_eq!(args.bin.as_deref(), Some("demo"));
assert!(args.example.is_none());
} else {
assert_eq!(args.example.as_deref(), Some("demo"));
assert!(args.bin.is_none());
}
assert_eq!(args.exec_args, ["argument"]);
assert!(
Cli::try_parse_from(["corgi", "run", selector, "first", selector, "second",])
.is_err()
);
}
assert!(
Cli::try_parse_from(["corgi", "run", "--bin", "first", "--example", "second",])
.is_err()
);
}
#[test]
fn named_targets_are_shared_across_build_commands() {
for command in ["build", "check", "clippy", "test", "bench"] {
let cli = Cli::try_parse_from([
"corgi",
command,
"--bin",
"first",
"--bin",
"second",
"--test",
"integration",
"--test",
"second",
"--bench",
"throughput",
"--bench",
"latency",
"--example",
"demo",
"--example",
"other",
])
.unwrap();
let targets = match cli.command.unwrap() {
Command::Build(args) | Command::Check(args) => args.targets,
Command::Clippy(args) => args.build.targets,
Command::Test(args) => args.targets,
Command::Bench(args) => args.build.targets,
_ => panic!("unexpected command"),
};
assert_eq!(targets.named_bins, ["first", "second"]);
assert_eq!(targets.named_tests, ["integration", "second"]);
assert_eq!(targets.named_benches, ["throughput", "latency"]);
assert_eq!(targets.named_examples, ["demo", "other"]);
}
}
#[test]
fn check_and_bench_accept_benchmark_selection() {
let cli =
Cli::try_parse_from(["corgi", "check", "-p", "app", "--bench", "throughput"]).unwrap();
let Some(Command::Check(args)) = cli.command else {
panic!("check command not parsed");
};
assert_eq!(args.targets.named_benches, ["throughput"]);
let cli = Cli::try_parse_from([
"corgi",
"bench",
"-p",
"app",
"--bench",
"throughput",
"--bench",
"latency",
"parse",
"--",
"--sample-size",
"10",
])
.unwrap();
let Some(Command::Bench(args)) = cli.command else {
panic!("bench command not parsed");
};
assert_eq!(args.build.targets.named_benches, ["throughput", "latency"]);
assert_eq!(args.filter.as_deref(), Some("parse"));
assert_eq!(args.exec_args, ["--sample-size", "10"]);
let cli = Cli::try_parse_from([
"corgi",
"bench",
"--bin",
"application",
"--bench",
"throughput",
])
.unwrap();
let Some(Command::Bench(args)) = cli.command else {
panic!("bench command not parsed");
};
assert_eq!(args.build.targets.named_bins, ["application"]);
assert_eq!(args.build.targets.named_benches, ["throughput"]);
}
#[test]
fn execution_arguments_require_the_delimiter() {
let cli = Cli::try_parse_from(["corgi", "test", "parser", "--", "--nocapture"]).unwrap();
let Some(Command::Test(args)) = cli.command else {
panic!("test command not parsed");
};
assert_eq!(args.filters, ["parser"]);
assert_eq!(args.exec_args, ["--nocapture"]);
assert!(Cli::try_parse_from(["corgi", "run", "--port", "8080"]).is_err());
}
#[test]
fn clippy_accepts_all_targets_and_delimited_arguments() {
let cli = Cli::try_parse_from([
"corgi",
"clippy",
"-p",
"app",
"--all-targets",
"--",
"-D",
"warnings",
])
.unwrap();
let Some(Command::Clippy(args)) = cli.command else {
panic!("clippy command not parsed");
};
assert_eq!(args.build.build.packages, ["app"]);
assert!(args.build.targets.all_targets);
assert_eq!(args.clippy_args, ["-D", "warnings"]);
assert!(Cli::try_parse_from(["corgi", "clippy", "-D", "warnings"]).is_err());
}
#[test]
fn fmt_accepts_direct_and_delimited_passthrough() {
let cli = Cli::try_parse_from(["corgi", "fmt", "--check"]).unwrap();
let Some(Command::Fmt(args)) = cli.command else {
panic!("fmt command not parsed");
};
assert_eq!(args.args, ["--check"]);
let cli = Cli::try_parse_from(["corgi", "fmt", "--", "--check"]).unwrap();
let Some(Command::Fmt(args)) = cli.command else {
panic!("fmt command not parsed");
};
assert_eq!(args.args, ["--check"]);
}
}