use core::fmt::{Display, Write as _};
use std::collections::BTreeMap;
use std::{fs, io};
use camino::Utf8Path;
use compact_str::CompactString;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::discover::Plan;
use crate::error::error;
use crate::model::{Mutant, Outcome};
use crate::parse::SourceFile;
use crate::{HashMap, HashSet, Result};
const SCHEMA_VERSION: &str = "2";
pub(super) const SUPPORTED_SCHEMA_MAJOR: u32 = 2;
pub(super) const FRAMEWORK_NAME: &str = "cargo-gamma";
type SchemaResult<T> = core::result::Result<T, String>;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Report {
pub schema_version: String,
pub thresholds: Thresholds,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_root: Option<String>,
pub framework: Framework,
pub files: BTreeMap<String, FileResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<RunInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunInfo {
pub started_at: u64,
#[serde(default, skip_serializing_if = "is_false")]
pub merged: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub shard: Option<ShardInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tests: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_built: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dropped_test_packages: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merge_provenance: Option<MergeProvenance>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MergeProvenance {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub sources: BTreeMap<String, SourceProvenance>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub verdicts: BTreeMap<String, VerdictProvenance>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SourceProvenance {
pub started_at: u64,
pub origin: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub lineage: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerdictProvenance {
pub started_at: u64,
pub origin: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub lineage: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShardInfo {
pub index: u32,
pub count: u32,
}
#[expect(clippy::trivially_copy_pass_by_ref, reason = "serde requires a predicate over a reference")]
const fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Thresholds {
pub high: u32,
pub low: u32,
}
impl Default for Thresholds {
fn default() -> Self {
Self { high: 80, low: 60 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Framework {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileResult {
pub source: String,
pub language: String,
pub mutants: Vec<MutantResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MutantResult {
pub id: CompactString,
pub mutator_name: CompactString,
pub location: Location,
pub status: CompactString,
#[serde(skip_serializing_if = "Option::is_none")]
pub replacement: Option<CompactString>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub killed_by: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Location {
pub start: Position,
pub end: Position,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Position {
pub line: usize,
pub column: usize,
}
pub(super) const OUT_OF_MEMORY_PREFIX: &str = "out of memory: ";
pub(super) const TIMEOUT_PREFIX: &str = "timed out: ";
pub(super) const NOT_BUILT_PREFIX: &str = "not built: ";
pub(super) const FLAKY_PREFIX: &str = "flaky: ";
const fn status_of(outcome: Outcome) -> &'static str {
match outcome {
Outcome::Pending => "Pending",
Outcome::Killed => "Killed",
Outcome::Survived | Outcome::Timeout | Outcome::OutOfMemory => "Survived",
Outcome::CompileError => "CompileError",
Outcome::Ignored | Outcome::NotBuilt | Outcome::Flaky => "Ignored",
Outcome::NoCoverage => "NoCoverage",
}
}
fn reason_for(mutant: &Mutant) -> Option<String> {
if let Some(suppression) = mutant.suppression.as_ref() {
let mut text = format!("suppressed by {}", suppression.channel.as_str());
if let Some(reason) = suppression.reason.as_ref() {
text.push_str(": ");
text.push_str(reason);
}
if let Some(tag) = suppression.tag.as_ref() {
let _ = write!(text, " [#{tag}]");
}
return Some(text);
}
match mutant.outcome {
Outcome::Killed => mutant
.killed_by
.as_ref()
.map(|test| format!("failed `{test}`"))
.or_else(|| mutant.note.clone()),
Outcome::CompileError => Some("the mutant does not compile".to_owned()),
Outcome::NotBuilt => Some(format!(
"{NOT_BUILT_PREFIX}{}",
mutant
.note
.clone()
.unwrap_or_else(|| "the build this mutant belonged to could not be converged".to_owned())
)),
Outcome::Timeout => Some(format!(
"{TIMEOUT_PREFIX}{}",
mutant.note.clone().unwrap_or_else(|| "the test run exceeded its budget".to_owned())
)),
Outcome::Flaky => Some(format!(
"{FLAKY_PREFIX}{}",
mutant
.note
.clone()
.unwrap_or_else(|| "a test failed with no mutant active as well as with one".to_owned())
)),
Outcome::OutOfMemory => Some(format!(
"{OUT_OF_MEMORY_PREFIX}{}",
mutant
.note
.clone()
.unwrap_or_else(|| "the test run exceeded the memory this run allowed it".to_owned())
)),
Outcome::NoCoverage => Some(
"no selected runtime test reached this mutation site; coverage reports may exclude this code or include other configurations"
.to_owned(),
),
_ => None,
}
}
pub fn build(plan: &Plan, thresholds: Thresholds, run: Option<RunInfo>) -> Result<Report> {
let mut files: BTreeMap<String, FileResult> = BTreeMap::new();
let mut grouped: HashMap<&Utf8Path, Vec<&Mutant>> = HashMap::default();
for mutant in &plan.mutants {
grouped.entry(&*mutant.file).or_default().push(mutant);
}
for file in &plan.files {
let Some(mutants) = grouped.get(file.path.as_path()) else {
continue;
};
let original = fs::read_to_string(file.absolute.as_std_path())
.map_err(|cause| error!("could not read `{}`", file.absolute).caused_by(cause))?;
let has_bom = original.starts_with('\u{feff}');
let source = SourceFile::parse(file.absolute.clone(), original.clone())?;
let rendered = mutants
.iter()
.map(|mutant| render_with_first_line_offset(mutant, &source, usize::from(has_bom)))
.collect();
let _ = files.insert(
file.path.to_string(),
FileResult {
source: original,
language: "rust".to_owned(),
mutants: rendered,
},
);
}
let not_built = plan.mutants.iter().filter(|mutant| mutant.outcome == Outcome::NotBuilt).count();
Ok(Report {
schema_version: SCHEMA_VERSION.to_owned(),
thresholds,
project_root: Some(plan.root.to_string()),
framework: Framework {
name: FRAMEWORK_NAME.to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
},
files,
config: run.map(|run| RunInfo {
not_built: (not_built > 0).then_some(not_built),
..run
}),
})
}
#[must_use]
pub(crate) fn supported_schema_version(version: &str) -> bool {
let mut parts = version.split('.');
let Some(major) = parts.next() else {
return false;
};
if !matches!(major, "1" | "2") {
return false;
}
let mut components = 1;
for part in parts {
components += 1;
if components > 3 || part.is_empty() || (part.len() > 1 && part.starts_with('0')) || !part.bytes().all(|byte| byte.is_ascii_digit())
{
return false;
}
}
true
}
pub(crate) fn validate_schema(document: &Value) -> SchemaResult<()> {
let report = object(document, "report")?;
validate_schema_version(required(report, "schemaVersion", "report")?, "report.schemaVersion")?;
validate_thresholds(required(report, "thresholds", "report")?, "report.thresholds")?;
validate_files(required(report, "files", "report")?, "report.files")?;
if let Some(config) = report.get("config") {
let _config = object(config, "report.config")?;
}
if let Some(root) = report.get("projectRoot") {
let _ = string(root, "report.projectRoot")?;
}
if let Some(framework) = report.get("framework") {
validate_framework(framework, "report.framework")?;
}
if let Some(performance) = report.get("performance") {
validate_performance(performance, "report.performance")?;
}
if let Some(test_files) = report.get("testFiles") {
validate_test_files(test_files, "report.testFiles")?;
}
if let Some(system) = report.get("system") {
validate_system(system, "report.system")?;
}
Ok(())
}
fn object(value: &Value, path: impl Display) -> SchemaResult<&serde_json::Map<String, Value>> {
value.as_object().ok_or_else(|| format!("{path} must be an object"))
}
fn array(value: &Value, path: impl Display) -> SchemaResult<&Vec<Value>> {
value.as_array().ok_or_else(|| format!("{path} must be an array"))
}
fn required<'value>(object: &'value serde_json::Map<String, Value>, name: &str, path: impl Display) -> SchemaResult<&'value Value> {
object.get(name).ok_or_else(|| format!("{path} is missing required field `{name}`"))
}
fn string(value: &Value, path: impl Display) -> SchemaResult<&str> {
value.as_str().ok_or_else(|| format!("{path} must be a string"))
}
fn number(value: &Value, path: impl Display) -> SchemaResult<()> {
if value.is_number() {
Ok(())
} else {
Err(format!("{path} must be a number"))
}
}
fn boolean(value: &Value, path: impl Display) -> SchemaResult<()> {
if value.is_boolean() {
Ok(())
} else {
Err(format!("{path} must be a boolean"))
}
}
fn integer(value: &Value, path: impl Display) -> SchemaResult<u64> {
value.as_u64().ok_or_else(|| format!("{path} must be a non-negative integer"))
}
fn validate_schema_version(value: &Value, path: &str) -> SchemaResult<()> {
let version = string(value, path)?;
if supported_schema_version(version) {
Ok(())
} else {
Err(format!("schema version `{version}` at {path} must match the supported pattern"))
}
}
fn validate_thresholds(value: &Value, path: &str) -> SchemaResult<()> {
let thresholds = object(value, path)?;
for name in ["high", "low"] {
let field = format!("{path}.{name}");
let threshold = integer(required(thresholds, name, path)?, &field)?;
if threshold > 100 {
return Err(format!("{field} must be at most 100"));
}
}
Ok(())
}
fn validate_files(value: &Value, path: &str) -> SchemaResult<()> {
for (name, file) in object(value, path)? {
validate_file(file, &format!("{path}[{name:?}]"))?;
}
Ok(())
}
fn validate_file(value: &Value, path: &str) -> SchemaResult<()> {
let file = object(value, path)?;
let _ = string(required(file, "language", path)?, format_args!("{path}.language"))?;
let _ = string(required(file, "source", path)?, format_args!("{path}.source"))?;
validate_mutants(required(file, "mutants", path)?, &format!("{path}.mutants"))
}
fn validate_mutants(value: &Value, path: &str) -> SchemaResult<()> {
let mutants = array(value, path)?;
let mut unique = HashSet::default();
let mut encoded = Vec::new();
for (index, mutant) in mutants.iter().enumerate() {
let path = format!("{path}[{index}]");
encoded.clear();
serde_json::to_writer(&mut encoded, mutant).map_err(|cause| format!("{path} could not be compared: {cause}"))?;
if !unique.insert(blake3::hash(&encoded)) {
return Err(format!("{path} duplicates another mutant"));
}
validate_mutant(mutant, &path)?;
}
Ok(())
}
fn validate_mutant(value: &Value, path: &str) -> SchemaResult<()> {
let mutant = object(value, path)?;
let id = string(required(mutant, "id", path)?, format_args!("{path}.id"))?;
let _ = string(required(mutant, "mutatorName", path)?, format_args!("{path}.mutatorName"))?;
validate_location(required(mutant, "location", path)?, format_args!("{path}.location"), false)?;
let status = string(required(mutant, "status", path)?, format_args!("{path}.status"))?;
if !matches!(
status,
"Killed" | "Survived" | "NoCoverage" | "CompileError" | "RuntimeError" | "Timeout" | "Ignored" | "Pending"
) {
return Err(format!("{path} mutant `{id}` has unknown schema status `{status}`"));
}
for name in ["description", "replacement", "statusReason"] {
if let Some(value) = mutant.get(name) {
let _ = string(value, format_args!("{path}.{name}"))?;
}
}
for name in ["duration", "testsCompleted"] {
if let Some(value) = mutant.get(name) {
number(value, format_args!("{path}.{name}"))?;
}
}
for name in ["coveredBy", "killedBy"] {
if let Some(value) = mutant.get(name) {
validate_strings(value, format_args!("{path}.{name}"))?;
}
}
if let Some(value) = mutant.get("static") {
boolean(value, format_args!("{path}.static"))?;
}
Ok(())
}
fn validate_strings(value: &Value, path: impl Display + Copy) -> SchemaResult<()> {
for (index, value) in array(value, path)?.iter().enumerate() {
let _ = string(value, format_args!("{path}[{index}]"))?;
}
Ok(())
}
fn validate_location(value: &Value, path: impl Display + Copy, open_end: bool) -> SchemaResult<()> {
let location = object(value, path)?;
validate_position(required(location, "start", path)?, format_args!("{path}.start"))?;
if open_end {
if let Some(end) = location.get("end") {
validate_position(end, format_args!("{path}.end"))?;
}
} else {
validate_position(required(location, "end", path)?, format_args!("{path}.end"))?;
}
Ok(())
}
fn validate_position(value: &Value, path: impl Display + Copy) -> SchemaResult<()> {
let position = object(value, path)?;
for name in ["line", "column"] {
if integer(required(position, name, path)?, format_args!("{path}.{name}"))? == 0 {
return Err(format!("{path}.{name} must be at least 1"));
}
}
Ok(())
}
fn validate_test_files(value: &Value, path: &str) -> SchemaResult<()> {
for (name, file) in object(value, path)? {
let file_path = format!("{path}[{name:?}]");
let file = object(file, &file_path)?;
if let Some(source) = file.get("source") {
let _ = string(source, format_args!("{file_path}.source"))?;
}
for (index, test) in array(required(file, "tests", &file_path)?, format_args!("{file_path}.tests"))?
.iter()
.enumerate()
{
let test_path = format!("{file_path}.tests[{index}]");
let test = object(test, &test_path)?;
let _ = string(required(test, "id", &test_path)?, format_args!("{test_path}.id"))?;
let _ = string(required(test, "name", &test_path)?, format_args!("{test_path}.name"))?;
if let Some(location) = test.get("location") {
validate_location(location, format_args!("{test_path}.location"), true)?;
}
}
}
Ok(())
}
fn validate_performance(value: &Value, path: &str) -> SchemaResult<()> {
let performance = object(value, path)?;
for name in ["setup", "initialRun", "mutation"] {
number(required(performance, name, path)?, format_args!("{path}.{name}"))?;
}
Ok(())
}
fn validate_framework(value: &Value, path: &str) -> SchemaResult<()> {
let framework = object(value, path)?;
let _ = string(required(framework, "name", path)?, format_args!("{path}.name"))?;
if let Some(version) = framework.get("version") {
let _ = string(version, format_args!("{path}.version"))?;
}
if let Some(branding) = framework.get("branding") {
let branding_path = format!("{path}.branding");
let branding = object(branding, &branding_path)?;
let homepage = string(
required(branding, "homepageUrl", &branding_path)?,
format_args!("{branding_path}.homepageUrl"),
)?;
if !is_uri(homepage) {
return Err(format!("{branding_path}.homepageUrl must be a URI"));
}
if let Some(image) = branding.get("imageUrl") {
let _ = string(image, format_args!("{branding_path}.imageUrl"))?;
}
}
if let Some(dependencies) = framework.get("dependencies") {
let dependencies_path = format!("{path}.dependencies");
for (name, version) in object(dependencies, &dependencies_path)? {
let _ = string(version, format_args!("{dependencies_path}[{name:?}]"))?;
}
}
Ok(())
}
fn is_uri(value: &str) -> bool {
let Some((scheme, rest)) = value.split_once(':') else {
return false;
};
if scheme.is_empty()
|| !scheme
.bytes()
.enumerate()
.all(|(index, byte)| byte.is_ascii_alphabetic() || (index > 0 && (byte.is_ascii_digit() || matches!(byte, b'+' | b'-' | b'.'))))
{
return false;
}
let bytes = rest.as_bytes();
let mut index = 0;
while let Some(&byte) = bytes.get(index) {
if byte == b'%' {
if !bytes.get(index + 1).is_some_and(u8::is_ascii_hexdigit) || !bytes.get(index + 2).is_some_and(u8::is_ascii_hexdigit) {
return false;
}
index += 3;
} else {
if byte.is_ascii_control() || matches!(byte, b' ' | b'"' | b'<' | b'>' | b'\\' | b'^' | b'`' | b'{' | b'|' | b'}') {
return false;
}
index += 1;
}
}
true
}
fn validate_system(value: &Value, path: &str) -> SchemaResult<()> {
let system = object(value, path)?;
boolean(required(system, "ci", path)?, format_args!("{path}.ci"))?;
if let Some(os) = system.get("os") {
let os_path = format!("{path}.os");
let os = object(os, &os_path)?;
let _ = string(required(os, "platform", &os_path)?, format_args!("{os_path}.platform"))?;
for name in ["description", "version"] {
if let Some(value) = os.get(name) {
let _ = string(value, format_args!("{os_path}.{name}"))?;
}
}
}
if let Some(cpu) = system.get("cpu") {
let cpu_path = format!("{path}.cpu");
let cpu = object(cpu, &cpu_path)?;
number(required(cpu, "logicalCores", &cpu_path)?, format_args!("{cpu_path}.logicalCores"))?;
if let Some(clock) = cpu.get("baseClock") {
number(clock, format_args!("{cpu_path}.baseClock"))?;
}
if let Some(model) = cpu.get("model") {
let _ = string(model, format_args!("{cpu_path}.model"))?;
}
}
if let Some(ram) = system.get("ram") {
let ram_path = format!("{path}.ram");
let ram = object(ram, &ram_path)?;
number(required(ram, "total", &ram_path)?, format_args!("{ram_path}.total"))?;
}
Ok(())
}
#[cfg(test)]
fn render(mutant: &Mutant, source: &SourceFile) -> MutantResult {
render_with_first_line_offset(mutant, source, 0)
}
#[expect(
clippy::cast_precision_loss,
reason = "the interchange schema represents durations as JSON numbers, while a run measures milliseconds in u64"
)]
fn render_with_first_line_offset(mutant: &Mutant, source: &SourceFile, first_line_offset: usize) -> MutantResult {
let (start_line, start_column) = source.location(mutant.span.start);
let (end_line, end_column) = source.location(mutant.span.end);
MutantResult {
id: CompactString::new(&mutant.id),
mutator_name: CompactString::new(&mutant.mutator),
location: Location {
start: Position {
line: start_line,
column: start_column + usize::from(start_line == 1) * first_line_offset,
},
end: Position {
line: end_line,
column: end_column + usize::from(end_line == 1) * first_line_offset,
},
},
status: CompactString::new(status_of(mutant.outcome)),
replacement: Some(mutant.replacement.clone()),
description: Some(mutant.summary()),
status_reason: reason_for(mutant),
duration: (mutant.elapsed_ms > 0).then_some(mutant.elapsed_ms as f64),
killed_by: mutant.killed_by.clone().map(|test| vec![test]),
}
}
pub fn to_json(report: &Report) -> Result<String> {
validate_report(report).map_err(|cause| error!("could not serialize the report: {cause}"))?;
serde_json::to_string_pretty(report).map_err(|cause| error!("could not serialize the report").caused_by(cause))
}
pub fn write_json(report: &Report, path: &Utf8Path) -> Result<()> {
validate_report(report).map_err(|cause| error!("could not serialize the report: {cause}"))?;
crate::elements::write_streamed(path, |writer| serde_json::to_writer_pretty(writer, report).map_err(io::Error::from))
}
fn validate_report(report: &Report) -> SchemaResult<()> {
if !supported_schema_version(&report.schema_version) {
return Err(format!(
"schema version `{}` at report.schemaVersion must match the supported pattern",
report.schema_version
));
}
for (name, threshold) in [("high", report.thresholds.high), ("low", report.thresholds.low)] {
if threshold > 100 {
return Err(format!("report.thresholds.{name} must be at most 100"));
}
}
for (name, file) in &report.files {
validate_file_result(file, &format!("report.files[{name:?}]"))?;
}
Ok(())
}
fn validate_file_result(file: &FileResult, path: &str) -> SchemaResult<()> {
let mut unique = HashSet::default();
for (index, mutant) in file.mutants.iter().enumerate() {
let path = format!("{path}.mutants[{index}]");
if !unique.insert(mutant.id.as_str()) {
return Err(format!("{path} duplicates another mutant"));
}
if !matches!(
mutant.status.as_str(),
"Killed" | "Survived" | "NoCoverage" | "CompileError" | "RuntimeError" | "Timeout" | "Ignored" | "Pending"
) {
return Err(format!(
"{path} mutant `{}` has unknown schema status `{}`",
mutant.id, mutant.status
));
}
for (corner, position) in [("start", &mutant.location.start), ("end", &mutant.location.end)] {
for (axis, value) in [("line", position.line), ("column", position.column)] {
if value == 0 {
return Err(format!("{path}.location.{corner}.{axis} must be at least 1"));
}
}
}
}
Ok(())
}
#[cfg(test)]
#[cfg(not(miri))]
mod tests {
use core::ops::Range;
use std::borrow::Cow;
use camino::Utf8PathBuf;
use super::super::digest::{Digest, settled_mutants, settled_verdict};
use super::*;
use crate::discover::TargetFile;
use crate::fixtures;
use crate::model::{Channel, Suppression};
fn mutant(outcome: Outcome, span: Range<usize>) -> Mutant {
Mutant {
id: "abc123abc123".to_owned().into(),
span,
original: "a < b".to_owned().into(),
replacement: "(a) <= (b)".to_owned().into(),
outcome,
..fixtures::mutant()
}
}
#[test]
fn every_verdict_maps_onto_the_closed_enum() {
const VALID: [&str; 7] = [
"Killed",
"Survived",
"NoCoverage",
"CompileError",
"RuntimeError",
"Timeout",
"Ignored",
];
for (outcome, status) in [
(Outcome::Killed, "Killed"),
(Outcome::Survived, "Survived"),
(Outcome::Timeout, "Survived"),
(Outcome::OutOfMemory, "Survived"),
(Outcome::CompileError, "CompileError"),
(Outcome::NoCoverage, "NoCoverage"),
(Outcome::Ignored, "Ignored"),
(Outcome::Flaky, "Ignored"),
(Outcome::NotBuilt, "Ignored"),
] {
assert_eq!(status_of(outcome), status, "{outcome} maps onto the wrong status");
assert!(VALID.contains(&status), "{status} is not one of the schema's statuses");
}
assert_eq!(status_of(Outcome::Pending), "Pending");
}
#[test]
fn the_schema_version_is_in_the_supported_range() {
assert_eq!(SCHEMA_VERSION, "2");
}
#[test]
fn branding_homepage_uris_follow_the_schema_format() {
for uri in [
"https://example.test/gamma%20report",
"data:image/png;base64,AAAA",
"mailto:maintainers@example.test",
] {
assert!(is_uri(uri), "{uri} should be a URI");
}
for value in ["not a URI", "https://example.test/a space", "https://example.test/%xz"] {
assert!(!is_uri(value), "{value} must not be a URI");
}
}
#[test]
fn a_span_becomes_a_one_based_half_open_location() {
let source = SourceFile::parse("src/lib.rs", "fn f() {\n a < b\n}\n".to_owned()).expect("parses");
let start = source.text().find("a <").expect("present");
let rendered = render(&mutant(Outcome::Survived, start..start + 5), &source);
assert_eq!(rendered.location.start.line, 2);
assert_eq!(rendered.location.start.column, 5);
assert_eq!(rendered.location.end.line, 2);
assert_eq!(rendered.location.end.column, 10);
}
#[test]
fn a_bom_embedded_in_report_source_offsets_first_line_columns() {
let source = SourceFile::parse("src/lib.rs", "\u{feff}fn f() {}".to_owned()).expect("parses");
let rendered = render_with_first_line_offset(&mutant(Outcome::Survived, 0..2), &source, 1);
assert_eq!(rendered.location.start.column, 2);
assert_eq!(rendered.location.end.column, 4);
}
#[test]
fn compact_report_fields_remain_json_strings() {
let source = SourceFile::parse("src/lib.rs", "fn f() { a < b; }".to_owned()).expect("parses");
let rendered = render(&mutant(Outcome::Survived, 9..14), &source);
let json = serde_json::to_value(rendered).expect("serializes");
assert_eq!(json["id"], "abc123abc123");
assert_eq!(json["mutatorName"], "relational.lt_to_le");
assert_eq!(json["status"], "Survived");
assert_eq!(json["replacement"], "(a) <= (b)");
}
#[test]
fn typed_reports_reject_two_results_for_one_mutant_id() {
let source = SourceFile::parse("src/lib.rs", "fn f() { a < b; }".to_owned()).expect("parses");
let first = render(&mutant(Outcome::Survived, 9..14), &source);
let mut second = first.clone();
second.status_reason = Some("a different observation".to_owned());
let file = FileResult {
source: source.text().to_owned(),
language: "rust".to_owned(),
mutants: vec![first, second],
};
assert_eq!(
validate_file_result(&file, "report.files[\"src/lib.rs\"]").unwrap_err(),
"report.files[\"src/lib.rs\"].mutants[1] duplicates another mutant"
);
}
#[test]
fn a_killing_test_is_named_in_the_status_reason() {
let mut subject = mutant(Outcome::Killed, 0..1);
subject.killed_by = Some("tests::the_boundary".to_owned());
assert_eq!(reason_for(&subject), Some("failed `tests::the_boundary`".to_owned()));
assert_eq!(
render(&subject, &SourceFile::parse("src/lib.rs", "fn f() {}".to_owned()).expect("parses")).killed_by,
Some(vec!["tests::the_boundary".to_owned()])
);
}
#[test]
fn a_killed_mutant_without_a_named_test_falls_back_to_its_note() {
let mut subject = mutant(Outcome::Killed, 0..1);
subject.killed_by = None;
subject.note = Some("failed X".to_owned());
assert_eq!(reason_for(&subject), Some("failed X".to_owned()));
}
#[test]
fn an_uncovered_mutant_explains_why_coverage_can_still_be_complete() {
let reason = reason_for(&mutant(Outcome::NoCoverage, 0..1)).expect("a reason");
assert!(reason.contains("no selected runtime test"), "{reason}");
assert!(reason.contains("coverage reports"), "{reason}");
}
#[test]
fn unviable_and_timeout_mutants_explain_their_status() {
let mut timed_out = mutant(Outcome::Timeout, 0..1);
assert_eq!(
reason_for(&mutant(Outcome::CompileError, 0..1)),
Some("the mutant does not compile".to_owned())
);
assert_eq!(
reason_for(&timed_out),
Some(format!("{TIMEOUT_PREFIX}the test run exceeded its budget"))
);
timed_out.note = Some("stalled, last test named was `slow_case`".to_owned());
assert_eq!(
reason_for(&timed_out),
Some(format!("{TIMEOUT_PREFIX}stalled, last test named was `slow_case`"))
);
}
#[test]
fn a_suppression_carries_its_reason_and_tag_into_the_report() {
let mut subject = mutant(Outcome::Ignored, 0..1);
subject.suppression = Some(Suppression {
channel: Channel::Comment,
reason: Some("fixed-point math".to_owned()),
tag: Some("perf".to_owned()),
line: Some(4),
});
assert_eq!(
reason_for(&subject),
Some("suppressed by comment: fixed-point math [#perf]".to_owned())
);
}
#[test]
fn a_suppression_with_no_reason_or_tag_still_names_its_channel() {
let mut subject = mutant(Outcome::Ignored, 0..1);
subject.suppression = Some(Suppression {
channel: Channel::Comment,
reason: None,
tag: None,
line: Some(4),
});
assert_eq!(reason_for(&subject), Some("suppressed by comment".to_owned()));
}
#[test]
fn an_untimed_mutant_omits_its_duration() {
let source = SourceFile::parse("src/lib.rs", "fn f() {}".to_owned()).expect("parses");
assert_eq!(render(&mutant(Outcome::Ignored, 0..1), &source).duration, None);
let mut timed = mutant(Outcome::Killed, 0..1);
timed.elapsed_ms = 12;
assert_eq!(render(&timed, &source).duration, Some(12.0));
}
#[test]
fn only_settled_mutants_are_carried_forward() {
let text = r#"{
"schemaVersion": "2",
"thresholds": { "high": 80, "low": 60 },
"framework": { "name": "cargo-gamma", "version": "0.1.0" },
"files": {
"src/lib.rs": {
"language": "rust",
"source": "src/lib.rs",
"mutants": [
{ "id": "a", "mutatorName": "m", "status": "Killed", "location": { "start": { "line": 1, "column": 1 }, "end": { "line": 1, "column": 2 } } },
{ "id": "b", "mutatorName": "m", "status": "Survived", "location": { "start": { "line": 1, "column": 1 }, "end": { "line": 1, "column": 2 } } },
{ "id": "c", "mutatorName": "m", "status": "Timeout", "statusReason": "the test run exceeded its budget", "location": { "start": { "line": 1, "column": 1 }, "end": { "line": 1, "column": 2 } } },
{ "id": "d", "mutatorName": "m", "status": "NoCoverage", "location": { "start": { "line": 1, "column": 1 }, "end": { "line": 1, "column": 2 } } }
]
}
}
}"#;
let settled = settled_mutants(text).expect("parses");
let mut ids: Vec<&str> = settled.iter().map(String::as_str).collect();
ids.sort_unstable();
assert_eq!(ids, vec!["a", "c"]);
}
#[test]
fn a_status_with_no_reason_to_disambiguate_it_is_not_settled() {
assert_eq!(settled_verdict("Ignored", None), None);
assert_eq!(settled_verdict("Timeout", None), None);
assert_eq!(settled_verdict("Ignored", Some("suppressed by attribute")), Some(Outcome::Ignored));
assert_eq!(
settled_verdict("Timeout", Some("the test run exceeded its budget")),
Some(Outcome::Timeout)
);
for outcome in [Outcome::Ignored, Outcome::Timeout, Outcome::NotBuilt, Outcome::Flaky] {
let mut subject = mutant(outcome, 0..1);
if outcome == Outcome::Ignored {
subject.suppression = Some(Suppression {
channel: Channel::Attribute,
reason: None,
tag: None,
line: None,
});
}
assert!(
reason_for(&subject).is_some(),
"{outcome:?} exports with no reason to disambiguate it"
);
}
}
#[test]
fn a_report_this_tool_did_not_write_settles_nothing() {
let document = |framework: &str, version: &str| {
format!(
r#"{{
"schemaVersion": "{version}",
"thresholds": {{ "high": 80, "low": 60 }},
"framework": {{ "name": "{framework}", "version": "0.1.0" }},
"files": {{
"src/lib.rs": {{
"language": "rust",
"source": "fn f() {{}}",
"mutants": [
{{ "id": "a", "mutatorName": "m", "status": "Killed", "location": {{ "start": {{ "line": 1, "column": 1 }}, "end": {{ "line": 1, "column": 2 }} }} }}
]
}}
}}
}}"#
)
};
let foreign = settled_mutants(&document("stryker-js", SCHEMA_VERSION)).expect_err("a foreign producer");
assert!(foreign.contains("stryker-js"), "{foreign}");
let ahead = settled_mutants(&document(FRAMEWORK_NAME, "3")).expect_err("an unsupported schema");
assert!(ahead.contains('3'), "{ahead}");
for version in ["later", "2.bad", "2.0.0.0"] {
let unreadable = settled_mutants(&document(FRAMEWORK_NAME, version)).expect_err("an unreadable schema");
assert!(unreadable.contains(version), "{unreadable}");
}
let ours = settled_mutants(&document(FRAMEWORK_NAME, "2.1.3")).expect("our own report");
assert_eq!(ours.len(), 1);
}
#[test]
fn a_report_that_does_not_identify_itself_settles_nothing() {
let text = r#"{
"files": {
"src/lib.rs": {
"language": "rust",
"source": "fn f() {}",
"mutants": [
{ "id": "a", "mutatorName": "m", "status": "Killed", "location": { "start": { "line": 1, "column": 1 }, "end": { "line": 1, "column": 2 } } }
]
}
}
}"#;
let _cause = settled_mutants(text).expect_err("a document that names neither its schema nor its producer");
}
#[test]
fn a_digest_borrows_its_verdicts_and_leaves_the_embedded_source_alone() {
let text = r#"{
"schemaVersion": "2",
"thresholds": { "high": 80, "low": 60 },
"framework": { "name": "cargo-gamma", "version": "0" },
"files": {
"src/lib.rs": {
"source": "fn f() {}",
"language": "rust",
"mutants": [
{ "id": "a", "mutatorName": "m", "status": "Killed",
"location": { "start": { "line": 1, "column": 1 }, "end": { "line": 1, "column": 2 } },
"killedBy": ["tests::one"] }
]
}
}
}"#;
let digest: Digest<'_> = serde_json::from_str(text).expect("parses");
let file = digest.files.get("src/lib.rs").expect("the file");
let mutant = file.mutants.first().expect("the mutant");
assert!(
matches!(mutant.id, Cow::Borrowed(_)),
"an unescaped id was copied out of the document"
);
assert_eq!(mutant.settled_outcome(), Some(Outcome::Killed));
}
#[test]
fn an_out_of_memory_mutant_keeps_its_note_in_the_status_reason() {
let mut starved = mutant(Outcome::OutOfMemory, 0..1);
let source = SourceFile::parse("src/lib.rs", "fn f() {}".to_owned()).expect("parses");
starved.note = Some("`unit-abc` reached 300.0 MB, past the 256.0 MB this run allowed it".to_owned());
let rendered = render(&starved, &source);
let json = serde_json::to_string(&rendered).expect("serializes");
let read: MutantResult = serde_json::from_str(&json).expect("round-trips");
let reason = read.status_reason.clone().expect("a reason");
assert_eq!(read.status, "Survived");
assert!(reason.starts_with(OUT_OF_MEMORY_PREFIX), "{reason}");
assert!(reason.contains("256.0 MB"), "{reason}");
assert_eq!(
settled_verdict(&read.status, read.status_reason.as_deref()),
None,
"a mutant the ceiling stopped was never judged, so a rerun could change it"
);
}
#[test]
fn a_flaky_mutant_keeps_the_test_to_fix_in_its_status_reason() {
let mut flake = mutant(Outcome::Flaky, 0..1);
let source = SourceFile::parse("src/lib.rs", "fn f() {}".to_owned()).expect("parses");
flake.note = Some("test `a::b` in `unit-abc` fails with no mutant active as well as with one".to_owned());
let rendered = render(&flake, &source);
let json = serde_json::to_string(&rendered).expect("serializes");
let read: MutantResult = serde_json::from_str(&json).expect("round-trips");
let reason = read.status_reason.clone().expect("a reason");
assert_eq!(read.status, "Ignored");
assert!(reason.starts_with(FLAKY_PREFIX), "{reason}");
assert!(reason.contains("test `a::b`"), "{reason}");
assert_eq!(
settled_verdict(&read.status, read.status_reason.as_deref()),
None,
"a flake was never judged, so a rerun could change it"
);
}
#[test]
fn a_flake_is_not_settled_but_a_real_suppression_is() {
let mut flake = mutant(Outcome::Flaky, 0..1);
let source = SourceFile::parse("src/lib.rs", "fn f() {}".to_owned()).expect("parses");
flake.note = Some("test `a::b` in `unit-abc` fails with no mutant active as well as with one".to_owned());
let read: MutantResult =
serde_json::from_str(&serde_json::to_string(&render(&flake, &source)).expect("serializes")).expect("round-trips");
assert_eq!(
settled_verdict(&read.status, read.status_reason.as_deref()),
None,
"a flake was never judged, so a rerun could change it"
);
let skipped = MutantResult {
status_reason: Some("suppressed by an attribute".to_owned()),
..read
};
assert_eq!(
settled_verdict(&skipped.status, skipped.status_reason.as_deref()),
Some(Outcome::Ignored)
);
}
#[test]
fn a_mutant_the_build_gave_up_on_is_not_settled_but_a_real_suppression_is() {
let mut abandoned = mutant(Outcome::NotBuilt, 0..1);
let source = SourceFile::parse("src/lib.rs", "fn f() {}".to_owned()).expect("parses");
abandoned.note = Some("the build this mutant belonged to could not be converged".to_owned());
let rendered = render(&abandoned, &source);
assert_eq!(rendered.status, "Ignored", "the status must stay in the schema's denominator");
let read: MutantResult = serde_json::from_str(&serde_json::to_string(&rendered).expect("serializes")).expect("round-trips");
assert!(
read.status_reason
.as_deref()
.is_some_and(|reason| reason.starts_with(NOT_BUILT_PREFIX)),
"{:?}",
read.status_reason
);
assert_eq!(
settled_verdict(&read.status, read.status_reason.as_deref()),
None,
"nothing was established about this mutant"
);
let skipped = MutantResult {
status_reason: Some("suppressed by an attribute".to_owned()),
..read
};
assert_eq!(
settled_verdict(&skipped.status, skipped.status_reason.as_deref()),
Some(Outcome::Ignored)
);
}
#[test]
fn a_report_counts_the_mutants_its_run_never_built() {
let source = "pub fn f(a: i32, b: i32) -> bool { a < b }
";
let dir = crate::testing::workdir("elements-not-built-");
let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
fs::write(root.join("lib.rs").as_std_path(), source).expect("source");
let plan = Plan {
skipped: Vec::new(),
digests: HashMap::default(),
root: root.clone(),
files: vec![TargetFile {
path: Utf8PathBuf::from("lib.rs"),
absolute: root.join("lib.rs"),
package: "subject".to_owned(),
}],
mutants: vec![
Mutant {
file: (Utf8PathBuf::from("lib.rs")).into(),
..mutant(Outcome::NotBuilt, 35..40)
},
Mutant {
id: "def456def456".to_owned().into(),
file: (Utf8PathBuf::from("lib.rs")).into(),
..mutant(Outcome::Killed, 35..40)
},
],
suppressed: 0,
idle: Vec::new(),
sharded_out: 0,
settled_out: 0,
reach: HashMap::default(),
specs: HashMap::default(),
};
let info = RunInfo {
started_at: 0,
merged: false,
shard: None,
tests: None,
not_built: None,
dropped_test_packages: Vec::new(),
merge_provenance: None,
};
let report = build(&plan, Thresholds::default(), Some(info)).expect("a report");
assert_eq!(report.config.expect("config").not_built, Some(1));
let clean = Plan {
mutants: vec![Mutant {
file: (Utf8PathBuf::from("lib.rs")).into(),
..mutant(Outcome::Killed, 35..40)
}],
..plan
};
let report = build(
&clean,
Thresholds::default(),
Some(RunInfo {
started_at: 0,
merged: false,
shard: None,
tests: None,
not_built: None,
dropped_test_packages: Vec::new(),
merge_provenance: None,
}),
)
.expect("a report");
assert_eq!(report.config.expect("config").not_built, None);
}
#[test]
fn an_out_of_memory_mutant_with_no_note_still_says_what_stopped_it() {
let reason = reason_for(&mutant(Outcome::OutOfMemory, 0..1)).expect("a reason");
assert!(reason.starts_with(OUT_OF_MEMORY_PREFIX), "{reason}");
assert!(reason.len() > OUT_OF_MEMORY_PREFIX.len(), "{reason}");
}
#[test]
fn a_timeout_exports_as_undetected_without_losing_its_verdict() {
let source = SourceFile::parse("src/lib.rs", "fn f() {}".to_owned()).expect("parses");
let rendered = render(&mutant(Outcome::Timeout, 0..1), &source);
assert_eq!(rendered.status, "Survived");
assert!(
rendered
.status_reason
.as_deref()
.is_some_and(|reason| reason.starts_with(TIMEOUT_PREFIX))
);
assert_eq!(
settled_verdict(&rendered.status, rendered.status_reason.as_deref()),
Some(Outcome::Timeout)
);
}
#[test]
fn an_out_of_memory_verdict_is_not_settled_but_a_real_timeout_is() {
let text = r#"{
"schemaVersion": "2",
"thresholds": { "high": 80, "low": 60 },
"framework": { "name": "cargo-gamma", "version": "0.1.0" },
"files": {
"src/lib.rs": {
"language": "rust",
"source": "src/lib.rs",
"mutants": [
{ "id": "stalled", "mutatorName": "m", "status": "Timeout", "statusReason": "stalled, last test named was `slow_case`", "location": { "start": { "line": 1, "column": 1 }, "end": { "line": 1, "column": 2 } } },
{ "id": "starved", "mutatorName": "m", "status": "Timeout", "statusReason": "out of memory: `unit-abc` reached 300.0 MB, past the 256.0 MB this run allowed it", "location": { "start": { "line": 1, "column": 1 }, "end": { "line": 1, "column": 2 } } }
]
}
}
}"#;
let settled = settled_mutants(text).expect("parses");
assert!(settled.contains("stalled"), "{settled:?}");
assert!(!settled.contains("starved"), "{settled:?}");
}
#[test]
fn a_file_without_mutants_is_left_out_of_the_report() {
let plan = Plan {
skipped: Vec::new(),
digests: HashMap::default(),
root: Utf8PathBuf::from("/w"),
files: vec![TargetFile {
path: Utf8PathBuf::from("src/lib.rs"),
absolute: Utf8PathBuf::from("/w/src/lib.rs"),
package: "subject".to_owned(),
}],
mutants: Vec::new(),
suppressed: 0,
idle: Vec::new(),
sharded_out: 0,
settled_out: 0,
reach: HashMap::default(),
specs: HashMap::default(),
};
let report = build(&plan, Thresholds::default(), None).expect("report builds");
assert!(report.files.is_empty());
}
#[test]
fn an_unparsable_prior_report_is_reported_rather_than_ignored() {
let _cause = settled_mutants("not json").unwrap_err();
}
#[test]
fn the_document_serializes_with_the_schema_field_names() {
let report = Report {
schema_version: SCHEMA_VERSION.to_owned(),
thresholds: Thresholds::default(),
project_root: None,
framework: Framework {
name: "cargo-gamma".to_owned(),
version: "0.1.0".to_owned(),
},
files: BTreeMap::new(),
config: None,
};
let json = to_json(&report).expect("serializes");
assert!(json.contains("\"schemaVersion\": \"2\""), "{json}");
assert!(json.contains("\"thresholds\""), "{json}");
assert!(json.contains("\"files\""), "{json}");
assert!(!json.contains("projectRoot"), "{json}");
}
#[test]
fn serialization_refuses_reports_outside_the_adopted_schema() {
let mut threshold = fixtures::report();
threshold.thresholds.high = 101;
assert!(to_json(&threshold).is_err(), "an out-of-range threshold must not be emitted");
let mut position = fixtures::report();
let mut mutant = fixtures::mutant_result();
mutant.location.start.line = 0;
let _ = position.files.insert(
"src/lib.rs".to_owned(),
FileResult {
source: "fn f() {}\n".to_owned(),
language: "rust".to_owned(),
mutants: vec![mutant],
},
);
assert!(to_json(&position).is_err(), "a zero source position must not be emitted");
}
#[test]
fn streamed_json_matches_the_string_form() {
let report = fixtures::report();
let directory = crate::testing::workdir("elements-stream-json-");
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the scratch path is UTF-8");
let path = root.join("report.json");
write_json(&report, &path).expect("streams the report");
let expected = to_json(&report).expect("serializes");
assert_eq!(fs::read_to_string(path.as_std_path()).expect("published bytes"), expected);
}
#[test]
fn streamed_json_refuses_an_out_of_schema_report_without_writing() {
let mut threshold = fixtures::report();
threshold.thresholds.high = 101;
let directory = crate::testing::workdir("elements-stream-invalid-");
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the scratch path is UTF-8");
let path = root.join("report.json");
assert!(
write_json(&threshold, &path).is_err(),
"an out-of-range threshold must not be emitted"
);
assert!(!path.exists(), "a rejected report must leave no file behind");
}
#[test]
fn every_discovered_mutant_reaches_the_report() {
let directory = crate::testing::workdir("elements-conservation");
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the scratch path is UTF-8");
let sources = [
("a.rs", "fn a() { x(a < b); }"),
("b.rs", "fn b() { y(a < b); }"),
("c.rs", "fn c() { z(a < b); }"),
];
for (name, text) in sources {
fs::write(root.join(name).as_std_path(), text).expect("source");
}
let files: Vec<TargetFile> = sources
.iter()
.map(|(name, _text)| TargetFile {
path: Utf8PathBuf::from(*name),
absolute: root.join(*name),
package: "subject".to_owned(),
})
.collect();
let placements = [("a.rs", "m1"), ("a.rs", "m2"), ("b.rs", "m3"), ("c.rs", "m4")];
let plan = Plan {
skipped: Vec::new(),
digests: HashMap::default(),
root,
files,
mutants: placements
.iter()
.map(|(file, id)| Mutant {
id: (*id).to_owned().into(),
file: (Utf8PathBuf::from(*file)).into(),
..mutant(Outcome::Killed, 11..16)
})
.collect(),
suppressed: 0,
idle: Vec::new(),
sharded_out: 0,
settled_out: 0,
reach: HashMap::default(),
specs: HashMap::default(),
};
let report = build(&plan, Thresholds::default(), None).expect("a report");
let mut emitted: Vec<(String, String)> = report
.files
.iter()
.flat_map(|(path, file)| file.mutants.iter().map(move |result| (path.clone(), result.id.to_string())))
.collect();
emitted.sort();
let mut expected: Vec<(String, String)> = placements.iter().map(|(file, id)| ((*file).to_owned(), (*id).to_owned())).collect();
expected.sort();
assert_eq!(emitted, expected);
}
#[test]
fn a_mutant_in_an_unlisted_file_is_the_only_thing_the_report_drops() {
let directory = crate::testing::workdir("elements-unlisted");
let root = Utf8PathBuf::from_path_buf(directory.path().to_path_buf()).expect("the scratch path is UTF-8");
fs::write(root.join("a.rs").as_std_path(), "fn a() { x(a < b); }").expect("source");
let plan = Plan {
skipped: Vec::new(),
digests: HashMap::default(),
root: root.clone(),
files: vec![TargetFile {
path: Utf8PathBuf::from("a.rs"),
absolute: root.join("a.rs"),
package: "subject".to_owned(),
}],
mutants: vec![
Mutant {
id: "listed".to_owned().into(),
file: (Utf8PathBuf::from("a.rs")).into(),
..mutant(Outcome::Killed, 11..16)
},
Mutant {
id: "unlisted".to_owned().into(),
file: (Utf8PathBuf::from("gone.rs")).into(),
..mutant(Outcome::Killed, 11..16)
},
],
suppressed: 0,
idle: Vec::new(),
sharded_out: 0,
settled_out: 0,
reach: HashMap::default(),
specs: HashMap::default(),
};
let report = build(&plan, Thresholds::default(), None).expect("a report");
let emitted: Vec<&str> = report
.files
.values()
.flat_map(|file| file.mutants.iter().map(|result| result.id.as_str()))
.collect();
assert_eq!(emitted, vec!["listed"]);
assert!(!report.files.contains_key("gone.rs"));
}
#[test]
fn write_json_matches_to_json_output() {
let report = fixtures::report_with(Some((0, 2)), 100, vec![fixtures::mutant_result()]);
let expected = to_json(&report).expect("to_json succeeds");
let dir = crate::testing::workdir("write_json_matches");
let path = Utf8PathBuf::from_path_buf(dir.path().join("report.json")).expect("utf8");
write_json(&report, &path).expect("write_json succeeds");
let written = fs::read_to_string(&path).expect("read back");
assert_eq!(written, expected, "streaming write must produce identical bytes");
}
}