#![warn(clippy::pedantic)]
#![allow(
clippy::module_name_repetitions,
clippy::needless_raw_string_hashes,
clippy::too_many_lines
)]
mod annotation;
mod build_dir;
mod cargo;
mod config;
mod console;
mod copy_tree;
mod exit_code;
mod fnvalue;
mod glob;
mod in_diff;
mod interrupt;
mod lab;
mod list;
mod manifest;
mod mutant;
mod options;
mod outcome;
mod output;
mod package;
mod path;
mod pretty;
mod process;
mod scenario;
mod shard;
mod source;
mod span;
mod tail_file;
#[cfg(test)]
#[path = "../tests/util/mod.rs"]
mod test_util;
mod timeouts;
mod visit;
mod workspace;
use std::env;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use anyhow::{Context, Result, anyhow};
use camino::{Utf8Path, Utf8PathBuf};
use clap::{
ArgAction, CommandFactory, Parser, ValueEnum,
builder::{Styles, styling},
};
use clap_complete::{Shell, generate};
use color_print::cstr;
use console::enable_console_colors;
use tracing::{debug, error, info};
use crate::{
build_dir::BuildDir,
console::Console,
exit_code::ExitCode,
in_diff::diff_filter_file,
interrupt::check_interrupted,
lab::test_mutants,
list::{list_files, list_mutants},
mutant::{Genre, Mutant},
options::{Colors, Common, Options},
outcome::{Phase, ScenarioOutcome},
output::{OutputDir, load_previously_caught},
package::Package,
scenario::Scenario,
shard::Shard,
source::SourceFile,
visit::walk_file,
workspace::{PackageFilter, Workspace},
};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const NAME: &str = env!("CARGO_PKG_NAME");
static MUTATION_MARKER_COMMENT: &str = "/* ~ changed by cargo-mutants ~ */";
static SPONSOR_MESSAGE: &str = cstr!(
"<magenta><bold>Support and accelerate cargo-mutants at <<https://github.com/sponsors/sourcefrog>></></>"
);
#[mutants::skip] fn clap_styles() -> Styles {
styling::Styles::styled()
.header(styling::AnsiColor::Green.on_default() | styling::Effects::BOLD)
.usage(styling::AnsiColor::Green.on_default() | styling::Effects::BOLD)
.literal(styling::AnsiColor::Blue.on_default() | styling::Effects::BOLD)
.placeholder(styling::AnsiColor::Cyan.on_default())
}
#[derive(Parser)]
#[command(name = "cargo", bin_name = "cargo", styles(clap_styles()))]
enum Cargo {
#[command(name = "mutants", styles(clap_styles()))]
Mutants(Args),
}
#[derive(Debug, Default, ValueEnum, Clone, Copy, Eq, PartialEq)]
pub enum BaselineStrategy {
#[default]
Run,
Skip,
}
#[derive(Debug, ValueEnum, Clone, Copy, Eq, PartialEq)]
pub enum SchemaType {
Config,
}
#[allow(clippy::struct_excessive_bools, clippy::struct_field_names)]
#[derive(Parser, PartialEq, Debug)]
#[command(
author,
about,
after_help = SPONSOR_MESSAGE,
styles(clap_styles())
)]
pub struct Args {
#[arg(long, action = ArgAction::Set, help_heading = "Build")]
cap_lints: Option<bool>,
#[arg(long, help_heading = "Build")]
profile: Option<String>,
#[arg(
long,
help_heading = "Config",
value_name = "FILE",
conflicts_with = "no_config"
)]
config: Option<Utf8PathBuf>,
#[arg(long, help_heading = "Config", conflicts_with = "config")]
no_config: bool,
#[arg(long, help_heading = "Copying", group = "copy_opts")]
copy_target: Option<bool>,
#[arg(long, help_heading = "Copying", visible_alias = "copy_git")]
copy_vcs: Option<bool>,
#[arg(long, help_heading = "Copying", group = "copy_opts")]
gitignore: Option<bool>,
#[arg(
long,
help_heading = "Copying",
conflicts_with = "jobs",
conflicts_with = "copy_opts"
)]
in_place: bool,
#[arg(long, help_heading = "Copying", group = "copy_opts", hide = true)]
no_copy_target: bool,
#[arg(long, help_heading = "Debug")]
leak_dirs: bool,
#[arg(
long,
short = 'L',
default_value = "info",
env = "CARGO_MUTANTS_TRACE_LEVEL",
help_heading = "Debug"
)]
level: tracing::Level,
#[arg(
help_heading = "Debug",
value_name = "FILE",
long = "Zmutate-file",
conflicts_with = "in_diff",
conflicts_with = "package"
)]
mutate_file: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = BaselineStrategy::Run, help_heading = "Execution")]
baseline: BaselineStrategy,
#[arg(long, help_heading = "Execution", conflicts_with = "build_timeout")]
build_timeout_multiplier: Option<f64>,
#[arg(long, help_heading = "Execution")]
build_timeout: Option<f64>,
#[arg(
long,
short = 'C',
allow_hyphen_values = true,
help_heading = "Execution"
)]
cargo_arg: Vec<String>,
#[arg(long, allow_hyphen_values = true, help_heading = "Execution")]
cargo_test_arg: Vec<String>,
#[arg(last = true, help_heading = "Execution")]
cargo_test_args: Vec<String>,
#[arg(long, help_heading = "Execution")]
check: bool,
#[arg(
long,
short = 'j',
env = "CARGO_MUTANTS_JOBS",
help_heading = "Execution"
)]
jobs: Option<usize>,
#[arg(long, action = ArgAction::Set, help_heading = "Execution", default_value_t = true)]
jobserver: bool,
#[arg(long, help_heading = "Execution")]
jobserver_tasks: Option<usize>,
#[arg(long, help_heading = "Execution")]
list: bool,
#[arg(long, help_heading = "Execution")]
list_files: bool,
#[arg(
long,
env = "CARGO_MUTANTS_MINIMUM_TEST_TIMEOUT",
help_heading = "Execution"
)]
minimum_test_timeout: Option<f64>,
#[arg(long, help_heading = "Execution")]
no_shuffle: bool,
#[arg(long, help_heading = "Execution")]
shard: Option<Shard>,
#[arg(long, help_heading = "Execution", conflicts_with = "no_shuffle")]
shuffle: bool,
#[arg(long, short = 't', help_heading = "Execution")]
timeout: Option<f64>,
#[arg(long, help_heading = "Execution", conflicts_with = "timeout")]
timeout_multiplier: Option<f64>,
#[arg(long, help_heading = "Features")]
pub features: Vec<String>,
#[arg(long, help_heading = "Features")]
pub no_default_features: bool,
#[arg(long, help_heading = "Features")]
pub all_features: bool,
#[arg(
long = "re",
short = 'F',
alias = "regex",
alias = "examine-regex",
alias = "examine-re",
help_heading = "Filters"
)]
examine_re: Vec<String>,
#[arg(long, short = 'e', help_heading = "Filters")]
exclude: Vec<String>,
#[arg(long, short = 'E', alias = "exclude-regex", help_heading = "Filters")]
exclude_re: Vec<String>,
#[arg(long, short = 'f', help_heading = "Filters")]
file: Vec<String>,
#[arg(long, short = 'D', help_heading = "Filters")]
in_diff: Option<Utf8PathBuf>,
#[arg(long, help_heading = "Filters")]
iterate: bool,
#[arg(id = "package", long, short = 'p', help_heading = "Filters")]
mutate_packages: Vec<String>,
#[arg(long, help_heading = "Filters")]
skip_calls: Vec<String>,
#[arg(long, help_heading = "Filters")]
skip_calls_defaults: Option<bool>,
#[arg(long, help_heading = "Filters")]
workspace: bool,
#[arg(long, help_heading = "Generate")]
error: Vec<String>,
#[arg(
long,
short = 'd',
conflicts_with = "manifest_path",
help_heading = "Input"
)]
dir: Option<Utf8PathBuf>,
#[arg(long, help_heading = "Input")]
manifest_path: Option<Utf8PathBuf>,
#[arg(long, help_heading = "Meta")]
completions: Option<Shell>,
#[arg(long, action = clap::ArgAction::SetTrue, help_heading = "Meta")]
version: bool,
#[arg(long, help_heading = "Output")]
all_logs: bool,
#[arg(long, help_heading = "Output", default_value = "auto")]
annotations: annotation::AutoAnnotation,
#[arg(long, short = 'v', help_heading = "Output")]
caught: bool,
#[arg(
long,
value_enum,
help_heading = "Output",
default_value_t,
env = "CARGO_TERM_COLOR"
)]
colors: Colors,
#[arg(long, help_heading = "Output")]
json: bool,
#[arg(long, action = ArgAction::Set, default_value = "true", help_heading = "Output")]
line_col: bool,
#[arg(long, help_heading = "Output")]
no_times: bool,
#[arg(
long,
short = 'o',
env = "CARGO_MUTANTS_OUTPUT",
help_heading = "Output"
)]
output: Option<Utf8PathBuf>,
#[arg(long, short = 'V', help_heading = "Output")]
unviable: bool,
#[arg(long, help_heading = "Tests")]
test_package: Vec<String>,
#[arg(long, help_heading = "Tests")]
test_workspace: Option<bool>,
#[arg(long, value_enum, help_heading = "Misc")]
emit_schema: Option<SchemaType>,
#[clap(flatten)]
common: Common,
}
fn main() -> Result<ExitCode> {
let args = match Cargo::try_parse() {
Ok(Cargo::Mutants(args)) => args,
Err(e) => {
e.print().expect("Failed to show clap error message");
let code = match e.exit_code() {
2 => ExitCode::Usage,
0 => ExitCode::Success,
_ => ExitCode::Software,
};
return Ok(code);
}
};
if args.version {
println!("{NAME} {VERSION}");
return Ok(ExitCode::Success);
} else if let Some(shell) = args.completions {
generate(shell, &mut Cargo::command(), "cargo", &mut io::stdout());
return Ok(ExitCode::Success);
} else if let Some(schema_type) = args.emit_schema {
emit_schema(schema_type)?;
return Ok(ExitCode::Success);
}
let console = Console::new();
console.setup_global_trace(args.level, args.colors); enable_console_colors(args.colors);
interrupt::install_handler();
if let Some(path) = &args.mutate_file {
let config = if let Some(config_path) = &args.config {
config::Config::read_file(config_path.as_ref())?
} else {
config::Config::default()
};
let options = Options::new(&args, &config)?;
mutate_file(path, &options)?;
return Ok(ExitCode::Success);
}
let start_dir: &Utf8Path = if let Some(manifest_path) = &args.manifest_path {
if !manifest_path.is_file() {
error!("Manifest path is not a file");
return Ok(ExitCode::Usage);
}
manifest_path
.parent()
.context("Manifest path has no parent")?
} else if let Some(dir) = &args.dir {
dir
} else {
Utf8Path::new(".")
};
let workspace = Workspace::open(start_dir)?;
let config = if args.no_config {
config::Config::default()
} else if let Some(config_path) = &args.config {
config::Config::read_file(config_path.as_ref())?
} else {
config::Config::read_tree_config(workspace.root())?
};
debug!(?config);
debug!(?args.features);
let options = Options::new(&args, &config)?;
debug!(?options);
let package_filter = if !args.mutate_packages.is_empty() {
PackageFilter::explicit(&args.mutate_packages)
} else if args.workspace {
PackageFilter::All
} else {
PackageFilter::Auto(start_dir.to_owned())
};
let output_parent_dir = options
.output_in_dir
.clone()
.unwrap_or_else(|| workspace.root().to_owned());
let mut discovered = workspace.discover(&package_filter, &options, &console)?;
let previously_caught = if args.iterate {
let previously_caught = load_previously_caught(&output_parent_dir)?;
info!(
"Iteration excludes {} previously caught or unviable mutants",
previously_caught.len()
);
discovered.remove_previously_caught(&previously_caught);
Some(previously_caught)
} else {
None
};
console.clear();
if args.list_files {
print!("{}", list_files(&discovered.files, &options));
return Ok(ExitCode::Success);
}
let mut mutants = discovered.mutants;
if let Some(diff_path) = &args.in_diff {
mutants = match diff_filter_file(mutants, diff_path) {
Ok(mutants) => mutants,
Err(err) => {
if err.exit_code() == ExitCode::Success {
info!("{err}");
} else {
error!("{err}");
}
return Ok(err.exit_code());
}
};
}
if let Some(shard) = &args.shard {
mutants = options.sharding().shard(*shard, mutants);
}
if args.list {
print!("{}", list_mutants(&mutants, &options));
Ok(ExitCode::Success)
} else {
let output_dir = OutputDir::new(&output_parent_dir)?;
if let Some(previously_caught) = previously_caught {
output_dir.write_previously_caught(&previously_caught)?;
}
console.set_debug_log(output_dir.open_debug_log()?);
let lab_outcome = test_mutants(mutants, &workspace, output_dir, &options, &console)?;
Ok(lab_outcome.exit_code())
}
}
fn emit_schema(schema_type: SchemaType) -> Result<()> {
match schema_type {
SchemaType::Config => {
let schema = schemars::schema_for!(config::Config);
println!("{}", serde_json::to_string_pretty(&schema)?);
Ok(())
}
}
}
fn mutate_file(path: &Path, options: &Options) -> Result<()> {
let fake_package = Package {
name: "single_file".to_string(),
version: "0.0.0".to_string(),
relative_dir: Utf8PathBuf::new(),
top_sources: Vec::new(),
};
let path = Utf8PathBuf::from_path_buf(path.to_owned())
.map_err(|_| anyhow!("mutate_file path is not UTF-8"))?;
let source_file = SourceFile::load(
path.parent().context("get parent of mutate_file")?,
path.file_name()
.context("get file name of mutate_file")?
.into(),
&fake_package,
true,
)
.context("load source file")?
.context("single source file is outside of tree??")?;
let error_exprs = options.parsed_error_exprs()?;
let (mutants, _mod_refs) = walk_file(&source_file, &error_exprs, options)?;
print!("{}", list_mutants(&mutants, options));
Ok(())
}
#[cfg(test)]
mod test {
use clap::{CommandFactory, Parser};
#[test]
fn config_option_conflicts_with_no_config() {
let args = super::Args::try_parse_from(["mutants", "--config=foo.toml", "--no-config"]);
assert!(args.is_err(), "Expected error due to conflicting options");
println!("Error message: {}", args.unwrap_err());
}
#[test]
fn option_help_sentence_case_without_period() {
let args = super::Args::command();
let mut problems = Vec::new();
for arg in args.get_arguments() {
if let Some(help) = arg.get_help().map(ToString::to_string) {
if !help.starts_with(char::is_uppercase) {
problems.push(format!(
"Help for {:?} does not start with a capital letter: {:?}",
arg.get_id(),
help
));
}
if help.ends_with('.') {
problems.push(format!(
"Help for {:?} ends with a period: {:?}",
arg.get_id(),
help
));
}
if help.is_empty() {
problems.push(format!("Help for {:?} is empty", arg.get_id()));
}
} else {
problems.push(format!("No help for {:?}", arg.get_id()));
}
}
for problem in &problems {
eprintln!("{problem}");
}
assert!(problems.is_empty(), "Problems with help text");
}
}