Skip to main content

epicenter_cli/
status.rs

1//! Read-only inspection of the shared cache: total size, per-directory
2//! buckets, oldest entry, and the largest build units.
3
4use std::cmp::Reverse;
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use anyhow::Result;
10use walkdir::WalkDir;
11
12use crate::prune;
13
14/// Size/count of one top-level bucket (usually a profile dir: `debug`, `release`).
15pub struct DirStat {
16    pub name: String,
17    pub bytes: u64,
18    pub files: u64,
19}
20
21pub struct CacheSummary {
22    pub root: PathBuf,
23    pub total_bytes: u64,
24    pub total_files: u64,
25    /// Per top-level directory, largest first.
26    pub dirs: Vec<DirStat>,
27    /// Oldest file mtime = how long ago the cache was first used.
28    pub oldest: Option<SystemTime>,
29    /// The 10 largest build units, largest first.
30    pub top_units: Vec<(String, u64)>,
31}
32
33/// Gather statistics about the cache at `root`.
34///
35/// Note the two failure postures on purpose: scanning stops at the first
36/// *unreadable entry* silently (status must never fail because one file is
37/// locked), but a failing `prune::scan` uses `?` (a cache that can't be
38/// classified is interesting and loudly reported).
39pub fn summarize(root: &Path) -> Result<CacheSummary> {
40    let mut summary = CacheSummary {
41        root: root.to_path_buf(),
42        total_bytes: 0,
43        total_files: 0,
44        dirs: Vec::new(),
45        oldest: None,
46        top_units: Vec::new(),
47    };
48
49    // Per top-level bucket (profile dirs like `debug`/`release`, usually).
50    let mut buckets: BTreeMap<String, (u64, u64)> = BTreeMap::new();
51    for entry in WalkDir::new(root).min_depth(1) {
52        let entry = match entry {
53            Ok(entry) => entry,
54            Err(_) => continue, // locked/unreadable: skip, never crash status
55        };
56        if !entry.file_type().is_file() {
57            continue;
58        }
59        let Ok(meta) = entry.metadata() else { continue };
60
61        summary.total_bytes += meta.len();
62        summary.total_files += 1;
63        if let Ok(mtime) = meta.modified() {
64            if summary.oldest.map_or(true, |oldest| mtime < oldest) {
65                summary.oldest = Some(mtime);
66            }
67        }
68
69        // Bucket by the FIRST component under the cache root; files that
70        // live directly IN the root have no directory component and go to
71        // the synthetic "(cache root)" bucket.
72        let rel: Vec<String> = entry
73            .path()
74            .strip_prefix(root)
75            .ok()
76            .map(|rel| {
77                rel.components()
78                    .map(|comp| comp.as_os_str().to_string_lossy().into_owned())
79                    .collect()
80            })
81            .unwrap_or_default();
82        let bucket = if rel.len() > 1 {
83            rel[0].clone()
84        } else {
85            "(cache root)".to_string()
86        };
87        let bucket = buckets.entry(bucket).or_insert((0, 0));
88        bucket.0 += meta.len();
89        bucket.1 += 1;
90    }
91
92    summary.dirs = buckets
93        .into_iter()
94        .map(|(name, (bytes, files))| DirStat { name, bytes, files })
95        .collect();
96    summary.dirs.sort_by_key(|dir| Reverse(dir.bytes));
97
98    summary.top_units = prune::scan(root)?
99        .into_values()
100        .map(|group| (group.key, group.bytes))
101        .collect();
102    summary.top_units.sort_by_key(|(_, bytes)| Reverse(*bytes));
103    summary.top_units.truncate(10);
104
105    Ok(summary)
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use std::fs;
112
113    #[test]
114    fn summarizes_sizes_and_buckets() -> Result<()> {
115        let root = tempfile::tempdir()?;
116        let debug = root.path().join("debug");
117        fs::create_dir_all(debug.join("deps"))?;
118        fs::write(debug.join("deps/liba-abc.rlib"), [0u8; 10])?;
119        fs::write(debug.join("a.d"), [0u8; 3])?;
120        fs::write(root.path().join("top.txt"), [0u8; 2])?;
121
122        let summary = summarize(root.path())?;
123        assert_eq!(summary.total_bytes, 15);
124        assert_eq!(summary.total_files, 3);
125
126        let debug_bucket = summary.dirs.iter().find(|d| d.name == "debug").unwrap();
127        assert_eq!(debug_bucket.bytes, 13);
128        assert_eq!(debug_bucket.files, 2);
129
130        let root_bucket = summary
131            .dirs
132            .iter()
133            .find(|d| d.name == "(cache root)")
134            .unwrap();
135        assert_eq!(root_bucket.bytes, 2);
136
137        // Only `liba-abc.rlib` is classifiable into a unit.
138        assert_eq!(summary.top_units.len(), 1);
139        Ok(())
140    }
141}