use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum MutationRunner {
#[default]
Auto,
Vitest,
NodeTest,
BunTest,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum MutationOperatorName {
ArithmeticOperator,
RelationalOperator,
LogicalOperator,
NegateCondition,
UnaryOperator,
UpdateOperator,
RemovedCall,
ReturnValue,
BooleanLiteral,
StringLiteral,
OptionalChaining,
RegexLiteral,
}
impl MutationOperatorName {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ArithmeticOperator => "arithmetic-operator",
Self::RelationalOperator => "relational-operator",
Self::LogicalOperator => "logical-operator",
Self::NegateCondition => "negate-condition",
Self::UnaryOperator => "unary-operator",
Self::UpdateOperator => "update-operator",
Self::RemovedCall => "removed-call",
Self::ReturnValue => "return-value",
Self::BooleanLiteral => "boolean-literal",
Self::StringLiteral => "string-literal",
Self::OptionalChaining => "optional-chaining",
Self::RegexLiteral => "regex-literal",
}
}
}
fn deserialize_min_score<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<f64>::deserialize(deserializer)?;
if let Some(score) = value
&& (!score.is_finite() || !(0.0..=100.0).contains(&score))
{
return Err(serde::de::Error::custom(format!(
"mutation.minScore must be between 0 and 100 (got {score})"
)));
}
Ok(value)
}
fn deserialize_concurrency<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<usize>::deserialize(deserializer)?;
if value == Some(0) {
return Err(serde::de::Error::custom(
"mutation.concurrency must be at least 1",
));
}
Ok(value)
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct MutationConfig {
#[serde(default)]
pub runner: MutationRunner,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operators: Option<Vec<MutationOperatorName>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_min_score"
)]
#[schemars(range(min = 0.0, max = 100.0))]
pub min_score: Option<f64>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_concurrency"
)]
#[schemars(range(min = 1))]
pub concurrency: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub typecheck: Option<bool>,
}
impl MutationConfig {
#[must_use]
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(json: &str) -> Result<MutationConfig, String> {
serde_json::from_str::<MutationConfig>(json).map_err(|error| error.to_string())
}
#[test]
fn every_key_parses_in_camel_case() {
let config = parse(
r#"{"runner":"vitest","operators":["arithmetic-operator","removed-call"],
"minScore":80,"concurrency":4,"typecheck":true}"#,
)
.expect("valid config");
assert_eq!(config.runner, MutationRunner::Vitest);
assert_eq!(
config.operators,
Some(vec![
MutationOperatorName::ArithmeticOperator,
MutationOperatorName::RemovedCall
])
);
assert_eq!(config.min_score, Some(80.0));
assert_eq!(config.concurrency, Some(4));
assert_eq!(config.typecheck, Some(true));
}
#[test]
fn the_runner_names_node_test_and_bun_test() {
let node = parse(r#"{"runner":"node-test"}"#).expect("node-test");
assert_eq!(node.runner, MutationRunner::NodeTest);
let bun = parse(r#"{"runner":"bun-test"}"#).expect("bun-test");
assert_eq!(bun.runner, MutationRunner::BunTest);
assert!(parse(r#"{"runner":"jest"}"#).is_err());
}
#[test]
fn an_empty_section_is_the_default() {
let config = parse("{}").expect("empty section");
assert!(config.is_default());
assert_eq!(config.runner, MutationRunner::Auto);
assert!(!parse(r#"{"minScore":10}"#).expect("set").is_default());
}
#[test]
fn side_effect_override_is_not_a_config_key() {
let error = parse(r#"{"allowSideEffects":true}"#).expect_err("flag only");
assert!(error.contains("allowSideEffects"), "{error}");
}
#[test]
fn keys_that_the_run_does_not_apply_are_rejected() {
for key in ["include", "exclude", "timeoutFactor", "timeoutMs"] {
let json = if key.starts_with("timeout") {
format!(r#"{{"{key}":2}}"#)
} else {
format!(r#"{{"{key}":["src/**"]}}"#)
};
let error = parse(&json).expect_err(&json);
assert!(error.contains("unknown field"), "{json}: {error}");
}
}
#[test]
fn out_of_range_values_fail_loud() {
for (json, needle) in [
(r#"{"minScore":101}"#, "between 0 and 100"),
(r#"{"minScore":-1}"#, "between 0 and 100"),
(r#"{"concurrency":0}"#, "concurrency must be at least 1"),
(r#"{"runner":"jest"}"#, "unknown variant"),
(r#"{"operators":["bogus"]}"#, "unknown variant"),
] {
let error = parse(json).expect_err(json);
assert!(error.contains(needle), "{json}: {error}");
}
assert_eq!(
parse(r#"{"minScore":0}"#).expect("zero").min_score,
Some(0.0)
);
assert_eq!(
parse(r#"{"minScore":100}"#).expect("max").min_score,
Some(100.0)
);
}
#[test]
fn operator_names_round_trip_through_serde() {
for name in [
MutationOperatorName::ArithmeticOperator,
MutationOperatorName::RelationalOperator,
MutationOperatorName::LogicalOperator,
MutationOperatorName::NegateCondition,
MutationOperatorName::UnaryOperator,
MutationOperatorName::UpdateOperator,
MutationOperatorName::RemovedCall,
MutationOperatorName::ReturnValue,
MutationOperatorName::BooleanLiteral,
MutationOperatorName::StringLiteral,
MutationOperatorName::OptionalChaining,
] {
let json = serde_json::to_value(name).expect("serializes");
assert_eq!(json, serde_json::Value::String(name.as_str().to_owned()));
}
}
}