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
274static BACKGROUND_SWEEP: std::sync::Mutex<Option<std::thread::JoinHandle<()>>> =
275    std::sync::Mutex::new(None);
276
277/// Empty the trash while the command runs.
278///
279/// A run moves the workspace it replaces into the trash before the command
280/// starts, and nothing unlinked it until the command had ended: Supercov then
281/// started a sweeper, waited a tenth of a second and exited, and the tree went
282/// on being deleted for most of a second after. A command started in that
283/// time that walks the project met a directory on its way out. AVA looks for
284/// test files in every directory, `.supercov` included, and failed on
285/// `EINVAL: invalid argument, scandir '.supercov/.trash/…'`.
286///
287/// The tree is not needed from the moment it is in the trash, so it goes while
288/// the tests run.
289pub fn sweep_trash_in_background(project_root: &Path) {
290    let root = project_root.to_owned();
291    let sweep = std::thread::spawn(move || {
292        let _ = sweep_trash(&root);
293    });
294    if let Ok(mut running) = BACKGROUND_SWEEP.lock() {
295        *running = Some(sweep);
296    }
297}
298
299/// Wait for the sweep the run started, for as long as `patience`. True when
300/// there is none left running.
301pub fn finish_background_sweep(patience: Duration) -> bool {
302    let Some(sweep) = BACKGROUND_SWEEP
303        .lock()
304        .ok()
305        .and_then(|mut running| running.take())
306    else {
307        return true;
308    };
309    let deadline = std::time::Instant::now() + patience;
310    while !sweep.is_finished() {
311        if std::time::Instant::now() >= deadline {
312            return false;
313        }
314        std::thread::sleep(Duration::from_millis(5));
315    }
316    let _ = sweep.join();
317    true
318}
319
320/// Retryable trash unlinking. Public commands execute this in a child.
321pub fn sweep_trash(project_root: &Path) -> Result<usize, LifecycleError> {
322    let root = absolute_root(project_root)?;
323    let trash = root.join(TRASH);
324    reject_linked_ancestors(&root, &trash, true)?;
325    let entries = match fs::read_dir(&trash) {
326        Ok(entries) => entries,
327        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
328        Err(source) => return Err(io_error(&trash, source)),
329    };
330    let Some(_lock) = TrashLock::acquire(&trash)? else {
331        return Ok(0);
332    };
333    let mut removed = 0;
334    for entry in entries {
335        let entry = entry.map_err(|source| io_error(&trash, source))?;
336        let path = entry.path();
337        if entry.file_name() == ".deleter.lock" {
338            continue;
339        }
340        // An entry already gone -- removed by hand, or by a sweeper whose
341        // lock was replaced -- is as swept as one removed here.
342        let gone = |error: &io::Error| error.kind() == io::ErrorKind::NotFound;
343        let metadata = match fs::symlink_metadata(&path) {
344            Ok(metadata) => metadata,
345            Err(error) if gone(&error) => continue,
346            Err(source) => return Err(io_error(&path, source)),
347        };
348        let removal = if metadata.file_type().is_dir() {
349            fs::remove_dir_all(&path)
350        } else {
351            fs::remove_file(&path)
352        };
353        match removal {
354            Ok(()) => removed += 1,
355            Err(error) if gone(&error) => {}
356            Err(source) => return Err(io_error(&path, source)),
357        }
358    }
359    Ok(removed)
360}
361
362struct TrashLock {
363    path: PathBuf,
364}
365
366impl TrashLock {
367    fn acquire(trash: &Path) -> Result<Option<Self>, LifecycleError> {
368        let path = trash.join(".deleter.lock");
369        for _ in 0..2 {
370            match OpenOptions::new().write(true).create_new(true).open(&path) {
371                Ok(mut file) => {
372                    write!(file, "{}", std::process::id())
373                        .and_then(|_| file.sync_all())
374                        .map_err(|source| io_error(&path, source))?;
375                    return Ok(Some(Self { path }));
376                }
377                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
378                    let owner = fs::read_to_string(&path)
379                        .ok()
380                        .and_then(|value| value.parse::<u32>().ok());
381                    if owner.is_some_and(process_exists) {
382                        return Ok(None);
383                    }
384                    if owner.is_none() {
385                        let age = fs::metadata(&path)
386                            .and_then(|metadata| metadata.modified())
387                            .ok()
388                            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
389                            .unwrap_or_default();
390                        if age < INCOMPLETE_LOCK_GRACE {
391                            return Ok(None);
392                        }
393                    }
394                    fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
395                }
396                Err(source) => return Err(io_error(&path, source)),
397            }
398        }
399        Ok(None)
400    }
401}
402
403impl Drop for TrashLock {
404    fn drop(&mut self) {
405        let _ = fs::remove_file(&self.path);
406    }
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "lowercase")]
411pub enum RunStateStatus {
412    Preparing,
413    Building,
414    Testing,
415    Publishing,
416    Complete,
417    Failed,
418    Interrupted,
419    Abandoned,
420}
421
422impl RunStateStatus {
423    pub fn terminal(self) -> bool {
424        matches!(
425            self,
426            Self::Complete | Self::Failed | Self::Interrupted | Self::Abandoned
427        )
428    }
429}
430
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
432#[serde(rename_all = "camelCase", deny_unknown_fields)]
433pub struct RunState {
434    pub id: String,
435    pub pid: u32,
436    pub root: String,
437    pub workspace: String,
438    pub started_at: String,
439    pub updated_at: String,
440    pub status: RunStateStatus,
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub signal: Option<String>,
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub error: Option<String>,
445}
446
447fn state_path(root: &Path, id: &str) -> PathBuf {
448    root.join(".supercov/work").join(id).join("state.json")
449}
450
451pub fn write_run_state(root: &Path, state: &RunState) -> Result<(), LifecycleError> {
452    checked_id(&state.id)?;
453    let mut bytes = serde_json::to_vec_pretty(state).map_err(LifecycleError::Metadata)?;
454    bytes.push(b'\n');
455    atomic_write(root, &state_path(root, &state.id), &bytes)
456}
457
458fn read_state(root: &Path, id: &str) -> Result<Option<RunState>, LifecycleError> {
459    checked_id(id)?;
460    let path = state_path(root, id);
461    let bytes = match fs::read(&path) {
462        Ok(bytes) => bytes,
463        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
464        Err(source) => return Err(io_error(&path, source)),
465    };
466    serde_json::from_slice(&bytes)
467        .map(Some)
468        .map_err(|error| LifecycleError::InvalidState(error.to_string()))
469}
470
471pub fn update_run_state(
472    root: &Path,
473    id: &str,
474    status: RunStateStatus,
475    updated_at: &str,
476    error: Option<String>,
477) -> Result<RunState, LifecycleError> {
478    let mut state = read_state(root, id)?
479        .ok_or_else(|| LifecycleError::InvalidState(format!("state is missing for {id}")))?;
480    state.status = status;
481    state.updated_at = updated_at.into();
482    state.error = error;
483    write_run_state(root, &state)?;
484    Ok(state)
485}
486
487pub fn interrupt_run_state(
488    root: &Path,
489    id: &str,
490    updated_at: &str,
491    signal: &str,
492) -> Result<RunState, LifecycleError> {
493    let mut state = read_state(root, id)?
494        .ok_or_else(|| LifecycleError::InvalidState(format!("state is missing for {id}")))?;
495    state.status = RunStateStatus::Interrupted;
496    state.updated_at = updated_at.into();
497    state.signal = Some(signal.into());
498    state.error = Some(format!("Interrupted by {signal}"));
499    write_run_state(root, &state)?;
500    Ok(state)
501}
502
503#[cfg(unix)]
504fn process_exists(pid: u32) -> bool {
505    if pid == 0 || pid > libc::pid_t::MAX as u32 {
506        return false;
507    }
508    // SAFETY: signal zero only checks existence/permission.
509    let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
510    result == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
511}
512
513#[cfg(windows)]
514fn process_exists(pid: u32) -> bool {
515    use windows_sys::Win32::Foundation::{
516        CloseHandle, ERROR_ACCESS_DENIED, GetLastError, STILL_ACTIVE,
517    };
518    use windows_sys::Win32::System::Threading::{
519        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
520    };
521    if pid == 0 {
522        return false;
523    }
524    // SAFETY: a query-only handle, closed below; nothing is read through it
525    // but the exit code.
526    let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
527    if handle.is_null() {
528        // A process another account owns refuses the query, and exists.
529        return unsafe { GetLastError() } == ERROR_ACCESS_DENIED;
530    }
531    let mut code = 0u32;
532    // SAFETY: `handle` is open and `code` outlives the call.
533    let read = unsafe { GetExitCodeProcess(handle, &mut code) };
534    // SAFETY: opened above and not used after this.
535    unsafe { CloseHandle(handle) };
536    read != 0 && code == STILL_ACTIVE as u32
537}
538
539#[cfg(not(any(unix, windows)))]
540fn process_exists(pid: u32) -> bool {
541    pid == std::process::id()
542}
543
544#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
545#[serde(rename_all = "camelCase", deny_unknown_fields)]
546struct LockOwner {
547    run_id: String,
548    pid: u32,
549    started_at: String,
550}
551
552pub struct ProjectLock {
553    root: PathBuf,
554    path: PathBuf,
555    owner: LockOwner,
556    released: bool,
557}
558
559impl ProjectLock {
560    pub fn acquire(root: &Path, run_id: &str, started_at: &str) -> Result<Self, LifecycleError> {
561        checked_id(run_id)?;
562        let path = root.join(".supercov/locks/active.json");
563        let parent = path.parent().expect("lock parent");
564        reject_linked_ancestors(root, parent, true)?;
565        fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
566        // Direct frontends do not create an isolated workspace. Initialize
567        // the store's ignore file here as well, without replacing user rules
568        // or following a pre-existing symlink.
569        let ignore = root.join(".supercov/.gitignore");
570        match OpenOptions::new()
571            .write(true)
572            .create_new(true)
573            .open(&ignore)
574        {
575            Ok(mut file) => file
576                .write_all(b"*\n")
577                .map_err(|source| io_error(&ignore, source))?,
578            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
579            Err(source) => return Err(io_error(&ignore, source)),
580        }
581        let owner = LockOwner {
582            run_id: run_id.into(),
583            pid: std::process::id(),
584            started_at: started_at.into(),
585        };
586        let mut payload = serde_json::to_vec_pretty(&owner).map_err(LifecycleError::Metadata)?;
587        payload.push(b'\n');
588        for _ in 0..2 {
589            match OpenOptions::new().write(true).create_new(true).open(&path) {
590                Ok(mut file) => {
591                    file.write_all(&payload)
592                        .and_then(|_| file.sync_all())
593                        .map_err(|source| io_error(&path, source))?;
594                    sync_directory(parent)?;
595                    return Ok(Self {
596                        root: root.to_owned(),
597                        path,
598                        owner,
599                        released: false,
600                    });
601                }
602                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
603                    let existing = fs::read(&path)
604                        .ok()
605                        .and_then(|bytes| serde_json::from_slice::<LockOwner>(&bytes).ok());
606                    if let Some(existing) = existing {
607                        if process_exists(existing.pid) {
608                            return Err(LifecycleError::ActiveRun {
609                                run_id: existing.run_id,
610                                pid: existing.pid,
611                            });
612                        }
613                    } else {
614                        let age = fs::metadata(&path)
615                            .and_then(|metadata| metadata.modified())
616                            .ok()
617                            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
618                            .unwrap_or_default();
619                        if age < INCOMPLETE_LOCK_GRACE {
620                            return Err(LifecycleError::LockAcquiring);
621                        }
622                    }
623                    fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
624                }
625                Err(source) => return Err(io_error(&path, source)),
626            }
627        }
628        Err(LifecycleError::LockUnavailable)
629    }
630
631    pub fn release(&mut self) -> Result<(), LifecycleError> {
632        if self.released {
633            return Ok(());
634        }
635        self.released = true;
636        let owned = fs::read(&self.path)
637            .ok()
638            .and_then(|bytes| serde_json::from_slice::<LockOwner>(&bytes).ok())
639            .is_some_and(|owner| owner == self.owner);
640        if owned {
641            fs::remove_file(&self.path).map_err(|source| io_error(&self.path, source))?;
642        }
643        Ok(())
644    }
645
646    pub(crate) fn protects(&self, root: &Path) -> bool {
647        !self.released && self.root == root
648    }
649}
650
651impl Drop for ProjectLock {
652    fn drop(&mut self) {
653        let _ = self.release();
654    }
655}
656
657fn copy_regular_file(source: &Path, destination: &Path) -> Result<u64, LifecycleError> {
658    let metadata = fs::symlink_metadata(source).map_err(|error| io_error(source, error))?;
659    if !metadata.file_type().is_file() {
660        return Err(LifecycleError::UnsafePath(source.into()));
661    }
662    // A clone where the filesystem can make one (APFS, Btrfs, XFS): the
663    // archive's blocks are shared, not copied -- 150 MB on a large run --
664    // and the clone is the source as it was at that instant.
665    if reflink_copy::reflink(source, destination).is_ok() {
666        File::open(destination)
667            .and_then(|file| file.sync_all())
668            .map_err(|error| io_error(destination, error))?;
669        return fs::symlink_metadata(destination)
670            .map(|metadata| metadata.len())
671            .map_err(|error| io_error(destination, error));
672    }
673    let mut input = File::open(source).map_err(|error| io_error(source, error))?;
674    let mut output = OpenOptions::new()
675        .write(true)
676        .create_new(true)
677        .open(destination)
678        .map_err(|error| io_error(destination, error))?;
679    let copied = io::copy(&mut input, &mut output).map_err(|error| io_error(destination, error))?;
680    output
681        .sync_all()
682        .map_err(|error| io_error(destination, error))?;
683    Ok(copied)
684}
685
686fn file_sha256(path: &Path) -> Result<[u8; 32], LifecycleError> {
687    let metadata = fs::symlink_metadata(path).map_err(|source| io_error(path, source))?;
688    if !metadata.file_type().is_file() {
689        return Err(LifecycleError::UnsafePath(path.into()));
690    }
691    let mut file = File::open(path).map_err(|source| io_error(path, source))?;
692    let mut hash = Sha256::new();
693    let mut buffer = [0_u8; 128 * 1024];
694    loop {
695        let read = file
696            .read(&mut buffer)
697            .map_err(|source| io_error(path, source))?;
698        if read == 0 {
699            break;
700        }
701        hash.update(&buffer[..read]);
702    }
703    Ok(hash.finalize().into())
704}
705
706fn hex_digest(digest: &[u8; 32]) -> String {
707    use std::fmt::Write as _;
708    digest
709        .iter()
710        .fold(String::with_capacity(64), |mut text, byte| {
711            let _ = write!(text, "{byte:02x}");
712            text
713        })
714}
715
716/// Publish immutable run evidence with its source manifest cache and per-test
717/// execution record together.
718pub fn publish_run(
719    root: &Path,
720    metadata: &RunMetadata,
721    evidence_source: &Path,
722) -> Result<PathBuf, LifecycleError> {
723    publish_run_with_fault(root, metadata, evidence_source, Archived::default(), None)
724}
725
726/// What a frontend that still holds the run it archived hands publication,
727/// so it need not read the archive back: the analysis of the archived
728/// request, and the assertion inputs archived beside it. Each must be what
729/// the archive gives; a frontend that cannot say so leaves it out.
730#[derive(Default)]
731pub struct Archived {
732    pub report: Option<crate::coverage_report::CoverageReport>,
733    pub inputs: Option<crate::source_manifest::InputManifest>,
734}
735
736/// `publish_run` for a frontend that holds what it archived; see `Archived`.
737pub fn publish_archived_run(
738    root: &Path,
739    metadata: &RunMetadata,
740    evidence_source: &Path,
741    archived: Archived,
742) -> Result<PathBuf, LifecycleError> {
743    publish_run_with_fault(root, metadata, evidence_source, archived, None)
744}
745
746#[derive(Debug, Clone, Copy, PartialEq, Eq)]
747pub(crate) enum RunPublicationFault {
748    FinalRename,
749    /// The query index cannot be written at publication.
750    QueryIndexWrite,
751}
752
753pub(crate) fn publish_run_with_fault(
754    root: &Path,
755    metadata: &RunMetadata,
756    evidence_source: &Path,
757    archived: Archived,
758    fault: Option<RunPublicationFault>,
759) -> Result<PathBuf, LifecycleError> {
760    checked_id(&metadata.id)?;
761    let destination = root.join(".supercov/runs").join(&metadata.id);
762    reject_linked_ancestors(root, &destination, false)?;
763    if fs::symlink_metadata(&destination).is_ok() {
764        return Err(LifecycleError::PublicationExists(metadata.id.clone()));
765    }
766    let staging = root
767        .join(".supercov/work")
768        .join(&metadata.id)
769        .join("run-publication");
770    reject_linked_ancestors(root, &staging, true)?;
771    if fs::symlink_metadata(&staging).is_ok() {
772        remove_stored_tree_deferred(root, &staging)?;
773    }
774    let evidence_sha256 = file_sha256(evidence_source)?;
775    fs::create_dir_all(&staging).map_err(|source| io_error(&staging, source))?;
776    let copied = copy_regular_file(evidence_source, &staging.join("evidence.raw.gz"))?;
777    if copied != metadata.raw_evidence.compressed_bytes {
778        remove_stored_tree_deferred(root, &staging)?;
779        return Err(LifecycleError::EvidenceLength {
780            expected: metadata.raw_evidence.compressed_bytes,
781            actual: copied,
782        });
783    }
784    // What is published is the copy: it must be the evidence as hashed
785    // before it was taken.
786    if file_sha256(&staging.join("evidence.raw.gz"))? != evidence_sha256 {
787        remove_stored_tree_deferred(root, &staging)?;
788        return Err(LifecycleError::EvidenceChanged);
789    }
790    let mut json = serde_json::to_vec_pretty(metadata).map_err(LifecycleError::Metadata)?;
791    json.push(b'\n');
792    atomic_write(root, &staging.join("run.json"), &json)?;
793    // Everything publication derives from the evidence -- the record of what
794    // each test ran and the query index -- comes from one analysis of it, made
795    // here. Each used to analyse
796    // the archive on its own: once here, twice more in the first query after
797    // the run, and on a 3,900-test Python suite each analysis took 5 to 8
798    // seconds. Evidence that will not analyse is published as before; the
799    // first query then reports why.
800    let staged = crate::run_store::StoredRun {
801        id: metadata.id.clone(),
802        directory: staging.clone(),
803        evidence_path: staging.join("evidence.raw.gz"),
804        metadata_path: staging.join("run.json"),
805        query_index_path: staging.join(crate::run_store::RUST_QUERY_INDEX_FILE),
806        metadata: metadata.clone(),
807    };
808    let analysed = if metadata.merged == Some(true) {
809        None
810    } else if archived.report.is_some() {
811        archived.report
812    } else {
813        crate::run_store::analyze_stored_run(&staged).ok()
814    };
815    let archived_inputs = match archived
816        .inputs
817        .map(|manifest| {
818            crate::source_manifest::archived_manifest(manifest, hex_digest(&evidence_sha256))
819        })
820        .transpose()
821    {
822        Ok(inputs) => inputs,
823        Err(reason) => {
824            let _ = remove_stored_tree_deferred(root, &staging);
825            return Err(LifecycleError::InvalidState(format!(
826                "source manifest publication: {reason}"
827            )));
828        }
829    };
830    // The execution record and the query index both read the analysis and
831    // write their own files, so they are written side by side.
832    let prepared = std::thread::scope(|scope| {
833        let index = analysed.as_ref().map(|report| {
834            scope.spawn(|| {
835                // Disposable, like every query index: one that failed to
836                // write is rebuilt by the first query.
837                if fault == Some(RunPublicationFault::QueryIndexWrite)
838                    || crate::run_store::write_query_index_from(&staged, report).is_err()
839                {
840                    let _ = fs::remove_file(&staged.query_index_path);
841                }
842                // Also disposable: the report analyses the evidence when the
843                // file is missing.
844                if crate::run_store::write_statement_spans(&staged, report).is_err() {
845                    let _ = fs::remove_file(
846                        staged
847                            .directory
848                            .join(crate::run_store::STATEMENT_SPANS_FILE),
849                    );
850                }
851                if crate::run_store::write_assessment_input(&staged, report).is_err() {
852                    let _ = fs::remove_file(
853                        staged
854                            .directory
855                            .join(crate::run_store::ASSESSMENT_INPUT_FILE),
856                    );
857                }
858            })
859        });
860        let prepared = crate::source_manifest::prepare_publication_with(
861            root,
862            &staging,
863            metadata,
864            analysed.as_ref(),
865            archived_inputs,
866        );
867        if let Some(index) = index {
868            index
869                .join()
870                .unwrap_or_else(|panic| std::panic::resume_unwind(panic));
871        }
872        prepared
873    });
874    // Everything publication derives from the analysis is written. It is
875    // gigabytes of small allocations on a large run, and freeing them is
876    // nobody's wait.
877    if let Some(report) = analysed {
878        std::thread::spawn(move || drop(report));
879    }
880    if let Err(reason) = prepared {
881        let _ = remove_stored_tree_deferred(root, &staging);
882        return Err(LifecycleError::InvalidState(format!(
883            "source manifest publication: {reason}"
884        )));
885    }
886    sync_directory(&staging)?;
887    let runs = destination.parent().expect("runs parent");
888    fs::create_dir_all(runs).map_err(|source| io_error(runs, source))?;
889    if fault == Some(RunPublicationFault::FinalRename) {
890        let error = io_error(
891            &destination,
892            io::Error::new(
893                io::ErrorKind::PermissionDenied,
894                "injected final run publication rename failure",
895            ),
896        );
897        let _ = remove_stored_tree_deferred(root, &staging);
898        return Err(error);
899    }
900    fs::rename(&staging, &destination).map_err(|source| io_error(&destination, source))?;
901    sync_directory(runs)?;
902    Ok(destination)
903}
904
905/// Read a published run's start timestamp for retention ordering. Runs whose
906/// metadata is missing or unreadable sort as oldest, matching the run inventory,
907/// which leaves such directories out of its listing altogether.
908fn published_started_at(root: &Path, id: &str) -> String {
909    let directory = root.join(".supercov/runs").join(id);
910    fs::read(directory.join("run.json"))
911        .ok()
912        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
913        .as_ref()
914        .and_then(|value| value.get("startedAt"))
915        .and_then(|value| value.as_str())
916        .unwrap_or_default()
917        .to_owned()
918}
919
920fn published_run(root: &Path, id: &str) -> bool {
921    let directory = root.join(".supercov/runs").join(id);
922    let metadata = fs::read(directory.join("run.json"))
923        .ok()
924        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
925    metadata
926        .as_ref()
927        .and_then(|value| value.get("id"))
928        .and_then(|value| value.as_str())
929        == Some(id)
930        && fs::symlink_metadata(directory.join("evidence.raw.gz"))
931            .is_ok_and(|metadata| metadata.file_type().is_file())
932}
933
934pub fn finalize_published_run(root: &Path, id: &str) -> Result<bool, LifecycleError> {
935    checked_id(id)?;
936    if !published_run(root, id) {
937        return Ok(false);
938    }
939    remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(id))?;
940    remove_stored_tree_deferred(root, &root.join(".supercov/work").join(id))?;
941    Ok(true)
942}
943
944fn child_directories(path: &Path) -> Result<Vec<String>, LifecycleError> {
945    let root = path
946        .ancestors()
947        .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".supercov"))
948        .and_then(Path::parent)
949        .unwrap_or(path);
950    reject_linked_ancestors(root, path, true)?;
951    let entries = match fs::read_dir(path) {
952        Ok(entries) => entries,
953        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
954        Err(source) => return Err(io_error(path, source)),
955    };
956    let mut names = Vec::new();
957    for entry in entries {
958        let entry = entry.map_err(|source| io_error(path, source))?;
959        let file_type = entry
960            .file_type()
961            .map_err(|source| io_error(&entry.path(), source))?;
962        if file_type.is_symlink() {
963            return Err(LifecycleError::UnsafePath(entry.path()));
964        }
965        if !file_type.is_dir() {
966            continue;
967        }
968        let name = entry
969            .file_name()
970            .into_string()
971            .map_err(|_| LifecycleError::UnsafePath(entry.path()))?;
972        checked_id(&name)?;
973        names.push(name);
974    }
975    names.sort();
976    Ok(names)
977}
978
979/// Where a run's evidence is kept when Supercov could not publish it.
980const FAILED_EVIDENCE: &str = ".supercov/failed-evidence";
981
982/// Keep what a run measured when Supercov itself is what failed.
983///
984/// A suite pays for its measurement in wall clock, and the one that lost a run
985/// to a phase reported twice had spent two and three-quarter hours on it. The
986/// work directory goes when a run does not publish, and it held the only copy
987/// of the evidence: nothing was left to diagnose the failure with, and nothing
988/// to recover. The evidence is kept outside the work tree, where neither the
989/// recovery sweep nor the next run reclaims it, and the failure says where.
990///
991/// Best effort by design: a run has already failed by the time this is called,
992/// and being unable to keep the evidence must not replace the failure that
993/// matters with one about copying it.
994pub fn keep_failed_evidence(root: &Path, evidence: &Path, id: &str) -> Option<PathBuf> {
995    if !evidence.is_dir() {
996        return None;
997    }
998    let kept = root.join(FAILED_EVIDENCE).join(id);
999    fs::create_dir_all(&kept).ok()?;
1000    let mut files = 0;
1001    if copy_evidence(evidence, &kept, &mut files).is_err() || files == 0 {
1002        // Nothing worth keeping, or nothing that could be: leave no empty
1003        // directory behind to puzzle over.
1004        let _ = fs::remove_dir_all(&kept);
1005        return None;
1006    }
1007    Some(kept)
1008}
1009
1010/// Say where a failed run's evidence was kept, on the failure itself.
1011///
1012/// The user is looking at a Supercov bug at this point; the one thing they can
1013/// do about it is send the evidence, and they only will if the failure says
1014/// where it is. Returns the error unchanged when there was nothing to keep.
1015pub fn note_kept_evidence(root: &Path, evidence: &Path, id: &str, error: String) -> String {
1016    let Some(kept) = keep_failed_evidence(root, evidence, id) else {
1017        return error;
1018    };
1019    format!(
1020        "{error}\n[supercov] the tests were measured and the run could not be published; \
1021their evidence is kept at {} -- this is a Supercov bug, and that directory is what diagnoses it",
1022        kept.display()
1023    )
1024}
1025
1026fn copy_evidence(source: &Path, destination: &Path, files: &mut usize) -> std::io::Result<()> {
1027    for entry in fs::read_dir(source)? {
1028        let entry = entry?;
1029        let target = destination.join(entry.file_name());
1030        if entry.file_type()?.is_dir() {
1031            fs::create_dir_all(&target)?;
1032            copy_evidence(&entry.path(), &target, files)?;
1033        } else {
1034            fs::copy(entry.path(), &target)?;
1035            *files += 1;
1036        }
1037    }
1038    Ok(())
1039}
1040
1041pub fn recover_abandoned_runs(
1042    root: &Path,
1043    updated_at: &str,
1044) -> Result<Vec<String>, LifecycleError> {
1045    let mut recovered = Vec::new();
1046    for id in child_directories(&root.join(".supercov/work"))? {
1047        let Some(state) = read_state(root, &id)? else {
1048            continue;
1049        };
1050        if state.status.terminal() {
1051            finalize_published_run(root, &id)?;
1052            continue;
1053        }
1054        if process_exists(state.pid) {
1055            continue;
1056        }
1057        let workspace_name = root.file_name().unwrap_or_default();
1058        remove_stored_tree_deferred(
1059            root,
1060            &root.join(".supercov/work").join(&id).join(workspace_name),
1061        )?;
1062        remove_stored_tree_deferred(
1063            root,
1064            &root
1065                .join(".supercov/work")
1066                .join(&id)
1067                .join("run-publication"),
1068        )?;
1069        if !finalize_published_run(root, &id)? {
1070            remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(&id))?;
1071            update_run_state(
1072                root,
1073                &id,
1074                RunStateStatus::Abandoned,
1075                updated_at,
1076                Some(format!(
1077                    "Recovered after process {} exited without cleanup",
1078                    state.pid
1079                )),
1080            )?;
1081            remove_stored_tree_deferred(root, &root.join(".supercov/work").join(&id))?;
1082        }
1083        recovered.push(id);
1084    }
1085    recovered.sort();
1086    Ok(recovered)
1087}
1088
1089#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1090pub struct CleanupOptions {
1091    pub keep: usize,
1092    pub dry_run: bool,
1093}
1094
1095#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1096#[serde(rename_all = "camelCase")]
1097pub struct CleanupResult {
1098    pub removed_runs: Vec<String>,
1099    pub removed_workspaces: Vec<String>,
1100    pub removed_evidence: Vec<String>,
1101    /// Evidence kept from runs Supercov itself failed to publish. Retention
1102    /// leaves it alone -- it is the only copy, and the automatic path taking it
1103    /// is what made a lost run unrecoverable -- but `clean` is the user asking
1104    /// for the project's storage back, so it goes then, and the summary says so.
1105    pub removed_failed_evidence: Vec<String>,
1106    pub removed_build_cache: bool,
1107}
1108
1109pub fn cleanup_storage_locked(
1110    root: &Path,
1111    options: CleanupOptions,
1112    remove_build_cache: bool,
1113) -> Result<CleanupResult, LifecycleError> {
1114    let runs_root = root.join(".supercov/runs");
1115    let work_root = root.join(".supercov/work");
1116    let evidence_root = root.join(".supercov/evidence");
1117    let published = child_directories(&runs_root)?;
1118    let work = child_directories(&work_root)?;
1119    let evidence = child_directories(&evidence_root)?;
1120    let mut ids = published
1121        .iter()
1122        .chain(&work)
1123        .chain(&evidence)
1124        .cloned()
1125        .collect::<BTreeSet<_>>()
1126        .into_iter()
1127        .collect::<Vec<_>>();
1128    ids.sort_by(|left, right| right.cmp(left));
1129    let mut active = BTreeSet::new();
1130    for id in &ids {
1131        if read_state(root, id)?.is_some_and(|state| !state.status.terminal()) {
1132            active.insert(id.clone());
1133        }
1134    }
1135    let mut retention_order = published
1136        .iter()
1137        .filter(|id| !active.contains(*id))
1138        .map(|id| (published_started_at(root, id), id.clone()))
1139        .collect::<Vec<_>>();
1140    retention_order.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1)));
1141    let retained = retention_order
1142        .into_iter()
1143        .take(options.keep)
1144        .map(|(_, id)| id)
1145        .collect::<BTreeSet<_>>();
1146    let mut result = CleanupResult {
1147        removed_runs: Vec::new(),
1148        removed_workspaces: Vec::new(),
1149        removed_evidence: Vec::new(),
1150        removed_failed_evidence: Vec::new(),
1151        removed_build_cache: false,
1152    };
1153    for id in ids {
1154        if active.contains(&id) {
1155            continue;
1156        }
1157        let has_run = published.contains(&id);
1158        let remove_history = has_run && !retained.contains(&id);
1159        if work.contains(&id) && read_state(root, &id)?.is_none_or(|state| state.status.terminal())
1160        {
1161            result.removed_workspaces.push(id.clone());
1162            if !options.dry_run {
1163                remove_stored_tree_deferred(root, &work_root.join(&id))?;
1164            }
1165        }
1166        if evidence.contains(&id) && (!has_run || remove_history) {
1167            result.removed_evidence.push(id.clone());
1168            if !options.dry_run {
1169                remove_stored_tree_deferred(root, &evidence_root.join(&id))?;
1170            }
1171        }
1172        if remove_history {
1173            result.removed_runs.push(id.clone());
1174            if !options.dry_run {
1175                remove_stored_tree_deferred(root, &runs_root.join(&id))?;
1176            }
1177        }
1178    }
1179    // Only a full `clean` reclaims kept evidence. `--keep N` is a trim, and the
1180    // evidence of a run Supercov failed to publish is the last thing a user
1181    // trimming their history means to lose; a retention pass would take it
1182    // within one run of keeping it.
1183    if remove_build_cache && options.keep == 0 && active.is_empty() {
1184        let kept_root = root.join(FAILED_EVIDENCE);
1185        for id in child_directories(&kept_root)? {
1186            result.removed_failed_evidence.push(id.clone());
1187            if !options.dry_run {
1188                remove_stored_tree_deferred(root, &kept_root.join(&id))?;
1189            }
1190        }
1191    }
1192    let container = crate::workspace::workspace_container(root);
1193    let legacy = [root.join(".supercov/.cache"), root.join(".supercov/cache")];
1194    let mut caches = Vec::new();
1195    if remove_build_cache && active.is_empty() {
1196        if owned_workspace_container(root) {
1197            caches.push(container);
1198        }
1199        caches.extend(
1200            legacy
1201                .into_iter()
1202                .filter(|path| fs::symlink_metadata(path).is_ok()),
1203        );
1204    }
1205    result.removed_build_cache = !caches.is_empty();
1206    if !options.dry_run {
1207        for cache in caches {
1208            remove_stored_tree_deferred(root, &cache)?;
1209        }
1210    }
1211    Ok(result)
1212}
1213
1214fn cleanup_storage(
1215    root: &Path,
1216    options: CleanupOptions,
1217    remove_build_cache: bool,
1218    updated_at: &str,
1219) -> Result<CleanupResult, LifecycleError> {
1220    let operation = if remove_build_cache {
1221        "clean"
1222    } else {
1223        "retention"
1224    };
1225    let lock_id = format!("{operation}-{}-{}", std::process::id(), unique_name());
1226    let mut lock = ProjectLock::acquire(root, &lock_id, updated_at)?;
1227    recover_abandoned_runs(root, updated_at)?;
1228    let result = cleanup_storage_locked(root, options, remove_build_cache);
1229    lock.release()?;
1230    result
1231}
1232
1233pub fn clean_storage(
1234    root: &Path,
1235    options: CleanupOptions,
1236    updated_at: &str,
1237) -> Result<CleanupResult, LifecycleError> {
1238    cleanup_storage(root, options, true, updated_at)
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243    use super::*;
1244    use crate::run_store::{RawEvidenceMetadata, RunFingerprint, RunIntegrity};
1245
1246    fn project() -> PathBuf {
1247        let root = std::env::temp_dir().join(format!("supercov-lifecycle-{}", unique_name()));
1248        fs::create_dir_all(root.join("src")).unwrap();
1249        fs::write(root.join("src/index.js"), "user source").unwrap();
1250        root
1251    }
1252
1253    #[cfg(unix)]
1254    #[test]
1255    fn removing_a_stored_tree_never_follows_a_link_out_of_it() {
1256        // A report from supergateway: a worktree's node_modules linked to the
1257        // main checkout's, and the main checkout's vanished mid-run. Supercov
1258        // was not the cause (another process removed it), but its removal and
1259        // sweep must never reach through a link into what the link points at.
1260        let root = project();
1261        let outside = std::env::temp_dir().join(format!("supercov-outside-{}", unique_name()));
1262        fs::create_dir_all(outside.join("tsx/dist")).unwrap();
1263        fs::write(outside.join("tsx/dist/loader.mjs"), "the dependency").unwrap();
1264        let tree = root.join(".supercov/work/run_links");
1265        fs::create_dir_all(tree.join("mirror")).unwrap();
1266        std::os::unix::fs::symlink(&outside, tree.join("node_modules")).unwrap();
1267        // The workspace's node_modules is a real directory of per-package links.
1268        std::os::unix::fs::symlink(outside.join("tsx"), tree.join("mirror/tsx")).unwrap();
1269        // And a stored tree that is itself a link.
1270        std::os::unix::fs::symlink(&outside, root.join(".supercov/work/run_linked_root")).unwrap();
1271        for target in [tree.clone(), root.join(".supercov/work/run_linked_root")] {
1272            remove_stored_tree_deferred(&root, &target)
1273                .unwrap()
1274                .unwrap();
1275        }
1276        assert!(sweep_trash(&root).unwrap() >= 2);
1277        assert!(!tree.exists());
1278        assert_eq!(
1279            fs::read_to_string(outside.join("tsx/dist/loader.mjs")).unwrap(),
1280            "the dependency"
1281        );
1282        fs::remove_dir_all(&outside).unwrap();
1283        fs::remove_dir_all(&root).unwrap();
1284    }
1285
1286    fn kept_evidence(root: &Path, id: &str) -> PathBuf {
1287        let evidence = root.join(".supercov/work").join(id).join("python/evidence");
1288        fs::create_dir_all(&evidence).unwrap();
1289        fs::write(evidence.join("main.jsonl"), "what the run saw").unwrap();
1290        keep_failed_evidence(root, &evidence, id).expect("the evidence")
1291    }
1292
1293    fn state(root: &Path, id: &str, status: RunStateStatus, pid: u32) -> RunState {
1294        RunState {
1295            id: id.into(),
1296            pid,
1297            root: root.display().to_string(),
1298            workspace: root.join("dist").display().to_string(),
1299            started_at: "start".into(),
1300            updated_at: "update".into(),
1301            status,
1302            signal: None,
1303            error: None,
1304        }
1305    }
1306
1307    fn metadata(id: &str, bytes: u64) -> RunMetadata {
1308        RunMetadata {
1309            id: id.into(),
1310            started_at: "2026-01-01T00:00:00Z".into(),
1311            duration_ms: 1.0,
1312            command: vec!["test".into()],
1313            test_exit_code: Some(0),
1314            integrity: RunIntegrity {
1315                schema_version: 2,
1316                instrumenter_version: "rust".into(),
1317                git: None,
1318                fingerprint: RunFingerprint {
1319                    algorithm: "sha256".into(),
1320                    source: "0".repeat(64),
1321                    tests: "0".repeat(64),
1322                    dependencies: "0".repeat(64),
1323                    configuration: "0".repeat(64),
1324                    instrumenter: "0".repeat(64),
1325                    execution: "0".repeat(64),
1326                    combined: "0".repeat(64),
1327                    source_files: 1,
1328                    test_files: 1,
1329                },
1330                stale: None,
1331                stale_reasons: None,
1332            },
1333            raw_evidence: RawEvidenceMetadata {
1334                schema_version: 2,
1335                format: "supercov-evidence-archive".into(),
1336                file: "evidence.raw.gz".into(),
1337                files: 1,
1338                uncompressed_bytes: bytes,
1339                compressed_bytes: bytes,
1340            },
1341            isolated_build: None,
1342            instrumented_build_cache: None,
1343            timings: None,
1344            merged: None,
1345            parents: None,
1346            source_roots: None,
1347        }
1348    }
1349
1350    fn evidence(root: &Path) -> (PathBuf, u64) {
1351        use crate::evidence_archive::{EvidenceArchiveEntry, write_archive};
1352        let mut entries = ["manifest.json", "frontend.json", "coverage-model.json"]
1353            .into_iter()
1354            .map(|path| EvidenceArchiveEntry {
1355                path: path.into(),
1356                contents: b"{}".to_vec(),
1357            })
1358            .collect::<Vec<_>>();
1359        let inputs =
1360            crate::source_capture::capture(root, "javascript", [PathBuf::from("src/index.js")])
1361                .unwrap();
1362        entries = crate::source_capture::append(entries, &inputs).unwrap();
1363        let path = root.join("evidence.gz");
1364        let metadata = write_archive(entries, &path).unwrap();
1365        (path, metadata.compressed_bytes)
1366    }
1367
1368    #[test]
1369    fn defers_only_owned_storage_and_sweeps_without_touching_source() {
1370        let root = project();
1371        let owned = root.join(".supercov/evidence/run");
1372        fs::create_dir_all(&owned).unwrap();
1373        fs::write(owned.join("hit"), "hit").unwrap();
1374        let trash = remove_stored_tree_deferred(&root, &owned).unwrap().unwrap();
1375        assert!(!owned.exists());
1376        assert!(trash.exists());
1377        assert!(matches!(
1378            remove_stored_tree_deferred(&root, &root.join("src")),
1379            Err(LifecycleError::UnsafePath(_))
1380        ));
1381        assert_eq!(sweep_trash(&root).unwrap(), 1);
1382        assert!(root.join("src/index.js").exists());
1383        fs::remove_dir_all(root).unwrap();
1384    }
1385
1386    #[cfg(unix)]
1387    #[test]
1388    fn refuses_linked_storage_ancestors_instead_of_renaming_external_data() {
1389        use std::os::unix::fs::symlink;
1390
1391        let root = project();
1392        let outside = project();
1393        fs::create_dir_all(root.join(".supercov")).unwrap();
1394        fs::create_dir_all(outside.join("run")).unwrap();
1395        fs::write(outside.join("run/user.txt"), "user").unwrap();
1396        symlink(&outside, root.join(".supercov/evidence")).unwrap();
1397        assert!(matches!(
1398            remove_stored_tree_deferred(&root, &root.join(".supercov/evidence/run")),
1399            Err(LifecycleError::UnsafePath(_))
1400        ));
1401        assert_eq!(
1402            fs::read_to_string(outside.join("run/user.txt")).unwrap(),
1403            "user"
1404        );
1405        fs::remove_dir_all(root).unwrap();
1406        fs::remove_dir_all(outside).unwrap();
1407    }
1408
1409    /// Evidence that analyses -- the analysable fixture run -- to publish as
1410    /// `id`, from outside the project's runs.
1411    fn analysable_evidence(root: &Path, id: &str) -> (PathBuf, RunMetadata) {
1412        let source = project();
1413        let directory = crate::run_store::create_analyzable_test_run(&source, "source");
1414        let evidence = root.join(format!("{id}.evidence.gz"));
1415        fs::copy(directory.join("evidence.raw.gz"), &evidence).unwrap();
1416        let mut metadata: RunMetadata =
1417            serde_json::from_slice(&fs::read(directory.join("run.json")).unwrap()).unwrap();
1418        metadata.id = id.into();
1419        fs::remove_dir_all(source).unwrap();
1420        (evidence, metadata)
1421    }
1422
1423    /// The published run as a query reads it.
1424    fn stored(root: &Path, id: &str) -> crate::run_store::StoredRun {
1425        let directory = root.join(".supercov/runs").join(id);
1426        crate::run_store::StoredRun {
1427            id: id.into(),
1428            evidence_path: directory.join("evidence.raw.gz"),
1429            metadata_path: directory.join("run.json"),
1430            query_index_path: directory.join(crate::run_store::RUST_QUERY_INDEX_FILE),
1431            metadata: serde_json::from_slice(&fs::read(directory.join("run.json")).unwrap())
1432                .unwrap(),
1433            directory,
1434        }
1435    }
1436
1437    #[test]
1438    fn publication_writes_the_query_index_a_first_query_would_have_built() {
1439        // Written beside staged evidence and renamed with it, the index must be
1440        // the one the first query builds from the published evidence -- the
1441        // same bytes, valid under the published run's identity.
1442        let root = project();
1443        let id = "2026-01-01T00-00-00-100Z";
1444        let (evidence, metadata) = analysable_evidence(&root, id);
1445        let published = publish_run(&root, &metadata, &evidence).unwrap();
1446        let index = published.join(crate::run_store::RUST_QUERY_INDEX_FILE);
1447        assert!(index.is_file(), "publication wrote the index");
1448        let run = stored(&root, id);
1449        assert!(
1450            crate::run_store::open_existing_query_index(&run)
1451                .unwrap()
1452                .is_some(),
1453            "and it is valid for the published run"
1454        );
1455        // The report's statement spans come from the same analysis, and are
1456        // what analysing the published evidence again would give.
1457        let analysed = crate::source_manifest::coverage(&run).unwrap();
1458        assert_eq!(
1459            crate::run_store::read_statement_spans(&run).expect("publication wrote the spans"),
1460            crate::run_store::statement_spans(&analysed)
1461        );
1462        // So is what an assessment reads.
1463        assert_eq!(
1464            crate::run_store::read_assessment_input(&run).expect("publication wrote the input"),
1465            crate::assertion_coverage::assessment_input(&analysed)
1466        );
1467        let written = fs::read(&index).unwrap();
1468        fs::remove_file(&index).unwrap();
1469        crate::run_store::open_or_rebuild_query_index(&run).unwrap();
1470        assert_eq!(
1471            fs::read(&index).unwrap(),
1472            written,
1473            "a query rebuilds the same bytes"
1474        );
1475        fs::remove_dir_all(root).unwrap();
1476    }
1477
1478    #[test]
1479    fn evidence_that_will_not_analyse_is_published_as_it_was_before() {
1480        // Publication used to tolerate evidence it could not analyse and leave
1481        // the first query to report why. It still publishes it, writes neither
1482        // index nor summary, and the first query fails as it did.
1483        let root = project();
1484        let id = "2026-01-01T00-00-00-200Z";
1485        let (evidence, bytes) = evidence(&root);
1486        let published = publish_run(&root, &metadata(id, bytes), &evidence).unwrap();
1487        assert!(published.join("run.json").is_file());
1488        assert!(published.join("source-manifest.cache.json").is_file());
1489        assert!(
1490            !published
1491                .join(crate::run_store::RUST_QUERY_INDEX_FILE)
1492                .exists()
1493        );
1494        assert!(
1495            !published
1496                .join(crate::source_manifest::EXECUTIONS_FILE)
1497                .exists()
1498        );
1499        let run = stored(&root, id);
1500        let Err(query) = crate::run_store::open_or_rebuild_query_index(&run) else {
1501            panic!("the first query cannot analyse it either");
1502        };
1503        let direct = crate::run_store::analyze_stored_run(&run).expect_err("unanalysable");
1504        assert_eq!(
1505            query.to_string(),
1506            direct.to_string(),
1507            "and says why in the same words"
1508        );
1509        fs::remove_dir_all(root).unwrap();
1510    }
1511
1512    #[test]
1513    fn a_merged_run_is_indexed_by_its_first_query() {
1514        // Publication does not analyse a merged run; its first query does.
1515        let root = project();
1516        let id = "2026-01-01T00-00-00-300Z";
1517        let (evidence, mut metadata) = analysable_evidence(&root, id);
1518        metadata.merged = Some(true);
1519        let published = publish_run(&root, &metadata, &evidence).unwrap();
1520        let index = published.join(crate::run_store::RUST_QUERY_INDEX_FILE);
1521        assert!(!index.exists());
1522        crate::run_store::open_or_rebuild_query_index(&stored(&root, id)).unwrap();
1523        assert!(index.is_file());
1524        fs::remove_dir_all(root).unwrap();
1525    }
1526
1527    #[test]
1528    fn an_index_that_could_not_be_written_is_built_by_the_first_query() {
1529        // A failed index write loses nothing: publication succeeds without a
1530        // partial file, and the first query builds the index as it used to.
1531        let root = project();
1532        let id = "2026-01-01T00-00-00-400Z";
1533        let (evidence, metadata) = analysable_evidence(&root, id);
1534        let published = publish_run_with_fault(
1535            &root,
1536            &metadata,
1537            &evidence,
1538            Archived::default(),
1539            Some(RunPublicationFault::QueryIndexWrite),
1540        )
1541        .unwrap();
1542        let index = published.join(crate::run_store::RUST_QUERY_INDEX_FILE);
1543        assert!(!index.exists(), "no partial index is left");
1544        assert!(published.join("run.json").is_file());
1545        crate::run_store::open_or_rebuild_query_index(&stored(&root, id)).unwrap();
1546        assert!(index.is_file());
1547        fs::remove_dir_all(root).unwrap();
1548    }
1549
1550    #[test]
1551    fn publishes_both_required_files_with_one_visible_rename() {
1552        let root = project();
1553        let id = "2026-01-01T00-00-00-000Z";
1554        let (evidence, bytes) = evidence(&root);
1555        let published = publish_run(&root, &metadata(id, bytes), &evidence).unwrap();
1556        assert!(published.join("run.json").is_file());
1557        assert_eq!(
1558            fs::read(published.join("evidence.raw.gz")).unwrap(),
1559            fs::read(&evidence).unwrap()
1560        );
1561        assert!(published.join("source-manifest.cache.json").is_file());
1562        assert!(matches!(
1563            publish_run(&root, &metadata(id, bytes), &evidence),
1564            Err(LifecycleError::PublicationExists(_))
1565        ));
1566        fs::remove_dir_all(root).unwrap();
1567    }
1568
1569    #[test]
1570    fn final_rename_failure_exposes_no_run_and_removes_staging() {
1571        let root = project();
1572        let id = "2026-01-01T00-00-00-000Z";
1573        let (evidence, bytes) = evidence(&root);
1574        let error = publish_run_with_fault(
1575            &root,
1576            &metadata(id, bytes),
1577            &evidence,
1578            Archived::default(),
1579            Some(RunPublicationFault::FinalRename),
1580        )
1581        .unwrap_err();
1582        assert!(
1583            error
1584                .to_string()
1585                .contains("injected final run publication rename failure")
1586        );
1587        assert!(!root.join(".supercov/runs").join(id).exists());
1588        assert!(
1589            !root
1590                .join(".supercov/work")
1591                .join(id)
1592                .join("run-publication")
1593                .exists()
1594        );
1595        sweep_trash(&root).unwrap();
1596        fs::remove_dir_all(root).unwrap();
1597    }
1598
1599    #[test]
1600    fn malformed_assertion_inputs_expose_no_partial_run() {
1601        use crate::evidence_archive::{read_archive, write_archive};
1602        let root = project();
1603        let id = "2026-01-01T00-00-00-000Z";
1604        let (evidence, _) = evidence(&root);
1605        let mut entries = read_archive(&evidence).unwrap();
1606        entries
1607            .iter_mut()
1608            .find(|e| e.path == crate::source_capture::ARCHIVE_PATH)
1609            .unwrap()
1610            .contents = b"{broken".to_vec();
1611        let raw = write_archive(entries, &evidence).unwrap();
1612        let error = publish_run(&root, &metadata(id, raw.compressed_bytes), &evidence).unwrap_err();
1613        assert!(error.to_string().contains("source manifest publication"));
1614        assert!(!root.join(".supercov/runs").join(id).exists());
1615        assert!(
1616            !root
1617                .join(".supercov/work")
1618                .join(id)
1619                .join("run-publication")
1620                .exists()
1621        );
1622        fs::remove_dir_all(root).unwrap();
1623    }
1624
1625    #[test]
1626    fn recovers_dead_unpublished_and_fully_published_runs_from_derived_paths() {
1627        let root = project();
1628        let dead = "2026-01-01T00-00-00-000Z";
1629        let published = "2026-01-02T00-00-00-000Z";
1630        for id in [dead, published] {
1631            fs::create_dir_all(
1632                root.join(".supercov/work")
1633                    .join(id)
1634                    .join(root.file_name().unwrap()),
1635            )
1636            .unwrap();
1637            fs::create_dir_all(root.join(".supercov/evidence").join(id)).unwrap();
1638            write_run_state(&root, &state(&root, id, RunStateStatus::Testing, u32::MAX)).unwrap();
1639        }
1640        let (evidence, bytes) = evidence(&root);
1641        publish_run(&root, &metadata(published, bytes), &evidence).unwrap();
1642        assert_eq!(
1643            recover_abandoned_runs(&root, "recovered").unwrap(),
1644            [dead, published]
1645        );
1646        assert!(!root.join(".supercov/work").join(dead).exists());
1647        assert!(!root.join(".supercov/work").join(published).exists());
1648        assert!(root.join(".supercov/runs").join(published).exists());
1649        assert!(root.join("src/index.js").exists());
1650        sweep_trash(&root).unwrap();
1651        fs::remove_dir_all(root).unwrap();
1652    }
1653
1654    #[test]
1655    fn retention_is_deterministic_dry_run_safe_and_preserves_active_work() {
1656        let root = project();
1657        let ids = [
1658            "2026-01-01T00-00-00-000Z",
1659            "2026-01-02T00-00-00-000Z",
1660            "2026-01-03T00-00-00-000Z",
1661        ];
1662        for id in ids {
1663            fs::create_dir_all(root.join(".supercov/runs").join(id)).unwrap();
1664            write_run_state(
1665                &root,
1666                &state(&root, id, RunStateStatus::Complete, std::process::id()),
1667            )
1668            .unwrap();
1669        }
1670        let active = "2025-12-31T00-00-00-000Z";
1671        write_run_state(
1672            &root,
1673            &state(&root, active, RunStateStatus::Testing, std::process::id()),
1674        )
1675        .unwrap();
1676        let preview = cleanup_storage_locked(
1677            &root,
1678            CleanupOptions {
1679                keep: 1,
1680                dry_run: true,
1681            },
1682            false,
1683        )
1684        .unwrap();
1685        assert_eq!(preview.removed_runs, [ids[1], ids[0]]);
1686        assert!(
1687            ids.iter()
1688                .all(|id| root.join(".supercov/runs").join(id).exists())
1689        );
1690        let result = cleanup_storage_locked(
1691            &root,
1692            CleanupOptions {
1693                keep: 1,
1694                dry_run: false,
1695            },
1696            false,
1697        )
1698        .unwrap();
1699        assert_eq!(result, preview);
1700        assert!(root.join(".supercov/work").join(active).exists());
1701        assert!(root.join(".supercov/runs").join(ids[2]).exists());
1702        sweep_trash(&root).unwrap();
1703        fs::remove_dir_all(root).unwrap();
1704    }
1705
1706    #[test]
1707    fn retention_keeps_the_newest_runs_when_run_ids_disagree_with_recency() {
1708        let root = project();
1709        let (evidence, bytes) = evidence(&root);
1710        // Run IDs are random hex, so ordering them as strings says nothing about
1711        // time. The newest run here carries the smallest ID and must survive.
1712        for (id, started_at) in [
1713            ("run_ffffffffffffffff", "2026-01-01T00:00:00Z"),
1714            ("run_7777777777777777", "2026-05-01T00:00:00Z"),
1715            ("run_0000000000000001", "2026-09-01T00:00:00Z"),
1716        ] {
1717            let mut run = metadata(id, bytes);
1718            run.started_at = started_at.into();
1719            publish_run(&root, &run, &evidence).unwrap();
1720        }
1721        let result = cleanup_storage_locked(
1722            &root,
1723            CleanupOptions {
1724                keep: 1,
1725                dry_run: false,
1726            },
1727            false,
1728        )
1729        .unwrap();
1730        assert_eq!(
1731            result.removed_runs,
1732            ["run_ffffffffffffffff", "run_7777777777777777"]
1733        );
1734        assert!(
1735            root.join(".supercov/runs")
1736                .join("run_0000000000000001")
1737                .exists()
1738        );
1739        sweep_trash(&root).unwrap();
1740        fs::remove_dir_all(root).unwrap();
1741    }
1742
1743    #[test]
1744    fn direct_runs_initialize_store_ignore_without_overwriting_existing_rules() {
1745        let root = project();
1746        let ignore = root.join(".supercov/.gitignore");
1747        let mut lock = ProjectLock::acquire(&root, "first", "start").unwrap();
1748        assert_eq!(fs::read_to_string(&ignore).unwrap(), "*\n");
1749        lock.release().unwrap();
1750        fs::write(&ignore, "custom\n").unwrap();
1751        let mut lock = ProjectLock::acquire(&root, "second", "start").unwrap();
1752        assert_eq!(fs::read_to_string(&ignore).unwrap(), "custom\n");
1753        lock.release().unwrap();
1754        fs::remove_dir_all(root).unwrap();
1755    }
1756
1757    #[test]
1758    fn cleanup_is_project_locked_and_clean_alone_removes_owned_caches() {
1759        let root = project();
1760        let container = root.join(".supercov/workspaces");
1761        fs::create_dir_all(container.join("workspace/project")).unwrap();
1762        fs::write(
1763            container.join(".supercov-workspace-store"),
1764            b"Supercov instrumented workspace. Safe to delete.\n",
1765        )
1766        .unwrap();
1767        fs::create_dir_all(root.join(".supercov/cache/legacy")).unwrap();
1768        let mut active = ProjectLock::acquire(&root, "active", "start").unwrap();
1769        assert!(matches!(
1770            clean_storage(
1771                &root,
1772                CleanupOptions {
1773                    keep: 0,
1774                    dry_run: false
1775                },
1776                "now"
1777            ),
1778            Err(LifecycleError::ActiveRun { .. })
1779        ));
1780        assert!(container.exists());
1781        active.release().unwrap();
1782
1783        let cleaned = clean_storage(
1784            &root,
1785            CleanupOptions {
1786                keep: 0,
1787                dry_run: false,
1788            },
1789            "now",
1790        )
1791        .unwrap();
1792        assert!(cleaned.removed_build_cache);
1793        assert!(!container.exists());
1794        assert!(!root.join(".supercov/cache").exists());
1795        sweep_trash(&root).unwrap();
1796        fs::remove_dir_all(root).unwrap();
1797    }
1798
1799    // What a failed run measured is the only record of hours of test time, and
1800    // it lived in the work tree that failing removes. #40 lost a run to a phase
1801    // reported twice after two and three-quarter hours, with nothing left to
1802    // diagnose it from.
1803    #[test]
1804    fn evidence_a_failed_run_measured_outlives_the_work_tree() {
1805        let root = project();
1806        let work = root.join(".supercov/work/run_kept");
1807        let evidence = work.join("python/evidence");
1808        fs::create_dir_all(evidence.join("worker-1")).unwrap();
1809        fs::write(evidence.join("main.jsonl"), "what the run saw").unwrap();
1810        fs::write(evidence.join("worker-1/mcdc.json"), "{}").unwrap();
1811
1812        let kept = keep_failed_evidence(&root, &evidence, "run_kept").expect("the evidence");
1813        remove_stored_tree_deferred(&root, &work).unwrap();
1814        sweep_trash(&root).unwrap();
1815
1816        assert!(!evidence.exists(), "the work tree still went");
1817        assert_eq!(
1818            fs::read_to_string(kept.join("main.jsonl")).unwrap(),
1819            "what the run saw",
1820        );
1821        // Nested workers are evidence too: a run measured by several of them
1822        // cannot be diagnosed from one.
1823        assert_eq!(
1824            fs::read_to_string(kept.join("worker-1/mcdc.json")).unwrap(),
1825            "{}"
1826        );
1827        fs::remove_dir_all(root).unwrap();
1828    }
1829
1830    #[test]
1831    fn nothing_to_keep_leaves_no_directory_to_puzzle_over() {
1832        let root = project();
1833        // A run that failed before it measured anything: the frontend never
1834        // reached the point of writing evidence, or wrote none.
1835        let missing = root.join(".supercov/work/run_early/python/evidence");
1836        assert!(keep_failed_evidence(&root, &missing, "run_early").is_none());
1837        fs::create_dir_all(&missing).unwrap();
1838        assert!(keep_failed_evidence(&root, &missing, "run_early").is_none());
1839        assert!(
1840            !root.join(".supercov/failed-evidence/run_early").exists(),
1841            "an empty directory is worse than none: it reads as evidence",
1842        );
1843        fs::remove_dir_all(root).unwrap();
1844    }
1845
1846    // The failure is the only thing the user reads. Keeping the evidence and
1847    // not saying where is the same as not keeping it.
1848    #[test]
1849    fn the_failure_says_where_the_evidence_was_kept() {
1850        let root = project();
1851        let evidence = root.join(".supercov/work/run_told/python/evidence");
1852        fs::create_dir_all(&evidence).unwrap();
1853        fs::write(evidence.join("main.jsonl"), "what the run saw").unwrap();
1854
1855        let told = note_kept_evidence(&root, &evidence, "run_told", "boom".into());
1856        // Taken from the keeper rather than spelled out here: a path written as
1857        // one string renders every separator the same way, which is not what a
1858        // Windows path built join by join looks like, so asserting on the
1859        // spelling tested this test instead of the message. Keeping is
1860        // idempotent, so asking again names the directory already kept.
1861        let kept = keep_failed_evidence(&root, &evidence, "run_told").expect("the evidence");
1862        assert_eq!(kept, root.join(FAILED_EVIDENCE).join("run_told"));
1863        assert!(
1864            told.starts_with("boom\n"),
1865            "the failure itself came second: {told}"
1866        );
1867        assert!(told.contains(&kept.display().to_string()), "{told}");
1868
1869        // Nothing kept, nothing to say: the failure that matters is not buried
1870        // under one about copying it.
1871        let nothing = root.join(".supercov/work/run_empty/python/evidence");
1872        assert_eq!(
1873            note_kept_evidence(&root, &nothing, "run_empty", "boom".into()),
1874            "boom",
1875        );
1876        fs::remove_dir_all(root).unwrap();
1877    }
1878
1879    #[test]
1880    fn a_trim_leaves_kept_evidence_alone() {
1881        let root = project();
1882        let kept = kept_evidence(&root, "run_trimmed");
1883
1884        // `--keep N` is the user trimming history, not asking for a reset, and
1885        // a retention pass runs on its own: either taking the evidence would
1886        // undo the keeping within one run.
1887        clean_storage(
1888            &root,
1889            CleanupOptions {
1890                keep: 20,
1891                dry_run: false,
1892            },
1893            "now",
1894        )
1895        .unwrap();
1896        let retention = cleanup_storage_locked(
1897            &root,
1898            CleanupOptions {
1899                keep: 0,
1900                dry_run: false,
1901            },
1902            false,
1903        )
1904        .unwrap();
1905        sweep_trash(&root).unwrap();
1906
1907        assert!(retention.removed_failed_evidence.is_empty());
1908        assert!(kept.join("main.jsonl").exists());
1909        fs::remove_dir_all(root).unwrap();
1910    }
1911
1912    #[test]
1913    fn cleaning_everything_reclaims_kept_evidence_and_says_which() {
1914        let root = project();
1915        let kept = kept_evidence(&root, "run_cleaned");
1916
1917        let preview = clean_storage(
1918            &root,
1919            CleanupOptions {
1920                keep: 0,
1921                dry_run: true,
1922            },
1923            "now",
1924        )
1925        .unwrap();
1926        assert_eq!(
1927            preview.removed_failed_evidence,
1928            vec!["run_cleaned".to_string()]
1929        );
1930        assert!(kept.join("main.jsonl").exists(), "a preview removed it");
1931
1932        let result = clean_storage(
1933            &root,
1934            CleanupOptions {
1935                keep: 0,
1936                dry_run: false,
1937            },
1938            "now",
1939        )
1940        .unwrap();
1941        sweep_trash(&root).unwrap();
1942
1943        // Removing it is irreversible and it is not a run, so the summary has
1944        // to name it rather than fold it into the run count.
1945        assert_eq!(
1946            result.removed_failed_evidence,
1947            vec!["run_cleaned".to_string()]
1948        );
1949        assert!(!kept.exists());
1950        fs::remove_dir_all(root).unwrap();
1951    }
1952}