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