Add mutation testing with cargo-mutants
Twelve mutants survived the initial run. Five were killed by new
targeted tests (dash-filter precedence, single-arg struct fallthrough,
enum newtype boundary). The remaining seven are equivalent or
unreachable (cov-excl blocks) and excluded via .cargo/mutants.toml.
The justfile mutants task treats exit 3 (timeouts from infinite-loop
mutants) as success; only exit 2 (missed mutants) fails the build.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml
new file mode 100644
index 0000000..10dbf5d
--- /dev/null
+++ b/.cargo/mutants.toml
@@ -0,0 +1,10 @@
+# Equivalent mutant: the `== 1` branch above already handles len 1,
+# so `> 1` and `>= 1` are equivalent in the else-if position.
+#
+# Unreachable code: NodeContentDeserializer::deserialize_any is behind
+# a cov-excl block — serde's Content buffering can't round-trip through
+# tree-structured deserializers.
+exclude_re = [
+ "de\\.rs:\\d+:\\d+: replace > with >= in <impl de::Deserializer<'de> for FieldDeserializer<'a>>::deserialize_any",
+ "de\\.rs:\\d+:\\d+: .* in <impl de::Deserializer<'de> for NodeContentDeserializer<'a>>::deserialize_any",
+]
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2cef060..4f66bd9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- run: rustup component add clippy rustfmt llvm-tools
- - run: cargo install grcov
- - run: cargo install just
+ - run: cargo install grcov cargo-mutants just
- run: cargo fmt --check
- run: just clippy coverage
+ - run: just mutants
diff --git a/.gitignore b/.gitignore
index 96ef6c0..8c932d5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
/target
Cargo.lock
+/mutants.out*
diff --git a/AGENTS.md b/AGENTS.md
index bb5ee54..784b895 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,6 +24,7 @@ Requires Rust nightly (edition 2024).
just all # fmt + clippy + coverage (enforces 100%)
just clippy # clippy with -D warnings
just coverage # instrumented test run, grcov report, fails below 100%
+just mutants # mutation testing via cargo-mutants, fails on missed mutants
cargo test # plain test run without coverage
```
@@ -31,6 +32,8 @@ cargo test # plain test run without coverage
- Rust edition 2024 (nightly toolchain)
- 100% line coverage required; CI fails otherwise
+- Zero missed mutants required; `.cargo/mutants.toml` excludes equivalent
+ and unreachable mutations with documented reasons
- `cargo clippy -- -D warnings` must pass with no warnings
- `cargo fmt` enforced in CI
- Use `thiserror` for error types
@@ -43,6 +46,7 @@ GitHub Actions runs on push to main and on PRs:
1. `cargo fmt --check`
2. `just clippy coverage`
+3. `just mutants`
Release workflow tags calver versions on successful CI runs against main.
Crates.io publishing is configured but currently commented out.
diff --git a/justfile b/justfile
index de8afe8..1df132b 100644
--- a/justfile
+++ b/justfile
@@ -42,4 +42,15 @@ coverage:
exit 1
fi
+mutants:
+ #!/usr/bin/env bash
+ set -uo pipefail
+ cargo mutants --timeout-multiplier 3 -j4
+ rc=$?
+ # 0 = all caught, 3 = timeouts (infinite loops from mutants, still caught)
+ if [ "$rc" -eq 0 ] || [ "$rc" -eq 3 ]; then
+ exit 0
+ fi
+ exit "$rc"
+
all: fmt clippy coverage
diff --git a/tests/integration.rs b/tests/integration.rs
index 02f5017..862a2ce 100644
--- a/tests/integration.rs
+++ b/tests/integration.rs
@@ -2013,3 +2013,104 @@ fn value_deserializer_seq_error() {
};
assert!(err.to_string().contains("sequence"));
}
+
+// ════════════════════════════════════════════════════════════════════════
+// Mutation testing — targeted tests for surviving mutants
+// ════════════════════════════════════════════════════════════════════════
+
+// Kills mutant: de.rs FieldDeserializer::deserialize_seq
+// dash filter `==` → `!=`. With mixed dash and non-dash children,
+// the mutant would pick non-dash nodes instead of dash nodes.
+#[test]
+fn field_deserialize_seq_dash_filter_over_non_dash() {
+ #[derive(Deserialize, Debug, PartialEq)]
+ struct S {
+ items: Vec<i32>,
+ }
+ let val: S = serde_kdl2::from_str("items {\n - 10\n - 20\n extra 99\n}\n").unwrap();
+ assert_eq!(val.items, vec![10, 20]);
+}
+
+// Kills mutant: de.rs FieldDeserializer::deserialize_struct
+// `fields.len() == 1 && args.len() == 1` → `||`. With 2 fields and 1
+// argument, `||` would enter SingleArgStructAccess (wrong), while `&&`
+// falls through to the empty-map path.
+#[test]
+fn field_deserialize_struct_multi_field_single_arg_falls_through() {
+ #[derive(Deserialize, Debug, PartialEq)]
+ struct Multi {
+ #[serde(default)]
+ a: Option<i32>,
+ #[serde(default)]
+ b: Option<String>,
+ }
+ #[derive(Deserialize, Debug, PartialEq)]
+ struct S {
+ data: Multi,
+ }
+ // 2 fields, 1 arg. With `&&`: false (2 ≠ 1), falls to empty map →
+ // both default to None. With `||`: `args.len() == 1` is true →
+ // SingleArgStructAccess assigns 42 to "a", producing Some(42).
+ let val: S = serde_kdl2::from_str("data 42").unwrap();
+ assert_eq!(val.data, Multi { a: None, b: None });
+}
+
+// Kills mutant: de.rs NodeContentDeserializer::deserialize_seq
+// dash filter `==` → `!=` in repeated-node context.
+#[test]
+fn node_content_seq_dash_filter_over_non_dash() {
+ #[derive(Deserialize, Debug, PartialEq)]
+ struct S {
+ group: Vec<Vec<i32>>,
+ }
+ let val: S = serde_kdl2::from_str(
+ "group {\n - 10\n - 20\n extra 99\n}\ngroup {\n - 30\n}\n",
+ )
+ .unwrap();
+ assert_eq!(val.group, vec![vec![10, 20], vec![30]]);
+}
+
+// Kills mutant: de.rs NodeContentDeserializer::deserialize_struct
+// `fields.len() == 1 && args.len() == 1` → `||` in repeated-node context.
+// With 2 fields and 1 arg per node, `||` enters SingleArgStructAccess
+// (wrong), while `&&` falls through to empty map.
+#[test]
+fn node_content_struct_multi_field_single_arg_falls_through() {
+ #[derive(Deserialize, Debug, PartialEq, Default)]
+ struct Multi {
+ #[serde(default)]
+ a: Option<i32>,
+ #[serde(default)]
+ b: Option<String>,
+ }
+ #[derive(Deserialize, Debug, PartialEq)]
+ struct S {
+ item: Vec<Multi>,
+ }
+ let val: S = serde_kdl2::from_str("item 42\nitem 99\n").unwrap();
+ assert_eq!(
+ val.item,
+ vec![Multi { a: None, b: None }, Multi { a: None, b: None }]
+ );
+}
+
+// Kills mutant: de.rs EnumUnitVariantAccess::newtype_variant_seed
+// `arg_offset < args.len()` → `arg_offset <= args.len()`. With the
+// mutant, accessing args[args.len()] panics instead of returning an error.
+#[test]
+fn enum_newtype_variant_missing_value_errors() {
+ #[derive(Deserialize, Debug)]
+ enum Val {
+ Wrap(String),
+ }
+ #[derive(Deserialize, Debug)]
+ struct S {
+ #[allow(dead_code)]
+ value: Val,
+ }
+ // "Wrap" is the only arg → arg_offset=1, args.len()=1.
+ // With `<`: 1 < 1 is false → returns error.
+ // With `<=`: 1 <= 1 is true → tries args[1] → panic.
+ let result = serde_kdl2::from_str::<S>(r#"value "Wrap""#);
+ assert!(result.is_err());
+}