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 WORKSPACE_MARKER: &str = ".supercov-workspace-store";
23const INCOMPLETE_LOCK_GRACE: Duration = Duration::from_secs(30);
24static UNIQUE: AtomicU64 = AtomicU64::new(0);
25
26#[derive(Debug)]
27pub enum LifecycleError {
28    Io { path: PathBuf, source: io::Error },
29    InvalidRunId(String),
30    UnsafePath(PathBuf),
31    InvalidState(String),
32    ActiveRun { run_id: String, pid: u32 },
33    LockAcquiring,
34    LockUnavailable,
35    PublicationExists(String),
36    Metadata(serde_json::Error),
37    EvidenceLength { expected: u64, actual: u64 },
38    EvidenceChanged,
39}
40
41impl std::fmt::Display for LifecycleError {
42    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
45            Self::InvalidRunId(id) => write!(formatter, "invalid coverage run ID: {id}"),
46            Self::UnsafePath(path) => {
47                write!(
48                    formatter,
49                    "unsafe Supercov storage path: {}",
50                    path.display()
51                )
52            }
53            Self::InvalidState(reason) => write!(formatter, "invalid run state: {reason}"),
54            Self::ActiveRun { run_id, pid } => write!(
55                formatter,
56                "coverage run {run_id} is already active in this project (pid {pid})"
57            ),
58            Self::LockAcquiring => {
59                write!(
60                    formatter,
61                    "a coverage run is currently acquiring the project lock"
62                )
63            }
64            Self::LockUnavailable => {
65                write!(formatter, "could not acquire the Supercov project lock")
66            }
67            Self::PublicationExists(id) => write!(formatter, "coverage run already exists: {id}"),
68            Self::Metadata(error) => write!(formatter, "invalid run metadata: {error}"),
69            Self::EvidenceLength { expected, actual } => write!(
70                formatter,
71                "evidence length changed before publication: expected {expected}, got {actual}"
72            ),
73            Self::EvidenceChanged => write!(formatter, "evidence changed during publication"),
74        }
75    }
76}
77
78impl std::error::Error for LifecycleError {}
79
80fn io_error(path: &Path, source: io::Error) -> LifecycleError {
81    LifecycleError::Io {
82        path: path.to_owned(),
83        source,
84    }
85}
86
87fn checked_id(id: &str) -> Result<(), LifecycleError> {
88    valid_run_id(id)
89        .then_some(())
90        .ok_or_else(|| LifecycleError::InvalidRunId(id.into()))
91}
92
93fn absolute_root(root: &Path) -> Result<PathBuf, LifecycleError> {
94    if root.is_absolute() {
95        Ok(root.to_owned())
96    } else {
97        std::env::current_dir()
98            .map(|cwd| cwd.join(root))
99            .map_err(|source| io_error(root, source))
100    }
101}
102
103fn lexical_descendant(root: &Path, path: &Path) -> bool {
104    let Ok(local) = path.strip_prefix(root) else {
105        return false;
106    };
107    !local.as_os_str().is_empty()
108        && local
109            .components()
110            .all(|component| matches!(component, Component::Normal(_)))
111}
112
113fn reject_linked_ancestors(
114    root: &Path,
115    path: &Path,
116    include_leaf: bool,
117) -> Result<(), LifecycleError> {
118    let local = path
119        .strip_prefix(root)
120        .map_err(|_| LifecycleError::UnsafePath(path.into()))?;
121    let components = local.components().collect::<Vec<_>>();
122    let through = if include_leaf {
123        components.len()
124    } else {
125        components.len().saturating_sub(1)
126    };
127    let mut current = root.to_owned();
128    for component in components.into_iter().take(through) {
129        let Component::Normal(component) = component else {
130            return Err(LifecycleError::UnsafePath(path.into()));
131        };
132        current.push(component);
133        match fs::symlink_metadata(&current) {
134            Ok(metadata) if metadata.file_type().is_symlink() => {
135                return Err(LifecycleError::UnsafePath(current));
136            }
137            Ok(metadata) if !metadata.file_type().is_dir() => {
138                return Err(LifecycleError::UnsafePath(current));
139            }
140            Ok(_) => {}
141            Err(error) if error.kind() == io::ErrorKind::NotFound => break,
142            Err(source) => return Err(io_error(&current, source)),
143        }
144    }
145    Ok(())
146}
147
148fn workspace_container(root: &Path) -> PathBuf {
149    root.join("supercov")
150}
151
152fn owned_workspace_container(root: &Path) -> bool {
153    let container = workspace_container(root);
154    fs::symlink_metadata(&container).is_ok_and(|metadata| metadata.file_type().is_dir())
155        && fs::symlink_metadata(container.join(WORKSPACE_MARKER))
156            .is_ok_and(|metadata| metadata.file_type().is_file())
157}
158
159fn unique_name() -> String {
160    let nanos = SystemTime::now()
161        .duration_since(UNIX_EPOCH)
162        .unwrap_or_default()
163        .as_nanos();
164    format!(
165        "{}-{nanos}-{}",
166        std::process::id(),
167        UNIQUE.fetch_add(1, Ordering::Relaxed)
168    )
169}
170
171pub(crate) fn sync_directory(path: &Path) -> Result<(), LifecycleError> {
172    #[cfg(unix)]
173    File::open(path)
174        .and_then(|file| file.sync_all())
175        .map_err(|source| io_error(path, source))?;
176    #[cfg(not(unix))]
177    let _ = path;
178    Ok(())
179}
180
181pub(crate) fn atomic_rename(source: &Path, destination: &Path) -> Result<(), LifecycleError> {
182    let source_parent = source
183        .parent()
184        .ok_or_else(|| LifecycleError::UnsafePath(source.into()))?;
185    let destination_parent = destination
186        .parent()
187        .ok_or_else(|| LifecycleError::UnsafePath(destination.into()))?;
188    fs::create_dir_all(destination_parent).map_err(|error| io_error(destination_parent, error))?;
189    fs::rename(source, destination).map_err(|error| io_error(destination, error))?;
190    sync_directory(destination_parent)?;
191    if source_parent != destination_parent {
192        sync_directory(source_parent)?;
193    }
194    Ok(())
195}
196
197fn atomic_write(root: &Path, path: &Path, bytes: &[u8]) -> Result<(), LifecycleError> {
198    let parent = path
199        .parent()
200        .ok_or_else(|| LifecycleError::UnsafePath(path.into()))?;
201    reject_linked_ancestors(root, parent, true)?;
202    fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
203    for _ in 0..16 {
204        let temporary = parent.join(format!(
205            ".{}.{}.tmp",
206            path.file_name()
207                .and_then(|value| value.to_str())
208                .unwrap_or("state"),
209            unique_name()
210        ));
211        let mut file = match OpenOptions::new()
212            .write(true)
213            .create_new(true)
214            .open(&temporary)
215        {
216            Ok(file) => file,
217            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
218            Err(source) => return Err(io_error(&temporary, source)),
219        };
220        if let Err(source) = file.write_all(bytes).and_then(|_| file.sync_all()) {
221            let _ = fs::remove_file(&temporary);
222            return Err(io_error(&temporary, source));
223        }
224        drop(file);
225        if let Err(source) = fs::rename(&temporary, path) {
226            let _ = fs::remove_file(&temporary);
227            return Err(io_error(path, source));
228        }
229        sync_directory(parent)?;
230        return Ok(());
231    }
232    Err(LifecycleError::LockUnavailable)
233}
234
235pub fn remove_stored_tree_deferred(
236    project_root: &Path,
237    target: &Path,
238) -> Result<Option<PathBuf>, LifecycleError> {
239    let root = absolute_root(project_root)?;
240    let target = if target.is_absolute() {
241        target.to_owned()
242    } else {
243        root.join(target)
244    };
245    match fs::symlink_metadata(&target) {
246        Ok(_) => {}
247        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
248        Err(source) => return Err(io_error(&target, source)),
249    }
250    let store = root.join(".supercov");
251    let trash = root.join(TRASH);
252    let in_store = lexical_descendant(&store, &target) && !lexical_descendant(&trash, &target);
253    let container = workspace_container(&root);
254    let in_workspace = owned_workspace_container(&root)
255        && (target == container || lexical_descendant(&container, &target));
256    if !in_store && !in_workspace {
257        return Err(LifecycleError::UnsafePath(target));
258    }
259    reject_linked_ancestors(if in_store { &store } else { &container }, &target, false)?;
260    reject_linked_ancestors(&root, &trash, true)?;
261    fs::create_dir_all(&trash).map_err(|source| io_error(&trash, source))?;
262    let destination = trash.join(unique_name());
263    atomic_rename(&target, &destination)?;
264    Ok(Some(destination))
265}
266
267/// Retryable trash unlinking. Public commands execute this in a child.
268pub fn sweep_trash(project_root: &Path) -> Result<usize, LifecycleError> {
269    let root = absolute_root(project_root)?;
270    let trash = root.join(TRASH);
271    reject_linked_ancestors(&root, &trash, true)?;
272    let entries = match fs::read_dir(&trash) {
273        Ok(entries) => entries,
274        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
275        Err(source) => return Err(io_error(&trash, source)),
276    };
277    let Some(_lock) = TrashLock::acquire(&trash)? else {
278        return Ok(0);
279    };
280    let mut removed = 0;
281    for entry in entries {
282        let entry = entry.map_err(|source| io_error(&trash, source))?;
283        let path = entry.path();
284        if entry.file_name() == ".deleter.lock" {
285            continue;
286        }
287        let metadata = fs::symlink_metadata(&path).map_err(|source| io_error(&path, source))?;
288        if metadata.file_type().is_dir() {
289            fs::remove_dir_all(&path).map_err(|source| io_error(&path, source))?;
290        } else {
291            fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
292        }
293        removed += 1;
294    }
295    Ok(removed)
296}
297
298struct TrashLock {
299    path: PathBuf,
300}
301
302impl TrashLock {
303    fn acquire(trash: &Path) -> Result<Option<Self>, LifecycleError> {
304        let path = trash.join(".deleter.lock");
305        for _ in 0..2 {
306            match OpenOptions::new().write(true).create_new(true).open(&path) {
307                Ok(mut file) => {
308                    write!(file, "{}", std::process::id())
309                        .and_then(|_| file.sync_all())
310                        .map_err(|source| io_error(&path, source))?;
311                    return Ok(Some(Self { path }));
312                }
313                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
314                    let owner = fs::read_to_string(&path)
315                        .ok()
316                        .and_then(|value| value.parse::<u32>().ok());
317                    if owner.is_some_and(process_exists) {
318                        return Ok(None);
319                    }
320                    if owner.is_none() {
321                        let age = fs::metadata(&path)
322                            .and_then(|metadata| metadata.modified())
323                            .ok()
324                            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
325                            .unwrap_or_default();
326                        if age < INCOMPLETE_LOCK_GRACE {
327                            return Ok(None);
328                        }
329                    }
330                    fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
331                }
332                Err(source) => return Err(io_error(&path, source)),
333            }
334        }
335        Ok(None)
336    }
337}
338
339impl Drop for TrashLock {
340    fn drop(&mut self) {
341        let _ = fs::remove_file(&self.path);
342    }
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "lowercase")]
347pub enum RunStateStatus {
348    Preparing,
349    Building,
350    Testing,
351    Publishing,
352    Complete,
353    Failed,
354    Interrupted,
355    Abandoned,
356}
357
358impl RunStateStatus {
359    pub fn terminal(self) -> bool {
360        matches!(
361            self,
362            Self::Complete | Self::Failed | Self::Interrupted | Self::Abandoned
363        )
364    }
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368#[serde(rename_all = "camelCase", deny_unknown_fields)]
369pub struct RunState {
370    pub id: String,
371    pub pid: u32,
372    pub root: String,
373    pub workspace: String,
374    pub started_at: String,
375    pub updated_at: String,
376    pub status: RunStateStatus,
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub signal: Option<String>,
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub error: Option<String>,
381}
382
383fn state_path(root: &Path, id: &str) -> PathBuf {
384    root.join(".supercov/work").join(id).join("state.json")
385}
386
387pub fn write_run_state(root: &Path, state: &RunState) -> Result<(), LifecycleError> {
388    checked_id(&state.id)?;
389    let mut bytes = serde_json::to_vec_pretty(state).map_err(LifecycleError::Metadata)?;
390    bytes.push(b'\n');
391    atomic_write(root, &state_path(root, &state.id), &bytes)
392}
393
394fn read_state(root: &Path, id: &str) -> Result<Option<RunState>, LifecycleError> {
395    checked_id(id)?;
396    let path = state_path(root, id);
397    let bytes = match fs::read(&path) {
398        Ok(bytes) => bytes,
399        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
400        Err(source) => return Err(io_error(&path, source)),
401    };
402    serde_json::from_slice(&bytes)
403        .map(Some)
404        .map_err(|error| LifecycleError::InvalidState(error.to_string()))
405}
406
407pub fn update_run_state(
408    root: &Path,
409    id: &str,
410    status: RunStateStatus,
411    updated_at: &str,
412    error: Option<String>,
413) -> Result<RunState, LifecycleError> {
414    let mut state = read_state(root, id)?
415        .ok_or_else(|| LifecycleError::InvalidState(format!("state is missing for {id}")))?;
416    state.status = status;
417    state.updated_at = updated_at.into();
418    state.error = error;
419    write_run_state(root, &state)?;
420    Ok(state)
421}
422
423pub fn interrupt_run_state(
424    root: &Path,
425    id: &str,
426    updated_at: &str,
427    signal: &str,
428) -> Result<RunState, LifecycleError> {
429    let mut state = read_state(root, id)?
430        .ok_or_else(|| LifecycleError::InvalidState(format!("state is missing for {id}")))?;
431    state.status = RunStateStatus::Interrupted;
432    state.updated_at = updated_at.into();
433    state.signal = Some(signal.into());
434    state.error = Some(format!("Interrupted by {signal}"));
435    write_run_state(root, &state)?;
436    Ok(state)
437}
438
439#[cfg(unix)]
440fn process_exists(pid: u32) -> bool {
441    if pid == 0 || pid > libc::pid_t::MAX as u32 {
442        return false;
443    }
444    // SAFETY: signal zero only checks existence/permission.
445    let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
446    result == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
447}
448
449#[cfg(not(unix))]
450fn process_exists(pid: u32) -> bool {
451    // Replaced by the Windows Job-object strategy before Windows GA.
452    pid == std::process::id()
453}
454
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456#[serde(rename_all = "camelCase", deny_unknown_fields)]
457struct LockOwner {
458    run_id: String,
459    pid: u32,
460    started_at: String,
461}
462
463pub struct ProjectLock {
464    root: PathBuf,
465    path: PathBuf,
466    owner: LockOwner,
467    released: bool,
468}
469
470impl ProjectLock {
471    pub fn acquire(root: &Path, run_id: &str, started_at: &str) -> Result<Self, LifecycleError> {
472        checked_id(run_id)?;
473        let path = root.join(".supercov/locks/active.json");
474        let parent = path.parent().expect("lock parent");
475        reject_linked_ancestors(root, parent, true)?;
476        fs::create_dir_all(parent).map_err(|source| io_error(parent, source))?;
477        let owner = LockOwner {
478            run_id: run_id.into(),
479            pid: std::process::id(),
480            started_at: started_at.into(),
481        };
482        let mut payload = serde_json::to_vec_pretty(&owner).map_err(LifecycleError::Metadata)?;
483        payload.push(b'\n');
484        for _ in 0..2 {
485            match OpenOptions::new().write(true).create_new(true).open(&path) {
486                Ok(mut file) => {
487                    file.write_all(&payload)
488                        .and_then(|_| file.sync_all())
489                        .map_err(|source| io_error(&path, source))?;
490                    sync_directory(parent)?;
491                    return Ok(Self {
492                        root: root.to_owned(),
493                        path,
494                        owner,
495                        released: false,
496                    });
497                }
498                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
499                    let existing = fs::read(&path)
500                        .ok()
501                        .and_then(|bytes| serde_json::from_slice::<LockOwner>(&bytes).ok());
502                    if let Some(existing) = existing {
503                        if process_exists(existing.pid) {
504                            return Err(LifecycleError::ActiveRun {
505                                run_id: existing.run_id,
506                                pid: existing.pid,
507                            });
508                        }
509                    } else {
510                        let age = fs::metadata(&path)
511                            .and_then(|metadata| metadata.modified())
512                            .ok()
513                            .and_then(|modified| SystemTime::now().duration_since(modified).ok())
514                            .unwrap_or_default();
515                        if age < INCOMPLETE_LOCK_GRACE {
516                            return Err(LifecycleError::LockAcquiring);
517                        }
518                    }
519                    fs::remove_file(&path).map_err(|source| io_error(&path, source))?;
520                }
521                Err(source) => return Err(io_error(&path, source)),
522            }
523        }
524        Err(LifecycleError::LockUnavailable)
525    }
526
527    pub fn release(&mut self) -> Result<(), LifecycleError> {
528        if self.released {
529            return Ok(());
530        }
531        self.released = true;
532        let owned = fs::read(&self.path)
533            .ok()
534            .and_then(|bytes| serde_json::from_slice::<LockOwner>(&bytes).ok())
535            .is_some_and(|owner| owner == self.owner);
536        if owned {
537            fs::remove_file(&self.path).map_err(|source| io_error(&self.path, source))?;
538        }
539        Ok(())
540    }
541
542    pub(crate) fn protects(&self, root: &Path) -> bool {
543        !self.released && self.root == root
544    }
545}
546
547impl Drop for ProjectLock {
548    fn drop(&mut self) {
549        let _ = self.release();
550    }
551}
552
553fn copy_regular_file(source: &Path, destination: &Path) -> Result<u64, LifecycleError> {
554    let metadata = fs::symlink_metadata(source).map_err(|error| io_error(source, error))?;
555    if !metadata.file_type().is_file() {
556        return Err(LifecycleError::UnsafePath(source.into()));
557    }
558    let mut input = File::open(source).map_err(|error| io_error(source, error))?;
559    let mut output = OpenOptions::new()
560        .write(true)
561        .create_new(true)
562        .open(destination)
563        .map_err(|error| io_error(destination, error))?;
564    let copied = io::copy(&mut input, &mut output).map_err(|error| io_error(destination, error))?;
565    output
566        .sync_all()
567        .map_err(|error| io_error(destination, error))?;
568    Ok(copied)
569}
570
571fn file_sha256(path: &Path) -> Result<[u8; 32], LifecycleError> {
572    let metadata = fs::symlink_metadata(path).map_err(|source| io_error(path, source))?;
573    if !metadata.file_type().is_file() {
574        return Err(LifecycleError::UnsafePath(path.into()));
575    }
576    let mut file = File::open(path).map_err(|source| io_error(path, source))?;
577    let mut hash = Sha256::new();
578    let mut buffer = [0_u8; 128 * 1024];
579    loop {
580        let read = file
581            .read(&mut buffer)
582            .map_err(|source| io_error(path, source))?;
583        if read == 0 {
584            break;
585        }
586        hash.update(&buffer[..read]);
587    }
588    Ok(hash.finalize().into())
589}
590
591/// Publish both immutable run files with one final directory rename.
592pub fn publish_run(
593    root: &Path,
594    metadata: &RunMetadata,
595    evidence_source: &Path,
596) -> Result<PathBuf, LifecycleError> {
597    checked_id(&metadata.id)?;
598    let destination = root.join(".supercov/runs").join(&metadata.id);
599    reject_linked_ancestors(root, &destination, false)?;
600    if fs::symlink_metadata(&destination).is_ok() {
601        return Err(LifecycleError::PublicationExists(metadata.id.clone()));
602    }
603    let staging = root
604        .join(".supercov/work")
605        .join(&metadata.id)
606        .join("run-publication");
607    reject_linked_ancestors(root, &staging, true)?;
608    if fs::symlink_metadata(&staging).is_ok() {
609        remove_stored_tree_deferred(root, &staging)?;
610    }
611    let evidence_sha256 = file_sha256(evidence_source)?;
612    fs::create_dir_all(&staging).map_err(|source| io_error(&staging, source))?;
613    let copied = copy_regular_file(evidence_source, &staging.join("evidence.raw.gz"))?;
614    if copied != metadata.raw_evidence.compressed_bytes {
615        remove_stored_tree_deferred(root, &staging)?;
616        return Err(LifecycleError::EvidenceLength {
617            expected: metadata.raw_evidence.compressed_bytes,
618            actual: copied,
619        });
620    }
621    if file_sha256(evidence_source)? != evidence_sha256
622        || file_sha256(&staging.join("evidence.raw.gz"))? != evidence_sha256
623    {
624        remove_stored_tree_deferred(root, &staging)?;
625        return Err(LifecycleError::EvidenceChanged);
626    }
627    let mut json = serde_json::to_vec_pretty(metadata).map_err(LifecycleError::Metadata)?;
628    json.push(b'\n');
629    atomic_write(root, &staging.join("run.json"), &json)?;
630    sync_directory(&staging)?;
631    let runs = destination.parent().expect("runs parent");
632    fs::create_dir_all(runs).map_err(|source| io_error(runs, source))?;
633    fs::rename(&staging, &destination).map_err(|source| io_error(&destination, source))?;
634    sync_directory(runs)?;
635    Ok(destination)
636}
637
638fn published_run(root: &Path, id: &str) -> bool {
639    let directory = root.join(".supercov/runs").join(id);
640    let metadata = fs::read(directory.join("run.json"))
641        .ok()
642        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
643    metadata
644        .as_ref()
645        .and_then(|value| value.get("id"))
646        .and_then(|value| value.as_str())
647        == Some(id)
648        && fs::symlink_metadata(directory.join("evidence.raw.gz"))
649            .is_ok_and(|metadata| metadata.file_type().is_file())
650}
651
652pub fn finalize_published_run(root: &Path, id: &str) -> Result<bool, LifecycleError> {
653    checked_id(id)?;
654    if !published_run(root, id) {
655        return Ok(false);
656    }
657    remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(id))?;
658    remove_stored_tree_deferred(root, &root.join(".supercov/work").join(id))?;
659    Ok(true)
660}
661
662fn child_directories(path: &Path) -> Result<Vec<String>, LifecycleError> {
663    let root = path
664        .ancestors()
665        .find(|ancestor| ancestor.file_name().is_some_and(|name| name == ".supercov"))
666        .and_then(Path::parent)
667        .unwrap_or(path);
668    reject_linked_ancestors(root, path, true)?;
669    let entries = match fs::read_dir(path) {
670        Ok(entries) => entries,
671        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
672        Err(source) => return Err(io_error(path, source)),
673    };
674    let mut names = Vec::new();
675    for entry in entries {
676        let entry = entry.map_err(|source| io_error(path, source))?;
677        let file_type = entry
678            .file_type()
679            .map_err(|source| io_error(&entry.path(), source))?;
680        if file_type.is_symlink() {
681            return Err(LifecycleError::UnsafePath(entry.path()));
682        }
683        if !file_type.is_dir() {
684            continue;
685        }
686        let name = entry
687            .file_name()
688            .into_string()
689            .map_err(|_| LifecycleError::UnsafePath(entry.path()))?;
690        checked_id(&name)?;
691        names.push(name);
692    }
693    names.sort();
694    Ok(names)
695}
696
697pub fn recover_abandoned_runs(
698    root: &Path,
699    updated_at: &str,
700) -> Result<Vec<String>, LifecycleError> {
701    let mut recovered = Vec::new();
702    for id in child_directories(&root.join(".supercov/work"))? {
703        let Some(state) = read_state(root, &id)? else {
704            continue;
705        };
706        if state.status.terminal() {
707            finalize_published_run(root, &id)?;
708            continue;
709        }
710        if process_exists(state.pid) {
711            continue;
712        }
713        let workspace_name = root.file_name().unwrap_or_default();
714        remove_stored_tree_deferred(
715            root,
716            &root.join(".supercov/work").join(&id).join(workspace_name),
717        )?;
718        remove_stored_tree_deferred(
719            root,
720            &root
721                .join(".supercov/work")
722                .join(&id)
723                .join("run-publication"),
724        )?;
725        if !finalize_published_run(root, &id)? {
726            remove_stored_tree_deferred(root, &root.join(".supercov/evidence").join(&id))?;
727            update_run_state(
728                root,
729                &id,
730                RunStateStatus::Abandoned,
731                updated_at,
732                Some(format!(
733                    "Recovered after process {} exited without cleanup",
734                    state.pid
735                )),
736            )?;
737        }
738        recovered.push(id);
739    }
740    recovered.sort();
741    Ok(recovered)
742}
743
744#[derive(Debug, Clone, Copy, PartialEq, Eq)]
745pub struct CleanupOptions {
746    pub keep: usize,
747    pub dry_run: bool,
748}
749
750#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
751#[serde(rename_all = "camelCase")]
752pub struct CleanupResult {
753    pub removed_runs: Vec<String>,
754    pub removed_workspaces: Vec<String>,
755    pub removed_evidence: Vec<String>,
756    pub removed_build_cache: bool,
757}
758
759pub fn cleanup_storage_locked(
760    root: &Path,
761    options: CleanupOptions,
762    remove_build_cache: bool,
763) -> Result<CleanupResult, LifecycleError> {
764    let runs_root = root.join(".supercov/runs");
765    let work_root = root.join(".supercov/work");
766    let evidence_root = root.join(".supercov/evidence");
767    let published = child_directories(&runs_root)?;
768    let work = child_directories(&work_root)?;
769    let evidence = child_directories(&evidence_root)?;
770    let mut ids = published
771        .iter()
772        .chain(&work)
773        .chain(&evidence)
774        .cloned()
775        .collect::<BTreeSet<_>>()
776        .into_iter()
777        .collect::<Vec<_>>();
778    ids.sort_by(|left, right| right.cmp(left));
779    let mut active = BTreeSet::new();
780    for id in &ids {
781        if read_state(root, id)?.is_some_and(|state| !state.status.terminal()) {
782            active.insert(id.clone());
783        }
784    }
785    let retained = published
786        .iter()
787        .rev()
788        .filter(|id| !active.contains(*id))
789        .take(options.keep)
790        .cloned()
791        .collect::<BTreeSet<_>>();
792    let mut result = CleanupResult {
793        removed_runs: Vec::new(),
794        removed_workspaces: Vec::new(),
795        removed_evidence: Vec::new(),
796        removed_build_cache: false,
797    };
798    for id in ids {
799        if active.contains(&id) {
800            continue;
801        }
802        let has_run = published.contains(&id);
803        let remove_history = has_run && !retained.contains(&id);
804        if work.contains(&id) && read_state(root, &id)?.is_none_or(|state| state.status.terminal())
805        {
806            result.removed_workspaces.push(id.clone());
807            if !options.dry_run {
808                remove_stored_tree_deferred(root, &work_root.join(&id))?;
809            }
810        }
811        if evidence.contains(&id) && (!has_run || remove_history) {
812            result.removed_evidence.push(id.clone());
813            if !options.dry_run {
814                remove_stored_tree_deferred(root, &evidence_root.join(&id))?;
815            }
816        }
817        if remove_history {
818            result.removed_runs.push(id.clone());
819            if !options.dry_run {
820                remove_stored_tree_deferred(root, &runs_root.join(&id))?;
821            }
822        }
823    }
824    let container = workspace_container(root);
825    let legacy = [root.join(".supercov/.cache"), root.join(".supercov/cache")];
826    let mut caches = Vec::new();
827    if remove_build_cache && active.is_empty() {
828        if owned_workspace_container(root) {
829            caches.push(container);
830        }
831        caches.extend(
832            legacy
833                .into_iter()
834                .filter(|path| fs::symlink_metadata(path).is_ok()),
835        );
836    }
837    result.removed_build_cache = !caches.is_empty();
838    if !options.dry_run {
839        for cache in caches {
840            remove_stored_tree_deferred(root, &cache)?;
841        }
842    }
843    Ok(result)
844}
845
846fn cleanup_storage(
847    root: &Path,
848    options: CleanupOptions,
849    remove_build_cache: bool,
850    updated_at: &str,
851) -> Result<CleanupResult, LifecycleError> {
852    let operation = if remove_build_cache { "clean" } else { "prune" };
853    let lock_id = format!("{operation}-{}-{}", std::process::id(), unique_name());
854    let mut lock = ProjectLock::acquire(root, &lock_id, updated_at)?;
855    recover_abandoned_runs(root, updated_at)?;
856    let result = cleanup_storage_locked(root, options, remove_build_cache);
857    lock.release()?;
858    result
859}
860
861pub fn prune_storage(
862    root: &Path,
863    options: CleanupOptions,
864    updated_at: &str,
865) -> Result<CleanupResult, LifecycleError> {
866    cleanup_storage(root, options, false, updated_at)
867}
868
869pub fn clean_storage(
870    root: &Path,
871    options: CleanupOptions,
872    updated_at: &str,
873) -> Result<CleanupResult, LifecycleError> {
874    cleanup_storage(root, options, true, updated_at)
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880    use crate::run_store::{RawEvidenceMetadata, RunFingerprint, RunIntegrity};
881
882    fn project() -> PathBuf {
883        let root = std::env::temp_dir().join(format!("supercov-lifecycle-{}", unique_name()));
884        fs::create_dir_all(root.join("src")).unwrap();
885        fs::write(root.join("src/index.js"), "user source").unwrap();
886        root
887    }
888
889    fn state(root: &Path, id: &str, status: RunStateStatus, pid: u32) -> RunState {
890        RunState {
891            id: id.into(),
892            pid,
893            root: root.display().to_string(),
894            workspace: root.join("dist").display().to_string(),
895            started_at: "start".into(),
896            updated_at: "update".into(),
897            status,
898            signal: None,
899            error: None,
900        }
901    }
902
903    fn metadata(id: &str, bytes: u64) -> RunMetadata {
904        RunMetadata {
905            id: id.into(),
906            started_at: "2026-01-01T00:00:00Z".into(),
907            duration_ms: 1.0,
908            command: vec!["test".into()],
909            test_exit_code: Some(0),
910            integrity: RunIntegrity {
911                schema_version: 2,
912                instrumenter_version: "rust".into(),
913                git: None,
914                fingerprint: RunFingerprint {
915                    algorithm: "sha256".into(),
916                    source: "0".repeat(64),
917                    tests: "0".repeat(64),
918                    dependencies: "0".repeat(64),
919                    configuration: "0".repeat(64),
920                    instrumenter: "0".repeat(64),
921                    execution: "0".repeat(64),
922                    combined: "0".repeat(64),
923                    source_files: 1,
924                    test_files: 1,
925                },
926                stale: None,
927                stale_reasons: None,
928            },
929            raw_evidence: RawEvidenceMetadata {
930                schema_version: 2,
931                format: "supercov-evidence-archive".into(),
932                file: "evidence.raw.gz".into(),
933                files: 1,
934                uncompressed_bytes: bytes,
935                compressed_bytes: bytes,
936            },
937            isolated_build: None,
938            instrumented_build_cache: None,
939            timings: None,
940            merged: None,
941            parents: None,
942        }
943    }
944
945    #[test]
946    fn defers_only_owned_storage_and_sweeps_without_touching_source() {
947        let root = project();
948        let owned = root.join(".supercov/evidence/run");
949        fs::create_dir_all(&owned).unwrap();
950        fs::write(owned.join("hit"), "hit").unwrap();
951        let trash = remove_stored_tree_deferred(&root, &owned).unwrap().unwrap();
952        assert!(!owned.exists());
953        assert!(trash.exists());
954        assert!(matches!(
955            remove_stored_tree_deferred(&root, &root.join("src")),
956            Err(LifecycleError::UnsafePath(_))
957        ));
958        assert_eq!(sweep_trash(&root).unwrap(), 1);
959        assert!(root.join("src/index.js").exists());
960        fs::remove_dir_all(root).unwrap();
961    }
962
963    #[cfg(unix)]
964    #[test]
965    fn refuses_linked_storage_ancestors_instead_of_renaming_external_data() {
966        use std::os::unix::fs::symlink;
967
968        let root = project();
969        let outside = project();
970        fs::create_dir_all(root.join(".supercov")).unwrap();
971        fs::create_dir_all(outside.join("run")).unwrap();
972        fs::write(outside.join("run/user.txt"), "user").unwrap();
973        symlink(&outside, root.join(".supercov/evidence")).unwrap();
974        assert!(matches!(
975            remove_stored_tree_deferred(&root, &root.join(".supercov/evidence/run")),
976            Err(LifecycleError::UnsafePath(_))
977        ));
978        assert_eq!(
979            fs::read_to_string(outside.join("run/user.txt")).unwrap(),
980            "user"
981        );
982        fs::remove_dir_all(root).unwrap();
983        fs::remove_dir_all(outside).unwrap();
984    }
985
986    #[test]
987    fn publishes_both_required_files_with_one_visible_rename() {
988        let root = project();
989        let id = "2026-01-01T00-00-00-000Z";
990        let evidence = root.join("evidence.gz");
991        fs::write(&evidence, b"evidence").unwrap();
992        let published = publish_run(&root, &metadata(id, 8), &evidence).unwrap();
993        assert!(published.join("run.json").is_file());
994        assert_eq!(
995            fs::read(published.join("evidence.raw.gz")).unwrap(),
996            b"evidence"
997        );
998        assert!(matches!(
999            publish_run(&root, &metadata(id, 8), &evidence),
1000            Err(LifecycleError::PublicationExists(_))
1001        ));
1002        fs::remove_dir_all(root).unwrap();
1003    }
1004
1005    #[test]
1006    fn recovers_dead_unpublished_and_fully_published_runs_from_derived_paths() {
1007        let root = project();
1008        let dead = "2026-01-01T00-00-00-000Z";
1009        let published = "2026-01-02T00-00-00-000Z";
1010        for id in [dead, published] {
1011            fs::create_dir_all(
1012                root.join(".supercov/work")
1013                    .join(id)
1014                    .join(root.file_name().unwrap()),
1015            )
1016            .unwrap();
1017            fs::create_dir_all(root.join(".supercov/evidence").join(id)).unwrap();
1018            write_run_state(&root, &state(&root, id, RunStateStatus::Testing, u32::MAX)).unwrap();
1019        }
1020        let evidence = root.join("published.gz");
1021        fs::write(&evidence, b"evidence").unwrap();
1022        publish_run(&root, &metadata(published, 8), &evidence).unwrap();
1023        assert_eq!(
1024            recover_abandoned_runs(&root, "recovered").unwrap(),
1025            [dead, published]
1026        );
1027        assert_eq!(
1028            read_state(&root, dead).unwrap().unwrap().status,
1029            RunStateStatus::Abandoned
1030        );
1031        assert!(!root.join(".supercov/work").join(published).exists());
1032        assert!(root.join(".supercov/runs").join(published).exists());
1033        assert!(root.join("src/index.js").exists());
1034        sweep_trash(&root).unwrap();
1035        fs::remove_dir_all(root).unwrap();
1036    }
1037
1038    #[test]
1039    fn retention_is_deterministic_dry_run_safe_and_preserves_active_work() {
1040        let root = project();
1041        let ids = [
1042            "2026-01-01T00-00-00-000Z",
1043            "2026-01-02T00-00-00-000Z",
1044            "2026-01-03T00-00-00-000Z",
1045        ];
1046        for id in ids {
1047            fs::create_dir_all(root.join(".supercov/runs").join(id)).unwrap();
1048            write_run_state(
1049                &root,
1050                &state(&root, id, RunStateStatus::Complete, std::process::id()),
1051            )
1052            .unwrap();
1053        }
1054        let active = "2025-12-31T00-00-00-000Z";
1055        write_run_state(
1056            &root,
1057            &state(&root, active, RunStateStatus::Testing, std::process::id()),
1058        )
1059        .unwrap();
1060        let preview = cleanup_storage_locked(
1061            &root,
1062            CleanupOptions {
1063                keep: 1,
1064                dry_run: true,
1065            },
1066            false,
1067        )
1068        .unwrap();
1069        assert_eq!(preview.removed_runs, [ids[1], ids[0]]);
1070        assert!(
1071            ids.iter()
1072                .all(|id| root.join(".supercov/runs").join(id).exists())
1073        );
1074        let result = cleanup_storage_locked(
1075            &root,
1076            CleanupOptions {
1077                keep: 1,
1078                dry_run: false,
1079            },
1080            false,
1081        )
1082        .unwrap();
1083        assert_eq!(result, preview);
1084        assert!(root.join(".supercov/work").join(active).exists());
1085        assert!(root.join(".supercov/runs").join(ids[2]).exists());
1086        sweep_trash(&root).unwrap();
1087        fs::remove_dir_all(root).unwrap();
1088    }
1089
1090    #[test]
1091    fn cleanup_is_project_locked_and_clean_alone_removes_owned_caches() {
1092        let root = project();
1093        let container = root.join("supercov");
1094        fs::create_dir_all(container.join("workspace/project")).unwrap();
1095        fs::write(container.join(WORKSPACE_MARKER), "owned").unwrap();
1096        fs::create_dir_all(root.join(".supercov/cache/legacy")).unwrap();
1097
1098        let mut active = ProjectLock::acquire(&root, "active", "start").unwrap();
1099        assert!(matches!(
1100            clean_storage(
1101                &root,
1102                CleanupOptions {
1103                    keep: 0,
1104                    dry_run: false
1105                },
1106                "now"
1107            ),
1108            Err(LifecycleError::ActiveRun { .. })
1109        ));
1110        assert!(container.exists());
1111        active.release().unwrap();
1112
1113        let pruned = prune_storage(
1114            &root,
1115            CleanupOptions {
1116                keep: 0,
1117                dry_run: false,
1118            },
1119            "now",
1120        )
1121        .unwrap();
1122        assert!(!pruned.removed_build_cache);
1123        assert!(container.exists());
1124
1125        let cleaned = clean_storage(
1126            &root,
1127            CleanupOptions {
1128                keep: 0,
1129                dry_run: false,
1130            },
1131            "now",
1132        )
1133        .unwrap();
1134        assert!(cleaned.removed_build_cache);
1135        assert!(!container.exists());
1136        assert!(!root.join(".supercov/cache").exists());
1137        sweep_trash(&root).unwrap();
1138        fs::remove_dir_all(root).unwrap();
1139    }
1140}