#[derive(Debug, Clone)]
pub struct Mutation {
pub start: usize,
pub end: usize,
pub from: &'static str,
pub to: &'static str,
pub line: u32,
}
impl Mutation {
pub fn apply(&self, source: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(source.len() + self.to.len());
out.extend_from_slice(&source[..self.start]);
out.extend_from_slice(self.to.as_bytes());
out.extend_from_slice(&source[self.end..]);
out
}
pub fn description(&self) -> String {
format!("line {}: `{}` → `{}`", self.line, self.from, self.to)
}
}
static OPERATORS: &[(&str, &str)] = &[
(">=", ">"),
("<=", "<"),
("!=", "=="),
("==", "!="),
(" and ", " or "),
(" or ", " and "),
(" + ", " - "),
(" - ", " + "),
(" * ", " / "),
(" / ", " * "),
(">", ">="),
("<", "<="),
("true", "false"),
("false", "true"),
];
pub fn find_mutations(source: &[u8]) -> Vec<Mutation> {
let text = match std::str::from_utf8(source) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let mut mutations: Vec<Mutation> = Vec::new();
let mut byte_offset: usize = 0;
for (line_idx, raw_line) in text.lines().enumerate() {
let line_num = (line_idx + 1) as u32;
let line = raw_line.trim_end_matches('\r');
let code_end = comment_start(line);
let code = &line[..code_end];
let mut used: Vec<(usize, usize)> = Vec::new();
for &(from, to) in OPERATORS {
let mut scan = 0usize;
while scan < code.len() {
let Some(rel) = code[scan..].find(from) else {
break;
};
let abs = scan + rel;
scan = abs + 1;
if used.iter().any(|&(s, e)| s < abs + from.len() && e > abs) {
continue;
}
if inside_string(code, abs) {
continue;
}
if from == ">" || from == "<" {
let prev = abs.checked_sub(1).map(|i| code.as_bytes()[i]);
let next = code.as_bytes().get(abs + from.len()).copied();
if matches!(prev, Some(b'>' | b'<' | b'=' | b'!')) || matches!(next, Some(b'='))
{
continue;
}
}
if from == "true" || from == "false" {
let prev = abs.checked_sub(1).map(|i| code.as_bytes()[i]);
let next = code.as_bytes().get(abs + from.len()).copied();
let prev_ok = prev.is_none_or(|b| !b.is_ascii_alphanumeric() && b != b'_');
let next_ok = next.is_none_or(|b| !b.is_ascii_alphanumeric() && b != b'_');
if !prev_ok || !next_ok {
continue;
}
}
used.push((abs, abs + from.len()));
mutations.push(Mutation {
start: byte_offset + abs,
end: byte_offset + abs + from.len(),
from,
to,
line: line_num,
});
scan = abs + from.len();
}
}
byte_offset += raw_line.len() + 1; }
mutations
}
fn comment_start(line: &str) -> usize {
let mut in_str: Option<char> = None;
for (i, c) in line.char_indices() {
match in_str {
Some(q) if c == q => in_str = None,
None if c == '"' || c == '\'' => in_str = Some(c),
None if c == '#' => return i,
_ => {}
}
}
line.len()
}
fn inside_string(line: &str, pos: usize) -> bool {
let mut in_str: Option<char> = None;
for (i, c) in line.char_indices() {
if i >= pos {
break;
}
match in_str {
Some(q) if c == q => in_str = None,
None if c == '"' || c == '\'' => in_str = Some(c),
_ => {}
}
}
in_str.is_some()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_comparison_mutations() {
let src = b"if health > 0:\n pass\n";
let muts = find_mutations(src);
assert!(
muts.iter()
.any(|m| m.from == ">" && m.to == ">=" && m.line == 1),
"expected > → >= mutation: {muts:?}"
);
}
#[test]
fn skips_greater_equal_for_plain_greater() {
let src = b"if x >= 0:\n pass\n";
let muts = find_mutations(src);
let gt_to_gte: Vec<_> = muts
.iter()
.filter(|m| m.from == ">" && m.to == ">=")
.collect();
assert!(
gt_to_gte.is_empty(),
"bare > should not appear inside >=: {gt_to_gte:?}"
);
}
#[test]
fn skips_operators_in_strings() {
let src = b"var s = \"a > b\"\nif x > 0:\n pass\n";
let muts = find_mutations(src);
let line1: Vec<_> = muts.iter().filter(|m| m.line == 1).collect();
assert!(line1.is_empty(), "no mutations inside strings: {line1:?}");
assert!(muts.iter().any(|m| m.line == 2 && m.from == ">"));
}
#[test]
fn skips_comments() {
let src = b"# if x > 0: pass\nif y > 0:\n pass\n";
let muts = find_mutations(src);
let line1: Vec<_> = muts.iter().filter(|m| m.line == 1).collect();
assert!(line1.is_empty(), "no mutations in comments: {line1:?}");
}
#[test]
fn true_false_mutation_respects_word_boundary() {
let src = b"var is_true := true\nvar retrue := 1\n";
let muts = find_mutations(src);
let true_muts: Vec<_> = muts
.iter()
.filter(|m| m.from == "true" && m.to == "false")
.collect();
assert_eq!(
true_muts.len(),
1,
"expected exactly 1 true→false: {true_muts:?}"
);
assert_eq!(true_muts[0].line, 1);
let col = true_muts[0].start;
assert!(
col > 10,
"mutation should be past the identifier, got byte offset {col}"
);
}
#[test]
fn apply_mutation_replaces_bytes() {
let src = b"if x > 0:\n pass\n";
let muts = find_mutations(src);
let gt_mut = muts.iter().find(|m| m.from == ">").unwrap();
let mutated = gt_mut.apply(src);
let mutated_str = std::str::from_utf8(&mutated).unwrap();
assert!(
mutated_str.contains(">="),
"mutation not applied: {mutated_str}"
);
assert!(
!mutated_str.contains(" > ") || mutated_str.contains(">="),
"original not replaced"
);
}
}