use super::{
MutationOutcomeRecord, json_scalar_as_string, json_scalar_as_usize, normalize_report_path,
};
use serde_json::Value;
const MUTANT_ID_KEYS: &[&str] = &["id", "mutant_id", "mutantId"];
const SEAM_ID_KEYS: &[&str] = &["seam_id", "seamId", "probe_id", "probeId"];
const FILE_KEYS: &[&str] = &["file", "path", "source_file", "src_file", "filename"];
const NESTED_FILE_KEYS: &[&str] = &[
"file",
"path",
"source_file",
"src_file",
"filename",
"file_name",
];
const LINE_KEYS: &[&str] = &["line", "line_start", "start_line", "startLine"];
const OPERATOR_KEYS: &[&str] = &[
"operator",
"mutation_operator",
"mutator",
"mutation",
"description",
"replacement",
"name",
];
const RUNTIME_OUTCOME_KEYS: &[&str] = &["outcome", "status", "result", "summary", "state"];
const DURATION_KEYS: &[&str] = &[
"duration_ms",
"durationMillis",
"duration",
"elapsed_ms",
"elapsed",
];
const TEST_COMMAND_KEYS: &[&str] = &["test_command", "testCommand", "command", "cmd", "test_cmd"];
struct OutcomeObjectContext<'a> {
object: &'a serde_json::Map<String, Value>,
mutant: Option<&'a serde_json::Map<String, Value>>,
mutation: Option<&'a serde_json::Map<String, Value>>,
location: Option<&'a serde_json::Map<String, Value>>,
span: Option<&'a serde_json::Map<String, Value>>,
}
struct OutcomeIdentity {
mutant_id: Option<String>,
seam_id: Option<String>,
file: Option<String>,
line: Option<usize>,
}
struct RuntimeDetails {
mutation_operator: String,
runtime_outcome: String,
duration: Option<String>,
test_command: Option<String>,
}
pub(super) fn parse_mutation_outcomes_json(
json: &str,
) -> Result<Vec<MutationOutcomeRecord>, String> {
let value: Value = serde_json::from_str(json)
.map_err(|err| format!("failed to parse cargo-mutants JSON: {err}"))?;
let mut records = Vec::new();
collect_mutation_outcome_records(&value, &mut records);
let mut records = super::merge_mutation_outcome_records(records);
records.sort_by(|left, right| {
left.seam_id
.cmp(&right.seam_id)
.then(left.file.cmp(&right.file))
.then(left.line.cmp(&right.line))
.then(left.mutation_operator.cmp(&right.mutation_operator))
.then(left.runtime_outcome.cmp(&right.runtime_outcome))
});
Ok(records)
}
fn collect_mutation_outcome_records(value: &Value, records: &mut Vec<MutationOutcomeRecord>) {
match value {
Value::Array(items) => {
for item in items {
collect_mutation_outcome_records(item, records);
}
}
Value::Object(object) => {
collect_nested_outcome_lists(object, records);
if let Some(record) = mutation_outcome_record_from_object(object) {
records.push(record);
}
}
_ => {}
}
}
fn collect_nested_outcome_lists(
object: &serde_json::Map<String, Value>,
records: &mut Vec<MutationOutcomeRecord>,
) {
for key in [
"outcomes",
"mutants",
"results",
"mutations",
"mutation_results",
] {
if let Some(items) = object.get(key).and_then(Value::as_array) {
for item in items {
collect_mutation_outcome_records(item, records);
}
}
}
}
fn mutation_outcome_record_from_object(
object: &serde_json::Map<String, Value>,
) -> Option<MutationOutcomeRecord> {
let context = OutcomeObjectContext::new(object);
let identity = context.identity();
let details = context.runtime_details();
has_record_signal(&identity, &details).then_some(MutationOutcomeRecord {
mutant_id: identity.mutant_id,
seam_id: identity.seam_id,
file: identity.file,
line: identity.line,
mutation_operator: details.mutation_operator,
runtime_outcome: details.runtime_outcome,
duration: details.duration,
test_command: details.test_command,
})
}
fn has_record_signal(identity: &OutcomeIdentity, details: &RuntimeDetails) -> bool {
let has_identity = identity.mutant_id.is_some()
|| identity.seam_id.is_some()
|| identity.file.is_some()
|| identity.line.is_some();
let has_runtime_detail = details.runtime_outcome != "unknown"
|| details.mutation_operator != "unknown"
|| details.duration.is_some()
|| details.test_command.is_some();
has_identity && has_runtime_detail
}
impl<'a> OutcomeObjectContext<'a> {
fn new(object: &'a serde_json::Map<String, Value>) -> Self {
let mutant = nested_object(object, "mutant");
let mutation = nested_object(object, "mutation");
let location = nested_object(object, "location");
let span = nested_object(object, "span")
.or_else(|| mutant.and_then(|nested| nested_object(nested, "span")))
.or_else(|| mutation.and_then(|nested| nested_object(nested, "span")))
.or_else(|| location.and_then(|nested| nested_object(nested, "span")));
Self {
object,
mutant,
mutation,
location,
span,
}
}
fn identity(&self) -> OutcomeIdentity {
OutcomeIdentity {
mutant_id: self.mutant_id(),
seam_id: self.seam_id(),
file: self.file(),
line: self.line(),
}
}
fn runtime_details(&self) -> RuntimeDetails {
RuntimeDetails {
mutation_operator: self.mutation_operator(),
runtime_outcome: string_field_any(self.object, RUNTIME_OUTCOME_KEYS)
.unwrap_or_else(|| "unknown".to_string()),
duration: string_field_any(self.object, DURATION_KEYS),
test_command: string_field_any(self.object, TEST_COMMAND_KEYS),
}
}
fn mutant_id(&self) -> Option<String> {
string_field_any(self.object, MUTANT_ID_KEYS)
.or_else(|| {
self.mutant
.and_then(|nested| string_field_any(nested, MUTANT_ID_KEYS))
})
.or_else(|| {
self.mutation
.and_then(|nested| string_field_any(nested, MUTANT_ID_KEYS))
})
}
fn seam_id(&self) -> Option<String> {
string_field_any(self.object, SEAM_ID_KEYS)
.or_else(|| {
self.mutant
.and_then(|nested| string_field_any(nested, SEAM_ID_KEYS))
})
.or_else(|| {
self.mutation
.and_then(|nested| string_field_any(nested, SEAM_ID_KEYS))
})
}
fn file(&self) -> Option<String> {
string_field_any(self.object, FILE_KEYS)
.or_else(|| {
self.mutant
.and_then(|nested| string_field_any(nested, FILE_KEYS))
})
.or_else(|| {
self.mutation
.and_then(|nested| string_field_any(nested, FILE_KEYS))
})
.or_else(|| {
self.location
.and_then(|nested| string_field_any(nested, NESTED_FILE_KEYS))
})
.or_else(|| {
self.span
.and_then(|nested| string_field_any(nested, NESTED_FILE_KEYS))
})
.map(|path| normalize_report_path(&path))
}
fn line(&self) -> Option<usize> {
usize_field_any(self.object, LINE_KEYS)
.or_else(|| {
self.mutant
.and_then(|nested| usize_field_any(nested, LINE_KEYS))
})
.or_else(|| {
self.mutation
.and_then(|nested| usize_field_any(nested, LINE_KEYS))
})
.or_else(|| {
self.location
.and_then(|nested| usize_field_any(nested, LINE_KEYS))
})
.or_else(|| self.span.and_then(span_start_line))
}
fn mutation_operator(&self) -> String {
string_field_any(self.object, OPERATOR_KEYS)
.or_else(|| {
self.mutant
.and_then(|nested| string_field_any(nested, OPERATOR_KEYS))
})
.or_else(|| {
self.mutation
.and_then(|nested| string_field_any(nested, OPERATOR_KEYS))
})
.unwrap_or_else(|| "unknown".to_string())
}
}
fn nested_object<'a>(
object: &'a serde_json::Map<String, Value>,
key: &str,
) -> Option<&'a serde_json::Map<String, Value>> {
object.get(key).and_then(Value::as_object)
}
fn span_start_line(span: &serde_json::Map<String, Value>) -> Option<usize> {
usize_field_any(span, LINE_KEYS)
.or_else(|| {
nested_object(span, "start").and_then(|start| usize_field_any(start, LINE_KEYS))
})
.or_else(|| {
nested_object(span, "start_position")
.and_then(|start| usize_field_any(start, LINE_KEYS))
})
.or_else(|| nested_object(span, "lo").and_then(|start| usize_field_any(start, LINE_KEYS)))
}
fn string_field_any(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| object.get(*key).and_then(json_scalar_as_string))
.filter(|value| !value.trim().is_empty())
}
fn usize_field_any(object: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<usize> {
keys.iter()
.find_map(|key| object.get(*key).and_then(json_scalar_as_usize))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_nested_mutant_location_and_span_shapes() -> Result<(), String> {
let records = parse_mutation_outcomes_json(
r#"{
"mutation_results": [
{
"mutant": {
"id": "m-nested",
"seamId": "seam-nested",
"span": {"file_name": "./src/pricing.rs", "start": {"line": 42}},
"replacement": ">"
},
"status": "missed",
"durationMillis": 17,
"command": "cargo test pricing"
},
{
"mutation": {
"mutantId": "m-mutation",
"probeId": "seam-mutation",
"filename": "src/cart.rs",
"startLine": "9",
"mutator": "replace literal"
},
"result": "caught"
}
]
}"#,
)?;
assert_eq!(records.len(), 2);
assert_eq!(records[0].mutant_id.as_deref(), Some("m-mutation"));
assert_eq!(records[0].seam_id.as_deref(), Some("seam-mutation"));
assert_eq!(records[0].file.as_deref(), Some("src/cart.rs"));
assert_eq!(records[0].line, Some(9));
assert_eq!(records[0].mutation_operator, "replace literal");
assert_eq!(records[0].runtime_outcome, "caught");
assert_eq!(records[1].mutant_id.as_deref(), Some("m-nested"));
assert_eq!(records[1].seam_id.as_deref(), Some("seam-nested"));
assert_eq!(records[1].file.as_deref(), Some("src/pricing.rs"));
assert_eq!(records[1].line, Some(42));
assert_eq!(records[1].mutation_operator, ">");
assert_eq!(records[1].runtime_outcome, "missed");
assert_eq!(records[1].duration.as_deref(), Some("17"));
assert_eq!(
records[1].test_command.as_deref(),
Some("cargo test pricing")
);
Ok(())
}
#[test]
fn ignores_objects_without_runtime_signal_and_keeps_detail_only_records() -> Result<(), String>
{
let records = parse_mutation_outcomes_json(
r#"[
{"id": "identity-only", "file": "src/lib.rs", "line": 1},
{"line": 2, "duration_ms": 5},
{"location": {"path": "./src/nested.rs", "line_start": 3}, "state": "timeout"}
]"#,
)?;
assert_eq!(records.len(), 2);
let timeout = records
.iter()
.find(|record| record.runtime_outcome == "timeout")
.ok_or_else(|| "timeout record should be retained".to_string())?;
assert_eq!(timeout.file.as_deref(), Some("src/nested.rs"));
assert_eq!(timeout.line, Some(3));
let duration_only = records
.iter()
.find(|record| record.duration.as_deref() == Some("5"))
.ok_or_else(|| "duration-only record should be retained".to_string())?;
assert_eq!(duration_only.line, Some(2));
assert_eq!(duration_only.runtime_outcome, "unknown");
Ok(())
}
}