Skip to main content

supercov_engine/
lifecycle.rs

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