Skip to main content

putzen_cli/caches/
mod.rs

1//! `putzen caches` — interactive cache cleanup TUI.
2
3pub mod defaults;
4pub mod format;
5pub mod model;
6pub mod scan;
7pub mod tui;
8
9use std::io;
10use std::path::PathBuf;
11
12pub struct CachesArgs {
13    pub roots: Vec<PathBuf>,
14    pub floor: Option<String>,
15    pub dry_run: bool,
16    pub yes: bool,
17}
18
19pub fn run(args: CachesArgs) -> io::Result<()> {
20    use std::time::{Duration, Instant, SystemTime};
21
22    let home = std::env::var_os("HOME")
23        .map(PathBuf::from)
24        .ok_or_else(|| io::Error::other("HOME is not set"))?;
25
26    let seeds = select_seeds(&home, &args.roots);
27
28    let floor = args
29        .floor
30        .as_deref()
31        .map(parse_duration)
32        .transpose()
33        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?
34        .unwrap_or(Duration::from_secs(7 * 86_400));
35
36    // Start with an empty list + a visible spinner.  The actual seed scan
37    // runs on a worker (Effect::LoadSeeds) so the TUI is responsive
38    // immediately even when HOME contains huge cache trees.
39    let state = tui::State {
40        now: SystemTime::now(),
41        all: Vec::new(),
42        sort: model::Sort::Score,
43        marks: model::MarkSet::default(),
44        cursor: 0,
45        files_cursor: 0,
46        floor: model::FloorPolicy { floor },
47        focus_right: false,
48        stack: Vec::new(),
49        stack_labels: Vec::new(),
50        quit: false,
51        modal: tui::Modal::None,
52        dry_run: args.dry_run,
53        yes_mode: args.yes,
54        total_freed: 0,
55        filter: None,
56        loading: Some(tui::Loading {
57            label: "scanning folders".into(),
58            frame: 0,
59            started: Instant::now(),
60            folders: Some(0),
61        }),
62        overlay: None,
63        level_dirty: false,
64        drill_paths: Vec::new(),
65        cursor_stack: Vec::new(),
66    };
67
68    let initial_effects = vec![tui::Effect::LoadSeeds { seeds }];
69
70    let mut term = tui::enter_tui()?;
71    let loop_result = tui::run_loop(&mut term, state, initial_effects);
72    let (final_state, total_freed) = match loop_result {
73        Ok(out) => out,
74        Err(e) => {
75            let _ = tui::leave_tui(&mut term);
76            return Err(e);
77        }
78    };
79    let _ = final_state;
80
81    tui::leave_tui(&mut term)?;
82
83    #[cfg(feature = "highscore-board")]
84    if !args.dry_run && total_freed > 0 {
85        use crate::RunObserver;
86        let mut obs = crate::HighscoreObserver::load()?;
87        if let Some(medal) = obs.on_run_complete(total_freed) {
88            println!("{medal}");
89        }
90    }
91
92    #[cfg(not(feature = "highscore-board"))]
93    let _ = total_freed;
94
95    Ok(())
96}
97
98/// Accepts a duration like "24h", "7d", "2w", or "1y". Returns Err on parse failure.
99pub fn parse_duration(s: &str) -> Result<std::time::Duration, String> {
100    use std::time::Duration;
101    let (num, unit) = s.split_at(s.len().saturating_sub(1));
102    let n: u64 = num.parse().map_err(|_| format!("bad duration `{s}`"))?;
103    match unit {
104        "h" => Ok(Duration::from_secs(n * 3_600)),
105        "d" => Ok(Duration::from_secs(n * 86_400)),
106        "w" => Ok(Duration::from_secs(n * 7 * 86_400)),
107        "y" => Ok(Duration::from_secs(n * 365 * 86_400)),
108        _ => Err(format!("bad duration unit in `{s}`, expected h|d|w|y")),
109    }
110}
111
112#[cfg(test)]
113mod parse_duration_tests {
114    use super::*;
115    #[test]
116    fn parses_hours_days_years() {
117        assert_eq!(parse_duration("24h").unwrap().as_secs(), 24 * 3600);
118        assert_eq!(parse_duration("7d").unwrap().as_secs(), 7 * 86_400);
119        assert_eq!(parse_duration("1y").unwrap().as_secs(), 365 * 86_400);
120    }
121    #[test]
122    fn rejects_garbage() {
123        assert!(parse_duration("hello").is_err());
124        assert!(parse_duration("7x").is_err());
125    }
126}
127
128/// Resolve a HOME-relative path string against `$HOME`. Absolute paths
129/// (`/...`) pass through unchanged.
130pub fn resolve_path(home: &std::path::Path, raw: &str) -> std::path::PathBuf {
131    if raw.starts_with('/') {
132        std::path::PathBuf::from(raw)
133    } else {
134        home.join(raw)
135    }
136}
137
138/// Pick the seed set for the scan: `--root` values when given, otherwise
139/// the built-in defaults resolved against `home`. The two are alternatives
140/// rather than complementary — passing `--root` is the user telling us
141/// "scan this tree, not the usual ones".
142pub fn select_seeds(home: &std::path::Path, roots: &[PathBuf]) -> Vec<PathBuf> {
143    if roots.is_empty() {
144        defaults::defaults()
145            .map(|r| resolve_path(home, r.path))
146            .collect()
147    } else {
148        roots.to_vec()
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use std::path::PathBuf;
156
157    #[test]
158    fn resolve_home_relative() {
159        let home = PathBuf::from("/u/sven");
160        assert_eq!(
161            resolve_path(&home, ".cargo/registry"),
162            PathBuf::from("/u/sven/.cargo/registry")
163        );
164    }
165
166    #[test]
167    fn resolve_absolute_passthrough() {
168        let home = PathBuf::from("/u/sven");
169        assert_eq!(
170            resolve_path(&home, "/var/cache"),
171            PathBuf::from("/var/cache")
172        );
173    }
174
175    #[test]
176    fn select_seeds_no_roots_uses_defaults() {
177        let home = PathBuf::from("/u/sven");
178        let seeds = select_seeds(&home, &[]);
179        assert!(!seeds.is_empty(), "default seeds must be populated");
180        assert!(
181            seeds.iter().any(|p| p.starts_with(&home)),
182            "default seeds resolve under $HOME"
183        );
184    }
185
186    #[test]
187    fn select_seeds_with_roots_replaces_defaults() {
188        let home = PathBuf::from("/u/sven");
189        let roots = vec![PathBuf::from("/tmp/scratch"), PathBuf::from("/var/cache")];
190        let seeds = select_seeds(&home, &roots);
191        assert_eq!(seeds, roots, "--root replaces, never extends");
192    }
193}