Skip to main content

fallow_config/config/
mutation.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4/// The test runner of `fallow mutation`.
5#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
6#[serde(rename_all = "kebab-case")]
7pub enum MutationRunner {
8    /// Detect the runner from the project: the `vitest` dependency, the
9    /// test module that the test files import, a Bun file at the root, or a
10    /// `node --test` script.
11    #[default]
12    Auto,
13    /// Vitest 4.1 to 5 from the project `node_modules`.
14    Vitest,
15    /// `node:test` of Node.js 22.18 or later. Each test file runs in its own
16    /// Node process.
17    NodeTest,
18    /// `bun test` of Bun 1.4 or later. Each test file runs in its own Bun
19    /// process.
20    BunTest,
21}
22
23/// A mutation operator name, kebab-case.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
25#[serde(rename_all = "kebab-case")]
26pub enum MutationOperatorName {
27    /// `a + b` to `a - b`, and the other arithmetic pairs.
28    ArithmeticOperator,
29    /// `a < b` to `a <= b`, and the other relational pairs.
30    RelationalOperator,
31    /// `a && b` to `a || b`, and the reverse.
32    LogicalOperator,
33    /// A condition to its negation.
34    NegateCondition,
35    /// A unary operator removed or changed.
36    UnaryOperator,
37    /// `i++` to `i--`, and the reverse.
38    UpdateOperator,
39    /// A call statement removed.
40    RemovedCall,
41    /// A return value changed.
42    ReturnValue,
43    /// `true` to `false`, and the reverse.
44    BooleanLiteral,
45    /// A string literal emptied or filled.
46    StringLiteral,
47    /// `a?.b` to `a.b`.
48    OptionalChaining,
49    /// One token of a regular expression literal changed: an anchor
50    /// removed, a character class negated, or a class escape with the
51    /// opposite case.
52    RegexLiteral,
53}
54
55impl MutationOperatorName {
56    /// The kebab-case name.
57    #[must_use]
58    pub const fn as_str(self) -> &'static str {
59        match self {
60            Self::ArithmeticOperator => "arithmetic-operator",
61            Self::RelationalOperator => "relational-operator",
62            Self::LogicalOperator => "logical-operator",
63            Self::NegateCondition => "negate-condition",
64            Self::UnaryOperator => "unary-operator",
65            Self::UpdateOperator => "update-operator",
66            Self::RemovedCall => "removed-call",
67            Self::ReturnValue => "return-value",
68            Self::BooleanLiteral => "boolean-literal",
69            Self::StringLiteral => "string-literal",
70            Self::OptionalChaining => "optional-chaining",
71            Self::RegexLiteral => "regex-literal",
72        }
73    }
74}
75
76fn deserialize_min_score<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
77where
78    D: Deserializer<'de>,
79{
80    let value = Option::<f64>::deserialize(deserializer)?;
81    if let Some(score) = value
82        && (!score.is_finite() || !(0.0..=100.0).contains(&score))
83    {
84        return Err(serde::de::Error::custom(format!(
85            "mutation.minScore must be between 0 and 100 (got {score})"
86        )));
87    }
88    Ok(value)
89}
90
91fn deserialize_concurrency<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
92where
93    D: Deserializer<'de>,
94{
95    let value = Option::<usize>::deserialize(deserializer)?;
96    if value == Some(0) {
97        return Err(serde::de::Error::custom(
98            "mutation.concurrency must be at least 1",
99        ));
100    }
101    Ok(value)
102}
103
104/// Project settings for `fallow mutation`.
105///
106/// Project config cannot widen what a run executes: the override that keeps
107/// mutants near file system, process, network or database sinks is a CLI
108/// flag only (`--allow-side-effects`), and no key starts a run.
109#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize, JsonSchema)]
110#[serde(rename_all = "camelCase", deny_unknown_fields)]
111pub struct MutationConfig {
112    /// The test runner: `auto`, `vitest`, `node-test` or `bun-test`. `auto`
113    /// detects it from the project.
114    #[serde(default)]
115    pub runner: MutationRunner,
116
117    /// The operators to use. Unset uses each operator. `--operators` wins.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub operators: Option<Vec<MutationOperatorName>>,
120
121    /// The minimum mutation score, from 0 to 100. When the score of a run is
122    /// lower, `fallow mutation` exits with code 1. Unset never fails a run.
123    /// `--min-score` wins.
124    #[serde(
125        default,
126        skip_serializing_if = "Option::is_none",
127        deserialize_with = "deserialize_min_score"
128    )]
129    #[schemars(range(min = 0.0, max = 100.0))]
130    pub min_score: Option<f64>,
131
132    /// The number of companion processes. Unset uses half the logical cores.
133    /// `--concurrency` wins.
134    #[serde(
135        default,
136        skip_serializing_if = "Option::is_none",
137        deserialize_with = "deserialize_concurrency"
138    )]
139    #[schemars(range(min = 1))]
140    pub concurrency: Option<usize>,
141
142    /// Type-check each survived mutant through the TypeScript-Go companion
143    /// of `--type-aware`. A survivor that does not type-check becomes
144    /// `compile_error` and does not count in the score. Unset or `false`
145    /// checks no survivor. `--typecheck` turns the check on.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub typecheck: Option<bool>,
148}
149
150impl MutationConfig {
151    /// True when no key is set.
152    #[must_use]
153    pub fn is_default(&self) -> bool {
154        *self == Self::default()
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn parse(json: &str) -> Result<MutationConfig, String> {
163        serde_json::from_str::<MutationConfig>(json).map_err(|error| error.to_string())
164    }
165
166    #[test]
167    fn every_key_parses_in_camel_case() {
168        let config = parse(
169            r#"{"runner":"vitest","operators":["arithmetic-operator","removed-call"],
170                "minScore":80,"concurrency":4,"typecheck":true}"#,
171        )
172        .expect("valid config");
173        assert_eq!(config.runner, MutationRunner::Vitest);
174        assert_eq!(
175            config.operators,
176            Some(vec![
177                MutationOperatorName::ArithmeticOperator,
178                MutationOperatorName::RemovedCall
179            ])
180        );
181        assert_eq!(config.min_score, Some(80.0));
182        assert_eq!(config.concurrency, Some(4));
183        assert_eq!(config.typecheck, Some(true));
184    }
185
186    #[test]
187    fn the_runner_names_node_test_and_bun_test() {
188        let node = parse(r#"{"runner":"node-test"}"#).expect("node-test");
189        assert_eq!(node.runner, MutationRunner::NodeTest);
190        let bun = parse(r#"{"runner":"bun-test"}"#).expect("bun-test");
191        assert_eq!(bun.runner, MutationRunner::BunTest);
192        assert!(parse(r#"{"runner":"jest"}"#).is_err());
193    }
194
195    #[test]
196    fn an_empty_section_is_the_default() {
197        let config = parse("{}").expect("empty section");
198        assert!(config.is_default());
199        assert_eq!(config.runner, MutationRunner::Auto);
200        assert!(!parse(r#"{"minScore":10}"#).expect("set").is_default());
201    }
202
203    #[test]
204    fn side_effect_override_is_not_a_config_key() {
205        let error = parse(r#"{"allowSideEffects":true}"#).expect_err("flag only");
206        assert!(error.contains("allowSideEffects"), "{error}");
207    }
208
209    #[test]
210    fn keys_that_the_run_does_not_apply_are_rejected() {
211        for key in ["include", "exclude", "timeoutFactor", "timeoutMs"] {
212            let json = if key.starts_with("timeout") {
213                format!(r#"{{"{key}":2}}"#)
214            } else {
215                format!(r#"{{"{key}":["src/**"]}}"#)
216            };
217            let error = parse(&json).expect_err(&json);
218            assert!(error.contains("unknown field"), "{json}: {error}");
219        }
220    }
221
222    #[test]
223    fn out_of_range_values_fail_loud() {
224        for (json, needle) in [
225            (r#"{"minScore":101}"#, "between 0 and 100"),
226            (r#"{"minScore":-1}"#, "between 0 and 100"),
227            (r#"{"concurrency":0}"#, "concurrency must be at least 1"),
228            (r#"{"runner":"jest"}"#, "unknown variant"),
229            (r#"{"operators":["bogus"]}"#, "unknown variant"),
230        ] {
231            let error = parse(json).expect_err(json);
232            assert!(error.contains(needle), "{json}: {error}");
233        }
234        assert_eq!(
235            parse(r#"{"minScore":0}"#).expect("zero").min_score,
236            Some(0.0)
237        );
238        assert_eq!(
239            parse(r#"{"minScore":100}"#).expect("max").min_score,
240            Some(100.0)
241        );
242    }
243
244    #[test]
245    fn operator_names_round_trip_through_serde() {
246        for name in [
247            MutationOperatorName::ArithmeticOperator,
248            MutationOperatorName::RelationalOperator,
249            MutationOperatorName::LogicalOperator,
250            MutationOperatorName::NegateCondition,
251            MutationOperatorName::UnaryOperator,
252            MutationOperatorName::UpdateOperator,
253            MutationOperatorName::RemovedCall,
254            MutationOperatorName::ReturnValue,
255            MutationOperatorName::BooleanLiteral,
256            MutationOperatorName::StringLiteral,
257            MutationOperatorName::OptionalChaining,
258        ] {
259            let json = serde_json::to_value(name).expect("serializes");
260            assert_eq!(json, serde_json::Value::String(name.as_str().to_owned()));
261        }
262    }
263}