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