Skip to main content

supercov_engine/
lifecycle.rs

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