1use 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 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 || 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 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
138fn 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
158pub(crate) fn collect_integrity_inputs(
159 root: &Path,
160 command: &[String],
161) -> Result<ExplicitIntegrityInputs, String> {
162 let mut files = Vec::new();
163 let mut links = Vec::new();
164 collect_project_inputs(root, root, true, &mut files, &mut links)?;
165 files.sort();
166 files.dedup();
167 links.sort();
168 links.dedup();
169 let source_files = files
170 .iter()
171 .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("rs"))
172 .cloned()
173 .collect::<Vec<_>>();
174 let test_files = source_files.clone();
178 let dependency_files = files
179 .iter()
180 .filter(|path| {
181 path.file_name()
182 .and_then(|value| value.to_str())
183 .is_some_and(|name| matches!(name, "Cargo.toml" | "Cargo.lock"))
184 })
185 .cloned()
186 .collect::<Vec<_>>();
187 let source_set = source_files.iter().cloned().collect::<BTreeSet<_>>();
188 let dependency_set = dependency_files.iter().cloned().collect::<BTreeSet<_>>();
189 let configuration_files = files
190 .into_iter()
191 .filter(|path| !source_set.contains(path) && !dependency_set.contains(path))
192 .collect();
193 let mut execution_configuration = command.join("\0").into_bytes();
194 for link in links {
195 execution_configuration.push(0);
196 execution_configuration.extend_from_slice(link.as_bytes());
197 }
198 Ok(ExplicitIntegrityInputs {
199 source_files,
200 test_files,
201 dependency_files,
202 configuration_files,
203 execution_configuration,
204 })
205}
206
207pub fn current_rust_integrity(
208 root: &Path,
209 command: &[String],
210 source_roots: Option<&[String]>,
211) -> Result<crate::run_store::RunIntegrity, String> {
212 let root = fs::canonicalize(root).map_err(|error| error.to_string())?;
213 let mut inputs = collect_integrity_inputs(&root, command)?;
214 crate::source_discovery::fold_roots_into_configuration(
215 &mut inputs.execution_configuration,
216 source_roots,
217 );
218 create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_rust())
219 .map_err(|error| error.to_string())
220}
221
222pub fn run_direct_rust(
223 request: &DirectRustRunRequest,
224 diagnostics: &mut dyn Write,
225) -> Result<DirectRustRunResult, String> {
226 if request.command.is_empty() {
227 return Err("test command must not be empty".into());
228 }
229 if !cfg!(any(
234 target_os = "macos",
235 target_os = "linux",
236 target_os = "windows"
237 )) {
238 return Err(
239 "Rust suites are not supported on this platform: the probe transport has no implementation here, so a run would measure nothing"
240 .into(),
241 );
242 }
243 let total_started = Instant::now();
244 let initialization_started = Instant::now();
245 let root = fs::canonicalize(&request.root)
246 .map_err(|error| format!("{}: {error}", request.root.display()))?;
247 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
248 .map_err(|error| error.to_string())?;
249 let initialization_ms = elapsed_ms(initialization_started);
250 let result = (|| {
251 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
252 .map_err(|error| error.to_string())?;
253 if !recovered_runs.is_empty() {
254 writeln!(
255 diagnostics,
256 "[supercov] recovered abandoned run(s): {}",
257 recovered_runs.join(", ")
258 )
259 .map_err(|error| error.to_string())?;
260 }
261
262 let adapter_started = Instant::now();
263 let source_roots = crate::source_discovery::configured_source_roots(
264 &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
265 );
266 let mut integrity_inputs = collect_integrity_inputs(&root, &request.command)?;
267 crate::source_discovery::fold_roots_into_configuration(
268 &mut integrity_inputs.execution_configuration,
269 source_roots.as_deref(),
270 );
271 let source_inputs =
272 crate::source_capture::capture(&root, "rust", integrity_inputs.assertion_paths())?;
273 let integrity = create_explicit_run_integrity(
274 &root,
275 &integrity_inputs,
276 &FrontendIntegrityInputs::embedded_rust(),
277 )
278 .map_err(|error| error.to_string())?;
279 let build_cache_key = rust_build_cache_key(&integrity, &request.command)
280 .map_err(|error| error.to_string())?;
281
282 let workspace_started = Instant::now();
283 recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
284 let workspace = cached_workspace_path(&root, &lock).map_err(|error| error.to_string())?;
285 if let Some(planted) = crate::workspace::planted_cargo_configuration(&root, &workspace) {
286 return Err(format!(
287 "{} would change how Supercov builds the project but not how `cargo test` does; remove it",
288 planted.display()
289 ));
290 }
291 let target_directory = rust_target_directory(&root);
292 let cache_started = Instant::now();
293 let cached = read_rust_build_cache(&workspace, &target_directory, &build_cache_key);
294 let cache_read_ms = elapsed_ms(cache_started);
295 let mut copy_ms = 0.0;
296 let reused_build = cached.is_some();
297 let mut project = if let Some(cached) = cached {
298 writeln!(
299 diagnostics,
300 "[supercov] detected Rust; reusing authenticated instrumented workspace {}",
301 workspace.display()
302 )
303 .map_err(|error| error.to_string())?;
304 PreparedRustProject {
305 workspace_root: workspace.clone(),
306 target_directory: target_directory.clone(),
307 source_files: cached.source_files,
308 crate_roots: Vec::new(),
309 runtime_module: String::new(),
310 manifest: cached.manifest,
311 probes: cached.probes,
312 preparation: Default::default(),
313 }
314 } else {
315 let copy_started = Instant::now();
316 let workspace =
317 prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
318 copy_ms = elapsed_ms(copy_started);
319 writeln!(
320 diagnostics,
321 "[supercov] detected Rust; instrumenting isolated Cargo workspace {}",
322 workspace.display()
323 )
324 .map_err(|error| error.to_string())?;
325 let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
326 let roots = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
327 &root, &workspace, &ambient,
328 )
329 .map_err(|error| error.to_string())?;
330 let cfg = crate::rust_test_runner::cargo_invocation(&workspace, &request.command)
333 .ok()
334 .and_then(|invocation| {
335 crate::rust_cargo_configuration::active_target_cfg(&workspace, &invocation)
336 })
337 .map(|values| crate::rust_instrumenter::TargetCfg::new(&values));
338 prepare_rust_project_for(&workspace, roots.as_ref(), cfg.as_ref())
339 .map_err(|error| error.to_string())?
340 };
341 project.target_directory = target_directory;
342 fs::create_dir_all(&project.target_directory).map_err(|error| error.to_string())?;
343 let workspace_preparation_ms = elapsed_ms(workspace_started);
344 let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
345 if std::env::var("SUPERCOV_PHASE_TIMING").as_deref() == Ok("1") {
346 let preparation = &project.preparation;
347 writeln!(
348 diagnostics,
349 "[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",
350 preparation.metadata_ms,
351 preparation.discovery_ms,
352 preparation.instrument_ms,
353 preparation.runtime_ms,
354 )
355 .map_err(|error| error.to_string())?;
356 }
357
358 let nextest = request
359 .command
360 .windows(2)
361 .any(|pair| pair == ["nextest", "run"]);
362 writeln!(
363 diagnostics,
364 "{}",
365 if nextest {
366 "[supercov] running cargo nextest with Supercov as its target runner, each attempt in its own process"
367 } else {
368 "[supercov] building once and running each libtest case and doctest in its own process"
369 }
370 )
371 .map_err(|error| error.to_string())?;
372 let rust_workers = crate::rust_worker::CargoTestWorkers::prepare(
379 &workspace,
380 &workspace
381 .join(".supercov/rust-evidence")
382 .join(&request.run_id)
383 .join("wrangler"),
384 )?;
385 let before = crate::workspace::workspace_output_baseline(&workspace)
386 .map_err(|error| error.to_string())?;
387 let run = run_prepared_rust_tests(
388 &project,
389 &request.command,
390 &request.run_id,
391 &request.started_at,
392 rust_workers.as_ref(),
393 diagnostics,
394 )
395 .map_err(|error| error.to_string())?;
396 let outputs = crate::workspace::sync_outputs_of(
399 crate::workspace::CommandOutputs::Rust,
400 &root,
401 &workspace,
402 &before,
403 &BTreeSet::new(),
404 )
405 .map_err(|error| error.to_string())?;
406 for line in crate::workspace::describe_command_outputs(&outputs)
409 .into_iter()
410 .filter(|line| !line.contains("stayed in the isolated workspace"))
411 {
412 writeln!(diagnostics, "{line}").map_err(|error| error.to_string())?;
413 }
414 write_rust_build_cache(
415 &root,
416 &workspace,
417 &build_cache_key,
418 &request.started_at,
419 &project.source_files,
420 &run.request.manifest,
423 &project.probes,
424 &run.artifact_files,
425 )?;
426
427 let publication_started = Instant::now();
428 let archive_path = root
429 .join(".supercov/work")
430 .join(&request.run_id)
431 .join("evidence.raw.gz");
432 let raw = write_archive(
433 crate::source_capture::append(
434 run.archive_entries().map_err(|error| error.to_string())?,
435 &source_inputs,
436 )?,
437 &archive_path,
438 )
439 .map_err(|error| error.to_string())?;
440 remove_stored_tree_deferred(
441 &root,
442 &workspace
443 .join(".supercov/rust-evidence")
444 .join(&request.run_id),
445 )
446 .map_err(|error| error.to_string())?;
447 let evidence_publication_ms = elapsed_ms(publication_started);
448 let timings = RunTimings {
449 initialization_ms,
450 workspace_preparation_ms,
451 adapter_setup_ms,
452 instrumented_build_ms: (run.build_ms * 10.0).round() / 10.0,
453 test_command_ms: (run.execution_ms * 10.0).round() / 10.0,
454 evidence_publication_ms,
455 };
456 let metadata = RunMetadata {
457 id: request.run_id.clone(),
458 started_at: request.started_at.clone(),
459 duration_ms: elapsed_ms(total_started),
460 command: request.command.clone(),
461 test_exit_code: Some(run.exit_code),
462 integrity,
463 raw_evidence: RawEvidenceMetadata {
464 schema_version: raw.schema_version,
465 format: raw.format.into(),
466 file: raw.file.into(),
467 files: raw.files,
468 uncompressed_bytes: raw.uncompressed_bytes,
469 compressed_bytes: raw.compressed_bytes,
470 },
471 isolated_build: Some(true),
472 instrumented_build_cache: Some(InstrumentedBuildCache {
473 key: build_cache_key,
474 reused: reused_build,
475 }),
476 timings: Some(timings),
477 merged: None,
478 parents: None,
479 source_roots: source_roots.clone(),
480 copy: Some(lock.copy()),
481 merged_across: None,
482 recorded_by: None,
483 companions: None,
484 };
485 let run_directory =
486 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
487 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
488 Ok(DirectRustRunResult {
489 run_id: request.run_id.clone(),
490 run_directory,
491 exit_code: run.exit_code,
492 tests: run
494 .request
495 .raw_results
496 .iter()
497 .map(|result| result.test.as_str())
498 .collect::<std::collections::BTreeSet<_>>()
499 .len(),
500 artifacts: run.artifacts,
501 recovered_runs,
502 metadata,
503 })
504 })();
505 if result.is_err() {
506 let _ =
507 remove_stored_tree_deferred(&root, &root.join(".supercov/work").join(&request.run_id));
508 if let Ok(workspace) = cached_workspace_path(&root, &lock) {
509 let _ = remove_stored_tree_deferred(
510 &root,
511 &workspace
512 .join(".supercov/rust-evidence")
513 .join(&request.run_id),
514 );
515 }
516 }
517 let release = lock.release().map_err(|error| error.to_string());
518 match (result, release) {
519 (Ok(result), Ok(())) => Ok(result),
520 (Err(error), _) => Err(error),
521 (Ok(_), Err(error)) => Err(error),
522 }
523}