use core::panic::AssertUnwindSafe;
use std::ffi::OsString;
use std::io::Write;
use std::panic;
use clap::Parser;
use clap::error::ErrorKind;
use super::clean::clean;
use super::cli::{Cli, Command, SelectArgs};
use super::completions::completions;
use super::explain::explain;
use super::hints::hints_with_cargo;
use super::host::Host;
use super::list::list_with_cargo;
use super::merge::merge;
use super::run::{configure, run_session};
use super::suppress::suppress;
use super::unsuppress::unsuppress_with_cargo;
use crate::config::Config;
use crate::error::error;
use crate::report::Styler;
pub(super) const DEFAULT_TEST_TIMEOUT_MULTIPLIER: f64 = 1.5;
pub const EXIT_OK: i32 = 0;
pub const EXIT_USAGE: i32 = 1;
pub const EXIT_GATE_FAILED: i32 = 2;
pub const EXIT_CANNOT_PROCEED: i32 = 3;
pub const EXIT_INTERNAL: i32 = 70;
pub fn run<H: Host>(host: &mut H, args: impl IntoIterator<Item = impl Into<OsString> + Clone>) -> i32 {
#[cfg(windows)]
cargo_gamma_unsafe::job::suppress_error_dialogs();
let notes = crate::notes::Run::new();
let _notes = crate::notes::enter(Some(¬es));
match panic::catch_unwind(AssertUnwindSafe(|| dispatched(host, args))) {
Ok(code) => code,
Err(_payload) => EXIT_INTERNAL,
}
}
fn dispatched<H: Host>(host: &mut H, args: impl IntoIterator<Item = impl Into<OsString> + Clone>) -> i32 {
let normalized = normalize(args);
let cli = match Cli::try_parse_from(normalized) {
Ok(cli) => cli,
Err(cause) => {
let is_help = matches!(cause.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion);
let text = cause.render().ansi().to_string();
if is_help {
let _ = write!(host.output(), "{text}");
return EXIT_OK;
}
let _ = write!(host.error(), "{text}");
return EXIT_USAGE;
}
};
let styler = Styler::new(cli.color.resolve(host.is_terminal()));
let code = match dispatch(host, cli, styler) {
Ok(code) => code,
Err(cause) => {
let code = if cause.is_usage() { EXIT_USAGE } else { EXIT_CANNOT_PROCEED };
let label = styler.error("error:");
let mut stream = host.error();
let _ = writeln!(stream, "{label} {cause}");
code
}
};
say_notes(host, styler);
code
}
fn say_notes<H: Host>(host: &mut H, styler: Styler) {
let notes = crate::notes::drain();
if notes.is_empty() {
return;
}
let label = styler.warning();
let mut stream = host.error();
for note in notes {
let _ = writeln!(stream, "{label} {note}");
}
}
fn normalize(args: impl IntoIterator<Item = impl Into<OsString> + Clone>) -> Vec<OsString> {
let mut normalized: Vec<OsString> = args.into_iter().map(Into::into).collect();
if normalized.get(1).is_some_and(|entry| entry == "gamma") {
let _ = normalized.remove(1);
}
if !normalized.is_empty() && implies_run(normalized.get(1..).unwrap_or_default()) {
normalized.insert(1, "run".into());
}
normalized
}
const GLOBAL_OPTIONS: [&str; 2] = ["--color", "--progress"];
fn implies_run(args: &[OsString]) -> bool {
let mut rest = args;
while let Some(first) = rest.first().and_then(|entry| entry.to_str()) {
if GLOBAL_OPTIONS
.iter()
.any(|option| first.strip_prefix(option).is_some_and(|rest| rest.starts_with('=')))
{
rest = &rest[1..];
} else if GLOBAL_OPTIONS.contains(&first) {
rest = rest.get(2..).unwrap_or_default();
} else {
break;
}
}
let Some(first) = rest.first().and_then(|entry| entry.to_str()) else {
return true;
};
first.starts_with('-') && !matches!(first, "-h" | "--help" | "-V" | "--version")
}
#[cfg(target_os = "linux")]
fn relaunch_for_memory_control<H: Host>(host: &H, args: &super::cli::RunArgs) -> Option<i32> {
use crate::exec::relaunch;
if !host.may_replace_process() || args.measure.no_relaunch || relaunch::relaunched() {
return None;
}
if !super::run::memory_policy(args).measuring() || crate::exec::memory_support().is_ok() {
return None;
}
relaunch::relaunch().ok().flatten()
}
pub(super) fn dispatch<H: Host>(host: &mut H, cli: Cli, styler: Styler) -> crate::Result<i32> {
match cli.command {
Command::Run(mut args) => {
configure(host, &mut args, styler)?;
check_shard(&args.select)?;
#[cfg(target_os = "linux")]
if let Some(code) = relaunch_for_memory_control(host, &args) {
return Ok(code);
}
run_session(host, &args, cli.progress, styler)
}
Command::List(mut args) => {
let config = Config::resolve(&args.select)?;
let cargo = config.cargo_options();
config.apply_selection(&mut args.select)?;
check_shard(&args.select)?;
list_with_cargo(host, &args, styler, &cargo)
}
Command::Explain(args) => explain(host, &args),
Command::Suppress(mut args) => {
configure(host, &mut args.run, styler)?;
check_shard(&args.run.select)?;
#[cfg(target_os = "linux")]
if let Some(code) = relaunch_for_memory_control(host, &args.run) {
return Ok(code);
}
suppress(host, &args, cli.progress, styler)
}
Command::Unsuppress(mut args) => {
let config = Config::resolve(&args.select)?;
let cargo = config.cargo_options();
config.apply_selection(&mut args.select)?;
check_shard(&args.select)?;
unsuppress_with_cargo(host, &args, styler, &cargo)
}
Command::Merge(args) => merge(host, &args, styler),
Command::Hints(mut args) => {
refuse_shard(&args.select)?;
let config = Config::resolve(&args.select)?;
let cargo = config.cargo_options();
config.apply_selection(&mut args.select)?;
hints_with_cargo(host, &args, styler, &cargo)
}
Command::Clean(args) => clean(host, &args, styler),
Command::Completions(args) => Ok(completions(host, &args)),
}
}
fn check_shard(select: &SelectArgs) -> crate::Result<()> {
let _shard = select.shard()?;
Ok(())
}
fn refuse_shard(select: &SelectArgs) -> crate::Result<()> {
if select.shard_count.is_none() && select.shard_index.is_none() {
return Ok(());
}
Err(error!(
"`hints` is deliberately unsharded: it promotes from the whole population, because a shard sees a fraction of it and every job in the matrix would race to overwrite the artifact. Drop `--shard-count` and `--shard-index`, and promote from one job rather than all of them"
)
.usage())
}
#[cfg(test)]
#[cfg(not(miri))]
mod tests {
use std::fs;
use std::sync::Barrier;
use super::*;
use crate::testing::Sink as TestSink;
struct ClosedOutput {
err: Vec<u8>,
}
impl Host for ClosedOutput {
fn output(&mut self) -> impl Write {
crate::testing::Broken
}
fn error(&mut self) -> impl Write {
&mut self.err
}
fn is_terminal(&self) -> bool {
false
}
fn terminal_width(&self) -> Option<u16> {
None
}
}
#[test]
fn a_closed_results_pipe_ends_a_listing_successfully_without_a_diagnostic() {
crate::notes::alone(|| {
let dir = crate_dir("dispatch-broken-pipe-", None);
let root = dir.path().to_string_lossy().into_owned();
let mut host = ClosedOutput { err: Vec::new() };
let code = run(&mut host, ["cargo-gamma", "gamma", "list", "mutants", "--dir", &root]);
assert_eq!(code, EXIT_OK);
assert!(host.err.is_empty(), "{}", String::from_utf8_lossy(&host.err));
});
}
#[test]
fn a_panic_inside_the_tool_is_reported_as_an_internal_error_rather_than_escaping() {
let previous = panic::take_hook();
panic::set_hook(Box::new(|_| {}));
let code = run(&mut Exploding, ["cargo-gamma", "gamma", "list", "files"]);
panic::set_hook(previous);
assert_eq!(code, EXIT_INTERNAL, "a panic reached the caller as something other than a tool bug");
}
struct Exploding;
impl Host for Exploding {
fn output(&mut self) -> impl Write {
Vec::new()
}
fn error(&mut self) -> impl Write {
Vec::new()
}
fn is_terminal(&self) -> bool {
panic!("a bug in the tool")
}
fn terminal_width(&self) -> Option<u16> {
None
}
}
fn crate_dir(name: &str, config: Option<&str>) -> tempfile::TempDir {
let (dir, root) = crate::fixtures::crate_dir(name, "pub fn less(a: i32, b: i32) -> bool { a < b }\n");
if let Some(text) = config {
fs::write(root.join("gamma.toml"), text).expect("config");
}
dir
}
#[test]
fn a_count_from_the_file_and_an_index_from_the_command_line_run() {
crate::notes::alone(|| {
let dir = crate_dir("dispatch-shard-split-", Some("[shard]\ncount = 3\n"));
let root = dir.path().to_string_lossy().into_owned();
let mut host = crate::testing::Sink::default();
let code = run(
&mut host,
["cargo-gamma", "gamma", "list", "mutants", "--dir", &root, "--shard-index", "1"],
);
assert_eq!(code, EXIT_OK, "{}", host.err());
});
}
#[test]
fn a_shard_count_with_nothing_to_complete_it_is_a_usage_error() {
crate::notes::alone(|| {
let dir = crate_dir("dispatch-shard-half-", Some("[shard]\ncount = 3\n"));
let root = dir.path().to_string_lossy().into_owned();
let mut host = crate::testing::Sink::default();
let code = run(&mut host, ["cargo-gamma", "gamma", "list", "mutants", "--dir", &root]);
assert_eq!(code, EXIT_USAGE, "{}", host.out());
assert!(host.err().contains("--shard-index"), "{}", host.err());
assert!(host.out().is_empty(), "a rejected shard listed mutants anyway: {}", host.out());
});
}
#[test]
fn half_a_shard_on_the_command_line_is_refused_by_the_command_rather_than_the_parser() {
crate::notes::alone(|| {
let dir = crate_dir("dispatch-shard-cli-half-", None);
let root = dir.path().to_string_lossy().into_owned();
let mut host = crate::testing::Sink::default();
let code = run(
&mut host,
["cargo-gamma", "gamma", "list", "mutants", "--dir", &root, "--shard-index", "1"],
);
assert_eq!(code, EXIT_USAGE, "{}", host.out());
assert!(host.err().contains("--shard-count"), "{}", host.err());
});
}
#[test]
fn a_sharded_hints_is_refused_rather_than_quietly_promoting_from_everything() {
crate::notes::alone(|| {
let dir = crate_dir("dispatch-shard-hints-", None);
let root = dir.path().to_string_lossy().into_owned();
let mut host = crate::testing::Sink::default();
let code = run(
&mut host,
[
"cargo-gamma",
"gamma",
"hints",
"--dir",
&root,
"--shard-index",
"1",
"--shard-count",
"4",
],
);
assert_eq!(code, EXIT_USAGE, "{}", host.out());
assert!(host.err().contains("deliberately unsharded"), "{}", host.err());
});
}
#[test]
fn a_config_selection_key_narrows_a_listing() {
crate::notes::alone(|| {
let dir = crate_dir("dispatch-config-list-", Some("mutators = [\"relational.lt_to_le\"]\n"));
let root = dir.path().to_string_lossy().into_owned();
let mut host = crate::testing::Sink::default();
let code = run(&mut host, ["cargo-gamma", "gamma", "list", "mutators", "--json", "--dir", &root]);
assert_eq!(code, EXIT_OK, "{}", host.err());
let entries: Vec<serde_json::Value> = serde_json::from_str(&host.out()).expect("the listing is JSON");
let enabled = |name: &str| {
entries
.iter()
.find(|entry| entry["name"] == name)
.and_then(|entry| entry["enabled"].as_bool())
};
assert_eq!(enabled("relational.lt_to_le"), Some(true), "{}", host.out());
assert_eq!(enabled("relational.lt_to_gt"), Some(false), "{}", host.out());
});
}
#[test]
fn only_the_discovery_commands_apply_config_selection() {
crate::notes::alone(|| {
let dir = crate_dir("dispatch-config-selection-", Some("packages = [\"subject\"]\n"));
let root = dir.path().to_string_lossy().into_owned();
for command in ["list", "unsuppress", "hints"] {
let mut host = crate::testing::Sink::default();
let code = run(&mut host, ["cargo-gamma", "gamma", command, "--workspace", "--dir", &root]);
assert_eq!(code, EXIT_USAGE, "`{command}` did not fold in the file's selection: {}", host.err());
assert!(host.err().contains("packages"), "`{command}`: {}", host.err());
assert!(host.err().contains("workspace"), "`{command}`: {}", host.err());
}
let mut host = crate::testing::Sink::default();
let code = run(&mut host, ["cargo-gamma", "gamma", "explain", "relational"]);
assert_eq!(code, EXIT_OK, "explain applied config selection: {}", host.err());
assert!(host.out().contains("relational.lt_to_le"), "{}", host.out());
assert!(host.out().contains("relational.lt_to_gt"), "{}", host.out());
});
}
#[test]
fn a_run_with_an_impossible_shard_stops_before_it_builds() {
crate::notes::alone(|| {
let dir = crate_dir("dispatch-shard-run-", Some("[shard]\ncount = 2\n"));
let root = dir.path().to_string_lossy().into_owned();
let mut host = crate::testing::Sink::default();
let code = run(&mut host, ["cargo-gamma", "gamma", "run", "--dir", &root, "--shard-index", "9"]);
assert_eq!(code, EXIT_USAGE, "{}", host.out());
assert!(host.err().contains("out of range"), "{}", host.err());
});
}
#[test]
fn completions_are_dispatched_to_the_results_stream() {
crate::notes::alone(|| {
let mut host = crate::testing::Sink::default();
let code = run(&mut host, ["cargo-gamma", "gamma", "completions", "bash"]);
assert_eq!(code, EXIT_OK);
assert!(host.out().contains("cargo-gamma"), "{}", host.out());
});
}
#[test]
fn a_note_raised_below_the_seam_is_said_through_the_host() {
crate::notes::alone(|| {
let mut host = crate::testing::Sink::default();
crate::notes::note("something worth saying");
say_notes(&mut host, Styler::new(false));
assert!(host.err().contains("something worth saying"), "{}", host.err());
assert!(host.err().contains("warning"), "{}", host.err());
assert!(host.out().is_empty(), "a diagnostic reached the results stream: {}", host.out());
});
}
#[test]
fn every_pending_note_is_said() {
crate::notes::alone(|| {
let mut host = crate::testing::Sink::default();
crate::notes::note("the first");
crate::notes::note("the second");
say_notes(&mut host, Styler::new(false));
assert_eq!(host.err().lines().count(), 2, "{}", host.err());
});
}
#[test]
fn a_note_is_said_once_and_not_again_by_the_next_command() {
crate::notes::alone(|| {
let mut first = crate::testing::Sink::default();
let mut second = crate::testing::Sink::default();
crate::notes::note("said once");
say_notes(&mut first, Styler::new(false));
say_notes(&mut second, Styler::new(false));
assert!(first.err().contains("said once"), "{}", first.err());
assert!(second.err().is_empty(), "{}", second.err());
});
}
#[test]
fn concurrent_hosts_receive_only_the_notes_from_their_own_runs() {
let first = crate::notes::Run::new();
let second = crate::notes::Run::new();
let ready = Barrier::new(2);
std::thread::scope(|scope| {
let first = first.clone();
let first_ready = &ready;
let left = scope.spawn(move || {
let _notes = crate::notes::enter(Some(&first));
let mut host = TestSink::default();
crate::notes::note("first run");
let _ready = first_ready.wait();
say_notes(&mut host, Styler::new(false));
String::from_utf8(host.err).expect("note output is UTF-8")
});
let second = second.clone();
let second_ready = &ready;
let right = scope.spawn(move || {
let _notes = crate::notes::enter(Some(&second));
let mut host = TestSink::default();
crate::notes::note("second run");
let _ready = second_ready.wait();
say_notes(&mut host, Styler::new(false));
String::from_utf8(host.err).expect("note output is UTF-8")
});
let left = left.join().expect("first run");
let right = right.join().expect("second run");
assert!(left.contains("first run"), "{left}");
assert!(!left.contains("second run"), "{left}");
assert!(right.contains("second run"), "{right}");
assert!(!right.contains("first run"), "{right}");
});
}
#[test]
fn a_command_that_raised_no_note_writes_nothing() {
crate::notes::alone(|| {
let mut host = crate::testing::Sink::default();
say_notes(&mut host, Styler::new(false));
assert!(host.err().is_empty(), "{}", host.err());
});
}
#[test]
fn cargos_inserted_argument_is_stripped() {
let normalized = normalize(["cargo-gamma", "gamma", "list"]);
assert_eq!(normalized, vec!["cargo-gamma", "list"]);
}
#[test]
fn direct_invocation_is_left_alone() {
let normalized = normalize(["cargo-gamma", "list"]);
assert_eq!(normalized, vec!["cargo-gamma", "list"]);
}
#[test]
fn only_the_second_argument_named_gamma_is_stripped() {
let normalized = normalize(["cargo-gamma", "list", "gamma"]);
assert_eq!(normalized, vec!["cargo-gamma", "list", "gamma"]);
}
#[test]
fn an_empty_argument_list_does_not_panic() {
assert!(normalize(Vec::<String>::new()).is_empty());
}
#[test]
fn a_bare_invocation_implies_run() {
assert_eq!(normalize(["cargo-gamma", "gamma"]), vec!["cargo-gamma", "run"]);
}
#[test]
fn a_leading_option_implies_run() {
assert_eq!(
normalize(["cargo-gamma", "gamma", "--mutators", "relational"]),
vec!["cargo-gamma", "run", "--mutators", "relational"]
);
}
#[test]
fn a_named_subcommand_is_not_second_guessed() {
for command in ["run", "list", "explain", "suppress", "merge", "help"] {
assert_eq!(normalize(["cargo-gamma", "gamma", command]), vec!["cargo-gamma", command]);
}
}
#[test]
fn a_misspelled_subcommand_is_left_for_clap_to_diagnose() {
assert_eq!(normalize(["cargo-gamma", "gamma", "mrege"]), vec!["cargo-gamma", "mrege"]);
}
#[test]
fn help_and_version_stay_at_the_top_level() {
for flag in ["-h", "--help", "-V", "--version"] {
assert_eq!(normalize(["cargo-gamma", "gamma", flag]), vec!["cargo-gamma", flag]);
}
}
#[test]
fn a_global_option_before_a_subcommand_is_stepped_over() {
assert_eq!(
normalize(["cargo-gamma", "gamma", "--color", "never", "merge", "a.json"]),
vec!["cargo-gamma", "--color", "never", "merge", "a.json"]
);
assert_eq!(
normalize(["cargo-gamma", "gamma", "--progress=never", "merge", "a.json"]),
vec!["cargo-gamma", "--progress=never", "merge", "a.json"]
);
}
#[test]
fn a_global_option_before_no_subcommand_still_implies_run() {
assert_eq!(
normalize(["cargo-gamma", "gamma", "--color", "never", "--mutators", "stmt"]),
vec!["cargo-gamma", "run", "--color", "never", "--mutators", "stmt"]
);
}
#[test]
fn a_dangling_global_option_does_not_panic() {
assert_eq!(
normalize(["cargo-gamma", "gamma", "--color"]),
vec!["cargo-gamma", "run", "--color"]
);
}
#[test]
fn a_global_option_with_value_before_run_options_still_implies_run() {
assert_eq!(
normalize(["cargo-gamma", "gamma", "--progress=never", "--mutators", "relational"]),
vec!["cargo-gamma", "run", "--progress=never", "--mutators", "relational"]
);
}
}