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    SourceRoots(String),
56}
57
58impl std::fmt::Display for RustProjectError {
59    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::Io { path, reason } => write!(formatter, "{}: {reason}", path.display()),
62            Self::MetadataLaunch(reason) => {
63                write!(formatter, "could not launch cargo metadata: {reason}")
64            }
65            Self::MetadataFailed(reason) => write!(formatter, "cargo metadata failed: {reason}"),
66            Self::MetadataJson(reason) => write!(formatter, "invalid cargo metadata: {reason}"),
67            Self::UnsafePath(path) => {
68                write!(formatter, "Cargo reported an unsafe workspace path: {path}")
69            }
70            Self::NoWorkspacePackages => {
71                write!(formatter, "Cargo metadata reported no workspace packages")
72            }
73            Self::NoSourceFiles => write!(
74                formatter,
75                "Cargo workspace contains no owned Rust source files"
76            ),
77            Self::Instrument { file, reason } => {
78                write!(formatter, "could not instrument {file}: {reason}")
79            }
80            Self::DuplicateObligation(id) => {
81                write!(formatter, "duplicate Rust obligation ID: {id}")
82            }
83            Self::Runtime(reason) => write!(formatter, "could not generate Rust runtime: {reason}"),
84            Self::SourceRoots(reason) => write!(formatter, "{reason}"),
85        }
86    }
87}
88
89impl std::error::Error for RustProjectError {}
90
91#[derive(Deserialize)]
92struct CargoMetadata {
93    packages: Vec<CargoPackage>,
94    workspace_members: Vec<String>,
95    workspace_root: PathBuf,
96    target_directory: PathBuf,
97}
98
99#[derive(Deserialize)]
100struct CargoPackage {
101    id: String,
102    manifest_path: PathBuf,
103    targets: Vec<CargoTarget>,
104}
105
106#[derive(Deserialize)]
107struct CargoTarget {
108    kind: Vec<String>,
109    src_path: PathBuf,
110}
111
112fn canonical_directory(path: &Path) -> Result<PathBuf, RustProjectError> {
113    fs::canonicalize(path).map_err(|error| RustProjectError::Io {
114        path: path.to_owned(),
115        reason: error.to_string(),
116    })
117}
118
119fn confined_relative(root: &Path, path: &Path) -> Result<String, RustProjectError> {
120    let relative = path
121        .strip_prefix(root)
122        .map_err(|_| RustProjectError::UnsafePath(path.display().to_string()))?;
123    if relative.as_os_str().is_empty()
124        || relative
125            .components()
126            .any(|component| !matches!(component, Component::Normal(_)))
127    {
128        return Err(RustProjectError::UnsafePath(path.display().to_string()));
129    }
130    Ok(relative.to_string_lossy().replace('\\', "/"))
131}
132
133fn cargo_metadata(root: &Path) -> Result<CargoMetadata, RustProjectError> {
134    let target_directory = root.join(".supercov/rust-target");
135    let output = Command::new("cargo")
136        .args(["metadata", "--format-version=1", "--no-deps"])
137        .current_dir(root)
138        .env("CARGO_TARGET_DIR", &target_directory)
139        .output()
140        .map_err(|error| RustProjectError::MetadataLaunch(error.to_string()))?;
141    if !output.status.success() {
142        return Err(RustProjectError::MetadataFailed(
143            String::from_utf8_lossy(&output.stderr).trim().to_owned(),
144        ));
145    }
146    serde_json::from_slice(&output.stdout)
147        .map_err(|error| RustProjectError::MetadataJson(error.to_string()))
148}
149
150/// The workspace sources the build actually read, from the depinfo rustc
151/// writes beside every artifact (`<artifact>.d`: a make rule whose
152/// prerequisites are every file that went into it). `None` when no depinfo
153/// could be read, which means "do not prune".
154///
155/// Source discovery follows `mod` declarations because that is what rustc
156/// RESOLVES; this is what it COMPILES. A module behind a `#[cfg]` that is off
157/// is resolved and never built, and its obligations can never be covered --
158/// smallvec's `serde`, `borsh`, `rayon` and `specialization` modules are 142
159/// such lines. Reading the build is exact for every `cfg` predicate, where
160/// evaluating them here would have to reproduce Cargo's feature resolution.
161pub fn compiled_source_files(
162    workspace: &Path,
163    target_directory: &Path,
164) -> Option<BTreeSet<String>> {
165    let mut compiled = BTreeSet::new();
166    let mut depinfo_files = 0;
167    let mut directories = vec![target_directory.to_path_buf()];
168    while let Some(directory) = directories.pop() {
169        let Ok(entries) = fs::read_dir(&directory) else {
170            continue;
171        };
172        for entry in entries.flatten() {
173            let path = entry.path();
174            match entry.file_type() {
175                Ok(file_type) if file_type.is_dir() => directories.push(path),
176                Ok(file_type) if file_type.is_file() => {
177                    if path.extension().is_some_and(|extension| extension == "d")
178                        && let Ok(text) = fs::read_to_string(&path)
179                    {
180                        depinfo_files += 1;
181                        collect_depinfo_sources(&text, workspace, &mut compiled);
182                    }
183                }
184                _ => {}
185            }
186        }
187    }
188    (depinfo_files > 0).then_some(compiled)
189}
190
191/// The prerequisites of every rule in a depinfo file, as workspace-relative
192/// paths. A rule is `target: prerequisite prerequisite`, and a space inside a
193/// path is escaped as `\ `.
194fn collect_depinfo_sources(text: &str, workspace: &Path, compiled: &mut BTreeSet<String>) {
195    for line in text.lines() {
196        // A prerequisite-only line (`path:` with nothing after it) carries no
197        // sources, and a target's own path is not a source.
198        let Some((_, prerequisites)) = line.split_once(": ") else {
199            continue;
200        };
201        let mut current = String::new();
202        let mut characters = prerequisites.chars().peekable();
203        while let Some(character) = characters.next() {
204            match character {
205                '\\' if characters.peek() == Some(&' ') => {
206                    characters.next();
207                    current.push(' ');
208                }
209                ' ' => {
210                    push_workspace_source(&current, workspace, compiled);
211                    current.clear();
212                }
213                _ => current.push(character),
214            }
215        }
216        push_workspace_source(&current, workspace, compiled);
217    }
218}
219
220fn push_workspace_source(path: &str, workspace: &Path, compiled: &mut BTreeSet<String>) {
221    let path = path.trim();
222    if path.is_empty() || !path.ends_with(".rs") {
223        return;
224    }
225    // Cargo runs rustc with the workspace as its working directory, so a
226    // source of the crate being built is named relatively and a dependency's
227    // source -- from the registry -- absolutely.
228    let candidate = Path::new(path);
229    let relative = if candidate.is_absolute() {
230        let Ok(relative) = candidate.strip_prefix(workspace) else {
231            return;
232        };
233        relative
234    } else {
235        candidate
236    };
237    if let Some(text) = relative.to_str() {
238        compiled.insert(text.replace('\\', "/"));
239    }
240}
241
242/// The files rustc compiles for the given crate roots: each root and,
243/// transitively, every module it declares with `mod name;` (resolved the way
244/// rustc resolves it, `#[path]` included) and every file it pulls in with a
245/// literal `include!("....rs")`. A `.rs` file under the package that no module
246/// reaches -- a runtime source embedded as data with `include_str!`, a test
247/// fixture, a snippet -- is not part of any crate, so instrumenting it would
248/// change the data and count code that is never compiled.
249///
250/// A file that does not exist is skipped, not an error: a `#[cfg]`-gated
251/// module may name a file the checkout lacks, and rustc only complains when
252/// that cfg is active. Files outside the workspace are left alone as well.
253fn resolve_module_tree(
254    workspace: &Path,
255    roots: &BTreeSet<PathBuf>,
256    files: &mut BTreeSet<PathBuf>,
257) -> Result<(), RustProjectError> {
258    let canonical_workspace = canonical_directory(workspace)?;
259    // (file, directory its `mod` children resolve in)
260    let mut pending = roots
261        .iter()
262        .map(|root| (root.clone(), owner_directory(root)))
263        .collect::<Vec<_>>();
264    while let Some((file, directory)) = pending.pop() {
265        // `#[path = "../src/shared.rs"]` climbs out of its directory; the
266        // path is normalised lexically so the workspace check and the file
267        // set see one spelling of it.
268        let file = normalize(&file);
269        let directory = normalize(&directory);
270        if !file.starts_with(workspace) {
271            continue;
272        }
273        let Ok(metadata) = fs::symlink_metadata(&file) else {
274            continue;
275        };
276        // A symlink is followed only within the workspace: crossbeam shares
277        // one source file between its crates that way. The file is recorded
278        // under its target's path, so it is instrumented and digested once as
279        // a regular file; a symlink leaving the workspace would be
280        // instrumented in place, outside the copy, and is refused.
281        let file = if metadata.file_type().is_symlink() {
282            let target = fs::canonicalize(&file).map_err(|error| RustProjectError::Io {
283                path: file.clone(),
284                reason: error.to_string(),
285            })?;
286            if !target.starts_with(&canonical_workspace) || !target.is_file() {
287                return Err(RustProjectError::UnsafePath(file.display().to_string()));
288            }
289            target
290        } else if metadata.is_file() {
291            file.clone()
292        } else {
293            continue;
294        };
295        if !files.insert(file.clone()) {
296            continue;
297        }
298        let source = fs::read_to_string(&file).map_err(|error| RustProjectError::Io {
299            path: file.clone(),
300            reason: error.to_string(),
301        })?;
302        let parsed = SourceFile::parse(&source, Edition::CURRENT).tree();
303        collect_module_declarations(parsed.items(), &file, &directory, false, &mut pending);
304    }
305    Ok(())
306}
307
308/// Resolve `.` and `..` components without touching the filesystem.
309fn normalize(path: &Path) -> PathBuf {
310    let mut normalized = PathBuf::new();
311    for component in path.components() {
312        match component {
313            Component::ParentDir => {
314                normalized.pop();
315            }
316            Component::CurDir => {}
317            other => normalized.push(other.as_os_str()),
318        }
319    }
320    normalized
321}
322
323fn owner_directory(file: &Path) -> PathBuf {
324    file.parent().map_or_else(PathBuf::new, Path::to_path_buf)
325}
326
327/// Walk the items of one module body. `directory` is where this module's
328/// `mod name;` children live; `inline` says whether we are inside a
329/// `mod name { ... }` block, which changes what `#[path]` is relative to.
330fn collect_module_declarations(
331    items: impl Iterator<Item = ast::Item>,
332    file: &Path,
333    directory: &Path,
334    inline: bool,
335    pending: &mut Vec<(PathBuf, PathBuf)>,
336) {
337    for item in items {
338        match item {
339            ast::Item::Module(module) => {
340                let Some(name) = module.name() else {
341                    continue;
342                };
343                let name = name.text().to_string();
344                let path_attribute = module.attrs().find_map(|attr| {
345                    let is_path = attr
346                        .path()
347                        .is_some_and(|path| path.syntax().text() == "path");
348                    is_path.then(|| string_literal(attr.syntax())).flatten()
349                });
350                if let Some(list) = module.item_list() {
351                    let nested = directory.join(&name);
352                    collect_module_declarations(list.items(), file, &nested, true, pending);
353                } else if let Some(path) = path_attribute {
354                    // Relative to the file's own directory at the top level,
355                    // to the inline module's directory inside a block; the
356                    // loaded file owns its directory like a `mod.rs` does.
357                    let base = if inline {
358                        directory.to_path_buf()
359                    } else {
360                        owner_directory(file)
361                    };
362                    let target = base.join(path);
363                    let owner = owner_directory(&target);
364                    pending.push((target, owner));
365                } else {
366                    // `name.rs` and `name/mod.rs` both put their children in
367                    // `directory/name/`.
368                    let children = directory.join(&name);
369                    pending.push((directory.join(format!("{name}.rs")), children.clone()));
370                    pending.push((children.join("mod.rs"), children));
371                }
372            }
373            ast::Item::MacroCall(call) => {
374                let is_include = call.path().is_some_and(|path| {
375                    matches!(
376                        path.syntax().text().to_string().as_str(),
377                        "include" | "std::include" | "core::include" | "::std::include"
378                    )
379                });
380                if !is_include {
381                    // A macro that selects among implementations declares its
382                    // modules in its token tree, where no item walk reaches
383                    // them: hashbrown's `cfg_select!` and the `cfg_if!` in
384                    // crates everywhere. Both arms are followed; the build
385                    // decides which one counts.
386                    for name in token_tree_modules(call.syntax()) {
387                        let children = directory.join(&name);
388                        pending.push((directory.join(format!("{name}.rs")), children.clone()));
389                        pending.push((children.join("mod.rs"), children));
390                    }
391                    continue;
392                }
393                let Some(literal) = string_literal(call.syntax()) else {
394                    continue;
395                };
396                if !literal.ends_with(".rs") {
397                    continue;
398                }
399                // Included code is spliced into this module: its own `mod`
400                // declarations resolve where this module's do.
401                pending.push((owner_directory(file).join(literal), directory.to_path_buf()));
402            }
403            _ => {}
404        }
405    }
406}
407
408/// Every `mod <name>;` declared inside a macro's token tree. The tokens are
409/// unparsed there, so the sequence is matched directly.
410fn token_tree_modules(node: &ra_ap_syntax::SyntaxNode) -> Vec<String> {
411    let mut names = Vec::new();
412    let mut tokens = node
413        .descendants_with_tokens()
414        .filter_map(|element| element.into_token())
415        .filter(|token| !token.kind().is_trivia())
416        .peekable();
417    while let Some(token) = tokens.next() {
418        if token.text() != "mod" {
419            continue;
420        }
421        let Some(name) = tokens
422            .peek()
423            .filter(|next| next.kind() == SyntaxKind::IDENT)
424        else {
425            continue;
426        };
427        let name = name.text().to_string();
428        tokens.next();
429        // `mod name;` declares a file; `mod name { .. }` is inline and its
430        // items are already in this tree.
431        if tokens
432            .peek()
433            .is_some_and(|next| next.kind() == SyntaxKind::SEMICOLON)
434        {
435            tokens.next();
436            names.push(name);
437        }
438    }
439    names
440}
441
442/// The first string literal under a node, unescaped. Inside a macro's token
443/// tree the literal is a bare token, not a `Literal` node, so look at tokens.
444fn string_literal(node: &ra_ap_syntax::SyntaxNode) -> Option<String> {
445    node.descendants_with_tokens().find_map(|element| {
446        let string = ast::String::cast(element.into_token()?)?;
447        string.value().ok().map(|value| value.into_owned())
448    })
449}
450
451/// The crate roots of every workspace member: the source file of each Cargo
452/// target except build scripts, which Cargo compiles and runs on their own.
453/// The crate roots of proc-macro targets. What they compile to is a compiler
454/// plugin: rustc loads it while building the crate under test and runs it
455/// there, so no test process ever executes it and the owned probes see
456/// nothing.
457fn proc_macro_crate_roots(
458    workspace: &Path,
459    packages: &[CargoPackage],
460) -> Result<BTreeSet<PathBuf>, RustProjectError> {
461    let mut roots = BTreeSet::new();
462    for package in packages {
463        for target in &package.targets {
464            if !target.kind.iter().any(|kind| kind == "proc-macro") {
465                continue;
466            }
467            let root =
468                fs::canonicalize(&target.src_path).map_err(|error| RustProjectError::Io {
469                    path: target.src_path.clone(),
470                    reason: error.to_string(),
471                })?;
472            if confined_relative(workspace, &root).is_ok() {
473                roots.insert(root);
474            }
475        }
476    }
477    Ok(roots)
478}
479
480fn crate_roots(
481    workspace: &Path,
482    packages: &[CargoPackage],
483) -> Result<BTreeSet<PathBuf>, RustProjectError> {
484    let mut roots = BTreeSet::new();
485    for package in packages {
486        let directory = package.manifest_path.parent().ok_or_else(|| {
487            RustProjectError::UnsafePath(package.manifest_path.display().to_string())
488        })?;
489        let directory = canonical_directory(directory)?;
490        confined_relative(workspace, &directory).or_else(|error| {
491            (directory == workspace)
492                .then_some(String::new())
493                .ok_or(error)
494        })?;
495        for target in &package.targets {
496            if target.kind.iter().any(|kind| kind == "custom-build") {
497                continue;
498            }
499            let root =
500                fs::canonicalize(&target.src_path).map_err(|error| RustProjectError::Io {
501                    path: target.src_path.clone(),
502                    reason: error.to_string(),
503                })?;
504            confined_relative(workspace, &root)?;
505            roots.insert(root);
506        }
507    }
508    Ok(roots)
509}
510
511/// Read-only Cargo workspace source discovery used by integrity checks. This
512/// deliberately shares the same path policy as transformation preparation.
513pub fn discover_rust_source_files(workspace: &Path) -> Result<Vec<String>, RustProjectError> {
514    let workspace = canonical_directory(workspace)?;
515    let metadata = cargo_metadata(&workspace)?;
516    let metadata_root = canonical_directory(&metadata.workspace_root)?;
517    if metadata_root != workspace {
518        return Err(RustProjectError::UnsafePath(
519            metadata.workspace_root.display().to_string(),
520        ));
521    }
522    let members = metadata
523        .workspace_members
524        .into_iter()
525        .collect::<BTreeSet<_>>();
526    let packages = metadata
527        .packages
528        .into_iter()
529        .filter(|package| members.contains(&package.id))
530        .collect::<Vec<_>>();
531    if packages.is_empty() {
532        return Err(RustProjectError::NoWorkspacePackages);
533    }
534    let mut files = BTreeSet::new();
535    resolve_module_tree(&workspace, &crate_roots(&workspace, &packages)?, &mut files)?;
536    if files.is_empty() {
537        return Err(RustProjectError::NoSourceFiles);
538    }
539    files
540        .into_iter()
541        .map(|path| confined_relative(&workspace, &path))
542        .collect()
543}
544
545fn runtime_module_name(sources: &BTreeMap<String, String>) -> String {
546    let mut suffix = 0_usize;
547    loop {
548        let candidate = if suffix == 0 {
549            "__supercov_runtime_v1".to_owned()
550        } else {
551            format!("__supercov_runtime_v1_{suffix}")
552        };
553        if sources.values().all(|source| !source.contains(&candidate)) {
554            return candidate;
555        }
556        suffix += 1;
557    }
558}
559
560/// Twelve hex digits identifying an instrumentation: a digest of every
561/// obligation ID in the manifest. Two builds of the same sources share it;
562/// any other program's instrumentation, such as a fixture a test prepares
563/// and runs, has another.
564/// Obligations in a proc-macro crate leave the denominator: the compiler runs
565/// that code while it builds the crate under test, so a test process cannot
566/// observe it. They stay in the manifest -- the evidence files are named by a
567/// token over its obligation IDs -- and are declined, with one limitation per
568/// file saying why.
569fn decline_proc_macro_obligations(
570    workspace: &Path,
571    proc_macro_roots: &BTreeSet<PathBuf>,
572    manifest: &mut CoverageManifest,
573) -> Result<(), RustProjectError> {
574    if proc_macro_roots.is_empty() {
575        return Ok(());
576    }
577    let mut reached = BTreeSet::new();
578    resolve_module_tree(workspace, proc_macro_roots, &mut reached)?;
579    let mut files = BTreeSet::new();
580    for path in reached {
581        if let Ok(relative) = confined_relative(workspace, &path) {
582            files.insert(relative);
583        }
584    }
585    if files.is_empty() {
586        return Ok(());
587    }
588    let mut unmeasured = manifest.unmeasured.iter().cloned().collect::<BTreeSet<_>>();
589    unmeasured.extend(
590        manifest
591            .points
592            .iter()
593            .filter(|point| files.contains(&point.file))
594            .map(|point| point.id.clone()),
595    );
596    unmeasured.extend(
597        manifest
598            .decisions
599            .iter()
600            .filter(|decision| files.contains(&decision.file))
601            .map(|decision| decision.id.clone()),
602    );
603    unmeasured.extend(
604        manifest
605            .branches
606            .iter()
607            .filter(|branch| files.contains(&branch.file))
608            .map(|branch| branch.id.clone()),
609    );
610    manifest.unmeasured = unmeasured.into_iter().collect();
611    // The boundaries already declared inside these files say nothing now.
612    manifest.limitations.retain(|limitation| {
613        limitation
614            .get("file")
615            .and_then(|file| file.as_str())
616            .is_none_or(|file| !files.contains(file))
617    });
618    for file in files {
619        manifest.limitations.push(serde_json::json!({
620            "id": format!("rust-proc-macro-runs-in-the-compiler#{file}"),
621            "kind": "source-scope",
622            "file": file,
623            "line": 1,
624            "column": 0,
625            "source": "",
626            "blocking": false,
627            "reason": "This crate compiles to a compiler plugin: rustc loads it and runs it while building the crate under test, so no test process executes it"
628        }));
629    }
630    Ok(())
631}
632
633pub fn manifest_token(manifest: &CoverageManifest) -> String {
634    let mut ids = manifest
635        .points
636        .iter()
637        .map(|point| point.id.as_str())
638        .chain(
639            manifest
640                .decisions
641                .iter()
642                .map(|decision| decision.id.as_str()),
643        )
644        .chain(manifest.branches.iter().flat_map(|branch| {
645            branch
646                .alternatives
647                .iter()
648                .map(|alternative| alternative.id.as_str())
649        }))
650        .collect::<Vec<_>>();
651    ids.sort_unstable();
652    ids.dedup();
653    let mut hasher = Sha256::new();
654    for id in ids {
655        hasher.update(id.as_bytes());
656        hasher.update(b"\n");
657    }
658    hex(&hasher.finalize()[..6])
659}
660
661/// The runtime names its evidence files `<crate key>-<pid>.events`; the key
662/// is the manifest token followed by a digest of the crate root, so the
663/// reader can tell this instrumentation's files from any other's and two
664/// crates of one process write separate files.
665fn crate_key(token: &str, path: &str) -> String {
666    format!("{token}{}", hex(&Sha256::digest(path.as_bytes())[..6]))
667}
668
669fn hex(bytes: &[u8]) -> String {
670    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
671}
672
673fn merge_manifest(
674    destination: &mut CoverageManifest,
675    mut source: CoverageManifest,
676) -> Result<(), RustProjectError> {
677    let mut ids = destination
678        .points
679        .iter()
680        .map(|point| point.id.as_str())
681        .chain(
682            destination
683                .decisions
684                .iter()
685                .map(|decision| decision.id.as_str()),
686        )
687        .chain(destination.branches.iter().map(|branch| branch.id.as_str()))
688        .collect::<BTreeSet<_>>();
689    for id in source
690        .points
691        .iter()
692        .map(|point| point.id.as_str())
693        .chain(source.decisions.iter().map(|decision| decision.id.as_str()))
694        .chain(source.branches.iter().map(|branch| branch.id.as_str()))
695    {
696        if !ids.insert(id) {
697            return Err(RustProjectError::DuplicateObligation(id.into()));
698        }
699    }
700    destination.points.append(&mut source.points);
701    destination.decisions.append(&mut source.decisions);
702    destination.branches.append(&mut source.branches);
703    // Obligations the file declined -- a `const fn` body, a `GlobalAlloc`
704    // implementation -- are declined for the project too.
705    destination.unmeasured.append(&mut source.unmeasured);
706    // A limitation is one site in one file. Deduping on the id alone kept a
707    // single macro-expansion limitation for the whole project, charged to
708    // whichever file merged first.
709    let site = |value: &serde_json::Value| {
710        (
711            value
712                .get("id")
713                .and_then(|id| id.as_str())
714                .map(str::to_owned),
715            value
716                .get("file")
717                .and_then(|file| file.as_str())
718                .map(str::to_owned),
719            value.get("line").and_then(serde_json::Value::as_u64),
720            value.get("column").and_then(serde_json::Value::as_u64),
721        )
722    };
723    for limitation in source.limitations {
724        let key = site(&limitation);
725        if !destination
726            .limitations
727            .iter()
728            .any(|existing| site(existing) == key)
729        {
730            destination.limitations.push(limitation);
731        }
732    }
733    Ok(())
734}
735
736pub fn prepare_rust_project(
737    workspace: &Path,
738    roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
739) -> Result<PreparedRustProject, RustProjectError> {
740    // Named apart from the crate roots below, which reuse `roots`.
741    let source_roots = roots;
742    let elapsed = |started: std::time::Instant| started.elapsed().as_secs_f64() * 1000.0;
743    let mut preparation = RustPreparationTimings::default();
744    let workspace = canonical_directory(workspace)?;
745    let started = std::time::Instant::now();
746    let metadata = cargo_metadata(&workspace)?;
747    preparation.metadata_ms = elapsed(started);
748    let metadata_root = canonical_directory(&metadata.workspace_root)?;
749    if metadata_root != workspace {
750        return Err(RustProjectError::UnsafePath(
751            metadata.workspace_root.display().to_string(),
752        ));
753    }
754    let members = metadata
755        .workspace_members
756        .into_iter()
757        .collect::<BTreeSet<_>>();
758    let packages = metadata
759        .packages
760        .into_iter()
761        .filter(|package| members.contains(&package.id))
762        .collect::<Vec<_>>();
763    if packages.is_empty() {
764        return Err(RustProjectError::NoWorkspacePackages);
765    }
766
767    let started = std::time::Instant::now();
768    let roots = crate_roots(&workspace, &packages)?;
769    let proc_macro_roots = proc_macro_crate_roots(&workspace, &packages)?;
770    let mut files = BTreeSet::new();
771    resolve_module_tree(&workspace, &roots, &mut files)?;
772    if files.is_empty() {
773        return Err(RustProjectError::NoSourceFiles);
774    }
775
776    let mut sources = BTreeMap::new();
777    for path in files {
778        let relative = confined_relative(&workspace, &path)?;
779        let source = fs::read_to_string(&path).map_err(|error| RustProjectError::Io {
780            path: path.clone(),
781            reason: error.to_string(),
782        })?;
783        sources.insert(relative, source);
784    }
785    preparation.discovery_ms = elapsed(started);
786    let started = std::time::Instant::now();
787    let runtime_module = runtime_module_name(&sources);
788    // Narrowed only after the runtime's name is chosen: that name must not
789    // collide with any module in the crate, including those the roots leave
790    // out. A module left out is simply not instrumented, and compiles as the
791    // author wrote it.
792    let mut outside = Vec::new();
793    if let Some(source_roots) = source_roots {
794        let mut kept = sources.keys().cloned().collect::<Vec<_>>();
795        source_roots.narrow(&mut kept, &mut outside, String::as_str);
796        let kept = kept.into_iter().collect::<BTreeSet<_>>();
797        sources.retain(|relative, _| kept.contains(relative));
798        source_roots
799            .refuse_if_empty(sources.len(), "Rust")
800            .map_err(RustProjectError::SourceRoots)?;
801    }
802    let runtime_path = format!("crate::{runtime_module}");
803    let mut manifest = CoverageManifest {
804        unmeasured: Vec::new(),
805        decisions: Vec::new(),
806        points: Vec::new(),
807        branches: Vec::new(),
808        limitations: Vec::new(),
809        scope: None,
810    };
811    for (relative, source) in &sources {
812        let transformed =
813            instrument_rust_source(relative, source, &runtime_path).map_err(|error| {
814                RustProjectError::Instrument {
815                    file: relative.clone(),
816                    reason: error.to_string(),
817                }
818            })?;
819        merge_manifest(&mut manifest, transformed.manifest)?;
820        fs::write(workspace.join(relative), transformed.code).map_err(|error| {
821            RustProjectError::Io {
822                path: workspace.join(relative),
823                reason: error.to_string(),
824            }
825        })?;
826    }
827
828    preparation.instrument_ms = elapsed(started);
829    let started = std::time::Instant::now();
830    decline_proc_macro_obligations(&workspace, &proc_macro_roots, &mut manifest)?;
831
832    let token = manifest_token(&manifest);
833    let mut crate_roots = Vec::new();
834    for root in roots {
835        let relative = confined_relative(&workspace, &root)?;
836        let runtime = render_rust_runtime(&runtime_module, &crate_key(&token, &relative))
837            .map_err(RustProjectError::Runtime)?;
838        let mut source = fs::read_to_string(&root).map_err(|error| RustProjectError::Io {
839            path: root.clone(),
840            reason: error.to_string(),
841        })?;
842        source.push('\n');
843        source.push_str(&runtime);
844        fs::write(&root, source).map_err(|error| RustProjectError::Io {
845            path: root,
846            reason: error.to_string(),
847        })?;
848        crate_roots.push(relative);
849    }
850    preparation.runtime_ms = elapsed(started);
851
852    manifest
853        .points
854        .sort_by(|left, right| left.id.cmp(&right.id));
855    manifest
856        .decisions
857        .sort_by(|left, right| left.id.cmp(&right.id));
858    manifest
859        .branches
860        .sort_by(|left, right| left.id.cmp(&right.id));
861    manifest.limitations.sort_by(|left, right| {
862        left.get("id")
863            .and_then(|value| value.as_str())
864            .cmp(&right.get("id").and_then(|value| value.as_str()))
865    });
866    let target_directory = metadata.target_directory;
867    let target_directory = if target_directory.is_absolute() {
868        target_directory
869    } else {
870        workspace.join(target_directory)
871    };
872    if !target_directory.starts_with(&workspace) {
873        return Err(RustProjectError::UnsafePath(
874            target_directory.display().to_string(),
875        ));
876    }
877    // Only when roots shaped what is measured, so default output is unchanged.
878    if let Some(source_roots) = source_roots {
879        let kept = sources.keys().cloned().collect::<Vec<_>>();
880        let scope = source_roots
881            .scope(&kept, &outside)
882            .map_err(|error| RustProjectError::SourceRoots(error.to_string()))?;
883        manifest.scope = Some(
884            serde_json::to_value(scope)
885                .map_err(|error| RustProjectError::SourceRoots(error.to_string()))?,
886        );
887    }
888    Ok(PreparedRustProject {
889        workspace_root: workspace,
890        target_directory,
891        source_files: sources.into_keys().collect(),
892        crate_roots,
893        runtime_module,
894        manifest,
895        preparation,
896    })
897}
898
899#[cfg(test)]
900mod tests {
901    use std::{
902        process::Command,
903        sync::atomic::{AtomicU64, Ordering},
904        time::{SystemTime, UNIX_EPOCH},
905    };
906
907    use super::*;
908
909    fn fixture() -> PathBuf {
910        // One test calls this today, so nothing can collide with it yet. The
911        // counter is here because the clock is not enough on its own: it ticks
912        // once per microsecond and every test shares the pid, so the second
913        // test to use this helper would draw the same root as the first when
914        // the two start together.
915        static UNIQUE: AtomicU64 = AtomicU64::new(0);
916        let nonce = SystemTime::now()
917            .duration_since(UNIX_EPOCH)
918            .unwrap()
919            .as_nanos();
920        let root = std::env::temp_dir().join(format!(
921            "supercov-rust-project-{}-{nonce}-{}",
922            std::process::id(),
923            UNIQUE.fetch_add(1, Ordering::Relaxed)
924        ));
925        fs::create_dir(&root).unwrap();
926        fs::create_dir(root.join("src")).unwrap();
927        fs::create_dir(root.join("tests")).unwrap();
928        fs::write(
929            root.join("Cargo.toml"),
930            "[package]\nname='rust-project-fixture'\nversion='0.0.0'\nedition='2024'\n",
931        )
932        .unwrap();
933        fs::write(
934            root.join("src/lib.rs"),
935            r#"pub fn choose(first: bool, second: bool) -> i32 {
936    if first && second { 7 } else { 3 }
937}
938
939#[cfg(test)]
940mod tests {
941    #[test]
942    fn unit_choice() {
943        assert_eq!(super::choose(true, true), 7);
944    }
945}
946"#,
947        )
948        .unwrap();
949        fs::write(
950            root.join("tests/integration.rs"),
951            r#"#[test]
952fn integration_choice() {
953    assert_eq!(rust_project_fixture::choose(false, true), 3);
954}
955"#,
956        )
957        .unwrap();
958        root
959    }
960
961    #[test]
962    fn explicit_roots_narrow_what_rust_instruments() {
963        // A module the roots leave out is not instrumented, so it compiles as
964        // the author wrote it. The runtime module is still declared in the
965        // crate root whether or not that root is kept, which is what lets the
966        // modules that are kept refer to it.
967        let root = fixture();
968        fs::write(
969            root.join("Cargo.toml"),
970            "[package]\nname='rust_project_fixture'\nversion='0.0.0'\nedition='2024'\n",
971        )
972        .unwrap();
973        fs::write(
974            root.join("src/lib.rs"),
975            "mod helper;\nmod vendored;\npub fn entry(flag: bool) -> i32 { if flag { helper::one() } else { vendored::two() } }\n",
976        )
977        .unwrap();
978        fs::write(root.join("src/helper.rs"), "pub fn one() -> i32 { 1 }\n").unwrap();
979        let vendored = "pub fn two() -> i32 { 2 }\n";
980        fs::write(root.join("src/vendored.rs"), vendored).unwrap();
981
982        let roots = crate::source_discovery::ExplicitSourceRoots::resolve(
983            &root,
984            &["src/lib.rs".to_owned(), "src/helper.rs".to_owned()],
985        )
986        .unwrap();
987        let prepared = prepare_rust_project(&root, Some(&roots)).unwrap();
988        assert_eq!(prepared.source_files, ["src/helper.rs", "src/lib.rs"]);
989        assert!(
990            prepared
991                .manifest
992                .points
993                .iter()
994                .all(|point| point.file != "src/vendored.rs"),
995            "a module outside the roots carries no obligations"
996        );
997        assert_eq!(
998            fs::read_to_string(root.join("src/vendored.rs")).unwrap(),
999            vendored,
1000            "and is left exactly as written"
1001        );
1002        let scope: crate::source_discovery::SourceScope =
1003            serde_json::from_value(prepared.manifest.scope.clone().expect("a scope")).unwrap();
1004        assert_eq!(
1005            scope.mode,
1006            crate::source_discovery::SourceScopeMode::Explicit
1007        );
1008        assert!(
1009            scope
1010                .entries
1011                .iter()
1012                .any(|entry| entry.file == "src/vendored.rs"
1013                    && entry.reason == "outside explicit source roots"),
1014            "{scope:?}"
1015        );
1016        fs::remove_dir_all(root).ok();
1017    }
1018
1019    #[test]
1020    fn a_proc_macro_crates_own_code_is_declined() {
1021        // async-trait, serde_derive and thiserror-impl compile to compiler
1022        // plugins: rustc loads them while building the crate under test and
1023        // runs them there, so no test process executes a line of them and
1024        // every file read 0%.
1025        let root = fixture();
1026        fs::write(
1027            root.join("Cargo.toml"),
1028            concat!(
1029                "[package]\nname='rust_project_fixture'\nversion='0.0.0'\nedition='2024'\n",
1030                "\n[lib]\nproc-macro=true\n",
1031            ),
1032        )
1033        .unwrap();
1034        fs::write(
1035            root.join("src/lib.rs"),
1036            "mod helper;\npub fn entry(flag: bool) -> i32 { if flag { helper::one() } else { 0 } }\n",
1037        )
1038        .unwrap();
1039        fs::write(root.join("src/helper.rs"), "pub fn one() -> i32 { 1 }\n").unwrap();
1040
1041        let prepared = prepare_rust_project(&root, None).unwrap();
1042        // The files are still instrumented and still in the manifest; their
1043        // obligations are declined, and a limitation says why.
1044        let declined = prepared.manifest.unmeasured.iter().collect::<BTreeSet<_>>();
1045        assert!(!declined.is_empty());
1046        for point in &prepared.manifest.points {
1047            let plugin = point.file == "src/lib.rs" || point.file == "src/helper.rs";
1048            assert_eq!(declined.contains(&point.id), plugin, "{}", point.file);
1049        }
1050        let reasons = prepared
1051            .manifest
1052            .limitations
1053            .iter()
1054            .filter_map(|limitation| limitation.get("id")?.as_str())
1055            .filter(|id| id.starts_with("rust-proc-macro-runs-in-the-compiler#"))
1056            .collect::<BTreeSet<_>>();
1057        assert_eq!(
1058            reasons,
1059            BTreeSet::from([
1060                "rust-proc-macro-runs-in-the-compiler#src/helper.rs",
1061                "rust-proc-macro-runs-in-the-compiler#src/lib.rs",
1062            ])
1063        );
1064        fs::remove_dir_all(&root).ok();
1065    }
1066
1067    #[test]
1068    fn modules_declared_inside_a_macro_are_instrumented() {
1069        // hashbrown selects its SIMD group with `cfg_select! { ... mod neon;
1070        // ... }`, and `cfg_if!` has the same shape; the declaration lives in
1071        // the macro's token tree, which no item walk reaches. Both arms are
1072        // followed -- the build decides which one is in the denominator.
1073        let root = fixture();
1074        fs::write(
1075            root.join("src/lib.rs"),
1076            concat!(
1077                "macro_rules! select { ($($rest:tt)*) => { $($rest)* } }\n",
1078                "select! {\n",
1079                "    #[cfg(target_endian = \"little\")]\n",
1080                "    mod little;\n",
1081                "    #[cfg(not(target_endian = \"little\"))]\n",
1082                "    mod big;\n",
1083                "    mod inline { pub fn here() -> i32 { 1 } }\n",
1084                "}\n",
1085                "pub fn value() -> i32 { inline::here() }\n",
1086            ),
1087        )
1088        .unwrap();
1089        fs::write(root.join("src/little.rs"), "pub fn v() -> i32 { 1 }\n").unwrap();
1090        fs::write(root.join("src/big.rs"), "pub fn v() -> i32 { 2 }\n").unwrap();
1091
1092        let prepared = prepare_rust_project(&root, None).unwrap();
1093        // Both arms, and never the inline module, which has no file.
1094        assert_eq!(
1095            prepared.source_files,
1096            [
1097                "src/big.rs",
1098                "src/lib.rs",
1099                "src/little.rs",
1100                "tests/integration.rs"
1101            ],
1102            "{:?}",
1103            prepared.source_files
1104        );
1105        fs::remove_dir_all(&root).ok();
1106    }
1107
1108    #[test]
1109    fn only_files_the_module_tree_reaches_are_instrumented() {
1110        let root = fixture();
1111        fs::create_dir_all(root.join("src/nested")).unwrap();
1112        fs::create_dir_all(root.join("src/deep/inner")).unwrap();
1113        fs::create_dir_all(root.join("runtime-assets")).unwrap();
1114        fs::write(
1115            root.join("src/lib.rs"),
1116            concat!(
1117                "mod util;\n",
1118                "mod nested;\n",
1119                "#[path = \"renamed_file.rs\"]\n",
1120                "mod renamed;\n",
1121                "mod deep;\n",
1122                "include!(\"included.rs\");\n",
1123                "pub const EMBEDDED: &str = include_str!(\"../runtime-assets/embedded.rs\");\n",
1124                "pub fn choose(first: bool, second: bool) -> i32 {\n",
1125                "    if first && second { util::seven() } else { nested::three() }\n",
1126                "}\n",
1127            ),
1128        )
1129        .unwrap();
1130        fs::write(root.join("src/util.rs"), "pub fn seven() -> i32 { 7 }\n").unwrap();
1131        fs::write(
1132            root.join("src/nested/mod.rs"),
1133            "mod leaf;\npub fn three() -> i32 { leaf::three() }\n",
1134        )
1135        .unwrap();
1136        fs::write(
1137            root.join("src/nested/leaf.rs"),
1138            "pub fn three() -> i32 { 3 }\n",
1139        )
1140        .unwrap();
1141        fs::write(
1142            root.join("src/renamed_file.rs"),
1143            "pub fn renamed() -> i32 { 1 }\n",
1144        )
1145        .unwrap();
1146        fs::write(
1147            root.join("src/deep.rs"),
1148            "pub mod inner {\n    mod block_child;\n    pub fn deep() -> i32 { block_child::v() }\n}\n",
1149        )
1150        .unwrap();
1151        fs::write(
1152            root.join("src/deep/inner/block_child.rs"),
1153            "pub fn v() -> i32 { 9 }\n",
1154        )
1155        .unwrap();
1156        fs::write(
1157            root.join("src/included.rs"),
1158            "pub fn included() -> i32 { 2 }\n",
1159        )
1160        .unwrap();
1161        // serde_json's tests reach into src with `#[path = "../src/..."]`.
1162        fs::write(
1163            root.join("tests/integration.rs"),
1164            concat!(
1165                "#[path = \"../src/util.rs\"]\n",
1166                "mod util;\n",
1167                "#[test]\n",
1168                "fn integration_choice() {\n",
1169                "    assert_eq!(rust_project_fixture::choose(false, true), 3);\n",
1170                "    assert_eq!(util::seven(), 7);\n",
1171                "}\n",
1172            ),
1173        )
1174        .unwrap();
1175        // Data, not code: embedded verbatim and compiled by a consumer of
1176        // its own, which would not know any runtime module of ours.
1177        let embedded = "pub fn standalone() -> i32 { if true { 1 } else { 0 } }\n";
1178        fs::write(root.join("runtime-assets/embedded.rs"), embedded).unwrap();
1179        fs::write(
1180            root.join("src/orphan.rs"),
1181            "pub fn unreachable_module() {}\n",
1182        )
1183        .unwrap();
1184
1185        let prepared = prepare_rust_project(&root, None).unwrap();
1186        assert_eq!(
1187            prepared.source_files,
1188            [
1189                "src/deep.rs",
1190                "src/deep/inner/block_child.rs",
1191                "src/included.rs",
1192                "src/lib.rs",
1193                "src/nested/leaf.rs",
1194                "src/nested/mod.rs",
1195                "src/renamed_file.rs",
1196                "src/util.rs",
1197                "tests/integration.rs",
1198            ]
1199        );
1200        assert_eq!(
1201            fs::read_to_string(root.join("runtime-assets/embedded.rs")).unwrap(),
1202            embedded
1203        );
1204        assert!(
1205            !fs::read_to_string(root.join("src/orphan.rs"))
1206                .unwrap()
1207                .contains("__supercov")
1208        );
1209        assert!(
1210            fs::read_to_string(root.join("src/deep/inner/block_child.rs"))
1211                .unwrap()
1212                .contains("__supercov")
1213        );
1214        let build = Command::new("cargo")
1215            .args(["test", "--no-run"])
1216            .current_dir(&root)
1217            .env("CARGO_TARGET_DIR", &prepared.target_directory)
1218            .output()
1219            .unwrap();
1220        assert!(
1221            build.status.success(),
1222            "{}",
1223            String::from_utf8_lossy(&build.stderr)
1224        );
1225        fs::remove_dir_all(root).unwrap();
1226    }
1227
1228    #[cfg(unix)]
1229    #[test]
1230    fn a_module_shared_through_a_symlink_is_instrumented_once() {
1231        let root = fixture();
1232        fs::write(root.join("src/shared.rs"), "pub fn shared() -> i32 { 5 }\n").unwrap();
1233        std::os::unix::fs::symlink("../src/shared.rs", root.join("tests/shared.rs")).unwrap();
1234        fs::write(
1235            root.join("src/lib.rs"),
1236            concat!(
1237                "pub mod shared;\n",
1238                "pub fn choose(first: bool, second: bool) -> i32 {\n",
1239                "    if first && second { 7 } else { shared::shared() }\n",
1240                "}\n",
1241            ),
1242        )
1243        .unwrap();
1244        fs::write(
1245            root.join("tests/integration.rs"),
1246            concat!(
1247                "mod shared;\n",
1248                "#[test]\n",
1249                "fn integration_choice() {\n",
1250                "    assert_eq!(rust_project_fixture::choose(false, true), 5);\n",
1251                "    assert_eq!(shared::shared(), 5);\n",
1252                "}\n",
1253            ),
1254        )
1255        .unwrap();
1256        let prepared = prepare_rust_project(&root, None).unwrap();
1257        // The target's path, once; never the symlink's spelling.
1258        let shared = prepared
1259            .source_files
1260            .iter()
1261            .filter(|file| file.ends_with("shared.rs"))
1262            .collect::<Vec<_>>();
1263        assert_eq!(shared, ["src/shared.rs"], "{:?}", prepared.source_files);
1264        // The one function in it carries one function probe: instrumented
1265        // once, through whichever spelling reached it first.
1266        let instrumented = fs::read_to_string(root.join("src/shared.rs")).unwrap();
1267        assert_eq!(instrumented.matches("rs:function:").count(), 1);
1268        let build = Command::new("cargo")
1269            .args(["test", "--no-run"])
1270            .current_dir(&root)
1271            .env("CARGO_TARGET_DIR", &prepared.target_directory)
1272            .output()
1273            .unwrap();
1274        assert!(
1275            build.status.success(),
1276            "{}",
1277            String::from_utf8_lossy(&build.stderr)
1278        );
1279        fs::remove_dir_all(root).unwrap();
1280    }
1281
1282    #[test]
1283    fn crate_keys_carry_the_manifest_token() {
1284        let root = fixture();
1285        let prepared = prepare_rust_project(&root, None).unwrap();
1286        let token = manifest_token(&prepared.manifest);
1287        assert_eq!(token.len(), 12);
1288        assert!(token.bytes().all(|byte| byte.is_ascii_hexdigit()));
1289        assert_eq!(token, manifest_token(&prepared.manifest));
1290        let key = crate_key(&token, "src/lib.rs");
1291        assert_eq!(key.len(), 24);
1292        assert!(key.starts_with(&token));
1293        assert_ne!(key, crate_key(&token, "tests/integration.rs"));
1294        for crate_root in &prepared.crate_roots {
1295            assert!(
1296                fs::read_to_string(root.join(crate_root))
1297                    .unwrap()
1298                    .contains(&crate_key(&token, crate_root))
1299            );
1300        }
1301        fs::remove_dir_all(root).unwrap();
1302    }
1303
1304    #[test]
1305    fn prepares_every_workspace_crate_root_and_compiles_without_manifest_changes() {
1306        let root = fixture();
1307        let manifest_before = fs::read(root.join("Cargo.toml")).unwrap();
1308        let prepared = prepare_rust_project(&root, None).unwrap();
1309        assert_eq!(
1310            prepared.source_files,
1311            ["src/lib.rs", "tests/integration.rs"]
1312        );
1313        assert_eq!(prepared.crate_roots, ["src/lib.rs", "tests/integration.rs"]);
1314        assert!(!prepared.manifest.points.is_empty());
1315        assert!(!prepared.manifest.decisions.is_empty());
1316        assert_eq!(fs::read(root.join("Cargo.toml")).unwrap(), manifest_before);
1317        for crate_root in &prepared.crate_roots {
1318            assert!(
1319                fs::read_to_string(root.join(crate_root))
1320                    .unwrap()
1321                    .contains(&format!("mod {}", prepared.runtime_module))
1322            );
1323        }
1324        let build = Command::new("cargo")
1325            .args(["test", "--no-run"])
1326            .current_dir(&root)
1327            .env("CARGO_TARGET_DIR", &prepared.target_directory)
1328            .output()
1329            .unwrap();
1330        assert!(
1331            build.status.success(),
1332            "{}",
1333            String::from_utf8_lossy(&build.stderr)
1334        );
1335        fs::remove_dir_all(root).unwrap();
1336    }
1337}