1use std::collections::BTreeMap;
15
16use anyhow::Result;
17use chrono::{DateTime, Utc};
18
19use crate::config::Registry;
20use crate::constants::{HISTORY_STARTS_AT, PRUNE_LOG_STARTS_AT};
21use crate::history::{self, Pass, Trigger};
22use crate::output;
23
24const PASSES_SHOWN: usize = 10;
26
27const REPOS_SHOWN: usize = 10;
29
30const MANAGERS_SHOWN: usize = 10;
33
34pub fn run(json_output: bool) -> Result<()> {
36 let registry = Registry::load()?;
37 let passes = history::merged(history::load().unwrap_or_default(), ®istry);
40
41 if json_output {
42 return crate::json::emit(&crate::json::stats_document(®istry, &passes));
43 }
44
45 output::print_header("Lifetime");
46 output::print_info(&format!(
47 "Space reclaimed: {}",
48 output::format_bytes_styled(registry.total_freed_bytes)
49 ));
50 output::print_info(&format!(
54 "Caches emptied: {}",
55 output::format_bytes_styled(registry.total_cache_freed_bytes)
56 ));
57 output::print_info(&format!(
60 "Containers cleared: {}",
61 output::format_bytes_styled(registry.total_container_freed_bytes)
62 ));
63 output::print_info(&format!(
64 "Prune passes: {}",
65 registry.total_pruned_count
66 ));
67 output::print_info(&format!(
68 "Repositories: {} tracked",
69 registry.repo_count()
70 ));
71
72 print_last_pass(®istry);
73 print_recent_passes(®istry);
74 print_biggest_repositories(®istry);
75 print_by_manager(&passes);
76 print_by_trigger(&passes);
77
78 Ok(())
79}
80
81fn print_last_pass(registry: &Registry) {
83 output::print_header("Most recent pass");
84
85 let Some(last) = ®istry.last_prune else {
86 output::print_info(
87 "Nothing recorded yet — `devp run --dry-run` shows what a pass would do.",
88 );
89 return;
90 };
91
92 let bytes: u64 = last.dirs.iter().map(|d| d.size_freed).sum();
93 output::print_info(&format!(
94 "{} ({}) — {} from {} {}",
95 last.at.format("%Y-%m-%d %H:%M UTC"),
96 describe_age(last.at),
97 output::format_bytes_styled(bytes),
98 last.dirs.len(),
99 output::plural(last.dirs.len(), "directory", "directories"),
100 ));
101 output::print_info("Put it back with: devp restore --last-run");
102}
103
104fn print_recent_passes(registry: &Registry) {
105 if registry.prune_history.is_empty() {
106 return;
107 }
108
109 output::print_header("Recent passes");
110 for summary in registry.prune_history.iter().rev().take(PASSES_SHOWN) {
111 use colored::Colorize;
112 println!(
115 " {} {} {} {} across {} {}",
116 summary.at.format("%Y-%m-%d %H:%M"),
117 format!("{:>10}", output::format_bytes(summary.bytes_freed)).green(),
118 summary.dirs_removed,
119 output::plural(summary.dirs_removed, "directory", "directories"),
120 summary.repos_touched,
121 output::plural(summary.repos_touched, "repository", "repositories"),
122 );
123 }
124
125 let total = registry.prune_history.len();
126 if total > PASSES_SHOWN {
127 output::print_info(&format!(
128 "{total} passes recorded; showing the last {PASSES_SHOWN}."
129 ));
130 }
131}
132
133fn print_biggest_repositories(registry: &Registry) {
134 let mut ranked: Vec<_> = registry
135 .repositories
136 .iter()
137 .filter(|(_, entry)| entry.total_freed_bytes > 0)
138 .collect();
139
140 output::print_header("Biggest reclaims");
141
142 if ranked.is_empty() {
143 output::print_info(&format!(
147 "No per-repository figures yet — these are recorded from {HISTORY_STARTS_AT} onward."
148 ));
149 return;
150 }
151
152 ranked.sort_by(|a, b| {
153 b.1.total_freed_bytes
154 .cmp(&a.1.total_freed_bytes)
155 .then_with(|| a.0.cmp(b.0))
156 });
157
158 for (path, entry) in ranked.iter().take(REPOS_SHOWN) {
159 use colored::Colorize;
160 let last = entry
161 .last_pruned_at
162 .map(|at| format!("last pruned {}", describe_age(at)))
163 .unwrap_or_else(|| "never pruned by this install".to_string());
164 println!(
165 " {} {} ({last})",
166 format!("{:>10}", output::format_bytes(entry.total_freed_bytes)).green(),
167 output::styled_path(path),
168 );
169 }
170}
171
172pub(crate) struct ManagerTotal {
174 pub(crate) manager: String,
175 pub(crate) bytes: u64,
176 pub(crate) dirs: usize,
177}
178
179pub(crate) fn rank_managers(passes: &[Pass]) -> (Vec<ManagerTotal>, usize) {
185 let mut totals: BTreeMap<&str, (u64, usize)> = BTreeMap::new();
186 let mut unaccounted = 0;
187
188 for pass in passes {
189 let Some(dirs) = pass.dirs() else {
190 unaccounted += 1;
191 continue;
192 };
193 for dir in dirs {
194 let entry = totals.entry(dir.adapter.as_str()).or_default();
195 entry.0 += dir.size_freed;
196 entry.1 += 1;
197 }
198 }
199
200 let mut ranked: Vec<ManagerTotal> = totals
201 .into_iter()
202 .map(|(manager, (bytes, dirs))| ManagerTotal {
203 manager: manager.to_string(),
204 bytes,
205 dirs,
206 })
207 .collect();
208 ranked.sort_by(|a, b| {
209 b.bytes
210 .cmp(&a.bytes)
211 .then_with(|| a.manager.cmp(&b.manager))
212 });
213 (ranked, unaccounted)
214}
215
216pub(crate) struct TriggerTotal {
218 pub(crate) trigger: Trigger,
219 pub(crate) bytes: u64,
220 pub(crate) passes: usize,
221}
222
223pub(crate) fn split_by_trigger(passes: &[Pass]) -> (Vec<TriggerTotal>, usize) {
228 let mut totals: Vec<TriggerTotal> = Trigger::ALL
229 .iter()
230 .map(|&trigger| TriggerTotal {
231 trigger,
232 bytes: 0,
233 passes: 0,
234 })
235 .collect();
236 let mut unaccounted = 0;
237
238 for pass in passes {
239 let Some(trigger) = pass.trigger() else {
240 unaccounted += 1;
241 continue;
242 };
243 if let Some(slot) = totals.iter_mut().find(|t| t.trigger == trigger) {
244 slot.bytes += pass.bytes_freed();
245 slot.passes += 1;
246 }
247 }
248
249 (totals, unaccounted)
250}
251
252fn print_by_manager(passes: &[Pass]) {
254 let (ranked, unaccounted) = rank_managers(passes);
255
256 output::print_header("By package manager");
257
258 if ranked.is_empty() {
259 output::print_info(&format!(
260 "No per-manager figures yet — these are recorded from {PRUNE_LOG_STARTS_AT} onward."
261 ));
262 return;
263 }
264
265 let width = ranked
266 .iter()
267 .take(MANAGERS_SHOWN)
268 .map(|m| m.manager.len())
269 .max()
270 .unwrap_or(0);
271
272 for total in ranked.iter().take(MANAGERS_SHOWN) {
273 use colored::Colorize;
274 println!(
276 " {} {:width$} {} {}",
277 format!("{:>10}", output::format_bytes(total.bytes)).green(),
278 total.manager,
279 total.dirs,
280 output::plural(total.dirs, "directory", "directories"),
281 );
282 }
283
284 if ranked.len() > MANAGERS_SHOWN {
285 output::print_info(&format!(
286 "{} managers in total; showing the top {MANAGERS_SHOWN}.",
287 ranked.len()
288 ));
289 }
290 output::print_info("Pruned project directories only — not the caches or containers above.");
294 if unaccounted > 0 {
295 output::print_info(&format!(
296 "{unaccounted} earlier {} not counted here: only totals were kept before {PRUNE_LOG_STARTS_AT}.",
297 output::plural(unaccounted, "pass is", "passes are"),
298 ));
299 }
300}
301
302fn print_by_trigger(passes: &[Pass]) {
304 let (totals, unaccounted) = split_by_trigger(passes);
305 let counted: usize = totals.iter().map(|t| t.passes).sum();
306
307 output::print_header("How passes start");
308
309 if counted == 0 {
310 output::print_info(&format!(
311 "No triggers recorded yet — these are recorded from {PRUNE_LOG_STARTS_AT} onward."
312 ));
313 return;
314 }
315
316 let bytes_total: u64 = totals.iter().map(|t| t.bytes).sum();
317 let width = Trigger::ALL
318 .iter()
319 .map(|t| t.label().len())
320 .max()
321 .unwrap_or(0);
322
323 for total in &totals {
324 use colored::Colorize;
325 let share = if bytes_total == 0 {
328 0
329 } else {
330 u128::from(total.bytes) * 100 / u128::from(bytes_total)
331 };
332 println!(
333 " {} {:width$} {} {} ({share}%)",
334 format!("{:>10}", output::format_bytes(total.bytes)).green(),
335 total.trigger.label(),
336 total.passes,
337 output::plural(total.passes, "pass", "passes"),
338 );
339 }
340
341 if totals
344 .iter()
345 .any(|t| t.trigger == Trigger::Scheduled && t.passes == 0)
346 {
347 output::print_info("Nothing has run unattended yet — `devp status daemon` says why.");
348 }
349 if unaccounted > 0 {
350 output::print_info(&format!(
351 "{unaccounted} earlier {} no trigger recorded.",
352 output::plural(unaccounted, "pass has", "passes have"),
353 ));
354 }
355}
356
357fn describe_age(at: DateTime<Utc>) -> String {
359 let elapsed = Utc::now().signed_duration_since(at);
360 let days = elapsed.num_days();
361 if days >= 1 {
362 return format!(
363 "{days} {} ago",
364 output::plural(days as usize, "day", "days")
365 );
366 }
367 let hours = elapsed.num_hours();
368 if hours >= 1 {
369 return format!(
370 "{hours} {} ago",
371 output::plural(hours as usize, "hour", "hours")
372 );
373 }
374 let minutes = elapsed.num_minutes().max(0);
375 format!(
376 "{minutes} {} ago",
377 output::plural(minutes as usize, "minute", "minutes")
378 )
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::config::PrunedDir;
385 use crate::history::PassRecord;
386 use chrono::Duration;
387
388 fn logged(trigger: Trigger, dirs: &[(&str, u64)]) -> Pass {
390 Pass::Detailed(PassRecord {
391 at: Utc::now(),
392 trigger,
393 argv: vec!["run".to_string()],
394 version: "1.17.0".to_string(),
395 dirs: dirs
396 .iter()
397 .map(|(adapter, size)| PrunedDir {
398 repo_path: std::path::PathBuf::from("/tmp/repo"),
399 bloat_dir: "node_modules".to_string(),
400 adapter: (*adapter).to_string(),
401 size_freed: *size,
402 runtime: None,
403 })
404 .collect(),
405 })
406 }
407
408 fn recovered(bytes: u64) -> Pass {
410 Pass::Summary {
411 at: Utc::now() - Duration::days(2),
412 bytes_freed: bytes,
413 dirs_removed: 1,
414 repos_touched: 1,
415 dirs: None,
416 }
417 }
418
419 #[test]
420 fn managers_rank_by_what_they_gave_back() {
421 let passes = [
422 logged(Trigger::Manual, &[("npm", 500), ("cargo", 800)]),
423 logged(Trigger::Scheduled, &[("npm", 900)]),
424 ];
425
426 let (ranked, unaccounted) = rank_managers(&passes);
427
428 assert_eq!(ranked[0].manager, "npm");
431 assert_eq!(ranked[0].bytes, 1400);
432 assert_eq!(ranked[0].dirs, 2);
433 assert_eq!(ranked[1].manager, "cargo");
434 assert_eq!(unaccounted, 0);
435 }
436
437 #[test]
438 fn a_pass_with_no_directories_is_declared_rather_than_dropped() {
439 let passes = [logged(Trigger::Manual, &[("npm", 500)]), recovered(9_000)];
443
444 let (ranked, unaccounted) = rank_managers(&passes);
445
446 assert_eq!(ranked.len(), 1);
447 assert_eq!(ranked[0].bytes, 500);
448 assert_eq!(unaccounted, 1);
449 }
450
451 #[test]
452 fn every_trigger_is_listed_even_at_zero() {
453 let passes = [logged(Trigger::Manual, &[("npm", 700)])];
456
457 let (totals, unaccounted) = split_by_trigger(&passes);
458
459 assert_eq!(totals.len(), Trigger::ALL.len());
460 let scheduled = totals
461 .iter()
462 .find(|t| t.trigger == Trigger::Scheduled)
463 .expect("scheduled is one of the three");
464 assert_eq!(scheduled.passes, 0);
465 assert_eq!(scheduled.bytes, 0);
466
467 let manual = totals
468 .iter()
469 .find(|t| t.trigger == Trigger::Manual)
470 .expect("manual is one of the three");
471 assert_eq!(manual.passes, 1);
472 assert_eq!(manual.bytes, 700);
473 assert_eq!(unaccounted, 0);
474 }
475
476 #[test]
477 fn a_pass_from_before_the_log_has_no_trigger_to_split_by() {
478 let passes = [recovered(9_000)];
479
480 let (totals, unaccounted) = split_by_trigger(&passes);
481
482 assert!(totals.iter().all(|t| t.passes == 0));
483 assert_eq!(unaccounted, 1);
484 }
485
486 #[test]
487 fn an_age_is_described_in_the_coarsest_unit_that_fits() {
488 assert_eq!(describe_age(Utc::now() - Duration::days(3)), "3 days ago");
489 assert_eq!(describe_age(Utc::now() - Duration::days(1)), "1 day ago");
490 assert_eq!(describe_age(Utc::now() - Duration::hours(5)), "5 hours ago");
491 assert_eq!(
492 describe_age(Utc::now() - Duration::minutes(2)),
493 "2 minutes ago"
494 );
495 }
496
497 #[test]
498 fn a_timestamp_in_the_future_does_not_render_as_negative() {
499 assert_eq!(
502 describe_age(Utc::now() + Duration::minutes(5)),
503 "0 minutes ago"
504 );
505 }
506}