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