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,
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}
31
32#[derive(Debug)]
33pub enum RustProjectError {
34 Io { path: PathBuf, reason: String },
35 MetadataLaunch(String),
36 MetadataFailed(String),
37 MetadataJson(String),
38 UnsafePath(String),
39 NoWorkspacePackages,
40 NoSourceFiles,
41 Instrument { file: String, reason: String },
42 DuplicateObligation(String),
43 Runtime(String),
44}
45
46impl std::fmt::Display for RustProjectError {
47 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 Self::Io { path, reason } => write!(formatter, "{}: {reason}", path.display()),
50 Self::MetadataLaunch(reason) => {
51 write!(formatter, "could not launch cargo metadata: {reason}")
52 }
53 Self::MetadataFailed(reason) => write!(formatter, "cargo metadata failed: {reason}"),
54 Self::MetadataJson(reason) => write!(formatter, "invalid cargo metadata: {reason}"),
55 Self::UnsafePath(path) => {
56 write!(formatter, "Cargo reported an unsafe workspace path: {path}")
57 }
58 Self::NoWorkspacePackages => {
59 write!(formatter, "Cargo metadata reported no workspace packages")
60 }
61 Self::NoSourceFiles => write!(
62 formatter,
63 "Cargo workspace contains no owned Rust source files"
64 ),
65 Self::Instrument { file, reason } => {
66 write!(formatter, "could not instrument {file}: {reason}")
67 }
68 Self::DuplicateObligation(id) => {
69 write!(formatter, "duplicate Rust obligation ID: {id}")
70 }
71 Self::Runtime(reason) => write!(formatter, "could not generate Rust runtime: {reason}"),
72 }
73 }
74}
75
76impl std::error::Error for RustProjectError {}
77
78#[derive(Deserialize)]
79struct CargoMetadata {
80 packages: Vec<CargoPackage>,
81 workspace_members: Vec<String>,
82 workspace_root: PathBuf,
83 target_directory: PathBuf,
84}
85
86#[derive(Deserialize)]
87struct CargoPackage {
88 id: String,
89 manifest_path: PathBuf,
90 targets: Vec<CargoTarget>,
91}
92
93#[derive(Deserialize)]
94struct CargoTarget {
95 kind: Vec<String>,
96 src_path: PathBuf,
97}
98
99fn canonical_directory(path: &Path) -> Result<PathBuf, RustProjectError> {
100 fs::canonicalize(path).map_err(|error| RustProjectError::Io {
101 path: path.to_owned(),
102 reason: error.to_string(),
103 })
104}
105
106fn confined_relative(root: &Path, path: &Path) -> Result<String, RustProjectError> {
107 let relative = path
108 .strip_prefix(root)
109 .map_err(|_| RustProjectError::UnsafePath(path.display().to_string()))?;
110 if relative.as_os_str().is_empty()
111 || relative
112 .components()
113 .any(|component| !matches!(component, Component::Normal(_)))
114 {
115 return Err(RustProjectError::UnsafePath(path.display().to_string()));
116 }
117 Ok(relative.to_string_lossy().replace('\\', "/"))
118}
119
120fn cargo_metadata(root: &Path) -> Result<CargoMetadata, RustProjectError> {
121 let target_directory = root.join(".supercov/rust-target");
122 let output = Command::new("cargo")
123 .args(["metadata", "--format-version=1", "--no-deps"])
124 .current_dir(root)
125 .env("CARGO_TARGET_DIR", &target_directory)
126 .output()
127 .map_err(|error| RustProjectError::MetadataLaunch(error.to_string()))?;
128 if !output.status.success() {
129 return Err(RustProjectError::MetadataFailed(
130 String::from_utf8_lossy(&output.stderr).trim().to_owned(),
131 ));
132 }
133 serde_json::from_slice(&output.stdout)
134 .map_err(|error| RustProjectError::MetadataJson(error.to_string()))
135}
136
137fn resolve_module_tree(
149 workspace: &Path,
150 roots: &BTreeSet<PathBuf>,
151 files: &mut BTreeSet<PathBuf>,
152) -> Result<(), RustProjectError> {
153 let mut pending = roots
155 .iter()
156 .map(|root| (root.clone(), owner_directory(root)))
157 .collect::<Vec<_>>();
158 while let Some((file, directory)) = pending.pop() {
159 if !file.starts_with(workspace) {
160 continue;
161 }
162 let Ok(metadata) = fs::symlink_metadata(&file) else {
163 continue;
164 };
165 if metadata.file_type().is_symlink() {
166 return Err(RustProjectError::UnsafePath(file.display().to_string()));
167 }
168 if !metadata.is_file() || !files.insert(file.clone()) {
169 continue;
170 }
171 let source = fs::read_to_string(&file).map_err(|error| RustProjectError::Io {
172 path: file.clone(),
173 reason: error.to_string(),
174 })?;
175 let parsed = SourceFile::parse(&source, Edition::CURRENT).tree();
176 collect_module_declarations(parsed.items(), &file, &directory, false, &mut pending);
177 }
178 Ok(())
179}
180
181fn owner_directory(file: &Path) -> PathBuf {
182 file.parent().map_or_else(PathBuf::new, Path::to_path_buf)
183}
184
185fn collect_module_declarations(
189 items: impl Iterator<Item = ast::Item>,
190 file: &Path,
191 directory: &Path,
192 inline: bool,
193 pending: &mut Vec<(PathBuf, PathBuf)>,
194) {
195 for item in items {
196 match item {
197 ast::Item::Module(module) => {
198 let Some(name) = module.name() else {
199 continue;
200 };
201 let name = name.text().to_string();
202 let path_attribute = module.attrs().find_map(|attr| {
203 let is_path = attr
204 .path()
205 .is_some_and(|path| path.syntax().text() == "path");
206 is_path.then(|| string_literal(attr.syntax())).flatten()
207 });
208 if let Some(list) = module.item_list() {
209 let nested = directory.join(&name);
210 collect_module_declarations(list.items(), file, &nested, true, pending);
211 } else if let Some(path) = path_attribute {
212 let base = if inline {
216 directory.to_path_buf()
217 } else {
218 owner_directory(file)
219 };
220 let target = base.join(path);
221 let owner = owner_directory(&target);
222 pending.push((target, owner));
223 } else {
224 let children = directory.join(&name);
227 pending.push((directory.join(format!("{name}.rs")), children.clone()));
228 pending.push((children.join("mod.rs"), children));
229 }
230 }
231 ast::Item::MacroCall(call) => {
232 let is_include = call.path().is_some_and(|path| {
233 matches!(
234 path.syntax().text().to_string().as_str(),
235 "include" | "std::include" | "core::include" | "::std::include"
236 )
237 });
238 if !is_include {
239 continue;
240 }
241 let Some(literal) = string_literal(call.syntax()) else {
242 continue;
243 };
244 if !literal.ends_with(".rs") {
245 continue;
246 }
247 pending.push((owner_directory(file).join(literal), directory.to_path_buf()));
250 }
251 _ => {}
252 }
253 }
254}
255
256fn string_literal(node: &ra_ap_syntax::SyntaxNode) -> Option<String> {
259 node.descendants_with_tokens().find_map(|element| {
260 let string = ast::String::cast(element.into_token()?)?;
261 string.value().ok().map(|value| value.into_owned())
262 })
263}
264
265fn crate_roots(
268 workspace: &Path,
269 packages: &[CargoPackage],
270) -> Result<BTreeSet<PathBuf>, RustProjectError> {
271 let mut roots = BTreeSet::new();
272 for package in packages {
273 let directory = package.manifest_path.parent().ok_or_else(|| {
274 RustProjectError::UnsafePath(package.manifest_path.display().to_string())
275 })?;
276 let directory = canonical_directory(directory)?;
277 confined_relative(workspace, &directory).or_else(|error| {
278 (directory == workspace)
279 .then_some(String::new())
280 .ok_or(error)
281 })?;
282 for target in &package.targets {
283 if target.kind.iter().any(|kind| kind == "custom-build") {
284 continue;
285 }
286 let root =
287 fs::canonicalize(&target.src_path).map_err(|error| RustProjectError::Io {
288 path: target.src_path.clone(),
289 reason: error.to_string(),
290 })?;
291 confined_relative(workspace, &root)?;
292 roots.insert(root);
293 }
294 }
295 Ok(roots)
296}
297
298pub fn discover_rust_source_files(workspace: &Path) -> Result<Vec<String>, RustProjectError> {
301 let workspace = canonical_directory(workspace)?;
302 let metadata = cargo_metadata(&workspace)?;
303 let metadata_root = canonical_directory(&metadata.workspace_root)?;
304 if metadata_root != workspace {
305 return Err(RustProjectError::UnsafePath(
306 metadata.workspace_root.display().to_string(),
307 ));
308 }
309 let members = metadata
310 .workspace_members
311 .into_iter()
312 .collect::<BTreeSet<_>>();
313 let packages = metadata
314 .packages
315 .into_iter()
316 .filter(|package| members.contains(&package.id))
317 .collect::<Vec<_>>();
318 if packages.is_empty() {
319 return Err(RustProjectError::NoWorkspacePackages);
320 }
321 let mut files = BTreeSet::new();
322 resolve_module_tree(&workspace, &crate_roots(&workspace, &packages)?, &mut files)?;
323 if files.is_empty() {
324 return Err(RustProjectError::NoSourceFiles);
325 }
326 files
327 .into_iter()
328 .map(|path| confined_relative(&workspace, &path))
329 .collect()
330}
331
332fn runtime_module_name(sources: &BTreeMap<String, String>) -> String {
333 let mut suffix = 0_usize;
334 loop {
335 let candidate = if suffix == 0 {
336 "__supercov_runtime_v1".to_owned()
337 } else {
338 format!("__supercov_runtime_v1_{suffix}")
339 };
340 if sources.values().all(|source| !source.contains(&candidate)) {
341 return candidate;
342 }
343 suffix += 1;
344 }
345}
346
347pub fn manifest_token(manifest: &CoverageManifest) -> String {
352 let mut ids = manifest
353 .points
354 .iter()
355 .map(|point| point.id.as_str())
356 .chain(
357 manifest
358 .decisions
359 .iter()
360 .map(|decision| decision.id.as_str()),
361 )
362 .chain(manifest.branches.iter().flat_map(|branch| {
363 branch
364 .alternatives
365 .iter()
366 .map(|alternative| alternative.id.as_str())
367 }))
368 .collect::<Vec<_>>();
369 ids.sort_unstable();
370 ids.dedup();
371 let mut hasher = Sha256::new();
372 for id in ids {
373 hasher.update(id.as_bytes());
374 hasher.update(b"\n");
375 }
376 hex(&hasher.finalize()[..6])
377}
378
379fn crate_key(token: &str, path: &str) -> String {
384 format!("{token}{}", hex(&Sha256::digest(path.as_bytes())[..6]))
385}
386
387fn hex(bytes: &[u8]) -> String {
388 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
389}
390
391fn merge_manifest(
392 destination: &mut CoverageManifest,
393 mut source: CoverageManifest,
394) -> Result<(), RustProjectError> {
395 let mut ids = destination
396 .points
397 .iter()
398 .map(|point| point.id.as_str())
399 .chain(
400 destination
401 .decisions
402 .iter()
403 .map(|decision| decision.id.as_str()),
404 )
405 .chain(destination.branches.iter().map(|branch| branch.id.as_str()))
406 .collect::<BTreeSet<_>>();
407 for id in source
408 .points
409 .iter()
410 .map(|point| point.id.as_str())
411 .chain(source.decisions.iter().map(|decision| decision.id.as_str()))
412 .chain(source.branches.iter().map(|branch| branch.id.as_str()))
413 {
414 if !ids.insert(id) {
415 return Err(RustProjectError::DuplicateObligation(id.into()));
416 }
417 }
418 destination.points.append(&mut source.points);
419 destination.decisions.append(&mut source.decisions);
420 destination.branches.append(&mut source.branches);
421 for limitation in source.limitations {
422 let id = limitation.get("id").and_then(|value| value.as_str());
423 if !destination
424 .limitations
425 .iter()
426 .any(|existing| existing.get("id").and_then(|value| value.as_str()) == id)
427 {
428 destination.limitations.push(limitation);
429 }
430 }
431 Ok(())
432}
433
434pub fn prepare_rust_project(workspace: &Path) -> Result<PreparedRustProject, RustProjectError> {
435 let workspace = canonical_directory(workspace)?;
436 let metadata = cargo_metadata(&workspace)?;
437 let metadata_root = canonical_directory(&metadata.workspace_root)?;
438 if metadata_root != workspace {
439 return Err(RustProjectError::UnsafePath(
440 metadata.workspace_root.display().to_string(),
441 ));
442 }
443 let members = metadata
444 .workspace_members
445 .into_iter()
446 .collect::<BTreeSet<_>>();
447 let packages = metadata
448 .packages
449 .into_iter()
450 .filter(|package| members.contains(&package.id))
451 .collect::<Vec<_>>();
452 if packages.is_empty() {
453 return Err(RustProjectError::NoWorkspacePackages);
454 }
455
456 let roots = crate_roots(&workspace, &packages)?;
457 let mut files = BTreeSet::new();
458 resolve_module_tree(&workspace, &roots, &mut files)?;
459 if files.is_empty() {
460 return Err(RustProjectError::NoSourceFiles);
461 }
462
463 let mut sources = BTreeMap::new();
464 for path in files {
465 let relative = confined_relative(&workspace, &path)?;
466 let source = fs::read_to_string(&path).map_err(|error| RustProjectError::Io {
467 path: path.clone(),
468 reason: error.to_string(),
469 })?;
470 sources.insert(relative, source);
471 }
472 let runtime_module = runtime_module_name(&sources);
473 let runtime_path = format!("crate::{runtime_module}");
474 let mut manifest = CoverageManifest {
475 unmeasured: Vec::new(),
476 decisions: Vec::new(),
477 points: Vec::new(),
478 branches: Vec::new(),
479 limitations: Vec::new(),
480 scope: None,
481 };
482 for (relative, source) in &sources {
483 let transformed =
484 instrument_rust_source(relative, source, &runtime_path).map_err(|error| {
485 RustProjectError::Instrument {
486 file: relative.clone(),
487 reason: error.to_string(),
488 }
489 })?;
490 merge_manifest(&mut manifest, transformed.manifest)?;
491 fs::write(workspace.join(relative), transformed.code).map_err(|error| {
492 RustProjectError::Io {
493 path: workspace.join(relative),
494 reason: error.to_string(),
495 }
496 })?;
497 }
498
499 let token = manifest_token(&manifest);
500 let mut crate_roots = Vec::new();
501 for root in roots {
502 let relative = confined_relative(&workspace, &root)?;
503 let runtime = render_rust_runtime(&runtime_module, &crate_key(&token, &relative))
504 .map_err(RustProjectError::Runtime)?;
505 let mut source = fs::read_to_string(&root).map_err(|error| RustProjectError::Io {
506 path: root.clone(),
507 reason: error.to_string(),
508 })?;
509 source.push('\n');
510 source.push_str(&runtime);
511 fs::write(&root, source).map_err(|error| RustProjectError::Io {
512 path: root,
513 reason: error.to_string(),
514 })?;
515 crate_roots.push(relative);
516 }
517
518 manifest
519 .points
520 .sort_by(|left, right| left.id.cmp(&right.id));
521 manifest
522 .decisions
523 .sort_by(|left, right| left.id.cmp(&right.id));
524 manifest
525 .branches
526 .sort_by(|left, right| left.id.cmp(&right.id));
527 manifest.limitations.sort_by(|left, right| {
528 left.get("id")
529 .and_then(|value| value.as_str())
530 .cmp(&right.get("id").and_then(|value| value.as_str()))
531 });
532 let target_directory = metadata.target_directory;
533 let target_directory = if target_directory.is_absolute() {
534 target_directory
535 } else {
536 workspace.join(target_directory)
537 };
538 if !target_directory.starts_with(&workspace) {
539 return Err(RustProjectError::UnsafePath(
540 target_directory.display().to_string(),
541 ));
542 }
543 Ok(PreparedRustProject {
544 workspace_root: workspace,
545 target_directory,
546 source_files: sources.into_keys().collect(),
547 crate_roots,
548 runtime_module,
549 manifest,
550 })
551}
552
553#[cfg(test)]
554mod tests {
555 use std::{
556 process::Command,
557 sync::atomic::{AtomicU64, Ordering},
558 time::{SystemTime, UNIX_EPOCH},
559 };
560
561 use super::*;
562
563 fn fixture() -> PathBuf {
564 static UNIQUE: AtomicU64 = AtomicU64::new(0);
570 let nonce = SystemTime::now()
571 .duration_since(UNIX_EPOCH)
572 .unwrap()
573 .as_nanos();
574 let root = std::env::temp_dir().join(format!(
575 "supercov-rust-project-{}-{nonce}-{}",
576 std::process::id(),
577 UNIQUE.fetch_add(1, Ordering::Relaxed)
578 ));
579 fs::create_dir(&root).unwrap();
580 fs::create_dir(root.join("src")).unwrap();
581 fs::create_dir(root.join("tests")).unwrap();
582 fs::write(
583 root.join("Cargo.toml"),
584 "[package]\nname='rust-project-fixture'\nversion='0.0.0'\nedition='2024'\n",
585 )
586 .unwrap();
587 fs::write(
588 root.join("src/lib.rs"),
589 r#"pub fn choose(first: bool, second: bool) -> i32 {
590 if first && second { 7 } else { 3 }
591}
592
593#[cfg(test)]
594mod tests {
595 #[test]
596 fn unit_choice() {
597 assert_eq!(super::choose(true, true), 7);
598 }
599}
600"#,
601 )
602 .unwrap();
603 fs::write(
604 root.join("tests/integration.rs"),
605 r#"#[test]
606fn integration_choice() {
607 assert_eq!(rust_project_fixture::choose(false, true), 3);
608}
609"#,
610 )
611 .unwrap();
612 root
613 }
614
615 #[test]
616 fn only_files_the_module_tree_reaches_are_instrumented() {
617 let root = fixture();
618 fs::create_dir_all(root.join("src/nested")).unwrap();
619 fs::create_dir_all(root.join("src/deep/inner")).unwrap();
620 fs::create_dir_all(root.join("runtime-assets")).unwrap();
621 fs::write(
622 root.join("src/lib.rs"),
623 concat!(
624 "mod util;\n",
625 "mod nested;\n",
626 "#[path = \"renamed_file.rs\"]\n",
627 "mod renamed;\n",
628 "mod deep;\n",
629 "include!(\"included.rs\");\n",
630 "pub const EMBEDDED: &str = include_str!(\"../runtime-assets/embedded.rs\");\n",
631 "pub fn choose(first: bool, second: bool) -> i32 {\n",
632 " if first && second { util::seven() } else { nested::three() }\n",
633 "}\n",
634 ),
635 )
636 .unwrap();
637 fs::write(root.join("src/util.rs"), "pub fn seven() -> i32 { 7 }\n").unwrap();
638 fs::write(
639 root.join("src/nested/mod.rs"),
640 "mod leaf;\npub fn three() -> i32 { leaf::three() }\n",
641 )
642 .unwrap();
643 fs::write(
644 root.join("src/nested/leaf.rs"),
645 "pub fn three() -> i32 { 3 }\n",
646 )
647 .unwrap();
648 fs::write(
649 root.join("src/renamed_file.rs"),
650 "pub fn renamed() -> i32 { 1 }\n",
651 )
652 .unwrap();
653 fs::write(
654 root.join("src/deep.rs"),
655 "pub mod inner {\n mod block_child;\n pub fn deep() -> i32 { block_child::v() }\n}\n",
656 )
657 .unwrap();
658 fs::write(
659 root.join("src/deep/inner/block_child.rs"),
660 "pub fn v() -> i32 { 9 }\n",
661 )
662 .unwrap();
663 fs::write(
664 root.join("src/included.rs"),
665 "pub fn included() -> i32 { 2 }\n",
666 )
667 .unwrap();
668 let embedded = "pub fn standalone() -> i32 { if true { 1 } else { 0 } }\n";
671 fs::write(root.join("runtime-assets/embedded.rs"), embedded).unwrap();
672 fs::write(
673 root.join("src/orphan.rs"),
674 "pub fn unreachable_module() {}\n",
675 )
676 .unwrap();
677
678 let prepared = prepare_rust_project(&root).unwrap();
679 assert_eq!(
680 prepared.source_files,
681 [
682 "src/deep.rs",
683 "src/deep/inner/block_child.rs",
684 "src/included.rs",
685 "src/lib.rs",
686 "src/nested/leaf.rs",
687 "src/nested/mod.rs",
688 "src/renamed_file.rs",
689 "src/util.rs",
690 "tests/integration.rs",
691 ]
692 );
693 assert_eq!(
694 fs::read_to_string(root.join("runtime-assets/embedded.rs")).unwrap(),
695 embedded
696 );
697 assert!(
698 !fs::read_to_string(root.join("src/orphan.rs"))
699 .unwrap()
700 .contains("__supercov")
701 );
702 assert!(
703 fs::read_to_string(root.join("src/deep/inner/block_child.rs"))
704 .unwrap()
705 .contains("__supercov")
706 );
707 let build = Command::new("cargo")
708 .args(["test", "--no-run"])
709 .current_dir(&root)
710 .env("CARGO_TARGET_DIR", &prepared.target_directory)
711 .output()
712 .unwrap();
713 assert!(
714 build.status.success(),
715 "{}",
716 String::from_utf8_lossy(&build.stderr)
717 );
718 fs::remove_dir_all(root).unwrap();
719 }
720
721 #[test]
722 fn crate_keys_carry_the_manifest_token() {
723 let root = fixture();
724 let prepared = prepare_rust_project(&root).unwrap();
725 let token = manifest_token(&prepared.manifest);
726 assert_eq!(token.len(), 12);
727 assert!(token.bytes().all(|byte| byte.is_ascii_hexdigit()));
728 assert_eq!(token, manifest_token(&prepared.manifest));
729 let key = crate_key(&token, "src/lib.rs");
730 assert_eq!(key.len(), 24);
731 assert!(key.starts_with(&token));
732 assert_ne!(key, crate_key(&token, "tests/integration.rs"));
733 for crate_root in &prepared.crate_roots {
734 assert!(
735 fs::read_to_string(root.join(crate_root))
736 .unwrap()
737 .contains(&crate_key(&token, crate_root))
738 );
739 }
740 fs::remove_dir_all(root).unwrap();
741 }
742
743 #[test]
744 fn prepares_every_workspace_crate_root_and_compiles_without_manifest_changes() {
745 let root = fixture();
746 let manifest_before = fs::read(root.join("Cargo.toml")).unwrap();
747 let prepared = prepare_rust_project(&root).unwrap();
748 assert_eq!(
749 prepared.source_files,
750 ["src/lib.rs", "tests/integration.rs"]
751 );
752 assert_eq!(prepared.crate_roots, ["src/lib.rs", "tests/integration.rs"]);
753 assert!(!prepared.manifest.points.is_empty());
754 assert!(!prepared.manifest.decisions.is_empty());
755 assert_eq!(fs::read(root.join("Cargo.toml")).unwrap(), manifest_before);
756 for crate_root in &prepared.crate_roots {
757 assert!(
758 fs::read_to_string(root.join(crate_root))
759 .unwrap()
760 .contains(&format!("mod {}", prepared.runtime_module))
761 );
762 }
763 let build = Command::new("cargo")
764 .args(["test", "--no-run"])
765 .current_dir(&root)
766 .env("CARGO_TARGET_DIR", &prepared.target_directory)
767 .output()
768 .unwrap();
769 assert!(
770 build.status.success(),
771 "{}",
772 String::from_utf8_lossy(&build.stderr)
773 );
774 fs::remove_dir_all(root).unwrap();
775 }
776}