1use std::{
4 collections::{BTreeMap, BTreeSet},
5 fs,
6 path::{Component, Path, PathBuf},
7 process::Command,
8};
9
10use serde::Deserialize;
11use sha2::{Digest, Sha256};
12
13use crate::{
14 coverage_report::CoverageManifest, rust_instrumenter::instrument_rust_source,
15 rust_runtime::render_rust_runtime,
16};
17
18#[derive(Debug, Clone, PartialEq)]
19pub struct PreparedRustProject {
20 pub workspace_root: PathBuf,
21 pub target_directory: PathBuf,
22 pub source_files: Vec<String>,
23 pub crate_roots: Vec<String>,
24 pub runtime_module: String,
25 pub manifest: CoverageManifest,
26}
27
28#[derive(Debug)]
29pub enum RustProjectError {
30 Io { path: PathBuf, reason: String },
31 MetadataLaunch(String),
32 MetadataFailed(String),
33 MetadataJson(String),
34 UnsafePath(String),
35 NoWorkspacePackages,
36 NoSourceFiles,
37 Instrument { file: String, reason: String },
38 DuplicateObligation(String),
39 Runtime(String),
40}
41
42impl std::fmt::Display for RustProjectError {
43 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 Self::Io { path, reason } => write!(formatter, "{}: {reason}", path.display()),
46 Self::MetadataLaunch(reason) => {
47 write!(formatter, "could not launch cargo metadata: {reason}")
48 }
49 Self::MetadataFailed(reason) => write!(formatter, "cargo metadata failed: {reason}"),
50 Self::MetadataJson(reason) => write!(formatter, "invalid cargo metadata: {reason}"),
51 Self::UnsafePath(path) => {
52 write!(formatter, "Cargo reported an unsafe workspace path: {path}")
53 }
54 Self::NoWorkspacePackages => {
55 write!(formatter, "Cargo metadata reported no workspace packages")
56 }
57 Self::NoSourceFiles => write!(
58 formatter,
59 "Cargo workspace contains no owned Rust source files"
60 ),
61 Self::Instrument { file, reason } => {
62 write!(formatter, "could not instrument {file}: {reason}")
63 }
64 Self::DuplicateObligation(id) => {
65 write!(formatter, "duplicate Rust obligation ID: {id}")
66 }
67 Self::Runtime(reason) => write!(formatter, "could not generate Rust runtime: {reason}"),
68 }
69 }
70}
71
72impl std::error::Error for RustProjectError {}
73
74#[derive(Deserialize)]
75struct CargoMetadata {
76 packages: Vec<CargoPackage>,
77 workspace_members: Vec<String>,
78 workspace_root: PathBuf,
79 target_directory: PathBuf,
80}
81
82#[derive(Deserialize)]
83struct CargoPackage {
84 id: String,
85 manifest_path: PathBuf,
86 targets: Vec<CargoTarget>,
87}
88
89#[derive(Deserialize)]
90struct CargoTarget {
91 kind: Vec<String>,
92 src_path: PathBuf,
93}
94
95fn canonical_directory(path: &Path) -> Result<PathBuf, RustProjectError> {
96 fs::canonicalize(path).map_err(|error| RustProjectError::Io {
97 path: path.to_owned(),
98 reason: error.to_string(),
99 })
100}
101
102fn confined_relative(root: &Path, path: &Path) -> Result<String, RustProjectError> {
103 let relative = path
104 .strip_prefix(root)
105 .map_err(|_| RustProjectError::UnsafePath(path.display().to_string()))?;
106 if relative.as_os_str().is_empty()
107 || relative
108 .components()
109 .any(|component| !matches!(component, Component::Normal(_)))
110 {
111 return Err(RustProjectError::UnsafePath(path.display().to_string()));
112 }
113 Ok(relative.to_string_lossy().replace('\\', "/"))
114}
115
116fn cargo_metadata(root: &Path) -> Result<CargoMetadata, RustProjectError> {
117 let target_directory = root.join(".supercov/rust-target");
118 let output = Command::new("cargo")
119 .args(["metadata", "--format-version=1", "--no-deps"])
120 .current_dir(root)
121 .env("CARGO_TARGET_DIR", &target_directory)
122 .output()
123 .map_err(|error| RustProjectError::MetadataLaunch(error.to_string()))?;
124 if !output.status.success() {
125 return Err(RustProjectError::MetadataFailed(
126 String::from_utf8_lossy(&output.stderr).trim().to_owned(),
127 ));
128 }
129 serde_json::from_slice(&output.stdout)
130 .map_err(|error| RustProjectError::MetadataJson(error.to_string()))
131}
132
133fn collect_rust_files(
134 directory: &Path,
135 files: &mut BTreeSet<PathBuf>,
136) -> Result<(), RustProjectError> {
137 let mut entries = fs::read_dir(directory)
138 .map_err(|error| RustProjectError::Io {
139 path: directory.to_owned(),
140 reason: error.to_string(),
141 })?
142 .collect::<Result<Vec<_>, _>>()
143 .map_err(|error| RustProjectError::Io {
144 path: directory.to_owned(),
145 reason: error.to_string(),
146 })?;
147 entries.sort_by_key(fs::DirEntry::file_name);
148 for entry in entries {
149 let name = entry.file_name();
150 let name = name.to_string_lossy();
151 if matches!(name.as_ref(), ".git" | ".supercov" | "target") {
152 continue;
153 }
154 let file_type = entry.file_type().map_err(|error| RustProjectError::Io {
155 path: entry.path(),
156 reason: error.to_string(),
157 })?;
158 if file_type.is_symlink() {
159 return Err(RustProjectError::UnsafePath(
160 entry.path().display().to_string(),
161 ));
162 }
163 if file_type.is_dir() {
164 collect_rust_files(&entry.path(), files)?;
165 } else if file_type.is_file()
166 && entry.path().extension().and_then(|value| value.to_str()) == Some("rs")
167 && entry.file_name() != "build.rs"
168 {
169 files.insert(entry.path());
170 }
171 }
172 Ok(())
173}
174
175pub fn discover_rust_source_files(workspace: &Path) -> Result<Vec<String>, RustProjectError> {
178 let workspace = canonical_directory(workspace)?;
179 let metadata = cargo_metadata(&workspace)?;
180 let metadata_root = canonical_directory(&metadata.workspace_root)?;
181 if metadata_root != workspace {
182 return Err(RustProjectError::UnsafePath(
183 metadata.workspace_root.display().to_string(),
184 ));
185 }
186 let members = metadata
187 .workspace_members
188 .into_iter()
189 .collect::<BTreeSet<_>>();
190 let packages = metadata
191 .packages
192 .into_iter()
193 .filter(|package| members.contains(&package.id))
194 .collect::<Vec<_>>();
195 if packages.is_empty() {
196 return Err(RustProjectError::NoWorkspacePackages);
197 }
198 let mut files = BTreeSet::new();
199 for package in packages {
200 let directory = package.manifest_path.parent().ok_or_else(|| {
201 RustProjectError::UnsafePath(package.manifest_path.display().to_string())
202 })?;
203 let directory = canonical_directory(directory)?;
204 confined_relative(&workspace, &directory).or_else(|error| {
205 (directory == workspace)
206 .then_some(String::new())
207 .ok_or(error)
208 })?;
209 collect_rust_files(&directory, &mut files)?;
210 }
211 if files.is_empty() {
212 return Err(RustProjectError::NoSourceFiles);
213 }
214 files
215 .into_iter()
216 .map(|path| confined_relative(&workspace, &path))
217 .collect()
218}
219
220fn runtime_module_name(sources: &BTreeMap<String, String>) -> String {
221 let mut suffix = 0_usize;
222 loop {
223 let candidate = if suffix == 0 {
224 "__supercov_runtime_v1".to_owned()
225 } else {
226 format!("__supercov_runtime_v1_{suffix}")
227 };
228 if sources.values().all(|source| !source.contains(&candidate)) {
229 return candidate;
230 }
231 suffix += 1;
232 }
233}
234
235fn crate_key(path: &str) -> String {
236 let digest = Sha256::digest(path.as_bytes());
237 digest[..12]
238 .iter()
239 .map(|byte| format!("{byte:02x}"))
240 .collect()
241}
242
243fn merge_manifest(
244 destination: &mut CoverageManifest,
245 mut source: CoverageManifest,
246) -> Result<(), RustProjectError> {
247 let mut ids = destination
248 .points
249 .iter()
250 .map(|point| point.id.as_str())
251 .chain(
252 destination
253 .decisions
254 .iter()
255 .map(|decision| decision.id.as_str()),
256 )
257 .chain(destination.branches.iter().map(|branch| branch.id.as_str()))
258 .collect::<BTreeSet<_>>();
259 for id in source
260 .points
261 .iter()
262 .map(|point| point.id.as_str())
263 .chain(source.decisions.iter().map(|decision| decision.id.as_str()))
264 .chain(source.branches.iter().map(|branch| branch.id.as_str()))
265 {
266 if !ids.insert(id) {
267 return Err(RustProjectError::DuplicateObligation(id.into()));
268 }
269 }
270 destination.points.append(&mut source.points);
271 destination.decisions.append(&mut source.decisions);
272 destination.branches.append(&mut source.branches);
273 for limitation in source.limitations {
274 let id = limitation.get("id").and_then(|value| value.as_str());
275 if !destination
276 .limitations
277 .iter()
278 .any(|existing| existing.get("id").and_then(|value| value.as_str()) == id)
279 {
280 destination.limitations.push(limitation);
281 }
282 }
283 Ok(())
284}
285
286pub fn prepare_rust_project(workspace: &Path) -> Result<PreparedRustProject, RustProjectError> {
287 let workspace = canonical_directory(workspace)?;
288 let metadata = cargo_metadata(&workspace)?;
289 let metadata_root = canonical_directory(&metadata.workspace_root)?;
290 if metadata_root != workspace {
291 return Err(RustProjectError::UnsafePath(
292 metadata.workspace_root.display().to_string(),
293 ));
294 }
295 let members = metadata
296 .workspace_members
297 .into_iter()
298 .collect::<BTreeSet<_>>();
299 let packages = metadata
300 .packages
301 .into_iter()
302 .filter(|package| members.contains(&package.id))
303 .collect::<Vec<_>>();
304 if packages.is_empty() {
305 return Err(RustProjectError::NoWorkspacePackages);
306 }
307
308 let mut files = BTreeSet::new();
309 let mut roots = BTreeSet::new();
310 for package in &packages {
311 let directory = package.manifest_path.parent().ok_or_else(|| {
312 RustProjectError::UnsafePath(package.manifest_path.display().to_string())
313 })?;
314 let directory = canonical_directory(directory)?;
315 confined_relative(&workspace, &directory).or_else(|error| {
316 (directory == workspace)
317 .then_some(String::new())
318 .ok_or(error)
319 })?;
320 collect_rust_files(&directory, &mut files)?;
321 for target in &package.targets {
322 if target.kind.iter().any(|kind| kind == "custom-build") {
323 continue;
324 }
325 let root =
326 fs::canonicalize(&target.src_path).map_err(|error| RustProjectError::Io {
327 path: target.src_path.clone(),
328 reason: error.to_string(),
329 })?;
330 confined_relative(&workspace, &root)?;
331 roots.insert(root);
332 }
333 }
334 if files.is_empty() {
335 return Err(RustProjectError::NoSourceFiles);
336 }
337
338 let mut sources = BTreeMap::new();
339 for path in files {
340 let relative = confined_relative(&workspace, &path)?;
341 let source = fs::read_to_string(&path).map_err(|error| RustProjectError::Io {
342 path: path.clone(),
343 reason: error.to_string(),
344 })?;
345 sources.insert(relative, source);
346 }
347 let runtime_module = runtime_module_name(&sources);
348 let runtime_path = format!("crate::{runtime_module}");
349 let mut manifest = CoverageManifest {
350 unmeasured: Vec::new(),
351 decisions: Vec::new(),
352 points: Vec::new(),
353 branches: Vec::new(),
354 limitations: Vec::new(),
355 scope: None,
356 };
357 for (relative, source) in &sources {
358 let transformed =
359 instrument_rust_source(relative, source, &runtime_path).map_err(|error| {
360 RustProjectError::Instrument {
361 file: relative.clone(),
362 reason: error.to_string(),
363 }
364 })?;
365 merge_manifest(&mut manifest, transformed.manifest)?;
366 fs::write(workspace.join(relative), transformed.code).map_err(|error| {
367 RustProjectError::Io {
368 path: workspace.join(relative),
369 reason: error.to_string(),
370 }
371 })?;
372 }
373
374 let mut crate_roots = Vec::new();
375 for root in roots {
376 let relative = confined_relative(&workspace, &root)?;
377 let runtime = render_rust_runtime(&runtime_module, &crate_key(&relative))
378 .map_err(RustProjectError::Runtime)?;
379 let mut source = fs::read_to_string(&root).map_err(|error| RustProjectError::Io {
380 path: root.clone(),
381 reason: error.to_string(),
382 })?;
383 source.push('\n');
384 source.push_str(&runtime);
385 fs::write(&root, source).map_err(|error| RustProjectError::Io {
386 path: root,
387 reason: error.to_string(),
388 })?;
389 crate_roots.push(relative);
390 }
391
392 manifest
393 .points
394 .sort_by(|left, right| left.id.cmp(&right.id));
395 manifest
396 .decisions
397 .sort_by(|left, right| left.id.cmp(&right.id));
398 manifest
399 .branches
400 .sort_by(|left, right| left.id.cmp(&right.id));
401 manifest.limitations.sort_by(|left, right| {
402 left.get("id")
403 .and_then(|value| value.as_str())
404 .cmp(&right.get("id").and_then(|value| value.as_str()))
405 });
406 let target_directory = metadata.target_directory;
407 let target_directory = if target_directory.is_absolute() {
408 target_directory
409 } else {
410 workspace.join(target_directory)
411 };
412 if !target_directory.starts_with(&workspace) {
413 return Err(RustProjectError::UnsafePath(
414 target_directory.display().to_string(),
415 ));
416 }
417 Ok(PreparedRustProject {
418 workspace_root: workspace,
419 target_directory,
420 source_files: sources.into_keys().collect(),
421 crate_roots,
422 runtime_module,
423 manifest,
424 })
425}
426
427#[cfg(test)]
428mod tests {
429 use std::{
430 process::Command,
431 sync::atomic::{AtomicU64, Ordering},
432 time::{SystemTime, UNIX_EPOCH},
433 };
434
435 use super::*;
436
437 fn fixture() -> PathBuf {
438 static UNIQUE: AtomicU64 = AtomicU64::new(0);
444 let nonce = SystemTime::now()
445 .duration_since(UNIX_EPOCH)
446 .unwrap()
447 .as_nanos();
448 let root = std::env::temp_dir().join(format!(
449 "supercov-rust-project-{}-{nonce}-{}",
450 std::process::id(),
451 UNIQUE.fetch_add(1, Ordering::Relaxed)
452 ));
453 fs::create_dir(&root).unwrap();
454 fs::create_dir(root.join("src")).unwrap();
455 fs::create_dir(root.join("tests")).unwrap();
456 fs::write(
457 root.join("Cargo.toml"),
458 "[package]\nname='rust-project-fixture'\nversion='0.0.0'\nedition='2024'\n",
459 )
460 .unwrap();
461 fs::write(
462 root.join("src/lib.rs"),
463 r#"pub fn choose(first: bool, second: bool) -> i32 {
464 if first && second { 7 } else { 3 }
465}
466
467#[cfg(test)]
468mod tests {
469 #[test]
470 fn unit_choice() {
471 assert_eq!(super::choose(true, true), 7);
472 }
473}
474"#,
475 )
476 .unwrap();
477 fs::write(
478 root.join("tests/integration.rs"),
479 r#"#[test]
480fn integration_choice() {
481 assert_eq!(rust_project_fixture::choose(false, true), 3);
482}
483"#,
484 )
485 .unwrap();
486 root
487 }
488
489 #[test]
490 fn prepares_every_workspace_crate_root_and_compiles_without_manifest_changes() {
491 let root = fixture();
492 let manifest_before = fs::read(root.join("Cargo.toml")).unwrap();
493 let prepared = prepare_rust_project(&root).unwrap();
494 assert_eq!(
495 prepared.source_files,
496 ["src/lib.rs", "tests/integration.rs"]
497 );
498 assert_eq!(prepared.crate_roots, ["src/lib.rs", "tests/integration.rs"]);
499 assert!(!prepared.manifest.points.is_empty());
500 assert!(!prepared.manifest.decisions.is_empty());
501 assert_eq!(fs::read(root.join("Cargo.toml")).unwrap(), manifest_before);
502 for crate_root in &prepared.crate_roots {
503 assert!(
504 fs::read_to_string(root.join(crate_root))
505 .unwrap()
506 .contains(&format!("mod {}", prepared.runtime_module))
507 );
508 }
509 let build = Command::new("cargo")
510 .args(["test", "--no-run"])
511 .current_dir(&root)
512 .env("CARGO_TARGET_DIR", &prepared.target_directory)
513 .output()
514 .unwrap();
515 assert!(
516 build.status.success(),
517 "{}",
518 String::from_utf8_lossy(&build.stderr)
519 );
520 fs::remove_dir_all(root).unwrap();
521 }
522}