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