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