use rand::Rng;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Strategy {
StringNudge,
StringUnicode,
NumberNudge,
BoolFlip,
TypeFlip,
DropKey,
GrowArray,
}
const ALL: &[Strategy] = &[
Strategy::StringNudge,
Strategy::StringUnicode,
Strategy::NumberNudge,
Strategy::BoolFlip,
Strategy::TypeFlip,
Strategy::DropKey,
Strategy::GrowArray,
];
pub fn mutate(input: &Value, rng: &mut impl Rng) -> Value {
let strategy = ALL[rng.gen_range(0..ALL.len())];
let mut out = input.clone();
apply(&mut out, strategy, rng);
out
}
fn apply(value: &mut Value, strategy: Strategy, rng: &mut impl Rng) {
match value {
Value::Object(map) => {
if map.is_empty() {
return;
}
let keys: Vec<String> = map.keys().cloned().collect();
let chosen = &keys[rng.gen_range(0..keys.len())];
match strategy {
Strategy::DropKey => {
map.remove(chosen);
}
Strategy::GrowArray => {
if let Some(Value::Array(arr)) = map.get_mut(chosen) {
if let Some(last) = arr.last().cloned() {
arr.push(last);
} else {
arr.push(Value::Null);
}
} else if let Some(child) = map.get_mut(chosen) {
apply(child, strategy, rng);
}
}
_ => {
if let Some(child) = map.get_mut(chosen) {
apply(child, strategy, rng);
}
}
}
}
Value::Array(arr) => {
if arr.is_empty() {
if matches!(strategy, Strategy::GrowArray) {
arr.push(Value::Null);
}
return;
}
let idx = rng.gen_range(0..arr.len());
apply(&mut arr[idx], strategy, rng);
}
Value::String(s) => match strategy {
Strategy::StringNudge => s.push_str("_x"),
Strategy::StringUnicode => {
*s = format!("\u{202E}\u{200D}{s}");
}
Strategy::TypeFlip => {
*value = Value::Number(serde_json::Number::from(0));
}
_ => {}
},
Value::Number(_) => match strategy {
Strategy::NumberNudge => {
if let Some(n) = value.as_i64() {
let nudged = if rng.gen_bool(0.1) {
if rng.gen_bool(0.5) {
i64::MAX
} else {
i64::MIN
}
} else {
n.saturating_add(if rng.gen_bool(0.5) { 1 } else { -1 })
};
*value = Value::Number(serde_json::Number::from(nudged));
} else if let Some(n) = value.as_f64() {
*value = Value::Number(
serde_json::Number::from_f64(n + 1.0)
.unwrap_or_else(|| serde_json::Number::from(0)),
);
}
}
Strategy::TypeFlip => {
*value = Value::String("0".to_string());
}
_ => {}
},
Value::Bool(b) => {
if matches!(strategy, Strategy::BoolFlip | Strategy::TypeFlip) {
if matches!(strategy, Strategy::TypeFlip) {
*value = Value::String((!*b).to_string());
} else {
*value = Value::Bool(!*b);
}
}
}
Value::Null => {
if matches!(strategy, Strategy::TypeFlip) {
*value = Value::String("".to_string());
}
}
}
}
pub fn splice(a: &Value, b: &Value, rng: &mut impl Rng) -> Value {
match (a, b) {
(Value::Object(am), Value::Object(bm)) => {
let mut out = am.clone();
let bkeys: Vec<&String> = bm.keys().collect();
if bkeys.is_empty() {
return Value::Object(out);
}
let take = (bkeys.len() / 2).max(1);
for k in bkeys.iter().take(take) {
if let Some(v) = bm.get(*k) {
out.insert((*k).clone(), v.clone());
}
}
apply(&mut Value::Object(out.clone()), Strategy::StringNudge, rng);
Value::Object(out)
}
_ => mutate(a, rng),
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use rand::SeedableRng;
use rand_chacha::ChaCha20Rng;
use serde_json::json;
fn rng() -> ChaCha20Rng {
ChaCha20Rng::from_seed([42; 32])
}
#[test]
fn mutate_preserves_top_level_object_shape() {
let input = json!({"name": "alice", "age": 30});
for _ in 0..50 {
let out = mutate(&input, &mut rng());
assert!(out.is_object(), "top-level must stay object");
}
}
#[test]
fn mutate_actually_mutates_at_least_some_of_the_time() {
let input = json!({"name": "alice", "age": 30, "active": true});
let mut rng = rng();
let mut differs = 0;
for _ in 0..50 {
let out = mutate(&input, &mut rng);
if out != input {
differs += 1;
}
}
assert!(
differs > 25,
"mutation should actually change the input most of the time; got {differs}/50"
);
}
#[test]
fn mutate_is_resilient_against_empty_input() {
let mut rng = rng();
for _ in 0..10 {
let _ = mutate(&json!({}), &mut rng);
let _ = mutate(&json!([]), &mut rng);
let _ = mutate(&json!(null), &mut rng);
}
}
#[test]
fn splice_combines_keys_from_both_parents() {
let a = json!({"a": 1, "b": 2});
let b = json!({"c": 3, "d": 4});
let mut rng = rng();
let mut saw_c_or_d = 0;
for _ in 0..50 {
let out = splice(&a, &b, &mut rng);
let obj = out.as_object().expect("object");
if obj.contains_key("c") || obj.contains_key("d") {
saw_c_or_d += 1;
}
}
assert!(
saw_c_or_d > 0,
"splice should sometimes pull keys from `b`; got {saw_c_or_d}/50"
);
}
}