1use std::{
8 collections::BTreeSet,
9 fs::{self, File, OpenOptions},
10 io::{self, Read, Write},
11 path::{Component, Path, PathBuf},
12 sync::atomic::{AtomicU64, Ordering},
13 time::{Duration, SystemTime, UNIX_EPOCH},
14};
15
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18
19use crate::run_store::{RunMetadata, valid_run_id};
20
21const TRASH: &str = ".supercov/.trash";
22const INCOMPLETE_LOCK_GRACE: Duration = Duration::from_secs(30);
23static UNIQUE: AtomicU64 = AtomicU64::new(0);
24
25#[derive(Debug)]
26pub enum LifecycleError {
27 Io { path: PathBuf, source: io::Error },
28 InvalidRunId(String),
29 UnsafePath(PathBuf),
30 InvalidState(String),
31 ActiveRun { run_id: String, pid: u32 },
32 LockAcquiring,
33 LockUnavailable,
34 PublicationExists(String),
35 Metadata(serde_json::Error),
36 EvidenceLength { expected: u64, actual: u64 },
37 EvidenceChanged,
38}
39
40impl std::fmt::Display for LifecycleError {
41 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
44 Self::InvalidRunId(id) => write!(formatter, "invalid coverage run ID: {id}"),
45 Self::UnsafePath(path) => {
46 write!(
47 formatter,
48 "unsafe Supercov storage path: {}",
49 path.display()
50 )
51 }
52 Self::InvalidState(reason) => write!(formatter, "invalid run state: {reason}"),
53 Self::ActiveRun { run_id, pid } => write!(
54 formatter,
55 "coverage run {run_id} is already active in this project (pid {pid})"
56 ),
57 Self::LockAcquiring => {
58 write!(
59 formatter,
60 "a coverage run is currently acquiring the project lock"
61 )
62 }
63 Self::LockUnavailable => {
64 write!(formatter, "could not acquire the Supercov project lock")
65 }
66 Self::PublicationExists(id) => write!(formatter, "coverage run already exists: {id}"),
67 Self::Metadata(error) => write!(formatter, "invalid run metadata: {error}"),
68 Self::EvidenceLength { expected, actual } => write!(
69 formatter,
70 "evidence length changed before publication: expected {expected}, got {actual}"
71 ),
72 Self::EvidenceChanged => write!(formatter, "evidence changed during publication"),
73 }
74 }
75}
76
77impl std::error::Error for LifecycleError {}
78
79fn io_error(path: &Path, source: io::Error) -> LifecycleError {
80 LifecycleError::Io {
81 path: path.to_owned(),
82 source,
83 }
84}
85
86fn checked_id(id: &str) -> Result<(), LifecycleError> {
87 valid_run_id(id)
88 .then_some(())
89 .ok_or_else(|| LifecycleError::InvalidRunId(id.into()))
90}
91
92fn absolute_root(root: &Path) -> Result<PathBuf, LifecycleError> {
93 if root.is_absolute() {
94 Ok(root.to_owned())
95 } else {
96 std::env::current_dir()
97 .map(|cwd| cwd.join(root))
98 .map_err(|source| io_error(root, source))
99 }
100}
101
102fn lexical_descendant(root: &Path, path: &Path) -> bool {
103 let Ok(local) = path.strip_prefix(root) else {
104 return false;
105 };
106 !local.as_os_str().is_empty()
107 && local
108 .components()
109 .all(|component| matches!(component, Component::Normal(_)))
110}
111
112fn reject_linked_ancestors(
113 root: &Path,
114 path: &Path,
115 include_leaf: bool,
116) -> Result<(), LifecycleError> {
117 let local = path
118 .strip_prefix(root)
119 .map_err(|_| LifecycleError::UnsafePath(path.into()))?;
120 let components = local.components().collect::<Vec<_>>();
121 let through = if include_leaf {
122 components.len()
123 } else {
124 components.len().saturating_sub(1)
125 };
126 let mut current = root.to_owned();
127 for component in components.into_iter().take(through) {
128 let Component::Normal(component) = component else {
129 return Err(LifecycleError::UnsafePath(path.into()));
130 };
131 current.push(component);
132 match fs::symlink_metadata(¤t) {
133 Ok(metadata) if metadata.file_type().is_symlink() => {
134 return Err(LifecycleError::UnsafePath(current));
135 }
136 Ok(metadata) if !metadata.file_type().is_dir() => {
137 return Err(LifecycleError::UnsafePath(current));
138 }
139 Ok(_) => {}
140 Err(error) if error.kind() == io::ErrorKind::NotFound => break,
141 Err(source) => return Err(io_error(¤t, source)),
142 }
143 }
144 Ok(())
145}
146
147fn owned_workspace_container(root: &Path) -> bool {
148 let container = crate::workspace::workspace_container(root);
149 crate::workspace::owned_workspace_path(&container)
150}
151
152fn unique_name() -> String {
153 let nanos = SystemTime::now()
154 .duration_since(UNIX_EPOCH)
155 .unwrap_or_default()
156 .as_nanos();
157 format!(
158 "{}-{nanos}-{}",
159 std::process::id(),
160 UNIQUE.fetch_add(1, Ordering::Relaxed)
161 )
162}
163
164pub(crate) fn sync_directory_handle(path: &Path) -> io::Result<()> {
173 #[cfg(unix)]
174 {
175 File::open(path).and_then(|directory| directory.sync_all())
176 }
177 #[cfg(not(unix))]
178 {
179 let _ = path;
180 Ok(())
181 }
182}
183
184pub(crate) fn sync_directory(path: &Path) -> Result<(), LifecycleError> {
185 sync_directory_handle(path).map_err(|source| io_error(path, source))
186}
187
188pub(crate) fn atomic_rename(source: &Path, destination: &Path) -> Result<(), LifecycleError> {
189 let source_parent = source
190 .parent()
191 .ok_or_else(|| LifecycleError::UnsafePath(source.into()))?;
192 let destination_parent = destination
193 .parent()
194 .ok_or_else(|| LifecycleError::UnsafePath(destination.into()))?;
195 fs::create_dir_all(destination_parent).map_err(|error| io_error(destination_parent, error))?;
196 fs::rename(source, destination).map_err(|error| io_error(destination, error))?;
197 sync_directory(destination_parent)?;
198 if source_parent != destination_parent {
199 sync_directory(source_parent)?;
200 }
201 Ok(())
202}
203
204pub(crate) fn atomic_write(root: &Path, path: &Path, bytes: &[u8]) -> Result<(), LifecycleError> {
205 let parent = path
206 .parent()
207 .ok_or_else(|| LifecycleError::UnsafePath(path.into()))?;
208 reject_linked_ancestors(root, parent, true)?;
209 fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
210 for _ in 0..16 {
211 let temporary = parent.join(format!(
212 ".{}.{}.tmp",
213 path.file_name()
214 .and_then(|value| value.to_str())
215 .unwrap_or("state"),
216 unique_name()
217 ));
218 let mut file = match OpenOptions::new()
219 .write(true)
220 .create_new(true)
221 .open(&temporary)
222 {
223 Ok(file) => file,
224 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
225 Err(source) => return Err(io_error(&temporary, source)),
226 };
227 if let Err(source) = file.write_all(bytes).and_then(|_| file.sync_all()) {
228 let _ = fs::remove_file(&temporary);
229 return Err(io_error(&temporary, source));
230 }
231 drop(file);
232 if let Err(source) = fs::rename(&temporary, path) {
233 let _ = fs::remove_file(&temporary);
234 return Err(io_error(path, source));
235 }
236 sync_directory(parent)?;
237 return Ok(());
238 }
239 Err(LifecycleError::LockUnavailable)
240}
241
242pub fn remove_stored_tree_deferred(
243 project_root: &Path,
244 target: &Path,
245) -> Result<Option<PathBuf>, LifecycleError> {
246 let root = absolute_root(project_root)?;
247 let target = if target.is_absolute() {
248 target.to_owned()
249 } else {
250 root.join(target)
251 };
252 match fs::symlink_metadata(&target) {
253 Ok(_) => {}
254 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
255 Err(source) => return Err(io_error(&target, source)),
256 }
257 let store = root.join(".supercov");
258 let trash = root.join(TRASH);
259 let in_store = lexical_descendant(&store, &target) && !lexical_descendant(&trash, &target);
260 let container = crate::workspace::workspace_container(&root);
261 let in_workspace = owned_workspace_container(&root)
262 && (target == container || lexical_descendant(&container, &target));
263 if !in_store && !in_workspace {
264 return Err(LifecycleError::UnsafePath(target));
265 }
266 reject_linked_ancestors(if in_store { &store } else { &container }, &target, false)?;
267 reject_linked_ancestors(&root, &trash, true)?;
268 fs::create_dir_all(&trash).map_err(|source| io_error(&trash, source))?;
269 let destination = trash.join(unique_name());
270 atomic_rename(&target, &destination)?;
271 Ok(Some(destination))
272}
273
274pub fn sweep_trash(project_root: &Path) -> Result<usize, LifecycleError> {
276 let root = absolute_root(project_root)?;
277 let trash = root.join(TRASH);
278 reject_linked_ancestors(&root, &trash, true)?;
279 let entries = match fs::read_dir(&trash) {
280 Ok(entries) => entries,
281 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
282 Err(source) => return Err(io_error(&trash, source)),
283 };
284 let Some(_lock) = TrashLock::acquire(&trash)? else {
285 return Ok(0);
286 };
287 let mut removed = 0;
288 for entry in entries {
289 let entry = entry.map_err(|source| io_error(&trash, source))?;
290 let path = entry.path();
291 if entry.file_name() == ".deleter.lock" {
292 continue;
293 }
294 let metadata = fs::symlink_metadata(&path).map_err(|source| io_error(&path, source))?;
295 if metadata.file_type().is_dir() {
296 fs::remove_dir_all(&path).map_err(|source| io_error(&path, source))?;
297 } else {
298 fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
299 }
300 removed += 1;
301 }
302 Ok(removed)
303}
304
305struct TrashLock {
306 path: PathBuf,
307}
308
309impl TrashLock {
310 fn acquire(trash: &Path) -> Result<Option<Self>, LifecycleError> {
311 let path = trash.join(".deleter.lock");
312 for _ in 0..2 {
313 match OpenOptions::new().write(true).create_new(true).open(&path) {
314 Ok(mut file) => {
315 write!(file, "{}", std::process::id())
316 .and_then(|_| file.sync_all())
317 .map_err(|source| io_error(&path, source))?;
318 return Ok(Some(Self { path }));
319 }
320 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
321 let owner = fs::read_to_string(&path)
322 .ok()
323 .and_then(|value| value.parse::<u32>().ok());
324 if owner.is_some_and(process_exists) {
325 return Ok(None);
326 }
327 if owner.is_none() {
328 let age = fs::metadata(&path)
329 .and_then(|metadata| metadata.modified())
330 .ok()
331 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
332 .unwrap_or_default();
333 if age < INCOMPLETE_LOCK_GRACE {
334 return Ok(None);
335 }
336 }
337 fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
338 }
339 Err(source) => return Err(io_error(&path, source)),
340 }
341 }
342 Ok(None)
343 }
344}
345
346impl Drop for TrashLock {
347 fn drop(&mut self) {
348 let _ = fs::remove_file(&self.path);
349 }
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "lowercase")]
354pub enum RunStateStatus {
355 Preparing,
356 Building,
357 Testing,
358 Publishing,
359 Complete,
360 Failed,
361 Interrupted,
362 Abandoned,
363}
364
365impl RunStateStatus {
366 pub fn terminal(self) -> bool {
367 matches!(
368 self,
369 Self::Complete | Self::Failed | Self::Interrupted | Self::Abandoned
370 )
371 }
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(rename_all = "camelCase", deny_unknown_fields)]
376pub struct RunState {
377 pub id: String,
378 pub pid: u32,
379 pub root: String,
380 pub workspace: String,
381 pub started_at: String,
382 pub updated_at: String,
383 pub status: RunStateStatus,
384 #[serde(skip_serializing_if = "Option::is_none")]
385 pub signal: Option<String>,
386 #[serde(skip_serializing_if = "Option::is_none")]
387 pub error: Option<String>,
388}
389
390fn state_path(root: &Path, id: &str) -> PathBuf {
391 root.join(".supercov/work").join(id).join("state.json")
392}
393
394pub fn write_run_state(root: &Path, state: &RunState) -> Result<(), LifecycleError> {
395 checked_id(&state.id)?;
396 let mut bytes = serde_json::to_vec_pretty(state).map_err(LifecycleError::Metadata)?;
397 bytes.push(b'\n');
398 atomic_write(root, &state_path(root, &state.id), &bytes)
399}
400
401fn read_state(root: &Path, id: &str) -> Result<Option<RunState>, LifecycleError> {
402 checked_id(id)?;
403 let path = state_path(root, id);
404 let bytes = match fs::read(&path) {
405 Ok(bytes) => bytes,
406 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
407 Err(source) => return Err(io_error(&path, source)),
408 };
409 serde_json::from_slice(&bytes)
410 .map(Some)
411 .map_err(|error| LifecycleError::InvalidState(error.to_string()))
412}
413
414pub fn update_run_state(
415 root: &Path,
416 id: &str,
417 status: RunStateStatus,
418 updated_at: &str,
419 error: Option<String>,
420) -> Result<RunState, LifecycleError> {
421 let mut state = read_state(root, id)?
422 .ok_or_else(|| LifecycleError::InvalidState(format!("state is missing for {id}")))?;
423 state.status = status;
424 state.updated_at = updated_at.into();
425 state.error = error;
426 write_run_state(root, &state)?;
427 Ok(state)
428}
429
430pub fn interrupt_run_state(
431 root: &Path,
432 id: &str,
433 updated_at: &str,
434 signal: &str,
435) -> Result<RunState, LifecycleError> {
436 let mut state = read_state(root, id)?
437 .ok_or_else(|| LifecycleError::InvalidState(format!("state is missing for {id}")))?;
438 state.status = RunStateStatus::Interrupted;
439 state.updated_at = updated_at.into();
440 state.signal = Some(signal.into());
441 state.error = Some(format!("Interrupted by {signal}"));
442 write_run_state(root, &state)?;
443 Ok(state)
444}
445
446#[cfg(unix)]
447fn process_exists(pid: u32) -> bool {
448 if pid == 0 || pid > libc::pid_t::MAX as u32 {
449 return false;
450 }
451 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
453 result == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
454}
455
456#[cfg(not(unix))]
457fn process_exists(pid: u32) -> bool {
458 pid == std::process::id()
460}
461
462#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
463#[serde(rename_all = "camelCase", deny_unknown_fields)]
464struct LockOwner {
465 run_id: String,
466 pid: u32,
467 started_at: String,
468}
469
470pub struct ProjectLock {
471 root: PathBuf,
472 path: PathBuf,
473 owner: LockOwner,
474 released: bool,
475}
476
477impl ProjectLock {
478 pub fn acquire(root: &Path, run_id: &str, started_at: &str) -> Result<Self, LifecycleError> {
479 checked_id(run_id)?;
480 let path = root.join(".supercov/locks/active.json");
481 let parent = path.parent().expect("lock parent");
482 reject_linked_ancestors(root, parent, true)?;
483 fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
484 let ignore = root.join(".supercov/.gitignore");
488 match OpenOptions::new()
489 .write(true)
490 .create_new(true)
491 .open(&ignore)
492 {
493 Ok(mut file) => file
494 .write_all(b"*\n")
495 .map_err(|source| io_error(&ignore, source))?,
496 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
497 Err(source) => return Err(io_error(&ignore, source)),
498 }
499 let owner = LockOwner {
500 run_id: run_id.into(),
501 pid: std::process::id(),
502 started_at: started_at.into(),
503 };
504 let mut payload = serde_json::to_vec_pretty(&owner).map_err(LifecycleError::Metadata)?;
505 payload.push(b'\n');
506 for _ in 0..2 {
507 match OpenOptions::new().write(true).create_new(true).open(&path) {
508 Ok(mut file) => {
509 file.write_all(&payload)
510 .and_then(|_| file.sync_all())
511 .map_err(|source| io_error(&path, source))?;
512 sync_directory(parent)?;
513 return Ok(Self {
514 root: root.to_owned(),
515 path,
516 owner,
517 released: false,
518 });
519 }
520 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
521 let existing = fs::read(&path)
522 .ok()
523 .and_then(|bytes| serde_json::from_slice::<LockOwner>(&bytes).ok());
524 if let Some(existing) = existing {
525 if process_exists(existing.pid) {
526 return Err(LifecycleError::ActiveRun {
527 run_id: existing.run_id,
528 pid: existing.pid,
529 });
530 }
531 } else {
532 let age = fs::metadata(&path)
533 .and_then(|metadata| metadata.modified())
534 .ok()
535 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
536 .unwrap_or_default();
537 if age < INCOMPLETE_LOCK_GRACE {
538 return Err(LifecycleError::LockAcquiring);
539 }
540 }
541 fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
542 }
543 Err(source) => return Err(io_error(&path, source)),
544 }
545 }
546 Err(LifecycleError::LockUnavailable)
547 }
548
549 pub fn release(&mut self) -> Result<(), LifecycleError> {
550 if self.released {
551 return Ok(());
552 }
553 self.released = true;
554 let owned = fs::read(&self.path)
555 .ok()
556 .and_then(|bytes| serde_json::from_slice::<LockOwner>(&bytes).ok())
557 .is_some_and(|owner| owner == self.owner);
558 if owned {
559 fs::remove_file(&self.path).map_err(|source| io_error(&self.path, source))?;
560 }
561 Ok(())
562 }
563
564 pub(crate) fn protects(&self, root: &Path) -> bool {
565 !self.released && self.root == root
566 }
567}
568
569impl Drop for ProjectLock {
570 fn drop(&mut self) {
571 let _ = self.release();
572 }
573}
574
575fn copy_regular_file(source: &Path, destination: &Path) -> Result<u64, LifecycleError> {
576 let metadata = fs::symlink_metadata(source).map_err(|error| io_error(source, error))?;
577 if !metadata.file_type().is_file() {
578 return Err(LifecycleError::UnsafePath(source.into()));
579 }
580 if reflink_copy::reflink(source, destination).is_ok() {
584 File::open(destination)
585 .and_then(|file| file.sync_all())
586 .map_err(|error| io_error(destination, error))?;
587 return fs::symlink_metadata(destination)
588 .map(|metadata| metadata.len())
589 .map_err(|error| io_error(destination, error));
590 }
591 let mut input = File::open(source).map_err(|error| io_error(source, error))?;
592 let mut output = OpenOptions::new()
593 .write(true)
594 .create_new(true)
595 .open(destination)
596 .map_err(|error| io_error(destination, error))?;
597 let copied = io::copy(&mut input, &mut output).map_err(|error| io_error(destination, error))?;
598 output
599 .sync_all()
600 .map_err(|error| io_error(destination, error))?;
601 Ok(copied)
602}
603
604fn file_sha256(path: &Path) -> Result<[u8; 32], LifecycleError> {
605 let metadata = fs::symlink_metadata(path).map_err(|source| io_error(path, source))?;
606 if !metadata.file_type().is_file() {
607 return Err(LifecycleError::UnsafePath(path.into()));
608 }
609 let mut file = File::open(path).map_err(|source| io_error(path, source))?;
610 let mut hash = Sha256::new();
611 let mut buffer = [0_u8; 128 * 1024];
612 loop {
613 let read = file
614 .read(&mut buffer)
615 .map_err(|source| io_error(path, source))?;
616 if read == 0 {
617 break;
618 }
619 hash.update(&buffer[..read]);
620 }
621 Ok(hash.finalize().into())
622}
623
624fn hex_digest(digest: &[u8; 32]) -> String {
625 use std::fmt::Write as _;
626 digest
627 .iter()
628 .fold(String::with_capacity(64), |mut text, byte| {
629 let _ = write!(text, "{byte:02x}");
630 text
631 })
632}
633
634pub fn publish_run(
636 root: &Path,
637 metadata: &RunMetadata,
638 evidence_source: &Path,
639) -> Result<PathBuf, LifecycleError> {
640 publish_run_with_fault(root, metadata, evidence_source, Archived::default(), None)
641}
642
643#[derive(Default)]
648pub struct Archived {
649 pub report: Option<crate::coverage_report::CoverageReport>,
650 pub inputs: Option<crate::assertion_map::InputManifest>,
651}
652
653pub fn publish_archived_run(
655 root: &Path,
656 metadata: &RunMetadata,
657 evidence_source: &Path,
658 archived: Archived,
659) -> Result<PathBuf, LifecycleError> {
660 publish_run_with_fault(root, metadata, evidence_source, archived, None)
661}
662
663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
664pub(crate) enum RunPublicationFault {
665 FinalRename,
666 QueryIndexWrite,
668}
669
670pub(crate) fn publish_run_with_fault(
671 root: &Path,
672 metadata: &RunMetadata,
673 evidence_source: &Path,
674 archived: Archived,
675 fault: Option<RunPublicationFault>,
676) -> Result<PathBuf, LifecycleError> {
677 checked_id(&metadata.id)?;
678 let destination = root.join(".supercov/runs").join(&metadata.id);
679 reject_linked_ancestors(root, &destination, false)?;
680 if fs::symlink_metadata(&destination).is_ok() {
681 return Err(LifecycleError::PublicationExists(metadata.id.clone()));
682 }
683 let staging = root
684 .join(".supercov/work")
685 .join(&metadata.id)
686 .join("run-publication");
687 reject_linked_ancestors(root, &staging, true)?;
688 if fs::symlink_metadata(&staging).is_ok() {
689 remove_stored_tree_deferred(root, &staging)?;
690 }
691 let evidence_sha256 = file_sha256(evidence_source)?;
692 fs::create_dir_all(&staging).map_err(|source| io_error(&staging, source))?;
693 let copied = copy_regular_file(evidence_source, &staging.join("evidence.raw.gz"))?;
694 if copied != metadata.raw_evidence.compressed_bytes {
695 remove_stored_tree_deferred(root, &staging)?;
696 return Err(LifecycleError::EvidenceLength {
697 expected: metadata.raw_evidence.compressed_bytes,
698 actual: copied,
699 });
700 }
701 if file_sha256(&staging.join("evidence.raw.gz"))? != evidence_sha256 {
704 remove_stored_tree_deferred(root, &staging)?;
705 return Err(LifecycleError::EvidenceChanged);
706 }
707 let mut json = serde_json::to_vec_pretty(metadata).map_err(LifecycleError::Metadata)?;
708 json.push(b'\n');
709 atomic_write(root, &staging.join("run.json"), &json)?;
710 let staged = crate::run_store::StoredRun {
718 id: metadata.id.clone(),
719 directory: staging.clone(),
720 evidence_path: staging.join("evidence.raw.gz"),
721 metadata_path: staging.join("run.json"),
722 query_index_path: staging.join(crate::run_store::RUST_QUERY_INDEX_FILE),
723 metadata: metadata.clone(),
724 };
725 let analysed = if metadata.merged == Some(true) {
726 None
727 } else if archived.report.is_some() {
728 archived.report
729 } else {
730 crate::run_store::analyze_stored_run(&staged).ok()
731 };
732 let archived_inputs = match archived
733 .inputs
734 .map(|manifest| {
735 crate::assertion_store::archived_manifest(manifest, hex_digest(&evidence_sha256))
736 })
737 .transpose()
738 {
739 Ok(inputs) => inputs,
740 Err(reason) => {
741 let _ = remove_stored_tree_deferred(root, &staging);
742 return Err(LifecycleError::InvalidState(format!(
743 "assertion map publication: {reason}"
744 )));
745 }
746 };
747 let prepared = std::thread::scope(|scope| {
750 let index = analysed.as_ref().map(|report| {
751 scope.spawn(|| {
752 if fault == Some(RunPublicationFault::QueryIndexWrite)
755 || crate::run_store::write_query_index_from(&staged, report).is_err()
756 {
757 let _ = fs::remove_file(&staged.query_index_path);
758 }
759 })
760 });
761 let prepared = crate::assertion_store::prepare_publication_with(
762 root,
763 &staging,
764 metadata,
765 analysed.as_ref(),
766 archived_inputs,
767 );
768 if let Some(index) = index {
769 index
770 .join()
771 .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
772 }
773 prepared
774 });
775 if let Some(report) = analysed {
779 std::thread::spawn(move || drop(report));
780 }
781 if let Err(reason) = prepared {
782 let _ = remove_stored_tree_deferred(root, &staging);
783 return Err(LifecycleError::InvalidState(format!(
784 "assertion map publication: {reason}"
785 )));
786 }
787 sync_directory(&staging)?;
788 let runs = destination.parent().expect("runs parent");
789 fs::create_dir_all(runs).map_err(|source| io_error(runs, source))?;
790 if fault == Some(RunPublicationFault::FinalRename) {
791 let error = io_error(
792 &destination,
793 io::Error::new(
794 io::ErrorKind::PermissionDenied,
795 "injected final run publication rename failure",
796 ),
797 );
798 let _ = remove_stored_tree_deferred(root, &staging);
799 return Err(error);
800 }
801 fs::rename(&staging, &destination).map_err(|source| io_error(&destination, source))?;
802 sync_directory(runs)?;
803 Ok(destination)
804}
805
806fn published_started_at(root: &Path, id: &str) -> String {
810 let directory = root.join(".supercov/runs").join(id);
811 fs::read(directory.join("run.json"))
812 .ok()
813 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
814 .as_ref()
815 .and_then(|value| value.get("startedAt"))
816 .and_then(|value| value.as_str())
817 .unwrap_or_default()
818 .to_owned()
819}
820
821fn published_run(root: &Path, id: &str) -> bool {
822 let directory = root.join(".supercov/runs").join(id);
823 let metadata = fs::read(directory.join("run.json"))
824 .ok()
825 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
826 metadata
827 .as_ref()
828 .and_then(|value| value.get("id"))
829 .and_then(|value| value.as_str())
830 == Some(id)
831 && fs::symlink_metadata(directory.join("evidence.raw.gz"))
832 .is_ok_and(|metadata| metadata.file_type().is_file())
833}
834
835pub fn finalize_published_run(root: &Path, id: &str) -> Result<bool, LifecycleError> {
836 checked_id(id)?;
837 if !published_run(root, id) {
838 return Ok(false);
839 }
840 remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(id))?;
841 remove_stored_tree_deferred(root, &root.join(".supercov/work").join(id))?;
842 Ok(true)
843}
844
845fn child_directories(path: &Path) -> Result<Vec<String>, LifecycleError> {
846 let root = path
847 .ancestors()
848 .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".supercov"))
849 .and_then(Path::parent)
850 .unwrap_or(path);
851 reject_linked_ancestors(root, path, true)?;
852 let entries = match fs::read_dir(path) {
853 Ok(entries) => entries,
854 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
855 Err(source) => return Err(io_error(path, source)),
856 };
857 let mut names = Vec::new();
858 for entry in entries {
859 let entry = entry.map_err(|source| io_error(path, source))?;
860 let file_type = entry
861 .file_type()
862 .map_err(|source| io_error(&entry.path(), source))?;
863 if file_type.is_symlink() {
864 return Err(LifecycleError::UnsafePath(entry.path()));
865 }
866 if !file_type.is_dir() {
867 continue;
868 }
869 let name = entry
870 .file_name()
871 .into_string()
872 .map_err(|_| LifecycleError::UnsafePath(entry.path()))?;
873 checked_id(&name)?;
874 names.push(name);
875 }
876 names.sort();
877 Ok(names)
878}
879
880const FAILED_EVIDENCE: &str = ".supercov/failed-evidence";
882
883pub fn keep_failed_evidence(root: &Path, evidence: &Path, id: &str) -> Option<PathBuf> {
896 if !evidence.is_dir() {
897 return None;
898 }
899 let kept = root.join(FAILED_EVIDENCE).join(id);
900 fs::create_dir_all(&kept).ok()?;
901 let mut files = 0;
902 if copy_evidence(evidence, &kept, &mut files).is_err() || files == 0 {
903 let _ = fs::remove_dir_all(&kept);
906 return None;
907 }
908 Some(kept)
909}
910
911pub fn note_kept_evidence(root: &Path, evidence: &Path, id: &str, error: String) -> String {
917 let Some(kept) = keep_failed_evidence(root, evidence, id) else {
918 return error;
919 };
920 format!(
921 "{error}\n[supercov] the tests were measured and the run could not be published; \
922their evidence is kept at {} -- this is a Supercov bug, and that directory is what diagnoses it",
923 kept.display()
924 )
925}
926
927fn copy_evidence(source: &Path, destination: &Path, files: &mut usize) -> std::io::Result<()> {
928 for entry in fs::read_dir(source)? {
929 let entry = entry?;
930 let target = destination.join(entry.file_name());
931 if entry.file_type()?.is_dir() {
932 fs::create_dir_all(&target)?;
933 copy_evidence(&entry.path(), &target, files)?;
934 } else {
935 fs::copy(entry.path(), &target)?;
936 *files += 1;
937 }
938 }
939 Ok(())
940}
941
942pub fn recover_abandoned_runs(
943 root: &Path,
944 updated_at: &str,
945) -> Result<Vec<String>, LifecycleError> {
946 let mut recovered = Vec::new();
947 for id in child_directories(&root.join(".supercov/work"))? {
948 let Some(state) = read_state(root, &id)? else {
949 continue;
950 };
951 if state.status.terminal() {
952 finalize_published_run(root, &id)?;
953 continue;
954 }
955 if process_exists(state.pid) {
956 continue;
957 }
958 let workspace_name = root.file_name().unwrap_or_default();
959 remove_stored_tree_deferred(
960 root,
961 &root.join(".supercov/work").join(&id).join(workspace_name),
962 )?;
963 remove_stored_tree_deferred(
964 root,
965 &root
966 .join(".supercov/work")
967 .join(&id)
968 .join("run-publication"),
969 )?;
970 if !finalize_published_run(root, &id)? {
971 remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(&id))?;
972 update_run_state(
973 root,
974 &id,
975 RunStateStatus::Abandoned,
976 updated_at,
977 Some(format!(
978 "Recovered after process {} exited without cleanup",
979 state.pid
980 )),
981 )?;
982 remove_stored_tree_deferred(root, &root.join(".supercov/work").join(&id))?;
983 }
984 recovered.push(id);
985 }
986 recovered.sort();
987 Ok(recovered)
988}
989
990#[derive(Debug, Clone, Copy, PartialEq, Eq)]
991pub struct CleanupOptions {
992 pub keep: usize,
993 pub dry_run: bool,
994}
995
996#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
997#[serde(rename_all = "camelCase")]
998pub struct CleanupResult {
999 pub removed_runs: Vec<String>,
1000 pub removed_workspaces: Vec<String>,
1001 pub removed_evidence: Vec<String>,
1002 pub removed_failed_evidence: Vec<String>,
1007 pub removed_build_cache: bool,
1008}
1009
1010pub fn cleanup_storage_locked(
1011 root: &Path,
1012 options: CleanupOptions,
1013 remove_build_cache: bool,
1014) -> Result<CleanupResult, LifecycleError> {
1015 let runs_root = root.join(".supercov/runs");
1016 let work_root = root.join(".supercov/work");
1017 let evidence_root = root.join(".supercov/evidence");
1018 let published = child_directories(&runs_root)?;
1019 let work = child_directories(&work_root)?;
1020 let evidence = child_directories(&evidence_root)?;
1021 let mut ids = published
1022 .iter()
1023 .chain(&work)
1024 .chain(&evidence)
1025 .cloned()
1026 .collect::<BTreeSet<_>>()
1027 .into_iter()
1028 .collect::<Vec<_>>();
1029 ids.sort_by(|left, right| right.cmp(left));
1030 let mut active = BTreeSet::new();
1031 for id in &ids {
1032 if read_state(root, id)?.is_some_and(|state| !state.status.terminal()) {
1033 active.insert(id.clone());
1034 }
1035 }
1036 let mut retention_order = published
1037 .iter()
1038 .filter(|id| !active.contains(*id))
1039 .map(|id| (published_started_at(root, id), id.clone()))
1040 .collect::<Vec<_>>();
1041 retention_order.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1)));
1042 let retained = retention_order
1043 .into_iter()
1044 .take(options.keep)
1045 .map(|(_, id)| id)
1046 .collect::<BTreeSet<_>>();
1047 let mut result = CleanupResult {
1048 removed_runs: Vec::new(),
1049 removed_workspaces: Vec::new(),
1050 removed_evidence: Vec::new(),
1051 removed_failed_evidence: Vec::new(),
1052 removed_build_cache: false,
1053 };
1054 for id in ids {
1055 if active.contains(&id) {
1056 continue;
1057 }
1058 let has_run = published.contains(&id);
1059 let remove_history = has_run && !retained.contains(&id);
1060 if work.contains(&id) && read_state(root, &id)?.is_none_or(|state| state.status.terminal())
1061 {
1062 result.removed_workspaces.push(id.clone());
1063 if !options.dry_run {
1064 remove_stored_tree_deferred(root, &work_root.join(&id))?;
1065 }
1066 }
1067 if evidence.contains(&id) && (!has_run || remove_history) {
1068 result.removed_evidence.push(id.clone());
1069 if !options.dry_run {
1070 remove_stored_tree_deferred(root, &evidence_root.join(&id))?;
1071 }
1072 }
1073 if remove_history {
1074 result.removed_runs.push(id.clone());
1075 if !options.dry_run {
1076 remove_stored_tree_deferred(root, &runs_root.join(&id))?;
1077 }
1078 }
1079 }
1080 if remove_build_cache && options.keep == 0 && active.is_empty() {
1085 let kept_root = root.join(FAILED_EVIDENCE);
1086 for id in child_directories(&kept_root)? {
1087 result.removed_failed_evidence.push(id.clone());
1088 if !options.dry_run {
1089 remove_stored_tree_deferred(root, &kept_root.join(&id))?;
1090 }
1091 }
1092 }
1093 let container = crate::workspace::workspace_container(root);
1094 let legacy = [root.join(".supercov/.cache"), root.join(".supercov/cache")];
1095 let mut caches = Vec::new();
1096 let mut removed_cargo_cache = false;
1097 if remove_build_cache && active.is_empty() {
1098 if owned_workspace_container(root) {
1099 caches.push(container);
1100 }
1101 caches.extend(
1102 legacy
1103 .into_iter()
1104 .filter(|path| fs::symlink_metadata(path).is_ok()),
1105 );
1106 removed_cargo_cache = crate::workspace::clean_cargo_workspace(root, options.dry_run)
1107 .map_err(|error| {
1108 LifecycleError::InvalidState(format!(
1109 "could not clean the owned Cargo workspace: {error}"
1110 ))
1111 })?;
1112 }
1113 result.removed_build_cache = removed_cargo_cache || !caches.is_empty();
1114 if !options.dry_run {
1115 for cache in caches {
1116 remove_stored_tree_deferred(root, &cache)?;
1117 }
1118 }
1119 Ok(result)
1120}
1121
1122fn cleanup_storage(
1123 root: &Path,
1124 options: CleanupOptions,
1125 remove_build_cache: bool,
1126 updated_at: &str,
1127) -> Result<CleanupResult, LifecycleError> {
1128 let operation = if remove_build_cache {
1129 "clean"
1130 } else {
1131 "retention"
1132 };
1133 let lock_id = format!("{operation}-{}-{}", std::process::id(), unique_name());
1134 let mut lock = ProjectLock::acquire(root, &lock_id, updated_at)?;
1135 recover_abandoned_runs(root, updated_at)?;
1136 let result = cleanup_storage_locked(root, options, remove_build_cache);
1137 lock.release()?;
1138 result
1139}
1140
1141pub fn clean_storage(
1142 root: &Path,
1143 options: CleanupOptions,
1144 updated_at: &str,
1145) -> Result<CleanupResult, LifecycleError> {
1146 cleanup_storage(root, options, true, updated_at)
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151 use super::*;
1152 use crate::run_store::{RawEvidenceMetadata, RunFingerprint, RunIntegrity};
1153
1154 fn project() -> PathBuf {
1155 let root = std::env::temp_dir().join(format!("supercov-lifecycle-{}", unique_name()));
1156 fs::create_dir_all(root.join("src")).unwrap();
1157 fs::write(root.join("src/index.js"), "user source").unwrap();
1158 root
1159 }
1160
1161 fn kept_evidence(root: &Path, id: &str) -> PathBuf {
1162 let evidence = root.join(".supercov/work").join(id).join("python/evidence");
1163 fs::create_dir_all(&evidence).unwrap();
1164 fs::write(evidence.join("main.jsonl"), "what the run saw").unwrap();
1165 keep_failed_evidence(root, &evidence, id).expect("the evidence")
1166 }
1167
1168 fn state(root: &Path, id: &str, status: RunStateStatus, pid: u32) -> RunState {
1169 RunState {
1170 id: id.into(),
1171 pid,
1172 root: root.display().to_string(),
1173 workspace: root.join("dist").display().to_string(),
1174 started_at: "start".into(),
1175 updated_at: "update".into(),
1176 status,
1177 signal: None,
1178 error: None,
1179 }
1180 }
1181
1182 fn metadata(id: &str, bytes: u64) -> RunMetadata {
1183 RunMetadata {
1184 id: id.into(),
1185 started_at: "2026-01-01T00:00:00Z".into(),
1186 duration_ms: 1.0,
1187 command: vec!["test".into()],
1188 test_exit_code: Some(0),
1189 integrity: RunIntegrity {
1190 schema_version: 2,
1191 instrumenter_version: "rust".into(),
1192 git: None,
1193 fingerprint: RunFingerprint {
1194 algorithm: "sha256".into(),
1195 source: "0".repeat(64),
1196 tests: "0".repeat(64),
1197 dependencies: "0".repeat(64),
1198 configuration: "0".repeat(64),
1199 instrumenter: "0".repeat(64),
1200 execution: "0".repeat(64),
1201 combined: "0".repeat(64),
1202 source_files: 1,
1203 test_files: 1,
1204 },
1205 stale: None,
1206 stale_reasons: None,
1207 },
1208 raw_evidence: RawEvidenceMetadata {
1209 schema_version: 2,
1210 format: "supercov-evidence-archive".into(),
1211 file: "evidence.raw.gz".into(),
1212 files: 1,
1213 uncompressed_bytes: bytes,
1214 compressed_bytes: bytes,
1215 },
1216 isolated_build: None,
1217 instrumented_build_cache: None,
1218 timings: None,
1219 merged: None,
1220 parents: None,
1221 source_roots: None,
1222 }
1223 }
1224
1225 fn evidence(root: &Path) -> (PathBuf, u64) {
1226 use crate::evidence_archive::{EvidenceArchiveEntry, write_archive};
1227 let mut entries = ["manifest.json", "frontend.json", "coverage-model.json"]
1228 .into_iter()
1229 .map(|path| EvidenceArchiveEntry {
1230 path: path.into(),
1231 contents: b"{}".to_vec(),
1232 })
1233 .collect::<Vec<_>>();
1234 let inputs =
1235 crate::assertion_inputs::capture(root, "javascript", [PathBuf::from("src/index.js")])
1236 .unwrap();
1237 entries = crate::assertion_inputs::append(entries, &inputs).unwrap();
1238 let path = root.join("evidence.gz");
1239 let metadata = write_archive(entries, &path).unwrap();
1240 (path, metadata.compressed_bytes)
1241 }
1242
1243 #[test]
1244 fn defers_only_owned_storage_and_sweeps_without_touching_source() {
1245 let root = project();
1246 let owned = root.join(".supercov/evidence/run");
1247 fs::create_dir_all(&owned).unwrap();
1248 fs::write(owned.join("hit"), "hit").unwrap();
1249 let trash = remove_stored_tree_deferred(&root, &owned).unwrap().unwrap();
1250 assert!(!owned.exists());
1251 assert!(trash.exists());
1252 assert!(matches!(
1253 remove_stored_tree_deferred(&root, &root.join("src")),
1254 Err(LifecycleError::UnsafePath(_))
1255 ));
1256 assert_eq!(sweep_trash(&root).unwrap(), 1);
1257 assert!(root.join("src/index.js").exists());
1258 fs::remove_dir_all(root).unwrap();
1259 }
1260
1261 #[cfg(unix)]
1262 #[test]
1263 fn refuses_linked_storage_ancestors_instead_of_renaming_external_data() {
1264 use std::os::unix::fs::symlink;
1265
1266 let root = project();
1267 let outside = project();
1268 fs::create_dir_all(root.join(".supercov")).unwrap();
1269 fs::create_dir_all(outside.join("run")).unwrap();
1270 fs::write(outside.join("run/user.txt"), "user").unwrap();
1271 symlink(&outside, root.join(".supercov/evidence")).unwrap();
1272 assert!(matches!(
1273 remove_stored_tree_deferred(&root, &root.join(".supercov/evidence/run")),
1274 Err(LifecycleError::UnsafePath(_))
1275 ));
1276 assert_eq!(
1277 fs::read_to_string(outside.join("run/user.txt")).unwrap(),
1278 "user"
1279 );
1280 fs::remove_dir_all(root).unwrap();
1281 fs::remove_dir_all(outside).unwrap();
1282 }
1283
1284 fn analysable_evidence(root: &Path, id: &str) -> (PathBuf, RunMetadata) {
1287 let source = project();
1288 let directory = crate::run_store::create_analyzable_test_run(&source, "source");
1289 let evidence = root.join(format!("{id}.evidence.gz"));
1290 fs::copy(directory.join("evidence.raw.gz"), &evidence).unwrap();
1291 let mut metadata: RunMetadata =
1292 serde_json::from_slice(&fs::read(directory.join("run.json")).unwrap()).unwrap();
1293 metadata.id = id.into();
1294 fs::remove_dir_all(source).unwrap();
1295 (evidence, metadata)
1296 }
1297
1298 fn stored(root: &Path, id: &str) -> crate::run_store::StoredRun {
1300 let directory = root.join(".supercov/runs").join(id);
1301 crate::run_store::StoredRun {
1302 id: id.into(),
1303 evidence_path: directory.join("evidence.raw.gz"),
1304 metadata_path: directory.join("run.json"),
1305 query_index_path: directory.join(crate::run_store::RUST_QUERY_INDEX_FILE),
1306 metadata: serde_json::from_slice(&fs::read(directory.join("run.json")).unwrap())
1307 .unwrap(),
1308 directory,
1309 }
1310 }
1311
1312 #[test]
1313 fn publication_writes_the_query_index_a_first_query_would_have_built() {
1314 let root = project();
1318 let id = "2026-01-01T00-00-00-100Z";
1319 let (evidence, metadata) = analysable_evidence(&root, id);
1320 let published = publish_run(&root, &metadata, &evidence).unwrap();
1321 let index = published.join(crate::run_store::RUST_QUERY_INDEX_FILE);
1322 assert!(index.is_file(), "publication wrote the index");
1323 let run = stored(&root, id);
1324 assert!(
1325 crate::run_store::open_existing_query_index(&run)
1326 .unwrap()
1327 .is_some(),
1328 "and it is valid for the published run"
1329 );
1330 let written = fs::read(&index).unwrap();
1331 fs::remove_file(&index).unwrap();
1332 crate::run_store::open_or_rebuild_query_index(&run).unwrap();
1333 assert_eq!(
1334 fs::read(&index).unwrap(),
1335 written,
1336 "a query rebuilds the same bytes"
1337 );
1338 fs::remove_dir_all(root).unwrap();
1339 }
1340
1341 #[test]
1342 fn evidence_that_will_not_analyse_is_published_as_it_was_before() {
1343 let root = project();
1347 let id = "2026-01-01T00-00-00-200Z";
1348 let (evidence, bytes) = evidence(&root);
1349 let published = publish_run(&root, &metadata(id, bytes), &evidence).unwrap();
1350 assert!(published.join("run.json").is_file());
1351 assert!(published.join("assertions.json").is_file());
1352 assert!(
1353 !published
1354 .join(crate::run_store::RUST_QUERY_INDEX_FILE)
1355 .exists()
1356 );
1357 assert!(!published.join("assertions.summary.cache.json").exists());
1358 let run = stored(&root, id);
1359 let Err(query) = crate::run_store::open_or_rebuild_query_index(&run) else {
1360 panic!("the first query cannot analyse it either");
1361 };
1362 let direct = crate::run_store::analyze_stored_run(&run).expect_err("unanalysable");
1363 assert_eq!(
1364 query.to_string(),
1365 direct.to_string(),
1366 "and says why in the same words"
1367 );
1368 fs::remove_dir_all(root).unwrap();
1369 }
1370
1371 #[test]
1372 fn a_merged_run_is_indexed_by_its_first_query() {
1373 let root = project();
1375 let id = "2026-01-01T00-00-00-300Z";
1376 let (evidence, mut metadata) = analysable_evidence(&root, id);
1377 metadata.merged = Some(true);
1378 let published = publish_run(&root, &metadata, &evidence).unwrap();
1379 let index = published.join(crate::run_store::RUST_QUERY_INDEX_FILE);
1380 assert!(!index.exists());
1381 crate::run_store::open_or_rebuild_query_index(&stored(&root, id)).unwrap();
1382 assert!(index.is_file());
1383 fs::remove_dir_all(root).unwrap();
1384 }
1385
1386 #[test]
1387 fn an_index_that_could_not_be_written_is_built_by_the_first_query() {
1388 let root = project();
1391 let id = "2026-01-01T00-00-00-400Z";
1392 let (evidence, metadata) = analysable_evidence(&root, id);
1393 let published = publish_run_with_fault(
1394 &root,
1395 &metadata,
1396 &evidence,
1397 Archived::default(),
1398 Some(RunPublicationFault::QueryIndexWrite),
1399 )
1400 .unwrap();
1401 let index = published.join(crate::run_store::RUST_QUERY_INDEX_FILE);
1402 assert!(!index.exists(), "no partial index is left");
1403 assert!(published.join("run.json").is_file());
1404 crate::run_store::open_or_rebuild_query_index(&stored(&root, id)).unwrap();
1405 assert!(index.is_file());
1406 fs::remove_dir_all(root).unwrap();
1407 }
1408
1409 #[test]
1410 fn publishes_both_required_files_with_one_visible_rename() {
1411 let root = project();
1412 let id = "2026-01-01T00-00-00-000Z";
1413 let (evidence, bytes) = evidence(&root);
1414 let published = publish_run(&root, &metadata(id, bytes), &evidence).unwrap();
1415 assert!(published.join("run.json").is_file());
1416 assert_eq!(
1417 fs::read(published.join("evidence.raw.gz")).unwrap(),
1418 fs::read(&evidence).unwrap()
1419 );
1420 assert!(published.join("assertions.json").is_file());
1421 assert!(published.join("assertions.state.json").is_file());
1422 assert!(matches!(
1423 publish_run(&root, &metadata(id, bytes), &evidence),
1424 Err(LifecycleError::PublicationExists(_))
1425 ));
1426 fs::remove_dir_all(root).unwrap();
1427 }
1428
1429 #[test]
1430 fn final_rename_failure_exposes_no_run_and_removes_staging() {
1431 let root = project();
1432 let id = "2026-01-01T00-00-00-000Z";
1433 let (evidence, bytes) = evidence(&root);
1434 let error = publish_run_with_fault(
1435 &root,
1436 &metadata(id, bytes),
1437 &evidence,
1438 Archived::default(),
1439 Some(RunPublicationFault::FinalRename),
1440 )
1441 .unwrap_err();
1442 assert!(
1443 error
1444 .to_string()
1445 .contains("injected final run publication rename failure")
1446 );
1447 assert!(!root.join(".supercov/runs").join(id).exists());
1448 assert!(
1449 !root
1450 .join(".supercov/work")
1451 .join(id)
1452 .join("run-publication")
1453 .exists()
1454 );
1455 sweep_trash(&root).unwrap();
1456 fs::remove_dir_all(root).unwrap();
1457 }
1458
1459 #[test]
1460 fn malformed_assertion_inputs_expose_no_partial_run() {
1461 use crate::evidence_archive::{read_archive, write_archive};
1462 let root = project();
1463 let id = "2026-01-01T00-00-00-000Z";
1464 let (evidence, _) = evidence(&root);
1465 let mut entries = read_archive(&evidence).unwrap();
1466 entries
1467 .iter_mut()
1468 .find(|e| e.path == crate::assertion_inputs::ARCHIVE_PATH)
1469 .unwrap()
1470 .contents = b"{broken".to_vec();
1471 let raw = write_archive(entries, &evidence).unwrap();
1472 let error = publish_run(&root, &metadata(id, raw.compressed_bytes), &evidence).unwrap_err();
1473 assert!(error.to_string().contains("assertion map publication"));
1474 assert!(!root.join(".supercov/runs").join(id).exists());
1475 assert!(
1476 !root
1477 .join(".supercov/work")
1478 .join(id)
1479 .join("run-publication")
1480 .exists()
1481 );
1482 fs::remove_dir_all(root).unwrap();
1483 }
1484
1485 #[test]
1486 fn recovers_dead_unpublished_and_fully_published_runs_from_derived_paths() {
1487 let root = project();
1488 let dead = "2026-01-01T00-00-00-000Z";
1489 let published = "2026-01-02T00-00-00-000Z";
1490 for id in [dead, published] {
1491 fs::create_dir_all(
1492 root.join(".supercov/work")
1493 .join(id)
1494 .join(root.file_name().unwrap()),
1495 )
1496 .unwrap();
1497 fs::create_dir_all(root.join(".supercov/evidence").join(id)).unwrap();
1498 write_run_state(&root, &state(&root, id, RunStateStatus::Testing, u32::MAX)).unwrap();
1499 }
1500 let (evidence, bytes) = evidence(&root);
1501 publish_run(&root, &metadata(published, bytes), &evidence).unwrap();
1502 assert_eq!(
1503 recover_abandoned_runs(&root, "recovered").unwrap(),
1504 [dead, published]
1505 );
1506 assert!(!root.join(".supercov/work").join(dead).exists());
1507 assert!(!root.join(".supercov/work").join(published).exists());
1508 assert!(root.join(".supercov/runs").join(published).exists());
1509 assert!(root.join("src/index.js").exists());
1510 sweep_trash(&root).unwrap();
1511 fs::remove_dir_all(root).unwrap();
1512 }
1513
1514 #[test]
1515 fn retention_is_deterministic_dry_run_safe_and_preserves_active_work() {
1516 let root = project();
1517 let ids = [
1518 "2026-01-01T00-00-00-000Z",
1519 "2026-01-02T00-00-00-000Z",
1520 "2026-01-03T00-00-00-000Z",
1521 ];
1522 for id in ids {
1523 fs::create_dir_all(root.join(".supercov/runs").join(id)).unwrap();
1524 write_run_state(
1525 &root,
1526 &state(&root, id, RunStateStatus::Complete, std::process::id()),
1527 )
1528 .unwrap();
1529 }
1530 let active = "2025-12-31T00-00-00-000Z";
1531 write_run_state(
1532 &root,
1533 &state(&root, active, RunStateStatus::Testing, std::process::id()),
1534 )
1535 .unwrap();
1536 let preview = cleanup_storage_locked(
1537 &root,
1538 CleanupOptions {
1539 keep: 1,
1540 dry_run: true,
1541 },
1542 false,
1543 )
1544 .unwrap();
1545 assert_eq!(preview.removed_runs, [ids[1], ids[0]]);
1546 assert!(
1547 ids.iter()
1548 .all(|id| root.join(".supercov/runs").join(id).exists())
1549 );
1550 let result = cleanup_storage_locked(
1551 &root,
1552 CleanupOptions {
1553 keep: 1,
1554 dry_run: false,
1555 },
1556 false,
1557 )
1558 .unwrap();
1559 assert_eq!(result, preview);
1560 assert!(root.join(".supercov/work").join(active).exists());
1561 assert!(root.join(".supercov/runs").join(ids[2]).exists());
1562 sweep_trash(&root).unwrap();
1563 fs::remove_dir_all(root).unwrap();
1564 }
1565
1566 #[test]
1567 fn retention_keeps_the_newest_runs_when_run_ids_disagree_with_recency() {
1568 let root = project();
1569 let (evidence, bytes) = evidence(&root);
1570 for (id, started_at) in [
1573 ("run_ffffffffffffffff", "2026-01-01T00:00:00Z"),
1574 ("run_7777777777777777", "2026-05-01T00:00:00Z"),
1575 ("run_0000000000000001", "2026-09-01T00:00:00Z"),
1576 ] {
1577 let mut run = metadata(id, bytes);
1578 run.started_at = started_at.into();
1579 publish_run(&root, &run, &evidence).unwrap();
1580 }
1581 let result = cleanup_storage_locked(
1582 &root,
1583 CleanupOptions {
1584 keep: 1,
1585 dry_run: false,
1586 },
1587 false,
1588 )
1589 .unwrap();
1590 assert_eq!(
1591 result.removed_runs,
1592 ["run_ffffffffffffffff", "run_7777777777777777"]
1593 );
1594 assert!(
1595 root.join(".supercov/runs")
1596 .join("run_0000000000000001")
1597 .exists()
1598 );
1599 sweep_trash(&root).unwrap();
1600 fs::remove_dir_all(root).unwrap();
1601 }
1602
1603 #[test]
1604 fn direct_runs_initialize_store_ignore_without_overwriting_existing_rules() {
1605 let root = project();
1606 let ignore = root.join(".supercov/.gitignore");
1607 let mut lock = ProjectLock::acquire(&root, "first", "start").unwrap();
1608 assert_eq!(fs::read_to_string(&ignore).unwrap(), "*\n");
1609 lock.release().unwrap();
1610 fs::write(&ignore, "custom\n").unwrap();
1611 let mut lock = ProjectLock::acquire(&root, "second", "start").unwrap();
1612 assert_eq!(fs::read_to_string(&ignore).unwrap(), "custom\n");
1613 lock.release().unwrap();
1614 fs::remove_dir_all(root).unwrap();
1615 }
1616
1617 #[test]
1618 fn cleanup_is_project_locked_and_clean_alone_removes_owned_caches() {
1619 let root = project();
1620 let container = root.join(".supercov/workspaces");
1621 fs::create_dir_all(container.join("workspace/project")).unwrap();
1622 fs::write(
1623 container.join(".supercov-workspace-store"),
1624 b"Supercov instrumented workspace. Safe to delete.\n",
1625 )
1626 .unwrap();
1627 fs::create_dir_all(root.join(".supercov/cache/legacy")).unwrap();
1628 let mut preparation = ProjectLock::acquire(&root, "prepare", "start").unwrap();
1629 crate::workspace::prepare_cargo_cached_workspace(&root, &preparation).unwrap();
1630 let cargo_container = crate::workspace::cargo_workspace_container(&root).unwrap();
1631 preparation.release().unwrap();
1632
1633 let mut active = ProjectLock::acquire(&root, "active", "start").unwrap();
1634 assert!(matches!(
1635 clean_storage(
1636 &root,
1637 CleanupOptions {
1638 keep: 0,
1639 dry_run: false
1640 },
1641 "now"
1642 ),
1643 Err(LifecycleError::ActiveRun { .. })
1644 ));
1645 assert!(container.exists());
1646 assert!(cargo_container.exists());
1647 active.release().unwrap();
1648
1649 let cleaned = clean_storage(
1650 &root,
1651 CleanupOptions {
1652 keep: 0,
1653 dry_run: false,
1654 },
1655 "now",
1656 )
1657 .unwrap();
1658 assert!(cleaned.removed_build_cache);
1659 assert!(!container.exists());
1660 assert!(!cargo_container.exists());
1661 assert!(!root.join(".supercov/cache").exists());
1662 sweep_trash(&root).unwrap();
1663 fs::remove_dir_all(root).unwrap();
1664 }
1665
1666 #[test]
1671 fn evidence_a_failed_run_measured_outlives_the_work_tree() {
1672 let root = project();
1673 let work = root.join(".supercov/work/run_kept");
1674 let evidence = work.join("python/evidence");
1675 fs::create_dir_all(evidence.join("worker-1")).unwrap();
1676 fs::write(evidence.join("main.jsonl"), "what the run saw").unwrap();
1677 fs::write(evidence.join("worker-1/mcdc.json"), "{}").unwrap();
1678
1679 let kept = keep_failed_evidence(&root, &evidence, "run_kept").expect("the evidence");
1680 remove_stored_tree_deferred(&root, &work).unwrap();
1681 sweep_trash(&root).unwrap();
1682
1683 assert!(!evidence.exists(), "the work tree still went");
1684 assert_eq!(
1685 fs::read_to_string(kept.join("main.jsonl")).unwrap(),
1686 "what the run saw",
1687 );
1688 assert_eq!(
1691 fs::read_to_string(kept.join("worker-1/mcdc.json")).unwrap(),
1692 "{}"
1693 );
1694 fs::remove_dir_all(root).unwrap();
1695 }
1696
1697 #[test]
1698 fn nothing_to_keep_leaves_no_directory_to_puzzle_over() {
1699 let root = project();
1700 let missing = root.join(".supercov/work/run_early/python/evidence");
1703 assert!(keep_failed_evidence(&root, &missing, "run_early").is_none());
1704 fs::create_dir_all(&missing).unwrap();
1705 assert!(keep_failed_evidence(&root, &missing, "run_early").is_none());
1706 assert!(
1707 !root.join(".supercov/failed-evidence/run_early").exists(),
1708 "an empty directory is worse than none: it reads as evidence",
1709 );
1710 fs::remove_dir_all(root).unwrap();
1711 }
1712
1713 #[test]
1716 fn the_failure_says_where_the_evidence_was_kept() {
1717 let root = project();
1718 let evidence = root.join(".supercov/work/run_told/python/evidence");
1719 fs::create_dir_all(&evidence).unwrap();
1720 fs::write(evidence.join("main.jsonl"), "what the run saw").unwrap();
1721
1722 let told = note_kept_evidence(&root, &evidence, "run_told", "boom".into());
1723 let kept = keep_failed_evidence(&root, &evidence, "run_told").expect("the evidence");
1729 assert_eq!(kept, root.join(FAILED_EVIDENCE).join("run_told"));
1730 assert!(
1731 told.starts_with("boom\n"),
1732 "the failure itself came second: {told}"
1733 );
1734 assert!(told.contains(&kept.display().to_string()), "{told}");
1735
1736 let nothing = root.join(".supercov/work/run_empty/python/evidence");
1739 assert_eq!(
1740 note_kept_evidence(&root, ¬hing, "run_empty", "boom".into()),
1741 "boom",
1742 );
1743 fs::remove_dir_all(root).unwrap();
1744 }
1745
1746 #[test]
1747 fn a_trim_leaves_kept_evidence_alone() {
1748 let root = project();
1749 let kept = kept_evidence(&root, "run_trimmed");
1750
1751 clean_storage(
1755 &root,
1756 CleanupOptions {
1757 keep: 20,
1758 dry_run: false,
1759 },
1760 "now",
1761 )
1762 .unwrap();
1763 let retention = cleanup_storage_locked(
1764 &root,
1765 CleanupOptions {
1766 keep: 0,
1767 dry_run: false,
1768 },
1769 false,
1770 )
1771 .unwrap();
1772 sweep_trash(&root).unwrap();
1773
1774 assert!(retention.removed_failed_evidence.is_empty());
1775 assert!(kept.join("main.jsonl").exists());
1776 fs::remove_dir_all(root).unwrap();
1777 }
1778
1779 #[test]
1780 fn cleaning_everything_reclaims_kept_evidence_and_says_which() {
1781 let root = project();
1782 let kept = kept_evidence(&root, "run_cleaned");
1783
1784 let preview = clean_storage(
1785 &root,
1786 CleanupOptions {
1787 keep: 0,
1788 dry_run: true,
1789 },
1790 "now",
1791 )
1792 .unwrap();
1793 assert_eq!(
1794 preview.removed_failed_evidence,
1795 vec!["run_cleaned".to_string()]
1796 );
1797 assert!(kept.join("main.jsonl").exists(), "a preview removed it");
1798
1799 let result = clean_storage(
1800 &root,
1801 CleanupOptions {
1802 keep: 0,
1803 dry_run: false,
1804 },
1805 "now",
1806 )
1807 .unwrap();
1808 sweep_trash(&root).unwrap();
1809
1810 assert_eq!(
1813 result.removed_failed_evidence,
1814 vec!["run_cleaned".to_string()]
1815 );
1816 assert!(!kept.exists());
1817 fs::remove_dir_all(root).unwrap();
1818 }
1819}