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