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 Cleaning,
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!(
55 formatter,
56 "coverage run {run_id} is active in this project (pid {pid}). Its storage can be cleaned when no run is active."
57 ),
58 Self::Cleaning => {
59 write!(
60 formatter,
61 "this project's Supercov storage is being cleaned. No test ran: start the run again when the cleaning has finished."
62 )
63 }
64 Self::LockUnavailable => {
65 write!(formatter, "could not acquire the Supercov project lock")
66 }
67 Self::PublicationExists(id) => write!(formatter, "coverage run already exists: {id}"),
68 Self::Metadata(error) => write!(formatter, "invalid run metadata: {error}"),
69 Self::EvidenceLength { expected, actual } => write!(
70 formatter,
71 "evidence length changed before publication: expected {expected}, got {actual}"
72 ),
73 Self::EvidenceChanged => write!(formatter, "evidence changed during publication"),
74 }
75 }
76}
77
78impl std::error::Error for LifecycleError {}
79
80fn io_error(path: &Path, source: io::Error) -> LifecycleError {
81 LifecycleError::Io {
82 path: path.to_owned(),
83 source,
84 }
85}
86
87fn checked_id(id: &str) -> Result<(), LifecycleError> {
88 valid_run_id(id)
89 .then_some(())
90 .ok_or_else(|| LifecycleError::InvalidRunId(id.into()))
91}
92
93fn absolute_root(root: &Path) -> Result<PathBuf, LifecycleError> {
94 if root.is_absolute() {
95 Ok(root.to_owned())
96 } else {
97 std::env::current_dir()
98 .map(|cwd| cwd.join(root))
99 .map_err(|source| io_error(root, source))
100 }
101}
102
103fn lexical_descendant(root: &Path, path: &Path) -> bool {
104 let Ok(local) = path.strip_prefix(root) else {
105 return false;
106 };
107 !local.as_os_str().is_empty()
108 && local
109 .components()
110 .all(|component| matches!(component, Component::Normal(_)))
111}
112
113fn reject_linked_ancestors(
114 root: &Path,
115 path: &Path,
116 include_leaf: bool,
117) -> Result<(), LifecycleError> {
118 let local = path
119 .strip_prefix(root)
120 .map_err(|_| LifecycleError::UnsafePath(path.into()))?;
121 let components = local.components().collect::<Vec<_>>();
122 let through = if include_leaf {
123 components.len()
124 } else {
125 components.len().saturating_sub(1)
126 };
127 let mut current = root.to_owned();
128 for component in components.into_iter().take(through) {
129 let Component::Normal(component) = component else {
130 return Err(LifecycleError::UnsafePath(path.into()));
131 };
132 current.push(component);
133 match fs::symlink_metadata(¤t) {
134 Ok(metadata) if metadata.file_type().is_symlink() => {
135 return Err(LifecycleError::UnsafePath(current));
136 }
137 Ok(metadata) if !metadata.file_type().is_dir() => {
138 return Err(LifecycleError::UnsafePath(current));
139 }
140 Ok(_) => {}
141 Err(error) if error.kind() == io::ErrorKind::NotFound => break,
142 Err(source) => return Err(io_error(¤t, source)),
143 }
144 }
145 Ok(())
146}
147
148fn owned_workspace_container(root: &Path) -> bool {
149 let container = crate::workspace::workspace_container(root);
150 crate::workspace::owned_workspace_path(&container)
151}
152
153fn unique_name() -> String {
154 let nanos = SystemTime::now()
155 .duration_since(UNIX_EPOCH)
156 .unwrap_or_default()
157 .as_nanos();
158 format!(
159 "{}-{nanos}-{}",
160 std::process::id(),
161 UNIQUE.fetch_add(1, Ordering::Relaxed)
162 )
163}
164
165pub(crate) fn sync_directory_handle(path: &Path) -> io::Result<()> {
174 #[cfg(unix)]
175 {
176 File::open(path).and_then(|directory| directory.sync_all())
177 }
178 #[cfg(not(unix))]
179 {
180 let _ = path;
181 Ok(())
182 }
183}
184
185pub(crate) fn sync_directory(path: &Path) -> Result<(), LifecycleError> {
186 sync_directory_handle(path).map_err(|source| io_error(path, source))
187}
188
189pub(crate) fn atomic_rename(source: &Path, destination: &Path) -> Result<(), LifecycleError> {
190 let source_parent = source
191 .parent()
192 .ok_or_else(|| LifecycleError::UnsafePath(source.into()))?;
193 let destination_parent = destination
194 .parent()
195 .ok_or_else(|| LifecycleError::UnsafePath(destination.into()))?;
196 fs::create_dir_all(destination_parent).map_err(|error| io_error(destination_parent, error))?;
197 fs::rename(source, destination).map_err(|error| io_error(destination, error))?;
198 sync_directory(destination_parent)?;
199 if source_parent != destination_parent {
200 sync_directory(source_parent)?;
201 }
202 Ok(())
203}
204
205pub(crate) fn atomic_write(root: &Path, path: &Path, bytes: &[u8]) -> Result<(), LifecycleError> {
206 let parent = path
207 .parent()
208 .ok_or_else(|| LifecycleError::UnsafePath(path.into()))?;
209 reject_linked_ancestors(root, parent, true)?;
210 fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
211 for _ in 0..16 {
212 let temporary = parent.join(format!(
213 ".{}.{}.tmp",
214 path.file_name()
215 .and_then(|value| value.to_str())
216 .unwrap_or("state"),
217 unique_name()
218 ));
219 let mut file = match OpenOptions::new()
220 .write(true)
221 .create_new(true)
222 .open(&temporary)
223 {
224 Ok(file) => file,
225 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
226 Err(source) => return Err(io_error(&temporary, source)),
227 };
228 if let Err(source) = file.write_all(bytes).and_then(|_| file.sync_all()) {
229 let _ = fs::remove_file(&temporary);
230 return Err(io_error(&temporary, source));
231 }
232 drop(file);
233 if let Err(source) = fs::rename(&temporary, path) {
234 let _ = fs::remove_file(&temporary);
235 return Err(io_error(path, source));
236 }
237 sync_directory(parent)?;
238 return Ok(());
239 }
240 Err(LifecycleError::LockUnavailable)
241}
242
243pub fn remove_stored_tree_deferred(
244 project_root: &Path,
245 target: &Path,
246) -> Result<Option<PathBuf>, LifecycleError> {
247 let root = absolute_root(project_root)?;
248 let target = if target.is_absolute() {
249 target.to_owned()
250 } else {
251 root.join(target)
252 };
253 match fs::symlink_metadata(&target) {
254 Ok(_) => {}
255 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
256 Err(source) => return Err(io_error(&target, source)),
257 }
258 let store = root.join(".supercov");
259 let trash = root.join(TRASH);
260 let in_store = lexical_descendant(&store, &target) && !lexical_descendant(&trash, &target);
261 let container = crate::workspace::workspace_container(&root);
262 let in_workspace = owned_workspace_container(&root)
263 && (target == container || lexical_descendant(&container, &target));
264 if !in_store && !in_workspace {
265 return Err(LifecycleError::UnsafePath(target));
266 }
267 reject_linked_ancestors(if in_store { &store } else { &container }, &target, false)?;
268 reject_linked_ancestors(&root, &trash, true)?;
269 fs::create_dir_all(&trash).map_err(|source| io_error(&trash, source))?;
270 let destination = trash.join(unique_name());
271 atomic_rename(&target, &destination)?;
272 Ok(Some(destination))
273}
274
275static BACKGROUND_SWEEP: std::sync::Mutex<Option<std::thread::JoinHandle<()>>> =
276 std::sync::Mutex::new(None);
277
278pub fn sweep_trash_in_background(project_root: &Path) {
291 let root = project_root.to_owned();
292 let sweep = std::thread::spawn(move || {
293 let _ = sweep_trash(&root);
294 });
295 if let Ok(mut running) = BACKGROUND_SWEEP.lock() {
296 *running = Some(sweep);
297 }
298}
299
300pub fn finish_background_sweep(patience: Duration) -> bool {
303 let Some(sweep) = BACKGROUND_SWEEP
304 .lock()
305 .ok()
306 .and_then(|mut running| running.take())
307 else {
308 return true;
309 };
310 let deadline = std::time::Instant::now() + patience;
311 while !sweep.is_finished() {
312 if std::time::Instant::now() >= deadline {
313 return false;
314 }
315 std::thread::sleep(Duration::from_millis(5));
316 }
317 let _ = sweep.join();
318 true
319}
320
321pub fn sweep_trash(project_root: &Path) -> Result<usize, LifecycleError> {
323 let root = absolute_root(project_root)?;
324 let trash = root.join(TRASH);
325 reject_linked_ancestors(&root, &trash, true)?;
326 let entries = match fs::read_dir(&trash) {
327 Ok(entries) => entries,
328 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
329 Err(source) => return Err(io_error(&trash, source)),
330 };
331 let Some(_lock) = TrashLock::acquire(&trash)? else {
332 return Ok(0);
333 };
334 let mut removed = 0;
335 for entry in entries {
336 let entry = entry.map_err(|source| io_error(&trash, source))?;
337 let path = entry.path();
338 if entry.file_name() == ".deleter.lock" {
339 continue;
340 }
341 let gone = |error: &io::Error| error.kind() == io::ErrorKind::NotFound;
344 let metadata = match fs::symlink_metadata(&path) {
345 Ok(metadata) => metadata,
346 Err(error) if gone(&error) => continue,
347 Err(source) => return Err(io_error(&path, source)),
348 };
349 let removal = if metadata.file_type().is_dir() {
350 fs::remove_dir_all(&path)
351 } else {
352 fs::remove_file(&path)
353 };
354 match removal {
355 Ok(()) => removed += 1,
356 Err(error) if gone(&error) => {}
357 Err(source) => return Err(io_error(&path, source)),
358 }
359 }
360 Ok(removed)
361}
362
363struct TrashLock {
364 path: PathBuf,
365}
366
367impl TrashLock {
368 fn acquire(trash: &Path) -> Result<Option<Self>, LifecycleError> {
369 let path = trash.join(".deleter.lock");
370 for _ in 0..2 {
371 match OpenOptions::new().write(true).create_new(true).open(&path) {
372 Ok(mut file) => {
373 write!(file, "{}", std::process::id())
374 .and_then(|_| file.sync_all())
375 .map_err(|source| io_error(&path, source))?;
376 return Ok(Some(Self { path }));
377 }
378 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
379 let owner = fs::read_to_string(&path)
380 .ok()
381 .and_then(|value| value.parse::<u32>().ok());
382 if owner.is_some_and(process_exists) {
383 return Ok(None);
384 }
385 if owner.is_none() {
386 let age = fs::metadata(&path)
387 .and_then(|metadata| metadata.modified())
388 .ok()
389 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
390 .unwrap_or_default();
391 if age < INCOMPLETE_LOCK_GRACE {
392 return Ok(None);
393 }
394 }
395 fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
396 }
397 Err(source) => return Err(io_error(&path, source)),
398 }
399 }
400 Ok(None)
401 }
402}
403
404impl Drop for TrashLock {
405 fn drop(&mut self) {
406 let _ = fs::remove_file(&self.path);
407 }
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(rename_all = "lowercase")]
412pub enum RunStateStatus {
413 Preparing,
414 Building,
415 Testing,
416 Publishing,
417 Complete,
418 Failed,
419 Interrupted,
420 Abandoned,
421}
422
423impl RunStateStatus {
424 pub fn terminal(self) -> bool {
425 matches!(
426 self,
427 Self::Complete | Self::Failed | Self::Interrupted | Self::Abandoned
428 )
429 }
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(rename_all = "camelCase", deny_unknown_fields)]
434pub struct RunState {
435 pub id: String,
436 pub pid: u32,
437 pub root: String,
438 pub workspace: String,
439 pub started_at: String,
440 pub updated_at: String,
441 pub status: RunStateStatus,
442 #[serde(skip_serializing_if = "Option::is_none")]
443 pub signal: Option<String>,
444 #[serde(skip_serializing_if = "Option::is_none")]
445 pub error: Option<String>,
446}
447
448fn state_path(root: &Path, id: &str) -> PathBuf {
449 root.join(".supercov/work").join(id).join("state.json")
450}
451
452pub fn write_run_state(root: &Path, state: &RunState) -> Result<(), LifecycleError> {
453 checked_id(&state.id)?;
454 let mut bytes = serde_json::to_vec_pretty(state).map_err(LifecycleError::Metadata)?;
455 bytes.push(b'\n');
456 atomic_write(root, &state_path(root, &state.id), &bytes)
457}
458
459fn read_state(root: &Path, id: &str) -> Result<Option<RunState>, LifecycleError> {
460 checked_id(id)?;
461 let path = state_path(root, id);
462 let bytes = match fs::read(&path) {
463 Ok(bytes) => bytes,
464 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
465 Err(source) => return Err(io_error(&path, source)),
466 };
467 serde_json::from_slice(&bytes)
468 .map(Some)
469 .map_err(|error| LifecycleError::InvalidState(error.to_string()))
470}
471
472pub fn update_run_state(
473 root: &Path,
474 id: &str,
475 status: RunStateStatus,
476 updated_at: &str,
477 error: Option<String>,
478) -> Result<RunState, LifecycleError> {
479 let mut state = read_state(root, id)?
480 .ok_or_else(|| LifecycleError::InvalidState(format!("state is missing for {id}")))?;
481 state.status = status;
482 state.updated_at = updated_at.into();
483 state.error = error;
484 write_run_state(root, &state)?;
485 Ok(state)
486}
487
488pub fn interrupt_run_state(
489 root: &Path,
490 id: &str,
491 updated_at: &str,
492 signal: &str,
493) -> Result<RunState, LifecycleError> {
494 let mut state = read_state(root, id)?
495 .ok_or_else(|| LifecycleError::InvalidState(format!("state is missing for {id}")))?;
496 state.status = RunStateStatus::Interrupted;
497 state.updated_at = updated_at.into();
498 state.signal = Some(signal.into());
499 state.error = Some(format!("Interrupted by {signal}"));
500 write_run_state(root, &state)?;
501 Ok(state)
502}
503
504#[cfg(unix)]
505fn process_exists(pid: u32) -> bool {
506 if pid == 0 || pid > libc::pid_t::MAX as u32 {
507 return false;
508 }
509 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
511 result == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
512}
513
514#[cfg(windows)]
515fn process_exists(pid: u32) -> bool {
516 use windows_sys::Win32::Foundation::{
517 CloseHandle, ERROR_ACCESS_DENIED, GetLastError, STILL_ACTIVE,
518 };
519 use windows_sys::Win32::System::Threading::{
520 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
521 };
522 if pid == 0 {
523 return false;
524 }
525 let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
528 if handle.is_null() {
529 return unsafe { GetLastError() } == ERROR_ACCESS_DENIED;
531 }
532 let mut code = 0u32;
533 let read = unsafe { GetExitCodeProcess(handle, &mut code) };
535 unsafe { CloseHandle(handle) };
537 read != 0 && code == STILL_ACTIVE as u32
538}
539
540#[cfg(not(any(unix, windows)))]
541fn process_exists(pid: u32) -> bool {
542 pid == std::process::id()
543}
544
545#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(rename_all = "camelCase", deny_unknown_fields)]
547struct LockOwner {
548 run_id: String,
549 pid: u32,
550 started_at: String,
551}
552
553const MOST_COPIES: usize = 64;
557
558const IDLE_COPY_KEPT: Duration = Duration::from_secs(24 * 60 * 60);
562
563pub struct ProjectLock {
573 root: PathBuf,
574 owner: LockOwner,
575 copy: usize,
576 held: Vec<Held>,
578 released: bool,
579}
580
581fn copy_lock_name(copy: usize) -> String {
584 if copy == 1 {
585 "active".into()
586 } else {
587 format!("active-{copy}")
588 }
589}
590
591fn read_owner(path: &Path) -> Option<LockOwner> {
592 serde_json::from_slice(&fs::read(path).ok()?).ok()
593}
594
595struct Held(File);
602
603impl Drop for Held {
604 fn drop(&mut self) {
605 let _ = self.0.unlock();
606 }
607}
608
609fn take_lock(
614 path: &Path,
615 shared: bool,
616 alive: impl Fn() -> bool,
617) -> Result<Option<Held>, LifecycleError> {
618 let file = OpenOptions::new()
619 .read(true)
620 .write(true)
621 .create(true)
622 .truncate(false)
623 .open(path)
624 .map_err(|source| io_error(path, source))?;
625 let taken = if shared {
626 file.try_lock_shared()
627 } else {
628 file.try_lock()
629 };
630 match taken {
631 Ok(()) => Ok(Some(Held(file))),
632 Err(fs::TryLockError::WouldBlock) => Ok(None),
633 Err(fs::TryLockError::Error(_)) => Ok((!alive()).then_some(Held(file))),
634 }
635}
636
637fn live_owner(locks: &Path, copy: usize) -> Option<LockOwner> {
639 read_owner(&locks.join(format!("{}.json", copy_lock_name(copy))))
640 .filter(|owner| owner.pid != std::process::id() && process_exists(owner.pid))
641}
642
643impl ProjectLock {
644 pub fn acquire(root: &Path, run_id: &str, started_at: &str) -> Result<Self, LifecycleError> {
646 Self::take(root, run_id, started_at, false)
647 }
648
649 pub fn acquire_alone(
651 root: &Path,
652 run_id: &str,
653 started_at: &str,
654 ) -> Result<Self, LifecycleError> {
655 Self::take(root, run_id, started_at, true)
656 }
657
658 fn take(
659 root: &Path,
660 run_id: &str,
661 started_at: &str,
662 alone: bool,
663 ) -> Result<Self, LifecycleError> {
664 checked_id(run_id)?;
665 let locks = root.join(".supercov/locks");
666 reject_linked_ancestors(root, &locks, true)?;
667 fs::create_dir_all(&locks).map_err(|source| io_error(&locks, source))?;
668 let ignore = root.join(".supercov/.gitignore");
672 match OpenOptions::new()
673 .write(true)
674 .create_new(true)
675 .open(&ignore)
676 {
677 Ok(mut file) => file
678 .write_all(b"*\n")
679 .map_err(|source| io_error(&ignore, source))?,
680 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
681 Err(source) => return Err(io_error(&ignore, source)),
682 }
683 let owner = LockOwner {
684 run_id: run_id.into(),
685 pid: std::process::id(),
686 started_at: started_at.into(),
687 };
688 let active = || (1..=MOST_COPIES).find_map(|copy| live_owner(&locks, copy));
689 let store = take_lock(&locks.join("store.lock"), !alone, || {
690 alone && active().is_some()
691 })?;
692 let Some(store) = store else {
693 let holder = || {
696 (1..=MOST_COPIES).find_map(|copy| {
697 read_owner(&locks.join(format!("{}.json", copy_lock_name(copy))))
698 .filter(|owner| process_exists(owner.pid))
699 })
700 };
701 return Err(if alone {
702 holder().map_or(LifecycleError::LockUnavailable, |owner| {
703 LifecycleError::ActiveRun {
704 run_id: owner.run_id,
705 pid: owner.pid,
706 }
707 })
708 } else {
709 LifecycleError::Cleaning
710 });
711 };
712 if alone && let Some(owner) = active() {
715 return Err(LifecycleError::ActiveRun {
716 run_id: owner.run_id,
717 pid: owner.pid,
718 });
719 }
720 for copy in 1..=MOST_COPIES {
721 let name = copy_lock_name(copy);
722 let Some(lock) = take_lock(&locks.join(format!("{name}.lock")), false, || {
723 live_owner(&locks, copy).is_some()
724 })?
725 else {
726 continue;
727 };
728 if live_owner(&locks, copy).is_some() {
729 continue;
730 }
731 let path = locks.join(format!("{name}.json"));
732 let mut payload =
733 serde_json::to_vec_pretty(&owner).map_err(LifecycleError::Metadata)?;
734 payload.push(b'\n');
735 fs::write(&path, payload).map_err(|source| io_error(&path, source))?;
736 return Ok(Self {
737 root: root.to_owned(),
738 owner,
739 copy,
740 held: vec![store, lock],
741 released: false,
742 });
743 }
744 Err(LifecycleError::LockUnavailable)
745 }
746
747 pub fn copy(&self) -> usize {
749 self.copy
750 }
751
752 pub fn release(&mut self) -> Result<(), LifecycleError> {
753 if self.released {
754 return Ok(());
755 }
756 self.released = true;
757 let path = self
758 .root
759 .join(".supercov/locks")
760 .join(format!("{}.json", copy_lock_name(self.copy)));
761 if read_owner(&path).is_some_and(|owner| owner == self.owner) {
762 fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
763 }
764 let _ = self
766 .held
767 .last()
768 .map(|lock| lock.0.set_modified(SystemTime::now()));
769 self.held.clear();
770 Ok(())
771 }
772
773 pub(crate) fn protects(&self, root: &Path) -> bool {
774 !self.released && self.root == root
775 }
776}
777
778impl Drop for ProjectLock {
779 fn drop(&mut self) {
780 let _ = self.release();
781 }
782}
783
784fn copy_regular_file(source: &Path, destination: &Path) -> Result<u64, LifecycleError> {
785 let metadata = fs::symlink_metadata(source).map_err(|error| io_error(source, error))?;
786 if !metadata.file_type().is_file() {
787 return Err(LifecycleError::UnsafePath(source.into()));
788 }
789 if reflink_copy::reflink(source, destination).is_ok() {
793 File::open(destination)
794 .and_then(|file| file.sync_all())
795 .map_err(|error| io_error(destination, error))?;
796 return fs::symlink_metadata(destination)
797 .map(|metadata| metadata.len())
798 .map_err(|error| io_error(destination, error));
799 }
800 let mut input = File::open(source).map_err(|error| io_error(source, error))?;
801 let mut output = OpenOptions::new()
802 .write(true)
803 .create_new(true)
804 .open(destination)
805 .map_err(|error| io_error(destination, error))?;
806 let copied = io::copy(&mut input, &mut output).map_err(|error| io_error(destination, error))?;
807 output
808 .sync_all()
809 .map_err(|error| io_error(destination, error))?;
810 Ok(copied)
811}
812
813fn file_sha256(path: &Path) -> Result<[u8; 32], LifecycleError> {
814 let metadata = fs::symlink_metadata(path).map_err(|source| io_error(path, source))?;
815 if !metadata.file_type().is_file() {
816 return Err(LifecycleError::UnsafePath(path.into()));
817 }
818 let mut file = File::open(path).map_err(|source| io_error(path, source))?;
819 let mut hash = Sha256::new();
820 let mut buffer = [0_u8; 128 * 1024];
821 loop {
822 let read = file
823 .read(&mut buffer)
824 .map_err(|source| io_error(path, source))?;
825 if read == 0 {
826 break;
827 }
828 hash.update(&buffer[..read]);
829 }
830 Ok(hash.finalize().into())
831}
832
833fn hex_digest(digest: &[u8; 32]) -> String {
834 use std::fmt::Write as _;
835 digest
836 .iter()
837 .fold(String::with_capacity(64), |mut text, byte| {
838 let _ = write!(text, "{byte:02x}");
839 text
840 })
841}
842
843pub fn publish_run(
846 root: &Path,
847 metadata: &RunMetadata,
848 evidence_source: &Path,
849) -> Result<PathBuf, LifecycleError> {
850 publish_run_with_fault(root, metadata, evidence_source, Archived::default(), None)
851}
852
853#[derive(Default)]
858pub struct Archived {
859 pub report: Option<crate::coverage_report::CoverageReport>,
860 pub inputs: Option<crate::source_manifest::InputManifest>,
861}
862
863pub fn publish_archived_run(
865 root: &Path,
866 metadata: &RunMetadata,
867 evidence_source: &Path,
868 archived: Archived,
869) -> Result<PathBuf, LifecycleError> {
870 publish_run_with_fault(root, metadata, evidence_source, archived, None)
871}
872
873#[derive(Debug, Clone, Copy, PartialEq, Eq)]
874pub(crate) enum RunPublicationFault {
875 FinalRename,
876 QueryIndexWrite,
878}
879
880pub(crate) fn publish_run_with_fault(
881 root: &Path,
882 metadata: &RunMetadata,
883 evidence_source: &Path,
884 archived: Archived,
885 fault: Option<RunPublicationFault>,
886) -> Result<PathBuf, LifecycleError> {
887 let mut metadata = metadata.clone();
889 metadata
890 .recorded_by
891 .get_or_insert_with(|| env!("CARGO_PKG_VERSION").to_owned());
892 let metadata = &metadata;
893 checked_id(&metadata.id)?;
894 let destination = root.join(".supercov/runs").join(&metadata.id);
895 reject_linked_ancestors(root, &destination, false)?;
896 if fs::symlink_metadata(&destination).is_ok() {
897 return Err(LifecycleError::PublicationExists(metadata.id.clone()));
898 }
899 let staging = root
900 .join(".supercov/work")
901 .join(&metadata.id)
902 .join("run-publication");
903 reject_linked_ancestors(root, &staging, true)?;
904 if fs::symlink_metadata(&staging).is_ok() {
905 remove_stored_tree_deferred(root, &staging)?;
906 }
907 let evidence_sha256 = file_sha256(evidence_source)?;
908 fs::create_dir_all(&staging).map_err(|source| io_error(&staging, source))?;
909 let copied = copy_regular_file(evidence_source, &staging.join("evidence.raw.gz"))?;
910 if copied != metadata.raw_evidence.compressed_bytes {
911 remove_stored_tree_deferred(root, &staging)?;
912 return Err(LifecycleError::EvidenceLength {
913 expected: metadata.raw_evidence.compressed_bytes,
914 actual: copied,
915 });
916 }
917 if file_sha256(&staging.join("evidence.raw.gz"))? != evidence_sha256 {
920 remove_stored_tree_deferred(root, &staging)?;
921 return Err(LifecycleError::EvidenceChanged);
922 }
923 let mut json = serde_json::to_vec_pretty(metadata).map_err(LifecycleError::Metadata)?;
924 json.push(b'\n');
925 atomic_write(root, &staging.join("run.json"), &json)?;
926 let staged = crate::run_store::StoredRun {
934 id: metadata.id.clone(),
935 directory: staging.clone(),
936 evidence_path: staging.join("evidence.raw.gz"),
937 metadata_path: staging.join("run.json"),
938 query_index_path: staging.join(crate::run_store::RUST_QUERY_INDEX_FILE),
939 metadata: metadata.clone(),
940 };
941 let analysed = if metadata.merged == Some(true) {
942 None
943 } else if archived.report.is_some() {
944 archived.report
945 } else {
946 crate::run_store::analyze_stored_run(&staged).ok()
947 };
948 let archived_inputs = match archived
949 .inputs
950 .map(|manifest| {
951 crate::source_manifest::archived_manifest(manifest, hex_digest(&evidence_sha256))
952 })
953 .transpose()
954 {
955 Ok(inputs) => inputs,
956 Err(reason) => {
957 let _ = remove_stored_tree_deferred(root, &staging);
958 return Err(LifecycleError::InvalidState(format!(
959 "source manifest publication: {reason}"
960 )));
961 }
962 };
963 let prepared = std::thread::scope(|scope| {
966 let index = analysed.as_ref().map(|report| {
967 scope.spawn(|| {
968 if fault == Some(RunPublicationFault::QueryIndexWrite)
971 || crate::run_store::write_query_index_from(&staged, report).is_err()
972 {
973 let _ = fs::remove_file(&staged.query_index_path);
974 }
975 if crate::run_store::write_statement_spans(&staged, report).is_err() {
978 let _ = fs::remove_file(
979 staged
980 .directory
981 .join(crate::run_store::STATEMENT_SPANS_FILE),
982 );
983 }
984 if crate::run_store::write_assessment_input(&staged, report).is_err() {
985 let _ = fs::remove_file(
986 staged
987 .directory
988 .join(crate::run_store::ASSESSMENT_INPUT_FILE),
989 );
990 }
991 })
992 });
993 let prepared = crate::source_manifest::prepare_publication_with(
994 root,
995 &staging,
996 metadata,
997 analysed.as_ref(),
998 archived_inputs,
999 );
1000 if let Some(index) = index {
1001 index
1002 .join()
1003 .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
1004 }
1005 prepared
1006 });
1007 if let Some(report) = analysed {
1011 std::thread::spawn(move || drop(report));
1012 }
1013 if let Err(reason) = prepared {
1014 let _ = remove_stored_tree_deferred(root, &staging);
1015 return Err(LifecycleError::InvalidState(format!(
1016 "source manifest publication: {reason}"
1017 )));
1018 }
1019 sync_directory(&staging)?;
1020 let runs = destination.parent().expect("runs parent");
1021 fs::create_dir_all(runs).map_err(|source| io_error(runs, source))?;
1022 if fault == Some(RunPublicationFault::FinalRename) {
1023 let error = io_error(
1024 &destination,
1025 io::Error::new(
1026 io::ErrorKind::PermissionDenied,
1027 "injected final run publication rename failure",
1028 ),
1029 );
1030 let _ = remove_stored_tree_deferred(root, &staging);
1031 return Err(error);
1032 }
1033 fs::rename(&staging, &destination).map_err(|source| io_error(&destination, source))?;
1034 sync_directory(runs)?;
1035 Ok(destination)
1036}
1037
1038fn published_started_at(root: &Path, id: &str) -> String {
1042 let directory = root.join(".supercov/runs").join(id);
1043 fs::read(directory.join("run.json"))
1044 .ok()
1045 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
1046 .as_ref()
1047 .and_then(|value| value.get("startedAt"))
1048 .and_then(|value| value.as_str())
1049 .unwrap_or_default()
1050 .to_owned()
1051}
1052
1053fn published_run(root: &Path, id: &str) -> bool {
1054 let directory = root.join(".supercov/runs").join(id);
1055 let metadata = fs::read(directory.join("run.json"))
1056 .ok()
1057 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
1058 metadata
1059 .as_ref()
1060 .and_then(|value| value.get("id"))
1061 .and_then(|value| value.as_str())
1062 == Some(id)
1063 && fs::symlink_metadata(directory.join("evidence.raw.gz"))
1064 .is_ok_and(|metadata| metadata.file_type().is_file())
1065}
1066
1067pub fn finalize_published_run(root: &Path, id: &str) -> Result<bool, LifecycleError> {
1068 checked_id(id)?;
1069 if !published_run(root, id) {
1070 return Ok(false);
1071 }
1072 remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(id))?;
1073 remove_stored_tree_deferred(root, &root.join(".supercov/work").join(id))?;
1074 Ok(true)
1075}
1076
1077fn child_directories(path: &Path) -> Result<Vec<String>, LifecycleError> {
1078 let root = path
1079 .ancestors()
1080 .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".supercov"))
1081 .and_then(Path::parent)
1082 .unwrap_or(path);
1083 reject_linked_ancestors(root, path, true)?;
1084 let entries = match fs::read_dir(path) {
1085 Ok(entries) => entries,
1086 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
1087 Err(source) => return Err(io_error(path, source)),
1088 };
1089 let mut names = Vec::new();
1090 for entry in entries {
1091 let entry = entry.map_err(|source| io_error(path, source))?;
1092 let file_type = entry
1093 .file_type()
1094 .map_err(|source| io_error(&entry.path(), source))?;
1095 if file_type.is_symlink() {
1096 return Err(LifecycleError::UnsafePath(entry.path()));
1097 }
1098 if !file_type.is_dir() {
1099 continue;
1100 }
1101 let name = entry
1102 .file_name()
1103 .into_string()
1104 .map_err(|_| LifecycleError::UnsafePath(entry.path()))?;
1105 checked_id(&name)?;
1106 names.push(name);
1107 }
1108 names.sort();
1109 Ok(names)
1110}
1111
1112const FAILED_EVIDENCE: &str = ".supercov/failed-evidence";
1114
1115pub fn keep_failed_evidence(root: &Path, evidence: &Path, id: &str) -> Option<PathBuf> {
1128 if !evidence.is_dir() {
1129 return None;
1130 }
1131 let kept = root.join(FAILED_EVIDENCE).join(id);
1132 fs::create_dir_all(&kept).ok()?;
1133 let mut files = 0;
1134 if copy_evidence(evidence, &kept, &mut files).is_err() || files == 0 {
1135 let _ = fs::remove_dir_all(&kept);
1138 return None;
1139 }
1140 Some(kept)
1141}
1142
1143pub fn note_kept_evidence(root: &Path, evidence: &Path, id: &str, error: String) -> String {
1149 let Some(kept) = keep_failed_evidence(root, evidence, id) else {
1150 return error;
1151 };
1152 format!(
1153 "{error}\n[supercov] the tests were measured and the run could not be published; \
1154their evidence is kept at {} -- this is a Supercov bug, and that directory is what diagnoses it",
1155 kept.display()
1156 )
1157}
1158
1159fn copy_evidence(source: &Path, destination: &Path, files: &mut usize) -> std::io::Result<()> {
1160 for entry in fs::read_dir(source)? {
1161 let entry = entry?;
1162 let target = destination.join(entry.file_name());
1163 if entry.file_type()?.is_dir() {
1164 fs::create_dir_all(&target)?;
1165 copy_evidence(&entry.path(), &target, files)?;
1166 } else {
1167 fs::copy(entry.path(), &target)?;
1168 *files += 1;
1169 }
1170 }
1171 Ok(())
1172}
1173
1174pub fn recover_abandoned_runs(
1181 root: &Path,
1182 updated_at: &str,
1183) -> Result<Vec<String>, LifecycleError> {
1184 let locks = root.join(".supercov/locks");
1185 fs::create_dir_all(&locks).map_err(|source| io_error(&locks, source))?;
1186 let Some(_recovering) = take_lock(&locks.join("recover.lock"), false, || false)? else {
1187 return Ok(Vec::new());
1188 };
1189 remove_idle_copies(root, &locks)?;
1190 let mut recovered = Vec::new();
1191 for id in child_directories(&root.join(".supercov/work"))? {
1192 let Some(state) = read_state(root, &id)? else {
1193 continue;
1194 };
1195 if state.status.terminal() {
1196 finalize_published_run(root, &id)?;
1197 continue;
1198 }
1199 if process_exists(state.pid) {
1200 continue;
1201 }
1202 let workspace_name = root.file_name().unwrap_or_default();
1203 remove_stored_tree_deferred(
1204 root,
1205 &root.join(".supercov/work").join(&id).join(workspace_name),
1206 )?;
1207 remove_stored_tree_deferred(
1208 root,
1209 &root
1210 .join(".supercov/work")
1211 .join(&id)
1212 .join("run-publication"),
1213 )?;
1214 if !finalize_published_run(root, &id)? {
1215 remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(&id))?;
1216 update_run_state(
1217 root,
1218 &id,
1219 RunStateStatus::Abandoned,
1220 updated_at,
1221 Some(format!(
1222 "Recovered after process {} exited without cleanup",
1223 state.pid
1224 )),
1225 )?;
1226 remove_stored_tree_deferred(root, &root.join(".supercov/work").join(&id))?;
1227 }
1228 recovered.push(id);
1229 }
1230 recovered.sort();
1231 Ok(recovered)
1232}
1233
1234fn remove_idle_copies(root: &Path, locks: &Path) -> Result<(), LifecycleError> {
1237 let container = crate::workspace::workspace_container(root);
1238 for copy in 2..=MOST_COPIES {
1239 let lock_path = locks.join(format!("{}.lock", copy_lock_name(copy)));
1240 let Ok(metadata) = fs::metadata(&lock_path) else {
1241 continue;
1242 };
1243 let idle = metadata
1244 .modified()
1245 .ok()
1246 .and_then(|used| SystemTime::now().duration_since(used).ok())
1247 .is_some_and(|idle| idle >= IDLE_COPY_KEPT);
1248 if !idle {
1249 continue;
1250 }
1251 let Some(_held) = take_lock(&lock_path, false, || live_owner(locks, copy).is_some())?
1252 else {
1253 continue;
1254 };
1255 for directory in crate::workspace::copy_directories(&container, copy) {
1256 if fs::symlink_metadata(&directory).is_ok() {
1257 remove_stored_tree_deferred(root, &directory)?;
1258 }
1259 }
1260 }
1261 Ok(())
1262}
1263
1264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1265pub struct CleanupOptions {
1266 pub keep: usize,
1267 pub dry_run: bool,
1268}
1269
1270#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1271#[serde(rename_all = "camelCase")]
1272pub struct CleanupResult {
1273 pub removed_runs: Vec<String>,
1274 pub removed_workspaces: Vec<String>,
1275 pub removed_evidence: Vec<String>,
1276 pub removed_failed_evidence: Vec<String>,
1281 pub removed_build_cache: bool,
1282}
1283
1284pub fn cleanup_storage_locked(
1285 root: &Path,
1286 options: CleanupOptions,
1287 remove_build_cache: bool,
1288) -> Result<CleanupResult, LifecycleError> {
1289 let runs_root = root.join(".supercov/runs");
1290 let work_root = root.join(".supercov/work");
1291 let evidence_root = root.join(".supercov/evidence");
1292 let published = child_directories(&runs_root)?;
1293 let work = child_directories(&work_root)?;
1294 let evidence = child_directories(&evidence_root)?;
1295 let mut ids = published
1296 .iter()
1297 .chain(&work)
1298 .chain(&evidence)
1299 .cloned()
1300 .collect::<BTreeSet<_>>()
1301 .into_iter()
1302 .collect::<Vec<_>>();
1303 ids.sort_by(|left, right| right.cmp(left));
1304 let mut active = BTreeSet::new();
1305 for id in &ids {
1306 if read_state(root, id)?.is_some_and(|state| !state.status.terminal()) {
1307 active.insert(id.clone());
1308 }
1309 }
1310 let mut retention_order = published
1311 .iter()
1312 .filter(|id| !active.contains(*id))
1313 .map(|id| (published_started_at(root, id), id.clone()))
1314 .collect::<Vec<_>>();
1315 retention_order.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1)));
1316 let retained = retention_order
1317 .into_iter()
1318 .take(options.keep)
1319 .map(|(_, id)| id)
1320 .collect::<BTreeSet<_>>();
1321 let mut result = CleanupResult {
1322 removed_runs: Vec::new(),
1323 removed_workspaces: Vec::new(),
1324 removed_evidence: Vec::new(),
1325 removed_failed_evidence: Vec::new(),
1326 removed_build_cache: false,
1327 };
1328 for id in ids {
1329 if active.contains(&id) {
1330 continue;
1331 }
1332 let has_run = published.contains(&id);
1333 let remove_history = has_run && !retained.contains(&id);
1334 if work.contains(&id) && read_state(root, &id)?.is_none_or(|state| state.status.terminal())
1335 {
1336 result.removed_workspaces.push(id.clone());
1337 if !options.dry_run {
1338 remove_stored_tree_deferred(root, &work_root.join(&id))?;
1339 }
1340 }
1341 if evidence.contains(&id) && (!has_run || remove_history) {
1342 result.removed_evidence.push(id.clone());
1343 if !options.dry_run {
1344 remove_stored_tree_deferred(root, &evidence_root.join(&id))?;
1345 }
1346 }
1347 if remove_history {
1348 result.removed_runs.push(id.clone());
1349 if !options.dry_run {
1350 remove_stored_tree_deferred(root, &runs_root.join(&id))?;
1351 }
1352 }
1353 }
1354 if remove_build_cache && options.keep == 0 && active.is_empty() {
1359 let kept_root = root.join(FAILED_EVIDENCE);
1360 for id in child_directories(&kept_root)? {
1361 result.removed_failed_evidence.push(id.clone());
1362 if !options.dry_run {
1363 remove_stored_tree_deferred(root, &kept_root.join(&id))?;
1364 }
1365 }
1366 }
1367 let container = crate::workspace::workspace_container(root);
1368 let legacy = [root.join(".supercov/.cache"), root.join(".supercov/cache")];
1369 let mut caches = Vec::new();
1370 if remove_build_cache && active.is_empty() {
1371 if owned_workspace_container(root) {
1372 caches.push(container);
1373 }
1374 caches.extend(
1375 legacy
1376 .into_iter()
1377 .filter(|path| fs::symlink_metadata(path).is_ok()),
1378 );
1379 }
1380 result.removed_build_cache = !caches.is_empty();
1381 if !options.dry_run {
1382 for cache in caches {
1383 remove_stored_tree_deferred(root, &cache)?;
1384 }
1385 }
1386 Ok(result)
1387}
1388
1389fn cleanup_storage(
1390 root: &Path,
1391 options: CleanupOptions,
1392 remove_build_cache: bool,
1393 updated_at: &str,
1394) -> Result<CleanupResult, LifecycleError> {
1395 let operation = if remove_build_cache {
1396 "clean"
1397 } else {
1398 "retention"
1399 };
1400 let lock_id = format!("{operation}-{}-{}", std::process::id(), unique_name());
1401 let mut lock = ProjectLock::acquire_alone(root, &lock_id, updated_at)?;
1402 recover_abandoned_runs(root, updated_at)?;
1403 let result = cleanup_storage_locked(root, options, remove_build_cache);
1404 lock.release()?;
1405 result
1406}
1407
1408pub fn clean_storage(
1409 root: &Path,
1410 options: CleanupOptions,
1411 updated_at: &str,
1412) -> Result<CleanupResult, LifecycleError> {
1413 cleanup_storage(root, options, true, updated_at)
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418 use super::*;
1419 use crate::run_store::{RawEvidenceMetadata, RunFingerprint, RunIntegrity};
1420
1421 fn project() -> PathBuf {
1422 let root = std::env::temp_dir().join(format!("supercov-lifecycle-{}", unique_name()));
1423 fs::create_dir_all(root.join("src")).unwrap();
1424 fs::write(root.join("src/index.js"), "user source").unwrap();
1425 root
1426 }
1427
1428 #[cfg(unix)]
1429 #[test]
1430 fn removing_a_stored_tree_never_follows_a_link_out_of_it() {
1431 let root = project();
1436 let outside = std::env::temp_dir().join(format!("supercov-outside-{}", unique_name()));
1437 fs::create_dir_all(outside.join("tsx/dist")).unwrap();
1438 fs::write(outside.join("tsx/dist/loader.mjs"), "the dependency").unwrap();
1439 let tree = root.join(".supercov/work/run_links");
1440 fs::create_dir_all(tree.join("mirror")).unwrap();
1441 std::os::unix::fs::symlink(&outside, tree.join("node_modules")).unwrap();
1442 std::os::unix::fs::symlink(outside.join("tsx"), tree.join("mirror/tsx")).unwrap();
1444 std::os::unix::fs::symlink(&outside, root.join(".supercov/work/run_linked_root")).unwrap();
1446 for target in [tree.clone(), root.join(".supercov/work/run_linked_root")] {
1447 remove_stored_tree_deferred(&root, &target)
1448 .unwrap()
1449 .unwrap();
1450 }
1451 assert!(sweep_trash(&root).unwrap() >= 2);
1452 assert!(!tree.exists());
1453 assert_eq!(
1454 fs::read_to_string(outside.join("tsx/dist/loader.mjs")).unwrap(),
1455 "the dependency"
1456 );
1457 fs::remove_dir_all(&outside).unwrap();
1458 fs::remove_dir_all(&root).unwrap();
1459 }
1460
1461 fn kept_evidence(root: &Path, id: &str) -> PathBuf {
1462 let evidence = root.join(".supercov/work").join(id).join("python/evidence");
1463 fs::create_dir_all(&evidence).unwrap();
1464 fs::write(evidence.join("main.jsonl"), "what the run saw").unwrap();
1465 keep_failed_evidence(root, &evidence, id).expect("the evidence")
1466 }
1467
1468 fn state(root: &Path, id: &str, status: RunStateStatus, pid: u32) -> RunState {
1469 RunState {
1470 id: id.into(),
1471 pid,
1472 root: root.display().to_string(),
1473 workspace: root.join("dist").display().to_string(),
1474 started_at: "start".into(),
1475 updated_at: "update".into(),
1476 status,
1477 signal: None,
1478 error: None,
1479 }
1480 }
1481
1482 fn metadata(id: &str, bytes: u64) -> RunMetadata {
1483 RunMetadata {
1484 id: id.into(),
1485 started_at: "2026-01-01T00:00:00Z".into(),
1486 duration_ms: 1.0,
1487 command: vec!["test".into()],
1488 test_exit_code: Some(0),
1489 integrity: RunIntegrity {
1490 schema_version: 2,
1491 instrumenter_version: "rust".into(),
1492 git: None,
1493 fingerprint: RunFingerprint {
1494 algorithm: "sha256".into(),
1495 source: "0".repeat(64),
1496 tests: "0".repeat(64),
1497 dependencies: "0".repeat(64),
1498 configuration: "0".repeat(64),
1499 instrumenter: "0".repeat(64),
1500 execution: "0".repeat(64),
1501 combined: "0".repeat(64),
1502 source_files: 1,
1503 test_files: 1,
1504 },
1505 stale: None,
1506 stale_reasons: None,
1507 },
1508 raw_evidence: RawEvidenceMetadata {
1509 schema_version: 2,
1510 format: "supercov-evidence-archive".into(),
1511 file: "evidence.raw.gz".into(),
1512 files: 1,
1513 uncompressed_bytes: bytes,
1514 compressed_bytes: bytes,
1515 },
1516 isolated_build: None,
1517 instrumented_build_cache: None,
1518 timings: None,
1519 merged: None,
1520 parents: None,
1521 source_roots: None,
1522 copy: None,
1523 merged_across: None,
1524 recorded_by: None,
1525 }
1526 }
1527
1528 fn evidence(root: &Path) -> (PathBuf, u64) {
1529 use crate::evidence_archive::{EvidenceArchiveEntry, write_archive};
1530 let mut entries = ["manifest.json", "frontend.json", "coverage-model.json"]
1531 .into_iter()
1532 .map(|path| EvidenceArchiveEntry {
1533 path: path.into(),
1534 contents: b"{}".to_vec(),
1535 })
1536 .collect::<Vec<_>>();
1537 let inputs =
1538 crate::source_capture::capture(root, "javascript", [PathBuf::from("src/index.js")])
1539 .unwrap();
1540 entries = crate::source_capture::append(entries, &inputs).unwrap();
1541 let path = root.join("evidence.gz");
1542 let metadata = write_archive(entries, &path).unwrap();
1543 (path, metadata.compressed_bytes)
1544 }
1545
1546 #[test]
1547 fn defers_only_owned_storage_and_sweeps_without_touching_source() {
1548 let root = project();
1549 let owned = root.join(".supercov/evidence/run");
1550 fs::create_dir_all(&owned).unwrap();
1551 fs::write(owned.join("hit"), "hit").unwrap();
1552 let trash = remove_stored_tree_deferred(&root, &owned).unwrap().unwrap();
1553 assert!(!owned.exists());
1554 assert!(trash.exists());
1555 assert!(matches!(
1556 remove_stored_tree_deferred(&root, &root.join("src")),
1557 Err(LifecycleError::UnsafePath(_))
1558 ));
1559 assert_eq!(sweep_trash(&root).unwrap(), 1);
1560 assert!(root.join("src/index.js").exists());
1561 fs::remove_dir_all(root).unwrap();
1562 }
1563
1564 #[cfg(unix)]
1565 #[test]
1566 fn refuses_linked_storage_ancestors_instead_of_renaming_external_data() {
1567 use std::os::unix::fs::symlink;
1568
1569 let root = project();
1570 let outside = project();
1571 fs::create_dir_all(root.join(".supercov")).unwrap();
1572 fs::create_dir_all(outside.join("run")).unwrap();
1573 fs::write(outside.join("run/user.txt"), "user").unwrap();
1574 symlink(&outside, root.join(".supercov/evidence")).unwrap();
1575 assert!(matches!(
1576 remove_stored_tree_deferred(&root, &root.join(".supercov/evidence/run")),
1577 Err(LifecycleError::UnsafePath(_))
1578 ));
1579 assert_eq!(
1580 fs::read_to_string(outside.join("run/user.txt")).unwrap(),
1581 "user"
1582 );
1583 fs::remove_dir_all(root).unwrap();
1584 fs::remove_dir_all(outside).unwrap();
1585 }
1586
1587 fn analysable_evidence(root: &Path, id: &str) -> (PathBuf, RunMetadata) {
1590 let source = project();
1591 let directory = crate::run_store::create_analyzable_test_run(&source, "source");
1592 let evidence = root.join(format!("{id}.evidence.gz"));
1593 fs::copy(directory.join("evidence.raw.gz"), &evidence).unwrap();
1594 let mut metadata: RunMetadata =
1595 serde_json::from_slice(&fs::read(directory.join("run.json")).unwrap()).unwrap();
1596 metadata.id = id.into();
1597 fs::remove_dir_all(source).unwrap();
1598 (evidence, metadata)
1599 }
1600
1601 fn stored(root: &Path, id: &str) -> crate::run_store::StoredRun {
1603 let directory = root.join(".supercov/runs").join(id);
1604 crate::run_store::StoredRun {
1605 id: id.into(),
1606 evidence_path: directory.join("evidence.raw.gz"),
1607 metadata_path: directory.join("run.json"),
1608 query_index_path: directory.join(crate::run_store::RUST_QUERY_INDEX_FILE),
1609 metadata: serde_json::from_slice(&fs::read(directory.join("run.json")).unwrap())
1610 .unwrap(),
1611 directory,
1612 }
1613 }
1614
1615 #[test]
1616 fn publication_writes_the_query_index_a_first_query_would_have_built() {
1617 let root = project();
1621 let id = "2026-01-01T00-00-00-100Z";
1622 let (evidence, metadata) = analysable_evidence(&root, id);
1623 let published = publish_run(&root, &metadata, &evidence).unwrap();
1624 let index = published.join(crate::run_store::RUST_QUERY_INDEX_FILE);
1625 assert!(index.is_file(), "publication wrote the index");
1626 let run = stored(&root, id);
1627 assert!(
1628 crate::run_store::open_existing_query_index(&run)
1629 .unwrap()
1630 .is_some(),
1631 "and it is valid for the published run"
1632 );
1633 let analysed = crate::source_manifest::coverage(&run).unwrap();
1636 assert_eq!(
1637 crate::run_store::read_statement_spans(&run).expect("publication wrote the spans"),
1638 crate::run_store::statement_spans(&analysed)
1639 );
1640 assert_eq!(
1642 crate::run_store::read_assessment_input(&run).expect("publication wrote the input"),
1643 crate::assertion_coverage::assessment_input(&analysed)
1644 );
1645 let written = fs::read(&index).unwrap();
1646 fs::remove_file(&index).unwrap();
1647 crate::run_store::open_or_rebuild_query_index(&run).unwrap();
1648 assert_eq!(
1649 fs::read(&index).unwrap(),
1650 written,
1651 "a query rebuilds the same bytes"
1652 );
1653 fs::remove_dir_all(root).unwrap();
1654 }
1655
1656 #[test]
1657 fn evidence_that_will_not_analyse_is_published_as_it_was_before() {
1658 let root = project();
1662 let id = "2026-01-01T00-00-00-200Z";
1663 let (evidence, bytes) = evidence(&root);
1664 let published = publish_run(&root, &metadata(id, bytes), &evidence).unwrap();
1665 assert!(published.join("run.json").is_file());
1666 assert!(published.join("source-manifest.cache.json").is_file());
1667 assert!(
1668 !published
1669 .join(crate::run_store::RUST_QUERY_INDEX_FILE)
1670 .exists()
1671 );
1672 assert!(
1673 !published
1674 .join(crate::source_manifest::EXECUTIONS_FILE)
1675 .exists()
1676 );
1677 let run = stored(&root, id);
1678 let Err(query) = crate::run_store::open_or_rebuild_query_index(&run) else {
1679 panic!("the first query cannot analyse it either");
1680 };
1681 let direct = crate::run_store::analyze_stored_run(&run).expect_err("unanalysable");
1682 assert_eq!(
1683 query.to_string(),
1684 direct.to_string(),
1685 "and says why in the same words"
1686 );
1687 fs::remove_dir_all(root).unwrap();
1688 }
1689
1690 #[test]
1691 fn a_merged_run_is_indexed_by_its_first_query() {
1692 let root = project();
1694 let id = "2026-01-01T00-00-00-300Z";
1695 let (evidence, mut metadata) = analysable_evidence(&root, id);
1696 metadata.merged = Some(true);
1697 let published = publish_run(&root, &metadata, &evidence).unwrap();
1698 let index = published.join(crate::run_store::RUST_QUERY_INDEX_FILE);
1699 assert!(!index.exists());
1700 crate::run_store::open_or_rebuild_query_index(&stored(&root, id)).unwrap();
1701 assert!(index.is_file());
1702 fs::remove_dir_all(root).unwrap();
1703 }
1704
1705 #[test]
1706 fn an_index_that_could_not_be_written_is_built_by_the_first_query() {
1707 let root = project();
1710 let id = "2026-01-01T00-00-00-400Z";
1711 let (evidence, metadata) = analysable_evidence(&root, id);
1712 let published = publish_run_with_fault(
1713 &root,
1714 &metadata,
1715 &evidence,
1716 Archived::default(),
1717 Some(RunPublicationFault::QueryIndexWrite),
1718 )
1719 .unwrap();
1720 let index = published.join(crate::run_store::RUST_QUERY_INDEX_FILE);
1721 assert!(!index.exists(), "no partial index is left");
1722 assert!(published.join("run.json").is_file());
1723 crate::run_store::open_or_rebuild_query_index(&stored(&root, id)).unwrap();
1724 assert!(index.is_file());
1725 fs::remove_dir_all(root).unwrap();
1726 }
1727
1728 #[test]
1729 fn publishes_both_required_files_with_one_visible_rename() {
1730 let root = project();
1731 let id = "2026-01-01T00-00-00-000Z";
1732 let (evidence, bytes) = evidence(&root);
1733 let published = publish_run(&root, &metadata(id, bytes), &evidence).unwrap();
1734 assert!(published.join("run.json").is_file());
1735 assert_eq!(
1736 fs::read(published.join("evidence.raw.gz")).unwrap(),
1737 fs::read(&evidence).unwrap()
1738 );
1739 assert!(published.join("source-manifest.cache.json").is_file());
1740 assert!(matches!(
1741 publish_run(&root, &metadata(id, bytes), &evidence),
1742 Err(LifecycleError::PublicationExists(_))
1743 ));
1744 fs::remove_dir_all(root).unwrap();
1745 }
1746
1747 #[test]
1748 fn final_rename_failure_exposes_no_run_and_removes_staging() {
1749 let root = project();
1750 let id = "2026-01-01T00-00-00-000Z";
1751 let (evidence, bytes) = evidence(&root);
1752 let error = publish_run_with_fault(
1753 &root,
1754 &metadata(id, bytes),
1755 &evidence,
1756 Archived::default(),
1757 Some(RunPublicationFault::FinalRename),
1758 )
1759 .unwrap_err();
1760 assert!(
1761 error
1762 .to_string()
1763 .contains("injected final run publication rename failure")
1764 );
1765 assert!(!root.join(".supercov/runs").join(id).exists());
1766 assert!(
1767 !root
1768 .join(".supercov/work")
1769 .join(id)
1770 .join("run-publication")
1771 .exists()
1772 );
1773 sweep_trash(&root).unwrap();
1774 fs::remove_dir_all(root).unwrap();
1775 }
1776
1777 #[test]
1778 fn malformed_assertion_inputs_expose_no_partial_run() {
1779 use crate::evidence_archive::{read_archive, write_archive};
1780 let root = project();
1781 let id = "2026-01-01T00-00-00-000Z";
1782 let (evidence, _) = evidence(&root);
1783 let mut entries = read_archive(&evidence).unwrap();
1784 entries
1785 .iter_mut()
1786 .find(|e| e.path == crate::source_capture::ARCHIVE_PATH)
1787 .unwrap()
1788 .contents = b"{broken".to_vec();
1789 let raw = write_archive(entries, &evidence).unwrap();
1790 let error = publish_run(&root, &metadata(id, raw.compressed_bytes), &evidence).unwrap_err();
1791 assert!(error.to_string().contains("source manifest publication"));
1792 assert!(!root.join(".supercov/runs").join(id).exists());
1793 assert!(
1794 !root
1795 .join(".supercov/work")
1796 .join(id)
1797 .join("run-publication")
1798 .exists()
1799 );
1800 fs::remove_dir_all(root).unwrap();
1801 }
1802
1803 #[test]
1804 fn recovers_dead_unpublished_and_fully_published_runs_from_derived_paths() {
1805 let root = project();
1806 let dead = "2026-01-01T00-00-00-000Z";
1807 let published = "2026-01-02T00-00-00-000Z";
1808 for id in [dead, published] {
1809 fs::create_dir_all(
1810 root.join(".supercov/work")
1811 .join(id)
1812 .join(root.file_name().unwrap()),
1813 )
1814 .unwrap();
1815 fs::create_dir_all(root.join(".supercov/evidence").join(id)).unwrap();
1816 write_run_state(&root, &state(&root, id, RunStateStatus::Testing, u32::MAX)).unwrap();
1817 }
1818 let (evidence, bytes) = evidence(&root);
1819 publish_run(&root, &metadata(published, bytes), &evidence).unwrap();
1820 assert_eq!(
1821 recover_abandoned_runs(&root, "recovered").unwrap(),
1822 [dead, published]
1823 );
1824 assert!(!root.join(".supercov/work").join(dead).exists());
1825 assert!(!root.join(".supercov/work").join(published).exists());
1826 assert!(root.join(".supercov/runs").join(published).exists());
1827 assert!(root.join("src/index.js").exists());
1828 sweep_trash(&root).unwrap();
1829 fs::remove_dir_all(root).unwrap();
1830 }
1831
1832 #[test]
1833 fn retention_is_deterministic_dry_run_safe_and_preserves_active_work() {
1834 let root = project();
1835 let ids = [
1836 "2026-01-01T00-00-00-000Z",
1837 "2026-01-02T00-00-00-000Z",
1838 "2026-01-03T00-00-00-000Z",
1839 ];
1840 for id in ids {
1841 fs::create_dir_all(root.join(".supercov/runs").join(id)).unwrap();
1842 write_run_state(
1843 &root,
1844 &state(&root, id, RunStateStatus::Complete, std::process::id()),
1845 )
1846 .unwrap();
1847 }
1848 let active = "2025-12-31T00-00-00-000Z";
1849 write_run_state(
1850 &root,
1851 &state(&root, active, RunStateStatus::Testing, std::process::id()),
1852 )
1853 .unwrap();
1854 let preview = cleanup_storage_locked(
1855 &root,
1856 CleanupOptions {
1857 keep: 1,
1858 dry_run: true,
1859 },
1860 false,
1861 )
1862 .unwrap();
1863 assert_eq!(preview.removed_runs, [ids[1], ids[0]]);
1864 assert!(
1865 ids.iter()
1866 .all(|id| root.join(".supercov/runs").join(id).exists())
1867 );
1868 let result = cleanup_storage_locked(
1869 &root,
1870 CleanupOptions {
1871 keep: 1,
1872 dry_run: false,
1873 },
1874 false,
1875 )
1876 .unwrap();
1877 assert_eq!(result, preview);
1878 assert!(root.join(".supercov/work").join(active).exists());
1879 assert!(root.join(".supercov/runs").join(ids[2]).exists());
1880 sweep_trash(&root).unwrap();
1881 fs::remove_dir_all(root).unwrap();
1882 }
1883
1884 #[test]
1885 fn retention_keeps_the_newest_runs_when_run_ids_disagree_with_recency() {
1886 let root = project();
1887 let (evidence, bytes) = evidence(&root);
1888 for (id, started_at) in [
1891 ("run_ffffffffffffffff", "2026-01-01T00:00:00Z"),
1892 ("run_7777777777777777", "2026-05-01T00:00:00Z"),
1893 ("run_0000000000000001", "2026-09-01T00:00:00Z"),
1894 ] {
1895 let mut run = metadata(id, bytes);
1896 run.started_at = started_at.into();
1897 publish_run(&root, &run, &evidence).unwrap();
1898 }
1899 let result = cleanup_storage_locked(
1900 &root,
1901 CleanupOptions {
1902 keep: 1,
1903 dry_run: false,
1904 },
1905 false,
1906 )
1907 .unwrap();
1908 assert_eq!(
1909 result.removed_runs,
1910 ["run_ffffffffffffffff", "run_7777777777777777"]
1911 );
1912 assert!(
1913 root.join(".supercov/runs")
1914 .join("run_0000000000000001")
1915 .exists()
1916 );
1917 sweep_trash(&root).unwrap();
1918 fs::remove_dir_all(root).unwrap();
1919 }
1920
1921 #[test]
1922 fn direct_runs_initialize_store_ignore_without_overwriting_existing_rules() {
1923 let root = project();
1924 let ignore = root.join(".supercov/.gitignore");
1925 let mut lock = ProjectLock::acquire(&root, "first", "start").unwrap();
1926 assert_eq!(fs::read_to_string(&ignore).unwrap(), "*\n");
1927 lock.release().unwrap();
1928 fs::write(&ignore, "custom\n").unwrap();
1929 let mut lock = ProjectLock::acquire(&root, "second", "start").unwrap();
1930 assert_eq!(fs::read_to_string(&ignore).unwrap(), "custom\n");
1931 lock.release().unwrap();
1932 fs::remove_dir_all(root).unwrap();
1933 }
1934
1935 fn owner_file(root: &Path, copy: usize) -> PathBuf {
1936 root.join(".supercov/locks")
1937 .join(format!("{}.json", copy_lock_name(copy)))
1938 }
1939
1940 #[test]
1941 fn runs_at_once_each_hold_a_copy_and_the_lowest_free_is_taken_next() {
1942 let root = project();
1943 let mut first = ProjectLock::acquire(&root, "first", "start").unwrap();
1944 let mut second = ProjectLock::acquire(&root, "second", "start").unwrap();
1945 let third = ProjectLock::acquire(&root, "third", "start").unwrap();
1946 assert_eq!((first.copy(), second.copy(), third.copy()), (1, 2, 3));
1947 assert_eq!(read_owner(&owner_file(&root, 2)).unwrap().run_id, "second");
1948
1949 second.release().unwrap();
1951 assert!(!owner_file(&root, 2).exists());
1952 let again = ProjectLock::acquire(&root, "fourth", "start").unwrap();
1953 assert_eq!(again.copy(), 2);
1954
1955 drop(third);
1957 first.release().unwrap();
1958 assert_eq!(
1959 ProjectLock::acquire(&root, "fifth", "start")
1960 .unwrap()
1961 .copy(),
1962 1
1963 );
1964 fs::remove_dir_all(root).unwrap();
1965 }
1966
1967 #[test]
1968 fn many_runs_starting_in_the_same_instant_never_share_a_copy() {
1969 let root = project();
1970 let start = std::sync::Arc::new(std::sync::Barrier::new(24));
1971 let holds = (0..24)
1972 .map(|each| {
1973 let root = root.clone();
1974 let start = std::sync::Arc::clone(&start);
1975 std::thread::spawn(move || {
1976 start.wait();
1977 let lock =
1978 ProjectLock::acquire(&root, &format!("run-{each}"), "start").unwrap();
1979 recover_abandoned_runs(&root, "start").unwrap();
1981 lock
1982 })
1983 })
1984 .collect::<Vec<_>>()
1985 .into_iter()
1986 .map(|thread| thread.join().unwrap())
1987 .collect::<Vec<_>>();
1988 let mut copies = holds.iter().map(ProjectLock::copy).collect::<Vec<_>>();
1989 copies.sort_unstable();
1990 assert_eq!(copies, (1..=24).collect::<Vec<_>>());
1991 drop(holds);
1992 fs::remove_dir_all(root).unwrap();
1993 }
1994
1995 #[test]
1996 fn no_run_starts_while_the_store_is_cleaned_and_none_is_cleaned_under() {
1997 let root = project();
1998 let mut cleaning = ProjectLock::acquire_alone(&root, "clean", "start").unwrap();
1999 assert!(matches!(
2000 ProjectLock::acquire(&root, "run", "start"),
2001 Err(LifecycleError::Cleaning)
2002 ));
2003 cleaning.release().unwrap();
2004 let _first = ProjectLock::acquire(&root, "first", "start").unwrap();
2005 let _second = ProjectLock::acquire(&root, "second", "start").unwrap();
2006 match ProjectLock::acquire_alone(&root, "clean", "start") {
2007 Err(LifecycleError::ActiveRun { run_id, pid }) => {
2008 assert_eq!((run_id.as_str(), pid), ("first", std::process::id()));
2009 }
2010 other => panic!("cleaning under two runs: {:?}", other.map(|_| ())),
2011 }
2012 fs::remove_dir_all(root).unwrap();
2013 }
2014
2015 #[cfg(unix)]
2016 #[test]
2017 fn a_run_of_a_release_that_locked_nothing_keeps_its_copy() {
2018 let root = project();
2022 let locks = root.join(".supercov/locks");
2023 fs::create_dir_all(&locks).unwrap();
2024 let theirs = std::os::unix::process::parent_id();
2025 fs::write(
2026 owner_file(&root, 1),
2027 format!(r#"{{"runId":"run_theirs","pid":{theirs},"startedAt":"then"}}"#),
2028 )
2029 .unwrap();
2030 let mut beside = ProjectLock::acquire(&root, "beside", "start").unwrap();
2031 assert_eq!(beside.copy(), 2);
2032 assert!(matches!(
2033 ProjectLock::acquire_alone(&root, "clean", "start"),
2034 Err(LifecycleError::ActiveRun { ref run_id, pid }) if run_id == "run_theirs" && pid == theirs
2035 ));
2036 beside.release().unwrap();
2037
2038 let mut gone = std::process::Command::new("true").spawn().unwrap();
2040 let dead = gone.id();
2041 gone.wait().unwrap();
2042 fs::write(
2043 owner_file(&root, 1),
2044 format!(r#"{{"runId":"run_gone","pid":{dead},"startedAt":"then"}}"#),
2045 )
2046 .unwrap();
2047 assert_eq!(
2048 ProjectLock::acquire(&root, "after", "start")
2049 .unwrap()
2050 .copy(),
2051 1
2052 );
2053 fs::remove_dir_all(root).unwrap();
2054 }
2055
2056 #[test]
2057 fn a_copy_past_the_first_goes_a_day_after_its_last_run() {
2058 let root = project();
2059 let container = root.join(".supercov/workspaces");
2060 for copy in [
2061 "workspace",
2062 "workspace-2",
2063 "workspace-3",
2064 "workspace-4",
2065 "source-2",
2066 ] {
2067 fs::create_dir_all(container.join(copy).join("project")).unwrap();
2068 }
2069 fs::write(
2070 container.join(".supercov-workspace-store"),
2071 b"Supercov instrumented workspace. Safe to delete.\n",
2072 )
2073 .unwrap();
2074 let locks = root.join(".supercov/locks");
2075 fs::create_dir_all(&locks).unwrap();
2076 let long_ago = SystemTime::now() - Duration::from_secs(3 * 24 * 60 * 60);
2077 for (copy, used) in [
2080 (2, long_ago),
2081 (3, SystemTime::now() - Duration::from_secs(3600)),
2082 (4, long_ago),
2083 ] {
2084 let lock = File::create(locks.join(format!("{}.lock", copy_lock_name(copy)))).unwrap();
2085 lock.set_modified(used).unwrap();
2086 }
2087 let held = take_lock(&locks.join("active-4.lock"), false, || false)
2088 .unwrap()
2089 .unwrap();
2090 held.0.set_modified(long_ago).unwrap();
2091
2092 recover_abandoned_runs(&root, "now").unwrap();
2093 let left = |copy: &str| container.join(copy).exists();
2094 assert!(left("workspace"), "the first is never idle");
2095 assert!(!left("workspace-2") && !left("source-2"));
2096 assert!(left("workspace-3"));
2097 assert!(left("workspace-4"), "a copy in use is not removed");
2098 drop(held);
2099 sweep_trash(&root).unwrap();
2100 fs::remove_dir_all(root).unwrap();
2101 }
2102
2103 #[test]
2104 fn cleanup_is_project_locked_and_clean_alone_removes_owned_caches() {
2105 let root = project();
2106 let container = root.join(".supercov/workspaces");
2107 fs::create_dir_all(container.join("workspace/project")).unwrap();
2108 fs::write(
2109 container.join(".supercov-workspace-store"),
2110 b"Supercov instrumented workspace. Safe to delete.\n",
2111 )
2112 .unwrap();
2113 fs::create_dir_all(root.join(".supercov/cache/legacy")).unwrap();
2114 let mut active = ProjectLock::acquire(&root, "active", "start").unwrap();
2115 assert!(matches!(
2116 clean_storage(
2117 &root,
2118 CleanupOptions {
2119 keep: 0,
2120 dry_run: false
2121 },
2122 "now"
2123 ),
2124 Err(LifecycleError::ActiveRun { .. })
2125 ));
2126 assert!(container.exists());
2127 active.release().unwrap();
2128
2129 let cleaned = clean_storage(
2130 &root,
2131 CleanupOptions {
2132 keep: 0,
2133 dry_run: false,
2134 },
2135 "now",
2136 )
2137 .unwrap();
2138 assert!(cleaned.removed_build_cache);
2139 assert!(!container.exists());
2140 assert!(!root.join(".supercov/cache").exists());
2141 sweep_trash(&root).unwrap();
2142 fs::remove_dir_all(root).unwrap();
2143 }
2144
2145 #[test]
2150 fn evidence_a_failed_run_measured_outlives_the_work_tree() {
2151 let root = project();
2152 let work = root.join(".supercov/work/run_kept");
2153 let evidence = work.join("python/evidence");
2154 fs::create_dir_all(evidence.join("worker-1")).unwrap();
2155 fs::write(evidence.join("main.jsonl"), "what the run saw").unwrap();
2156 fs::write(evidence.join("worker-1/mcdc.json"), "{}").unwrap();
2157
2158 let kept = keep_failed_evidence(&root, &evidence, "run_kept").expect("the evidence");
2159 remove_stored_tree_deferred(&root, &work).unwrap();
2160 sweep_trash(&root).unwrap();
2161
2162 assert!(!evidence.exists(), "the work tree still went");
2163 assert_eq!(
2164 fs::read_to_string(kept.join("main.jsonl")).unwrap(),
2165 "what the run saw",
2166 );
2167 assert_eq!(
2170 fs::read_to_string(kept.join("worker-1/mcdc.json")).unwrap(),
2171 "{}"
2172 );
2173 fs::remove_dir_all(root).unwrap();
2174 }
2175
2176 #[test]
2177 fn nothing_to_keep_leaves_no_directory_to_puzzle_over() {
2178 let root = project();
2179 let missing = root.join(".supercov/work/run_early/python/evidence");
2182 assert!(keep_failed_evidence(&root, &missing, "run_early").is_none());
2183 fs::create_dir_all(&missing).unwrap();
2184 assert!(keep_failed_evidence(&root, &missing, "run_early").is_none());
2185 assert!(
2186 !root.join(".supercov/failed-evidence/run_early").exists(),
2187 "an empty directory is worse than none: it reads as evidence",
2188 );
2189 fs::remove_dir_all(root).unwrap();
2190 }
2191
2192 #[test]
2195 fn the_failure_says_where_the_evidence_was_kept() {
2196 let root = project();
2197 let evidence = root.join(".supercov/work/run_told/python/evidence");
2198 fs::create_dir_all(&evidence).unwrap();
2199 fs::write(evidence.join("main.jsonl"), "what the run saw").unwrap();
2200
2201 let told = note_kept_evidence(&root, &evidence, "run_told", "boom".into());
2202 let kept = keep_failed_evidence(&root, &evidence, "run_told").expect("the evidence");
2208 assert_eq!(kept, root.join(FAILED_EVIDENCE).join("run_told"));
2209 assert!(
2210 told.starts_with("boom\n"),
2211 "the failure itself came second: {told}"
2212 );
2213 assert!(told.contains(&kept.display().to_string()), "{told}");
2214
2215 let nothing = root.join(".supercov/work/run_empty/python/evidence");
2218 assert_eq!(
2219 note_kept_evidence(&root, ¬hing, "run_empty", "boom".into()),
2220 "boom",
2221 );
2222 fs::remove_dir_all(root).unwrap();
2223 }
2224
2225 #[test]
2226 fn a_trim_leaves_kept_evidence_alone() {
2227 let root = project();
2228 let kept = kept_evidence(&root, "run_trimmed");
2229
2230 clean_storage(
2234 &root,
2235 CleanupOptions {
2236 keep: 20,
2237 dry_run: false,
2238 },
2239 "now",
2240 )
2241 .unwrap();
2242 let retention = cleanup_storage_locked(
2243 &root,
2244 CleanupOptions {
2245 keep: 0,
2246 dry_run: false,
2247 },
2248 false,
2249 )
2250 .unwrap();
2251 sweep_trash(&root).unwrap();
2252
2253 assert!(retention.removed_failed_evidence.is_empty());
2254 assert!(kept.join("main.jsonl").exists());
2255 fs::remove_dir_all(root).unwrap();
2256 }
2257
2258 #[test]
2259 fn cleaning_everything_reclaims_kept_evidence_and_says_which() {
2260 let root = project();
2261 let kept = kept_evidence(&root, "run_cleaned");
2262
2263 let preview = clean_storage(
2264 &root,
2265 CleanupOptions {
2266 keep: 0,
2267 dry_run: true,
2268 },
2269 "now",
2270 )
2271 .unwrap();
2272 assert_eq!(
2273 preview.removed_failed_evidence,
2274 vec!["run_cleaned".to_string()]
2275 );
2276 assert!(kept.join("main.jsonl").exists(), "a preview removed it");
2277
2278 let result = clean_storage(
2279 &root,
2280 CleanupOptions {
2281 keep: 0,
2282 dry_run: false,
2283 },
2284 "now",
2285 )
2286 .unwrap();
2287 sweep_trash(&root).unwrap();
2288
2289 assert_eq!(
2292 result.removed_failed_evidence,
2293 vec!["run_cleaned".to_string()]
2294 );
2295 assert!(!kept.exists());
2296 fs::remove_dir_all(root).unwrap();
2297 }
2298}