1use crate::cli::arg::{
2 FochCliCacheArgs, FochCliCacheCleanArgs, FochCliCacheClearArgs, FochCliCacheCommands,
3 FochCliCacheLayerArg, FochCliCacheListArgs, FochCliCacheStatsArgs,
4};
5use crate::cli::handler::HandlerResult;
6use foch::game::eu4::script::parse_cache;
7use foch::platform::cache_store::{
8 CacheLayerEntryInfo, EvictionStats, FileCacheLayer, cache_cap_bytes, default_foch_cache_dir,
9};
10use std::path::{Path, PathBuf};
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13#[derive(Clone, Copy)]
14struct CacheLayerSpec {
15 selection: FochCliCacheLayerArg,
16 name: &'static str,
17 extension: &'static str,
18}
19
20const CACHE_LAYER_SPECS: [CacheLayerSpec; 5] = [
21 CacheLayerSpec {
22 selection: FochCliCacheLayerArg::Mods,
23 name: "mods",
24 extension: "rkyv",
25 },
26 CacheLayerSpec {
27 selection: FochCliCacheLayerArg::Diffs,
28 name: "diffs",
29 extension: "bin",
30 },
31 CacheLayerSpec {
32 selection: FochCliCacheLayerArg::DagBase,
33 name: "dag-base",
34 extension: "bin",
35 },
36 CacheLayerSpec {
37 selection: FochCliCacheLayerArg::CwtRules,
38 name: "cwt-rules",
39 extension: "bin",
40 },
41 CacheLayerSpec {
42 selection: FochCliCacheLayerArg::Parse,
43 name: "parse",
44 extension: "bin",
45 },
46];
47
48#[derive(Clone, Debug, Default)]
49struct LayerStats {
50 entry_count: usize,
51 total_bytes: u64,
52 oldest_mtime: Option<SystemTime>,
53 newest_mtime: Option<SystemTime>,
54}
55
56enum CacheLayer {
57 Files(FileCacheLayer),
58 Parse { root: PathBuf },
59}
60
61impl CacheLayer {
62 fn name(&self) -> &str {
63 match self {
64 Self::Files(layer) => layer.name(),
65 Self::Parse { .. } => "parse",
66 }
67 }
68
69 fn path(&self) -> &Path {
70 match self {
71 Self::Files(layer) => layer.path(),
72 Self::Parse { root } => root,
73 }
74 }
75
76 fn list_entries(&self) -> Result<Vec<CacheLayerEntryInfo>, Box<dyn std::error::Error>> {
77 match self {
78 Self::Files(layer) => Ok(layer.list_entries()?),
79 Self::Parse { .. } => Ok(parse_cache::list_entries()
80 .into_iter()
81 .map(|entry| CacheLayerEntryInfo {
82 layer_name: "parse".to_string(),
83 key: entry.key,
84 path: entry.path,
85 size_bytes: entry.size_bytes,
86 modified: entry.modified,
87 })
88 .collect()),
89 }
90 }
91
92 fn purge_older_than(&self, days: u32) -> Result<usize, Box<dyn std::error::Error>> {
93 match self {
94 Self::Files(layer) => Ok(layer.purge_older_than(days)?),
95 Self::Parse { .. } => Ok(parse_cache::purge_older_than(days)?),
96 }
97 }
98
99 fn evict_to_byte_cap(
100 &self,
101 cap_bytes: u64,
102 ) -> Result<EvictionStats, Box<dyn std::error::Error>> {
103 match self {
104 Self::Files(layer) => Ok(layer.evict_to_byte_cap(cap_bytes)?),
105 Self::Parse { .. } => {
106 let stats = parse_cache::gc_with_cap(cap_bytes);
107 Ok(EvictionStats {
108 removed_entries: usize::try_from(stats.evicted).unwrap_or(usize::MAX),
109 freed_bytes: stats.bytes_before.saturating_sub(stats.bytes_after),
110 })
111 }
112 }
113 }
114
115 fn clear(&self) -> Result<(), Box<dyn std::error::Error>> {
116 match self {
117 Self::Files(layer) => Ok(layer.clear()?),
118 Self::Parse { .. } => Ok(parse_cache::cache_clean()?),
119 }
120 }
121}
122
123pub fn handle_cache(cache_args: &FochCliCacheArgs) -> HandlerResult {
124 match &cache_args.command {
125 FochCliCacheCommands::Stats(args) => handle_cache_stats(args),
126 FochCliCacheCommands::List(args) => handle_cache_list(args),
127 FochCliCacheCommands::Clean(args) => handle_cache_clean(args),
128 FochCliCacheCommands::Clear(args) => handle_cache_clear(args),
129 FochCliCacheCommands::Where => handle_cache_where(),
130 }
131}
132
133fn handle_cache_stats(args: &FochCliCacheStatsArgs) -> HandlerResult {
134 let root = default_foch_cache_dir();
135 let layers = selected_layers(args.layer);
136 let mut total_size = 0_u64;
137 let mut total_entries = 0_usize;
138 let mut oldest = None;
139 let mut newest = None;
140
141 println!("cache root: {}", root.display());
142 println!("layer entries size path");
143 for layer in &layers {
144 let stats = layer_stats(layer)?;
145 total_size = total_size.saturating_add(stats.total_bytes);
146 total_entries = total_entries.saturating_add(stats.entry_count);
147 oldest = min_time(oldest, stats.oldest_mtime);
148 newest = max_time(newest, stats.newest_mtime);
149 println!(
150 "{:<11} {:>7} {:>9} {}",
151 layer.name(),
152 stats.entry_count,
153 format_bytes(stats.total_bytes),
154 layer.path().display()
155 );
156 }
157 println!(
158 "total: {:>7} {:>9}",
159 total_entries,
160 format_bytes(total_size)
161 );
162 println!("oldest: {}", format_optional_time(oldest));
163 println!("newest: {}", format_optional_time(newest));
164 Ok(0)
165}
166
167fn handle_cache_list(args: &FochCliCacheListArgs) -> HandlerResult {
168 let layers = selected_layers(args.layer);
169 let mut entries = Vec::new();
170 for layer in &layers {
171 entries.extend(layer.list_entries()?);
172 }
173 entries.sort_by(|left, right| {
174 left.layer_name
175 .cmp(&right.layer_name)
176 .then_with(|| left.modified.cmp(&right.modified))
177 .then_with(|| left.key.cmp(&right.key))
178 });
179
180 println!("layer modified size key");
181 for entry in entries {
182 println!(
183 "{:<11} {:<32} {:>9} {}",
184 entry.layer_name,
185 format_system_time(entry.modified),
186 format_bytes(entry.size_bytes),
187 entry.key
188 );
189 }
190 Ok(0)
191}
192
193fn handle_cache_clean(args: &FochCliCacheCleanArgs) -> HandlerResult {
194 let layers = selected_layers(args.layer);
195
196 let mut purged = 0_usize;
197 for layer in &layers {
198 purged += layer.purge_older_than(args.older_than)?;
199 }
200 println!(
201 "removed: {purged} entries older than {} days",
202 args.older_than
203 );
204
205 let cap = args.byte_cap.unwrap_or_else(cache_cap_bytes);
206 let mut evicted_entries = 0_usize;
207 let mut freed_bytes = 0_u64;
208 for layer in &layers {
209 let stats = layer.evict_to_byte_cap(cap)?;
210 evicted_entries = evicted_entries.saturating_add(stats.removed_entries);
211 freed_bytes = freed_bytes.saturating_add(stats.freed_bytes);
212 }
213 println!(
214 "byte-cap: evicted {} files ({}) with cap {}",
215 evicted_entries,
216 format_bytes(freed_bytes),
217 format_bytes(cap)
218 );
219 Ok(0)
220}
221
222fn handle_cache_clear(args: &FochCliCacheClearArgs) -> HandlerResult {
223 if !args.yes {
224 return Err("refusing to clear cache without --yes".into());
225 }
226 let layers = selected_layers(args.layer);
227 let stats = combined_stats(&layers)?;
228 for layer in &layers {
229 layer.clear()?;
230 }
231 println!(
232 "removed: {} entries ({}) from {}",
233 stats.entry_count,
234 format_bytes(stats.total_bytes),
235 format_layer_selection(args.layer)
236 );
237 Ok(0)
238}
239
240fn handle_cache_where() -> HandlerResult {
241 println!("{}", default_foch_cache_dir().display());
242 Ok(0)
243}
244
245fn selected_layers(arg: Option<FochCliCacheLayerArg>) -> Vec<CacheLayer> {
246 let selected = arg.unwrap_or(FochCliCacheLayerArg::All);
247 let root = default_foch_cache_dir();
248 CACHE_LAYER_SPECS
249 .iter()
250 .filter(|spec| selected == FochCliCacheLayerArg::All || spec.selection == selected)
251 .map(|spec| {
252 if spec.selection == FochCliCacheLayerArg::Parse {
253 CacheLayer::Parse {
254 root: root.join(spec.name),
255 }
256 } else {
257 CacheLayer::Files(FileCacheLayer::new(
258 spec.name,
259 root.join(spec.name),
260 spec.extension,
261 ))
262 }
263 })
264 .collect()
265}
266
267fn layer_stats(layer: &CacheLayer) -> Result<LayerStats, Box<dyn std::error::Error>> {
268 let entries = layer.list_entries()?;
269 Ok(stats_from_entries(&entries))
270}
271
272fn combined_stats(layers: &[CacheLayer]) -> Result<LayerStats, Box<dyn std::error::Error>> {
273 let mut entries = Vec::new();
274 for layer in layers {
275 entries.extend(layer.list_entries()?);
276 }
277 Ok(stats_from_entries(&entries))
278}
279
280fn stats_from_entries(entries: &[CacheLayerEntryInfo]) -> LayerStats {
281 let mut stats = LayerStats {
282 entry_count: entries.len(),
283 total_bytes: entries.iter().map(|entry| entry.size_bytes).sum(),
284 oldest_mtime: None,
285 newest_mtime: None,
286 };
287 for entry in entries {
288 stats.oldest_mtime = min_time(stats.oldest_mtime, Some(entry.modified));
289 stats.newest_mtime = max_time(stats.newest_mtime, Some(entry.modified));
290 }
291 stats
292}
293
294fn min_time(current: Option<SystemTime>, candidate: Option<SystemTime>) -> Option<SystemTime> {
295 match (current, candidate) {
296 (Some(current), Some(candidate)) => Some(current.min(candidate)),
297 (None, Some(candidate)) => Some(candidate),
298 (Some(current), None) => Some(current),
299 (None, None) => None,
300 }
301}
302
303fn max_time(current: Option<SystemTime>, candidate: Option<SystemTime>) -> Option<SystemTime> {
304 match (current, candidate) {
305 (Some(current), Some(candidate)) => Some(current.max(candidate)),
306 (None, Some(candidate)) => Some(candidate),
307 (Some(current), None) => Some(current),
308 (None, None) => None,
309 }
310}
311
312fn format_layer_selection(layer: Option<FochCliCacheLayerArg>) -> String {
313 match layer.unwrap_or(FochCliCacheLayerArg::All) {
314 FochCliCacheLayerArg::All => default_foch_cache_dir().display().to_string(),
315 other => selected_layers(Some(other))
316 .into_iter()
317 .next()
318 .map(|layer| layer.path().display().to_string())
319 .unwrap_or_else(|| default_foch_cache_dir().display().to_string()),
320 }
321}
322
323fn format_bytes(bytes: u64) -> String {
324 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
325 if bytes < 1024 {
326 return format!("{bytes} B");
327 }
328
329 let mut value = bytes as f64;
330 let mut unit = 0_usize;
331 while value >= 1024.0 && unit < UNITS.len() - 1 {
332 value /= 1024.0;
333 unit += 1;
334 }
335 format!("{value:.1} {}", UNITS[unit])
336}
337
338fn format_optional_time(time: Option<SystemTime>) -> String {
339 match time {
340 Some(time) => format_system_time(time),
341 None => "n/a".to_string(),
342 }
343}
344
345fn format_system_time(time: SystemTime) -> String {
346 let timestamp = match time.duration_since(UNIX_EPOCH) {
347 Ok(duration) => duration.as_secs() as i64,
348 Err(err) => -(err.duration().as_secs() as i64),
349 };
350 let days = timestamp.div_euclid(86_400);
351 let seconds_of_day = timestamp.rem_euclid(86_400);
352 let hour = seconds_of_day / 3_600;
353 let minute = (seconds_of_day % 3_600) / 60;
354 let second = seconds_of_day % 60;
355 let (year, month, day) = civil_from_days(days);
356 format!(
357 "{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02} UTC ({})",
358 format_age(time)
359 )
360}
361
362fn civil_from_days(days_since_unix_epoch: i64) -> (i64, u32, u32) {
363 let z = days_since_unix_epoch + 719_468;
364 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
365 let doe = z - era * 146_097;
366 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
367 let y = yoe + era * 400;
368 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
369 let mp = (5 * doy + 2) / 153;
370 let d = doy - (153 * mp + 2) / 5 + 1;
371 let m = mp + if mp < 10 { 3 } else { -9 };
372 let year = y + if m <= 2 { 1 } else { 0 };
373 (year, m as u32, d as u32)
374}
375
376fn format_age(time: SystemTime) -> String {
377 match SystemTime::now().duration_since(time) {
378 Ok(age) => format_duration_ago(age),
379 Err(err) => format!("{} in the future", format_duration(err.duration())),
380 }
381}
382
383fn format_duration_ago(duration: Duration) -> String {
384 if duration < Duration::from_secs(5) {
385 "just now".to_string()
386 } else {
387 format!("{} ago", format_duration(duration))
388 }
389}
390
391fn format_duration(duration: Duration) -> String {
392 let seconds = duration.as_secs();
393 if seconds < 60 {
394 format!("{seconds}s")
395 } else if seconds < 3_600 {
396 format!("{} min", seconds / 60)
397 } else if seconds < 86_400 {
398 format!("{} h", seconds / 3_600)
399 } else {
400 format!("{} days", seconds / 86_400)
401 }
402}