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