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