1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
6#[serde(rename_all = "kebab-case")]
7pub enum MutationRunner {
8 #[default]
12 Auto,
13 Vitest,
15 NodeTest,
18 BunTest,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
25#[serde(rename_all = "kebab-case")]
26pub enum MutationOperatorName {
27 ArithmeticOperator,
29 RelationalOperator,
31 LogicalOperator,
33 NegateCondition,
35 UnaryOperator,
37 UpdateOperator,
39 RemovedCall,
41 ReturnValue,
43 BooleanLiteral,
45 StringLiteral,
47 OptionalChaining,
49 RegexLiteral,
53 RemovedChainLink,
56}
57
58impl MutationOperatorName {
59 #[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#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize, JsonSchema)]
114#[serde(rename_all = "camelCase", deny_unknown_fields)]
115pub struct MutationConfig {
116 #[serde(default)]
119 pub runner: MutationRunner,
120
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub operators: Option<Vec<MutationOperatorName>>,
124
125 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub typecheck: Option<bool>,
152
153 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub runner_config: Option<String>,
159}
160
161impl MutationConfig {
162 #[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}