diff --git a/NEWS.md b/NEWS.md index ed5bd2bb..082604e7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,8 @@ ## Unreleased +- New: The configuration can also be read from `mutants.toml`, `.mutants.toml`, or `.config/mutants.toml` in the root of the source tree, or from a `[workspace.metadata.mutants]` or `[package.metadata.mutants]` table in the root `Cargo.toml`. These are searched in that order after `.cargo/mutants.toml`, and only the first one that exists is read ([#645](https://github.com/sourcefrog/cargo-mutants/issues/645)). + - New: `#[mutants::exclude_re("pattern")]` attribute to exclude specific mutations by regex, without disabling all mutations on the function. The attribute can be placed on functions, `impl` blocks, `trait` blocks, modules, files, and on expressions that can carry an attribute (such as `match`, struct literals, call expressions, method calls, and unary expressions). Multiple patterns can be applied. Also supported within `cfg_attr`. Requires the [mutants](https://crates.io/crates/mutants) crate version `0.0.5` or later. - Fixed: `#[mutants::skip]` (and `#[cfg_attr(..., mutants::skip)]`) is now honoured when placed on `const` and `static` items, including associated constants in `impl` and `trait` blocks. Previously the attribute was silently ignored on these items and operator mutants inside the initializer expression were still generated ([#508](https://github.com/sourcefrog/cargo-mutants/issues/508)). diff --git a/book/src/config-file.md b/book/src/config-file.md index a530070a..64f3102f 100644 --- a/book/src/config-file.md +++ b/book/src/config-file.md @@ -1,17 +1,38 @@ # Config file -Many options for cargo-mutants can be set in a config file. By default, the config file is read from +Many options for cargo-mutants can be set in a config file. By default, the config is read from `.cargo/mutants.toml` in the source tree root. -It's recommended that the config file be checked in to the source tree with values that will +It's recommended that the config be checked in to the source tree with values that will allow developers to run `cargo mutants` with no other options. -`--no-config` can be used to disable reading the configuration file. +`--no-config` can be used to disable reading the configuration from the source tree. -`--config FILE` can be used to read configuration from a custom file instead of the default location. +`--config FILE` can be used to read configuration from a custom file instead of the default locations. This is useful for having different configurations for different scenarios (e.g., CI/CD, development, specific testing requirements). +## Where the config is read from + +The config may be stored in any one of these locations, relative to the root of the source tree. +They are searched in this order and only the first one that exists is read: configuration from +several locations is never merged. + +1. `.cargo/mutants.toml` +2. `mutants.toml` +3. `.mutants.toml` +4. `.config/mutants.toml` +5. The `[workspace.metadata.mutants]` table in `Cargo.toml` +6. The `[package.metadata.mutants]` table in `Cargo.toml` + +The metadata tables hold the same keys as the config file, for example: + +```toml +[workspace.metadata.mutants] +exclude_globs = ["src/generated/**/*.rs"] +timeout_multiplier = 2.0 +``` + For a full list of keys, see . An example config file with detailed comments can be found at diff --git a/src/config.rs b/src/config.rs index 7271192b..a44a9e6d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,12 +1,17 @@ // Copyright 2022-2026 Martin Pool. -//! `.cargo/mutants.toml` configuration file. +//! `mutants.toml` configuration file. //! -//! The config file is read after parsing command line arguments, +//! The config is read after parsing command line arguments, //! and after finding the source tree, because these together //! determine its location. //! -//! The config file is then merged in to the [`Options`]. +//! Within the tree, the config is read from the first of +//! [`CONFIG_FILE_NAMES`] that exists, or otherwise from a +//! `[workspace.metadata.mutants]` or `[package.metadata.mutants]` +//! table in the root `Cargo.toml`. +//! +//! The config is then merged in to the [`Options`]. use std::default::Default; use std::fs::read_to_string; @@ -30,7 +35,7 @@ use crate::options::Common; #[schemars(extend("$id" = "https://json.schemastore.org/cargo-mutants-config.json"))] #[schemars(title = "cargo-mutants configuration")] #[schemars( - description = "cargo-mutants configuration, read by default from `.cargo/mutants.toml`. See ." + description = "cargo-mutants configuration, read by default from `.cargo/mutants.toml` or another standard location in the source tree. See ." )] pub struct Config { /// Pass extra args to every cargo invocation. @@ -93,6 +98,22 @@ pub struct Config { pub common: Common, } +/// Paths, relative to the root of the source tree, from which the config is read, +/// in order of precedence: the first one that exists is used. +const CONFIG_FILE_NAMES: &[&str] = &[ + ".cargo/mutants.toml", + "mutants.toml", + ".mutants.toml", + ".config/mutants.toml", +]; + +/// Tables in the root `Cargo.toml` from which the config is read if none of +/// [`CONFIG_FILE_NAMES`] exists, in order of precedence. +const MANIFEST_METADATA_TABLES: &[&[&str]] = &[ + &["workspace", "metadata", "mutants"], + &["package", "metadata", "mutants"], +]; + impl Config { pub fn read_file(path: &Path) -> Result { debug!(?path, "Read config"); @@ -101,17 +122,47 @@ impl Config { Config::from_str(&toml).with_context(|| format!("parse toml from {}", path.display())) } - /// Read the config from a tree's `.cargo/mutants.toml`, and return a default (empty) - /// Config is the file does not exist. + /// Read the config from the first of the standard locations in a tree that + /// exists, and return a default (empty) Config if there is none. pub fn read_tree_config(workspace_dir: &Utf8Path) -> Result { - let path = workspace_dir.join(".cargo").join("mutants.toml"); - if path.exists() { - debug!(?path, "Found config in source tree"); - Config::read_file(path.as_ref()) - } else { - debug!("No config found in workspace"); - Ok(Config::default()) + for name in CONFIG_FILE_NAMES { + let path = workspace_dir.join(name); + if path.is_file() { + debug!(?path, "Found config in source tree"); + return Config::read_file(path.as_ref()); + } + } + if let Some(config) = Config::read_manifest_metadata(workspace_dir)? { + return Ok(config); + } + debug!("No config found in workspace"); + Ok(Config::default()) + } + + /// Read the config from a metadata table in the tree's root `Cargo.toml`, + /// if there is one. + fn read_manifest_metadata(workspace_dir: &Utf8Path) -> Result> { + let path = workspace_dir.join("Cargo.toml"); + if !path.is_file() { + return Ok(None); + } + let toml = read_to_string(&path).with_context(|| format!("read manifest {path}"))?; + let manifest: toml::Table = + toml::de::from_str(&toml).with_context(|| format!("parse toml from {path}"))?; + for keys in MANIFEST_METADATA_TABLES { + let mut value = manifest.get(keys[0]); + for key in &keys[1..] { + value = value.and_then(|value| value.get(key)); + } + if let Some(value) = value { + let table = keys.join("."); + debug!(?path, table, "Found config in manifest"); + return Config::deserialize(value.clone()) + .with_context(|| format!("parse `[{table}]` from {path}")) + .map(Some); + } } + Ok(None) } } diff --git a/src/main.rs b/src/main.rs index 3e771564..1f199671 100644 --- a/src/main.rs +++ b/src/main.rs @@ -159,7 +159,7 @@ pub struct Args { profile: Option, // Config ============================================================ - /// Read configuration from this file instead of .cargo/mutants.toml. + /// Read configuration from this file instead of the config in the source tree. #[arg( long, help_heading = "Config", @@ -168,7 +168,7 @@ pub struct Args { )] config: Option, - /// Don't read .cargo/mutants.toml. + /// Don't read any configuration from the source tree. #[arg(long, help_heading = "Config", conflicts_with = "config")] no_config: bool, diff --git a/src/options.rs b/src/options.rs index b983fb98..823887f9 100644 --- a/src/options.rs +++ b/src/options.rs @@ -5,7 +5,7 @@ //! The [`Options`] structure is built by combining, in priority order: //! //! 1. Command line options -//! 2. Config options (read from `.cargo/mutants.toml`) +//! 2. Config options (read from `.cargo/mutants.toml` or another location in the tree) //! 3. Built-in defaults use std::env; diff --git a/tests/main.rs b/tests/main.rs index 0fb8973d..e1256356 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -3462,6 +3462,197 @@ fn invalid_field_rejected() { ); } +/// Write a config into any of the locations that are searched within the tree. +fn write_config_to(tempdir: &TempDir, relative_path: &str, config: &str) { + let path = tempdir.path().join(relative_path); + create_dir_all(path.parent().unwrap()).unwrap(); + write(path, config.as_bytes()).unwrap(); +} + +/// Append a table to the tree's root `Cargo.toml`. +fn append_to_manifest(tempdir: &TempDir, toml: &str) { + let path = tempdir.path().join("Cargo.toml"); + let mut manifest = read_to_string(&path).unwrap(); + manifest.push_str(toml); + write(path, manifest.as_bytes()).unwrap(); +} + +/// A config that makes `--list-files` list only the `_mod.rs` files. +const CONFIG_EXAMINING_MOD_FILES: &str = "examine_globs = [\"src/*_mod.rs\"]\n"; + +/// A config that makes `--list-files` list only `src/simple_fns.rs`. +const CONFIG_EXAMINING_SIMPLE_FNS: &str = "examine_globs = [\"src/simple_fns.rs\"]\n"; + +const MOD_FILES_LISTED: &str = "src/inside_mod.rs\nsrc/item_mod.rs\n"; + +fn assert_list_files(tempdir: &TempDir, expected: &'static str) { + run() + .args(["mutants", "--list-files", "-d"]) + .arg(tempdir.path()) + .assert() + .success() + .stdout(predicates::str::diff(expected)); +} + +#[test] +fn config_is_read_from_mutants_toml_in_tree_root() { + let testdata = copy_of_testdata("well_tested"); + write_config_to(&testdata, "mutants.toml", CONFIG_EXAMINING_MOD_FILES); + assert_list_files(&testdata, MOD_FILES_LISTED); +} + +#[test] +fn config_is_read_from_dot_mutants_toml_in_tree_root() { + let testdata = copy_of_testdata("well_tested"); + write_config_to(&testdata, ".mutants.toml", CONFIG_EXAMINING_MOD_FILES); + assert_list_files(&testdata, MOD_FILES_LISTED); +} + +#[test] +fn config_is_read_from_dot_config_mutants_toml() { + let testdata = copy_of_testdata("well_tested"); + write_config_to( + &testdata, + ".config/mutants.toml", + CONFIG_EXAMINING_MOD_FILES, + ); + assert_list_files(&testdata, MOD_FILES_LISTED); +} + +#[test] +fn config_is_read_from_workspace_metadata_mutants_in_cargo_toml() { + let testdata = copy_of_testdata("well_tested"); + append_to_manifest( + &testdata, + indoc! { r#" + + [workspace] + + [workspace.metadata.mutants] + examine_globs = ["src/*_mod.rs"] + "#}, + ); + assert_list_files(&testdata, MOD_FILES_LISTED); +} + +#[test] +fn config_is_read_from_package_metadata_mutants_in_cargo_toml() { + let testdata = copy_of_testdata("well_tested"); + append_to_manifest( + &testdata, + indoc! { r#" + + [package.metadata.mutants] + examine_globs = ["src/*_mod.rs"] + "#}, + ); + assert_list_files(&testdata, MOD_FILES_LISTED); +} + +#[test] +fn cargo_mutants_toml_takes_precedence_over_mutants_toml() { + let testdata = copy_of_testdata("well_tested"); + write_config_file(&testdata, CONFIG_EXAMINING_MOD_FILES); + write_config_to(&testdata, "mutants.toml", CONFIG_EXAMINING_SIMPLE_FNS); + assert_list_files(&testdata, MOD_FILES_LISTED); +} + +#[test] +fn mutants_toml_takes_precedence_over_dot_mutants_toml() { + let testdata = copy_of_testdata("well_tested"); + write_config_to(&testdata, "mutants.toml", CONFIG_EXAMINING_MOD_FILES); + write_config_to(&testdata, ".mutants.toml", CONFIG_EXAMINING_SIMPLE_FNS); + assert_list_files(&testdata, MOD_FILES_LISTED); +} + +#[test] +fn dot_mutants_toml_takes_precedence_over_dot_config_mutants_toml() { + let testdata = copy_of_testdata("well_tested"); + write_config_to(&testdata, ".mutants.toml", CONFIG_EXAMINING_MOD_FILES); + write_config_to( + &testdata, + ".config/mutants.toml", + CONFIG_EXAMINING_SIMPLE_FNS, + ); + assert_list_files(&testdata, MOD_FILES_LISTED); +} + +#[test] +fn dot_config_mutants_toml_takes_precedence_over_workspace_metadata_mutants() { + let testdata = copy_of_testdata("well_tested"); + write_config_to( + &testdata, + ".config/mutants.toml", + CONFIG_EXAMINING_MOD_FILES, + ); + append_to_manifest( + &testdata, + indoc! { r#" + + [workspace] + + [workspace.metadata.mutants] + examine_globs = ["src/simple_fns.rs"] + "#}, + ); + assert_list_files(&testdata, MOD_FILES_LISTED); +} + +#[test] +fn workspace_metadata_mutants_takes_precedence_over_package_metadata_mutants() { + let testdata = copy_of_testdata("well_tested"); + append_to_manifest( + &testdata, + indoc! { r#" + + [package.metadata.mutants] + examine_globs = ["src/simple_fns.rs"] + + [workspace] + + [workspace.metadata.mutants] + examine_globs = ["src/*_mod.rs"] + "#}, + ); + assert_list_files(&testdata, MOD_FILES_LISTED); +} + +#[test] +fn invalid_field_in_workspace_metadata_mutants_rejected() { + let testdata = copy_of_testdata("well_tested"); + append_to_manifest( + &testdata, + indoc! { r#" + + [workspace] + + [workspace.metadata.mutants] + wobble = false + "#}, + ); + run() + .args(["mutants", "--list-files", "-d"]) + .arg(testdata.path()) + .assert() + .failure() + .stderr( + predicates::str::contains("parse `[workspace.metadata.mutants]` from ") + .and(predicates::str::contains("unknown field `wobble`")), + ); +} + +#[test] +fn no_config_ignores_mutants_toml() { + let testdata = copy_of_testdata("well_tested"); + write_config_to(&testdata, "mutants.toml", CONFIG_EXAMINING_SIMPLE_FNS); + run() + .args(["mutants", "--no-config", "--list-files", "-d"]) + .arg(testdata.path()) + .assert() + .success() + .stdout(predicates::str::contains("src/methods.rs")); +} + #[test] fn list_with_config_file_exclusion() { let testdata = copy_of_testdata("well_tested");