Skip to main content

supercov_engine/
rust_project.rs

1//! Cargo workspace discovery and isolated owned-Rust frontend preparation.
2
3use 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/// Where preparing the instrumented workspace spent its time, for
34/// `SUPERCOV_PHASE_TIMING=1`.
35#[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
148/// The workspace sources the build actually read, from the depinfo rustc
149/// writes beside every artifact (`<artifact>.d`: a make rule whose
150/// prerequisites are every file that went into it). `None` when no depinfo
151/// could be read, which means "do not prune".
152///
153/// Source discovery follows `mod` declarations because that is what rustc
154/// RESOLVES; this is what it COMPILES. A module behind a `#[cfg]` that is off
155/// is resolved and never built, and its obligations can never be covered --
156/// smallvec's `serde`, `borsh`, `rayon` and `specialization` modules are 142
157/// such lines. Reading the build is exact for every `cfg` predicate, where
158/// evaluating them here would have to reproduce Cargo's feature resolution.
159pub 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
189/// The prerequisites of every rule in a depinfo file, as workspace-relative
190/// paths. A rule is `target: prerequisite prerequisite`, and a space inside a
191/// path is escaped as `\ `.
192fn collect_depinfo_sources(text: &str, workspace: &Path, compiled: &mut BTreeSet<String>) {
193    for line in text.lines() {
194        // A prerequisite-only line (`path:` with nothing after it) carries no
195        // sources, and a target's own path is not a source.
196        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(&current, workspace, compiled);
209                    current.clear();
210                }
211                _ => current.push(character),
212            }
213        }
214        push_workspace_source(&current, 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    // Cargo runs rustc with the workspace as its working directory, so a
224    // source of the crate being built is named relatively and a dependency's
225    // source -- from the registry -- absolutely.
226    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
240/// The files rustc compiles for the given crate roots: each root and,
241/// transitively, every module it declares with `mod name;` (resolved the way
242/// rustc resolves it, `#[path]` included) and every file it pulls in with a
243/// literal `include!("....rs")`. A `.rs` file under the package that no module
244/// reaches -- a runtime source embedded as data with `include_str!`, a test
245/// fixture, a snippet -- is not part of any crate, so instrumenting it would
246/// change the data and count code that is never compiled.
247///
248/// A file that does not exist is skipped, not an error: a `#[cfg]`-gated
249/// module may name a file the checkout lacks, and rustc only complains when
250/// that cfg is active. Files outside the workspace are left alone as well.
251fn 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    // (file, directory its `mod` children resolve in)
258    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        // `#[path = "../src/shared.rs"]` climbs out of its directory; the
264        // path is normalised lexically so the workspace check and the file
265        // set see one spelling of it.
266        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        // A symlink is followed only within the workspace: crossbeam shares
275        // one source file between its crates that way. The file is recorded
276        // under its target's path, so it is instrumented and digested once as
277        // a regular file; a symlink leaving the workspace would be
278        // instrumented in place, outside the copy, and is refused.
279        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
306/// Resolve `.` and `..` components without touching the filesystem.
307fn 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
325/// Walk the items of one module body. `directory` is where this module's
326/// `mod name;` children live; `inline` says whether we are inside a
327/// `mod name { ... }` block, which changes what `#[path]` is relative to.
328fn 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                    // Relative to the file's own directory at the top level,
353                    // to the inline module's directory inside a block; the
354                    // loaded file owns its directory like a `mod.rs` does.
355                    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                    // `name.rs` and `name/mod.rs` both put their children in
365                    // `directory/name/`.
366                    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                    // A macro that selects among implementations declares its
380                    // modules in its token tree, where no item walk reaches
381                    // them: hashbrown's `cfg_select!` and the `cfg_if!` in
382                    // crates everywhere. Both arms are followed; the build
383                    // decides which one counts.
384                    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                // Included code is spliced into this module: its own `mod`
398                // declarations resolve where this module's do.
399                pending.push((owner_directory(file).join(literal), directory.to_path_buf()));
400            }
401            _ => {}
402        }
403    }
404}
405
406/// Every `mod <name>;` declared inside a macro's token tree. The tokens are
407/// unparsed there, so the sequence is matched directly.
408fn 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        // `mod name;` declares a file; `mod name { .. }` is inline and its
428        // items are already in this tree.
429        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
440/// The first string literal under a node, unescaped. Inside a macro's token
441/// tree the literal is a bare token, not a `Literal` node, so look at tokens.
442fn 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
449/// The crate roots of every workspace member: the source file of each Cargo
450/// target except build scripts, which Cargo compiles and runs on their own.
451/// The crate roots of proc-macro targets. What they compile to is a compiler
452/// plugin: rustc loads it while building the crate under test and runs it
453/// there, so no test process ever executes it and the owned probes see
454/// nothing.
455fn 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
509/// Read-only Cargo workspace source discovery used by integrity checks. This
510/// deliberately shares the same path policy as transformation preparation.
511pub 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
558/// Twelve hex digits identifying an instrumentation: a digest of every
559/// obligation ID in the manifest. Two builds of the same sources share it;
560/// any other program's instrumentation, such as a fixture a test prepares
561/// and runs, has another.
562/// Obligations in a proc-macro crate leave the denominator: the compiler runs
563/// that code while it builds the crate under test, so a test process cannot
564/// observe it. They stay in the manifest -- the evidence files are named by a
565/// token over its obligation IDs -- and are declined, with one limitation per
566/// file saying why.
567fn 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    // The boundaries already declared inside these files say nothing now.
610    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
659/// The runtime names its evidence files `<crate key>-<pid>.events`; the key
660/// is the manifest token followed by a digest of the crate root, so the
661/// reader can tell this instrumentation's files from any other's and two
662/// crates of one process write separate files.
663fn 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    // Obligations the file declined -- a `const fn` body, a `GlobalAlloc`
702    // implementation -- are declined for the project too.
703    destination.unmeasured.append(&mut source.unmeasured);
704    // A limitation is one site in one file. Deduping on the id alone kept a
705    // single macro-expansion limitation for the whole project, charged to
706    // whichever file merged first.
707    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        // One test calls this today, so nothing can collide with it yet. The
879        // counter is here because the clock is not enough on its own: it ticks
880        // once per microsecond and every test shares the pid, so the second
881        // test to use this helper would draw the same root as the first when
882        // the two start together.
883        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        // async-trait, serde_derive and thiserror-impl compile to compiler
932        // plugins: rustc loads them while building the crate under test and
933        // runs them there, so no test process executes a line of them and
934        // every file read 0%.
935        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        // The files are still instrumented and still in the manifest; their
953        // obligations are declined, and a limitation says why.
954        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        // hashbrown selects its SIMD group with `cfg_select! { ... mod neon;
980        // ... }`, and `cfg_if!` has the same shape; the declaration lives in
981        // the macro's token tree, which no item walk reaches. Both arms are
982        // followed -- the build decides which one is in the denominator.
983        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        // Both arms, and never the inline module, which has no file.
1004        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        // serde_json's tests reach into src with `#[path = "../src/..."]`.
1072        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        // Data, not code: embedded verbatim and compiled by a consumer of
1086        // its own, which would not know any runtime module of ours.
1087        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        // The target's path, once; never the symlink's spelling.
1168        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        // The one function in it carries one function probe: instrumented
1175        // once, through whichever spelling reached it first.
1176        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}