1use std::{
4 collections::{BTreeMap, BTreeSet},
5 fs,
6 path::{Component, Path, PathBuf},
7 process::Command,
8};
9
10use ra_ap_syntax::{
11 AstNode, AstToken, Edition, SourceFile, SyntaxKind,
12 ast::{self, HasAttrs, HasModuleItem, HasName},
13};
14use serde::Deserialize;
15use sha2::{Digest, Sha256};
16
17use crate::{
18 coverage_report::CoverageManifest,
19 rust_instrumenter::{instrument_rust_file_numbered, test_code_path, test_item},
20 rust_runtime::render_rust_region_runtime,
21};
22
23const RUST_APPLICATION_SOURCE: &str = "Rust application source";
24const RUST_TEST_CODE: &str = "test, benchmark or example code";
27pub const RUST_NOT_COMPILED: &str = "not compiled by this build";
30
31#[derive(Debug, Clone, PartialEq)]
32pub struct PreparedRustProject {
33 pub workspace_root: PathBuf,
34 pub target_directory: PathBuf,
35 pub source_files: Vec<String>,
36 pub crate_roots: Vec<String>,
37 pub runtime_module: String,
38 pub manifest: CoverageManifest,
39 pub probes: crate::rust_instrumenter::RustProbeLayout,
41 pub preparation: RustPreparationTimings,
42}
43
44#[derive(Debug, Clone, Default, PartialEq)]
47pub struct RustPreparationTimings {
48 pub metadata_ms: f64,
49 pub discovery_ms: f64,
50 pub instrument_ms: f64,
51 pub runtime_ms: f64,
52}
53
54#[derive(Debug)]
55pub enum RustProjectError {
56 Io { path: PathBuf, reason: String },
57 MetadataLaunch(String),
58 MetadataFailed(String),
59 MetadataJson(String),
60 UnsafePath(String),
61 NoWorkspacePackages,
62 NoSourceFiles,
63 Instrument { file: String, reason: String },
64 DuplicateObligation(String),
65 Runtime(String),
66 SourceRoots(String),
67}
68
69impl std::fmt::Display for RustProjectError {
70 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 match self {
72 Self::Io { path, reason } => write!(formatter, "{}: {reason}", path.display()),
73 Self::MetadataLaunch(reason) => {
74 write!(formatter, "could not launch cargo metadata: {reason}")
75 }
76 Self::MetadataFailed(reason) => write!(formatter, "cargo metadata failed: {reason}"),
77 Self::MetadataJson(reason) => write!(formatter, "invalid cargo metadata: {reason}"),
78 Self::UnsafePath(path) => {
79 write!(formatter, "Cargo reported an unsafe workspace path: {path}")
80 }
81 Self::NoWorkspacePackages => {
82 write!(formatter, "Cargo metadata reported no workspace packages")
83 }
84 Self::NoSourceFiles => write!(
85 formatter,
86 "Cargo workspace contains no owned Rust source files"
87 ),
88 Self::Instrument { file, reason } => {
89 write!(formatter, "could not instrument {file}: {reason}")
90 }
91 Self::DuplicateObligation(id) => {
92 write!(formatter, "duplicate Rust obligation ID: {id}")
93 }
94 Self::Runtime(reason) => write!(formatter, "could not generate Rust runtime: {reason}"),
95 Self::SourceRoots(reason) => write!(formatter, "{reason}"),
96 }
97 }
98}
99
100impl std::error::Error for RustProjectError {}
101
102#[derive(Deserialize)]
103struct CargoMetadata {
104 packages: Vec<CargoPackage>,
105 workspace_members: Vec<String>,
106 workspace_root: PathBuf,
107 target_directory: PathBuf,
108}
109
110#[derive(Deserialize)]
111struct CargoPackage {
112 id: String,
113 manifest_path: PathBuf,
114 targets: Vec<CargoTarget>,
115}
116
117#[derive(Deserialize)]
118struct CargoTarget {
119 kind: Vec<String>,
120 src_path: PathBuf,
121}
122
123fn canonical_directory(path: &Path) -> Result<PathBuf, RustProjectError> {
124 fs::canonicalize(path).map_err(|error| RustProjectError::Io {
125 path: path.to_owned(),
126 reason: error.to_string(),
127 })
128}
129
130fn confined_relative(root: &Path, path: &Path) -> Result<String, RustProjectError> {
131 let relative = path
132 .strip_prefix(root)
133 .map_err(|_| RustProjectError::UnsafePath(path.display().to_string()))?;
134 if relative.as_os_str().is_empty()
135 || relative
136 .components()
137 .any(|component| !matches!(component, Component::Normal(_)))
138 {
139 return Err(RustProjectError::UnsafePath(path.display().to_string()));
140 }
141 Ok(relative.to_string_lossy().replace('\\', "/"))
142}
143
144fn cargo_metadata(root: &Path) -> Result<CargoMetadata, RustProjectError> {
145 let target_directory = root.join(".supercov/rust-target");
146 let output = Command::new("cargo")
147 .args(["metadata", "--format-version=1", "--no-deps"])
148 .current_dir(root)
149 .env("CARGO_TARGET_DIR", &target_directory)
150 .output()
151 .map_err(|error| RustProjectError::MetadataLaunch(error.to_string()))?;
152 if !output.status.success() {
153 return Err(RustProjectError::MetadataFailed(
154 String::from_utf8_lossy(&output.stderr).trim().to_owned(),
155 ));
156 }
157 serde_json::from_slice(&output.stdout)
158 .map_err(|error| RustProjectError::MetadataJson(error.to_string()))
159}
160
161pub fn compiled_source_files(
173 workspace: &Path,
174 target_directory: &Path,
175) -> Option<BTreeSet<String>> {
176 let mut compiled = BTreeSet::new();
177 let mut depinfo_files = 0;
178 let mut directories = vec![target_directory.to_path_buf()];
179 while let Some(directory) = directories.pop() {
180 let Ok(entries) = fs::read_dir(&directory) else {
181 continue;
182 };
183 for entry in entries.flatten() {
184 let path = entry.path();
185 match entry.file_type() {
186 Ok(file_type) if file_type.is_dir() => directories.push(path),
187 Ok(file_type) if file_type.is_file() => {
188 if path.extension().is_some_and(|extension| extension == "d")
189 && let Ok(text) = fs::read_to_string(&path)
190 {
191 depinfo_files += 1;
192 collect_depinfo_sources(&text, workspace, &mut compiled);
193 }
194 }
195 _ => {}
196 }
197 }
198 }
199 (depinfo_files > 0).then_some(compiled)
200}
201
202fn collect_depinfo_sources(text: &str, workspace: &Path, compiled: &mut BTreeSet<String>) {
206 for line in text.lines() {
207 let Some((_, prerequisites)) = line.split_once(": ") else {
210 continue;
211 };
212 let mut current = String::new();
213 let mut characters = prerequisites.chars().peekable();
214 while let Some(character) = characters.next() {
215 match character {
216 '\\' if characters.peek() == Some(&' ') => {
217 characters.next();
218 current.push(' ');
219 }
220 ' ' => {
221 push_workspace_source(¤t, workspace, compiled);
222 current.clear();
223 }
224 _ => current.push(character),
225 }
226 }
227 push_workspace_source(¤t, workspace, compiled);
228 }
229}
230
231fn push_workspace_source(path: &str, workspace: &Path, compiled: &mut BTreeSet<String>) {
232 let path = path.trim();
233 if path.is_empty() || !path.ends_with(".rs") {
234 return;
235 }
236 let candidate = Path::new(path);
240 let relative = if candidate.is_absolute() {
241 let Ok(relative) = candidate.strip_prefix(workspace) else {
242 return;
243 };
244 relative
245 } else {
246 candidate
247 };
248 if let Some(text) = relative.to_str() {
249 compiled.insert(text.replace('\\', "/"));
250 }
251}
252
253fn resolve_module_tree(
270 workspace: &Path,
271 roots: &BTreeSet<PathBuf>,
272 test_roots: &BTreeSet<PathBuf>,
273 files: &mut BTreeSet<PathBuf>,
274 tests: &mut BTreeSet<PathBuf>,
275 owners: &mut BTreeMap<PathBuf, BTreeSet<PathBuf>>,
276) -> Result<(), RustProjectError> {
277 let canonical_workspace = canonical_directory(workspace)?;
278 let (mut pending, mut pending_tests): (Vec<_>, Vec<_>) = roots
280 .iter()
281 .map(|root| (root.clone(), owner_directory(root), root.clone()))
282 .partition(|(root, _, _)| !test_roots.contains(root));
283 let mut visited = BTreeSet::new();
284 loop {
285 let (file, directory, root, test) = match pending.pop() {
286 Some((file, directory, root)) => (file, directory, root, false),
287 None => match pending_tests.pop() {
288 Some((file, directory, root)) => (file, directory, root, true),
289 None => break,
290 },
291 };
292 let file = normalize(&file);
296 let directory = normalize(&directory);
297 if !file.starts_with(workspace) {
298 continue;
299 }
300 let Ok(metadata) = fs::symlink_metadata(&file) else {
301 continue;
302 };
303 let file = if metadata.file_type().is_symlink() {
309 let target = fs::canonicalize(&file).map_err(|error| RustProjectError::Io {
310 path: file.clone(),
311 reason: error.to_string(),
312 })?;
313 if !target.starts_with(&canonical_workspace) || !target.is_file() {
314 return Err(RustProjectError::UnsafePath(file.display().to_string()));
315 }
316 target
317 } else if metadata.is_file() {
318 file.clone()
319 } else {
320 continue;
321 };
322 if files.insert(file.clone()) && test {
325 tests.insert(file.clone());
326 }
327 if !visited.insert((file.clone(), root.clone())) {
328 continue;
329 }
330 owners.entry(file.clone()).or_default().insert(root.clone());
331 let source = fs::read_to_string(&file).map_err(|error| RustProjectError::Io {
332 path: file.clone(),
333 reason: error.to_string(),
334 })?;
335 let parsed = SourceFile::parse(&source, Edition::CURRENT).tree();
336 let mut declared = Vec::new();
337 collect_module_declarations(
338 parsed.items(),
339 &file,
340 &directory,
341 false,
342 false,
343 &mut declared,
344 );
345 for (child, owner, gated) in declared {
346 if test || gated {
347 pending_tests.push((child, owner, root.clone()));
348 } else {
349 pending.push((child, owner, root.clone()));
350 }
351 }
352 }
353 Ok(())
354}
355
356fn normalize(path: &Path) -> PathBuf {
358 let mut normalized = PathBuf::new();
359 for component in path.components() {
360 match component {
361 Component::ParentDir => {
362 normalized.pop();
363 }
364 Component::CurDir => {}
365 other => normalized.push(other.as_os_str()),
366 }
367 }
368 normalized
369}
370
371fn owner_directory(file: &Path) -> PathBuf {
372 file.parent().map_or_else(PathBuf::new, Path::to_path_buf)
373}
374
375fn collect_module_declarations(
381 items: impl Iterator<Item = ast::Item>,
382 file: &Path,
383 directory: &Path,
384 inline: bool,
385 gated: bool,
386 pending: &mut Vec<(PathBuf, PathBuf, bool)>,
387) {
388 for item in items {
389 match item {
390 ast::Item::Module(module) => {
391 let Some(name) = module.name() else {
392 continue;
393 };
394 let gated = gated || test_item(module.syntax());
395 let name = name.text().to_string();
396 let path_attribute = module.attrs().find_map(|attr| {
397 let is_path = attr
398 .path()
399 .is_some_and(|path| path.syntax().text() == "path");
400 is_path.then(|| string_literal(attr.syntax())).flatten()
401 });
402 if let Some(list) = module.item_list() {
403 let nested = directory.join(&name);
404 collect_module_declarations(list.items(), file, &nested, true, gated, pending);
405 } else if let Some(path) = path_attribute {
406 let base = if inline {
410 directory.to_path_buf()
411 } else {
412 owner_directory(file)
413 };
414 let target = base.join(path);
415 let owner = owner_directory(&target);
416 pending.push((target, owner, gated));
417 } else {
418 let children = directory.join(&name);
421 pending.push((
422 directory.join(format!("{name}.rs")),
423 children.clone(),
424 gated,
425 ));
426 pending.push((children.join("mod.rs"), children, gated));
427 }
428 }
429 ast::Item::MacroCall(call) => {
430 let is_include = call.path().is_some_and(|path| {
431 matches!(
432 path.syntax().text().to_string().as_str(),
433 "include" | "std::include" | "core::include" | "::std::include"
434 )
435 });
436 if !is_include {
437 for name in token_tree_modules(call.syntax()) {
443 let children = directory.join(&name);
444 pending.push((
445 directory.join(format!("{name}.rs")),
446 children.clone(),
447 gated,
448 ));
449 pending.push((children.join("mod.rs"), children, gated));
450 }
451 continue;
452 }
453 let Some(literal) = string_literal(call.syntax()) else {
454 continue;
455 };
456 if !literal.ends_with(".rs") {
457 continue;
458 }
459 pending.push((
462 owner_directory(file).join(literal),
463 directory.to_path_buf(),
464 gated,
465 ));
466 }
467 _ => {}
468 }
469 }
470}
471
472fn token_tree_modules(node: &ra_ap_syntax::SyntaxNode) -> Vec<String> {
475 let mut names = Vec::new();
476 let mut tokens = node
477 .descendants_with_tokens()
478 .filter_map(|element| element.into_token())
479 .filter(|token| !token.kind().is_trivia())
480 .peekable();
481 while let Some(token) = tokens.next() {
482 if token.text() != "mod" {
483 continue;
484 }
485 let Some(name) = tokens
486 .peek()
487 .filter(|next| next.kind() == SyntaxKind::IDENT)
488 else {
489 continue;
490 };
491 let name = name.text().to_string();
492 tokens.next();
493 if tokens
496 .peek()
497 .is_some_and(|next| next.kind() == SyntaxKind::SEMICOLON)
498 {
499 tokens.next();
500 names.push(name);
501 }
502 }
503 names
504}
505
506fn string_literal(node: &ra_ap_syntax::SyntaxNode) -> Option<String> {
509 node.descendants_with_tokens().find_map(|element| {
510 let string = ast::String::cast(element.into_token()?)?;
511 string.value().ok().map(|value| value.into_owned())
512 })
513}
514
515fn proc_macro_crate_roots(
522 workspace: &Path,
523 packages: &[CargoPackage],
524) -> Result<BTreeSet<PathBuf>, RustProjectError> {
525 let mut roots = BTreeSet::new();
526 for package in packages {
527 for target in &package.targets {
528 if !target.kind.iter().any(|kind| kind == "proc-macro") {
529 continue;
530 }
531 let root =
532 fs::canonicalize(&target.src_path).map_err(|error| RustProjectError::Io {
533 path: target.src_path.clone(),
534 reason: error.to_string(),
535 })?;
536 if confined_relative(workspace, &root).is_ok() {
537 roots.insert(root);
538 }
539 }
540 }
541 Ok(roots)
542}
543
544fn crate_roots(
545 workspace: &Path,
546 packages: &[CargoPackage],
547) -> Result<BTreeSet<PathBuf>, RustProjectError> {
548 let mut roots = BTreeSet::new();
549 for package in packages {
550 let directory = package.manifest_path.parent().ok_or_else(|| {
551 RustProjectError::UnsafePath(package.manifest_path.display().to_string())
552 })?;
553 let directory = canonical_directory(directory)?;
554 confined_relative(workspace, &directory).or_else(|error| {
555 (directory == workspace)
556 .then_some(String::new())
557 .ok_or(error)
558 })?;
559 for target in &package.targets {
560 if target.kind.iter().any(|kind| kind == "custom-build") {
561 continue;
562 }
563 let root =
564 fs::canonicalize(&target.src_path).map_err(|error| RustProjectError::Io {
565 path: target.src_path.clone(),
566 reason: error.to_string(),
567 })?;
568 confined_relative(workspace, &root)?;
569 roots.insert(root);
570 }
571 }
572 Ok(roots)
573}
574
575fn test_target_roots(packages: &[CargoPackage]) -> BTreeSet<PathBuf> {
578 packages
579 .iter()
580 .flat_map(|package| &package.targets)
581 .filter(|target| {
582 target
583 .kind
584 .iter()
585 .any(|kind| matches!(kind.as_str(), "test" | "bench" | "example"))
586 })
587 .filter_map(|target| fs::canonicalize(&target.src_path).ok())
588 .collect()
589}
590
591fn runtime_module_name(sources: &BTreeMap<String, String>) -> String {
592 let mut suffix = 0_usize;
593 loop {
594 let candidate = if suffix == 0 {
595 "__supercov_runtime_v1".to_owned()
596 } else {
597 format!("__supercov_runtime_v1_{suffix}")
598 };
599 if sources.values().all(|source| !source.contains(&candidate)) {
600 return candidate;
601 }
602 suffix += 1;
603 }
604}
605
606fn decline_proc_macro_obligations(
616 workspace: &Path,
617 proc_macro_roots: &BTreeSet<PathBuf>,
618 manifest: &mut CoverageManifest,
619) -> Result<(), RustProjectError> {
620 if proc_macro_roots.is_empty() {
621 return Ok(());
622 }
623 let mut reached = BTreeSet::new();
624 resolve_module_tree(
625 workspace,
626 proc_macro_roots,
627 &BTreeSet::new(),
628 &mut reached,
629 &mut BTreeSet::new(),
630 &mut BTreeMap::new(),
631 )?;
632 let mut files = BTreeSet::new();
633 for path in reached {
634 if let Ok(relative) = confined_relative(workspace, &path) {
635 files.insert(relative);
636 }
637 }
638 if files.is_empty() {
639 return Ok(());
640 }
641 let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
642 unmeasured.extend(
643 manifest
644 .points
645 .iter()
646 .filter(|point| files.contains(&point.file))
647 .map(|point| point.id.clone()),
648 );
649 unmeasured.extend(
650 manifest
651 .decisions
652 .iter()
653 .filter(|decision| files.contains(&decision.file))
654 .map(|decision| decision.id.clone()),
655 );
656 unmeasured.extend(
657 manifest
658 .branches
659 .iter()
660 .filter(|branch| files.contains(&branch.file))
661 .map(|branch| branch.id.clone()),
662 );
663 manifest.unmeasured = unmeasured.into_iter().collect();
664 manifest.limitations.retain(|limitation| {
666 limitation
667 .get("file")
668 .and_then(|file| file.as_str())
669 .is_none_or(|file| !files.contains(file))
670 });
671 for file in files {
672 manifest.limitations.push(serde_json::json!({
673 "id": format!("rust-proc-macro-runs-in-the-compiler#{file}"),
674 "kind": "source-scope",
675 "file": file,
676 "line": 1,
677 "column": 0,
678 "source": "",
679 "blocking": false,
680 "reason": "This crate compiles to a compiler plugin: rustc loads it and runs it while building the crate under test, so no test process executes it"
681 }));
682 }
683 Ok(())
684}
685
686pub fn manifest_token(manifest: &CoverageManifest) -> String {
687 let mut ids = manifest
688 .points
689 .iter()
690 .map(|point| point.id.as_str())
691 .chain(
692 manifest
693 .decisions
694 .iter()
695 .map(|decision| decision.id.as_str()),
696 )
697 .chain(manifest.branches.iter().flat_map(|branch| {
698 branch
699 .alternatives
700 .iter()
701 .map(|alternative| alternative.id.as_str())
702 }))
703 .collect::<Vec<_>>();
704 ids.sort_unstable();
705 ids.dedup();
706 let mut hasher = Sha256::new();
707 for id in ids {
708 hasher.update(id.as_bytes());
709 hasher.update(b"\n");
710 }
711 hex(&hasher.finalize()[..6])
712}
713
714fn crate_key(token: &str, path: &str) -> String {
719 format!("{token}{}", hex(&Sha256::digest(path.as_bytes())[..6]))
720}
721
722fn hex(bytes: &[u8]) -> String {
723 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
724}
725
726fn merge_manifest(
727 destination: &mut CoverageManifest,
728 mut source: CoverageManifest,
729) -> Result<(), RustProjectError> {
730 let mut ids = destination
731 .points
732 .iter()
733 .map(|point| point.id.as_str())
734 .chain(
735 destination
736 .decisions
737 .iter()
738 .map(|decision| decision.id.as_str()),
739 )
740 .chain(destination.branches.iter().map(|branch| branch.id.as_str()))
741 .collect::<BTreeSet<_>>();
742 for id in source
743 .points
744 .iter()
745 .map(|point| point.id.as_str())
746 .chain(source.decisions.iter().map(|decision| decision.id.as_str()))
747 .chain(source.branches.iter().map(|branch| branch.id.as_str()))
748 {
749 if !ids.insert(id) {
750 return Err(RustProjectError::DuplicateObligation(id.into()));
751 }
752 }
753 destination.points.append(&mut source.points);
754 destination
755 .assertion_sites
756 .append(&mut source.assertion_sites);
757 destination.decisions.append(&mut source.decisions);
758 destination.branches.append(&mut source.branches);
759 destination.unmeasured.append(&mut source.unmeasured);
762 let site = |value: &serde_json::Value| {
766 (
767 value
768 .get("id")
769 .and_then(|id| id.as_str())
770 .map(str::to_owned),
771 value
772 .get("file")
773 .and_then(|file| file.as_str())
774 .map(str::to_owned),
775 value.get("line").and_then(serde_json::Value::as_u64),
776 value.get("column").and_then(serde_json::Value::as_u64),
777 )
778 };
779 for limitation in source.limitations {
780 let key = site(&limitation);
781 if !destination
782 .limitations
783 .iter()
784 .any(|existing| site(existing) == key)
785 {
786 destination.limitations.push(limitation);
787 }
788 }
789 Ok(())
790}
791
792pub fn prepare_rust_project(
793 workspace: &Path,
794 roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
795) -> Result<PreparedRustProject, RustProjectError> {
796 prepare_rust_project_for(workspace, roots, None)
797}
798
799pub fn prepare_rust_project_for(
802 workspace: &Path,
803 roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
804 cfg: Option<&crate::rust_instrumenter::TargetCfg>,
805) -> Result<PreparedRustProject, RustProjectError> {
806 let source_roots = roots;
808 let elapsed = |started: std::time::Instant| started.elapsed().as_secs_f64() * 1000.0;
809 let mut preparation = RustPreparationTimings::default();
810 let workspace = canonical_directory(workspace)?;
811 let started = std::time::Instant::now();
812 let metadata = cargo_metadata(&workspace)?;
813 preparation.metadata_ms = elapsed(started);
814 let metadata_root = canonical_directory(&metadata.workspace_root)?;
815 if metadata_root != workspace {
816 return Err(RustProjectError::UnsafePath(
817 metadata.workspace_root.display().to_string(),
818 ));
819 }
820 let members = metadata
821 .workspace_members
822 .into_iter()
823 .collect::<BTreeSet<_>>();
824 let packages = metadata
825 .packages
826 .into_iter()
827 .filter(|package| members.contains(&package.id))
828 .collect::<Vec<_>>();
829 if packages.is_empty() {
830 return Err(RustProjectError::NoWorkspacePackages);
831 }
832
833 let started = std::time::Instant::now();
834 let roots = crate_roots(&workspace, &packages)?;
835 let proc_macro_roots = proc_macro_crate_roots(&workspace, &packages)?;
836 let mut files = BTreeSet::new();
837 let mut tests = BTreeSet::new();
838 let mut owners = BTreeMap::new();
839 resolve_module_tree(
840 &workspace,
841 &roots,
842 &test_target_roots(&packages),
843 &mut files,
844 &mut tests,
845 &mut owners,
846 )?;
847 let owners = owners
849 .into_iter()
850 .filter_map(|(path, roots)| {
851 confined_relative(&workspace, &path)
852 .ok()
853 .map(|relative| (relative, roots))
854 })
855 .collect::<BTreeMap<_, _>>();
856 let tests = tests
858 .iter()
859 .map(|path| confined_relative(&workspace, path))
860 .collect::<Result<BTreeSet<_>, _>>()?;
861 if files.is_empty() {
862 return Err(RustProjectError::NoSourceFiles);
863 }
864
865 let mut sources = BTreeMap::new();
866 for path in files {
867 let relative = confined_relative(&workspace, &path)?;
868 let source = fs::read_to_string(&path).map_err(|error| RustProjectError::Io {
869 path: path.clone(),
870 reason: error.to_string(),
871 })?;
872 sources.insert(relative, source);
873 }
874 preparation.discovery_ms = elapsed(started);
875 let started = std::time::Instant::now();
876 let runtime_module = runtime_module_name(&sources);
877 let mut outside = Vec::new();
882 if let Some(source_roots) = source_roots {
883 let mut kept = sources.keys().cloned().collect::<Vec<_>>();
884 source_roots.narrow(&mut kept, &mut outside, String::as_str);
885 let kept = kept.into_iter().collect::<BTreeSet<_>>();
886 sources.retain(|relative, _| kept.contains(relative));
887 source_roots
888 .refuse_if_empty(sources.len(), "Rust")
889 .map_err(RustProjectError::SourceRoots)?;
890 }
891 let runtime_path = format!("crate::{runtime_module}");
892 let mut manifest = CoverageManifest {
893 unmeasured: Vec::new(),
894 decisions: Vec::new(),
895 points: Vec::new(),
896 branches: Vec::new(),
897 limitations: Vec::new(),
898 scope: None,
899 assertion_sites: Vec::new(),
900 };
901 let mut probes = crate::rust_instrumenter::RustProbeLayout::default();
904 for (relative, source) in &sources {
905 let test_file = tests.contains(relative) || test_code_path(relative);
906 let transformed = instrument_rust_file_numbered(
907 relative,
908 source,
909 &runtime_path,
910 test_file,
911 cfg,
912 &mut probes,
913 )
914 .map_err(|error| RustProjectError::Instrument {
915 file: relative.clone(),
916 reason: error.to_string(),
917 })?;
918 merge_manifest(&mut manifest, transformed.manifest)?;
919 fs::write(workspace.join(relative), transformed.code).map_err(|error| {
920 RustProjectError::Io {
921 path: workspace.join(relative),
922 reason: error.to_string(),
923 }
924 })?;
925 }
926
927 preparation.instrument_ms = elapsed(started);
928 let started = std::time::Instant::now();
929 decline_proc_macro_obligations(&workspace, &proc_macro_roots, &mut manifest)?;
930
931 let token = manifest_token(&manifest);
932 probes.token = token.clone();
933 let mut crate_roots = Vec::new();
934 let mut crate_files = Vec::new();
935 for root in roots {
936 let relative = confined_relative(&workspace, &root)?;
937 let crate_cfgs = probes
939 .cfgs
940 .iter()
941 .enumerate()
942 .filter(|(_, gate)| {
943 owners
944 .get(&gate.file)
945 .is_some_and(|roots| roots.contains(&root))
946 })
947 .map(|(ordinal, _)| ordinal)
948 .collect::<Vec<_>>();
949 let key = crate_key(&token, &relative);
950 let runtime =
951 render_rust_region_runtime(&runtime_module, &token, &key, &probes, &crate_cfgs)
952 .map_err(RustProjectError::Runtime)?;
953 crate_files.push((
954 key,
955 owners
956 .iter()
957 .filter(|(_, roots)| roots.contains(&root))
958 .map(|(file, _)| file.clone())
959 .collect::<Vec<_>>(),
960 ));
961 let mut source = fs::read_to_string(&root).map_err(|error| RustProjectError::Io {
962 path: root.clone(),
963 reason: error.to_string(),
964 })?;
965 source.push('\n');
966 source.push_str(&runtime);
967 fs::write(&root, source).map_err(|error| RustProjectError::Io {
968 path: root,
969 reason: error.to_string(),
970 })?;
971 crate_roots.push(relative);
972 }
973 preparation.runtime_ms = elapsed(started);
974 probes.crates = crate_files.into_iter().collect();
975
976 manifest
977 .points
978 .sort_by(|left, right| left.id.cmp(&right.id));
979 manifest
980 .assertion_sites
981 .sort_by(|left, right| left.id.cmp(&right.id));
982 manifest
983 .decisions
984 .sort_by(|left, right| left.id.cmp(&right.id));
985 manifest
986 .branches
987 .sort_by(|left, right| left.id.cmp(&right.id));
988 manifest.limitations.sort_by(|left, right| {
989 left.get("id")
990 .and_then(|value| value.as_str())
991 .cmp(&right.get("id").and_then(|value| value.as_str()))
992 });
993 let target_directory = metadata.target_directory;
994 let target_directory = if target_directory.is_absolute() {
995 target_directory
996 } else {
997 workspace.join(target_directory)
998 };
999 if !target_directory.starts_with(&workspace) {
1000 return Err(RustProjectError::UnsafePath(
1001 target_directory.display().to_string(),
1002 ));
1003 }
1004 let kept = sources.keys().cloned().collect::<Vec<_>>();
1005 let scope = match source_roots {
1006 Some(source_roots) => source_roots
1007 .scope(&kept, &outside)
1008 .map_err(|error| RustProjectError::SourceRoots(error.to_string()))?,
1009 None => {
1012 let (test_code, measured) = kept
1013 .into_iter()
1014 .partition::<Vec<_>, _>(|file| tests.contains(file) || test_code_path(file));
1015 crate::source_discovery::automatic_scope(
1016 measured
1017 .into_iter()
1018 .map(|file| (file, RUST_APPLICATION_SOURCE.to_owned())),
1019 test_code
1020 .into_iter()
1021 .map(|file| (file, RUST_TEST_CODE.to_owned())),
1022 )
1023 }
1024 };
1025 manifest.scope = Some(
1026 serde_json::to_value(scope)
1027 .map_err(|error| RustProjectError::SourceRoots(error.to_string()))?,
1028 );
1029 Ok(PreparedRustProject {
1030 workspace_root: workspace,
1031 target_directory,
1032 source_files: sources.into_keys().collect(),
1033 crate_roots,
1034 runtime_module,
1035 manifest,
1036 probes,
1037 preparation,
1038 })
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043 use std::{
1044 process::Command,
1045 sync::atomic::{AtomicU64, Ordering},
1046 time::{SystemTime, UNIX_EPOCH},
1047 };
1048
1049 use super::*;
1050
1051 fn fixture() -> PathBuf {
1052 static UNIQUE: AtomicU64 = AtomicU64::new(0);
1058 let nonce = SystemTime::now()
1059 .duration_since(UNIX_EPOCH)
1060 .unwrap()
1061 .as_nanos();
1062 let root = std::env::temp_dir().join(format!(
1063 "supercov-rust-project-{}-{nonce}-{}",
1064 std::process::id(),
1065 UNIQUE.fetch_add(1, Ordering::Relaxed)
1066 ));
1067 fs::create_dir(&root).unwrap();
1068 fs::create_dir(root.join("src")).unwrap();
1069 fs::create_dir(root.join("tests")).unwrap();
1070 fs::write(
1071 root.join("Cargo.toml"),
1072 "[package]\nname='rust-project-fixture'\nversion='0.0.0'\nedition='2024'\n",
1073 )
1074 .unwrap();
1075 fs::write(
1076 root.join("src/lib.rs"),
1077 r#"pub fn choose(first: bool, second: bool) -> i32 {
1078 if first && second { 7 } else { 3 }
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 #[test]
1084 fn unit_choice() {
1085 assert_eq!(super::choose(true, true), 7);
1086 }
1087}
1088"#,
1089 )
1090 .unwrap();
1091 fs::write(
1092 root.join("tests/integration.rs"),
1093 r#"#[test]
1094fn integration_choice() {
1095 assert_eq!(rust_project_fixture::choose(false, true), 3);
1096}
1097"#,
1098 )
1099 .unwrap();
1100 root
1101 }
1102
1103 #[test]
1104 fn explicit_roots_narrow_what_rust_instruments() {
1105 let root = fixture();
1110 fs::write(
1111 root.join("Cargo.toml"),
1112 "[package]\nname='rust_project_fixture'\nversion='0.0.0'\nedition='2024'\n",
1113 )
1114 .unwrap();
1115 fs::write(
1116 root.join("src/lib.rs"),
1117 "mod helper;\nmod vendored;\npub fn entry(flag: bool) -> i32 { if flag { helper::one() } else { vendored::two() } }\n",
1118 )
1119 .unwrap();
1120 fs::write(root.join("src/helper.rs"), "pub fn one() -> i32 { 1 }\n").unwrap();
1121 let vendored = "pub fn two() -> i32 { 2 }\n";
1122 fs::write(root.join("src/vendored.rs"), vendored).unwrap();
1123
1124 let roots = crate::source_discovery::ExplicitSourceRoots::resolve(
1125 &root,
1126 &["src/lib.rs".to_owned(), "src/helper.rs".to_owned()],
1127 )
1128 .unwrap();
1129 let prepared = prepare_rust_project(&root, Some(&roots)).unwrap();
1130 assert_eq!(prepared.source_files, ["src/helper.rs", "src/lib.rs"]);
1131 assert!(
1132 prepared
1133 .manifest
1134 .points
1135 .iter()
1136 .all(|point| point.file != "src/vendored.rs"),
1137 "a module outside the roots carries no obligations"
1138 );
1139 assert_eq!(
1140 fs::read_to_string(root.join("src/vendored.rs")).unwrap(),
1141 vendored,
1142 "and is left exactly as written"
1143 );
1144 let scope: crate::source_discovery::SourceScope =
1145 serde_json::from_value(prepared.manifest.scope.clone().expect("a scope")).unwrap();
1146 assert_eq!(
1147 scope.mode,
1148 crate::source_discovery::SourceScopeMode::Explicit
1149 );
1150 assert!(
1151 scope
1152 .entries
1153 .iter()
1154 .any(|entry| entry.file == "src/vendored.rs"
1155 && entry.reason == "outside explicit source roots"),
1156 "{scope:?}"
1157 );
1158 fs::remove_dir_all(root).ok();
1159 }
1160
1161 #[test]
1162 fn test_code_is_instrumented_for_its_assertions_alone() {
1163 let root = fixture();
1168 let library = fs::read_to_string(root.join("src/lib.rs")).unwrap();
1169 fs::write(
1170 root.join("src/lib.rs"),
1171 format!("#[cfg(test)]\nmod helpers;\n{library}"),
1172 )
1173 .unwrap();
1174 fs::write(
1175 root.join("src/helpers.rs"),
1176 "pub fn twice(value: i32) -> i32 {\n let doubled = value * 2;\n assert!(doubled >= value);\n doubled\n}\n",
1177 )
1178 .unwrap();
1179
1180 let prepared = prepare_rust_project(&root, None).unwrap();
1181 assert_eq!(
1182 prepared.source_files,
1183 ["src/helpers.rs", "src/lib.rs", "tests/integration.rs"]
1184 );
1185 let manifest = &prepared.manifest;
1186 let measured = manifest
1187 .points
1188 .iter()
1189 .map(|point| (point.file.as_str(), point.line))
1190 .chain(
1191 manifest
1192 .decisions
1193 .iter()
1194 .map(|decision| (decision.file.as_str(), decision.line)),
1195 )
1196 .chain(
1197 manifest
1198 .branches
1199 .iter()
1200 .map(|branch| (branch.file.as_str(), branch.line)),
1201 )
1202 .collect::<BTreeSet<_>>();
1203 assert!(
1205 measured
1206 .iter()
1207 .all(|(file, line)| *file == "src/lib.rs" && (3..=5).contains(line)),
1208 "{measured:?}"
1209 );
1210 assert!(!measured.is_empty());
1211 let sites = manifest
1212 .assertion_sites
1213 .iter()
1214 .map(|site| (site.file.as_str(), site.source.as_str()))
1215 .collect::<BTreeSet<_>>();
1216 assert_eq!(
1217 sites,
1218 BTreeSet::from([
1219 ("src/helpers.rs", "assert!(doubled >= value);"),
1220 ("src/lib.rs", "assert_eq!(super::choose(true, true), 7);"),
1221 (
1222 "tests/integration.rs",
1223 "assert_eq!(rust_project_fixture::choose(false, true), 3);"
1224 ),
1225 ])
1226 );
1227 let helpers = fs::read_to_string(root.join("src/helpers.rs")).unwrap();
1229 assert!(helpers.contains("::assertion("), "{helpers}");
1230 assert!(!helpers.contains("::hit("), "{helpers}");
1231 fs::remove_dir_all(&root).ok();
1232 }
1233
1234 #[test]
1235 fn a_proc_macro_crates_own_code_is_declined() {
1236 let root = fixture();
1241 fs::write(
1242 root.join("Cargo.toml"),
1243 concat!(
1244 "[package]\nname='rust_project_fixture'\nversion='0.0.0'\nedition='2024'\n",
1245 "\n[lib]\nproc-macro=true\n",
1246 ),
1247 )
1248 .unwrap();
1249 fs::write(
1250 root.join("src/lib.rs"),
1251 "mod helper;\npub fn entry(flag: bool) -> i32 { if flag { helper::one() } else { 0 } }\n",
1252 )
1253 .unwrap();
1254 fs::write(root.join("src/helper.rs"), "pub fn one() -> i32 { 1 }\n").unwrap();
1255
1256 let prepared = prepare_rust_project(&root, None).unwrap();
1257 let declined = prepared.manifest.unmeasured.iter().collect::<BTreeSet<_>>();
1260 assert!(!declined.is_empty());
1261 for point in &prepared.manifest.points {
1262 let plugin = point.file == "src/lib.rs" || point.file == "src/helper.rs";
1263 assert_eq!(declined.contains(&point.id), plugin, "{}", point.file);
1264 }
1265 let reasons = prepared
1266 .manifest
1267 .limitations
1268 .iter()
1269 .filter_map(|limitation| limitation.get("id")?.as_str())
1270 .filter(|id| id.starts_with("rust-proc-macro-runs-in-the-compiler#"))
1271 .collect::<BTreeSet<_>>();
1272 assert_eq!(
1273 reasons,
1274 BTreeSet::from([
1275 "rust-proc-macro-runs-in-the-compiler#src/helper.rs",
1276 "rust-proc-macro-runs-in-the-compiler#src/lib.rs",
1277 ])
1278 );
1279 fs::remove_dir_all(&root).ok();
1280 }
1281
1282 #[test]
1283 fn modules_declared_inside_a_macro_are_instrumented() {
1284 let root = fixture();
1289 fs::write(
1290 root.join("src/lib.rs"),
1291 concat!(
1292 "macro_rules! select { ($($rest:tt)*) => { $($rest)* } }\n",
1293 "select! {\n",
1294 " #[cfg(target_endian = \"little\")]\n",
1295 " mod little;\n",
1296 " #[cfg(not(target_endian = \"little\"))]\n",
1297 " mod big;\n",
1298 " mod inline { pub fn here() -> i32 { 1 } }\n",
1299 "}\n",
1300 "pub fn value() -> i32 { inline::here() }\n",
1301 ),
1302 )
1303 .unwrap();
1304 fs::write(root.join("src/little.rs"), "pub fn v() -> i32 { 1 }\n").unwrap();
1305 fs::write(root.join("src/big.rs"), "pub fn v() -> i32 { 2 }\n").unwrap();
1306
1307 let prepared = prepare_rust_project(&root, None).unwrap();
1308 assert_eq!(
1310 prepared.source_files,
1311 [
1312 "src/big.rs",
1313 "src/lib.rs",
1314 "src/little.rs",
1315 "tests/integration.rs"
1316 ],
1317 "{:?}",
1318 prepared.source_files
1319 );
1320 fs::remove_dir_all(&root).ok();
1321 }
1322
1323 #[test]
1324 fn only_files_the_module_tree_reaches_are_instrumented() {
1325 let root = fixture();
1326 fs::create_dir_all(root.join("src/nested")).unwrap();
1327 fs::create_dir_all(root.join("src/deep/inner")).unwrap();
1328 fs::create_dir_all(root.join("runtime-assets")).unwrap();
1329 fs::write(
1330 root.join("src/lib.rs"),
1331 concat!(
1332 "mod util;\n",
1333 "mod nested;\n",
1334 "#[path = \"renamed_file.rs\"]\n",
1335 "mod renamed;\n",
1336 "mod deep;\n",
1337 "include!(\"included.rs\");\n",
1338 "pub const EMBEDDED: &str = include_str!(\"../runtime-assets/embedded.rs\");\n",
1339 "pub fn choose(first: bool, second: bool) -> i32 {\n",
1340 " if first && second { util::seven() } else { nested::three() }\n",
1341 "}\n",
1342 ),
1343 )
1344 .unwrap();
1345 fs::write(root.join("src/util.rs"), "pub fn seven() -> i32 { 7 }\n").unwrap();
1346 fs::write(
1347 root.join("src/nested/mod.rs"),
1348 "mod leaf;\npub fn three() -> i32 { leaf::three() }\n",
1349 )
1350 .unwrap();
1351 fs::write(
1352 root.join("src/nested/leaf.rs"),
1353 "pub fn three() -> i32 { 3 }\n",
1354 )
1355 .unwrap();
1356 fs::write(
1357 root.join("src/renamed_file.rs"),
1358 "pub fn renamed() -> i32 { 1 }\n",
1359 )
1360 .unwrap();
1361 fs::write(
1362 root.join("src/deep.rs"),
1363 "pub mod inner {\n mod block_child;\n pub fn deep() -> i32 { block_child::v() }\n}\n",
1364 )
1365 .unwrap();
1366 fs::write(
1367 root.join("src/deep/inner/block_child.rs"),
1368 "pub fn v() -> i32 { 9 }\n",
1369 )
1370 .unwrap();
1371 fs::write(
1372 root.join("src/included.rs"),
1373 "pub fn included() -> i32 { 2 }\n",
1374 )
1375 .unwrap();
1376 fs::write(
1378 root.join("tests/integration.rs"),
1379 concat!(
1380 "#[path = \"../src/util.rs\"]\n",
1381 "mod util;\n",
1382 "#[test]\n",
1383 "fn integration_choice() {\n",
1384 " assert_eq!(rust_project_fixture::choose(false, true), 3);\n",
1385 " assert_eq!(util::seven(), 7);\n",
1386 "}\n",
1387 ),
1388 )
1389 .unwrap();
1390 let embedded = "pub fn standalone() -> i32 { if true { 1 } else { 0 } }\n";
1393 fs::write(root.join("runtime-assets/embedded.rs"), embedded).unwrap();
1394 fs::write(
1395 root.join("src/orphan.rs"),
1396 "pub fn unreachable_module() {}\n",
1397 )
1398 .unwrap();
1399
1400 let prepared = prepare_rust_project(&root, None).unwrap();
1401 assert_eq!(
1402 prepared.source_files,
1403 [
1404 "src/deep.rs",
1405 "src/deep/inner/block_child.rs",
1406 "src/included.rs",
1407 "src/lib.rs",
1408 "src/nested/leaf.rs",
1409 "src/nested/mod.rs",
1410 "src/renamed_file.rs",
1411 "src/util.rs",
1412 "tests/integration.rs",
1413 ]
1414 );
1415 assert_eq!(
1416 fs::read_to_string(root.join("runtime-assets/embedded.rs")).unwrap(),
1417 embedded
1418 );
1419 assert!(
1420 !fs::read_to_string(root.join("src/orphan.rs"))
1421 .unwrap()
1422 .contains("__supercov")
1423 );
1424 assert!(
1425 fs::read_to_string(root.join("src/deep/inner/block_child.rs"))
1426 .unwrap()
1427 .contains("__supercov")
1428 );
1429 let build = Command::new("cargo")
1430 .args(["test", "--no-run"])
1431 .current_dir(&root)
1432 .env("CARGO_TARGET_DIR", &prepared.target_directory)
1433 .output()
1434 .unwrap();
1435 assert!(
1436 build.status.success(),
1437 "{}",
1438 String::from_utf8_lossy(&build.stderr)
1439 );
1440 fs::remove_dir_all(root).unwrap();
1441 }
1442
1443 #[cfg(unix)]
1444 #[test]
1445 fn a_module_shared_through_a_symlink_is_instrumented_once() {
1446 let root = fixture();
1447 fs::write(root.join("src/shared.rs"), "pub fn shared() -> i32 { 5 }\n").unwrap();
1448 std::os::unix::fs::symlink("../src/shared.rs", root.join("tests/shared.rs")).unwrap();
1449 fs::write(
1450 root.join("src/lib.rs"),
1451 concat!(
1452 "pub mod shared;\n",
1453 "pub fn choose(first: bool, second: bool) -> i32 {\n",
1454 " if first && second { 7 } else { shared::shared() }\n",
1455 "}\n",
1456 ),
1457 )
1458 .unwrap();
1459 fs::write(
1460 root.join("tests/integration.rs"),
1461 concat!(
1462 "mod shared;\n",
1463 "#[test]\n",
1464 "fn integration_choice() {\n",
1465 " assert_eq!(rust_project_fixture::choose(false, true), 5);\n",
1466 " assert_eq!(shared::shared(), 5);\n",
1467 "}\n",
1468 ),
1469 )
1470 .unwrap();
1471 let prepared = prepare_rust_project(&root, None).unwrap();
1472 let shared = prepared
1474 .source_files
1475 .iter()
1476 .filter(|file| file.ends_with("shared.rs"))
1477 .collect::<Vec<_>>();
1478 assert_eq!(shared, ["src/shared.rs"], "{:?}", prepared.source_files);
1479 let instrumented = fs::read_to_string(root.join("src/shared.rs")).unwrap();
1482 let function = prepared
1483 .manifest
1484 .points
1485 .iter()
1486 .find(|point| point.file == "src/shared.rs" && point.id.starts_with("rs:function:"))
1487 .expect("the shared function's probe");
1488 let ordinal = prepared
1489 .probes
1490 .hits
1491 .iter()
1492 .position(|id| id == &function.id)
1493 .unwrap();
1494 assert_eq!(
1495 instrumented.matches(&format!("::hit({ordinal});")).count(),
1496 1
1497 );
1498 let build = Command::new("cargo")
1499 .args(["test", "--no-run"])
1500 .current_dir(&root)
1501 .env("CARGO_TARGET_DIR", &prepared.target_directory)
1502 .output()
1503 .unwrap();
1504 assert!(
1505 build.status.success(),
1506 "{}",
1507 String::from_utf8_lossy(&build.stderr)
1508 );
1509 fs::remove_dir_all(root).unwrap();
1510 }
1511
1512 #[test]
1513 fn crate_keys_carry_the_manifest_token() {
1514 let root = fixture();
1515 let prepared = prepare_rust_project(&root, None).unwrap();
1516 let token = manifest_token(&prepared.manifest);
1517 assert_eq!(token.len(), 12);
1518 assert!(token.bytes().all(|byte| byte.is_ascii_hexdigit()));
1519 assert_eq!(token, manifest_token(&prepared.manifest));
1520 let key = crate_key(&token, "src/lib.rs");
1521 assert_eq!(key.len(), 24);
1522 assert!(key.starts_with(&token));
1523 assert_ne!(key, crate_key(&token, "tests/integration.rs"));
1524 for crate_root in &prepared.crate_roots {
1525 assert!(
1526 fs::read_to_string(root.join(crate_root))
1527 .unwrap()
1528 .contains(&crate_key(&token, crate_root))
1529 );
1530 }
1531 fs::remove_dir_all(root).unwrap();
1532 }
1533
1534 #[test]
1535 fn prepares_every_workspace_crate_root_and_compiles_without_manifest_changes() {
1536 let root = fixture();
1537 let manifest_before = fs::read(root.join("Cargo.toml")).unwrap();
1538 let prepared = prepare_rust_project(&root, None).unwrap();
1539 assert_eq!(
1540 prepared.source_files,
1541 ["src/lib.rs", "tests/integration.rs"]
1542 );
1543 assert_eq!(prepared.crate_roots, ["src/lib.rs", "tests/integration.rs"]);
1544 assert!(!prepared.manifest.points.is_empty());
1545 assert!(!prepared.manifest.decisions.is_empty());
1546 assert_eq!(fs::read(root.join("Cargo.toml")).unwrap(), manifest_before);
1547 for crate_root in &prepared.crate_roots {
1548 assert!(
1549 fs::read_to_string(root.join(crate_root))
1550 .unwrap()
1551 .contains(&format!("mod {}", prepared.runtime_module))
1552 );
1553 }
1554 let build = Command::new("cargo")
1555 .args(["test", "--no-run"])
1556 .current_dir(&root)
1557 .env("CARGO_TARGET_DIR", &prepared.target_directory)
1558 .output()
1559 .unwrap();
1560 assert!(
1561 build.status.success(),
1562 "{}",
1563 String::from_utf8_lossy(&build.stderr)
1564 );
1565 fs::remove_dir_all(root).unwrap();
1566 }
1567}