Skip to main content

putzen_cli/caches/
model.rs

1//! Pure data types for the cache TUI.
2
3use std::path::PathBuf;
4use std::time::{Duration, SystemTime};
5
6#[derive(Clone, Debug)]
7pub struct Cache {
8    /// Display name (label derived at scan time).
9    pub label: String,
10    /// Absolute, canonical path.
11    pub path: PathBuf,
12    pub size_bytes: u64,
13    pub newest_mtime: Option<SystemTime>,
14    pub file_count: u64,
15    pub dir_count: u64,
16    /// Top-N largest files inside this cache, sorted by descending size.
17    /// Bounded so we don't keep millions for ~/.cache/huggingface; 64 is plenty.
18    pub top_files: Vec<TopFile>,
19    /// Count of dir entries that could not be read (permission, dangling symlink).
20    pub unreadable: u64,
21}
22
23#[derive(Clone, Debug)]
24pub struct TopFile {
25    pub name: String,
26    pub size_bytes: u64,
27    pub mtime: Option<SystemTime>,
28}
29
30impl Cache {
31    /// Duration since the newest file was touched. `None` for empty caches.
32    pub fn age(&self, now: SystemTime) -> Option<Duration> {
33        let mtime = self.newest_mtime?;
34        now.duration_since(mtime).ok().or(Some(Duration::ZERO))
35    }
36
37    /// Score: (size_MB) × (age_days). 0.0 for empty caches.
38    pub fn score(&self, now: SystemTime) -> f64 {
39        let Some(age) = self.age(now) else { return 0.0 };
40        let mb = self.size_bytes as f64 / 1_048_576.0;
41        let days = age.as_secs_f64() / 86_400.0;
42        mb * days
43    }
44}
45
46#[derive(Copy, Clone, Eq, PartialEq, Debug)]
47pub enum Sort {
48    Score,
49    Size,
50    Age,
51}
52
53impl Sort {
54    pub fn next(self) -> Sort {
55        match self {
56            Sort::Score => Sort::Size,
57            Sort::Size => Sort::Age,
58            Sort::Age => Sort::Score,
59        }
60    }
61}
62
63#[derive(Clone, Debug, Default)]
64pub struct MarkSet {
65    /// Indices into the current sorted list.
66    pub marked: std::collections::BTreeSet<usize>,
67}
68
69impl MarkSet {
70    pub fn toggle(&mut self, i: usize) {
71        if !self.marked.insert(i) {
72            self.marked.remove(&i);
73        }
74    }
75    pub fn mark_down_to(&mut self, last: usize) {
76        for i in 0..=last {
77            self.marked.insert(i);
78        }
79    }
80    pub fn clear(&mut self) {
81        self.marked.clear();
82    }
83    pub fn is_marked(&self, i: usize) -> bool {
84        self.marked.contains(&i)
85    }
86    pub fn count(&self) -> usize {
87        self.marked.len()
88    }
89}
90
91#[derive(Copy, Clone, Debug)]
92pub struct FloorPolicy {
93    pub floor: Duration,
94}
95
96impl FloorPolicy {
97    pub fn is_active(&self, age: Option<Duration>) -> bool {
98        age.map(|a| a < self.floor).unwrap_or(false)
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use std::time::{Duration, SystemTime};
106
107    fn at(epoch_secs: u64) -> SystemTime {
108        SystemTime::UNIX_EPOCH + Duration::from_secs(epoch_secs)
109    }
110
111    fn cache(size: u64, mtime_secs: u64) -> Cache {
112        Cache {
113            label: "x".into(),
114            path: PathBuf::from("/tmp/x"),
115            size_bytes: size,
116            newest_mtime: Some(at(mtime_secs)),
117            file_count: 1,
118            dir_count: 0,
119            top_files: Vec::new(),
120            unreadable: 0,
121        }
122    }
123
124    #[test]
125    fn score_zero_for_empty_cache() {
126        let mut c = cache(1024, 0);
127        c.newest_mtime = None;
128        assert_eq!(c.score(at(86_400)), 0.0);
129    }
130
131    #[test]
132    fn score_proportional_to_mb_days() {
133        let now = at(2 * 86_400);
134        let c = cache(1_048_576, 0); // 1 MB, 2 days cold
135        assert!((c.score(now) - 2.0).abs() < 1e-6);
136    }
137
138    #[test]
139    fn sort_cycles() {
140        assert_eq!(Sort::Score.next(), Sort::Size);
141        assert_eq!(Sort::Size.next(), Sort::Age);
142        assert_eq!(Sort::Age.next(), Sort::Score);
143    }
144
145    #[test]
146    fn markset_toggle_inserts_and_removes() {
147        let mut m = MarkSet::default();
148        m.toggle(3);
149        assert!(m.is_marked(3));
150        m.toggle(3);
151        assert!(!m.is_marked(3));
152    }
153
154    #[test]
155    fn markset_mark_down_to_inclusive() {
156        let mut m = MarkSet::default();
157        m.mark_down_to(2);
158        assert_eq!(m.count(), 3);
159        for i in 0..=2 {
160            assert!(m.is_marked(i));
161        }
162    }
163
164    #[test]
165    fn floor_active_when_cold_less_than_floor() {
166        let p = FloorPolicy {
167            floor: Duration::from_secs(7 * 86_400),
168        };
169        assert!(p.is_active(Some(Duration::from_secs(86_400))));
170        assert!(!p.is_active(Some(Duration::from_secs(30 * 86_400))));
171        assert!(!p.is_active(None));
172    }
173}