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
change sssoprrswtukynzlywpkzrwmqotkxqwk
commit 3c0590501be9173065ffd6f73913a3d588b83cc9
author Alpha Chen <alpha@kejadlen.dev>
date
parent pvtousqo
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());
+}