Skip to content

Commit d910412

Browse files
committed
Merge regex and globs options from args and config
Options that take lists (regexes and globs) are now merged from the command line and config file, rather than previously overriding the config file. This is a breaking change, so bump the version. Fixes #526, #527
1 parent 97d2e57 commit d910412

9 files changed

Lines changed: 98 additions & 44 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cargo-mutants"
3-
version = "26.2.0"
3+
version = "27.0.0-pre"
44
edition = "2024"
55
authors = ["Martin Pool"]
66
license = "MIT"

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Changed: Command line values for `--file`, `--exclude`, `--examine-re`, and `--exclude-re` are now combined with, rather than replacing, values given in the configuration file, consistently with every other option that takes a list. (Use `--config=OTHER` or `--no-config` to avoid using values in the configuration.) Thanks to @sandersaares for pointing this out.
6+
57
- New: `--Zmutate-file` lists the mutants generated from a single Rust source file in text or JSON, without reading or requiring a containing package. This is intended as an aid for developing and debugging mutation patterns.
68

79
## 26.2.0 - 2026-01-31

book/src/config-file.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ An example config file with detailed comments can be found at
1919

2020
## Merging config and command-line options
2121

22-
When options are specified in both the config file and the command line, the command line options take precedence.
22+
When options are specified in both the config file and the command line, for scalar options, the command line options take precedence.
2323

2424
For options that take a list of values, values from the configuration file are appended
2525
to values from the command line.

book/src/filter_mutants.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ This can be helpful
4747
if you want to systematically skip testing implementations of certain traits, or functions
4848
with certain names.
4949

50-
From cargo-mutants 23.11.2 onwards, if the command line options are given then the corresponding config file option is ignored.
50+
From cargo-mutants 27.0.0 onwards, the command line options are combined with filters specified in the configuration file.
5151

5252
For example:
5353

src/config.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2022-2025 Martin Pool.
1+
// Copyright 2022-2026 Martin Pool.
22

33
//! `.cargo/mutants.toml` configuration file.
44
//!

src/glob.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
// Copyright 2024 Martin Pool
1+
// Copyright 2024-2026 Martin Pool
22

3-
//! Build globsets.
3+
//! Build globsets from lists of strings.
44
55
use std::borrow::Cow;
66

@@ -9,15 +9,15 @@ use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
99

1010
use crate::Result;
1111

12-
pub fn build_glob_set<S>(globs: &[S]) -> Result<Option<GlobSet>>
12+
pub fn build_glob_set<S, I>(globs: I) -> Result<Option<GlobSet>>
1313
where
1414
S: AsRef<str>,
15+
I: IntoIterator<Item = S>,
1516
{
16-
if globs.is_empty() {
17-
return Ok(None);
18-
}
17+
let mut has_globs = false;
1918
let mut builder = GlobSetBuilder::new();
2019
for glob_str in globs {
20+
has_globs = true;
2121
let glob_str = glob_str.as_ref();
2222
let match_whole_path = if cfg!(windows) {
2323
glob_str.contains(['/', '\\'])
@@ -41,7 +41,11 @@ where
4141
);
4242
}
4343
}
44-
Ok(Some(builder.build().context("Failed to build glob set")?))
44+
if has_globs {
45+
Ok(Some(builder.build().context("Failed to build glob set")?))
46+
} else {
47+
Ok(None)
48+
}
4549
}
4650

4751
#[cfg(test)]

src/options.rs

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2021-2025 Martin Pool
1+
// Copyright 2021-2026 Martin Pool
22

33
//! Global in-process options for experimenting on mutants.
44
//!
@@ -141,10 +141,10 @@ pub struct Options {
141141
pub exclude_globset: Option<GlobSet>,
142142

143143
/// Mutants to examine, as a regexp matched against the full name.
144-
pub examine_names: RegexSet,
144+
pub examine_name_re: RegexSet,
145145

146146
/// Mutants to skip, as a regexp matched against the full name.
147-
pub exclude_names: RegexSet,
147+
pub exclude_name_re: RegexSet,
148148

149149
/// Create `mutants.out` within this directory (by default, the source directory).
150150
pub output_in_dir: Option<Utf8PathBuf>,
@@ -166,6 +166,8 @@ pub struct Options {
166166

167167
// Options that are implemented only once between clap and serde.
168168
//
169+
// (This is not a docstring so that clap doesn't try to render it as part of the help.)
170+
//
169171
// All fields should be optional so that we can represent a field being set on the command line but
170172
// not in the config, or vice versa.
171173
//
@@ -345,12 +347,14 @@ impl Options {
345347
emit_json: args.json,
346348
common: args.common.merge(&config.common),
347349
error_values: join_slices(&args.error, &config.error_values),
348-
examine_names: RegexSet::new(or_slices(&args.examine_re, &config.examine_re))
350+
examine_name_re: RegexSet::new(args.examine_re.iter().chain(config.examine_re.iter()))
349351
.context("Failed to compile examine_re regex")?,
350-
exclude_names: RegexSet::new(or_slices(&args.exclude_re, &config.exclude_re))
352+
exclude_name_re: RegexSet::new(args.exclude_re.iter().chain(&config.exclude_re))
351353
.context("Failed to compile exclude_re regex")?,
352-
examine_globset: build_glob_set(or_slices(&args.file, &config.examine_globs))?,
353-
exclude_globset: build_glob_set(or_slices(&args.exclude, &config.exclude_globs))?,
354+
examine_globset: build_glob_set(args.file.iter().chain(config.examine_globs.iter()))?,
355+
exclude_globset: build_glob_set(
356+
args.exclude.iter().chain(config.exclude_globs.iter()),
357+
)?,
354358
features: join_slices(&args.features, &config.features),
355359
gitignore: args
356360
.gitignore
@@ -474,8 +478,8 @@ impl Options {
474478
/// True if the options allow this mutant to be tested.
475479
pub fn allows_mutant(&self, mutant: &Mutant) -> bool {
476480
let name = mutant.name(true);
477-
(self.examine_names.is_empty() || self.examine_names.is_match(&name))
478-
&& (self.exclude_names.is_empty() || !self.exclude_names.is_match(&name))
481+
(self.examine_name_re.is_empty() || self.examine_name_re.is_match(&name))
482+
&& (self.exclude_name_re.is_empty() || !self.exclude_name_re.is_match(&name))
479483
}
480484

481485
pub fn emit_diffs(&self) -> bool {
@@ -491,11 +495,6 @@ impl Options {
491495
}
492496
}
493497

494-
/// If the first slices is non-empty, return that, otherwise the second.
495-
fn or_slices<'a: 'c, 'b: 'c, 'c, T>(a: &'a [T], b: &'b [T]) -> &'c [T] {
496-
if a.is_empty() { b } else { a }
497-
}
498-
499498
#[cfg(test)]
500499
mod test {
501500
use std::io::Write;
@@ -1217,6 +1216,65 @@ mod test {
12171216
let options = Options::new(&args, &config).unwrap();
12181217
assert!(options.shuffle);
12191218
}
1219+
1220+
#[test]
1221+
fn merge_exclude_from_config_and_command_line() {
1222+
let args = Args::parse_from([
1223+
"mutants",
1224+
"--exclude-re=foo",
1225+
"--exclude-re=bar",
1226+
"--exclude=foo.rs",
1227+
"--file=ex1.rs",
1228+
"--examine-re=exr1",
1229+
]);
1230+
let config = Config::from_str(
1231+
r#"
1232+
exclude_re = ["baz"]
1233+
exclude_globs = ["baz.rs"]
1234+
examine_globs = ["ex2.rs"]
1235+
examine_re = ["exr2"]
1236+
"#,
1237+
)
1238+
.unwrap();
1239+
let options = Options::new(&args, &config).unwrap();
1240+
for name in ["foo", "bar", "baz"] {
1241+
assert!(
1242+
options.exclude_name_re.is_match(name),
1243+
"Expected {name} to be excluded"
1244+
);
1245+
}
1246+
assert!(
1247+
!options.exclude_name_re.is_match("qux"),
1248+
"Expected qux not to be excluded"
1249+
);
1250+
1251+
assert!(
1252+
options.exclude_globset.as_ref().unwrap().is_match("baz.rs"),
1253+
"Expected baz.rs to be excluded"
1254+
);
1255+
assert!(
1256+
options.exclude_globset.as_ref().unwrap().is_match("foo.rs"),
1257+
"Expected foo.rs to be excluded"
1258+
);
1259+
assert!(
1260+
!options.exclude_globset.as_ref().unwrap().is_match("qux.rs"),
1261+
"Expected qux.rs NOT to be excluded"
1262+
);
1263+
1264+
for path in ["ex1.rs", "ex2.rs"] {
1265+
assert!(
1266+
options.examine_globset.as_ref().unwrap().is_match(path),
1267+
"Expected {path} to be examined"
1268+
);
1269+
}
1270+
1271+
for name in ["exr1", "exr2"] {
1272+
assert!(
1273+
options.examine_name_re.is_match(name),
1274+
"Expected {name} to be examined"
1275+
);
1276+
}
1277+
}
12201278
}
12211279

12221280
#[cfg(test)]

tests/main.rs

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3484,10 +3484,8 @@ fn list_with_config_file_inclusion() {
34843484
}
34853485

34863486
#[test]
3487-
fn file_argument_overrides_config_examine_globs_key() {
3487+
fn file_argument_merges_with_config_examine_globs_key() {
34883488
let testdata = copy_of_testdata("well_tested");
3489-
// This config key has no effect because the command line argument
3490-
// takes precedence.
34913489
write_config_file(
34923490
&testdata,
34933491
r#"examine_globs = ["src/*_mod.rs"]
@@ -3500,15 +3498,15 @@ fn file_argument_overrides_config_examine_globs_key() {
35003498
.assert()
35013499
.success()
35023500
.stdout(predicates::str::diff(indoc! { "\
3501+
src/inside_mod.rs
3502+
src/item_mod.rs
35033503
src/simple_fns.rs
35043504
" }));
35053505
}
35063506

35073507
#[test]
3508-
fn exclude_file_argument_overrides_config() {
3508+
fn exclude_file_argument_merge_with_config() {
35093509
let testdata = copy_of_testdata("well_tested");
3510-
// This config key has no effect because the command line argument
3511-
// takes precedence.
35123510
write_config_file(
35133511
&testdata,
35143512
indoc! { r#"
@@ -3526,7 +3524,8 @@ fn exclude_file_argument_overrides_config() {
35263524
.args(["--exclude", "src/b*.rs"])
35273525
.assert()
35283526
.success()
3529-
.stdout(predicates::str::diff(indoc! { "\
3527+
.stderr("")
3528+
.stdout(predicates::str::diff(indoc! {"\
35303529
src/lib.rs
35313530
src/arc.rs
35323531
src/empty_fns.rs
@@ -3569,7 +3568,7 @@ fn list_with_config_file_regexps() {
35693568
}
35703569

35713570
#[test]
3572-
fn exclude_re_overrides_config() {
3571+
fn exclude_re_on_command_line_merged_with_config() {
35733572
let testdata = copy_of_testdata("well_tested");
35743573
write_config_file(
35753574
&testdata,
@@ -3591,16 +3590,7 @@ fn exclude_re_overrides_config() {
35913590
.args(["-f", "src/simple_fns.rs"])
35923591
.assert()
35933592
.success();
3594-
assert_snapshot!(
3595-
String::from_utf8_lossy(&cmd.get_output().stdout),
3596-
@r###"
3597-
src/simple_fns.rs: replace returns_unit with ()
3598-
src/simple_fns.rs: replace += with -= in returns_unit
3599-
src/simple_fns.rs: replace += with *= in returns_unit
3600-
src/simple_fns.rs: replace == with != in divisible_by_three
3601-
src/simple_fns.rs: replace % with / in divisible_by_three
3602-
src/simple_fns.rs: replace % with + in divisible_by_three
3603-
"###);
3593+
assert_eq!(String::from_utf8_lossy(&cmd.get_output().stdout), "");
36043594
}
36053595

36063596
#[test]

0 commit comments

Comments
 (0)