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
70fn collect_project_inputs(
71    root: &Path,
72    directory: &Path,
73    root_level: bool,
74    regular: &mut Vec<PathBuf>,
75    links: &mut Vec<String>,
76) -> Result<(), String> {
77    let mut entries = fs::read_dir(directory)
78        .map_err(|error| format!("{}: {error}", directory.display()))?
79        .collect::<Result<Vec<_>, _>>()
80        .map_err(|error| error.to_string())?;
81    entries.sort_by_key(fs::DirEntry::file_name);
82    for entry in entries {
83        let path = entry.path();
84        let name = entry
85            .file_name()
86            .into_string()
87            .map_err(|_| format!("Rust project contains a non-UTF-8 path: {}", path.display()))?;
88        if (root_level && ROOT_INPUT_EXCLUSIONS.contains(&name.as_str()))
89            || matches!(name.as_str(), ".supercov" | ".mcdc-pool")
90        {
91            continue;
92        }
93        let file_type = entry.file_type().map_err(|error| error.to_string())?;
94        if file_type.is_dir() {
95            collect_project_inputs(root, &path, false, regular, links)?;
96        } else if file_type.is_file() {
97            let relative = path
98                .strip_prefix(root)
99                .map_err(|_| format!("project input escaped root: {}", path.display()))?;
100            regular.push(relative.to_owned());
101        } else if file_type.is_symlink() {
102            let relative = path
103                .strip_prefix(root)
104                .map_err(|_| format!("project link escaped root: {}", path.display()))?;
105            let target = fs::read_link(&path).map_err(|error| error.to_string())?;
106            links.push(format!(
107                "{}=>{}",
108                relative.to_string_lossy().replace('\\', "/"),
109                target.to_string_lossy().replace('\\', "/")
110            ));
111        } else {
112            return Err(format!(
113                "unsupported Rust project input: {}",
114                path.display()
115            ));
116        }
117    }
118    Ok(())
119}
120
121pub(crate) fn collect_integrity_inputs(
122    root: &Path,
123    command: &[String],
124) -> Result<ExplicitIntegrityInputs, String> {
125    let mut files = Vec::new();
126    let mut links = Vec::new();
127    collect_project_inputs(root, root, true, &mut files, &mut links)?;
128    files.sort();
129    files.dedup();
130    links.sort();
131    links.dedup();
132    let source_files = files
133        .iter()
134        .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("rs"))
135        .cloned()
136        .collect::<Vec<_>>();
137    // Inline `#[cfg(test)]` modules make every Rust source file a possible test
138    // input. Hashing the same file in both domains is intentional and prevents
139    // stale reuse when only an inline test changes.
140    let test_files = source_files.clone();
141    let dependency_files = files
142        .iter()
143        .filter(|path| {
144            path.file_name()
145                .and_then(|value| value.to_str())
146                .is_some_and(|name| matches!(name, "Cargo.toml" | "Cargo.lock"))
147        })
148        .cloned()
149        .collect::<Vec<_>>();
150    let source_set = source_files.iter().cloned().collect::<BTreeSet<_>>();
151    let dependency_set = dependency_files.iter().cloned().collect::<BTreeSet<_>>();
152    let configuration_files = files
153        .into_iter()
154        .filter(|path| !source_set.contains(path) && !dependency_set.contains(path))
155        .collect();
156    let mut execution_configuration = command.join("\0").into_bytes();
157    for link in links {
158        execution_configuration.push(0);
159        execution_configuration.extend_from_slice(link.as_bytes());
160    }
161    Ok(ExplicitIntegrityInputs {
162        source_files,
163        test_files,
164        dependency_files,
165        configuration_files,
166        execution_configuration,
167    })
168}
169
170pub fn current_rust_integrity(
171    root: &Path,
172    command: &[String],
173    source_roots: Option<&[String]>,
174) -> Result<crate::run_store::RunIntegrity, String> {
175    let root = fs::canonicalize(root).map_err(|error| error.to_string())?;
176    let mut inputs = collect_integrity_inputs(&root, command)?;
177    crate::source_discovery::fold_roots_into_configuration(
178        &mut inputs.execution_configuration,
179        source_roots,
180    );
181    create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_rust())
182        .map_err(|error| error.to_string())
183}
184
185pub fn run_direct_rust(
186    request: &DirectRustRunRequest,
187    diagnostics: &mut dyn Write,
188) -> Result<DirectRustRunResult, String> {
189    if request.command.is_empty() {
190        return Err("test command must not be empty".into());
191    }
192    // The shared probe runtime maps its evidence file and hooks thread and
193    // process creation on macOS, Linux and Windows; on any other host every
194    // probe is a no-op, so a run there would report zero coverage without a
195    // word of explanation. Refuse plainly instead.
196    if !cfg!(any(
197        target_os = "macos",
198        target_os = "linux",
199        target_os = "windows"
200    )) {
201        return Err(
202            "Rust suites are not supported on this platform: the probe transport has no implementation here, so a run would measure nothing"
203                .into(),
204        );
205    }
206    let total_started = Instant::now();
207    let initialization_started = Instant::now();
208    let root = fs::canonicalize(&request.root)
209        .map_err(|error| format!("{}: {error}", request.root.display()))?;
210    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
211        .map_err(|error| error.to_string())?;
212    let initialization_ms = elapsed_ms(initialization_started);
213    let result = (|| {
214        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
215            .map_err(|error| error.to_string())?;
216        if !recovered_runs.is_empty() {
217            writeln!(
218                diagnostics,
219                "[supercov] recovered abandoned run(s): {}",
220                recovered_runs.join(", ")
221            )
222            .map_err(|error| error.to_string())?;
223        }
224
225        let adapter_started = Instant::now();
226        let source_roots = crate::source_discovery::configured_source_roots(
227            &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
228        );
229        let mut integrity_inputs = collect_integrity_inputs(&root, &request.command)?;
230        crate::source_discovery::fold_roots_into_configuration(
231            &mut integrity_inputs.execution_configuration,
232            source_roots.as_deref(),
233        );
234        let source_inputs =
235            crate::source_capture::capture(&root, "rust", integrity_inputs.assertion_paths())?;
236        let integrity = create_explicit_run_integrity(
237            &root,
238            &integrity_inputs,
239            &FrontendIntegrityInputs::embedded_rust(),
240        )
241        .map_err(|error| error.to_string())?;
242        let build_cache_key = rust_build_cache_key(&integrity, &request.command)
243            .map_err(|error| error.to_string())?;
244
245        let workspace_started = Instant::now();
246        recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
247        let workspace = cached_workspace_path(&root, &lock).map_err(|error| error.to_string())?;
248        if let Some(planted) = crate::workspace::planted_cargo_configuration(&root, &workspace) {
249            return Err(format!(
250                "{} would change how Supercov builds the project but not how `cargo test` does; remove it",
251                planted.display()
252            ));
253        }
254        let target_directory = rust_target_directory(&root);
255        let cache_started = Instant::now();
256        let cached = read_rust_build_cache(&workspace, &target_directory, &build_cache_key);
257        let cache_read_ms = elapsed_ms(cache_started);
258        let mut copy_ms = 0.0;
259        let reused_build = cached.is_some();
260        let mut project = if let Some(cached) = cached {
261            writeln!(
262                diagnostics,
263                "[supercov] detected Rust; reusing authenticated instrumented workspace {}",
264                workspace.display()
265            )
266            .map_err(|error| error.to_string())?;
267            PreparedRustProject {
268                workspace_root: workspace.clone(),
269                target_directory: target_directory.clone(),
270                source_files: cached.source_files,
271                crate_roots: Vec::new(),
272                runtime_module: String::new(),
273                manifest: cached.manifest,
274                preparation: Default::default(),
275            }
276        } else {
277            let copy_started = Instant::now();
278            let workspace =
279                prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
280            copy_ms = elapsed_ms(copy_started);
281            writeln!(
282                diagnostics,
283                "[supercov] detected Rust; instrumenting isolated Cargo workspace {}",
284                workspace.display()
285            )
286            .map_err(|error| error.to_string())?;
287            let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
288            let roots = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
289                &root, &workspace, &ambient,
290            )
291            .map_err(|error| error.to_string())?;
292            // The target the command builds for decides which `#[cfg]` items
293            // exist; when it cannot be read, every item is measured.
294            let cfg = crate::rust_test_runner::cargo_invocation(&workspace, &request.command)
295                .ok()
296                .and_then(|invocation| {
297                    crate::rust_cargo_configuration::active_target_cfg(&workspace, &invocation)
298                })
299                .map(|values| crate::rust_instrumenter::TargetCfg::new(&values));
300            prepare_rust_project_for(&workspace, roots.as_ref(), cfg.as_ref())
301                .map_err(|error| error.to_string())?
302        };
303        project.target_directory = target_directory;
304        fs::create_dir_all(&project.target_directory).map_err(|error| error.to_string())?;
305        let workspace_preparation_ms = elapsed_ms(workspace_started);
306        let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
307        if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() == Ok("1") {
308            let preparation = &project.preparation;
309            writeln!(
310                diagnostics,
311                "[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",
312                preparation.metadata_ms,
313                preparation.discovery_ms,
314                preparation.instrument_ms,
315                preparation.runtime_ms,
316            )
317            .map_err(|error| error.to_string())?;
318        }
319
320        let nextest = request
321            .command
322            .windows(2)
323            .any(|pair| pair == ["nextest", "run"]);
324        writeln!(
325            diagnostics,
326            "{}",
327            if nextest {
328                "[supercov] running cargo nextest with Supercov as its target runner, each attempt in its own process"
329            } else {
330                "[supercov] building once and running each libtest case and doctest in its own process"
331            }
332        )
333        .map_err(|error| error.to_string())?;
334        // What is in the copy now is Supercov's; what the command adds to
335        // it or changes is the command's, and belongs in the project.
336        let before = crate::workspace::workspace_output_baseline(&workspace)
337            .map_err(|error| error.to_string())?;
338        let run = run_prepared_rust_tests(
339            &project,
340            &request.command,
341            &request.run_id,
342            &request.started_at,
343            diagnostics,
344        )
345        .map_err(|error| error.to_string())?;
346        // A snapshot a test accepted, a report: they stayed in the copy, and
347        // the project had the old ones or none.
348        let outputs = crate::workspace::sync_outputs_of(
349            crate::workspace::CommandOutputs::Rust,
350            &root,
351            &workspace,
352            &before,
353            &BTreeSet::new(),
354        )
355        .map_err(|error| error.to_string())?;
356        // What stayed is the lockfile and the sources, which nobody asked
357        // for: only what flowed back is said.
358        for line in crate::workspace::describe_command_outputs(&outputs)
359            .into_iter()
360            .filter(|line| !line.contains("stayed in the isolated workspace"))
361        {
362            writeln!(diagnostics, "{line}").map_err(|error| error.to_string())?;
363        }
364        write_rust_build_cache(
365            &root,
366            &workspace,
367            &build_cache_key,
368            &request.started_at,
369            &project.source_files,
370            // The manifest the run reported: pruned to what the build
371            // compiled, so a reused build reuses the same denominator.
372            &run.request.manifest,
373            &run.artifact_files,
374        )?;
375
376        let publication_started = Instant::now();
377        let archive_path = root
378            .join(".supercov/work")
379            .join(&request.run_id)
380            .join("evidence.raw.gz");
381        let raw = write_archive(
382            crate::source_capture::append(
383                run.archive_entries().map_err(|error| error.to_string())?,
384                &source_inputs,
385            )?,
386            &archive_path,
387        )
388        .map_err(|error| error.to_string())?;
389        remove_stored_tree_deferred(
390            &root,
391            &workspace
392                .join(".supercov/rust-evidence")
393                .join(&request.run_id),
394        )
395        .map_err(|error| error.to_string())?;
396        let evidence_publication_ms = elapsed_ms(publication_started);
397        let timings = RunTimings {
398            initialization_ms,
399            workspace_preparation_ms,
400            adapter_setup_ms,
401            instrumented_build_ms: (run.build_ms * 10.0).round() / 10.0,
402            test_command_ms: (run.execution_ms * 10.0).round() / 10.0,
403            evidence_publication_ms,
404        };
405        let metadata = RunMetadata {
406            id: request.run_id.clone(),
407            started_at: request.started_at.clone(),
408            duration_ms: elapsed_ms(total_started),
409            command: request.command.clone(),
410            test_exit_code: Some(run.exit_code),
411            integrity,
412            raw_evidence: RawEvidenceMetadata {
413                schema_version: raw.schema_version,
414                format: raw.format.into(),
415                file: raw.file.into(),
416                files: raw.files,
417                uncompressed_bytes: raw.uncompressed_bytes,
418                compressed_bytes: raw.compressed_bytes,
419            },
420            isolated_build: Some(true),
421            instrumented_build_cache: Some(InstrumentedBuildCache {
422                key: build_cache_key,
423                reused: reused_build,
424            }),
425            timings: Some(timings),
426            merged: None,
427            parents: None,
428            source_roots: source_roots.clone(),
429            copy: Some(lock.copy()),
430            merged_across: None,
431            recorded_by: None,
432        };
433        let run_directory =
434            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
435        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
436        Ok(DirectRustRunResult {
437            run_id: request.run_id.clone(),
438            run_directory,
439            exit_code: run.exit_code,
440            // Tests, not attempts: a retried test is still one test.
441            tests: run
442                .request
443                .raw_results
444                .iter()
445                .map(|result| result.test.as_str())
446                .collect::<std::collections::BTreeSet<_>>()
447                .len(),
448            artifacts: run.artifacts,
449            recovered_runs,
450            metadata,
451        })
452    })();
453    if result.is_err() {
454        let _ =
455            remove_stored_tree_deferred(&root, &root.join(".supercov/work").join(&request.run_id));
456        if let Ok(workspace) = cached_workspace_path(&root, &lock) {
457            let _ = remove_stored_tree_deferred(
458                &root,
459                &workspace
460                    .join(".supercov/rust-evidence")
461                    .join(&request.run_id),
462            );
463        }
464    }
465    let release = lock.release().map_err(|error| error.to_string());
466    match (result, release) {
467        (Ok(result), Ok(())) => Ok(result),
468        (Err(error), _) => Err(error),
469        (Ok(_), Err(error)) => Err(error),
470    }
471}