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
158fn 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 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 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 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 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 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 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 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 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 &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: 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 #[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}