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    /// `z.number().int()` to `z.number()` in a module-level `const`
54    /// initializer: one call link of a fluent chain removed.
55    RemovedChainLink,
56}
57
58impl MutationOperatorName {
59    /// The kebab-case name.
60    #[must_use]
61    pub const fn as_str(self) -> &'static str {
62        match self {
63            Self::ArithmeticOperator => "arithmetic-operator",
64            Self::RelationalOperator => "relational-operator",
65            Self::LogicalOperator => "logical-operator",
66            Self::NegateCondition => "negate-condition",
67            Self::UnaryOperator => "unary-operator",
68            Self::UpdateOperator => "update-operator",
69            Self::RemovedCall => "removed-call",
70            Self::ReturnValue => "return-value",
71            Self::BooleanLiteral => "boolean-literal",
72            Self::StringLiteral => "string-literal",
73            Self::OptionalChaining => "optional-chaining",
74            Self::RegexLiteral => "regex-literal",
75            Self::RemovedChainLink => "removed-chain-link",
76        }
77    }
78}
79
80fn deserialize_min_score<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
81where
82    D: Deserializer<'de>,
83{
84    let value = Option::<f64>::deserialize(deserializer)?;
85    if let Some(score) = value
86        && (!score.is_finite() || !(0.0..=100.0).contains(&score))
87    {
88        return Err(serde::de::Error::custom(format!(
89            "mutation.minScore must be between 0 and 100 (got {score})"
90        )));
91    }
92    Ok(value)
93}
94
95fn deserialize_concurrency<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
96where
97    D: Deserializer<'de>,
98{
99    let value = Option::<usize>::deserialize(deserializer)?;
100    if value == Some(0) {
101        return Err(serde::de::Error::custom(
102            "mutation.concurrency must be at least 1",
103        ));
104    }
105    Ok(value)
106}
107
108/// Project settings for `fallow mutation`.
109///
110/// Project config cannot widen what a run executes: the override that keeps
111/// mutants near file system, process, network or database sinks is a CLI
112/// flag only (`--allow-side-effects`), and no key starts a run.
113#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize, JsonSchema)]
114#[serde(rename_all = "camelCase", deny_unknown_fields)]
115pub struct MutationConfig {
116    /// The test runner: `auto`, `vitest`, `node-test` or `bun-test`. `auto`
117    /// detects it from the project.
118    #[serde(default)]
119    pub runner: MutationRunner,
120
121    /// The operators to use. Unset uses each operator. `--operators` wins.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub operators: Option<Vec<MutationOperatorName>>,
124
125    /// The minimum mutation score, from 0 to 100. When the score of a run is
126    /// lower, `fallow mutation` exits with code 1. Unset never fails a run.
127    /// `--min-score` wins.
128    #[serde(
129        default,
130        skip_serializing_if = "Option::is_none",
131        deserialize_with = "deserialize_min_score"
132    )]
133    #[schemars(range(min = 0.0, max = 100.0))]
134    pub min_score: Option<f64>,
135
136    /// The number of companion processes. Unset uses half the logical cores.
137    /// `--concurrency` wins.
138    #[serde(
139        default,
140        skip_serializing_if = "Option::is_none",
141        deserialize_with = "deserialize_concurrency"
142    )]
143    #[schemars(range(min = 1))]
144    pub concurrency: Option<usize>,
145
146    /// Type-check each survived mutant through the TypeScript-Go companion
147    /// of `--type-aware`. A survivor that does not type-check becomes
148    /// `compile_error` and does not count in the score. Unset or `false`
149    /// checks no survivor. `--typecheck` turns the check on.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub typecheck: Option<bool>,
152
153    /// The config file of the test runner, relative to the project root, for
154    /// example `vitest.integration.config.mjs`. The file must exist inside
155    /// the project root. Only the Vitest runner reads it. Unset uses the
156    /// default config discovery of the runner. `--runner-config` wins.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub runner_config: Option<String>,
159}
160
161impl MutationConfig {
162    /// True when no key is set.
163    #[must_use]
164    pub fn is_default(&self) -> bool {
165        *self == Self::default()
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    fn parse(json: &str) -> Result<MutationConfig, String> {
174        serde_json::from_str::<MutationConfig>(json).map_err(|error| error.to_string())
175    }
176
177    #[test]
178    fn every_key_parses_in_camel_case() {
179        let config = parse(
180            r#"{"runner":"vitest","operators":["arithmetic-operator","removed-call"],
181                "minScore":80,"concurrency":4,"typecheck":true,
182                "runnerConfig":"vitest.integration.config.mjs"}"#,
183        )
184        .expect("valid config");
185        assert_eq!(config.runner, MutationRunner::Vitest);
186        assert_eq!(
187            config.operators,
188            Some(vec![
189                MutationOperatorName::ArithmeticOperator,
190                MutationOperatorName::RemovedCall
191            ])
192        );
193        assert_eq!(config.min_score, Some(80.0));
194        assert_eq!(config.concurrency, Some(4));
195        assert_eq!(config.typecheck, Some(true));
196        assert_eq!(
197            config.runner_config.as_deref(),
198            Some("vitest.integration.config.mjs")
199        );
200    }
201
202    #[test]
203    fn the_runner_names_node_test_and_bun_test() {
204        let node = parse(r#"{"runner":"node-test"}"#).expect("node-test");
205        assert_eq!(node.runner, MutationRunner::NodeTest);
206        let bun = parse(r#"{"runner":"bun-test"}"#).expect("bun-test");
207        assert_eq!(bun.runner, MutationRunner::BunTest);
208        assert!(parse(r#"{"runner":"jest"}"#).is_err());
209    }
210
211    #[test]
212    fn an_empty_section_is_the_default() {
213        let config = parse("{}").expect("empty section");
214        assert!(config.is_default());
215        assert_eq!(config.runner, MutationRunner::Auto);
216        assert!(!parse(r#"{"minScore":10}"#).expect("set").is_default());
217    }
218
219    #[test]
220    fn side_effect_override_is_not_a_config_key() {
221        let error = parse(r#"{"allowSideEffects":true}"#).expect_err("flag only");
222        assert!(error.contains("allowSideEffects"), "{error}");
223    }
224
225    #[test]
226    fn keys_that_the_run_does_not_apply_are_rejected() {
227        for key in ["include", "exclude", "timeoutFactor", "timeoutMs"] {
228            let json = if key.starts_with("timeout") {
229                format!(r#"{{"{key}":2}}"#)
230            } else {
231                format!(r#"{{"{key}":["src/**"]}}"#)
232            };
233            let error = parse(&json).expect_err(&json);
234            assert!(error.contains("unknown field"), "{json}: {error}");
235        }
236    }
237
238    #[test]
239    fn out_of_range_values_fail_loud() {
240        for (json, needle) in [
241            (r#"{"minScore":101}"#, "between 0 and 100"),
242            (r#"{"minScore":-1}"#, "between 0 and 100"),
243            (r#"{"concurrency":0}"#, "concurrency must be at least 1"),
244            (r#"{"runner":"jest"}"#, "unknown variant"),
245            (r#"{"operators":["bogus"]}"#, "unknown variant"),
246        ] {
247            let error = parse(json).expect_err(json);
248            assert!(error.contains(needle), "{json}: {error}");
249        }
250        assert_eq!(
251            parse(r#"{"minScore":0}"#).expect("zero").min_score,
252            Some(0.0)
253        );
254        assert_eq!(
255            parse(r#"{"minScore":100}"#).expect("max").min_score,
256            Some(100.0)
257        );
258    }
259
260    #[test]
261    fn operator_names_round_trip_through_serde() {
262        for name in [
263            MutationOperatorName::ArithmeticOperator,
264            MutationOperatorName::RelationalOperator,
265            MutationOperatorName::LogicalOperator,
266            MutationOperatorName::NegateCondition,
267            MutationOperatorName::UnaryOperator,
268            MutationOperatorName::UpdateOperator,
269            MutationOperatorName::RemovedCall,
270            MutationOperatorName::ReturnValue,
271            MutationOperatorName::BooleanLiteral,
272            MutationOperatorName::StringLiteral,
273            MutationOperatorName::OptionalChaining,
274        ] {
275            let json = serde_json::to_value(name).expect("serializes");
276            assert_eq!(json, serde_json::Value::String(name.as_str().to_owned()));
277        }
278    }
279}