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