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