Skip to main content

dev_prune/commands/
stats.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune stats`.
5//
6// `devp status` answers "what could I reclaim right now"; this answers "what has this
7// thing actually done for me". They are different questions, and folding the second into
8// the dashboard would have meant a screen of history above the list people open it for.
9//
10// Two of the three sections here are only recorded from 1.1.0 onward, because the
11// per-repository total and the pass history did not exist before it. The report says so
12// rather than letting an upgraded machine look like it has never pruned anything.
13
14use 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
24/// How many passes the text report lists. The registry keeps more; a screen holds fewer.
25const PASSES_SHOWN: usize = 10;
26
27/// How many repositories the text report ranks.
28const REPOS_SHOWN: usize = 10;
29
30/// How many package managers the text report ranks. There are two dozen adapters and a
31/// machine that has used more than ten of them is not the one this line is written for.
32const MANAGERS_SHOWN: usize = 10;
33
34/// Run the `stats` command.
35pub fn run(json_output: bool) -> Result<()> {
36    let registry = Registry::load()?;
37    // Best-effort, like every other read of it: a machine with no log yet still gets the
38    // totals, and the two sections that need one say so themselves.
39    let passes = history::merged(history::load().unwrap_or_default(), &registry);
40
41    if json_output {
42        return crate::json::emit(&crate::json::stats_document(&registry, &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    // Its own line rather than added to the one above it. Both are space this tool gave
51    // back, but they are not interchangeable: the line above cost a reinstall in one
52    // repository, this one costs a download in every project on the disk.
53    output::print_info(&format!(
54        "Caches emptied:    {}",
55        output::format_bytes_styled(registry.total_cache_freed_bytes)
56    ));
57    // A third line for the same reason there is a second: an image costs a pull of the
58    // whole layer stack to put back, which is neither of the two bills above.
59    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(&registry);
73    print_recent_passes(&registry);
74    print_biggest_repositories(&registry);
75    print_by_manager(&passes);
76    print_by_trigger(&passes);
77
78    Ok(())
79}
80
81/// The pass `devp restore --last-run` would undo.
82fn print_last_pass(registry: &Registry) {
83    output::print_header("Most recent pass");
84
85    let Some(last) = &registry.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        // Pad before coloring: a `{:>10}` applied to a string carrying ANSI escapes
113        // counts the escapes as width and the column drifts.
114        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        // The distinction matters on an upgraded machine: `total_freed_bytes` above can
144        // be gigabytes while every per-repository figure is still zero, and reading that
145        // as "nothing was ever pruned here" would be wrong.
146        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
172/// One package manager's lifetime contribution to pruning.
173pub(crate) struct ManagerTotal {
174    pub(crate) manager: String,
175    pub(crate) bytes: u64,
176    pub(crate) dirs: usize,
177}
178
179/// Rank package managers by what they gave back, biggest first.
180///
181/// The second return is how many passes could not be counted. A pass recovered from the
182/// registry summary knows its total and nothing else — no directory list, so no adapter
183/// — and quietly leaving those out would make the ranking under-report without saying so.
184pub(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
216/// What one trigger has reclaimed, across every pass it started.
217pub(crate) struct TriggerTotal {
218    pub(crate) trigger: Trigger,
219    pub(crate) bytes: u64,
220    pub(crate) passes: usize,
221}
222
223/// Split the passes by what started them, in [`Trigger::ALL`] order.
224///
225/// Every trigger is returned even at zero — see [`Trigger::ALL`]. The second return is
226/// the passes with no trigger recorded, which is every pass from before the log existed.
227pub(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
252/// Which package manager is actually earning its keep.
253fn 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        // Pad before coloring, for the reason `print_recent_passes` documents.
275        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    // Without this the section reads as a breakdown of the lifetime total above, and it
291    // is not one: emptying a cache or clearing an image is not a prune pass and never
292    // enters the log these figures are summed from.
293    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
302/// Whether the scheduler is pulling its weight, or you are doing it all by hand.
303fn 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        // u128 so the multiply cannot wrap. The figure it guards is unreachable; the
326        // cast is cheaper than deciding that for certain.
327        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    // A zero on the scheduled line is the whole point of the section, and it is also the
342    // one line nobody reads as a call to action unless it says so.
343    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
357/// "3 days ago", in the coarsest unit that is not a lie.
358fn 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    /// A logged pass: `(adapter, bytes)` per directory, all in one repository.
389    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    /// A pass from before the log, as `history::merged` reconstructs one.
409    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        // npm's 1400 is spread over two passes; the ranking is by the sum, not by the
429        // biggest single directory, or cargo's 800 would lead.
430        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        // The upgraded-machine case. Its bytes cannot be attributed to any manager, and
440        // the section has to say so — silently omitting it would make the breakdown look
441        // like a complete account of the lifetime total, which it is not.
442        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        // A scheduled line reading zero is the answer to "is the daemon doing anything",
454        // and dropping empty rows would delete exactly that answer.
455        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        // Clock skew between the daemon and an interactive run is real, and
500        // "-1 minutes ago" is worse than rounding it to now.
501        assert_eq!(
502            describe_age(Utc::now() + Duration::minutes(5)),
503            "0 minutes ago"
504        );
505    }
506}