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