1use crate::{
7 coverage_report::{
8 ArchiveReportRequest, CoverageReport, ExitCodeInput, analyze_coverage_archive,
9 },
10 evidence_archive::read_archive_selected,
11 lifecycle::atomic_write,
12 run_store::{RunMetadata, StoredRun},
13 source_units::{Code, Diff, named},
14};
15use serde::{Deserialize, Serialize};
16use serde_json::{Value, json};
17use sha2::{Digest, Sha256};
18use std::{
19 collections::{BTreeMap, BTreeSet},
20 fs,
21 path::Path,
22};
23pub type Files = BTreeMap<String, String>;
24pub fn digest(value: &impl Serialize) -> String {
25 format!(
26 "{:x}",
27 Sha256::digest(serde_json::to_vec(value).expect("serializable map"))
28 )
29}
30
31pub fn local_path(file: &str) -> bool {
33 !file.is_empty()
34 && !file.contains(['\\', ':'])
35 && file.split('/').all(|p| !matches!(p, "" | "." | ".."))
36}
37pub fn line_starts(source: &str) -> Vec<usize> {
38 std::iter::once(0)
39 .chain(source.match_indices('\n').map(|(at, _)| at + 1))
40 .collect()
41}
42
43pub fn line_text<'s>(source: &'s str, starts: &[usize], line: usize) -> Option<&'s str> {
47 let start = *starts.get(line.checked_sub(1)?)?;
48 if start >= source.len() {
49 return None;
50 }
51 match starts.get(line) {
52 Some(next) => {
53 let text = &source[start..next - 1];
54 Some(text.strip_suffix('\r').unwrap_or(text))
55 }
56 None => Some(&source[start..]),
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase", deny_unknown_fields)]
63pub struct Inputs {
64 pub language: String,
65 pub files: Files,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase", deny_unknown_fields)]
70pub struct FileFingerprint {
71 pub sha256: String,
72 pub bytes: usize,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub code: Option<Code>,
79}
80impl FileFingerprint {
81 pub fn of(source: &str) -> Self {
82 Self {
83 sha256: format!("{:x}", Sha256::digest(source.as_bytes())),
84 bytes: source.len(),
85 code: None,
86 }
87 }
88 pub fn read(path: &str, source: &str) -> Self {
90 let mut fingerprint = Self::of(source);
91 fingerprint.code = crate::source_units::code(path, source);
92 fingerprint
93 }
94 pub fn same_bytes(&self, other: &Self) -> bool {
95 self.sha256 == other.sha256
96 }
97}
98pub type FileManifest = BTreeMap<String, FileFingerprint>;
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103pub struct InputManifest {
104 pub schema_version: u32,
105 pub language: String,
106 pub files: FileManifest,
107}
108pub const MANIFEST_SCHEMA: u32 = 3;
110impl Inputs {
111 pub fn manifest(&self) -> InputManifest {
112 InputManifest {
113 schema_version: MANIFEST_SCHEMA,
114 language: self.language.clone(),
115 files: self
116 .files
117 .iter()
118 .map(|(p, s)| (p.clone(), FileFingerprint::read(p, s)))
119 .collect(),
120 }
121 }
122}
123impl InputManifest {
124 pub fn with_sources(&self, files: Files) -> Inputs {
125 Inputs {
126 language: self.language.clone(),
127 files,
128 }
129 }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase", deny_unknown_fields)]
134pub struct TestSelector {
135 pub file: String,
136 pub name: String,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase", deny_unknown_fields)]
142pub struct Executions {
143 pub tests: Vec<Execution>,
144 pub probed: BTreeMap<String, Vec<usize>>,
147 #[serde(default)]
156 pub covered: BTreeMap<String, Vec<usize>>,
157}
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase", deny_unknown_fields)]
160pub struct Execution {
161 pub test: TestSelector,
162 pub passed: bool,
163 pub files: BTreeMap<String, Vec<usize>>,
165 #[serde(default = "exact_by_default")]
174 pub attribution: String,
175}
176
177fn exact_by_default() -> String {
180 crate::coverage_report::ATTRIBUTION_EXACT.to_owned()
181}
182pub enum FileChange<'a> {
185 Same,
186 CommentsOnly,
188 Added,
190 Removed,
191 Bytes,
193 Code {
194 before: &'a Code,
195 after: &'a Code,
196 diff: Diff,
197 narrow: bool,
200 },
201}
202pub fn file_change<'a>(
203 before: Option<&'a FileFingerprint>,
204 after: Option<&'a FileFingerprint>,
205 probed: Option<&[usize]>,
206) -> Option<FileChange<'a>> {
207 let Some(before) = before else {
208 return after.map(|_| FileChange::Added);
209 };
210 let Some(after) = after else {
211 return Some(FileChange::Removed);
212 };
213 if before.same_bytes(after) {
214 return Some(FileChange::Same);
215 }
216 let (Some(old), Some(new)) = (&before.code, &after.code) else {
217 return Some(FileChange::Bytes);
218 };
219 if old.semantic == new.semantic {
220 return Some(FileChange::CommentsOnly);
221 }
222 let diff = old.diff(new);
223 let narrow = probed.is_some_and(|probed| diff.narrow(old, probed));
224 Some(FileChange::Code {
225 before: old,
226 after: new,
227 diff,
228 narrow,
229 })
230}
231pub fn describe(before: &Code, after: &Code, diff: &Diff) -> String {
233 let mut parts = Vec::new();
234 if !diff.changed.is_empty() {
235 parts.push(named(diff.changed.iter().map(|i| &before.units[*i])));
236 }
237 if !diff.added.is_empty() {
238 parts.push(format!(
239 "added {}",
240 named(diff.added.iter().map(|i| &after.units[*i]))
241 ));
242 }
243 if !diff.removed.is_empty() {
244 parts.push(format!(
245 "removed {}",
246 named(diff.removed.iter().map(|i| &before.units[*i]))
247 ));
248 }
249 if parts.is_empty() {
250 "declarations".to_owned()
251 } else {
252 parts.join("; ")
253 }
254}
255
256const MANIFEST_CACHE_FILE: &str = "source-manifest.cache.json";
260pub const EXECUTIONS_FILE: &str = "test-executions.json";
262pub use crate::source_capture::ARCHIVE_PATH;
264
265#[derive(serde::Serialize, serde::Deserialize)]
266#[serde(rename_all = "camelCase", deny_unknown_fields)]
267struct ManifestCache {
268 evidence_digest: String,
269 manifest: InputManifest,
270 #[serde(default)]
271 statement_exclusions: Vec<Value>,
272}
273pub struct RunManifest {
274 pub manifest: InputManifest,
275 pub evidence_digest: String,
276 pub statement_exclusions: Vec<Value>,
277}
278pub struct RunInputs {
279 pub inputs: Inputs,
280 pub stored: RunManifest,
281}
282impl std::ops::Deref for RunInputs {
283 type Target = RunManifest;
284 fn deref(&self) -> &RunManifest {
285 &self.stored
286 }
287}
288pub fn load_inputs(root: &Path, run: &StoredRun) -> Result<RunInputs, String> {
291 let stored = load_manifest(run)?;
292 let inputs = crate::source_capture::current_sources(root, &stored.manifest)?;
293 Ok(RunInputs { inputs, stored })
294}
295pub fn load_manifest(run: &StoredRun) -> Result<RunManifest, String> {
296 load_optional_manifest(run)?.ok_or_else(|| {
297 "This run has no source manifest. Run tests once with this version of Supercov".into()
298 })
299}
300fn load_optional_manifest(run: &StoredRun) -> Result<Option<RunManifest>, String> {
301 if run.metadata.merged == Some(true) {
302 return Err("Use a single run; merged runs have multiple source manifests".into());
303 }
304 let bytes = fs::read(&run.evidence_path).map_err(|e| e.to_string())?;
305 let evidence_digest = format!("{:x}", Sha256::digest(&bytes));
306 if let Some(cached) = fs::read(run.directory.join(MANIFEST_CACHE_FILE))
307 .ok()
308 .and_then(|bytes| serde_json::from_slice::<ManifestCache>(&bytes).ok())
309 .filter(|cached| cached.evidence_digest == evidence_digest)
310 .filter(|cached| validate_manifest(&cached.manifest).is_ok())
311 {
312 return Ok(Some(RunManifest {
313 manifest: cached.manifest,
314 evidence_digest,
315 statement_exclusions: cached.statement_exclusions,
316 }));
317 }
318 let entries = read_archive_selected(&run.evidence_path, |path| {
319 path == ARCHIVE_PATH || path == "statement-exclusions.json"
320 })
321 .map_err(|e| e.to_string())?;
322 let Some(input) = entries.iter().find(|e| e.path == ARCHIVE_PATH) else {
323 return Ok(None);
324 };
325 let value: Value = serde_json::from_slice(&input.contents).map_err(|e| e.to_string())?;
326 if value["schemaVersion"].as_u64() != Some(u64::from(MANIFEST_SCHEMA)) {
327 return Err(
328 "Unsupported source manifest schema; rerun tests with this version of Supercov".into(),
329 );
330 }
331 let manifest = serde_json::from_value::<InputManifest>(value).map_err(|e| e.to_string())?;
332 validate_manifest(&manifest)?;
333 if fs::read(&run.evidence_path).map_err(|e| e.to_string())? != bytes {
334 return Err("Run archive changed during read".into());
335 }
336 Ok(Some(RunManifest {
337 manifest,
338 evidence_digest,
339 statement_exclusions: entries
340 .iter()
341 .find(|e| e.path == "statement-exclusions.json")
342 .map(|entry| serde_json::from_slice(&entry.contents).map_err(|e| e.to_string()))
343 .transpose()?
344 .unwrap_or_default(),
345 }))
346}
347fn validate_manifest(manifest: &InputManifest) -> Result<(), String> {
348 if manifest.files.iter().any(|(p, f)| {
349 !local_path(p) || f.sha256.len() != 64 || !f.sha256.bytes().all(|b| b.is_ascii_hexdigit())
350 }) {
351 return Err("Invalid source manifest".into());
352 }
353 Ok(())
354}
355
356pub(crate) fn archived_manifest(
360 manifest: InputManifest,
361 evidence_digest: String,
362) -> Result<RunManifest, String> {
363 validate_manifest(&manifest)?;
364 Ok(RunManifest {
365 manifest,
366 evidence_digest,
367 statement_exclusions: Vec::new(),
368 })
369}
370fn write_json(
371 root: &Path,
372 run: &StoredRun,
373 name: &str,
374 value: &impl serde::Serialize,
375) -> Result<(), String> {
376 let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
377 bytes.push(b'\n');
378 atomic_write(root, &run.directory.join(name), &bytes).map_err(|e| e.to_string())
379}
380pub(crate) fn prepare_publication_with(
386 root: &Path,
387 directory: &Path,
388 metadata: &RunMetadata,
389 analysed: Option<&CoverageReport>,
390 archived: Option<RunManifest>,
391) -> Result<(), String> {
392 if metadata.merged == Some(true) {
393 return Ok(());
394 }
395 let run = StoredRun {
396 id: metadata.id.clone(),
397 directory: directory.into(),
398 evidence_path: directory.join("evidence.raw.gz"),
399 metadata_path: directory.join("run.json"),
400 query_index_path: directory.join(crate::run_store::RUST_QUERY_INDEX_FILE),
401 metadata: metadata.clone(),
402 };
403 let input = match archived {
404 Some(input) => input,
405 None => match load_optional_manifest(&run)? {
406 Some(input) => input,
407 None => return Ok(()),
408 },
409 };
410 let current = crate::source_capture::current_sources(root, &input.manifest);
411 let owned;
412 let analysed = match analysed {
413 Some(report) => Some(report),
414 None => {
415 owned = coverage(&run).ok();
416 owned.as_ref()
417 }
418 };
419 if let Some(executions) = analysed.and_then(|report| {
422 executions(
423 report,
424 &input.manifest,
425 current.as_ref().ok().map(|inputs| &inputs.files),
426 )
427 }) {
428 write_json(root, &run, EXECUTIONS_FILE, &executions)?;
429 }
430 let _ = write_json(
432 root,
433 &run,
434 MANIFEST_CACHE_FILE,
435 &ManifestCache {
436 evidence_digest: input.evidence_digest.clone(),
437 manifest: input.manifest.clone(),
438 statement_exclusions: input.statement_exclusions.clone(),
439 },
440 );
441 Ok(())
442}
443pub fn executions(
449 coverage: &CoverageReport,
450 manifest: &InputManifest,
451 sources: Option<&Files>,
452) -> Option<Executions> {
453 let mut located: crate::interned::FastMap<&str, (usize, usize)> = Default::default();
457 let mut files: Vec<&str> = Vec::new();
458 let mut file_indexes: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
459 let mut probed: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
460 for point in &coverage.view.points {
461 let meta = &point.meta;
462 let Some(code) = manifest
463 .files
464 .get(&meta.file)
465 .and_then(|fingerprint| fingerprint.code.as_ref())
466 else {
467 continue;
468 };
469 let column = if manifest.language == "javascript" {
470 let source = sources?.get(&meta.file)?;
471 let Some(column) = byte_column(source, meta.line, meta.column, &manifest.language)
472 else {
473 continue;
474 };
475 column
476 } else {
477 meta.column + 1
478 };
479 let unit = code.unit_at(meta.line, column);
480 let file = *file_indexes.entry(meta.file.as_str()).or_insert_with(|| {
481 files.push(meta.file.as_str());
482 files.len() - 1
483 });
484 located.insert(meta.id.as_str(), (file, unit));
485 if code.units[unit].is_code() {
486 probed.entry(meta.file.clone()).or_default().insert(unit);
487 }
488 }
489 let mut covered_by_file = vec![Vec::new(); files.len()];
494 for test in &coverage.view.tests {
495 for hit in &test.hits {
496 if let Some((file, unit)) = located.get(hit.as_str()) {
497 covered_by_file[*file].push(*unit);
498 }
499 }
500 }
501 let covered = files
502 .iter()
503 .zip(covered_by_file)
504 .filter(|(_, units)| !units.is_empty())
505 .map(|(file, units)| {
506 (
507 (*file).to_owned(),
508 units.into_iter().collect::<BTreeSet<_>>(),
509 )
510 })
511 .collect::<BTreeMap<_, _>>();
512
513 let mut tests: BTreeMap<TestSelector, Execution> = BTreeMap::new();
514 for test in &coverage.view.tests {
515 let Some(file) = &test.file else {
516 continue;
517 };
518 let selector = TestSelector {
519 file: file.clone(),
520 name: test.name.clone(),
521 };
522 let record = tests.entry(selector.clone()).or_insert_with(|| Execution {
523 test: selector,
524 passed: false,
525 files: BTreeMap::new(),
526 attribution: crate::coverage_report::ATTRIBUTION_EXACT.to_owned(),
527 });
528 record.passed |= test.outcome == "passed";
529 if !crate::coverage_report::coverage_is_complete(&record.attribution) {
533 } else if !crate::coverage_report::coverage_is_complete(&test.attribution) {
535 record.attribution = test.attribution.clone();
536 }
537 let mut reached = test
538 .hits
539 .iter()
540 .filter_map(|hit| located.get(hit.as_str()).copied())
541 .collect::<Vec<_>>();
542 reached.sort_unstable();
543 for group in reached.chunk_by(|left, right| left.0 == right.0) {
544 record
545 .files
546 .entry(files[group[0].0].to_owned())
547 .or_default()
548 .extend(group.iter().map(|(_, unit)| *unit));
549 }
550 }
551 let mut tests = tests.into_values().collect::<Vec<_>>();
552 for record in &mut tests {
553 for units in record.files.values_mut() {
554 units.sort_unstable();
555 units.dedup();
556 }
557 }
558 Some(Executions {
559 tests,
560 probed: probed
561 .into_iter()
562 .map(|(file, units)| (file, units.into_iter().collect()))
563 .collect(),
564 covered: covered
565 .into_iter()
566 .map(|(file, units)| (file, units.into_iter().collect()))
567 .collect(),
568 })
569}
570pub fn affected_tests(root: &Path, run: &StoredRun) -> Result<Value, String> {
580 let stored = load_manifest(run)?;
581 let executions: Executions = fs::read(run.directory.join(EXECUTIONS_FILE))
582 .ok()
583 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
584 .ok_or(
585 "This run has no per-test execution record; rerun tests with this version of Supercov",
586 )?;
587 let executions = &executions;
588 let root = crate::workspace::canonicalize_simplified(root).map_err(|e| e.to_string())?;
589 let manifest = &stored.manifest;
590 let now = manifest
591 .files
592 .keys()
593 .filter_map(|file| {
594 let path = root.join(file);
595 let canonical = crate::workspace::canonicalize_simplified(&path).ok()?;
596 if !canonical.starts_with(&root) || !canonical.is_file() {
597 return None;
598 }
599 let text = fs::read_to_string(canonical).ok()?;
600 Some((file.clone(), FileFingerprint::read(file, &text)))
601 })
602 .collect::<FileManifest>();
603 let changes = manifest
604 .files
605 .keys()
606 .filter_map(|file| {
607 file_change(
608 manifest.files.get(file),
609 now.get(file),
610 executions.probed.get(file).map(Vec::as_slice),
611 )
612 .map(|change| (file.as_str(), change))
613 })
614 .collect::<BTreeMap<_, _>>();
615 let files = changes
616 .iter()
617 .filter_map(|(file, change)| {
618 let (kind, detail) = match change {
619 FileChange::Same | FileChange::Added => return None,
620 FileChange::CommentsOnly => ("formatting", None),
621 FileChange::Removed => ("removed", None),
622 FileChange::Bytes => ("changed", None),
623 FileChange::Code {
624 before,
625 after,
626 diff,
627 narrow,
628 } => (
629 if *narrow { "bodies" } else { "declarations" },
630 Some(describe(before, after, diff)),
631 ),
632 };
633 Some(json!({"file":file,"change":kind,"detail":detail}))
634 })
635 .collect::<Vec<_>>();
636 let beyond_the_run = changes
650 .iter()
651 .filter_map(|(file, change)| match change {
652 FileChange::Same | FileChange::CommentsOnly | FileChange::Added => None,
653 FileChange::Removed => Some(format!("{file} removed (the run covered code in it)")),
654 FileChange::Bytes => Some(format!("{file} changed (the run covered code in it)")),
655 FileChange::Code { before, diff, .. } => {
656 let code = manifest.files.get(*file).and_then(|f| f.code.as_ref());
661 let reached = executions
662 .covered
663 .get(*file)
664 .map(Vec::as_slice)
665 .unwrap_or_default()
666 .iter()
667 .flat_map(|unit| match code {
668 Some(code) if *unit < code.units.len() => {
669 code.ancestors(*unit).collect::<Vec<_>>()
670 }
671 _ => vec![*unit],
672 })
673 .collect::<BTreeSet<_>>();
674 let hit = diff
675 .changed
676 .iter()
677 .filter(|unit| reached.contains(unit))
678 .filter_map(|unit| before.units.get(*unit))
679 .collect::<Vec<_>>();
680 (!hit.is_empty())
681 .then(|| format!("{file}: {} changed (the run covered it)", named(hit)))
682 }
683 })
684 .collect::<Vec<_>>();
685
686 let named_by_tests = named_by_tests(&changes);
695 let mut test_sources = BTreeMap::<&str, Option<String>>::new();
696
697 let mut affected = Vec::new();
698 let mut undetermined = Vec::new();
699 let mut unaffected = Vec::new();
700 for record in &executions.tests {
701 let mut reasons = Vec::new();
702 let mut changed_code = Vec::new();
707 if !record.passed {
708 reasons.push("did not pass in the run".to_owned());
709 }
710 match changes.get(record.test.file.as_str()) {
711 None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
712 Some(FileChange::Removed) => reasons.push("test file removed".to_owned()),
713 Some(FileChange::Bytes) => reasons.push("test file changed".to_owned()),
714 Some(FileChange::Code {
715 before,
716 after,
717 diff,
718 ..
719 }) => reasons.push(format!(
720 "test file changed: {}",
721 describe(before, after, diff)
722 )),
723 }
724 for (file, units) in &record.files {
725 let code = manifest.files.get(file).and_then(|f| f.code.as_ref());
726 let ran = units
727 .iter()
728 .flat_map(|unit| match code {
729 Some(code) if *unit < code.units.len() => {
730 code.ancestors(*unit).collect::<Vec<_>>()
731 }
732 _ => vec![*unit],
733 })
734 .collect::<BTreeSet<_>>();
735 match changes.get(file.as_str()) {
736 None | Some(FileChange::Same | FileChange::CommentsOnly | FileChange::Added) => {}
737 Some(FileChange::Removed) => {
738 reasons.push(format!("{file} removed (this test ran code in it)"));
739 }
740 Some(FileChange::Bytes) => {
741 reasons.push(format!("{file} changed (this test ran code in it)"));
742 }
743 Some(FileChange::Code {
744 before,
745 after,
746 diff,
747 narrow,
748 }) => {
749 if *narrow {
750 let hit = diff
751 .changed
752 .iter()
753 .filter(|i| ran.contains(i))
754 .map(|i| &before.units[*i])
755 .collect::<Vec<_>>();
756 let now = after.by_path();
757 for index in diff.changed.iter().filter(|i| ran.contains(i)) {
758 let unit = &before.units[*index];
759 changed_code.push(json!({
760 "file": file,
761 "declaration": unit.path,
762 "own": own_lines(before, *index),
763 "now": now.get(unit.path.as_str()).map(|u| [u.line, u.end_line]),
764 }));
765 }
766 if !hit.is_empty() {
767 reasons
768 .push(format!("{file}: {} changed (this test ran it)", named(hit)));
769 }
770 } else {
771 reasons.push(format!(
772 "{file}: {} changed (this test ran code in this file)",
773 describe(before, after, diff)
774 ));
775 }
776 }
777 }
778 }
779 for (file, names) in &named_by_tests {
780 if record.files.contains_key(*file) || *file == record.test.file.as_str() {
781 continue; }
783 let source = test_sources
784 .entry(record.test.file.as_str())
785 .or_insert_with(|| fs::read_to_string(root.join(&record.test.file)).ok());
786 if let Some(name) = source
787 .as_deref()
788 .and_then(|text| names.iter().find(|name| names_word(text, name)))
789 {
790 reasons.push(format!(
791 "{file}: declarations changed, and the test file names {name} (code that runs once per process is credited to the first test to reach it)"
792 ));
793 }
794 }
795 if !crate::coverage_report::coverage_is_complete(&record.attribution) {
796 let proven = !reasons.is_empty();
800 if !proven {
804 reasons.extend(beyond_the_run.iter().cloned());
805 reasons.sort();
806 reasons.dedup();
807 }
808 let entry = json!({
809 "file": record.test.file,
810 "name": record.test.name,
811 "reasons": reasons,
812 "attribution": record.attribution,
813 });
814 if proven {
815 affected.push(entry);
816 } else if reasons.is_empty() {
817 unaffected.push(entry);
818 } else {
819 undetermined.push(entry);
820 }
821 continue;
822 }
823 let entry = json!({"file":record.test.file,"name":record.test.name,"reasons":reasons,"changedCode":changed_code});
824 if reasons.is_empty() {
825 unaffected.push(entry);
826 } else {
827 affected.push(entry);
828 }
829 }
830 let changed_words = changes
836 .iter()
837 .filter(|(_, change)| !matches!(change, FileChange::Same | FileChange::CommentsOnly))
838 .flat_map(|(file, _)| words_of(file))
839 .collect::<BTreeSet<_>>();
840 for bucket in [&mut affected, &mut undetermined] {
841 for entry in bucket.iter_mut() {
842 entry["nameMatch"] = json!(name_match(entry, &changed_words));
843 }
844 bucket.sort_by_key(|entry| std::cmp::Reverse(entry["nameMatch"].as_u64().unwrap_or(0)));
845 }
846 Ok(json!({
847 "run": run.id,
848 "affected": affected,
849 "undetermined": undetermined,
856 "unaffected": unaffected,
857 "changedFiles": files,
858 "summary": {
859 "tests": executions.tests.len(),
860 "affected": affected.len(),
861 "undetermined": undetermined.len(),
862 "unaffected": unaffected.len(),
863 "changedFiles": files.len(),
864 },
865 "meaning": "Tests whose recorded execution a change since the run could have reached, and those whose execution nothing recorded: a test that ran alongside others has no coverage of its own, so it is undetermined whenever a change reaches anything the run covered. Run both. A file the run never captured, a dependency or a configuration change is not seen here; see workingTree."
866 }))
867}
868fn named_by_tests<'a>(
871 changes: &BTreeMap<&'a str, FileChange<'_>>,
872) -> BTreeMap<&'a str, BTreeSet<String>> {
873 changes
874 .iter()
875 .filter(|(file, _)| {
876 [".java", ".kt", ".kts", ".groovy", ".scala"]
877 .iter()
878 .any(|extension| file.ends_with(extension))
879 })
880 .filter_map(|(file, change)| match change {
881 FileChange::Code {
882 before,
883 narrow: false,
884 ..
885 } => {
886 let stem = file.rsplit('/').next()?.split('.').next()?.to_owned();
887 let mut names = before
888 .units
889 .iter()
890 .filter(|unit| unit.parent.is_none_or(|p| before.units[p].path.is_empty()))
891 .filter_map(|unit| unit.path.rsplit('.').next().map(str::to_owned))
892 .filter(|name| name.len() > 2)
893 .collect::<BTreeSet<_>>();
894 if stem.len() > 2
895 && !matches!(stem.as_str(), "mod" | "lib" | "main" | "index" | "__init__")
896 {
897 names.insert(stem);
898 }
899 Some((*file, names))
900 }
901 _ => None,
902 })
903 .collect::<BTreeMap<_, _>>()
904}
905
906fn words_of(path: &str) -> BTreeSet<String> {
910 let stem = path.rsplit(['/', '\\']).next().unwrap_or(path);
911 let stem = stem.split('.').next().unwrap_or(stem);
912 split_words(stem)
913 .into_iter()
914 .filter(|w| !matches!(w.as_str(), "test" | "tests" | "spec" | "specs"))
915 .collect()
916}
917
918fn split_words(text: &str) -> Vec<String> {
921 let mut words = Vec::new();
922 let mut current = String::new();
923 let mut previous_lower = false;
924 for c in text.chars() {
925 if !c.is_alphanumeric() {
926 if !current.is_empty() {
927 words.push(std::mem::take(&mut current));
928 }
929 previous_lower = false;
930 continue;
931 }
932 if c.is_uppercase() && previous_lower && !current.is_empty() {
933 words.push(std::mem::take(&mut current));
934 }
935 previous_lower = c.is_lowercase() || c.is_ascii_digit();
936 current.extend(c.to_lowercase());
937 }
938 if !current.is_empty() {
939 words.push(current);
940 }
941 words.into_iter().filter(|w| w.len() > 1).collect()
942}
943
944fn name_match(entry: &Value, changed: &BTreeSet<String>) -> usize {
946 let file = entry["file"].as_str().unwrap_or("");
947 let name = entry["name"].as_str().unwrap_or("");
948 let own = name
951 .rsplit(['#', ':', '>', '.', '/'])
952 .next()
953 .unwrap_or(name)
954 .split(['[', '('])
955 .next()
956 .unwrap_or("");
957 let mut words = words_of(file);
958 words.extend(split_words(own));
959 words.intersection(changed).count()
960}
961
962fn names_word(text: &str, word: &str) -> bool {
964 text.match_indices(word).any(|(at, _)| {
965 let before = text[..at].chars().last();
966 let after = text[at + word.len()..].chars().next();
967 let part = |c: Option<char>| c.is_some_and(|c| c.is_alphanumeric() || c == '_');
968 !part(before) && !part(after)
969 })
970}
971
972fn own_lines(code: &crate::source_units::Code, index: usize) -> Vec<[usize; 2]> {
975 let unit = &code.units[index];
976 let mut children = code
977 .units
978 .iter()
979 .filter(|u| u.parent == Some(index))
980 .map(|u| (u.line, u.end_line))
981 .collect::<Vec<_>>();
982 children.sort_unstable();
983 let mut out = Vec::new();
984 let mut next = unit.line;
985 for (start, end) in children {
986 if start > next {
987 out.push([next, start - 1]);
988 }
989 next = next.max(end + 1);
990 }
991 if next <= unit.end_line {
992 out.push([next, unit.end_line]);
993 }
994 out
995}
996
997pub fn coverage(run: &StoredRun) -> Result<CoverageReport, String> {
998 analyze_coverage_archive(&ArchiveReportRequest {
999 archive_path: run.evidence_path.clone(),
1000 run_id: run.id.clone(),
1001 generated_at: run.metadata.started_at.clone(),
1002 integrity: None,
1003 test_exit_code: ExitCodeInput::Present(run.metadata.test_exit_code),
1004 })
1005 .map_err(|e| format!("{e:?}"))
1006}
1007fn byte_column(source: &str, line: usize, column: usize, language: &str) -> Option<usize> {
1008 byte_column_in(source, &line_starts(source), line, column, language)
1009}
1010
1011fn byte_column_in(
1014 source: &str,
1015 starts: &[usize],
1016 line: usize,
1017 column: usize,
1018 language: &str,
1019) -> Option<usize> {
1020 if line == 0 {
1021 return None;
1022 }
1023 let line = line_text(source, starts, line)?;
1024 if language != "javascript" {
1025 return column.checked_add(1);
1027 }
1028 if column == 0 {
1029 return None;
1030 }
1031 let mut units = 0;
1032 for (byte, ch) in line.char_indices() {
1033 if units == column - 1 {
1034 return Some(byte + 1);
1035 }
1036 units += ch.len_utf16();
1037 }
1038 (units == column - 1).then_some(line.len() + 1)
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use super::*;
1044 use crate::evidence_archive::{read_archive, write_archive};
1045 use crate::run_store::discover_runs;
1046
1047 fn prepare_publication(
1048 root: &Path,
1049 directory: &Path,
1050 metadata: &RunMetadata,
1051 analysed: Option<&CoverageReport>,
1052 ) -> Result<(), String> {
1053 prepare_publication_with(root, directory, metadata, analysed, None)
1054 }
1055
1056 fn recorded(run: &StoredRun) -> Executions {
1057 serde_json::from_slice(&fs::read(run.directory.join(EXECUTIONS_FILE)).expect("a record"))
1058 .unwrap()
1059 }
1060
1061 fn reference_byte_column(
1063 source: &str,
1064 line: usize,
1065 column: usize,
1066 language: &str,
1067 ) -> Option<usize> {
1068 if line == 0 {
1069 return None;
1070 }
1071 let line = source.lines().nth(line - 1)?;
1072 if language != "javascript" {
1073 return column.checked_add(1);
1074 }
1075 if column == 0 {
1076 return None;
1077 }
1078 let mut units = 0;
1079 for (byte, ch) in line.char_indices() {
1080 if units == column - 1 {
1081 return Some(byte + 1);
1082 }
1083 units += ch.len_utf16();
1084 }
1085 (units == column - 1).then_some(line.len() + 1)
1086 }
1087
1088 #[test]
1089 fn a_column_from_line_starts_agrees_with_walking_the_file() {
1090 for source in [
1093 "",
1094 "a",
1095 "a\n",
1096 "a\r\nb\r\n",
1097 "x\r",
1098 "héllo\nwörld\n",
1099 "😀x\ny😀z\n",
1100 "const a = 1;\n b();\n",
1101 ] {
1102 let starts = line_starts(source);
1103 for language in ["javascript", "python"] {
1104 for line in 0..source.len() + 3 {
1105 for column in 0..source.len() + 4 {
1106 assert_eq!(
1107 byte_column_in(source, &starts, line, column, language),
1108 reference_byte_column(source, line, column, language),
1109 "{language} {line}:{column} in {source:?}"
1110 );
1111 }
1112 }
1113 }
1114 }
1115 }
1116
1117 #[test]
1118 fn publication_records_what_each_test_ran_and_affected_tests_reads_it() {
1119 let root = std::env::temp_dir().join(format!("supercov-affected-{}", std::process::id()));
1120 let _ = fs::remove_dir_all(&root);
1121 fs::create_dir_all(root.join("src")).unwrap();
1122 fs::create_dir_all(root.join("tests")).unwrap();
1123 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1127 let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1128 let config = "export const LIMIT = 5;\nfunction check(n) {\n return n < LIMIT;\n}\n";
1129 fs::write(root.join("src/app.js"), app).unwrap();
1130 fs::write(root.join("src/config.js"), config).unwrap();
1131 fs::write(root.join("tests/app.test.js"), test).unwrap();
1132 let inputs = crate::source_capture::capture(
1136 &root,
1137 "python",
1138 [
1139 "src/app.js".into(),
1140 "src/config.js".into(),
1141 "tests/app.test.js".into(),
1142 ],
1143 )
1144 .unwrap();
1145 let directory = crate::run_store::create_analyzable_test_run(&root, "first");
1146 let path = directory.join("evidence.raw.gz");
1147 let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1148 let archive = write_archive(entries, &path).unwrap();
1149 let metadata_path = directory.join("run.json");
1150 let mut metadata: RunMetadata =
1151 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1152 metadata.raw_evidence.files = archive.files;
1153 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1154 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1155 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1156 prepare_publication(&root, &directory, &metadata, None).unwrap();
1157 let run = discover_runs(&root).unwrap().runs.remove(0);
1158 let stored = load_manifest(&run).unwrap();
1159 let executions = &recorded(&run);
1160 let code = stored.manifest.files["src/app.js"].code.as_ref().unwrap();
1161 let work = code.units.iter().position(|u| u.path == "work").unwrap();
1162 let idle = code.units.iter().position(|u| u.path == "idle").unwrap();
1163 assert_eq!(executions.tests.len(), 1);
1164 let record = &executions.tests[0];
1165 assert_eq!(record.test.file, "tests/app.test.js");
1166 assert_eq!(record.test.name, "test");
1167 assert!(record.passed);
1168 assert_eq!(record.files["src/app.js"], vec![work]);
1169 assert_eq!(
1170 executions.probed["src/app.js"],
1171 vec![work],
1172 "idle holds no probe in this run"
1173 );
1174 let _ = idle;
1175
1176 let names = |value: &Value, key: &str| {
1177 value[key]
1178 .as_array()
1179 .unwrap()
1180 .iter()
1181 .map(|t| t["name"].as_str().unwrap().to_owned())
1182 .collect::<Vec<_>>()
1183 };
1184 let report = affected_tests(&root, &run).unwrap();
1186 assert!(names(&report, "affected").is_empty(), "{report}");
1187 assert_eq!(names(&report, "unaffected"), ["test"]);
1188 assert_eq!(report["summary"]["changedFiles"], 0);
1189 fs::write(root.join("src/app.js"), format!("// about\n{app}")).unwrap();
1191 let report = affected_tests(&root, &run).unwrap();
1192 assert!(names(&report, "affected").is_empty(), "{report}");
1193 assert_eq!(report["changedFiles"][0]["change"], "formatting");
1194 fs::write(
1196 root.join("src/app.js"),
1197 app.replace("return 1;", "return 1 + 0;"),
1198 )
1199 .unwrap();
1200 let report = affected_tests(&root, &run).unwrap();
1201 assert_eq!(names(&report, "affected"), ["test"]);
1202 assert_eq!(
1203 report["affected"][0]["reasons"][0],
1204 "src/app.js: work (line 1) changed (this test ran it)"
1205 );
1206 fs::write(
1210 root.join("src/app.js"),
1211 app.replace("return 2;", "return 2 + 0;"),
1212 )
1213 .unwrap();
1214 let report = affected_tests(&root, &run).unwrap();
1215 assert_eq!(names(&report, "affected"), ["test"], "{report}");
1216 assert!(
1217 report["affected"][0]["reasons"][0]
1218 .as_str()
1219 .unwrap()
1220 .contains("this test ran code in this file"),
1221 "{report}"
1222 );
1223 fs::write(
1225 root.join("src/app.js"),
1226 format!("{app}function more() {{}}\n"),
1227 )
1228 .unwrap();
1229 let report = affected_tests(&root, &run).unwrap();
1230 assert_eq!(names(&report, "affected"), ["test"]);
1231 assert_eq!(report["changedFiles"][0]["change"], "declarations");
1232 fs::write(root.join("src/app.js"), app).unwrap();
1234 fs::write(root.join("tests/app.test.js"), test.replace("1)", "2)")).unwrap();
1235 let report = affected_tests(&root, &run).unwrap();
1236 assert_eq!(names(&report, "affected"), ["test"]);
1237 assert!(
1238 report["affected"][0]["reasons"][0]
1239 .as_str()
1240 .unwrap()
1241 .starts_with("test file changed"),
1242 "{report}"
1243 );
1244 fs::write(root.join("tests/app.test.js"), test).unwrap();
1248 fs::write(root.join("src/config.js"), config.replace("= 5", "= 6")).unwrap();
1249 let report = affected_tests(&root, &run).unwrap();
1250 assert!(names(&report, "affected").is_empty(), "{report}");
1251 fs::write(root.join("src/config.js"), config).unwrap();
1252 fs::remove_file(root.join("src/app.js")).unwrap();
1254 let report = affected_tests(&root, &run).unwrap();
1255 assert_eq!(
1256 report["affected"][0]["reasons"][0],
1257 "src/app.js removed (this test ran code in it)"
1258 );
1259 fs::remove_dir_all(root).unwrap();
1260 }
1261
1262 #[test]
1265 fn a_test_named_like_the_changed_file_comes_first() {
1266 assert_eq!(
1267 words_of("h11/tests/test_receive_buffer.py"),
1268 BTreeSet::from(["receive".to_owned(), "buffer".to_owned()])
1269 );
1270 assert_eq!(
1271 words_of("src/test/java/org/apache/commons/cli/ConverterTests.java"),
1272 BTreeSet::from(["converter".to_owned()])
1273 );
1274 assert_eq!(
1275 split_words("parseHTTPVersion_v2"),
1276 ["parse", "httpversion", "v2"]
1277 );
1278 let changed = words_of("src/main/java/org/apache/commons/cli/Converter.java");
1279 let entry = |file: &str, name: &str| json!({"file": file, "name": name});
1280 assert_eq!(
1281 name_match(
1282 &entry(
1283 "src/test/java/x/ConverterTests.java",
1284 "x.ConverterTests#testClass()"
1285 ),
1286 &changed
1287 ),
1288 1
1289 );
1290 assert_eq!(
1291 name_match(
1292 &entry(
1293 "src/test/java/x/OptionTests.java",
1294 "x.OptionTests#testConverter()"
1295 ),
1296 &changed
1297 ),
1298 1,
1299 "the test's own name counts too"
1300 );
1301 assert_eq!(
1302 name_match(
1303 &entry(
1304 "src/test/java/x/OptionTests.java",
1305 "x.OptionTests#testBuilder()"
1306 ),
1307 &changed
1308 ),
1309 0
1310 );
1311 }
1312
1313 #[test]
1318 fn a_jvm_class_changed_in_its_declarations_is_reached_by_its_name() {
1319 let java = "package app;\nclass Converter {\n static final Object CLASS = null;\n static int size(int n) {\n return n;\n }\n}\n";
1320 let before = FileFingerprint::read("src/app/Converter.java", java);
1321 let after = FileFingerprint::read(
1322 "src/app/Converter.java",
1323 &java.replace("CLASS = null", "CLASS = new Object()"),
1324 );
1325 let js = "export const LIMIT = 5;\n";
1326 let js_before = FileFingerprint::read("src/config.js", js);
1327 let js_after = FileFingerprint::read("src/config.js", &js.replace('5', "6"));
1328 let changes = BTreeMap::from([
1329 (
1330 "src/app/Converter.java",
1331 file_change(Some(&before), Some(&after), Some(&[])).unwrap(),
1332 ),
1333 (
1334 "src/config.js",
1335 file_change(Some(&js_before), Some(&js_after), Some(&[])).unwrap(),
1336 ),
1337 ]);
1338 let named = named_by_tests(&changes);
1339 assert_eq!(
1340 named.get("src/app/Converter.java"),
1341 Some(&BTreeSet::from(["Converter".to_owned()]))
1342 );
1343 assert!(!named.contains_key("src/config.js"));
1344 assert!(names_word("assertEquals(Converter.CLASS, x)", "Converter"));
1345 assert!(!names_word("ConverterTests", "Converter"));
1346 }
1347
1348 #[test]
1349 fn a_test_that_ran_beside_others_is_undetermined_rather_than_unaffected() {
1350 let root = std::env::temp_dir().join(format!(
1357 "supercov-undetermined-{}-{}",
1358 std::process::id(),
1359 std::time::SystemTime::now()
1360 .duration_since(std::time::UNIX_EPOCH)
1361 .unwrap()
1362 .as_nanos()
1363 ));
1364 let _ = fs::remove_dir_all(&root);
1365 fs::create_dir_all(root.join("src")).unwrap();
1366 fs::create_dir_all(root.join("tests")).unwrap();
1367 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1368 let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1369 let config = "export const LIMIT = 5;\nfunction check(n) {\n return n < LIMIT;\n}\n";
1370 fs::write(root.join("src/app.js"), app).unwrap();
1371 fs::write(root.join("src/config.js"), config).unwrap();
1372 fs::write(root.join("tests/app.test.js"), test).unwrap();
1373 let inputs = crate::source_capture::capture(
1374 &root,
1375 "python",
1376 ["src/app.js".into(), "tests/app.test.js".into()],
1377 )
1378 .unwrap();
1379 let directory = crate::run_store::create_run_wide_test_run(&root, "parallel");
1380 let path = directory.join("evidence.raw.gz");
1381 let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1382 let archive = write_archive(entries, &path).unwrap();
1383 let metadata_path = directory.join("run.json");
1384 let mut metadata: RunMetadata =
1385 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1386 metadata.raw_evidence.files = archive.files;
1387 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1388 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1389 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1390 prepare_publication(&root, &directory, &metadata, None).unwrap();
1391 let run = discover_runs(&root).unwrap().runs.remove(0);
1392
1393 let executions = &recorded(&run);
1394 assert_eq!(executions.tests.len(), 1, "the named test, not the record");
1395 let record = &executions.tests[0];
1396 assert_eq!(
1397 record.attribution,
1398 crate::coverage_report::ATTRIBUTION_RUN_WIDE
1399 );
1400 assert!(
1401 record.files.is_empty(),
1402 "it is credited with nothing: {:?}",
1403 record.files
1404 );
1405 assert!(
1406 !executions.covered["src/app.js"].is_empty(),
1407 "and the run is credited with what it reached"
1408 );
1409
1410 let bucket = |value: &Value, key: &str| {
1411 value[key]
1412 .as_array()
1413 .unwrap()
1414 .iter()
1415 .map(|t| t["name"].as_str().unwrap().to_owned())
1416 .collect::<Vec<_>>()
1417 };
1418
1419 let report = affected_tests(&root, &run).unwrap();
1422 assert_eq!(bucket(&report, "unaffected"), ["test"], "{report}");
1423 assert!(bucket(&report, "undetermined").is_empty(), "{report}");
1424
1425 fs::write(
1428 root.join("src/app.js"),
1429 app.replace("return 1;", "return 3;"),
1430 )
1431 .unwrap();
1432 let report = affected_tests(&root, &run).unwrap();
1433 assert!(
1434 bucket(&report, "affected").is_empty(),
1435 "nothing proves it ran the change: {report}"
1436 );
1437 assert_eq!(
1438 bucket(&report, "undetermined"),
1439 ["test"],
1440 "the run reached it, so this test might have: {report}"
1441 );
1442 assert_eq!(
1443 report["undetermined"][0]["attribution"],
1444 crate::coverage_report::ATTRIBUTION_RUN_WIDE,
1445 "{report}"
1446 );
1447 fs::remove_dir_all(root).unwrap();
1448 }
1449
1450 #[test]
1451 fn a_lower_bound_still_proves_what_it_recorded() {
1452 let root = std::env::temp_dir().join(format!(
1459 "supercov-partial-{}-{}",
1460 std::process::id(),
1461 std::time::SystemTime::now()
1462 .duration_since(std::time::UNIX_EPOCH)
1463 .unwrap()
1464 .as_nanos()
1465 ));
1466 let _ = fs::remove_dir_all(&root);
1467 fs::create_dir_all(root.join("src")).unwrap();
1468 fs::create_dir_all(root.join("tests")).unwrap();
1469 let app = "function work() {\n return 1;\n}\nfunction idle() {\n return 2;\n}\n";
1470 let test = "import assert from 'node:assert/strict';\nimport { LIMIT } from '../src/config.js';\nassert.equal(work(), 1);\n";
1471 let config = "export const LIMIT = 5;\nfunction check(n) {\n return n < LIMIT;\n}\n";
1472 fs::write(root.join("src/app.js"), app).unwrap();
1473 fs::write(root.join("src/config.js"), config).unwrap();
1474 fs::write(root.join("tests/app.test.js"), test).unwrap();
1475 let inputs = crate::source_capture::capture(
1476 &root,
1477 "python",
1478 ["src/app.js".into(), "tests/app.test.js".into()],
1479 )
1480 .unwrap();
1481 let directory = crate::run_store::create_partial_test_run(&root, "partial");
1482 let path = directory.join("evidence.raw.gz");
1483 let entries = crate::source_capture::append(read_archive(&path).unwrap(), &inputs).unwrap();
1484 let archive = write_archive(entries, &path).unwrap();
1485 let metadata_path = directory.join("run.json");
1486 let mut metadata: RunMetadata =
1487 serde_json::from_slice(&fs::read(&metadata_path).unwrap()).unwrap();
1488 metadata.raw_evidence.files = archive.files;
1489 metadata.raw_evidence.compressed_bytes = archive.compressed_bytes;
1490 metadata.raw_evidence.uncompressed_bytes = archive.uncompressed_bytes;
1491 fs::write(&metadata_path, serde_json::to_vec(&metadata).unwrap()).unwrap();
1492 prepare_publication(&root, &directory, &metadata, None).unwrap();
1493 let run = discover_runs(&root).unwrap().runs.remove(0);
1494
1495 let executions = recorded(&run);
1496 let record = &executions.tests[0];
1497 assert_eq!(
1498 record.attribution,
1499 crate::coverage_report::ATTRIBUTION_PARTIAL
1500 );
1501 assert!(
1502 !record.files.is_empty(),
1503 "a lower bound is coverage it was credited with"
1504 );
1505
1506 let names = |value: &Value, key: &str| {
1507 value[key]
1508 .as_array()
1509 .unwrap()
1510 .iter()
1511 .map(|t| t["name"].as_str().unwrap().to_owned())
1512 .collect::<Vec<_>>()
1513 };
1514 fs::write(
1515 root.join("src/app.js"),
1516 app.replace("return 1;", "return 3;"),
1517 )
1518 .unwrap();
1519 let report = affected_tests(&root, &run).unwrap();
1520 assert_eq!(
1521 names(&report, "affected"),
1522 ["test"],
1523 "what its own record proves, it proves: {report}"
1524 );
1525 assert!(names(&report, "undetermined").is_empty(), "{report}");
1526 let reasons = report["affected"][0]["reasons"].as_array().unwrap();
1528 assert_eq!(reasons.len(), 1, "{report}");
1529 let reason = reasons[0].as_str().unwrap();
1530 assert!(
1531 reason.contains("this test ran it"),
1532 "the reason is its own: {report}"
1533 );
1534 assert!(
1535 !reason.contains("the run covered"),
1536 "and not the run's stand-in: {report}"
1537 );
1538 fs::remove_dir_all(root).unwrap();
1539 }
1540}