Skip to main content

haz_cache/
clean.rs

1//! Cache invalidation per `CACHE-021` and `AUX-022..AUX-027`.
2//!
3//! Two entry points:
4//!
5//! - [`CacheWriter::clear`] is the unconditional reset behind
6//!   `haz cache clear`: it removes the entire cache root in a
7//!   single recursive call. Subsequent lookups against the same
8//!   workspace are misses until new entries are stored.
9//! - [`CacheWriter::clean`] is the composable selective reclamation
10//!   behind `haz cache clean`. It accepts a [`CleanOptions`] mode
11//!   set and applies the modes in spec-mandated priority order:
12//!     1. `soft` (`CACHE-022`): reclaim every *objectively
13//!        stale* artefact, entry directories with a missing,
14//!        unparseable, or schema-mismatched manifest plus
15//!        `.tmp-<key>-<random>` store-time directories and
16//!        `.restore-<key>-<random>` restore-time staging
17//!        directories.
18//!     2. `max_age` (`AUX-023` step 4): evict every remaining
19//!        well-formed entry whose `created_at` is strictly older
20//!        than `now_unix - max_age`.
21//!     3. `max_size` (`AUX-023` step 5): if the well-formed
22//!        survivors' footprint exceeds `max_size`, evict
23//!        oldest-`created_at`-first until the residual footprint
24//!        is at or below `max_size`.
25//!     4. `dry_run` (`AUX-024`): compute the eviction set but
26//!        make no on-disk changes.
27//!
28//! Per `AUX-024`, when more than one mode would name the same
29//! entry, the entry counts in the highest-priority mode
30//! (`soft` > `max_age` > `max_size`). [`CleanReport::evicted_entries`]
31//! carries one [`EvictedEntry`] per evicted entry, labelled with
32//! its priority mode.
33//!
34//! Both methods are idempotent on an absent cache root: calling
35//! them when `<workspace>/.haz/cache` does not exist is a no-op,
36//! not an error.
37
38use std::path::{Path, PathBuf};
39
40use haz_domain::settings::cache_clean::max_age::MaxAge;
41use haz_domain::settings::cache_clean::max_size::MaxSize;
42use haz_vfs::{EntryKind, FsError, WritableFilesystem};
43use snafu::Snafu;
44
45use crate::layout;
46use crate::manifest::{HashFunctionLabel, Manifest};
47use crate::writer::CacheWriter;
48
49/// Failure modes shared by [`CacheWriter::clear`] and [`CacheWriter::clean`].
50#[derive(Debug, Snafu)]
51pub enum CleanError {
52    /// Underlying filesystem error during the walk or removal.
53    /// The wrapped [`FsError`] carries the specific path.
54    #[snafu(display("filesystem error during cache invalidation: {source}"))]
55    Io {
56        /// The originating filesystem error.
57        source: FsError,
58    },
59}
60
61/// One best-effort failure collected during [`CacheWriter::clean`]
62/// per `AUX-028`.
63///
64/// A `clean` run records these and keeps going rather than aborting,
65/// so a single un-removable entry or unreadable shard does not
66/// strand the rest of the plan. Each variant carries the path it
67/// concerns and the originating [`FsError`].
68#[derive(Debug, Snafu)]
69pub enum CleanFailure {
70    /// A cache shard directory could not be read during planning, so
71    /// its entries were skipped.
72    #[snafu(display("failed to read cache shard at: {}: {source}", path.display()))]
73    Shard {
74        /// Shard directory that could not be read.
75        path: PathBuf,
76        /// Originating filesystem error.
77        source: FsError,
78    },
79    /// An entry's manifest could not be read (a real I/O error, not
80    /// a simply-absent manifest), so the entry was skipped.
81    #[snafu(display("failed to read entry manifest at: {}: {source}", path.display()))]
82    Manifest {
83        /// Manifest path that could not be read.
84        path: PathBuf,
85        /// Originating filesystem error.
86        source: FsError,
87    },
88    /// A planned entry directory could not be removed.
89    #[snafu(display("failed to remove cache entry at: {}: {source}", path.display()))]
90    Entry {
91        /// Entry directory that could not be removed.
92        path: PathBuf,
93        /// Originating filesystem error.
94        source: FsError,
95    },
96    /// An orphan `.tmp-` directory could not be removed.
97    #[snafu(display("failed to remove orphan tmp directory at: {}: {source}", path.display()))]
98    Tmp {
99        /// Tmp directory that could not be removed.
100        path: PathBuf,
101        /// Originating filesystem error.
102        source: FsError,
103    },
104    /// An orphan `.restore-` directory could not be removed.
105    #[snafu(display(
106        "failed to remove orphan restore directory at: {}: {source}",
107        path.display()
108    ))]
109    Restore {
110        /// Restore directory that could not be removed.
111        path: PathBuf,
112        /// Originating filesystem error.
113        source: FsError,
114    },
115}
116
117/// Outcome of [`CacheWriter::clean`]: the `AUX-024` report plus the
118/// best-effort [`CleanFailure`]s collected per `AUX-028`.
119///
120/// `failures` is empty on a fully-successful run. `report` is always
121/// present and describes what was actually reclaimed (under
122/// `--dry-run`, what would be reclaimed); a non-empty `failures`
123/// means some planned work could not be completed, and the caller
124/// surfaces those while still reporting the rest.
125#[derive(Debug, Default)]
126pub struct CleanOutcome {
127    /// The `AUX-024` report for the work that succeeded.
128    pub report: CleanReport,
129    /// Per-item failures collected best-effort per `AUX-028`.
130    pub failures: Vec<CleanFailure>,
131}
132
133/// Composable mode flags for [`CacheWriter::clean`] per `AUX-022`.
134///
135/// At least one of `soft`, `max_age`, `max_size` MUST be supplied
136/// for the call to remove anything; the CLI layer enforces the
137/// "must supply a mode" rule per `AUX-022`, so a [`CacheWriter::clean`]
138/// call with all-false / all-`None` mode fields is a well-defined
139/// no-op (the report shows zero counts).
140#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
141pub struct CleanOptions {
142    /// `CACHE-022` / `--soft`: reclaim objectively-stale state
143    /// (manifest missing, unparseable, or schema-mismatched, plus
144    /// orphan `.tmp-` and `.restore-` staging directories).
145    pub soft: bool,
146    /// `AUX-022` / `--max-age <DURATION>`: evict well-formed
147    /// entries whose `created_at` is strictly older than
148    /// `now_unix - max_age`. The cutoff is computed via
149    /// [`u64::saturating_sub`], so a workspace clock that has not
150    /// advanced past the threshold yields an empty eviction set
151    /// rather than an overflow.
152    pub max_age: Option<MaxAge>,
153    /// `AUX-022` / `--max-size <BYTES>`: after `soft` and
154    /// `max_age` have run, evict oldest-`created_at`-first
155    /// well-formed survivors until the residual footprint is at
156    /// or below `max_size`.
157    pub max_size: Option<MaxSize>,
158    /// `AUX-024` / `--dry-run`: compute the eviction set but DO
159    /// NOT remove anything from disk. The returned [`CleanReport`]
160    /// mirrors the non-dry-run report exactly, modulo the absence
161    /// of on-disk changes.
162    pub dry_run: bool,
163    /// Reference "now" in Unix seconds since the epoch. Used as
164    /// the right-hand side of the `AUX-023` step 4 cutoff
165    /// (`now_unix - max_age`). Required to be non-zero when
166    /// `max_age` is set; ignored otherwise. The injected value
167    /// makes the operation deterministic under test.
168    pub now_unix: u64,
169}
170
171/// Mode that accounted for evicting a given entry per `AUX-024`.
172///
173/// When more than one mode would match the same entry, the
174/// highest-priority mode wins (in this declaration order).
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum EvictionMode {
177    /// `CACHE-022` `--soft` pass: schema-stale or incomplete
178    /// entry.
179    Soft,
180    /// `AUX-023` `--max-age` pass: well-formed but too old.
181    MaxAge,
182    /// `AUX-023` `--max-size` pass: well-formed but oldest among
183    /// survivors of the previous passes when the residual
184    /// footprint exceeded the bound.
185    MaxSize,
186}
187
188/// Per-entry eviction detail surfaced for `AUX-024` dry-run
189/// rendering.
190///
191/// One instance per evicted entry; the CLI displays the list
192/// only under `--dry-run` per `AUX-024`, but the cache layer
193/// always populates it (the cost is one `Vec` allocation of
194/// modest size, and the audit-friendliness is worth it).
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct EvictedEntry {
197    /// First eight hex characters of the entry's cache key, per
198    /// `AUX-024` ("first 8 hex characters suffice for
199    /// human-readable output"). Sourced from the entry
200    /// directory's basename when the manifest is unparseable.
201    pub key_hex_prefix: String,
202    /// Manifest's `created_at_unix` field when available; zero
203    /// when the manifest is missing or unparseable.
204    pub created_at_unix: u64,
205    /// Manifest-declared footprint of the entry (`stdout_len +
206    /// stderr_len + sum(outputs[].size)`). Zero when the manifest
207    /// is unavailable.
208    pub footprint: u64,
209    /// Priority mode that accounted for this entry's eviction.
210    pub matched_mode: EvictionMode,
211}
212
213/// Outcome of [`CacheWriter::clean`] per `AUX-024`.
214///
215/// All counts are zero on an absent cache root. Per-mode counts
216/// follow the `AUX-024` priority rule (`soft` > `max_age` >
217/// `max_size`): an entry never contributes to more than one
218/// per-mode counter.
219#[derive(Debug, Default, Clone, PartialEq, Eq)]
220pub struct CleanReport {
221    /// `AUX-024` step 1: total number of entry directories the
222    /// walk looked at (well-formed + corrupt). Excludes
223    /// `.tmp-`/`.restore-` reclaimables.
224    pub inspected: u64,
225    /// `AUX-024` step 2: entries evicted under the `--soft` mode.
226    pub evicted_by_soft: u64,
227    /// `AUX-024` step 2: entries evicted under the `--max-age`
228    /// mode.
229    pub evicted_by_max_age: u64,
230    /// `AUX-024` step 2: entries evicted under the `--max-size`
231    /// mode.
232    pub evicted_by_max_size: u64,
233    /// `CACHE-022`: `.tmp-<key>-<random>` directories reclaimed.
234    /// Always zero when `soft` is false.
235    pub removed_tmp_dirs: u64,
236    /// `CACHE-022`: `.restore-<key>-<random>` directories
237    /// reclaimed. Always zero when `soft` is false.
238    pub removed_restore_dirs: u64,
239    /// `AUX-024` step 3: total bytes reclaimed (or projected
240    /// under `--dry-run`). Sums each evicted entry's
241    /// manifest-declared footprint; entries with no parseable
242    /// manifest contribute zero. `.tmp-`/`.restore-` reclamations
243    /// contribute zero.
244    pub bytes_reclaimed: u64,
245    /// One detail per evicted entry per `AUX-024`'s dry-run
246    /// requirement (always populated regardless of `dry_run`).
247    /// Sorted by `(matched_mode priority, created_at, key)` for
248    /// determinism.
249    pub evicted_entries: Vec<EvictedEntry>,
250}
251
252impl<Fs: WritableFilesystem> CacheWriter<Fs> {
253    /// Remove every cache entry under
254    /// `<workspace_root>/.haz/cache`, per `CACHE-021`.
255    ///
256    /// Idempotent on an absent cache root: calling `clear` when
257    /// the cache tree does not exist returns `Ok(())`, not an
258    /// error. The implementation only touches paths under the
259    /// cache root; the rest of the workspace is left alone.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`CleanError::Io`] wrapping the underlying
264    /// [`FsError`] if the recursive removal fails for any reason
265    /// other than "the cache root did not exist".
266    pub fn clear(&self) -> Result<(), CleanError> {
267        match self.fs().remove_dir_all(self.cache_root()) {
268            Ok(()) | Err(FsError::NotFound { .. }) => Ok(()),
269            Err(e) => Err(CleanError::Io { source: e }),
270        }
271    }
272
273    /// Walk the cache root and reclaim every artefact named by the
274    /// `CleanOptions` mode flags per `AUX-022..AUX-028`.
275    ///
276    /// Eviction priority follows `AUX-024` (`soft` > `max_age` >
277    /// `max_size`); each entry contributes to exactly one per-mode
278    /// count. Under `dry_run`, the eviction set is computed and
279    /// reported but no file or directory is removed.
280    ///
281    /// Best-effort per `AUX-028`: a shard or manifest that cannot be
282    /// read during planning, and an entry that cannot be removed
283    /// during eviction, are recorded in [`CleanOutcome::failures`]
284    /// and skipped rather than aborting the run. The returned
285    /// [`CleanReport`] counts and `bytes_reclaimed` reflect what
286    /// actually succeeded. Idempotent on an absent cache root.
287    ///
288    /// # Errors
289    ///
290    /// Returns [`CleanError::Io`] only when the cache root itself
291    /// cannot be read, which prevents computing any eviction set.
292    /// Per-item read/removal failures are NOT errors: they are
293    /// collected in [`CleanOutcome::failures`]. Unparseable or
294    /// missing manifests are not failures either; those entries
295    /// surface as the `soft`-eligible "objectively stale" case.
296    pub fn clean(&self, opts: &CleanOptions) -> Result<CleanOutcome, CleanError> {
297        let Some(enumerated) = self.enumerate_for_clean()? else {
298            return Ok(CleanOutcome::default());
299        };
300        let CleanEnumeration {
301            well_formed,
302            corrupt,
303            tmp_paths,
304            restore_paths,
305            mut failures,
306        } = enumerated;
307
308        let mut report = CleanReport {
309            inspected: (well_formed.len() + corrupt.len()) as u64,
310            ..CleanReport::default()
311        };
312
313        let mut plan: Vec<PlannedEviction> = Vec::new();
314        apply_soft_pass(opts, corrupt, &mut plan);
315        let survivors = apply_max_age_pass(opts, well_formed, &mut plan);
316        apply_max_size_pass(opts, survivors, &mut plan);
317
318        // `AUX-028`: apply the plan best-effort. Under `--dry-run`,
319        // `evict_path` is a no-op success, so every planned entry
320        // counts as reclaimed and nothing is removed; on a real run
321        // each removal either counts on success or is recorded as a
322        // failure, never aborting the rest of the plan.
323        let mut removed: Vec<EvictedEntry> = Vec::new();
324        for planned in plan {
325            match self.evict_path(opts.dry_run, &planned.path) {
326                Ok(()) => {
327                    bump_mode_count(&mut report, planned.detail.matched_mode);
328                    report.bytes_reclaimed = report
329                        .bytes_reclaimed
330                        .saturating_add(planned.detail.footprint);
331                    removed.push(planned.detail);
332                }
333                Err(source) => failures.push(CleanFailure::Entry {
334                    path: planned.path,
335                    source,
336                }),
337            }
338        }
339
340        if opts.soft {
341            for path in tmp_paths {
342                match self.evict_path(opts.dry_run, &path) {
343                    Ok(()) => {
344                        report.removed_tmp_dirs = report.removed_tmp_dirs.saturating_add(1);
345                    }
346                    Err(source) => failures.push(CleanFailure::Tmp { path, source }),
347                }
348            }
349            for path in restore_paths {
350                match self.evict_path(opts.dry_run, &path) {
351                    Ok(()) => {
352                        report.removed_restore_dirs = report.removed_restore_dirs.saturating_add(1);
353                    }
354                    Err(source) => failures.push(CleanFailure::Restore { path, source }),
355                }
356            }
357        }
358
359        report.evicted_entries = finalize_evicted_entries(removed);
360        Ok(CleanOutcome { report, failures })
361    }
362
363    /// Remove `path`, unless `dry_run` is set, in which case report
364    /// success without touching disk. Lets [`CacheWriter::clean`]
365    /// drive the dry-run projection and the real eviction through a
366    /// single best-effort loop.
367    fn evict_path(&self, dry_run: bool, path: &Path) -> Result<(), FsError> {
368        if dry_run {
369            Ok(())
370        } else {
371            self.fs().remove_dir_all(path)
372        }
373    }
374
375    fn enumerate_for_clean(&self) -> Result<Option<CleanEnumeration>, CleanError> {
376        let cache_entries = match self.fs().read_dir(self.cache_root()) {
377            Ok(es) => es,
378            Err(FsError::NotFound { .. }) => return Ok(None),
379            Err(e) => return Err(CleanError::Io { source: e }),
380        };
381        let mut e = CleanEnumeration::default();
382        for cache_entry in cache_entries {
383            let name = cache_entry
384                .path
385                .file_name()
386                .map(|n| n.to_string_lossy().into_owned())
387                .unwrap_or_default();
388
389            if name.starts_with(".restore-") {
390                e.restore_paths.push(cache_entry.path);
391                continue;
392            }
393            if cache_entry.metadata.kind != EntryKind::Dir {
394                continue;
395            }
396            self.clean_classify_shard(
397                &cache_entry.path,
398                &mut e.well_formed,
399                &mut e.corrupt,
400                &mut e.tmp_paths,
401                &mut e.failures,
402            );
403        }
404        Ok(Some(e))
405    }
406
407    fn clean_classify_shard(
408        &self,
409        shard_dir: &Path,
410        well_formed: &mut Vec<EntryRecord>,
411        corrupt: &mut Vec<EntryRecord>,
412        tmp_paths: &mut Vec<PathBuf>,
413        failures: &mut Vec<CleanFailure>,
414    ) {
415        // `AUX-028`: a shard we cannot read is recorded and skipped,
416        // not fatal; the rest of the cache is still enumerated.
417        let shard_entries = match self.fs().read_dir(shard_dir) {
418            Ok(entries) => entries,
419            Err(source) => {
420                failures.push(CleanFailure::Shard {
421                    path: shard_dir.to_path_buf(),
422                    source,
423                });
424                return;
425            }
426        };
427        for shard_entry in shard_entries {
428            let sname = shard_entry
429                .path
430                .file_name()
431                .map(|n| n.to_string_lossy().into_owned())
432                .unwrap_or_default();
433
434            if sname.starts_with(".tmp-") {
435                tmp_paths.push(shard_entry.path);
436                continue;
437            }
438
439            if shard_entry.metadata.kind != EntryKind::Dir {
440                continue;
441            }
442
443            self.clean_classify_entry(&shard_entry.path, &sname, well_formed, corrupt, failures);
444        }
445    }
446
447    fn clean_classify_entry(
448        &self,
449        entry_dir: &Path,
450        basename: &str,
451        well_formed: &mut Vec<EntryRecord>,
452        corrupt: &mut Vec<EntryRecord>,
453        failures: &mut Vec<CleanFailure>,
454    ) {
455        let key_hex_prefix: String = basename.chars().take(8).collect();
456        let manifest_path = entry_dir.join(layout::MANIFEST_FILE_NAME);
457        let bytes = match self.fs().read(&manifest_path) {
458            Ok(b) => b,
459            Err(FsError::NotFound { .. } | FsError::NotAFile { .. }) => {
460                corrupt.push(EntryRecord {
461                    path: entry_dir.to_path_buf(),
462                    key_hex_prefix,
463                    created_at_unix: 0,
464                    footprint: 0,
465                });
466                return;
467            }
468            // `AUX-028`: an unreadable manifest (a real I/O error,
469            // not a simply-absent one) is recorded and the entry
470            // skipped, not fatal.
471            Err(source) => {
472                failures.push(CleanFailure::Manifest {
473                    path: manifest_path,
474                    source,
475                });
476                return;
477            }
478        };
479        let Ok(manifest) = Manifest::from_json(&bytes) else {
480            corrupt.push(EntryRecord {
481                path: entry_dir.to_path_buf(),
482                key_hex_prefix,
483                created_at_unix: 0,
484                footprint: 0,
485            });
486            return;
487        };
488        let chapter_ok = manifest.current_chapter_revision_matches();
489        let hash_ok = HashFunctionLabel::from(self.hash_algo()) == manifest.hash_function;
490        let footprint = manifest_footprint(&manifest);
491        let record = EntryRecord {
492            path: entry_dir.to_path_buf(),
493            key_hex_prefix,
494            created_at_unix: manifest.created_at_unix,
495            footprint,
496        };
497        if chapter_ok && hash_ok {
498            well_formed.push(record);
499        } else {
500            corrupt.push(record);
501        }
502    }
503}
504
505struct EntryRecord {
506    path: PathBuf,
507    key_hex_prefix: String,
508    created_at_unix: u64,
509    footprint: u64,
510}
511
512struct PlannedEviction {
513    path: PathBuf,
514    detail: EvictedEntry,
515}
516
517#[derive(Default)]
518struct CleanEnumeration {
519    well_formed: Vec<EntryRecord>,
520    corrupt: Vec<EntryRecord>,
521    tmp_paths: Vec<PathBuf>,
522    restore_paths: Vec<PathBuf>,
523    failures: Vec<CleanFailure>,
524}
525
526fn apply_soft_pass(
527    opts: &CleanOptions,
528    corrupt: Vec<EntryRecord>,
529    plan: &mut Vec<PlannedEviction>,
530) {
531    if !opts.soft {
532        return;
533    }
534    for c in corrupt {
535        plan.push(PlannedEviction {
536            path: c.path,
537            detail: EvictedEntry {
538                key_hex_prefix: c.key_hex_prefix,
539                created_at_unix: c.created_at_unix,
540                footprint: c.footprint,
541                matched_mode: EvictionMode::Soft,
542            },
543        });
544    }
545}
546
547fn apply_max_age_pass(
548    opts: &CleanOptions,
549    well_formed: Vec<EntryRecord>,
550    plan: &mut Vec<PlannedEviction>,
551) -> Vec<EntryRecord> {
552    let Some(max_age) = opts.max_age else {
553        return well_formed;
554    };
555    let cutoff = opts
556        .now_unix
557        .saturating_sub(max_age.as_duration().as_secs());
558    let mut survivors: Vec<EntryRecord> = Vec::with_capacity(well_formed.len());
559    for wf in well_formed {
560        if wf.created_at_unix < cutoff {
561            plan.push(PlannedEviction {
562                path: wf.path.clone(),
563                detail: EvictedEntry {
564                    key_hex_prefix: wf.key_hex_prefix.clone(),
565                    created_at_unix: wf.created_at_unix,
566                    footprint: wf.footprint,
567                    matched_mode: EvictionMode::MaxAge,
568                },
569            });
570        } else {
571            survivors.push(wf);
572        }
573    }
574    survivors
575}
576
577fn apply_max_size_pass(
578    opts: &CleanOptions,
579    mut survivors: Vec<EntryRecord>,
580    plan: &mut Vec<PlannedEviction>,
581) {
582    let Some(max_size) = opts.max_size else {
583        return;
584    };
585    let limit = max_size.as_bytes();
586    let total: u64 = survivors.iter().map(|e| e.footprint).sum();
587    if total <= limit {
588        return;
589    }
590    survivors.sort_by(|a, b| {
591        a.created_at_unix
592            .cmp(&b.created_at_unix)
593            .then_with(|| a.key_hex_prefix.cmp(&b.key_hex_prefix))
594    });
595    let mut remaining = total;
596    for wf in &survivors {
597        if remaining <= limit {
598            break;
599        }
600        plan.push(PlannedEviction {
601            path: wf.path.clone(),
602            detail: EvictedEntry {
603                key_hex_prefix: wf.key_hex_prefix.clone(),
604                created_at_unix: wf.created_at_unix,
605                footprint: wf.footprint,
606                matched_mode: EvictionMode::MaxSize,
607            },
608        });
609        remaining = remaining.saturating_sub(wf.footprint);
610    }
611}
612
613/// Increment the per-mode evicted counter for `mode`. Called once
614/// per successful removal (and once per planned entry under
615/// `--dry-run`), so the counts track what was actually reclaimed
616/// per `AUX-028`.
617fn bump_mode_count(report: &mut CleanReport, mode: EvictionMode) {
618    match mode {
619        EvictionMode::Soft => {
620            report.evicted_by_soft = report.evicted_by_soft.saturating_add(1);
621        }
622        EvictionMode::MaxAge => {
623            report.evicted_by_max_age = report.evicted_by_max_age.saturating_add(1);
624        }
625        EvictionMode::MaxSize => {
626            report.evicted_by_max_size = report.evicted_by_max_size.saturating_add(1);
627        }
628    }
629}
630
631fn finalize_evicted_entries(mut details: Vec<EvictedEntry>) -> Vec<EvictedEntry> {
632    details.sort_by(|a, b| {
633        mode_rank(a.matched_mode)
634            .cmp(&mode_rank(b.matched_mode))
635            .then(a.created_at_unix.cmp(&b.created_at_unix))
636            .then_with(|| a.key_hex_prefix.cmp(&b.key_hex_prefix))
637    });
638    details
639}
640
641fn manifest_footprint(m: &Manifest) -> u64 {
642    let mut total = m.stdout_len.saturating_add(m.stderr_len);
643    for o in &m.outputs {
644        total = total.saturating_add(o.size);
645    }
646    total
647}
648
649const fn mode_rank(m: EvictionMode) -> u8 {
650    match m {
651        EvictionMode::Soft => 0,
652        EvictionMode::MaxAge => 1,
653        EvictionMode::MaxSize => 2,
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use std::io::ErrorKind;
660    use std::path::Path;
661
662    use haz_domain::path::CanonicalPath;
663    use haz_domain::settings::cache::HashAlgo;
664    use haz_domain::settings::cache_clean::max_age::MaxAge;
665    use haz_domain::settings::cache_clean::max_size::MaxSize;
666    use haz_vfs::{Filesystem, WritableFilesystem};
667    use haz_vfs_testing::{MemFaultOp, MemFilesystem};
668
669    use crate::clean::{CleanError, CleanFailure, CleanOptions, CleanReport, EvictionMode};
670    use crate::key::CacheKey;
671    use crate::key::prefix::CHAPTER_REVISION;
672    use crate::layout;
673    use crate::manifest::{HashFunctionLabel, Manifest, OutputBlob};
674    use crate::store::{StoreInputs, StoredOutput};
675    use crate::writer::CacheWriter;
676
677    fn cp(s: &str) -> CanonicalPath {
678        CanonicalPath::parse_workspace_absolute(s)
679            .expect("test helper expects a valid workspace-absolute path")
680    }
681
682    const WORKSPACE_ROOT: &str = "/ws";
683
684    fn make_cache(fs: MemFilesystem, algo: HashAlgo) -> CacheWriter<MemFilesystem> {
685        CacheWriter::new(fs, Path::new(WORKSPACE_ROOT), algo)
686    }
687
688    fn key_with_first_byte(first: u8) -> CacheKey {
689        let mut bytes = [0u8; 32];
690        bytes[0] = first;
691        CacheKey::from_bytes(bytes)
692    }
693
694    fn store_entry_at(
695        cache: &CacheWriter<MemFilesystem>,
696        key: &CacheKey,
697        rel: &str,
698        bytes: &[u8],
699        created_at_unix: u64,
700    ) {
701        let target = Path::new(WORKSPACE_ROOT).join(rel);
702        let anchored = format!("/{rel}");
703        cache.fs().create_dir_all(target.parent().unwrap()).unwrap();
704        cache.fs().write_file(&target, bytes).unwrap();
705        let outs = [StoredOutput {
706            workspace_absolute_path: &anchored,
707            on_disk_path: &target,
708            mode: 0o644,
709        }];
710        cache
711            .store(
712                key,
713                &StoreInputs {
714                    outputs: &outs,
715                    stdout: b"",
716                    stderr: b"",
717                    created_at_unix,
718                },
719            )
720            .unwrap();
721    }
722
723    fn store_a_valid_entry(
724        cache: &CacheWriter<MemFilesystem>,
725        key: &CacheKey,
726        rel: &str,
727        bytes: &[u8],
728    ) {
729        store_entry_at(cache, key, rel, bytes, 0);
730    }
731
732    fn write_manifest_to_entry(
733        cache: &CacheWriter<MemFilesystem>,
734        key: &CacheKey,
735        manifest: &Manifest,
736    ) {
737        cache
738            .fs()
739            .create_dir_all(&layout::entry_dir(cache.cache_root(), key))
740            .unwrap();
741        cache
742            .fs()
743            .write_file(
744                &layout::manifest_path(cache.cache_root(), key),
745                &manifest.to_json_bytes(),
746            )
747            .unwrap();
748    }
749
750    fn soft_only() -> CleanOptions {
751        CleanOptions {
752            soft: true,
753            ..Default::default()
754        }
755    }
756
757    // ---- clear ----
758
759    #[test]
760    fn cache_021_clear_empties_a_populated_cache() {
761        let mut fs = MemFilesystem::new();
762        fs.add_dir("/ws").unwrap();
763        let cache = make_cache(fs, HashAlgo::Blake3);
764        let key = key_with_first_byte(0xAB);
765        store_a_valid_entry(&cache, &key, "proj/out", b"x");
766
767        assert!(
768            cache.reader().lookup(&key).is_some(),
769            "precondition: entry present"
770        );
771        cache.clear().unwrap();
772        assert!(
773            cache.reader().lookup(&key).is_none(),
774            "lookup must be a miss after clear"
775        );
776    }
777
778    #[test]
779    fn cache_021_clear_on_fresh_cache_is_a_noop_not_an_error() {
780        let mut fs = MemFilesystem::new();
781        fs.add_dir("/ws").unwrap();
782        let cache = make_cache(fs, HashAlgo::Blake3);
783        cache.clear().unwrap();
784    }
785
786    #[test]
787    fn cache_021_clear_does_not_touch_files_outside_cache_root() {
788        let mut fs = MemFilesystem::new();
789        fs.add_dir("/ws").unwrap();
790        fs.add_file("/ws/unrelated.txt", b"keep me".to_vec())
791            .unwrap();
792        let cache = make_cache(fs, HashAlgo::Blake3);
793        let key = key_with_first_byte(0xAB);
794        store_a_valid_entry(&cache, &key, "proj/out", b"x");
795
796        cache.clear().unwrap();
797        assert_eq!(
798            cache.fs().read(Path::new("/ws/unrelated.txt")).unwrap(),
799            b"keep me"
800        );
801    }
802
803    // ---- clean: no-op cases ----
804
805    #[test]
806    fn cache_022_clean_soft_on_fresh_cache_is_a_noop_with_zero_counts() {
807        let mut fs = MemFilesystem::new();
808        fs.add_dir("/ws").unwrap();
809        let cache = make_cache(fs, HashAlgo::Blake3);
810        let report = cache.clean(&soft_only()).unwrap().report;
811        assert_eq!(report, CleanReport::default());
812    }
813
814    #[test]
815    fn aux_022_clean_with_no_modes_is_a_noop_on_a_populated_cache() {
816        let mut fs = MemFilesystem::new();
817        fs.add_dir("/ws").unwrap();
818        let cache = make_cache(fs, HashAlgo::Blake3);
819        let key = key_with_first_byte(0xAB);
820        store_a_valid_entry(&cache, &key, "proj/out", b"x");
821
822        let report = cache.clean(&CleanOptions::default()).unwrap().report;
823        assert_eq!(report.evicted_by_soft, 0);
824        assert_eq!(report.evicted_by_max_age, 0);
825        assert_eq!(report.evicted_by_max_size, 0);
826        assert_eq!(report.removed_tmp_dirs, 0);
827        assert_eq!(report.removed_restore_dirs, 0);
828        assert_eq!(report.inspected, 1);
829        assert!(cache.reader().lookup(&key).is_some());
830    }
831
832    #[test]
833    fn cache_022_clean_soft_keeps_a_valid_entry_intact() {
834        let mut fs = MemFilesystem::new();
835        fs.add_dir("/ws").unwrap();
836        let cache = make_cache(fs, HashAlgo::Blake3);
837        let key = key_with_first_byte(0xAB);
838        store_a_valid_entry(&cache, &key, "proj/out", b"x");
839
840        let report = cache.clean(&soft_only()).unwrap().report;
841        assert_eq!(report.evicted_by_soft, 0);
842        assert!(cache.reader().lookup(&key).is_some());
843    }
844
845    // ---- clean --soft: schema mismatch ----
846
847    #[test]
848    fn cache_022_clean_soft_removes_entry_with_chapter_revision_mismatch() {
849        let mut fs = MemFilesystem::new();
850        fs.add_dir("/ws").unwrap();
851        let cache = make_cache(fs, HashAlgo::Blake3);
852        let key = key_with_first_byte(0xAB);
853        let manifest = Manifest {
854            chapter_revision: CHAPTER_REVISION.saturating_add(1),
855            hash_function: HashFunctionLabel::Blake3,
856            key,
857            outputs: vec![],
858            stdout_len: 0,
859            stderr_len: 0,
860            stdout_hash: [0u8; 32],
861            stderr_hash: [0u8; 32],
862            exit_status: 0,
863            created_at_unix: 0,
864        };
865        write_manifest_to_entry(&cache, &key, &manifest);
866        assert!(
867            cache
868                .fs()
869                .metadata(&layout::entry_dir(cache.cache_root(), &key))
870                .is_ok()
871        );
872
873        let report = cache.clean(&soft_only()).unwrap().report;
874        assert_eq!(report.evicted_by_soft, 1);
875        assert!(
876            cache
877                .fs()
878                .metadata(&layout::entry_dir(cache.cache_root(), &key))
879                .is_err()
880        );
881    }
882
883    #[test]
884    fn cache_022_clean_soft_removes_entry_with_hash_function_mismatch() {
885        let mut fs = MemFilesystem::new();
886        fs.add_dir("/ws").unwrap();
887        let cache = make_cache(fs, HashAlgo::Blake3);
888        let key = key_with_first_byte(0xAB);
889        let manifest = Manifest {
890            chapter_revision: CHAPTER_REVISION,
891            hash_function: HashFunctionLabel::Sha256,
892            key,
893            outputs: vec![],
894            stdout_len: 0,
895            stderr_len: 0,
896            stdout_hash: [0u8; 32],
897            stderr_hash: [0u8; 32],
898            exit_status: 0,
899            created_at_unix: 0,
900        };
901        write_manifest_to_entry(&cache, &key, &manifest);
902
903        let report = cache.clean(&soft_only()).unwrap().report;
904        assert_eq!(report.evicted_by_soft, 1);
905    }
906
907    // ---- clean --soft: incomplete entry ----
908
909    #[test]
910    fn cache_022_clean_soft_removes_entry_without_a_manifest() {
911        let mut fs = MemFilesystem::new();
912        fs.add_dir("/ws").unwrap();
913        let cache = make_cache(fs, HashAlgo::Blake3);
914        let key = key_with_first_byte(0xAB);
915        cache
916            .fs()
917            .create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
918            .unwrap();
919
920        let report = cache.clean(&soft_only()).unwrap().report;
921        assert_eq!(report.evicted_by_soft, 1);
922    }
923
924    #[test]
925    fn cache_022_clean_soft_removes_entry_with_unparseable_manifest() {
926        let mut fs = MemFilesystem::new();
927        fs.add_dir("/ws").unwrap();
928        let cache = make_cache(fs, HashAlgo::Blake3);
929        let key = key_with_first_byte(0xAB);
930        cache
931            .fs()
932            .create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
933            .unwrap();
934        cache
935            .fs()
936            .write_file(
937                &layout::manifest_path(cache.cache_root(), &key),
938                b"this is not json",
939            )
940            .unwrap();
941
942        let report = cache.clean(&soft_only()).unwrap().report;
943        assert_eq!(report.evicted_by_soft, 1);
944    }
945
946    // ---- clean --soft: tmp / restore dirs ----
947
948    #[test]
949    fn cache_022_clean_soft_removes_store_tmp_directory() {
950        let mut fs = MemFilesystem::new();
951        fs.add_dir("/ws").unwrap();
952        let cache = make_cache(fs, HashAlgo::Blake3);
953        let key = key_with_first_byte(0xAB);
954        let tmp = layout::tmp_entry_dir(cache.cache_root(), &key, "abcdef");
955        cache.fs().create_dir_all(&tmp).unwrap();
956        cache
957            .fs()
958            .write_file(&tmp.join("manifest.json"), b"partial")
959            .unwrap();
960
961        let report = cache.clean(&soft_only()).unwrap().report;
962        assert_eq!(report.removed_tmp_dirs, 1);
963        assert!(cache.fs().metadata(&tmp).is_err());
964    }
965
966    #[test]
967    fn cache_022_clean_soft_removes_restore_staging_directory() {
968        let mut fs = MemFilesystem::new();
969        fs.add_dir("/ws").unwrap();
970        let cache = make_cache(fs, HashAlgo::Blake3);
971        let key = key_with_first_byte(0xAB);
972        let staging = layout::restore_staging_dir(cache.cache_root(), &key, "feedface");
973        cache.fs().create_dir_all(&staging).unwrap();
974        cache
975            .fs()
976            .write_file(&staging.join("00000000"), b"leftover")
977            .unwrap();
978
979        let report = cache.clean(&soft_only()).unwrap().report;
980        assert_eq!(report.removed_restore_dirs, 1);
981        assert!(cache.fs().metadata(&staging).is_err());
982    }
983
984    // ---- clean --soft: mixed state ----
985
986    #[test]
987    fn cache_022_clean_soft_is_selective_when_mixed_state_is_present() {
988        let mut fs = MemFilesystem::new();
989        fs.add_dir("/ws").unwrap();
990        let cache = make_cache(fs, HashAlgo::Blake3);
991
992        let key_good = key_with_first_byte(0xAB);
993        store_a_valid_entry(&cache, &key_good, "proj/out", b"x");
994
995        let key_stale = key_with_first_byte(0xCD);
996        let stale_manifest = Manifest {
997            chapter_revision: CHAPTER_REVISION,
998            hash_function: HashFunctionLabel::Sha256,
999            key: key_stale,
1000            outputs: vec![],
1001            stdout_len: 0,
1002            stderr_len: 0,
1003            stdout_hash: [0u8; 32],
1004            stderr_hash: [0u8; 32],
1005            exit_status: 0,
1006            created_at_unix: 0,
1007        };
1008        write_manifest_to_entry(&cache, &key_stale, &stale_manifest);
1009
1010        let key_tmp = key_with_first_byte(0xEF);
1011        let tmp = layout::tmp_entry_dir(cache.cache_root(), &key_tmp, "rnd1");
1012        cache.fs().create_dir_all(&tmp).unwrap();
1013
1014        let key_restore = key_with_first_byte(0x12);
1015        let staging = layout::restore_staging_dir(cache.cache_root(), &key_restore, "rnd2");
1016        cache.fs().create_dir_all(&staging).unwrap();
1017
1018        let report = cache.clean(&soft_only()).unwrap().report;
1019        assert_eq!(report.evicted_by_soft, 1);
1020        assert_eq!(report.removed_tmp_dirs, 1);
1021        assert_eq!(report.removed_restore_dirs, 1);
1022        assert!(cache.reader().lookup(&key_good).is_some());
1023    }
1024
1025    #[test]
1026    fn cache_022_clean_soft_does_not_touch_files_outside_cache_root() {
1027        let mut fs = MemFilesystem::new();
1028        fs.add_dir("/ws").unwrap();
1029        fs.add_file("/ws/sibling.txt", b"don't touch".to_vec())
1030            .unwrap();
1031        let cache = make_cache(fs, HashAlgo::Blake3);
1032        let key = key_with_first_byte(0xAB);
1033        cache
1034            .fs()
1035            .create_dir_all(&layout::entry_dir(cache.cache_root(), &key))
1036            .unwrap();
1037        cache.clean(&soft_only()).unwrap();
1038        assert_eq!(
1039            cache.fs().read(Path::new("/ws/sibling.txt")).unwrap(),
1040            b"don't touch"
1041        );
1042    }
1043
1044    #[test]
1045    fn cache_022_clean_soft_does_not_inspect_blob_contents() {
1046        // `CACHE-022` only checks `chapter_revision`/
1047        // `hash_function` and manifest presence/parseability. A
1048        // blob-content mismatch is a lookup-time concern, not a
1049        // clean-soft concern.
1050        let mut fs = MemFilesystem::new();
1051        fs.add_dir("/ws").unwrap();
1052        let cache = make_cache(fs, HashAlgo::Blake3);
1053        let key = key_with_first_byte(0xAB);
1054
1055        let manifest = Manifest {
1056            chapter_revision: CHAPTER_REVISION,
1057            hash_function: HashFunctionLabel::Blake3,
1058            key,
1059            outputs: vec![OutputBlob {
1060                workspace_absolute_path: cp("/proj/out"),
1061                content_hash: [0xAAu8; 32],
1062                size: 42,
1063                mode: 0o644,
1064            }],
1065            stdout_len: 0,
1066            stderr_len: 0,
1067            stdout_hash: [0u8; 32],
1068            stderr_hash: [0u8; 32],
1069            exit_status: 0,
1070            created_at_unix: 0,
1071        };
1072        write_manifest_to_entry(&cache, &key, &manifest);
1073
1074        let report = cache.clean(&soft_only()).unwrap().report;
1075        assert_eq!(report.evicted_by_soft, 0);
1076        assert!(
1077            cache
1078                .fs()
1079                .metadata(&layout::entry_dir(cache.cache_root(), &key))
1080                .is_ok()
1081        );
1082    }
1083
1084    // ---- clean --max-age ----
1085
1086    #[test]
1087    fn aux_023_clean_max_age_evicts_entries_strictly_older_than_cutoff() {
1088        let mut fs = MemFilesystem::new();
1089        fs.add_dir("/ws").unwrap();
1090        let cache = make_cache(fs, HashAlgo::Blake3);
1091
1092        let key_old = key_with_first_byte(0xAA);
1093        store_entry_at(&cache, &key_old, "proj/old", b"x", 100);
1094        let key_new = key_with_first_byte(0xBB);
1095        store_entry_at(&cache, &key_new, "proj/new", b"y", 260);
1096
1097        let opts = CleanOptions {
1098            max_age: Some(MaxAge::parse("50s").unwrap()),
1099            now_unix: 300,
1100            ..Default::default()
1101        };
1102        let report = cache.clean(&opts).unwrap().report;
1103        assert_eq!(report.evicted_by_max_age, 1);
1104        assert_eq!(report.evicted_by_soft, 0);
1105        assert_eq!(report.evicted_by_max_size, 0);
1106        assert!(cache.reader().lookup(&key_old).is_none());
1107        assert!(cache.reader().lookup(&key_new).is_some());
1108    }
1109
1110    #[test]
1111    fn aux_023_clean_max_age_keeps_entry_at_exactly_cutoff() {
1112        let mut fs = MemFilesystem::new();
1113        fs.add_dir("/ws").unwrap();
1114        let cache = make_cache(fs, HashAlgo::Blake3);
1115
1116        let key = key_with_first_byte(0xAA);
1117        store_entry_at(&cache, &key, "proj/x", b"x", 100);
1118
1119        let opts = CleanOptions {
1120            max_age: Some(MaxAge::parse("100s").unwrap()),
1121            now_unix: 200,
1122            ..Default::default()
1123        };
1124        let report = cache.clean(&opts).unwrap().report;
1125        assert_eq!(report.evicted_by_max_age, 0);
1126        assert!(cache.reader().lookup(&key).is_some());
1127    }
1128
1129    #[test]
1130    fn aux_023_clean_max_age_ignores_corrupt_entries() {
1131        let mut fs = MemFilesystem::new();
1132        fs.add_dir("/ws").unwrap();
1133        let cache = make_cache(fs, HashAlgo::Blake3);
1134
1135        let key_corrupt = key_with_first_byte(0xCC);
1136        let m = Manifest {
1137            chapter_revision: CHAPTER_REVISION,
1138            hash_function: HashFunctionLabel::Sha256,
1139            key: key_corrupt,
1140            outputs: vec![],
1141            stdout_len: 0,
1142            stderr_len: 0,
1143            stdout_hash: [0u8; 32],
1144            stderr_hash: [0u8; 32],
1145            exit_status: 0,
1146            created_at_unix: 0,
1147        };
1148        write_manifest_to_entry(&cache, &key_corrupt, &m);
1149
1150        let key_stale = key_with_first_byte(0xAA);
1151        store_entry_at(&cache, &key_stale, "proj/x", b"x", 100);
1152
1153        let opts = CleanOptions {
1154            max_age: Some(MaxAge::parse("50s").unwrap()),
1155            now_unix: 300,
1156            ..Default::default()
1157        };
1158        let report = cache.clean(&opts).unwrap().report;
1159        assert_eq!(report.evicted_by_max_age, 1);
1160        assert_eq!(report.evicted_by_soft, 0);
1161        assert!(
1162            cache
1163                .fs()
1164                .metadata(&layout::entry_dir(cache.cache_root(), &key_corrupt))
1165                .is_ok()
1166        );
1167        assert!(cache.reader().lookup(&key_stale).is_none());
1168    }
1169
1170    // ---- clean --max-size ----
1171
1172    #[test]
1173    fn aux_023_clean_max_size_is_noop_when_under_limit() {
1174        let mut fs = MemFilesystem::new();
1175        fs.add_dir("/ws").unwrap();
1176        let cache = make_cache(fs, HashAlgo::Blake3);
1177
1178        let key = key_with_first_byte(0xAA);
1179        store_entry_at(&cache, &key, "proj/x", b"hello", 100);
1180
1181        let opts = CleanOptions {
1182            max_size: Some(MaxSize::parse("1KB").unwrap()),
1183            ..Default::default()
1184        };
1185        let report = cache.clean(&opts).unwrap().report;
1186        assert_eq!(report.evicted_by_max_size, 0);
1187        assert!(cache.reader().lookup(&key).is_some());
1188    }
1189
1190    #[test]
1191    fn aux_023_clean_max_size_evicts_oldest_first_until_at_or_below_limit() {
1192        let mut fs = MemFilesystem::new();
1193        fs.add_dir("/ws").unwrap();
1194        let cache = make_cache(fs, HashAlgo::Blake3);
1195
1196        let bytes = b"0123456789"; // 10 bytes per entry footprint
1197        let key_old = key_with_first_byte(0x11);
1198        let key_mid = key_with_first_byte(0x22);
1199        let key_new = key_with_first_byte(0x33);
1200        store_entry_at(&cache, &key_old, "proj/a", bytes, 100);
1201        store_entry_at(&cache, &key_mid, "proj/b", bytes, 200);
1202        store_entry_at(&cache, &key_new, "proj/c", bytes, 300);
1203
1204        // Total 30 bytes; limit 15. Evict oldest until <= 15.
1205        let opts = CleanOptions {
1206            max_size: Some(MaxSize::parse("15").unwrap()),
1207            ..Default::default()
1208        };
1209        let report = cache.clean(&opts).unwrap().report;
1210        assert_eq!(report.evicted_by_max_size, 2);
1211        assert!(cache.reader().lookup(&key_old).is_none());
1212        assert!(cache.reader().lookup(&key_mid).is_none());
1213        assert!(cache.reader().lookup(&key_new).is_some());
1214        assert_eq!(report.bytes_reclaimed, 20);
1215    }
1216
1217    #[test]
1218    fn aux_023_clean_max_size_zero_evicts_every_well_formed_entry() {
1219        let mut fs = MemFilesystem::new();
1220        fs.add_dir("/ws").unwrap();
1221        let cache = make_cache(fs, HashAlgo::Blake3);
1222
1223        let key = key_with_first_byte(0xAA);
1224        store_entry_at(&cache, &key, "proj/x", b"x", 100);
1225
1226        let opts = CleanOptions {
1227            max_size: Some(MaxSize::parse("0").unwrap()),
1228            ..Default::default()
1229        };
1230        let report = cache.clean(&opts).unwrap().report;
1231        assert_eq!(report.evicted_by_max_size, 1);
1232        assert!(cache.reader().lookup(&key).is_none());
1233    }
1234
1235    // ---- clean: mode composition ----
1236
1237    #[test]
1238    fn aux_023_clean_soft_and_max_age_count_separately_per_priority() {
1239        let mut fs = MemFilesystem::new();
1240        fs.add_dir("/ws").unwrap();
1241        let cache = make_cache(fs, HashAlgo::Blake3);
1242
1243        let key_corrupt = key_with_first_byte(0xCC);
1244        let m = Manifest {
1245            chapter_revision: CHAPTER_REVISION.saturating_add(1),
1246            hash_function: HashFunctionLabel::Blake3,
1247            key: key_corrupt,
1248            outputs: vec![],
1249            stdout_len: 0,
1250            stderr_len: 0,
1251            stdout_hash: [0u8; 32],
1252            stderr_hash: [0u8; 32],
1253            exit_status: 0,
1254            created_at_unix: 0,
1255        };
1256        write_manifest_to_entry(&cache, &key_corrupt, &m);
1257
1258        let key_stale = key_with_first_byte(0xAA);
1259        store_entry_at(&cache, &key_stale, "proj/x", b"x", 100);
1260
1261        let opts = CleanOptions {
1262            soft: true,
1263            max_age: Some(MaxAge::parse("50s").unwrap()),
1264            now_unix: 300,
1265            ..Default::default()
1266        };
1267        let report = cache.clean(&opts).unwrap().report;
1268        assert_eq!(report.evicted_by_soft, 1);
1269        assert_eq!(report.evicted_by_max_age, 1);
1270        assert_eq!(report.inspected, 2);
1271    }
1272
1273    #[test]
1274    fn aux_023_clean_evicted_entries_sorted_by_mode_then_created_at() {
1275        let mut fs = MemFilesystem::new();
1276        fs.add_dir("/ws").unwrap();
1277        let cache = make_cache(fs, HashAlgo::Blake3);
1278
1279        // Two well-formed entries that both fall under max-age.
1280        let key_a = key_with_first_byte(0x11);
1281        let key_b = key_with_first_byte(0x22);
1282        store_entry_at(&cache, &key_a, "proj/a", b"x", 100);
1283        store_entry_at(&cache, &key_b, "proj/b", b"y", 200);
1284
1285        // One corrupt entry under --soft.
1286        let key_corrupt = key_with_first_byte(0xCC);
1287        let stale = Manifest {
1288            chapter_revision: CHAPTER_REVISION.saturating_add(1),
1289            hash_function: HashFunctionLabel::Blake3,
1290            key: key_corrupt,
1291            outputs: vec![],
1292            stdout_len: 0,
1293            stderr_len: 0,
1294            stdout_hash: [0u8; 32],
1295            stderr_hash: [0u8; 32],
1296            exit_status: 0,
1297            created_at_unix: 0,
1298        };
1299        write_manifest_to_entry(&cache, &key_corrupt, &stale);
1300
1301        let opts = CleanOptions {
1302            soft: true,
1303            max_age: Some(MaxAge::parse("50s").unwrap()),
1304            now_unix: 300,
1305            ..Default::default()
1306        };
1307        let report = cache.clean(&opts).unwrap().report;
1308        assert_eq!(report.evicted_entries.len(), 3);
1309        assert_eq!(report.evicted_entries[0].matched_mode, EvictionMode::Soft);
1310        assert_eq!(report.evicted_entries[1].matched_mode, EvictionMode::MaxAge);
1311        assert_eq!(
1312            report.evicted_entries[1].created_at_unix, 100,
1313            "older max-age entry sorts before newer one"
1314        );
1315        assert_eq!(report.evicted_entries[2].matched_mode, EvictionMode::MaxAge);
1316        assert_eq!(report.evicted_entries[2].created_at_unix, 200);
1317    }
1318
1319    // ---- clean: dry-run ----
1320
1321    #[test]
1322    fn aux_023_clean_dry_run_does_not_modify_disk() {
1323        let mut fs = MemFilesystem::new();
1324        fs.add_dir("/ws").unwrap();
1325        let cache = make_cache(fs, HashAlgo::Blake3);
1326
1327        let key = key_with_first_byte(0xAA);
1328        store_entry_at(&cache, &key, "proj/x", b"x", 100);
1329
1330        let opts = CleanOptions {
1331            max_age: Some(MaxAge::parse("50s").unwrap()),
1332            now_unix: 300,
1333            dry_run: true,
1334            ..Default::default()
1335        };
1336        let report = cache.clean(&opts).unwrap().report;
1337        assert_eq!(report.evicted_by_max_age, 1);
1338        assert_eq!(report.evicted_entries.len(), 1);
1339        assert_eq!(report.evicted_entries[0].matched_mode, EvictionMode::MaxAge);
1340        assert!(
1341            cache.reader().lookup(&key).is_some(),
1342            "dry-run must leave the entry on disk"
1343        );
1344    }
1345
1346    #[test]
1347    fn aux_023_clean_dry_run_under_soft_keeps_tmp_and_restore_dirs() {
1348        let mut fs = MemFilesystem::new();
1349        fs.add_dir("/ws").unwrap();
1350        let cache = make_cache(fs, HashAlgo::Blake3);
1351
1352        let key_tmp = key_with_first_byte(0xEF);
1353        let tmp = layout::tmp_entry_dir(cache.cache_root(), &key_tmp, "rnd1");
1354        cache.fs().create_dir_all(&tmp).unwrap();
1355
1356        let key_restore = key_with_first_byte(0x12);
1357        let staging = layout::restore_staging_dir(cache.cache_root(), &key_restore, "rnd2");
1358        cache.fs().create_dir_all(&staging).unwrap();
1359
1360        let opts = CleanOptions {
1361            soft: true,
1362            dry_run: true,
1363            ..Default::default()
1364        };
1365        let report = cache.clean(&opts).unwrap().report;
1366        assert_eq!(report.removed_tmp_dirs, 1);
1367        assert_eq!(report.removed_restore_dirs, 1);
1368        assert!(cache.fs().metadata(&tmp).is_ok());
1369        assert!(cache.fs().metadata(&staging).is_ok());
1370    }
1371
1372    #[test]
1373    fn aux_023_clean_bytes_reclaimed_sums_evicted_footprints() {
1374        let mut fs = MemFilesystem::new();
1375        fs.add_dir("/ws").unwrap();
1376        let cache = make_cache(fs, HashAlgo::Blake3);
1377
1378        let key_a = key_with_first_byte(0x11);
1379        store_entry_at(&cache, &key_a, "proj/a", b"hello", 100); // 5 bytes
1380        let key_b = key_with_first_byte(0x22);
1381        store_entry_at(&cache, &key_b, "proj/b", b"world!", 200); // 6 bytes
1382
1383        let opts = CleanOptions {
1384            max_age: Some(MaxAge::parse("50s").unwrap()),
1385            now_unix: 300,
1386            ..Default::default()
1387        };
1388        let report = cache.clean(&opts).unwrap().report;
1389        assert_eq!(report.evicted_by_max_age, 2);
1390        assert_eq!(report.bytes_reclaimed, 11);
1391    }
1392
1393    // ---- clean: AUX-028 best-effort ----
1394
1395    /// A schema-stale (hash-function-mismatch) manifest, so the
1396    /// entry is objectively stale per `CACHE-022` (soft-eligible)
1397    /// without depending on the current `CHAPTER_REVISION`.
1398    fn corrupt_manifest(key: CacheKey) -> Manifest {
1399        Manifest {
1400            chapter_revision: CHAPTER_REVISION,
1401            hash_function: HashFunctionLabel::Sha256,
1402            key,
1403            outputs: vec![],
1404            stdout_len: 0,
1405            stderr_len: 0,
1406            stdout_hash: [0u8; 32],
1407            stderr_hash: [0u8; 32],
1408            exit_status: 0,
1409            created_at_unix: 0,
1410        }
1411    }
1412
1413    #[test]
1414    fn aux_028_clean_soft_is_best_effort_when_one_removal_fails() {
1415        let mut fs = MemFilesystem::new();
1416        fs.add_dir("/ws").unwrap();
1417        let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1418
1419        let key_stuck = key_with_first_byte(0xAA);
1420        let key_ok = key_with_first_byte(0xBB);
1421        let stuck_dir = layout::entry_dir(&cache_root, &key_stuck);
1422        let ok_dir = layout::entry_dir(&cache_root, &key_ok);
1423        fs.fail_on(
1424            MemFaultOp::RemoveDirAll,
1425            &stuck_dir,
1426            ErrorKind::PermissionDenied,
1427        );
1428
1429        let cache = make_cache(fs, HashAlgo::Blake3);
1430        write_manifest_to_entry(&cache, &key_stuck, &corrupt_manifest(key_stuck));
1431        write_manifest_to_entry(&cache, &key_ok, &corrupt_manifest(key_ok));
1432
1433        let outcome = cache.clean(&soft_only()).unwrap();
1434
1435        // The removable entry is evicted despite the other failing.
1436        assert!(
1437            cache.fs().metadata(&stuck_dir).is_ok(),
1438            "stuck entry remains"
1439        );
1440        assert!(
1441            cache.fs().metadata(&ok_dir).is_err(),
1442            "removable entry evicted despite the other failing"
1443        );
1444        // Counts reflect what actually succeeded, not the plan.
1445        assert_eq!(outcome.report.evicted_by_soft, 1);
1446        assert_eq!(outcome.report.evicted_entries.len(), 1);
1447        assert_eq!(outcome.failures.len(), 1);
1448        assert!(matches!(
1449            &outcome.failures[0],
1450            CleanFailure::Entry { path, .. } if path == &stuck_dir
1451        ));
1452    }
1453
1454    #[test]
1455    fn aux_028_clean_collects_every_removal_failure_not_just_the_first() {
1456        let mut fs = MemFilesystem::new();
1457        fs.add_dir("/ws").unwrap();
1458        let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1459        let key_a = key_with_first_byte(0xA1);
1460        let key_b = key_with_first_byte(0xB2);
1461        fs.fail_on(
1462            MemFaultOp::RemoveDirAll,
1463            layout::entry_dir(&cache_root, &key_a),
1464            ErrorKind::PermissionDenied,
1465        );
1466        fs.fail_on(
1467            MemFaultOp::RemoveDirAll,
1468            layout::entry_dir(&cache_root, &key_b),
1469            ErrorKind::PermissionDenied,
1470        );
1471
1472        let cache = make_cache(fs, HashAlgo::Blake3);
1473        write_manifest_to_entry(&cache, &key_a, &corrupt_manifest(key_a));
1474        write_manifest_to_entry(&cache, &key_b, &corrupt_manifest(key_b));
1475
1476        let outcome = cache.clean(&soft_only()).unwrap();
1477        assert_eq!(
1478            outcome.report.evicted_by_soft, 0,
1479            "neither removal succeeded"
1480        );
1481        assert_eq!(
1482            outcome.failures.len(),
1483            2,
1484            "both failures surfaced, not just the first"
1485        );
1486    }
1487
1488    #[test]
1489    fn aux_028_clean_soft_best_effort_across_tmp_and_restore() {
1490        let mut fs = MemFilesystem::new();
1491        fs.add_dir("/ws").unwrap();
1492        let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1493        let tmp = layout::tmp_entry_dir(&cache_root, &key_with_first_byte(0xEF), "rnd1");
1494        let staging = layout::restore_staging_dir(&cache_root, &key_with_first_byte(0x12), "rnd2");
1495        fs.fail_on(MemFaultOp::RemoveDirAll, &tmp, ErrorKind::PermissionDenied);
1496
1497        let cache = make_cache(fs, HashAlgo::Blake3);
1498        cache.fs().create_dir_all(&tmp).unwrap();
1499        cache.fs().create_dir_all(&staging).unwrap();
1500
1501        let outcome = cache.clean(&soft_only()).unwrap();
1502        // The restore staging dir is still removed despite tmp failing.
1503        assert!(
1504            cache.fs().metadata(&staging).is_err(),
1505            "restore dir removed"
1506        );
1507        assert!(cache.fs().metadata(&tmp).is_ok(), "stuck tmp dir remains");
1508        assert_eq!(outcome.report.removed_restore_dirs, 1);
1509        assert_eq!(
1510            outcome.report.removed_tmp_dirs, 0,
1511            "tmp removal failed, not counted"
1512        );
1513        assert_eq!(outcome.failures.len(), 1);
1514        assert!(matches!(
1515            &outcome.failures[0],
1516            CleanFailure::Tmp { path, .. } if path == &tmp
1517        ));
1518    }
1519
1520    #[test]
1521    fn aux_028_clean_skips_unreadable_shard_and_cleans_the_rest() {
1522        let mut fs = MemFilesystem::new();
1523        fs.add_dir("/ws").unwrap();
1524        let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1525        let key_blocked = key_with_first_byte(0xAA);
1526        let key_ok = key_with_first_byte(0xBB);
1527        let blocked_shard = layout::shard_dir(&cache_root, &key_blocked);
1528        fs.fail_on(
1529            MemFaultOp::ReadDir,
1530            &blocked_shard,
1531            ErrorKind::PermissionDenied,
1532        );
1533
1534        let cache = make_cache(fs, HashAlgo::Blake3);
1535        write_manifest_to_entry(&cache, &key_blocked, &corrupt_manifest(key_blocked));
1536        write_manifest_to_entry(&cache, &key_ok, &corrupt_manifest(key_ok));
1537
1538        let outcome = cache.clean(&soft_only()).unwrap();
1539        assert!(
1540            cache
1541                .fs()
1542                .metadata(&layout::entry_dir(&cache_root, &key_ok))
1543                .is_err(),
1544            "entry in the readable shard is evicted"
1545        );
1546        assert!(
1547            cache
1548                .fs()
1549                .metadata(&layout::entry_dir(&cache_root, &key_blocked))
1550                .is_ok(),
1551            "entry in the unreadable shard is left untouched"
1552        );
1553        assert_eq!(outcome.report.evicted_by_soft, 1);
1554        assert_eq!(outcome.failures.len(), 1);
1555        assert!(matches!(
1556            &outcome.failures[0],
1557            CleanFailure::Shard { path, .. } if path == &blocked_shard
1558        ));
1559    }
1560
1561    #[test]
1562    fn aux_028_clean_skips_unreadable_manifest_and_cleans_the_rest() {
1563        let mut fs = MemFilesystem::new();
1564        fs.add_dir("/ws").unwrap();
1565        let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1566        let key_blocked = key_with_first_byte(0xAA);
1567        let key_ok = key_with_first_byte(0xCC);
1568        let blocked_manifest = layout::manifest_path(&cache_root, &key_blocked);
1569        fs.fail_on(
1570            MemFaultOp::Read,
1571            &blocked_manifest,
1572            ErrorKind::PermissionDenied,
1573        );
1574
1575        let cache = make_cache(fs, HashAlgo::Blake3);
1576        write_manifest_to_entry(&cache, &key_blocked, &corrupt_manifest(key_blocked));
1577        write_manifest_to_entry(&cache, &key_ok, &corrupt_manifest(key_ok));
1578
1579        let outcome = cache.clean(&soft_only()).unwrap();
1580        assert!(
1581            cache
1582                .fs()
1583                .metadata(&layout::entry_dir(&cache_root, &key_ok))
1584                .is_err(),
1585            "entry with a readable manifest is evicted"
1586        );
1587        assert!(
1588            cache
1589                .fs()
1590                .metadata(&layout::entry_dir(&cache_root, &key_blocked))
1591                .is_ok(),
1592            "entry with an unreadable manifest is left untouched"
1593        );
1594        assert_eq!(outcome.report.evicted_by_soft, 1);
1595        assert_eq!(outcome.failures.len(), 1);
1596        assert!(matches!(
1597            &outcome.failures[0],
1598            CleanFailure::Manifest { path, .. } if path == &blocked_manifest
1599        ));
1600    }
1601
1602    #[test]
1603    fn aux_028_clean_unreadable_cache_root_is_fatal_with_no_report() {
1604        let mut fs = MemFilesystem::new();
1605        fs.add_dir("/ws").unwrap();
1606        let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1607        // The cache root cannot be enumerated, so no eviction set can
1608        // be computed: this is the one fatal case per AUX-028.
1609        fs.fail_on(
1610            MemFaultOp::ReadDir,
1611            &cache_root,
1612            ErrorKind::PermissionDenied,
1613        );
1614        let cache = make_cache(fs, HashAlgo::Blake3);
1615
1616        let result = cache.clean(&soft_only());
1617        assert!(
1618            matches!(result, Err(CleanError::Io { .. })),
1619            "an unreadable cache root is fatal"
1620        );
1621    }
1622
1623    #[test]
1624    fn aux_028_dry_run_records_no_failures_even_with_a_remove_fault() {
1625        let mut fs = MemFilesystem::new();
1626        fs.add_dir("/ws").unwrap();
1627        let cache_root = layout::cache_root(Path::new(WORKSPACE_ROOT));
1628        let key = key_with_first_byte(0xAA);
1629        let entry_dir = layout::entry_dir(&cache_root, &key);
1630        fs.fail_on(
1631            MemFaultOp::RemoveDirAll,
1632            &entry_dir,
1633            ErrorKind::PermissionDenied,
1634        );
1635
1636        let cache = make_cache(fs, HashAlgo::Blake3);
1637        write_manifest_to_entry(&cache, &key, &corrupt_manifest(key));
1638
1639        let opts = CleanOptions {
1640            soft: true,
1641            dry_run: true,
1642            ..Default::default()
1643        };
1644        let outcome = cache.clean(&opts).unwrap();
1645        assert!(
1646            outcome.failures.is_empty(),
1647            "dry-run never touches disk, so no removal failure is recorded"
1648        );
1649        assert_eq!(
1650            outcome.report.evicted_by_soft, 1,
1651            "dry-run still projects the plan"
1652        );
1653        assert!(
1654            cache.fs().metadata(&entry_dir).is_ok(),
1655            "dry-run leaves the entry on disk"
1656        );
1657    }
1658}