1pub 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 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
98pub 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
128pub 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
138pub 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}