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