Skip to main content

sloc_core/
maintenance.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 Nima Shafie <nimzshafie@gmail.com>
3
4//! Disk-hygiene primitives for reclaiming space taken by old scan artifacts and
5//! log files.
6//!
7//! These functions are deliberately auth-free and side-effect-transparent: they
8//! operate directly on the on-disk registry and output tree, so the same logic
9//! can drive the operator's `oxide-sloc prune` CLI command, the web UI's
10//! auto-cleanup policy, and the audit-log rotation. The trust boundary is the
11//! host: whoever can run the binary or reach these files already owns them, so
12//! no separate "admin" credential is required — the shell (for the CLI) and the
13//! existing API-key gate (for the web surface) are the authority.
14//!
15//! Nothing here deletes source code, configuration, or anything outside the
16//! resolved output root / configured log path.
17
18use std::path::{Path, PathBuf};
19
20use chrono::{DateTime, Utc};
21
22use crate::history::{RegistryEntry, ScanRegistry};
23
24// ── Output-root / registry resolution (shared by CLI + web) ─────────────────────
25
26/// Workspace root used to anchor the default output tree.
27///
28/// `OXIDE_SLOC_ROOT` wins when it names a real directory (Docker / systemd / CI);
29/// otherwise the current working directory is used, matching the web server's resolution.
30#[must_use]
31pub fn workspace_root() -> PathBuf {
32    if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
33        let p = PathBuf::from(root);
34        if p.is_dir() {
35            return p;
36        }
37    }
38    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
39}
40
41/// Resolve the artifact output root.
42///
43/// Mirrors the web server so the CLI prunes exactly the tree the server writes to.
44/// An explicit `raw` path (e.g. a CLI `--output-dir`) overrides; a relative value
45/// is anchored at [`workspace_root`].
46#[must_use]
47pub fn resolve_output_root(raw: Option<&str>) -> PathBuf {
48    let value = raw.unwrap_or("out/web").trim();
49    let path = if value.is_empty() {
50        PathBuf::from("out/web")
51    } else {
52        PathBuf::from(value)
53    };
54    if path.is_absolute() {
55        path
56    } else {
57        workspace_root().join(path)
58    }
59}
60
61/// Path to the scan registry for a given output root. Honours `SLOC_REGISTRY_PATH`
62/// so a shared-drive deployment and its CLI agree on the same index.
63#[must_use]
64pub fn resolve_registry_path(output_root: &Path) -> PathBuf {
65    std::env::var("SLOC_REGISTRY_PATH")
66        .map_or_else(|_| output_root.join("registry.json"), PathBuf::from)
67}
68
69// ── Sizing ──────────────────────────────────────────────────────────────────────
70
71/// Total size in bytes of a directory tree. Best-effort: unreadable entries are
72/// skipped rather than erroring, so a partial permission failure still yields a
73/// useful estimate.
74#[must_use]
75pub fn dir_size_bytes(path: &Path) -> u64 {
76    fn walk(path: &Path, acc: &mut u64) {
77        let Ok(entries) = std::fs::read_dir(path) else {
78            return;
79        };
80        for entry in entries.flatten() {
81            let Ok(ft) = entry.file_type() else { continue };
82            if ft.is_dir() {
83                walk(&entry.path(), acc);
84            } else if let Ok(meta) = entry.metadata() {
85                *acc += meta.len();
86            }
87        }
88    }
89    let mut total = 0;
90    if path.is_file() {
91        return std::fs::metadata(path).map_or(0, |m| m.len());
92    }
93    walk(path, &mut total);
94    total
95}
96
97/// Recursively copy the file tree at `src` into `dest`, creating `dest` and any
98/// intermediate directories. Returns `(files_copied, bytes_written)`.
99///
100/// Symlinks are **not** followed (a link is skipped, not dereferenced) so a link
101/// inside a scanned/uploaded tree cannot redirect the copy outside `src` or loop.
102/// Used to publish a run's artifacts to an operator-configured export directory
103/// (e.g. a mounted network share), so it must be robust against hostile trees.
104///
105/// # Errors
106/// Returns the first I/O error encountered while creating `dest` or copying a file.
107pub fn copy_tree(src: &Path, dest: &Path) -> std::io::Result<(usize, u64)> {
108    std::fs::create_dir_all(dest)?;
109    let mut files = 0usize;
110    let mut bytes = 0u64;
111    // Iterative DFS over (src_dir, dest_dir) pairs.
112    let mut stack = vec![(src.to_path_buf(), dest.to_path_buf())];
113    while let Some((from, to)) = stack.pop() {
114        for entry in std::fs::read_dir(&from)?.flatten() {
115            let ft = entry.file_type()?;
116            if ft.is_symlink() {
117                continue;
118            }
119            let target = to.join(entry.file_name());
120            if ft.is_dir() {
121                std::fs::create_dir_all(&target)?;
122                stack.push((entry.path(), target));
123            } else if ft.is_file() {
124                let n = std::fs::copy(entry.path(), &target)?;
125                files += 1;
126                bytes = bytes.saturating_add(n);
127            }
128        }
129    }
130    Ok((files, bytes))
131}
132
133/// Derive the on-disk output directory that holds a run's artifacts from its registry entry.
134///
135/// Handles both the current layout (files nested in `html/` `json/` `pdf/` `excel/`
136/// subfolders — go up two levels) and the older flat layout (go up one level).
137/// Returns `None` when the entry stored no paths.
138#[must_use]
139pub fn run_output_dir(entry: &RegistryEntry) -> Option<PathBuf> {
140    let p = entry
141        .html_path
142        .as_ref()
143        .or(entry.json_path.as_ref())
144        .or(entry.pdf_path.as_ref())
145        .or(entry.csv_path.as_ref())
146        .or(entry.xlsx_path.as_ref())?;
147    let parent = p.parent()?;
148    let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
149    if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
150        parent.parent().map(PathBuf::from)
151    } else {
152        Some(parent.to_path_buf())
153    }
154}
155
156// ── Run pruning ─────────────────────────────────────────────────────────────────
157
158/// One run selected for removal, with the disk it will reclaim.
159#[derive(Debug, Clone)]
160pub struct PrunedRun {
161    pub run_id: String,
162    pub project_label: String,
163    pub timestamp_utc: DateTime<Utc>,
164    pub output_dir: Option<PathBuf>,
165    pub bytes: u64,
166}
167
168/// The set of runs a prune would remove, computed without touching disk.
169#[derive(Debug, Clone, Default)]
170pub struct PrunePlan {
171    pub runs: Vec<PrunedRun>,
172    pub total_bytes: u64,
173}
174
175impl PrunePlan {
176    #[must_use]
177    pub const fn is_empty(&self) -> bool {
178        self.runs.is_empty()
179    }
180}
181
182/// Compute which runs to delete under the given retention rules, newest-kept.
183///
184/// * `older_than_days` — delete runs whose timestamp is older than N days.
185/// * `keep_last` — keep only the N most-recent runs, delete the rest.
186///
187/// A run is selected if it matches *either* rule (union). With neither rule set
188/// the plan is empty — pruning is always opt-in about what "old" means. Registry
189/// entries are assumed newest-first (as maintained by [`ScanRegistry::add_entry`]).
190#[must_use]
191pub fn plan_run_prune(
192    reg: &ScanRegistry,
193    older_than_days: Option<u32>,
194    keep_last: Option<u32>,
195) -> PrunePlan {
196    use std::collections::HashSet;
197
198    // Work on a timestamp-sorted (newest-first) view so `keep_last` is stable even
199    // if the on-disk registry was hand-edited out of order.
200    let mut ordered: Vec<&RegistryEntry> = reg.entries.iter().collect();
201    ordered.sort_by_key(|e| std::cmp::Reverse(e.timestamp_utc));
202
203    let mut selected: HashSet<&str> = HashSet::new();
204    if let Some(days) = older_than_days {
205        let cutoff = Utc::now() - chrono::Duration::days(i64::from(days));
206        for e in &ordered {
207            if e.timestamp_utc < cutoff {
208                selected.insert(e.run_id.as_str());
209            }
210        }
211    }
212    if let Some(keep) = keep_last {
213        for e in ordered.iter().skip(keep as usize) {
214            selected.insert(e.run_id.as_str());
215        }
216    }
217
218    let mut runs = Vec::new();
219    let mut total_bytes = 0u64;
220    for e in &ordered {
221        if !selected.contains(e.run_id.as_str()) {
222            continue;
223        }
224        let output_dir = run_output_dir(e);
225        let bytes = output_dir.as_deref().map_or(0, dir_size_bytes);
226        total_bytes += bytes;
227        runs.push(PrunedRun {
228            run_id: e.run_id.clone(),
229            project_label: e.project_label.clone(),
230            timestamp_utc: e.timestamp_utc,
231            output_dir,
232            bytes,
233        });
234    }
235    PrunePlan { runs, total_bytes }
236}
237
238/// Outcome of executing a [`PrunePlan`].
239#[derive(Debug, Clone, Default)]
240pub struct PruneReport {
241    pub deleted_runs: usize,
242    pub bytes_freed: u64,
243    /// Runs whose on-disk directory could not be removed (path + error string).
244    pub failures: Vec<(String, String)>,
245}
246
247/// Delete the artifacts named by `plan` and drop their entries from `reg`.
248///
249/// The registry is mutated in place; the caller is responsible for persisting it
250/// via [`ScanRegistry::save`]. Directory-removal failures are collected rather
251/// than aborting so one locked run does not block reclaiming the rest.
252#[must_use]
253pub fn execute_run_prune(reg: &mut ScanRegistry, plan: &PrunePlan) -> PruneReport {
254    use std::collections::HashSet;
255
256    let mut report = PruneReport::default();
257    let mut removed_ids: HashSet<String> = HashSet::new();
258
259    for run in &plan.runs {
260        if let Some(dir) = &run.output_dir
261            && dir.exists()
262            && let Err(e) = std::fs::remove_dir_all(dir)
263            && e.kind() != std::io::ErrorKind::NotFound
264        {
265            report
266                .failures
267                .push((dir.display().to_string(), e.to_string()));
268            continue;
269        }
270        report.bytes_freed += run.bytes;
271        report.deleted_runs += 1;
272        removed_ids.insert(run.run_id.clone());
273    }
274
275    reg.entries.retain(|e| !removed_ids.contains(&e.run_id));
276    report
277}
278
279// ── Log rotation ────────────────────────────────────────────────────────────────
280
281/// Rotate `path` when it exceeds `max_bytes`, keeping up to `keep` compressed-by-age
282/// generations (`path.1`, `path.2`, …). Returns `Ok(true)` when a rotation happened.
283///
284/// Rotation is size-triggered and lossless up to `keep`: `path.(keep)` is dropped,
285/// each `path.N` shifts to `path.(N+1)`, the live file becomes `path.1`, and a
286/// fresh empty live file is implied (the caller re-creates it on next append).
287/// `keep == 0` simply truncates the oversized file, retaining no history.
288///
289/// # Errors
290///
291/// Returns an error if a rename or removal fails. A missing live file or a file
292/// under the threshold is a no-op that returns `Ok(false)`.
293pub fn rotate_log(path: &Path, max_bytes: u64, keep: u32) -> anyhow::Result<bool> {
294    let Ok(meta) = std::fs::metadata(path) else {
295        return Ok(false); // no live file yet
296    };
297    if meta.len() <= max_bytes {
298        return Ok(false);
299    }
300
301    if keep == 0 {
302        // No history retained: just clear the file in place.
303        std::fs::write(path, b"")?;
304        return Ok(true);
305    }
306
307    // Drop the oldest generation, then shift each remaining one down by one.
308    let gen_path = |n: u32| -> PathBuf {
309        let mut s = path.as_os_str().to_owned();
310        s.push(format!(".{n}"));
311        PathBuf::from(s)
312    };
313    let oldest = gen_path(keep);
314    if oldest.exists() {
315        std::fs::remove_file(&oldest)?;
316    }
317    for n in (1..keep).rev() {
318        let from = gen_path(n);
319        if from.exists() {
320            std::fs::rename(&from, gen_path(n + 1))?;
321        }
322    }
323    std::fs::rename(path, gen_path(1))?;
324    Ok(true)
325}
326
327/// Every rotated generation of `path` that currently exists on disk (`path.1`, …),
328/// scanning until the first gap. Used by the CLI to report and remove log history.
329#[must_use]
330pub fn rotated_log_paths(path: &Path) -> Vec<PathBuf> {
331    let mut out = Vec::new();
332    let mut n = 1u32;
333    loop {
334        let mut s = path.as_os_str().to_owned();
335        s.push(format!(".{n}"));
336        let p = PathBuf::from(s);
337        if p.exists() {
338            out.push(p);
339            n += 1;
340        } else {
341            break;
342        }
343    }
344    out
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::history::{RegistryEntry, ScanRegistry, ScanSummarySnapshot};
351    use std::sync::{Mutex, MutexGuard, OnceLock};
352
353    /// Tests that mutate process-global environment variables must hold this lock
354    /// for their whole duration so the parallel test runner cannot observe each
355    /// other's `set_var`/`remove_var` changes.
356    fn env_lock() -> MutexGuard<'static, ()> {
357        static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
358        ENV_MUTEX
359            .get_or_init(|| Mutex::new(()))
360            .lock()
361            .unwrap_or_else(std::sync::PoisonError::into_inner)
362    }
363
364    #[test]
365    fn copy_tree_copies_nested_files_and_counts() {
366        let src = tempfile::tempdir().unwrap();
367        let dst = tempfile::tempdir().unwrap();
368        std::fs::create_dir_all(src.path().join("json")).unwrap();
369        std::fs::write(src.path().join("json").join("result.json"), b"{\"a\":1}").unwrap();
370        std::fs::write(src.path().join("top.txt"), b"hello").unwrap();
371
372        let dest_root = dst.path().join("run-123");
373        let (files, bytes) = copy_tree(src.path(), &dest_root).unwrap();
374
375        assert_eq!(files, 2);
376        assert_eq!(bytes, 7 + 5);
377        assert!(dest_root.join("json").join("result.json").is_file());
378        assert!(dest_root.join("top.txt").is_file());
379        assert_eq!(
380            std::fs::read(dest_root.join("json").join("result.json")).unwrap(),
381            b"{\"a\":1}"
382        );
383    }
384
385    #[test]
386    fn copy_tree_empty_source_creates_dest() {
387        let src = tempfile::tempdir().unwrap();
388        let dst = tempfile::tempdir().unwrap();
389        let dest_root = dst.path().join("out");
390        let (files, bytes) = copy_tree(src.path(), &dest_root).unwrap();
391        assert_eq!((files, bytes), (0, 0));
392        assert!(dest_root.is_dir());
393    }
394
395    fn entry(run_id: &str, age_days: i64, root: &Path) -> RegistryEntry {
396        let dir = root.join(run_id);
397        std::fs::create_dir_all(dir.join("json")).unwrap();
398        std::fs::write(dir.join("json").join("result.json"), b"{}").unwrap();
399        RegistryEntry {
400            run_id: run_id.to_owned(),
401            timestamp_utc: Utc::now() - chrono::Duration::days(age_days),
402            project_label: format!("proj-{run_id}"),
403            input_roots: vec![],
404            json_path: Some(dir.join("json").join("result.json")),
405            html_path: None,
406            pdf_path: None,
407            csv_path: None,
408            xlsx_path: None,
409            summary: ScanSummarySnapshot::default(),
410            git_branch: None,
411            git_commit: None,
412            git_commit_long: None,
413            git_author: None,
414            git_tags: None,
415            git_nearest_tag: None,
416            git_commit_date: None,
417            scan_os: None,
418            scan_host: None,
419            scan_user: None,
420            scan_ci: None,
421        }
422    }
423
424    fn tmp() -> PathBuf {
425        let d = std::env::temp_dir().join(format!("sloc_maint_{}", uuid::Uuid::new_v4()));
426        std::fs::create_dir_all(&d).unwrap();
427        d
428    }
429
430    #[test]
431    fn run_output_dir_handles_nested_layout() {
432        let root = tmp();
433        let e = entry("abc", 0, &root);
434        // json is in <root>/abc/json/result.json → output dir is <root>/abc
435        assert_eq!(run_output_dir(&e), Some(root.join("abc")));
436        std::fs::remove_dir_all(&root).ok();
437    }
438
439    #[test]
440    fn plan_selects_by_age() {
441        let root = tmp();
442        let mut reg = ScanRegistry::default();
443        reg.entries.push(entry("old", 40, &root));
444        reg.entries.push(entry("new", 1, &root));
445
446        let plan = plan_run_prune(&reg, Some(30), None);
447        assert_eq!(plan.runs.len(), 1);
448        assert_eq!(plan.runs[0].run_id, "old");
449        std::fs::remove_dir_all(&root).ok();
450    }
451
452    #[test]
453    fn plan_selects_by_keep_last() {
454        let root = tmp();
455        let mut reg = ScanRegistry::default();
456        for (i, id) in ["a", "b", "c", "d"].iter().enumerate() {
457            reg.entries
458                .push(entry(id, i64::try_from(i).unwrap(), &root)); // a newest .. d oldest
459        }
460        let plan = plan_run_prune(&reg, None, Some(2));
461        let ids: Vec<_> = plan.runs.iter().map(|r| r.run_id.clone()).collect();
462        assert_eq!(plan.runs.len(), 2, "keep 2, delete the 2 oldest");
463        assert!(ids.contains(&"c".to_owned()) && ids.contains(&"d".to_owned()));
464        std::fs::remove_dir_all(&root).ok();
465    }
466
467    #[test]
468    fn execute_removes_dirs_and_entries() {
469        let root = tmp();
470        let mut reg = ScanRegistry::default();
471        reg.entries.push(entry("gone", 40, &root));
472        reg.entries.push(entry("kept", 1, &root));
473
474        let plan = plan_run_prune(&reg, Some(30), None);
475        let report = execute_run_prune(&mut reg, &plan);
476
477        assert_eq!(report.deleted_runs, 1);
478        assert!(report.failures.is_empty());
479        assert!(!root.join("gone").exists(), "artifacts removed");
480        assert!(root.join("kept").exists(), "recent run untouched");
481        assert_eq!(reg.entries.len(), 1);
482        assert_eq!(reg.entries[0].run_id, "kept");
483        std::fs::remove_dir_all(&root).ok();
484    }
485
486    #[test]
487    fn empty_plan_when_no_rules() {
488        let root = tmp();
489        let mut reg = ScanRegistry::default();
490        reg.entries.push(entry("x", 100, &root));
491        assert!(plan_run_prune(&reg, None, None).is_empty());
492        std::fs::remove_dir_all(&root).ok();
493    }
494
495    #[test]
496    fn rotate_log_shifts_generations() {
497        let root = tmp();
498        let log = root.join("audit.log");
499        std::fs::write(&log, vec![b'x'; 100]).unwrap();
500
501        // Under threshold: no-op.
502        assert!(!rotate_log(&log, 1000, 3).unwrap());
503        // Over threshold: rotates to audit.log.1, live file gone (recreated by caller).
504        assert!(rotate_log(&log, 50, 3).unwrap());
505        assert!(log.with_extension("log.1").exists());
506        assert!(!log.exists());
507
508        // Second rotation shifts .1 → .2.
509        std::fs::write(&log, vec![b'y'; 100]).unwrap();
510        assert!(rotate_log(&log, 50, 3).unwrap());
511        assert!(log.with_extension("log.1").exists());
512        assert!(log.with_extension("log.2").exists());
513        assert_eq!(rotated_log_paths(&log).len(), 2);
514        std::fs::remove_dir_all(&root).ok();
515    }
516
517    #[test]
518    fn rotate_log_keep_zero_truncates() {
519        let root = tmp();
520        let log = root.join("audit.log");
521        std::fs::write(&log, vec![b'x'; 100]).unwrap();
522        assert!(rotate_log(&log, 10, 0).unwrap());
523        assert!(log.exists());
524        assert_eq!(std::fs::metadata(&log).unwrap().len(), 0);
525        std::fs::remove_dir_all(&root).ok();
526    }
527
528    #[test]
529    fn workspace_root_prefers_env_dir_then_falls_back() {
530        let _guard = env_lock();
531        let dir = tmp();
532        // FIXME: Audit that the environment access only happens in single-threaded code.
533        unsafe { std::env::set_var("OXIDE_SLOC_ROOT", &dir) };
534        assert_eq!(workspace_root(), dir);
535        // A non-existent path is ignored → falls back to CWD (a real dir).
536        // FIXME: Audit that the environment access only happens in single-threaded code.
537        unsafe { std::env::set_var("OXIDE_SLOC_ROOT", dir.join("does-not-exist")) };
538        assert!(workspace_root().is_dir());
539        // FIXME: Audit that the environment access only happens in single-threaded code.
540        unsafe { std::env::remove_var("OXIDE_SLOC_ROOT") };
541        assert!(workspace_root().is_dir());
542        std::fs::remove_dir_all(&dir).ok();
543    }
544
545    #[test]
546    fn resolve_output_root_handles_absolute_relative_and_default() {
547        let _guard = env_lock();
548        let dir = tmp();
549        // Absolute path is returned verbatim.
550        let abs = dir.join("art");
551        assert_eq!(resolve_output_root(Some(abs.to_str().unwrap())), abs);
552        // Empty/whitespace falls back to the default relative tree under the root.
553        // FIXME: Audit that the environment access only happens in single-threaded code.
554        unsafe { std::env::set_var("OXIDE_SLOC_ROOT", &dir) };
555        assert_eq!(resolve_output_root(Some("   ")), dir.join("out/web"));
556        assert_eq!(resolve_output_root(None), dir.join("out/web"));
557        // A relative override is anchored at the workspace root.
558        assert_eq!(
559            resolve_output_root(Some("custom/out")),
560            dir.join("custom/out")
561        );
562        // FIXME: Audit that the environment access only happens in single-threaded code.
563        unsafe { std::env::remove_var("OXIDE_SLOC_ROOT") };
564        std::fs::remove_dir_all(&dir).ok();
565    }
566
567    #[test]
568    fn resolve_registry_path_honours_env_override() {
569        let _guard = env_lock();
570        let dir = tmp();
571        // FIXME: Audit that the environment access only happens in single-threaded code.
572        unsafe { std::env::remove_var("SLOC_REGISTRY_PATH") };
573        assert_eq!(resolve_registry_path(&dir), dir.join("registry.json"));
574        // FIXME: Audit that the environment access only happens in single-threaded code.
575        unsafe { std::env::set_var("SLOC_REGISTRY_PATH", dir.join("shared.json")) };
576        assert_eq!(resolve_registry_path(&dir), dir.join("shared.json"));
577        // FIXME: Audit that the environment access only happens in single-threaded code.
578        unsafe { std::env::remove_var("SLOC_REGISTRY_PATH") };
579        std::fs::remove_dir_all(&dir).ok();
580    }
581
582    #[test]
583    fn dir_size_bytes_counts_files_and_handles_single_file() {
584        let root = tmp();
585        std::fs::write(root.join("a.txt"), vec![b'x'; 10]).unwrap();
586        std::fs::create_dir_all(root.join("sub")).unwrap();
587        std::fs::write(root.join("sub").join("b.txt"), vec![b'y'; 5]).unwrap();
588        assert_eq!(dir_size_bytes(&root), 15);
589        // A path to a single file returns just that file's length.
590        assert_eq!(dir_size_bytes(&root.join("a.txt")), 10);
591        // A non-existent path is a best-effort zero.
592        assert_eq!(dir_size_bytes(&root.join("nope")), 0);
593        std::fs::remove_dir_all(&root).ok();
594    }
595
596    #[test]
597    fn run_output_dir_handles_flat_layout_and_missing_paths() {
598        let root = tmp();
599        // Flat layout: json sits directly under the run dir (no html/json subfolder).
600        let mut e = entry("flat", 0, &root);
601        e.json_path = Some(root.join("flat").join("result.json"));
602        assert_eq!(run_output_dir(&e), Some(root.join("flat")));
603        // No stored paths at all → None.
604        e.json_path = None;
605        assert_eq!(run_output_dir(&e), None);
606        std::fs::remove_dir_all(&root).ok();
607    }
608
609    #[test]
610    fn execute_run_prune_records_failure_for_locked_dir() {
611        let root = tmp();
612        let mut reg = ScanRegistry::default();
613        reg.entries.push(entry("target", 40, &root));
614        let mut plan = plan_run_prune(&reg, Some(30), None);
615        // Point the plan at a *file* masquerading as the output dir so
616        // remove_dir_all fails with a non-NotFound error, exercising the
617        // failure-collection branch without racing on OS file locks.
618        let bogus = root.join("target").join("json").join("result.json");
619        plan.runs[0].output_dir = Some(bogus);
620        let report = execute_run_prune(&mut reg, &plan);
621        assert_eq!(report.deleted_runs, 0);
622        assert_eq!(report.failures.len(), 1);
623        assert!(reg.entries.iter().any(|e| e.run_id == "target"));
624        std::fs::remove_dir_all(&root).ok();
625    }
626
627    #[test]
628    fn rotate_log_drops_oldest_generation_at_keep_cap() {
629        let root = tmp();
630        let log = root.join("audit.log");
631        // Pre-seed the max number of generations we keep.
632        std::fs::write(&log, vec![b'x'; 100]).unwrap();
633        std::fs::write(log.with_extension("log.1"), b"g1").unwrap();
634        std::fs::write(log.with_extension("log.2"), b"g2").unwrap();
635        // keep=2: the oldest (.2) is dropped, .1 shifts to .2, live becomes .1.
636        assert!(rotate_log(&log, 50, 2).unwrap());
637        assert!(log.with_extension("log.1").exists());
638        assert!(log.with_extension("log.2").exists());
639        assert!(!log.with_extension("log.3").exists());
640        std::fs::remove_dir_all(&root).ok();
641    }
642}