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}
54
55impl MutationOperatorName {
56 #[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#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize, JsonSchema)]
110#[serde(rename_all = "camelCase", deny_unknown_fields)]
111pub struct MutationConfig {
112 #[serde(default)]
115 pub runner: MutationRunner,
116
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub operators: Option<Vec<MutationOperatorName>>,
120
121 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub typecheck: Option<bool>,
148}
149
150impl MutationConfig {
151 #[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}