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},
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
146fn 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 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 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 integrity = create_explicit_run_integrity(
259 &root,
260 &integrity_inputs,
261 &FrontendIntegrityInputs::embedded_rust(),
262 )
263 .map_err(|error| error.to_string())?;
264 let build_cache_key = rust_build_cache_key(&integrity, &request.command)
265 .map_err(|error| error.to_string())?;
266
267 let workspace_started = Instant::now();
268 recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
269 let workspace = cached_workspace_path(&root).map_err(|error| error.to_string())?;
270 let target_directory = rust_target_directory(&root);
271 let cached = read_rust_build_cache(&workspace, &target_directory, &build_cache_key);
272 let reused_build = cached.is_some();
273 let mut project = if let Some(cached) = cached {
274 writeln!(
275 diagnostics,
276 "[supercov] detected Rust; reusing authenticated instrumented workspace {}",
277 workspace.display()
278 )
279 .map_err(|error| error.to_string())?;
280 PreparedRustProject {
281 workspace_root: workspace.clone(),
282 target_directory: target_directory.clone(),
283 source_files: cached.source_files,
284 crate_roots: Vec::new(),
285 runtime_module: String::new(),
286 manifest: cached.manifest,
287 }
288 } else {
289 let workspace =
290 prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
291 writeln!(
292 diagnostics,
293 "[supercov] detected Rust; instrumenting isolated Cargo workspace {}",
294 workspace.display()
295 )
296 .map_err(|error| error.to_string())?;
297 prepare_rust_project(&workspace).map_err(|error| error.to_string())?
298 };
299 project.target_directory = target_directory;
300 fs::create_dir_all(&project.target_directory).map_err(|error| error.to_string())?;
301 let workspace_preparation_ms = elapsed_ms(workspace_started);
302 let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
303
304 writeln!(
305 diagnostics,
306 "[supercov] building once and running each libtest case in its own process"
307 )
308 .map_err(|error| error.to_string())?;
309 let run = run_prepared_rust_tests(
310 &project,
311 &request.command,
312 &request.run_id,
313 &request.started_at,
314 diagnostics,
315 )
316 .map_err(|error| error.to_string())?;
317 write_rust_build_cache(
318 &root,
319 &workspace,
320 &build_cache_key,
321 &request.started_at,
322 &project.source_files,
323 &project.manifest,
324 &run.artifact_files,
325 )?;
326
327 let publication_started = Instant::now();
328 let archive_path = root
329 .join(".supercov/work")
330 .join(&request.run_id)
331 .join("evidence.raw.gz");
332 let raw = write_archive(
333 run.archive_entries().map_err(|error| error.to_string())?,
334 &archive_path,
335 )
336 .map_err(|error| error.to_string())?;
337 remove_stored_tree_deferred(
338 &root,
339 &workspace
340 .join(".supercov/rust-evidence")
341 .join(&request.run_id),
342 )
343 .map_err(|error| error.to_string())?;
344 let evidence_publication_ms = elapsed_ms(publication_started);
345 let timings = RunTimings {
346 initialization_ms,
347 workspace_preparation_ms,
348 adapter_setup_ms,
349 instrumented_build_ms: (run.build_ms * 10.0).round() / 10.0,
350 test_command_ms: (run.execution_ms * 10.0).round() / 10.0,
351 evidence_publication_ms,
352 };
353 let metadata = RunMetadata {
354 id: request.run_id.clone(),
355 started_at: request.started_at.clone(),
356 duration_ms: elapsed_ms(total_started),
357 command: request.command.clone(),
358 test_exit_code: Some(run.exit_code),
359 integrity,
360 raw_evidence: RawEvidenceMetadata {
361 schema_version: raw.schema_version,
362 format: raw.format.into(),
363 file: raw.file.into(),
364 files: raw.files,
365 uncompressed_bytes: raw.uncompressed_bytes,
366 compressed_bytes: raw.compressed_bytes,
367 },
368 isolated_build: Some(true),
369 instrumented_build_cache: Some(InstrumentedBuildCache {
370 key: build_cache_key,
371 reused: reused_build,
372 }),
373 timings: Some(timings),
374 merged: None,
375 parents: None,
376 };
377 let run_directory =
378 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
379 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
380 Ok(DirectRustRunResult {
381 run_id: request.run_id.clone(),
382 run_directory,
383 exit_code: run.exit_code,
384 tests: run.request.raw_results.len(),
385 artifacts: run.artifacts,
386 recovered_runs,
387 metadata,
388 })
389 })();
390 if result.is_err() {
391 let _ =
392 remove_stored_tree_deferred(&root, &root.join(".supercov/work").join(&request.run_id));
393 if let Ok(workspace) = cached_workspace_path(&root) {
394 let _ = remove_stored_tree_deferred(
395 &root,
396 &workspace
397 .join(".supercov/rust-evidence")
398 .join(&request.run_id),
399 );
400 }
401 }
402 let release = lock.release().map_err(|error| error.to_string());
403 match (result, release) {
404 (Ok(result), Ok(())) => Ok(result),
405 (Err(error), _) => Err(error),
406 (Ok(_), Err(error)) => Err(error),
407 }
408}