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 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 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 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).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 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 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 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 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 &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 };
430 let run_directory =
431 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
432 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
433 Ok(DirectRustRunResult {
434 run_id: request.run_id.clone(),
435 run_directory,
436 exit_code: run.exit_code,
437 tests: run
439 .request
440 .raw_results
441 .iter()
442 .map(|result| result.test.as_str())
443 .collect::<std::collections::BTreeSet<_>>()
444 .len(),
445 artifacts: run.artifacts,
446 recovered_runs,
447 metadata,
448 })
449 })();
450 if result.is_err() {
451 let _ =
452 remove_stored_tree_deferred(&root, &root.join(".supercov/work").join(&request.run_id));
453 if let Ok(workspace) = cached_workspace_path(&root) {
454 let _ = remove_stored_tree_deferred(
455 &root,
456 &workspace
457 .join(".supercov/rust-evidence")
458 .join(&request.run_id),
459 );
460 }
461 }
462 let release = lock.release().map_err(|error| error.to_string());
463 match (result, release) {
464 (Ok(result), Ok(())) => Ok(result),
465 (Err(error), _) => Err(error),
466 (Ok(_), Err(error)) => Err(error),
467 }
468}