1use 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
38pub const IMPORTED_RUNNER: &str = "lines";
40pub const IMPORTED_KIND: &str = "imported";
42const BACKGROUND_RUNNER: &str = "background";
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Credit {
47 Test(String),
49 Background,
51}
52
53#[derive(Debug, Clone)]
54pub struct ImportRequest<'a> {
55 pub root: &'a Path,
56 pub run: Option<&'a str>,
58 pub evidence: &'a Path,
60 pub credit: Credit,
61 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 pub lines_run: usize,
73 pub credited: usize,
75 pub added_files: Vec<String>,
77 pub skipped: Vec<(String, String)>,
79}
80
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
83struct RecordedFile {
84 lines: BTreeMap<usize, u64>,
85 functions: BTreeMap<usize, u64>,
86}
87
88fn 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" => {
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 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" 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" 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
171fn 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
178fn 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 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 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 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
247fn 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
344pub 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 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 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 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 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 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 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 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 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}