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 run = run_prepared_rust_tests(
335 &project,
336 &request.command,
337 &request.run_id,
338 &request.started_at,
339 diagnostics,
340 )
341 .map_err(|error| error.to_string())?;
342 write_rust_build_cache(
343 &root,
344 &workspace,
345 &build_cache_key,
346 &request.started_at,
347 &project.source_files,
348 &run.request.manifest,
351 &run.artifact_files,
352 )?;
353
354 let publication_started = Instant::now();
355 let archive_path = root
356 .join(".supercov/work")
357 .join(&request.run_id)
358 .join("evidence.raw.gz");
359 let raw = write_archive(
360 crate::source_capture::append(
361 run.archive_entries().map_err(|error| error.to_string())?,
362 &source_inputs,
363 )?,
364 &archive_path,
365 )
366 .map_err(|error| error.to_string())?;
367 remove_stored_tree_deferred(
368 &root,
369 &workspace
370 .join(".supercov/rust-evidence")
371 .join(&request.run_id),
372 )
373 .map_err(|error| error.to_string())?;
374 let evidence_publication_ms = elapsed_ms(publication_started);
375 let timings = RunTimings {
376 initialization_ms,
377 workspace_preparation_ms,
378 adapter_setup_ms,
379 instrumented_build_ms: (run.build_ms * 10.0).round() / 10.0,
380 test_command_ms: (run.execution_ms * 10.0).round() / 10.0,
381 evidence_publication_ms,
382 };
383 let metadata = RunMetadata {
384 id: request.run_id.clone(),
385 started_at: request.started_at.clone(),
386 duration_ms: elapsed_ms(total_started),
387 command: request.command.clone(),
388 test_exit_code: Some(run.exit_code),
389 integrity,
390 raw_evidence: RawEvidenceMetadata {
391 schema_version: raw.schema_version,
392 format: raw.format.into(),
393 file: raw.file.into(),
394 files: raw.files,
395 uncompressed_bytes: raw.uncompressed_bytes,
396 compressed_bytes: raw.compressed_bytes,
397 },
398 isolated_build: Some(true),
399 instrumented_build_cache: Some(InstrumentedBuildCache {
400 key: build_cache_key,
401 reused: reused_build,
402 }),
403 timings: Some(timings),
404 merged: None,
405 parents: None,
406 source_roots: source_roots.clone(),
407 };
408 let run_directory =
409 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
410 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
411 Ok(DirectRustRunResult {
412 run_id: request.run_id.clone(),
413 run_directory,
414 exit_code: run.exit_code,
415 tests: run
417 .request
418 .raw_results
419 .iter()
420 .map(|result| result.test.as_str())
421 .collect::<std::collections::BTreeSet<_>>()
422 .len(),
423 artifacts: run.artifacts,
424 recovered_runs,
425 metadata,
426 })
427 })();
428 if result.is_err() {
429 let _ =
430 remove_stored_tree_deferred(&root, &root.join(".supercov/work").join(&request.run_id));
431 if let Ok(workspace) = cached_workspace_path(&root) {
432 let _ = remove_stored_tree_deferred(
433 &root,
434 &workspace
435 .join(".supercov/rust-evidence")
436 .join(&request.run_id),
437 );
438 }
439 }
440 let release = lock.release().map_err(|error| error.to_string());
441 match (result, release) {
442 (Ok(result), Ok(())) => Ok(result),
443 (Err(error), _) => Err(error),
444 (Ok(_), Err(error)) => Err(error),
445 }
446}