Skip to main content

supercov_engine/
rust_run.rs

1//! Public, isolated Rust coverage run lifecycle.
2
3use std::{
4    collections::BTreeSet,
5    fs,
6    io::Write,
7    path::{Path, PathBuf},
8    time::Instant,
9};
10
11use serde::{Deserialize, Serialize};
12
13use crate::{
14    evidence_archive::write_archive,
15    integrity::{ExplicitIntegrityInputs, FrontendIntegrityInputs, create_explicit_run_integrity},
16    lifecycle::{
17        ProjectLock, finalize_published_run, publish_run, recover_abandoned_runs,
18        remove_stored_tree_deferred,
19    },
20    run_store::{InstrumentedBuildCache, RawEvidenceMetadata, RunMetadata, RunTimings},
21    rust_build_cache::{
22        read_rust_build_cache, rust_build_cache_key, rust_target_directory, write_rust_build_cache,
23    },
24    rust_project::{PreparedRustProject, prepare_rust_project_for},
25    rust_test_runner::run_prepared_rust_tests,
26    workspace::{cached_workspace_path, prepare_cached_workspace, recover_cached_workspace},
27};
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct DirectRustRunRequest {
32    pub root: PathBuf,
33    pub command: Vec<String>,
34    pub run_id: String,
35    pub started_at: String,
36}
37
38#[derive(Debug, Clone, PartialEq)]
39pub struct DirectRustRunResult {
40    pub run_id: String,
41    pub run_directory: PathBuf,
42    pub exit_code: i32,
43    pub tests: usize,
44    pub artifacts: usize,
45    pub recovered_runs: Vec<String>,
46    pub metadata: RunMetadata,
47}
48
49fn elapsed_ms(started: Instant) -> f64 {
50    (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
51}
52
53const ROOT_INPUT_EXCLUSIONS: &[&str] = &[
54    ".cache",
55    ".git",
56    ".supercov",
57    ".mcdc-pool",
58    "node_modules",
59    "target",
60    "build",
61    "dist",
62    ".next",
63    ".nuxt",
64    ".output",
65    "coverage",
66    "playwright-report",
67    "test-results",
68];
69
70/// An LLVM profile, written to the working directory by default: what a
71/// binary built for coverage leaves, never what a build reads. Counted as
72/// configuration, writing one -- to import its lines -- made every run stale.
73fn is_coverage_output(name: &str) -> bool {
74    let extension = Path::new(name).extension().and_then(|value| value.to_str());
75    matches!(extension, Some("profraw" | "profdata"))
76}
77
78fn collect_project_inputs(
79    root: &Path,
80    directory: &Path,
81    root_level: bool,
82    regular: &mut Vec<PathBuf>,
83    links: &mut Vec<String>,
84) -> Result<(), String> {
85    let mut entries = fs::read_dir(directory)
86        .map_err(|error| format!("{}: {error}", directory.display()))?
87        .collect::<Result<Vec<_>, _>>()
88        .map_err(|error| error.to_string())?;
89    entries.sort_by_key(fs::DirEntry::file_name);
90    for entry in entries {
91        let path = entry.path();
92        let name = entry
93            .file_name()
94            .into_string()
95            .map_err(|_| format!("Rust project contains a non-UTF-8 path: {}", path.display()))?;
96        if (root_level && ROOT_INPUT_EXCLUSIONS.contains(&name.as_str()))
97            // wrangler's local state (`.wrangler/`), which `wrangler dev`
98            // writes beside a Worker: what it served, never what Cargo reads.
99            || matches!(name.as_str(), ".supercov" | ".mcdc-pool" | ".wrangler")
100            || is_coverage_output(&name)
101        {
102            continue;
103        }
104        let file_type = entry.file_type().map_err(|error| error.to_string())?;
105        if file_type.is_dir() {
106            // What a build wrote, wherever it lies: a Cargo target directory
107            // of a crate in a subdirectory, the wasm wasm-bindgen generated.
108            // Rebuilding is not a change to the project.
109            if build_output_directory(&path) {
110                continue;
111            }
112            collect_project_inputs(root, &path, false, regular, links)?;
113        } else if file_type.is_file() {
114            let relative = path
115                .strip_prefix(root)
116                .map_err(|_| format!("project input escaped root: {}", path.display()))?;
117            regular.push(relative.to_owned());
118        } else if file_type.is_symlink() {
119            let relative = path
120                .strip_prefix(root)
121                .map_err(|_| format!("project link escaped root: {}", path.display()))?;
122            let target = fs::read_link(&path).map_err(|error| error.to_string())?;
123            links.push(format!(
124                "{}=>{}",
125                relative.to_string_lossy().replace('\\', "/"),
126                target.to_string_lossy().replace('\\', "/")
127            ));
128        } else {
129            return Err(format!(
130                "unsupported Rust project input: {}",
131                path.display()
132            ));
133        }
134    }
135    Ok(())
136}
137
138/// A Cargo target directory, told by what Cargo keeps in it, or the output
139/// of wasm-bindgen, told by its `<name>_bg.wasm`.
140fn build_output_directory(path: &Path) -> bool {
141    if path.join(".rustc_info.json").is_file()
142        || path.join(".cargo-lock").is_file()
143        || fs::read(path.join("CACHEDIR.TAG"))
144            .is_ok_and(|tag| tag.windows(5).any(|window| window == b"cargo"))
145    {
146        return true;
147    }
148    fs::read_dir(path).is_ok_and(|entries| {
149        entries.flatten().any(|entry| {
150            entry
151                .file_name()
152                .to_str()
153                .is_some_and(|name| name.ends_with("_bg.wasm"))
154        })
155    })
156}
157
158/// The files the project's `.gitignore` files leave in, relative to `root`;
159/// None outside a git repository, where nothing is ignored.
160fn kept_by_gitignore(root: &Path) -> Option<BTreeSet<PathBuf>> {
161    if !root
162        .ancestors()
163        .any(|ancestor| ancestor.join(".git").exists())
164    {
165        return None;
166    }
167    let walker = ignore::WalkBuilder::new(root)
168        .standard_filters(false)
169        .git_ignore(true)
170        .parents(!crate::source_discovery::ignored_by_enclosing_repository(
171            root,
172        ))
173        .require_git(true)
174        .follow_links(false)
175        .build();
176    Some(
177        walker
178            .filter_map(Result::ok)
179            .filter_map(|entry| entry.path().strip_prefix(root).ok().map(Path::to_owned))
180            .collect(),
181    )
182}
183
184pub(crate) fn collect_integrity_inputs(
185    root: &Path,
186    command: &[String],
187) -> Result<ExplicitIntegrityInputs, String> {
188    let mut files = Vec::new();
189    let mut links = Vec::new();
190    collect_project_inputs(root, root, true, &mut files, &mut links)?;
191    // What the project's own `.gitignore` excludes is no input a checkout
192    // carries: a `.zig-cache/` cargo-zigbuild leaves, a local `.env`. Counted
193    // as configuration, it made a run copied to CI read as stale. Only the
194    // project's `.gitignore` files decide, inside a git repository, as for
195    // source discovery: global excludes differ between machines.
196    let tracked = kept_by_gitignore(root);
197    files.retain(|file| tracked.as_ref().is_none_or(|kept| kept.contains(file)));
198    files.sort();
199    files.dedup();
200    links.sort();
201    links.dedup();
202    let source_files = files
203        .iter()
204        .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("rs"))
205        .cloned()
206        .collect::<Vec<_>>();
207    // Inline `#[cfg(test)]` modules make every Rust source file a possible test
208    // input. Hashing the same file in both domains is intentional and prevents
209    // stale reuse when only an inline test changes.
210    let test_files = source_files.clone();
211    let dependency_files = files
212        .iter()
213        .filter(|path| {
214            path.file_name()
215                .and_then(|value| value.to_str())
216                .is_some_and(|name| matches!(name, "Cargo.toml" | "Cargo.lock"))
217        })
218        .cloned()
219        .collect::<Vec<_>>();
220    let source_set = source_files.iter().cloned().collect::<BTreeSet<_>>();
221    let dependency_set = dependency_files.iter().cloned().collect::<BTreeSet<_>>();
222    let configuration_files = files
223        .into_iter()
224        .filter(|path| !source_set.contains(path) && !dependency_set.contains(path))
225        .collect();
226    let mut execution_configuration = command.join("\0").into_bytes();
227    for link in links {
228        execution_configuration.push(0);
229        execution_configuration.extend_from_slice(link.as_bytes());
230    }
231    Ok(ExplicitIntegrityInputs {
232        source_files,
233        test_files,
234        dependency_files,
235        configuration_files,
236        execution_configuration,
237    })
238}
239
240pub fn current_rust_integrity(
241    root: &Path,
242    command: &[String],
243    source_roots: Option<&[String]>,
244) -> Result<crate::run_store::RunIntegrity, String> {
245    let root = fs::canonicalize(root).map_err(|error| error.to_string())?;
246    let mut inputs = collect_integrity_inputs(&root, command)?;
247    crate::source_discovery::fold_roots_into_configuration(
248        &mut inputs.execution_configuration,
249        source_roots,
250    );
251    create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_rust())
252        .map_err(|error| error.to_string())
253}
254
255pub fn run_direct_rust(
256    request: &DirectRustRunRequest,
257    diagnostics: &mut dyn Write,
258) -> Result<DirectRustRunResult, String> {
259    if request.command.is_empty() {
260        return Err("test command must not be empty".into());
261    }
262    // The shared probe runtime maps its evidence file and hooks thread and
263    // process creation on macOS, Linux and Windows; on any other host every
264    // probe is a no-op, so a run there would report zero coverage without a
265    // word of explanation. Refuse plainly instead.
266    if !cfg!(any(
267        target_os = "macos",
268        target_os = "linux",
269        target_os = "windows"
270    )) {
271        return Err(
272            "Rust suites are not supported on this platform: the probe transport has no implementation here, so a run would measure nothing"
273                .into(),
274        );
275    }
276    let total_started = Instant::now();
277    let initialization_started = Instant::now();
278    let root = fs::canonicalize(&request.root)
279        .map_err(|error| format!("{}: {error}", request.root.display()))?;
280    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
281        .map_err(|error| error.to_string())?;
282    let initialization_ms = elapsed_ms(initialization_started);
283    let result = (|| {
284        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
285            .map_err(|error| error.to_string())?;
286        if !recovered_runs.is_empty() {
287            writeln!(
288                diagnostics,
289                "[supercov] recovered abandoned run(s): {}",
290                recovered_runs.join(", ")
291            )
292            .map_err(|error| error.to_string())?;
293        }
294
295        // `wasm-pack test` is learned once and then run as the `cargo test` it
296        // stands for; the run keeps the command it was given.
297        let learned = if crate::rust_wasm_pack::is_wasm_pack_test(&request.command) {
298            Some(crate::rust_wasm_pack::learn(
299                &root,
300                &request.command,
301                &root.join(".supercov/work").join(&request.run_id),
302            )?)
303        } else {
304            None
305        };
306        let command: &[String] = learned
307            .as_ref()
308            .map_or(&request.command, |learned| &learned.command);
309        let runner_environment = learned
310            .as_ref()
311            .map(|learned| learned.runner_environment.clone())
312            .unwrap_or_default();
313        let adapter_started = Instant::now();
314        let source_roots = crate::source_discovery::configured_source_roots(
315            &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
316        );
317        let mut integrity_inputs = collect_integrity_inputs(&root, &request.command)?;
318        crate::source_discovery::fold_roots_into_configuration(
319            &mut integrity_inputs.execution_configuration,
320            source_roots.as_deref(),
321        );
322        let source_inputs =
323            crate::source_capture::capture(&root, "rust", integrity_inputs.assertion_paths())?;
324        let integrity = create_explicit_run_integrity(
325            &root,
326            &integrity_inputs,
327            &FrontendIntegrityInputs::embedded_rust(),
328        )
329        .map_err(|error| error.to_string())?;
330        let build_cache_key = rust_build_cache_key(&integrity, &request.command)
331            .map_err(|error| error.to_string())?;
332
333        let workspace_started = Instant::now();
334        recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
335        let workspace = cached_workspace_path(&root, &lock).map_err(|error| error.to_string())?;
336        if let Some(planted) = crate::workspace::planted_cargo_configuration(&root, &workspace) {
337            return Err(format!(
338                "{} would change how Supercov builds the project but not how `cargo test` does; remove it",
339                planted.display()
340            ));
341        }
342        let target_directory = rust_target_directory(&root);
343        let cache_started = Instant::now();
344        let cached = read_rust_build_cache(&workspace, &target_directory, &build_cache_key);
345        let cache_read_ms = elapsed_ms(cache_started);
346        let mut copy_ms = 0.0;
347        let reused_build = cached.is_some();
348        let mut project = if let Some(cached) = cached {
349            writeln!(
350                diagnostics,
351                "[supercov] detected Rust; reusing authenticated instrumented workspace {}",
352                workspace.display()
353            )
354            .map_err(|error| error.to_string())?;
355            PreparedRustProject {
356                workspace_root: workspace.clone(),
357                target_directory: target_directory.clone(),
358                source_files: cached.source_files,
359                crate_roots: Vec::new(),
360                runtime_module: String::new(),
361                manifest: cached.manifest,
362                probes: cached.probes,
363                preparation: Default::default(),
364            }
365        } else {
366            let copy_started = Instant::now();
367            let workspace =
368                prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
369            copy_ms = elapsed_ms(copy_started);
370            writeln!(
371                diagnostics,
372                "[supercov] detected Rust; instrumenting isolated Cargo workspace {}",
373                workspace.display()
374            )
375            .map_err(|error| error.to_string())?;
376            let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
377            let roots = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
378                &root, &workspace, &ambient,
379            )
380            .map_err(|error| error.to_string())?;
381            // The target the command builds for decides which `#[cfg]` items
382            // exist; when it cannot be read, every item is measured.
383            let cfg = crate::rust_test_runner::cargo_invocation(&workspace, command)
384                .ok()
385                .and_then(|invocation| {
386                    crate::rust_cargo_configuration::active_target_cfg(&workspace, &invocation)
387                })
388                .map(|values| crate::rust_instrumenter::TargetCfg::new(&values));
389            prepare_rust_project_for(&workspace, roots.as_ref(), cfg.as_ref())
390                .map_err(|error| error.to_string())?
391        };
392        project.target_directory = target_directory;
393        fs::create_dir_all(&project.target_directory).map_err(|error| error.to_string())?;
394        let workspace_preparation_ms = elapsed_ms(workspace_started);
395        let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
396        if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() == Ok("1") {
397            let preparation = &project.preparation;
398            writeln!(
399                diagnostics,
400                "[supercov] workspace timings cache-check={cache_read_ms:.1}ms copy={copy_ms:.1}ms metadata={:.1}ms discovery={:.1}ms instrument={:.1}ms runtime={:.1}ms",
401                preparation.metadata_ms,
402                preparation.discovery_ms,
403                preparation.instrument_ms,
404                preparation.runtime_ms,
405            )
406            .map_err(|error| error.to_string())?;
407        }
408
409        let nextest = request
410            .command
411            .windows(2)
412            .any(|pair| pair == ["nextest", "run"]);
413        writeln!(
414            diagnostics,
415            "{}",
416            if nextest {
417                "[supercov] running cargo nextest with Supercov as its target runner, each attempt in its own process"
418            } else {
419                "[supercov] building once and running each libtest case and doctest in its own process"
420            }
421        )
422        .map_err(|error| error.to_string())?;
423        // What is in the copy now is Supercov's; what the command adds to
424        // it or changes is the command's, and belongs in the project.
425        // A test that starts a Rust Worker with `wrangler dev` finds
426        // Supercov's `wrangler` first, which reads what the Worker ran into
427        // that test's evidence. Prepared before the copy is taken stock of:
428        // the configurations it points at Supercov's entry are Supercov's.
429        let rust_workers = crate::rust_worker::CargoTestWorkers::prepare(
430            &workspace,
431            &workspace
432                .join(".supercov/rust-evidence")
433                .join(&request.run_id)
434                .join("wrangler"),
435        )?;
436        let before = crate::workspace::workspace_output_baseline(&workspace)
437            .map_err(|error| error.to_string())?;
438        let run = run_prepared_rust_tests(
439            &project,
440            command,
441            &request.run_id,
442            &request.started_at,
443            rust_workers.as_ref(),
444            &runner_environment,
445            diagnostics,
446        )
447        .map_err(|error| error.to_string())?;
448        // A snapshot a test accepted, a report: they stayed in the copy, and
449        // the project had the old ones or none.
450        let outputs = crate::workspace::sync_outputs_of(
451            crate::workspace::CommandOutputs::Rust,
452            &root,
453            &workspace,
454            &before,
455            &BTreeSet::new(),
456        )
457        .map_err(|error| error.to_string())?;
458        // What stayed is the lockfile and the sources, which nobody asked
459        // for: only what flowed back is said.
460        for line in crate::workspace::describe_command_outputs(&outputs)
461            .into_iter()
462            .filter(|line| !line.contains("stayed in the isolated workspace"))
463        {
464            writeln!(diagnostics, "{line}").map_err(|error| error.to_string())?;
465        }
466        write_rust_build_cache(
467            &root,
468            &workspace,
469            &build_cache_key,
470            &request.started_at,
471            &project.source_files,
472            // The manifest the run reported: pruned to what the build
473            // compiled, so a reused build reuses the same denominator.
474            &run.request.manifest,
475            &project.probes,
476            &run.artifact_files,
477        )?;
478
479        let publication_started = Instant::now();
480        let archive_path = root
481            .join(".supercov/work")
482            .join(&request.run_id)
483            .join("evidence.raw.gz");
484        let raw = write_archive(
485            crate::source_capture::append(
486                run.archive_entries().map_err(|error| error.to_string())?,
487                &source_inputs,
488            )?,
489            &archive_path,
490        )
491        .map_err(|error| error.to_string())?;
492        remove_stored_tree_deferred(
493            &root,
494            &workspace
495                .join(".supercov/rust-evidence")
496                .join(&request.run_id),
497        )
498        .map_err(|error| error.to_string())?;
499        let evidence_publication_ms = elapsed_ms(publication_started);
500        let timings = RunTimings {
501            initialization_ms,
502            workspace_preparation_ms,
503            adapter_setup_ms,
504            instrumented_build_ms: (run.build_ms * 10.0).round() / 10.0,
505            test_command_ms: (run.execution_ms * 10.0).round() / 10.0,
506            evidence_publication_ms,
507        };
508        let metadata = RunMetadata {
509            id: request.run_id.clone(),
510            started_at: request.started_at.clone(),
511            duration_ms: elapsed_ms(total_started),
512            command: request.command.clone(),
513            test_exit_code: Some(run.exit_code),
514            integrity,
515            raw_evidence: RawEvidenceMetadata {
516                schema_version: raw.schema_version,
517                format: raw.format.into(),
518                file: raw.file.into(),
519                files: raw.files,
520                uncompressed_bytes: raw.uncompressed_bytes,
521                compressed_bytes: raw.compressed_bytes,
522            },
523            isolated_build: Some(true),
524            instrumented_build_cache: Some(InstrumentedBuildCache {
525                key: build_cache_key,
526                reused: reused_build,
527            }),
528            timings: Some(timings),
529            merged: None,
530            parents: None,
531            source_roots: source_roots.clone(),
532            copy: Some(lock.copy()),
533            merged_across: None,
534            recorded_by: None,
535            companions: None,
536        };
537        let run_directory =
538            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
539        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
540        Ok(DirectRustRunResult {
541            run_id: request.run_id.clone(),
542            run_directory,
543            exit_code: run.exit_code,
544            // Tests, not attempts: a retried test is still one test.
545            tests: run
546                .request
547                .raw_results
548                .iter()
549                .map(|result| result.test.as_str())
550                .collect::<std::collections::BTreeSet<_>>()
551                .len(),
552            artifacts: run.artifacts,
553            recovered_runs,
554            metadata,
555        })
556    })();
557    if result.is_err() {
558        let _ =
559            remove_stored_tree_deferred(&root, &root.join(".supercov/work").join(&request.run_id));
560        if let Ok(workspace) = cached_workspace_path(&root, &lock) {
561            let _ = remove_stored_tree_deferred(
562                &root,
563                &workspace
564                    .join(".supercov/rust-evidence")
565                    .join(&request.run_id),
566            );
567        }
568    }
569    let release = lock.release().map_err(|error| error.to_string());
570    match (result, release) {
571        (Ok(result), Ok(())) => Ok(result),
572        (Err(error), _) => Err(error),
573        (Ok(_), Err(error)) => Err(error),
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    fn project(name: &str) -> PathBuf {
582        static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
583        let root = std::env::temp_dir().join(format!(
584            "supercov-rust-inputs-{}-{}-{name}",
585            std::process::id(),
586            N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
587        ));
588        fs::create_dir_all(root.join("src")).unwrap();
589        fs::create_dir_all(root.join(".zig-cache/o")).unwrap();
590        fs::write(root.join("Cargo.toml"), "[package]\nname = \"p\"\n").unwrap();
591        fs::write(root.join("src/lib.rs"), "").unwrap();
592        fs::write(root.join("rust-toolchain.toml"), "").unwrap();
593        fs::write(root.join(".zig-cache/o/build.o"), "x").unwrap();
594        fs::write(root.join(".gitignore"), ".zig-cache/\n").unwrap();
595        root
596    }
597
598    /// A build cache the project's `.gitignore` excludes is no configuration:
599    /// a run copied to CI, where it is absent, read as stale. Outside a git
600    /// repository nothing is ignored.
601    #[test]
602    fn what_the_project_ignores_in_git_is_no_input() {
603        let inside = project("git");
604        assert!(
605            std::process::Command::new("git")
606                .args(["init", "-q"])
607                .current_dir(&inside)
608                .status()
609                .unwrap()
610                .success()
611        );
612        let inputs = collect_integrity_inputs(&inside, &["cargo".into(), "test".into()]).unwrap();
613        let configuration = inputs
614            .configuration_files
615            .iter()
616            .map(|file| file.to_string_lossy().replace('\\', "/"))
617            .collect::<Vec<_>>();
618        assert!(
619            configuration.contains(&"rust-toolchain.toml".to_owned()),
620            "{configuration:?}"
621        );
622        assert!(
623            !configuration
624                .iter()
625                .any(|file| file.starts_with(".zig-cache")),
626            "{configuration:?}"
627        );
628        let outside = project("plain");
629        let inputs = collect_integrity_inputs(&outside, &["cargo".into(), "test".into()]).unwrap();
630        assert!(
631            inputs
632                .configuration_files
633                .iter()
634                .any(|file| file.starts_with(".zig-cache")),
635            "outside git nothing is ignored"
636        );
637        fs::remove_dir_all(&inside).unwrap();
638        fs::remove_dir_all(&outside).unwrap();
639    }
640}