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    LockAcquiring,
33    LockUnavailable,
34    PublicationExists(String),
35    Metadata(serde_json::Error),
36    EvidenceLength { expected: u64, actual: u64 },
37    EvidenceChanged,
38}
39
40impl std::fmt::Display for LifecycleError {
41    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
44            Self::InvalidRunId(id) => write!(formatter, "invalid coverage run ID: {id}"),
45            Self::UnsafePath(path) => {
46                write!(
47                    formatter,
48                    "unsafe Supercov storage path: {}",
49                    path.display()
50                )
51            }
52            Self::InvalidState(reason) => write!(formatter, "invalid run state: {reason}"),
53            Self::ActiveRun { run_id, pid } => write!(
54                formatter,
55                "coverage run {run_id} is already active in this project (pid {pid})"
56            ),
57            Self::LockAcquiring => {
58                write!(
59                    formatter,
60                    "a coverage run is currently acquiring the project lock"
61                )
62            }
63            Self::LockUnavailable => {
64                write!(formatter, "could not acquire the Supercov project lock")
65            }
66            Self::PublicationExists(id) => write!(formatter, "coverage run already exists: {id}"),
67            Self::Metadata(error) => write!(formatter, "invalid run metadata: {error}"),
68            Self::EvidenceLength { expected, actual } => write!(
69                formatter,
70                "evidence length changed before publication: expected {expected}, got {actual}"
71            ),
72            Self::EvidenceChanged => write!(formatter, "evidence changed during publication"),
73        }
74    }
75}
76
77impl std::error::Error for LifecycleError {}
78
79fn io_error(path: &Path, source: io::Error) -> LifecycleError {
80    LifecycleError::Io {
81        path: path.to_owned(),
82        source,
83    }
84}
85
86fn checked_id(id: &str) -> Result<(), LifecycleError> {
87    valid_run_id(id)
88        .then_some(())
89        .ok_or_else(|| LifecycleError::InvalidRunId(id.into()))
90}
91
92fn absolute_root(root: &Path) -> Result<PathBuf, LifecycleError> {
93    if root.is_absolute() {
94        Ok(root.to_owned())
95    } else {
96        std::env::current_dir()
97            .map(|cwd| cwd.join(root))
98            .map_err(|source| io_error(root, source))
99    }
100}
101
102fn lexical_descendant(root: &Path, path: &Path) -> bool {
103    let Ok(local) = path.strip_prefix(root) else {
104        return false;
105    };
106    !local.as_os_str().is_empty()
107        && local
108            .components()
109            .all(|component| matches!(component, Component::Normal(_)))
110}
111
112fn reject_linked_ancestors(
113    root: &Path,
114    path: &Path,
115    include_leaf: bool,
116) -> Result<(), LifecycleError> {
117    let local = path
118        .strip_prefix(root)
119        .map_err(|_| LifecycleError::UnsafePath(path.into()))?;
120    let components = local.components().collect::<Vec<_>>();
121    let through = if include_leaf {
122        components.len()
123    } else {
124        components.len().saturating_sub(1)
125    };
126    let mut current = root.to_owned();
127    for component in components.into_iter().take(through) {
128        let Component::Normal(component) = component else {
129            return Err(LifecycleError::UnsafePath(path.into()));
130        };
131        current.push(component);
132        match fs::symlink_metadata(&current) {
133            Ok(metadata) if metadata.file_type().is_symlink() => {
134                return Err(LifecycleError::UnsafePath(current));
135            }
136            Ok(metadata) if !metadata.file_type().is_dir() => {
137                return Err(LifecycleError::UnsafePath(current));
138            }
139            Ok(_) => {}
140            Err(error) if error.kind() == io::ErrorKind::NotFound => break,
141            Err(source) => return Err(io_error(&current, source)),
142        }
143    }
144    Ok(())
145}
146
147fn owned_workspace_container(root: &Path) -> bool {
148    let container = crate::workspace::workspace_container(root);
149    crate::workspace::owned_workspace_path(&container)
150}
151
152fn unique_name() -> String {
153    let nanos = SystemTime::now()
154        .duration_since(UNIX_EPOCH)
155        .unwrap_or_default()
156        .as_nanos();
157    format!(
158        "{}-{nanos}-{}",
159        std::process::id(),
160        UNIQUE.fetch_add(1, Ordering::Relaxed)
161    )
162}
163
164/// Make a rename or creation inside `path` durable. On Unix a directory opens
165/// like a file and fsync flushes its entries. On Windows there is nothing to
166/// do and no way to do it: CreateFileW refuses to open a directory without
167/// FILE_FLAG_BACKUP_SEMANTICS and answers ERROR_ACCESS_DENIED, which is how
168/// the first Windows run died -- in the JavaScript frontend's own copy of this
169/// idiom, on the generated node_modules directory it had just created -- and
170/// NTFS journals directory metadata without an explicit sync. Every place
171/// that syncs a directory goes through here so the rule is stated once.
172pub(crate) fn sync_directory_handle(path: &Path) -> io::Result<()> {
173    #[cfg(unix)]
174    {
175        File::open(path).and_then(|directory| directory.sync_all())
176    }
177    #[cfg(not(unix))]
178    {
179        let _ = path;
180        Ok(())
181    }
182}
183
184pub(crate) fn sync_directory(path: &Path) -> Result<(), LifecycleError> {
185    sync_directory_handle(path).map_err(|source| io_error(path, source))
186}
187
188pub(crate) fn atomic_rename(source: &Path, destination: &Path) -> Result<(), LifecycleError> {
189    let source_parent = source
190        .parent()
191        .ok_or_else(|| LifecycleError::UnsafePath(source.into()))?;
192    let destination_parent = destination
193        .parent()
194        .ok_or_else(|| LifecycleError::UnsafePath(destination.into()))?;
195    fs::create_dir_all(destination_parent).map_err(|error| io_error(destination_parent, error))?;
196    fs::rename(source, destination).map_err(|error| io_error(destination, error))?;
197    sync_directory(destination_parent)?;
198    if source_parent != destination_parent {
199        sync_directory(source_parent)?;
200    }
201    Ok(())
202}
203
204pub(crate) fn atomic_write(root: &Path, path: &Path, bytes: &[u8]) -> Result<(), LifecycleError> {
205    let parent = path
206        .parent()
207        .ok_or_else(|| LifecycleError::UnsafePath(path.into()))?;
208    reject_linked_ancestors(root, parent, true)?;
209    fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
210    for _ in 0..16 {
211        let temporary = parent.join(format!(
212            ".{}.{}.tmp",
213            path.file_name()
214                .and_then(|value| value.to_str())
215                .unwrap_or("state"),
216            unique_name()
217        ));
218        let mut file = match OpenOptions::new()
219            .write(true)
220            .create_new(true)
221            .open(&temporary)
222        {
223            Ok(file) => file,
224            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
225            Err(source) => return Err(io_error(&temporary, source)),
226        };
227        if let Err(source) = file.write_all(bytes).and_then(|_| file.sync_all()) {
228            let _ = fs::remove_file(&temporary);
229            return Err(io_error(&temporary, source));
230        }
231        drop(file);
232        if let Err(source) = fs::rename(&temporary, path) {
233            let _ = fs::remove_file(&temporary);
234            return Err(io_error(path, source));
235        }
236        sync_directory(parent)?;
237        return Ok(());
238    }
239    Err(LifecycleError::LockUnavailable)
240}
241
242pub fn remove_stored_tree_deferred(
243    project_root: &Path,
244    target: &Path,
245) -> Result<Option<PathBuf>, LifecycleError> {
246    let root = absolute_root(project_root)?;
247    let target = if target.is_absolute() {
248        target.to_owned()
249    } else {
250        root.join(target)
251    };
252    match fs::symlink_metadata(&target) {
253        Ok(_) => {}
254        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
255        Err(source) => return Err(io_error(&target, source)),
256    }
257    let store = root.join(".supercov");
258    let trash = root.join(TRASH);
259    let in_store = lexical_descendant(&store, &target) && !lexical_descendant(&trash, &target);
260    let container = crate::workspace::workspace_container(&root);
261    let in_workspace = owned_workspace_container(&root)
262        && (target == container || lexical_descendant(&container, &target));
263    if !in_store && !in_workspace {
264        return Err(LifecycleError::UnsafePath(target));
265    }
266    reject_linked_ancestors(if in_store { &store } else { &container }, &target, false)?;
267    reject_linked_ancestors(&root, &trash, true)?;
268    fs::create_dir_all(&trash).map_err(|source| io_error(&trash, source))?;
269    let destination = trash.join(unique_name());
270    atomic_rename(&target, &destination)?;
271    Ok(Some(destination))
272}
273
274/// Retryable trash unlinking. Public commands execute this in a child.
275pub fn sweep_trash(project_root: &Path) -> Result<usize, LifecycleError> {
276    let root = absolute_root(project_root)?;
277    let trash = root.join(TRASH);
278    reject_linked_ancestors(&root, &trash, true)?;
279    let entries = match fs::read_dir(&trash) {
280        Ok(entries) => entries,
281        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
282        Err(source) => return Err(io_error(&trash, source)),
283    };
284    let Some(_lock) = TrashLock::acquire(&trash)? else {
285        return Ok(0);
286    };
287    let mut removed = 0;
288    for entry in entries {
289        let entry = entry.map_err(|source| io_error(&trash, source))?;
290        let path = entry.path();
291        if entry.file_name() == ".deleter.lock" {
292            continue;
293        }
294        let metadata = fs::symlink_metadata(&path).map_err(|source| io_error(&path, source))?;
295        if metadata.file_type().is_dir() {
296            fs::remove_dir_all(&path).map_err(|source| io_error(&path, source))?;
297        } else {
298            fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
299        }
300        removed += 1;
301    }
302    Ok(removed)
303}
304
305struct TrashLock {
306    path: PathBuf,
307}
308
309impl TrashLock {
310    fn acquire(trash: &Path) -> Result<Option<Self>, LifecycleError> {
311        let path = trash.join(".deleter.lock");
312        for _ in 0..2 {
313            match OpenOptions::new().write(true).create_new(true).open(&path) {
314                Ok(mut file) => {
315                    write!(file, "{}", std::process::id())
316                        .and_then(|_| file.sync_all())
317                        .map_err(|source| io_error(&path, source))?;
318                    return Ok(Some(Self { path }));
319                }
320                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
321                    let owner = fs::read_to_string(&path)
322                        .ok()
323                        .and_then(|value| value.parse::<u32>().ok());
324                    if owner.is_some_and(process_exists) {
325                        return Ok(None);
326                    }
327                    if owner.is_none() {
328                        let age = fs::metadata(&path)
329                            .and_then(|metadata| metadata.modified())
330                            .ok()
331                            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
332                            .unwrap_or_default();
333                        if age < INCOMPLETE_LOCK_GRACE {
334                            return Ok(None);
335                        }
336                    }
337                    fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
338                }
339                Err(source) => return Err(io_error(&path, source)),
340            }
341        }
342        Ok(None)
343    }
344}
345
346impl Drop for TrashLock {
347    fn drop(&mut self) {
348        let _ = fs::remove_file(&self.path);
349    }
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "lowercase")]
354pub enum RunStateStatus {
355    Preparing,
356    Building,
357    Testing,
358    Publishing,
359    Complete,
360    Failed,
361    Interrupted,
362    Abandoned,
363}
364
365impl RunStateStatus {
366    pub fn terminal(self) -> bool {
367        matches!(
368            self,
369            Self::Complete | Self::Failed | Self::Interrupted | Self::Abandoned
370        )
371    }
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(rename_all = "camelCase", deny_unknown_fields)]
376pub struct RunState {
377    pub id: String,
378    pub pid: u32,
379    pub root: String,
380    pub workspace: String,
381    pub started_at: String,
382    pub updated_at: String,
383    pub status: RunStateStatus,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub signal: Option<String>,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub error: Option<String>,
388}
389
390fn state_path(root: &Path, id: &str) -> PathBuf {
391    root.join(".supercov/work").join(id).join("state.json")
392}
393
394pub fn write_run_state(root: &Path, state: &RunState) -> Result<(), LifecycleError> {
395    checked_id(&state.id)?;
396    let mut bytes = serde_json::to_vec_pretty(state).map_err(LifecycleError::Metadata)?;
397    bytes.push(b'\n');
398    atomic_write(root, &state_path(root, &state.id), &bytes)
399}
400
401fn read_state(root: &Path, id: &str) -> Result<Option<RunState>, LifecycleError> {
402    checked_id(id)?;
403    let path = state_path(root, id);
404    let bytes = match fs::read(&path) {
405        Ok(bytes) => bytes,
406        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
407        Err(source) => return Err(io_error(&path, source)),
408    };
409    serde_json::from_slice(&bytes)
410        .map(Some)
411        .map_err(|error| LifecycleError::InvalidState(error.to_string()))
412}
413
414pub fn update_run_state(
415    root: &Path,
416    id: &str,
417    status: RunStateStatus,
418    updated_at: &str,
419    error: Option<String>,
420) -> Result<RunState, LifecycleError> {
421    let mut state = read_state(root, id)?
422        .ok_or_else(|| LifecycleError::InvalidState(format!("state is missing for {id}")))?;
423    state.status = status;
424    state.updated_at = updated_at.into();
425    state.error = error;
426    write_run_state(root, &state)?;
427    Ok(state)
428}
429
430pub fn interrupt_run_state(
431    root: &Path,
432    id: &str,
433    updated_at: &str,
434    signal: &str,
435) -> Result<RunState, LifecycleError> {
436    let mut state = read_state(root, id)?
437        .ok_or_else(|| LifecycleError::InvalidState(format!("state is missing for {id}")))?;
438    state.status = RunStateStatus::Interrupted;
439    state.updated_at = updated_at.into();
440    state.signal = Some(signal.into());
441    state.error = Some(format!("Interrupted by {signal}"));
442    write_run_state(root, &state)?;
443    Ok(state)
444}
445
446#[cfg(unix)]
447fn process_exists(pid: u32) -> bool {
448    if pid == 0 || pid > libc::pid_t::MAX as u32 {
449        return false;
450    }
451    // SAFETY: signal zero only checks existence/permission.
452    let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
453    result == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
454}
455
456#[cfg(not(unix))]
457fn process_exists(pid: u32) -> bool {
458    // Replaced by the Windows Job-object strategy before Windows GA.
459    pid == std::process::id()
460}
461
462#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
463#[serde(rename_all = "camelCase", deny_unknown_fields)]
464struct LockOwner {
465    run_id: String,
466    pid: u32,
467    started_at: String,
468}
469
470pub struct ProjectLock {
471    root: PathBuf,
472    path: PathBuf,
473    owner: LockOwner,
474    released: bool,
475}
476
477impl ProjectLock {
478    pub fn acquire(root: &Path, run_id: &str, started_at: &str) -> Result<Self, LifecycleError> {
479        checked_id(run_id)?;
480        let path = root.join(".supercov/locks/active.json");
481        let parent = path.parent().expect("lock parent");
482        reject_linked_ancestors(root, parent, true)?;
483        fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
484        let owner = LockOwner {
485            run_id: run_id.into(),
486            pid: std::process::id(),
487            started_at: started_at.into(),
488        };
489        let mut payload = serde_json::to_vec_pretty(&owner).map_err(LifecycleError::Metadata)?;
490        payload.push(b'\n');
491        for _ in 0..2 {
492            match OpenOptions::new().write(true).create_new(true).open(&path) {
493                Ok(mut file) => {
494                    file.write_all(&payload)
495                        .and_then(|_| file.sync_all())
496                        .map_err(|source| io_error(&path, source))?;
497                    sync_directory(parent)?;
498                    return Ok(Self {
499                        root: root.to_owned(),
500                        path,
501                        owner,
502                        released: false,
503                    });
504                }
505                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
506                    let existing = fs::read(&path)
507                        .ok()
508                        .and_then(|bytes| serde_json::from_slice::<LockOwner>(&bytes).ok());
509                    if let Some(existing) = existing {
510                        if process_exists(existing.pid) {
511                            return Err(LifecycleError::ActiveRun {
512                                run_id: existing.run_id,
513                                pid: existing.pid,
514                            });
515                        }
516                    } else {
517                        let age = fs::metadata(&path)
518                            .and_then(|metadata| metadata.modified())
519                            .ok()
520                            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
521                            .unwrap_or_default();
522                        if age < INCOMPLETE_LOCK_GRACE {
523                            return Err(LifecycleError::LockAcquiring);
524                        }
525                    }
526                    fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
527                }
528                Err(source) => return Err(io_error(&path, source)),
529            }
530        }
531        Err(LifecycleError::LockUnavailable)
532    }
533
534    pub fn release(&mut self) -> Result<(), LifecycleError> {
535        if self.released {
536            return Ok(());
537        }
538        self.released = true;
539        let owned = fs::read(&self.path)
540            .ok()
541            .and_then(|bytes| serde_json::from_slice::<LockOwner>(&bytes).ok())
542            .is_some_and(|owner| owner == self.owner);
543        if owned {
544            fs::remove_file(&self.path).map_err(|source| io_error(&self.path, source))?;
545        }
546        Ok(())
547    }
548
549    pub(crate) fn protects(&self, root: &Path) -> bool {
550        !self.released && self.root == root
551    }
552}
553
554impl Drop for ProjectLock {
555    fn drop(&mut self) {
556        let _ = self.release();
557    }
558}
559
560fn copy_regular_file(source: &Path, destination: &Path) -> Result<u64, LifecycleError> {
561    let metadata = fs::symlink_metadata(source).map_err(|error| io_error(source, error))?;
562    if !metadata.file_type().is_file() {
563        return Err(LifecycleError::UnsafePath(source.into()));
564    }
565    let mut input = File::open(source).map_err(|error| io_error(source, error))?;
566    let mut output = OpenOptions::new()
567        .write(true)
568        .create_new(true)
569        .open(destination)
570        .map_err(|error| io_error(destination, error))?;
571    let copied = io::copy(&mut input, &mut output).map_err(|error| io_error(destination, error))?;
572    output
573        .sync_all()
574        .map_err(|error| io_error(destination, error))?;
575    Ok(copied)
576}
577
578fn file_sha256(path: &Path) -> Result<[u8; 32], LifecycleError> {
579    let metadata = fs::symlink_metadata(path).map_err(|source| io_error(path, source))?;
580    if !metadata.file_type().is_file() {
581        return Err(LifecycleError::UnsafePath(path.into()));
582    }
583    let mut file = File::open(path).map_err(|source| io_error(path, source))?;
584    let mut hash = Sha256::new();
585    let mut buffer = [0_u8; 128 * 1024];
586    loop {
587        let read = file
588            .read(&mut buffer)
589            .map_err(|source| io_error(path, source))?;
590        if read == 0 {
591            break;
592        }
593        hash.update(&buffer[..read]);
594    }
595    Ok(hash.finalize().into())
596}
597
598/// Publish immutable run evidence and its initial assertion map/state together.
599pub fn publish_run(
600    root: &Path,
601    metadata: &RunMetadata,
602    evidence_source: &Path,
603) -> Result<PathBuf, LifecycleError> {
604    publish_run_with_fault(root, metadata, evidence_source, None)
605}
606
607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub(crate) enum RunPublicationFault {
609    FinalRename,
610}
611
612pub(crate) fn publish_run_with_fault(
613    root: &Path,
614    metadata: &RunMetadata,
615    evidence_source: &Path,
616    fault: Option<RunPublicationFault>,
617) -> Result<PathBuf, LifecycleError> {
618    checked_id(&metadata.id)?;
619    let destination = root.join(".supercov/runs").join(&metadata.id);
620    reject_linked_ancestors(root, &destination, false)?;
621    if fs::symlink_metadata(&destination).is_ok() {
622        return Err(LifecycleError::PublicationExists(metadata.id.clone()));
623    }
624    let staging = root
625        .join(".supercov/work")
626        .join(&metadata.id)
627        .join("run-publication");
628    reject_linked_ancestors(root, &staging, true)?;
629    if fs::symlink_metadata(&staging).is_ok() {
630        remove_stored_tree_deferred(root, &staging)?;
631    }
632    let evidence_sha256 = file_sha256(evidence_source)?;
633    fs::create_dir_all(&staging).map_err(|source| io_error(&staging, source))?;
634    let copied = copy_regular_file(evidence_source, &staging.join("evidence.raw.gz"))?;
635    if copied != metadata.raw_evidence.compressed_bytes {
636        remove_stored_tree_deferred(root, &staging)?;
637        return Err(LifecycleError::EvidenceLength {
638            expected: metadata.raw_evidence.compressed_bytes,
639            actual: copied,
640        });
641    }
642    if file_sha256(evidence_source)? != evidence_sha256
643        || file_sha256(&staging.join("evidence.raw.gz"))? != evidence_sha256
644    {
645        remove_stored_tree_deferred(root, &staging)?;
646        return Err(LifecycleError::EvidenceChanged);
647    }
648    let mut json = serde_json::to_vec_pretty(metadata).map_err(LifecycleError::Metadata)?;
649    json.push(b'\n');
650    atomic_write(root, &staging.join("run.json"), &json)?;
651    if let Err(reason) = crate::assertion_store::prepare_publication(root, &staging, metadata) {
652        let _ = remove_stored_tree_deferred(root, &staging);
653        return Err(LifecycleError::InvalidState(format!(
654            "assertion map publication: {reason}"
655        )));
656    }
657    sync_directory(&staging)?;
658    let runs = destination.parent().expect("runs parent");
659    fs::create_dir_all(runs).map_err(|source| io_error(runs, source))?;
660    if fault == Some(RunPublicationFault::FinalRename) {
661        let error = io_error(
662            &destination,
663            io::Error::new(
664                io::ErrorKind::PermissionDenied,
665                "injected final run publication rename failure",
666            ),
667        );
668        let _ = remove_stored_tree_deferred(root, &staging);
669        return Err(error);
670    }
671    fs::rename(&staging, &destination).map_err(|source| io_error(&destination, source))?;
672    sync_directory(runs)?;
673    Ok(destination)
674}
675
676/// Read a published run's start timestamp for retention ordering. Runs whose
677/// metadata is missing or unreadable sort as oldest, matching the run inventory,
678/// which leaves such directories out of its listing altogether.
679fn published_started_at(root: &Path, id: &str) -> String {
680    let directory = root.join(".supercov/runs").join(id);
681    fs::read(directory.join("run.json"))
682        .ok()
683        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
684        .as_ref()
685        .and_then(|value| value.get("startedAt"))
686        .and_then(|value| value.as_str())
687        .unwrap_or_default()
688        .to_owned()
689}
690
691fn published_run(root: &Path, id: &str) -> bool {
692    let directory = root.join(".supercov/runs").join(id);
693    let metadata = fs::read(directory.join("run.json"))
694        .ok()
695        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
696    metadata
697        .as_ref()
698        .and_then(|value| value.get("id"))
699        .and_then(|value| value.as_str())
700        == Some(id)
701        && fs::symlink_metadata(directory.join("evidence.raw.gz"))
702            .is_ok_and(|metadata| metadata.file_type().is_file())
703}
704
705pub fn finalize_published_run(root: &Path, id: &str) -> Result<bool, LifecycleError> {
706    checked_id(id)?;
707    if !published_run(root, id) {
708        return Ok(false);
709    }
710    remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(id))?;
711    remove_stored_tree_deferred(root, &root.join(".supercov/work").join(id))?;
712    Ok(true)
713}
714
715fn child_directories(path: &Path) -> Result<Vec<String>, LifecycleError> {
716    let root = path
717        .ancestors()
718        .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".supercov"))
719        .and_then(Path::parent)
720        .unwrap_or(path);
721    reject_linked_ancestors(root, path, true)?;
722    let entries = match fs::read_dir(path) {
723        Ok(entries) => entries,
724        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
725        Err(source) => return Err(io_error(path, source)),
726    };
727    let mut names = Vec::new();
728    for entry in entries {
729        let entry = entry.map_err(|source| io_error(path, source))?;
730        let file_type = entry
731            .file_type()
732            .map_err(|source| io_error(&entry.path(), source))?;
733        if file_type.is_symlink() {
734            return Err(LifecycleError::UnsafePath(entry.path()));
735        }
736        if !file_type.is_dir() {
737            continue;
738        }
739        let name = entry
740            .file_name()
741            .into_string()
742            .map_err(|_| LifecycleError::UnsafePath(entry.path()))?;
743        checked_id(&name)?;
744        names.push(name);
745    }
746    names.sort();
747    Ok(names)
748}
749
750/// Where a run's evidence is kept when Supercov could not publish it.
751const FAILED_EVIDENCE: &str = ".supercov/failed-evidence";
752
753/// Keep what a run measured when Supercov itself is what failed.
754///
755/// A suite pays for its measurement in wall clock, and the one that lost a run
756/// to a phase reported twice had spent two and three-quarter hours on it. The
757/// work directory goes when a run does not publish, and it held the only copy
758/// of the evidence: nothing was left to diagnose the failure with, and nothing
759/// to recover. The evidence is kept outside the work tree, where neither the
760/// recovery sweep nor the next run reclaims it, and the failure says where.
761///
762/// Best effort by design: a run has already failed by the time this is called,
763/// and being unable to keep the evidence must not replace the failure that
764/// matters with one about copying it.
765pub fn keep_failed_evidence(root: &Path, evidence: &Path, id: &str) -> Option<PathBuf> {
766    if !evidence.is_dir() {
767        return None;
768    }
769    let kept = root.join(FAILED_EVIDENCE).join(id);
770    fs::create_dir_all(&kept).ok()?;
771    let mut files = 0;
772    if copy_evidence(evidence, &kept, &mut files).is_err() || files == 0 {
773        // Nothing worth keeping, or nothing that could be: leave no empty
774        // directory behind to puzzle over.
775        let _ = fs::remove_dir_all(&kept);
776        return None;
777    }
778    Some(kept)
779}
780
781/// Say where a failed run's evidence was kept, on the failure itself.
782///
783/// The user is looking at a Supercov bug at this point; the one thing they can
784/// do about it is send the evidence, and they only will if the failure says
785/// where it is. Returns the error unchanged when there was nothing to keep.
786pub fn note_kept_evidence(root: &Path, evidence: &Path, id: &str, error: String) -> String {
787    let Some(kept) = keep_failed_evidence(root, evidence, id) else {
788        return error;
789    };
790    format!(
791        "{error}\n[supercov] the tests were measured and the run could not be published; \
792their evidence is kept at {} -- this is a Supercov bug, and that directory is what diagnoses it",
793        kept.display()
794    )
795}
796
797fn copy_evidence(source: &Path, destination: &Path, files: &mut usize) -> std::io::Result<()> {
798    for entry in fs::read_dir(source)? {
799        let entry = entry?;
800        let target = destination.join(entry.file_name());
801        if entry.file_type()?.is_dir() {
802            fs::create_dir_all(&target)?;
803            copy_evidence(&entry.path(), &target, files)?;
804        } else {
805            fs::copy(entry.path(), &target)?;
806            *files += 1;
807        }
808    }
809    Ok(())
810}
811
812pub fn recover_abandoned_runs(
813    root: &Path,
814    updated_at: &str,
815) -> Result<Vec<String>, LifecycleError> {
816    let mut recovered = Vec::new();
817    for id in child_directories(&root.join(".supercov/work"))? {
818        let Some(state) = read_state(root, &id)? else {
819            continue;
820        };
821        if state.status.terminal() {
822            finalize_published_run(root, &id)?;
823            continue;
824        }
825        if process_exists(state.pid) {
826            continue;
827        }
828        let workspace_name = root.file_name().unwrap_or_default();
829        remove_stored_tree_deferred(
830            root,
831            &root.join(".supercov/work").join(&id).join(workspace_name),
832        )?;
833        remove_stored_tree_deferred(
834            root,
835            &root
836                .join(".supercov/work")
837                .join(&id)
838                .join("run-publication"),
839        )?;
840        if !finalize_published_run(root, &id)? {
841            remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(&id))?;
842            update_run_state(
843                root,
844                &id,
845                RunStateStatus::Abandoned,
846                updated_at,
847                Some(format!(
848                    "Recovered after process {} exited without cleanup",
849                    state.pid
850                )),
851            )?;
852            remove_stored_tree_deferred(root, &root.join(".supercov/work").join(&id))?;
853        }
854        recovered.push(id);
855    }
856    recovered.sort();
857    Ok(recovered)
858}
859
860#[derive(Debug, Clone, Copy, PartialEq, Eq)]
861pub struct CleanupOptions {
862    pub keep: usize,
863    pub dry_run: bool,
864}
865
866#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
867#[serde(rename_all = "camelCase")]
868pub struct CleanupResult {
869    pub removed_runs: Vec<String>,
870    pub removed_workspaces: Vec<String>,
871    pub removed_evidence: Vec<String>,
872    /// Evidence kept from runs Supercov itself failed to publish. Retention
873    /// leaves it alone -- it is the only copy, and the automatic path taking it
874    /// is what made a lost run unrecoverable -- but `clean` is the user asking
875    /// for the project's storage back, so it goes then, and the summary says so.
876    pub removed_failed_evidence: Vec<String>,
877    pub removed_build_cache: bool,
878}
879
880pub fn cleanup_storage_locked(
881    root: &Path,
882    options: CleanupOptions,
883    remove_build_cache: bool,
884) -> Result<CleanupResult, LifecycleError> {
885    let runs_root = root.join(".supercov/runs");
886    let work_root = root.join(".supercov/work");
887    let evidence_root = root.join(".supercov/evidence");
888    let published = child_directories(&runs_root)?;
889    let work = child_directories(&work_root)?;
890    let evidence = child_directories(&evidence_root)?;
891    let mut ids = published
892        .iter()
893        .chain(&work)
894        .chain(&evidence)
895        .cloned()
896        .collect::<BTreeSet<_>>()
897        .into_iter()
898        .collect::<Vec<_>>();
899    ids.sort_by(|left, right| right.cmp(left));
900    let mut active = BTreeSet::new();
901    for id in &ids {
902        if read_state(root, id)?.is_some_and(|state| !state.status.terminal()) {
903            active.insert(id.clone());
904        }
905    }
906    let mut retention_order = published
907        .iter()
908        .filter(|id| !active.contains(*id))
909        .map(|id| (published_started_at(root, id), id.clone()))
910        .collect::<Vec<_>>();
911    retention_order.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1)));
912    let retained = retention_order
913        .into_iter()
914        .take(options.keep)
915        .map(|(_, id)| id)
916        .collect::<BTreeSet<_>>();
917    let mut result = CleanupResult {
918        removed_runs: Vec::new(),
919        removed_workspaces: Vec::new(),
920        removed_evidence: Vec::new(),
921        removed_failed_evidence: Vec::new(),
922        removed_build_cache: false,
923    };
924    for id in ids {
925        if active.contains(&id) {
926            continue;
927        }
928        let has_run = published.contains(&id);
929        let remove_history = has_run && !retained.contains(&id);
930        if work.contains(&id) && read_state(root, &id)?.is_none_or(|state| state.status.terminal())
931        {
932            result.removed_workspaces.push(id.clone());
933            if !options.dry_run {
934                remove_stored_tree_deferred(root, &work_root.join(&id))?;
935            }
936        }
937        if evidence.contains(&id) && (!has_run || remove_history) {
938            result.removed_evidence.push(id.clone());
939            if !options.dry_run {
940                remove_stored_tree_deferred(root, &evidence_root.join(&id))?;
941            }
942        }
943        if remove_history {
944            result.removed_runs.push(id.clone());
945            if !options.dry_run {
946                remove_stored_tree_deferred(root, &runs_root.join(&id))?;
947            }
948        }
949    }
950    // Only a full `clean` reclaims kept evidence. `--keep N` is a trim, and the
951    // evidence of a run Supercov failed to publish is the last thing a user
952    // trimming their history means to lose; a retention pass would take it
953    // within one run of keeping it.
954    if remove_build_cache && options.keep == 0 && active.is_empty() {
955        let kept_root = root.join(FAILED_EVIDENCE);
956        for id in child_directories(&kept_root)? {
957            result.removed_failed_evidence.push(id.clone());
958            if !options.dry_run {
959                remove_stored_tree_deferred(root, &kept_root.join(&id))?;
960            }
961        }
962    }
963    let container = crate::workspace::workspace_container(root);
964    let legacy = [root.join(".supercov/.cache"), root.join(".supercov/cache")];
965    let mut caches = Vec::new();
966    let mut removed_cargo_cache = false;
967    if remove_build_cache && active.is_empty() {
968        if owned_workspace_container(root) {
969            caches.push(container);
970        }
971        caches.extend(
972            legacy
973                .into_iter()
974                .filter(|path| fs::symlink_metadata(path).is_ok()),
975        );
976        removed_cargo_cache = crate::workspace::clean_cargo_workspace(root, options.dry_run)
977            .map_err(|error| {
978                LifecycleError::InvalidState(format!(
979                    "could not clean the owned Cargo workspace: {error}"
980                ))
981            })?;
982    }
983    result.removed_build_cache = removed_cargo_cache || !caches.is_empty();
984    if !options.dry_run {
985        for cache in caches {
986            remove_stored_tree_deferred(root, &cache)?;
987        }
988    }
989    Ok(result)
990}
991
992fn cleanup_storage(
993    root: &Path,
994    options: CleanupOptions,
995    remove_build_cache: bool,
996    updated_at: &str,
997) -> Result<CleanupResult, LifecycleError> {
998    let operation = if remove_build_cache {
999        "clean"
1000    } else {
1001        "retention"
1002    };
1003    let lock_id = format!("{operation}-{}-{}", std::process::id(), unique_name());
1004    let mut lock = ProjectLock::acquire(root, &lock_id, updated_at)?;
1005    recover_abandoned_runs(root, updated_at)?;
1006    let result = cleanup_storage_locked(root, options, remove_build_cache);
1007    lock.release()?;
1008    result
1009}
1010
1011pub fn clean_storage(
1012    root: &Path,
1013    options: CleanupOptions,
1014    updated_at: &str,
1015) -> Result<CleanupResult, LifecycleError> {
1016    cleanup_storage(root, options, true, updated_at)
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022    use crate::run_store::{RawEvidenceMetadata, RunFingerprint, RunIntegrity};
1023
1024    fn project() -> PathBuf {
1025        let root = std::env::temp_dir().join(format!("supercov-lifecycle-{}", unique_name()));
1026        fs::create_dir_all(root.join("src")).unwrap();
1027        fs::write(root.join("src/index.js"), "user source").unwrap();
1028        root
1029    }
1030
1031    fn kept_evidence(root: &Path, id: &str) -> PathBuf {
1032        let evidence = root.join(".supercov/work").join(id).join("python/evidence");
1033        fs::create_dir_all(&evidence).unwrap();
1034        fs::write(evidence.join("main.jsonl"), "what the run saw").unwrap();
1035        keep_failed_evidence(root, &evidence, id).expect("the evidence")
1036    }
1037
1038    fn state(root: &Path, id: &str, status: RunStateStatus, pid: u32) -> RunState {
1039        RunState {
1040            id: id.into(),
1041            pid,
1042            root: root.display().to_string(),
1043            workspace: root.join("dist").display().to_string(),
1044            started_at: "start".into(),
1045            updated_at: "update".into(),
1046            status,
1047            signal: None,
1048            error: None,
1049        }
1050    }
1051
1052    fn metadata(id: &str, bytes: u64) -> RunMetadata {
1053        RunMetadata {
1054            id: id.into(),
1055            started_at: "2026-01-01T00:00:00Z".into(),
1056            duration_ms: 1.0,
1057            command: vec!["test".into()],
1058            test_exit_code: Some(0),
1059            integrity: RunIntegrity {
1060                schema_version: 2,
1061                instrumenter_version: "rust".into(),
1062                git: None,
1063                fingerprint: RunFingerprint {
1064                    algorithm: "sha256".into(),
1065                    source: "0".repeat(64),
1066                    tests: "0".repeat(64),
1067                    dependencies: "0".repeat(64),
1068                    configuration: "0".repeat(64),
1069                    instrumenter: "0".repeat(64),
1070                    execution: "0".repeat(64),
1071                    combined: "0".repeat(64),
1072                    source_files: 1,
1073                    test_files: 1,
1074                },
1075                stale: None,
1076                stale_reasons: None,
1077            },
1078            raw_evidence: RawEvidenceMetadata {
1079                schema_version: 2,
1080                format: "supercov-evidence-archive".into(),
1081                file: "evidence.raw.gz".into(),
1082                files: 1,
1083                uncompressed_bytes: bytes,
1084                compressed_bytes: bytes,
1085            },
1086            isolated_build: None,
1087            instrumented_build_cache: None,
1088            timings: None,
1089            merged: None,
1090            parents: None,
1091            source_roots: None,
1092        }
1093    }
1094
1095    fn evidence(root: &Path) -> (PathBuf, u64) {
1096        use crate::evidence_archive::{EvidenceArchiveEntry, write_archive};
1097        let mut entries = ["manifest.json", "frontend.json", "coverage-model.json"]
1098            .into_iter()
1099            .map(|path| EvidenceArchiveEntry {
1100                path: path.into(),
1101                contents: b"{}".to_vec(),
1102            })
1103            .collect::<Vec<_>>();
1104        let inputs =
1105            crate::assertion_inputs::capture(root, "javascript", [PathBuf::from("src/index.js")])
1106                .unwrap();
1107        entries = crate::assertion_inputs::append(entries, &inputs).unwrap();
1108        let path = root.join("evidence.gz");
1109        let metadata = write_archive(entries, &path).unwrap();
1110        (path, metadata.compressed_bytes)
1111    }
1112
1113    #[test]
1114    fn defers_only_owned_storage_and_sweeps_without_touching_source() {
1115        let root = project();
1116        let owned = root.join(".supercov/evidence/run");
1117        fs::create_dir_all(&owned).unwrap();
1118        fs::write(owned.join("hit"), "hit").unwrap();
1119        let trash = remove_stored_tree_deferred(&root, &owned).unwrap().unwrap();
1120        assert!(!owned.exists());
1121        assert!(trash.exists());
1122        assert!(matches!(
1123            remove_stored_tree_deferred(&root, &root.join("src")),
1124            Err(LifecycleError::UnsafePath(_))
1125        ));
1126        assert_eq!(sweep_trash(&root).unwrap(), 1);
1127        assert!(root.join("src/index.js").exists());
1128        fs::remove_dir_all(root).unwrap();
1129    }
1130
1131    #[cfg(unix)]
1132    #[test]
1133    fn refuses_linked_storage_ancestors_instead_of_renaming_external_data() {
1134        use std::os::unix::fs::symlink;
1135
1136        let root = project();
1137        let outside = project();
1138        fs::create_dir_all(root.join(".supercov")).unwrap();
1139        fs::create_dir_all(outside.join("run")).unwrap();
1140        fs::write(outside.join("run/user.txt"), "user").unwrap();
1141        symlink(&outside, root.join(".supercov/evidence")).unwrap();
1142        assert!(matches!(
1143            remove_stored_tree_deferred(&root, &root.join(".supercov/evidence/run")),
1144            Err(LifecycleError::UnsafePath(_))
1145        ));
1146        assert_eq!(
1147            fs::read_to_string(outside.join("run/user.txt")).unwrap(),
1148            "user"
1149        );
1150        fs::remove_dir_all(root).unwrap();
1151        fs::remove_dir_all(outside).unwrap();
1152    }
1153
1154    #[test]
1155    fn publishes_both_required_files_with_one_visible_rename() {
1156        let root = project();
1157        let id = "2026-01-01T00-00-00-000Z";
1158        let (evidence, bytes) = evidence(&root);
1159        let published = publish_run(&root, &metadata(id, bytes), &evidence).unwrap();
1160        assert!(published.join("run.json").is_file());
1161        assert_eq!(
1162            fs::read(published.join("evidence.raw.gz")).unwrap(),
1163            fs::read(&evidence).unwrap()
1164        );
1165        assert!(published.join("assertions.json").is_file());
1166        assert!(published.join("assertions.state.json").is_file());
1167        assert!(matches!(
1168            publish_run(&root, &metadata(id, bytes), &evidence),
1169            Err(LifecycleError::PublicationExists(_))
1170        ));
1171        fs::remove_dir_all(root).unwrap();
1172    }
1173
1174    #[test]
1175    fn final_rename_failure_exposes_no_run_and_removes_staging() {
1176        let root = project();
1177        let id = "2026-01-01T00-00-00-000Z";
1178        let (evidence, bytes) = evidence(&root);
1179        let error = publish_run_with_fault(
1180            &root,
1181            &metadata(id, bytes),
1182            &evidence,
1183            Some(RunPublicationFault::FinalRename),
1184        )
1185        .unwrap_err();
1186        assert!(
1187            error
1188                .to_string()
1189                .contains("injected final run publication rename failure")
1190        );
1191        assert!(!root.join(".supercov/runs").join(id).exists());
1192        assert!(
1193            !root
1194                .join(".supercov/work")
1195                .join(id)
1196                .join("run-publication")
1197                .exists()
1198        );
1199        sweep_trash(&root).unwrap();
1200        fs::remove_dir_all(root).unwrap();
1201    }
1202
1203    #[test]
1204    fn malformed_assertion_inputs_expose_no_partial_run() {
1205        use crate::evidence_archive::{read_archive, write_archive};
1206        let root = project();
1207        let id = "2026-01-01T00-00-00-000Z";
1208        let (evidence, _) = evidence(&root);
1209        let mut entries = read_archive(&evidence).unwrap();
1210        entries
1211            .iter_mut()
1212            .find(|e| e.path == crate::assertion_inputs::ARCHIVE_PATH)
1213            .unwrap()
1214            .contents = b"{broken".to_vec();
1215        let raw = write_archive(entries, &evidence).unwrap();
1216        let error = publish_run(&root, &metadata(id, raw.compressed_bytes), &evidence).unwrap_err();
1217        assert!(error.to_string().contains("assertion map publication"));
1218        assert!(!root.join(".supercov/runs").join(id).exists());
1219        assert!(
1220            !root
1221                .join(".supercov/work")
1222                .join(id)
1223                .join("run-publication")
1224                .exists()
1225        );
1226        fs::remove_dir_all(root).unwrap();
1227    }
1228
1229    #[test]
1230    fn recovers_dead_unpublished_and_fully_published_runs_from_derived_paths() {
1231        let root = project();
1232        let dead = "2026-01-01T00-00-00-000Z";
1233        let published = "2026-01-02T00-00-00-000Z";
1234        for id in [dead, published] {
1235            fs::create_dir_all(
1236                root.join(".supercov/work")
1237                    .join(id)
1238                    .join(root.file_name().unwrap()),
1239            )
1240            .unwrap();
1241            fs::create_dir_all(root.join(".supercov/evidence").join(id)).unwrap();
1242            write_run_state(&root, &state(&root, id, RunStateStatus::Testing, u32::MAX)).unwrap();
1243        }
1244        let (evidence, bytes) = evidence(&root);
1245        publish_run(&root, &metadata(published, bytes), &evidence).unwrap();
1246        assert_eq!(
1247            recover_abandoned_runs(&root, "recovered").unwrap(),
1248            [dead, published]
1249        );
1250        assert!(!root.join(".supercov/work").join(dead).exists());
1251        assert!(!root.join(".supercov/work").join(published).exists());
1252        assert!(root.join(".supercov/runs").join(published).exists());
1253        assert!(root.join("src/index.js").exists());
1254        sweep_trash(&root).unwrap();
1255        fs::remove_dir_all(root).unwrap();
1256    }
1257
1258    #[test]
1259    fn retention_is_deterministic_dry_run_safe_and_preserves_active_work() {
1260        let root = project();
1261        let ids = [
1262            "2026-01-01T00-00-00-000Z",
1263            "2026-01-02T00-00-00-000Z",
1264            "2026-01-03T00-00-00-000Z",
1265        ];
1266        for id in ids {
1267            fs::create_dir_all(root.join(".supercov/runs").join(id)).unwrap();
1268            write_run_state(
1269                &root,
1270                &state(&root, id, RunStateStatus::Complete, std::process::id()),
1271            )
1272            .unwrap();
1273        }
1274        let active = "2025-12-31T00-00-00-000Z";
1275        write_run_state(
1276            &root,
1277            &state(&root, active, RunStateStatus::Testing, std::process::id()),
1278        )
1279        .unwrap();
1280        let preview = cleanup_storage_locked(
1281            &root,
1282            CleanupOptions {
1283                keep: 1,
1284                dry_run: true,
1285            },
1286            false,
1287        )
1288        .unwrap();
1289        assert_eq!(preview.removed_runs, [ids[1], ids[0]]);
1290        assert!(
1291            ids.iter()
1292                .all(|id| root.join(".supercov/runs").join(id).exists())
1293        );
1294        let result = cleanup_storage_locked(
1295            &root,
1296            CleanupOptions {
1297                keep: 1,
1298                dry_run: false,
1299            },
1300            false,
1301        )
1302        .unwrap();
1303        assert_eq!(result, preview);
1304        assert!(root.join(".supercov/work").join(active).exists());
1305        assert!(root.join(".supercov/runs").join(ids[2]).exists());
1306        sweep_trash(&root).unwrap();
1307        fs::remove_dir_all(root).unwrap();
1308    }
1309
1310    #[test]
1311    fn retention_keeps_the_newest_runs_when_run_ids_disagree_with_recency() {
1312        let root = project();
1313        let (evidence, bytes) = evidence(&root);
1314        // Run IDs are random hex, so ordering them as strings says nothing about
1315        // time. The newest run here carries the smallest ID and must survive.
1316        for (id, started_at) in [
1317            ("run_ffffffffffffffff", "2026-01-01T00:00:00Z"),
1318            ("run_7777777777777777", "2026-05-01T00:00:00Z"),
1319            ("run_0000000000000001", "2026-09-01T00:00:00Z"),
1320        ] {
1321            let mut run = metadata(id, bytes);
1322            run.started_at = started_at.into();
1323            publish_run(&root, &run, &evidence).unwrap();
1324        }
1325        let result = cleanup_storage_locked(
1326            &root,
1327            CleanupOptions {
1328                keep: 1,
1329                dry_run: false,
1330            },
1331            false,
1332        )
1333        .unwrap();
1334        assert_eq!(
1335            result.removed_runs,
1336            ["run_ffffffffffffffff", "run_7777777777777777"]
1337        );
1338        assert!(
1339            root.join(".supercov/runs")
1340                .join("run_0000000000000001")
1341                .exists()
1342        );
1343        sweep_trash(&root).unwrap();
1344        fs::remove_dir_all(root).unwrap();
1345    }
1346
1347    #[test]
1348    fn cleanup_is_project_locked_and_clean_alone_removes_owned_caches() {
1349        let root = project();
1350        let container = root.join(".supercov/workspaces");
1351        fs::create_dir_all(container.join("workspace/project")).unwrap();
1352        fs::write(
1353            container.join(".supercov-workspace-store"),
1354            b"Supercov instrumented workspace. Safe to delete.\n",
1355        )
1356        .unwrap();
1357        fs::create_dir_all(root.join(".supercov/cache/legacy")).unwrap();
1358        let mut preparation = ProjectLock::acquire(&root, "prepare", "start").unwrap();
1359        crate::workspace::prepare_cargo_cached_workspace(&root, &preparation).unwrap();
1360        let cargo_container = crate::workspace::cargo_workspace_container(&root).unwrap();
1361        preparation.release().unwrap();
1362
1363        let mut active = ProjectLock::acquire(&root, "active", "start").unwrap();
1364        assert!(matches!(
1365            clean_storage(
1366                &root,
1367                CleanupOptions {
1368                    keep: 0,
1369                    dry_run: false
1370                },
1371                "now"
1372            ),
1373            Err(LifecycleError::ActiveRun { .. })
1374        ));
1375        assert!(container.exists());
1376        assert!(cargo_container.exists());
1377        active.release().unwrap();
1378
1379        let cleaned = clean_storage(
1380            &root,
1381            CleanupOptions {
1382                keep: 0,
1383                dry_run: false,
1384            },
1385            "now",
1386        )
1387        .unwrap();
1388        assert!(cleaned.removed_build_cache);
1389        assert!(!container.exists());
1390        assert!(!cargo_container.exists());
1391        assert!(!root.join(".supercov/cache").exists());
1392        sweep_trash(&root).unwrap();
1393        fs::remove_dir_all(root).unwrap();
1394    }
1395
1396    // What a failed run measured is the only record of hours of test time, and
1397    // it lived in the work tree that failing removes. #40 lost a run to a phase
1398    // reported twice after two and three-quarter hours, with nothing left to
1399    // diagnose it from.
1400    #[test]
1401    fn evidence_a_failed_run_measured_outlives_the_work_tree() {
1402        let root = project();
1403        let work = root.join(".supercov/work/run_kept");
1404        let evidence = work.join("python/evidence");
1405        fs::create_dir_all(evidence.join("worker-1")).unwrap();
1406        fs::write(evidence.join("main.jsonl"), "what the run saw").unwrap();
1407        fs::write(evidence.join("worker-1/mcdc.json"), "{}").unwrap();
1408
1409        let kept = keep_failed_evidence(&root, &evidence, "run_kept").expect("the evidence");
1410        remove_stored_tree_deferred(&root, &work).unwrap();
1411        sweep_trash(&root).unwrap();
1412
1413        assert!(!evidence.exists(), "the work tree still went");
1414        assert_eq!(
1415            fs::read_to_string(kept.join("main.jsonl")).unwrap(),
1416            "what the run saw",
1417        );
1418        // Nested workers are evidence too: a run measured by several of them
1419        // cannot be diagnosed from one.
1420        assert_eq!(
1421            fs::read_to_string(kept.join("worker-1/mcdc.json")).unwrap(),
1422            "{}"
1423        );
1424        fs::remove_dir_all(root).unwrap();
1425    }
1426
1427    #[test]
1428    fn nothing_to_keep_leaves_no_directory_to_puzzle_over() {
1429        let root = project();
1430        // A run that failed before it measured anything: the frontend never
1431        // reached the point of writing evidence, or wrote none.
1432        let missing = root.join(".supercov/work/run_early/python/evidence");
1433        assert!(keep_failed_evidence(&root, &missing, "run_early").is_none());
1434        fs::create_dir_all(&missing).unwrap();
1435        assert!(keep_failed_evidence(&root, &missing, "run_early").is_none());
1436        assert!(
1437            !root.join(".supercov/failed-evidence/run_early").exists(),
1438            "an empty directory is worse than none: it reads as evidence",
1439        );
1440        fs::remove_dir_all(root).unwrap();
1441    }
1442
1443    // The failure is the only thing the user reads. Keeping the evidence and
1444    // not saying where is the same as not keeping it.
1445    #[test]
1446    fn the_failure_says_where_the_evidence_was_kept() {
1447        let root = project();
1448        let evidence = root.join(".supercov/work/run_told/python/evidence");
1449        fs::create_dir_all(&evidence).unwrap();
1450        fs::write(evidence.join("main.jsonl"), "what the run saw").unwrap();
1451
1452        let told = note_kept_evidence(&root, &evidence, "run_told", "boom".into());
1453        // Taken from the keeper rather than spelled out here: a path written as
1454        // one string renders every separator the same way, which is not what a
1455        // Windows path built join by join looks like, so asserting on the
1456        // spelling tested this test instead of the message. Keeping is
1457        // idempotent, so asking again names the directory already kept.
1458        let kept = keep_failed_evidence(&root, &evidence, "run_told").expect("the evidence");
1459        assert_eq!(kept, root.join(FAILED_EVIDENCE).join("run_told"));
1460        assert!(
1461            told.starts_with("boom\n"),
1462            "the failure itself came second: {told}"
1463        );
1464        assert!(told.contains(&kept.display().to_string()), "{told}");
1465
1466        // Nothing kept, nothing to say: the failure that matters is not buried
1467        // under one about copying it.
1468        let nothing = root.join(".supercov/work/run_empty/python/evidence");
1469        assert_eq!(
1470            note_kept_evidence(&root, &nothing, "run_empty", "boom".into()),
1471            "boom",
1472        );
1473        fs::remove_dir_all(root).unwrap();
1474    }
1475
1476    #[test]
1477    fn a_trim_leaves_kept_evidence_alone() {
1478        let root = project();
1479        let kept = kept_evidence(&root, "run_trimmed");
1480
1481        // `--keep N` is the user trimming history, not asking for a reset, and
1482        // a retention pass runs on its own: either taking the evidence would
1483        // undo the keeping within one run.
1484        clean_storage(
1485            &root,
1486            CleanupOptions {
1487                keep: 20,
1488                dry_run: false,
1489            },
1490            "now",
1491        )
1492        .unwrap();
1493        let retention = cleanup_storage_locked(
1494            &root,
1495            CleanupOptions {
1496                keep: 0,
1497                dry_run: false,
1498            },
1499            false,
1500        )
1501        .unwrap();
1502        sweep_trash(&root).unwrap();
1503
1504        assert!(retention.removed_failed_evidence.is_empty());
1505        assert!(kept.join("main.jsonl").exists());
1506        fs::remove_dir_all(root).unwrap();
1507    }
1508
1509    #[test]
1510    fn cleaning_everything_reclaims_kept_evidence_and_says_which() {
1511        let root = project();
1512        let kept = kept_evidence(&root, "run_cleaned");
1513
1514        let preview = clean_storage(
1515            &root,
1516            CleanupOptions {
1517                keep: 0,
1518                dry_run: true,
1519            },
1520            "now",
1521        )
1522        .unwrap();
1523        assert_eq!(
1524            preview.removed_failed_evidence,
1525            vec!["run_cleaned".to_string()]
1526        );
1527        assert!(kept.join("main.jsonl").exists(), "a preview removed it");
1528
1529        let result = clean_storage(
1530            &root,
1531            CleanupOptions {
1532                keep: 0,
1533                dry_run: false,
1534            },
1535            "now",
1536        )
1537        .unwrap();
1538        sweep_trash(&root).unwrap();
1539
1540        // Removing it is irreversible and it is not a run, so the summary has
1541        // to name it rather than fold it into the run count.
1542        assert_eq!(
1543            result.removed_failed_evidence,
1544            vec!["run_cleaned".to_string()]
1545        );
1546        assert!(!kept.exists());
1547        fs::remove_dir_all(root).unwrap();
1548    }
1549}