Skip to main content

supercov_engine/
outside_evidence.rs

1//! Line evidence measured where Supercov could not run, as an LCOV file,
2//! added to a run. Supercov runs no other coverage tool: a profile another
3//! tool recorded is exported as LCOV by that tool first.
4//!
5//! Such evidence says which lines ran and which functions were entered, and
6//! nothing finer: not which statement on a line, which way a branch went, or
7//! which conditions decided. So it is credited by line, to a test of its own
8//! or to the run's background, and every line it credits is visibly its own:
9//! the test is of kind `imported`, its runner `lines`. A file the run did not
10//! measure joins it with one statement per recorded line, and a limitation
11//! that says so. The run's own records are left exactly as they were.
12//!
13//! The run is not changed: the import publishes a new run, made from it.
14use std::{
15    collections::{BTreeMap, BTreeSet},
16    fs,
17    path::Path,
18};
19
20use serde_json::{Value, json};
21use supercov_contracts::{
22    AttributionPrecision, ExecutionModel, FrontendAttribution, FrontendLimitation,
23    FrontendLimitationScope, FrontendRunDeclaration, FrontendRunnerDeclaration,
24};
25
26use crate::{
27    coverage_analysis::PointKind,
28    coverage_report::{
29        ATTRIBUTION_EXACT, CoverageManifest, PointMeta, RawTestResult, RuntimeSnapshot,
30        TestProvenance,
31    },
32    evidence_archive::{EvidenceArchiveEntry, read_archive, write_archive},
33    lifecycle::{ProjectLock, finalize_published_run, publish_run, remove_stored_tree_deferred},
34    run_store::{RawEvidenceMetadata, RunMetadata, discover_runs, select_run},
35    source_manifest::{FileFingerprint, InputManifest, local_path},
36};
37
38/// The runner of an imported test: what its evidence can say.
39pub const IMPORTED_RUNNER: &str = "lines";
40/// The kind of an imported test, so `--kind` can pick or leave it out.
41pub const IMPORTED_KIND: &str = "imported";
42const BACKGROUND_RUNNER: &str = "background";
43
44/// Who the imported lines are credited to.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Credit {
47    /// A test of its own, under this name.
48    Test(String),
49    /// The run's background: work no test can be named for.
50    Background,
51}
52
53#[derive(Debug, Clone)]
54pub struct ImportRequest<'a> {
55    pub root: &'a Path,
56    /// The run to import into: an id, a prefix of one, or `latest`.
57    pub run: Option<&'a str>,
58    /// An LCOV tracefile.
59    pub evidence: &'a Path,
60    pub credit: Credit,
61    /// The imported test's outcome, where it is known.
62    pub status: Option<&'a str>,
63    pub run_id: &'a str,
64    pub started_at: &'a str,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct ImportOutcome {
69    pub run_id: String,
70    pub parent: String,
71    /// Lines that ran, in files the import credited.
72    pub lines_run: usize,
73    /// The run's statements and functions those lines credited.
74    pub credited: usize,
75    /// Files the run did not measure, added with the import's lines.
76    pub added_files: Vec<String>,
77    /// Files of the evidence left out, each with why.
78    pub skipped: Vec<(String, String)>,
79}
80
81/// What a recorded file says ran: lines and function starts with their counts.
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
83struct RecordedFile {
84    lines: BTreeMap<usize, u64>,
85    functions: BTreeMap<usize, u64>,
86}
87
88/// LCOV's tracefile records, merged by file: a line ran if any record says so.
89fn parse_lcov(text: &str) -> Result<BTreeMap<String, RecordedFile>, String> {
90    let mut files = BTreeMap::<String, RecordedFile>::new();
91    let mut current: Option<String> = None;
92    let mut function_lines = BTreeMap::<String, usize>::new();
93    let number = |value: &str, what: &str, at: usize| {
94        value
95            .trim()
96            .parse::<u64>()
97            .map_err(|_| format!("line {at}: {what} is not a number: {value}"))
98    };
99    for (index, raw) in text.lines().enumerate() {
100        let at = index + 1;
101        let line = raw.trim();
102        if line.is_empty() || line == "end_of_record" {
103            if line == "end_of_record" {
104                current = None;
105                function_lines.clear();
106            }
107            continue;
108        }
109        let Some((tag, value)) = line.split_once(':') else {
110            continue;
111        };
112        match tag {
113            "SF" => {
114                files.entry(value.to_owned()).or_default();
115                current = Some(value.to_owned());
116                function_lines.clear();
117            }
118            "DA" | "FN" | "FNDA" => {
119                let Some(file) = current.as_ref() else {
120                    return Err(format!("line {at}: {tag} before any SF"));
121                };
122                let record = files.get_mut(file).expect("SF registered the file");
123                let fields = value.split(',').collect::<Vec<_>>();
124                match tag {
125                    // DA:<line>,<count>[,<checksum>]
126                    "DA" => {
127                        let [line, count, ..] = fields[..] else {
128                            return Err(format!("line {at}: DA needs a line and a count"));
129                        };
130                        let line = usize::try_from(number(line, "DA line", at)?)
131                            .map_err(|_| format!("line {at}: DA line out of range"))?;
132                        // Some tools write a negative or fractional count for a
133                        // line that ran; only zero says it did not.
134                        let count = count.trim().parse::<u64>().unwrap_or_else(|_| {
135                            u64::from(count.trim().parse::<f64>().is_ok_and(|v| v != 0.0))
136                        });
137                        let slot = record.lines.entry(line).or_default();
138                        *slot = slot.saturating_add(count);
139                    }
140                    // FN:<line>,<name> or, in LCOV 2, FN:<line>,<end>,<name>
141                    "FN" if fields.len() >= 2 => {
142                        let line = usize::try_from(number(fields[0], "FN line", at)?)
143                            .map_err(|_| format!("line {at}: FN line out of range"))?;
144                        let name = fields[1..].last().expect("two fields").to_string();
145                        function_lines.insert(name, line);
146                    }
147                    // FNDA:<count>,<name>
148                    "FNDA" if fields.len() >= 2 => {
149                        let count = number(fields[0], "FNDA count", at)?;
150                        let name = fields[1..].join(",");
151                        if let Some(&line) = function_lines
152                            .get(&name)
153                            .or_else(|| function_lines.get(fields[fields.len() - 1]))
154                        {
155                            let slot = record.functions.entry(line).or_default();
156                            *slot = slot.saturating_add(count);
157                        }
158                    }
159                    _ => return Err(format!("line {at}: malformed {tag}")),
160                }
161            }
162            _ => {}
163        }
164    }
165    if files.is_empty() {
166        return Err("no SF record: this is not an LCOV tracefile".into());
167    }
168    Ok(files)
169}
170
171/// An LLVM profile, raw or indexed, by its magic: `\xfflprofr\x81` and
172/// `\xfflprofi\x81`, little-endian as written on every host LLVM profiles.
173fn is_llvm_profile(bytes: &[u8]) -> bool {
174    let magic = |kind: u8| [0x81, kind, b'f', b'o', b'r', b'p', b'l', 0xff];
175    bytes.len() >= 8 && (bytes[..8] == magic(b'r') || bytes[..8] == magic(b'i'))
176}
177
178/// The project file a recorded path names: under the project root, or, for
179/// evidence recorded elsewhere (a container's `/app`, another checkout), the
180/// one measured file its path ends with.
181fn project_path(
182    root: &Path,
183    canonical_root: &Path,
184    recorded: &str,
185    measured: &BTreeSet<String>,
186) -> Result<String, String> {
187    let normalized = recorded.replace('\\', "/");
188    let path = Path::new(&normalized);
189    // Absolute however it was recorded: `/app/...` from a Linux container is
190    // absolute on Windows too, though `Path` reads it as relative there.
191    let absolute = normalized.starts_with('/') || normalized.as_bytes().get(1) == Some(&b':');
192    let mut relative = None;
193    if absolute {
194        for base in [root, canonical_root] {
195            if let Ok(inside) = path.strip_prefix(base) {
196                relative = Some(inside.to_string_lossy().replace('\\', "/"));
197                break;
198            }
199        }
200    } else {
201        relative = Some(normalized.trim_start_matches("./").to_owned());
202    }
203    if let Some(relative) = relative.filter(|relative| local_path(relative))
204        && (measured.contains(&relative) || root.join(&relative).is_file())
205    {
206        return Ok(relative);
207    }
208    // The longest measured path the recorded one ends with, where only one
209    // is that long: `/app/src/lib.rs` is `src/lib.rs`, never a guess
210    // between `a/lib.rs` and `b/lib.rs`.
211    let mut matches = measured
212        .iter()
213        .filter(|file| {
214            normalized == **file
215                || normalized
216                    .strip_suffix(file.as_str())
217                    .is_some_and(|prefix| prefix.ends_with('/'))
218        })
219        .collect::<Vec<_>>();
220    matches.sort_by_key(|file| std::cmp::Reverse(file.len()));
221    // A file the run did not measure: the longest tail of the recorded path
222    // that is a file of the project.
223    if matches.is_empty() {
224        let parts = normalized
225            .split('/')
226            .filter(|part| !part.is_empty())
227            .collect::<Vec<_>>();
228        if let Some(tail) = (1..parts.len())
229            .map(|from| parts[from..].join("/"))
230            .find(|tail| local_path(tail) && root.join(tail).is_file())
231        {
232            return Ok(tail);
233        }
234    }
235    match matches[..] {
236        [only] => Ok(only.clone()),
237        [first, second, ..] if first.len() > second.len() => Ok(first.clone()),
238        [_, _, ..] => Err("more than one measured file ends with this path".into()),
239        [] => Err(if absolute {
240            "outside the project".into()
241        } else {
242            "no such file in the project".into()
243        }),
244    }
245}
246
247/// The scope entry the run keeps for a file, if any: whether it measured it.
248fn scope_status(manifest: &CoverageManifest, file: &str) -> Option<(String, String)> {
249    manifest
250        .scope
251        .as_ref()?
252        .get("entries")?
253        .as_array()?
254        .iter()
255        .find(|entry| entry.get("file").and_then(Value::as_str) == Some(file))
256        .map(|entry| {
257            let field = |name| {
258                entry
259                    .get(name)
260                    .and_then(Value::as_str)
261                    .unwrap_or_default()
262                    .to_owned()
263            };
264            (field("status"), field("reason"))
265        })
266}
267
268fn imported_runner_declaration() -> FrontendRunnerDeclaration {
269    let unavailable = |suffix: &str, scope, reason: &str| FrontendLimitation {
270        id: format!("imported-lines-{suffix}-unavailable"),
271        scopes: vec![scope],
272        reason: reason.into(),
273    };
274    FrontendRunnerDeclaration {
275        runner: IMPORTED_RUNNER.into(),
276        execution_model: ExecutionModel::ProcessPerTest,
277        attribution: FrontendAttribution {
278            run: AttributionPrecision::Exact,
279            worker: AttributionPrecision::Unavailable,
280            test: AttributionPrecision::Exact,
281            retry: AttributionPrecision::Unavailable,
282            phase: AttributionPrecision::Unavailable,
283            action: AttributionPrecision::Unavailable,
284            assertion: AttributionPrecision::Unavailable,
285        },
286        limitations: vec![
287            unavailable(
288                "worker",
289                FrontendLimitationScope::Worker,
290                "Imported line evidence names no worker",
291            ),
292            unavailable(
293                "retry",
294                FrontendLimitationScope::Retry,
295                "Imported line evidence is one record, with no attempts",
296            ),
297            unavailable(
298                "phase",
299                FrontendLimitationScope::Phase,
300                "Imported line evidence says which lines ran, not when",
301            ),
302            unavailable(
303                "action",
304                FrontendLimitationScope::Action,
305                "Imported line evidence says which lines ran, not when",
306            ),
307            unavailable(
308                "assertion",
309                FrontendLimitationScope::Assertion,
310                "Imported line evidence carries no assertions",
311            ),
312        ],
313    }
314}
315
316fn background_runner_declaration() -> FrontendRunnerDeclaration {
317    FrontendRunnerDeclaration {
318        runner: BACKGROUND_RUNNER.into(),
319        execution_model: ExecutionModel::ParallelUnattributed,
320        attribution: FrontendAttribution {
321            run: AttributionPrecision::Exact,
322            worker: AttributionPrecision::Unavailable,
323            test: AttributionPrecision::Unavailable,
324            retry: AttributionPrecision::Unavailable,
325            phase: AttributionPrecision::Unavailable,
326            action: AttributionPrecision::Unavailable,
327            assertion: AttributionPrecision::Unavailable,
328        },
329        limitations: vec![FrontendLimitation {
330            id: "background-unattributed".into(),
331            scopes: vec![
332                FrontendLimitationScope::Worker,
333                FrontendLimitationScope::Test,
334                FrontendLimitationScope::Retry,
335                FrontendLimitationScope::Phase,
336                FrontendLimitationScope::Action,
337                FrontendLimitationScope::Assertion,
338            ],
339            reason: "Background work is not attributed to a test".into(),
340        }],
341    }
342}
343
344/// Add line evidence measured outside Supercov to a run, as a new run.
345pub fn import_line_evidence(request: &ImportRequest<'_>) -> Result<ImportOutcome, String> {
346    if let Credit::Test(name) = &request.credit
347        && name.trim().is_empty()
348    {
349        return Err("the imported test needs a name".into());
350    }
351    if let Some(status) = request.status
352        && !matches!(status, "passed" | "failed")
353    {
354        return Err(format!("--status is passed or failed, not {status}"));
355    }
356    let inventory = discover_runs(request.root)
357        .map_err(|error| format!("Cannot read local coverage runs: {error}"))?;
358    let parent = select_run(&inventory, request.run)
359        .map_err(|error| format!("Cannot import into that run: {error}"))?
360        .clone();
361    let work = request.root.join(".supercov/work").join(request.run_id);
362    let bytes = fs::read(request.evidence)
363        .map_err(|error| format!("Cannot read {}: {error}", request.evidence.display()))?;
364    if is_llvm_profile(&bytes) {
365        return Err(format!(
366            "{} is an LLVM profile: export it as LCOV with LLVM's own tools first, as `supercov docs cli` shows",
367            request.evidence.display()
368        ));
369    }
370    let text = String::from_utf8(bytes)
371        .map_err(|_| format!("{} is not an LCOV tracefile", request.evidence.display()))?;
372    let recorded = parse_lcov(&text).map_err(|error| {
373        format!(
374            "Cannot read {} as LCOV: {error}",
375            request.evidence.display()
376        )
377    })?;
378
379    let mut archive = read_archive(&parent.evidence_path)
380        .map_err(|error| format!("Cannot read run {}: {error}", parent.id))?;
381    let take = |archive: &mut Vec<EvidenceArchiveEntry>, path: &str| {
382        archive
383            .iter()
384            .position(|entry| entry.path == path)
385            .map(|at| archive.remove(at).contents)
386    };
387    let mut manifest = take(&mut archive, "manifest.json")
388        .ok_or("the run has no manifest")
389        .and_then(|bytes| {
390            serde_json::from_slice::<CoverageManifest>(&bytes)
391                .map_err(|_| "its manifest is invalid")
392        })
393        .map_err(|error| format!("Cannot import into run {}: {error}", parent.id))?;
394    let mut declaration = take(&mut archive, "frontend.json")
395        .ok_or("the run has no frontend declaration")
396        .and_then(|bytes| {
397            serde_json::from_slice::<FrontendRunDeclaration>(&bytes)
398                .map_err(|_| "its frontend declaration is invalid")
399        })
400        .map_err(|error| format!("Cannot import into run {}: {error}", parent.id))?;
401    let mut sources = take(&mut archive, crate::source_capture::ARCHIVE_PATH)
402        .map(|bytes| serde_json::from_slice::<InputManifest>(&bytes))
403        .transpose()
404        .map_err(|_| {
405            format!(
406                "Cannot import into run {}: its source record is invalid",
407                parent.id
408            )
409        })?;
410
411    let measured = manifest
412        .points
413        .iter()
414        .map(|point| point.file.clone())
415        .chain(manifest.branches.iter().map(|branch| branch.file.clone()))
416        .chain(
417            manifest
418                .decisions
419                .iter()
420                .map(|decision| decision.file.clone()),
421        )
422        .collect::<BTreeSet<_>>();
423    let canonical_root = crate::workspace::canonicalize_simplified(request.root)
424        .unwrap_or_else(|_| request.root.to_owned());
425    let mut by_file = BTreeMap::<String, RecordedFile>::new();
426    let mut skipped = Vec::new();
427    for (path, file) in recorded {
428        match project_path(request.root, &canonical_root, &path, &measured) {
429            Ok(project) => {
430                let merged = by_file.entry(project).or_default();
431                for (line, count) in file.lines {
432                    *merged.lines.entry(line).or_default() += count;
433                }
434                for (line, count) in file.functions {
435                    *merged.functions.entry(line).or_default() += count;
436                }
437            }
438            Err(reason) => skipped.push((path, reason)),
439        }
440    }
441
442    let mut hits = BTreeSet::<String>::new();
443    let mut lines_run = 0;
444    let mut added_files = Vec::new();
445    let evidence_name = request.evidence.file_name().map_or_else(
446        || request.evidence.display().to_string(),
447        |name| name.to_string_lossy().into_owned(),
448    );
449    for (file, recorded) in &by_file {
450        if !measured.contains(file)
451            && let Some((status, reason)) = scope_status(&manifest, file)
452            && status != "included"
453        {
454            skipped.push((file.clone(), format!("the run leaves it out: {reason}")));
455            continue;
456        }
457        let text = fs::read_to_string(request.root.join(file)).ok();
458        // Evidence of other source is not mixed in: a line past the file's
459        // end says the file has changed since it was recorded.
460        if let Some(text) = &text {
461            let length = text.lines().count();
462            if let Some((line, _)) = recorded
463                .lines
464                .iter()
465                .next_back()
466                .filter(|(line, _)| **line > length)
467            {
468                return Err(format!(
469                    "{} records line {line} of {file}, which has {length} lines: the evidence is of other source",
470                    request.evidence.display()
471                ));
472            }
473        }
474        let ran = recorded
475            .lines
476            .iter()
477            .filter(|(_, count)| **count > 0)
478            .map(|(line, _)| *line)
479            .collect::<BTreeSet<_>>();
480        lines_run += ran.len();
481        if measured.contains(file) {
482            let entered = recorded
483                .functions
484                .iter()
485                .filter(|(_, count)| **count > 0)
486                .map(|(line, _)| *line)
487                .collect::<BTreeSet<_>>();
488            for point in manifest.points.iter().filter(|point| point.file == *file) {
489                let credited = match point.kind {
490                    PointKind::Statement => ran.contains(&point.line),
491                    PointKind::Function => entered.contains(&point.line),
492                };
493                if credited {
494                    hits.insert(point.id.clone());
495                }
496            }
497            continue;
498        }
499        // A file the run did not measure: the evidence's own lines are its
500        // statements, and its source joins the run's record of sources.
501        let Some(text) = text else {
502            skipped.push((file.clone(), "no such file in the project".into()));
503            continue;
504        };
505        let source_lines = text.lines().collect::<Vec<_>>();
506        for line in recorded.lines.keys() {
507            let id = format!("imported:{file}:{line}");
508            manifest.points.push(PointMeta {
509                id: id.clone(),
510                kind: PointKind::Statement,
511                file: file.clone(),
512                line: *line,
513                column: 0,
514                source: source_lines
515                    .get(line.saturating_sub(1))
516                    .map_or_else(String::new, |source| source.trim().to_owned()),
517                label: None,
518            });
519            if ran.contains(line) {
520                hits.insert(id);
521            }
522        }
523        // A whole-file boundary, anchored as the Rust frontend anchors its own.
524        let limitation = format!("imported-lines#{file}");
525        manifest.limitations.push(json!({
526            "id": limitation,
527            "kind": "source-scope",
528            "file": file,
529            "line": 1,
530            "column": 0,
531            "source": "",
532            "reason": format!("Measured only by the lines {evidence_name} recorded: each line is one statement, and its branches, decisions and functions are not measured"),
533            "blocking": false,
534        }));
535        declaration.structural_limitations.push(limitation);
536        if let Some(entries) = manifest
537            .scope
538            .as_mut()
539            .and_then(|scope| scope.get_mut("entries"))
540            .and_then(Value::as_array_mut)
541        {
542            entries.push(json!({
543                "file": file,
544                "status": "included",
545                "reason": "imported line evidence",
546            }));
547        }
548        if let Some(sources) = sources.as_mut() {
549            sources
550                .files
551                .insert(file.clone(), FileFingerprint::read(file, &text));
552        }
553        added_files.push(file.clone());
554    }
555    if by_file.is_empty() {
556        return Err(format!(
557            "Nothing in {} is a file of this project: {}",
558            request.evidence.display(),
559            crate::run_merge::listed(
560                &skipped
561                    .iter()
562                    .map(|(file, reason)| format!("{file} ({reason})"))
563                    .collect::<Vec<_>>()
564            )
565        ));
566    }
567
568    let imports = archive
569        .iter()
570        .filter(|entry| {
571            entry.path.starts_with("imported-")
572                || entry.path.starts_with("server/background/imported-")
573        })
574        .count()
575        + 1;
576    let hits = hits.into_iter().collect::<Vec<_>>();
577    let credited = hits.len();
578    match &request.credit {
579        Credit::Test(name) => {
580            let test_id = format!("imported:{name}");
581            let taken = archive.iter().any(|entry| {
582                std::str::from_utf8(&entry.contents).is_ok_and(|text| {
583                    text.contains(&serde_json::to_string(&test_id).expect("string"))
584                })
585            });
586            if taken {
587                return Err(format!(
588                    "Run {} already holds an imported test named {name}; import into the original run, or give this one another name",
589                    parent.id
590                ));
591            }
592            let record = RawTestResult {
593                test_id: Some(test_id),
594                scope: None,
595                test: name.clone(),
596                test_file: None,
597                title: Some(name.clone()),
598                retry: request.status.map(|_| 0),
599                status: Some(request.status.unwrap_or("unknown").into()),
600                expected_status: None,
601                flaky: false,
602                span: None,
603                provenance: TestProvenance {
604                    runner: IMPORTED_RUNNER.into(),
605                    kind: IMPORTED_KIND.into(),
606                    project: None,
607                    source: format!("imported:{evidence_name}"),
608                    tags: Vec::new(),
609                },
610                role: "test".into(),
611                attribution: ATTRIBUTION_EXACT.into(),
612                phases: vec![],
613                runtime: vec![RuntimeSnapshot {
614                    decisions: vec![],
615                    hits,
616                    events: vec![],
617                    logicals: vec![],
618                    phase_id: None,
619                }],
620                browser: vec![],
621                server: vec![],
622            };
623            archive.push(EvidenceArchiveEntry {
624                path: format!("imported-{imports}/mcdc.json"),
625                contents: serde_json::to_vec(&record).map_err(|error| error.to_string())?,
626            });
627            if !declaration
628                .runners
629                .iter()
630                .any(|runner| runner.runner == IMPORTED_RUNNER)
631            {
632                declaration.runners.push(imported_runner_declaration());
633            }
634        }
635        Credit::Background => {
636            let mut contents = Vec::new();
637            for hit in &hits {
638                contents.extend(
639                    serde_json::to_vec(&json!({ "type": "hit", "id": hit })).expect("json"),
640                );
641                contents.push(b'\n');
642            }
643            archive.push(EvidenceArchiveEntry {
644                path: format!("server/background/imported-{imports}-0.jsonl"),
645                contents,
646            });
647            if !declaration
648                .runners
649                .iter()
650                .any(|runner| runner.runner == BACKGROUND_RUNNER)
651            {
652                declaration.runners.push(background_runner_declaration());
653            }
654        }
655    }
656    declaration
657        .runners
658        .sort_by(|left, right| left.runner.cmp(&right.runner));
659
660    let mut entries = Vec::with_capacity(archive.len() + 4);
661    entries.push(EvidenceArchiveEntry {
662        path: "frontend.json".into(),
663        contents: serde_json::to_vec(&declaration).map_err(|error| error.to_string())?,
664    });
665    entries.push(EvidenceArchiveEntry {
666        path: "manifest.json".into(),
667        contents: serde_json::to_vec(&manifest).map_err(|error| error.to_string())?,
668    });
669    if let Some(sources) = &sources {
670        entries.push(EvidenceArchiveEntry {
671            path: crate::source_capture::ARCHIVE_PATH.into(),
672            contents: serde_json::to_vec(sources).map_err(|error| error.to_string())?,
673        });
674    }
675    for entry in archive {
676        // The records name the run they were made in; they are this run's now.
677        let contents = if entry.path == "coverage-model.json" {
678            entry.contents
679        } else {
680            crate::run_merge::rewritten_contents(&entry.contents, request.run_id, None)
681                .map_err(|error| format!("Cannot import into run {}: {error}", parent.id))?
682                .unwrap_or_default()
683        };
684        entries.push(EvidenceArchiveEntry {
685            path: entry.path,
686            contents,
687        });
688    }
689
690    let mut lock = ProjectLock::acquire(request.root, request.run_id, request.started_at)
691        .map_err(|error| format!("Cannot lock coverage store for the import: {error}"))?;
692    let result = (|| -> Result<(), String> {
693        let archive_path = work.join("evidence.raw.gz");
694        let raw = write_archive(entries, &archive_path)
695            .map_err(|error| format!("Could not write the imported run: {error}"))?;
696        let mut command = vec![
697            "supercov".to_owned(),
698            "import".to_owned(),
699            parent.id.clone(),
700            request.evidence.display().to_string(),
701        ];
702        match &request.credit {
703            Credit::Test(name) => command.extend(["--test".to_owned(), name.clone()]),
704            Credit::Background => command.push("--background".to_owned()),
705        }
706        if let Some(status) = request.status {
707            command.extend(["--status".to_owned(), status.to_owned()]);
708        }
709        let metadata = RunMetadata {
710            id: request.run_id.into(),
711            started_at: request.started_at.into(),
712            duration_ms: 0.0,
713            command,
714            test_exit_code: match request.status {
715                Some("failed") => Some(1),
716                _ => parent.metadata.test_exit_code,
717            },
718            integrity: parent.metadata.integrity.clone(),
719            raw_evidence: RawEvidenceMetadata {
720                schema_version: raw.schema_version,
721                format: raw.format.into(),
722                file: raw.file.into(),
723                files: raw.files,
724                uncompressed_bytes: raw.uncompressed_bytes,
725                compressed_bytes: raw.compressed_bytes,
726            },
727            isolated_build: parent.metadata.isolated_build,
728            instrumented_build_cache: None,
729            timings: None,
730            // A run made from another, like a merge: its fingerprint is the
731            // parent's, checked against the parent's command.
732            merged: Some(true),
733            parents: Some(vec![parent.id.clone()]),
734            source_roots: parent.metadata.source_roots.clone(),
735            copy: None,
736            merged_across: parent.metadata.merged_across.clone(),
737            recorded_by: parent.metadata.recorded_by.clone(),
738            companions: None,
739        };
740        publish_run(request.root, &metadata, &archive_path)
741            .map_err(|error| format!("Could not publish the imported run: {error}"))?;
742        finalize_published_run(request.root, request.run_id)
743            .map_err(|error| format!("Could not finalize the imported run: {error}"))?;
744        Ok(())
745    })();
746    let _ = remove_stored_tree_deferred(request.root, &work);
747    let release = lock
748        .release()
749        .map_err(|error| format!("Could not release the import lock: {error}"));
750    result?;
751    release?;
752    Ok(ImportOutcome {
753        run_id: request.run_id.into(),
754        parent: parent.id,
755        lines_run,
756        credited,
757        added_files,
758        skipped,
759    })
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765
766    #[test]
767    fn lcov_records_merge_by_file_and_name_function_starts() {
768        let files = parse_lcov(
769            "TN:\nSF:/app/src/lib.rs\nFN:3,grade\nFNDA:2,grade\nFN:9,9,unused\nFNDA:0,unused\nDA:3,2\nDA:4,0\nDA:5,1,abc\nend_of_record\nTN:other\nSF:/app/src/lib.rs\nDA:4,3\nend_of_record\n",
770        )
771        .unwrap();
772        let file = &files["/app/src/lib.rs"];
773        assert_eq!(file.lines, BTreeMap::from([(3, 2), (4, 3), (5, 1)]));
774        assert_eq!(file.functions, BTreeMap::from([(3, 2), (9, 0)]));
775    }
776
777    #[test]
778    fn what_is_not_lcov_is_said_to_be() {
779        assert!(parse_lcov("hello\n").unwrap_err().contains("not an LCOV"));
780        assert!(
781            parse_lcov("DA:1,1\n")
782                .unwrap_err()
783                .contains("before any SF")
784        );
785        assert!(
786            parse_lcov("SF:a\nDA:x,1\n")
787                .unwrap_err()
788                .contains("not a number")
789        );
790    }
791
792    #[test]
793    fn recorded_paths_find_the_one_project_file_they_name() {
794        let root = Path::new("/work/project");
795        let measured = BTreeSet::from([
796            "src/lib.rs".to_owned(),
797            "a/util.rs".to_owned(),
798            "b/util.rs".to_owned(),
799            "crates/x/src/util.rs".to_owned(),
800        ]);
801        let path = |recorded| project_path(root, root, recorded, &measured);
802        assert_eq!(path("/work/project/src/lib.rs").unwrap(), "src/lib.rs");
803        assert_eq!(path("src/lib.rs").unwrap(), "src/lib.rs");
804        assert_eq!(path("./src/lib.rs").unwrap(), "src/lib.rs");
805        // Recorded in a container that mounted the project at /app.
806        assert_eq!(path("/app/src/lib.rs").unwrap(), "src/lib.rs");
807        assert_eq!(path("C:\\build\\src\\lib.rs").unwrap(), "src/lib.rs");
808        // The longest match where it is the only one that long.
809        assert_eq!(
810            path("/app/crates/x/src/util.rs").unwrap(),
811            "crates/x/src/util.rs"
812        );
813        assert!(path("/app/util.rs").is_err());
814        assert!(
815            path("/elsewhere/lib/other.rs")
816                .unwrap_err()
817                .contains("outside")
818        );
819        // A suffix that is not a whole path segment is no match.
820        assert!(path("/app/xsrc/lib.rs").is_err());
821    }
822
823    #[test]
824    fn llvm_profiles_are_known_by_their_magic() {
825        let raw = [0x81, b'r', b'f', b'o', b'r', b'p', b'l', 0xff, 0];
826        let indexed = [0x81, b'i', b'f', b'o', b'r', b'p', b'l', 0xff];
827        assert!(is_llvm_profile(&raw));
828        assert!(is_llvm_profile(&indexed));
829        assert!(!is_llvm_profile(b"SF:a\nDA:1,1\n"));
830    }
831}