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 anyhow::Result;
15use chrono::{DateTime, Utc};
16
17use crate::config::Registry;
18use crate::constants::HISTORY_STARTS_AT;
19use crate::output;
20
21/// How many passes the text report lists. The registry keeps more; a screen holds fewer.
22const PASSES_SHOWN: usize = 10;
23
24/// How many repositories the text report ranks.
25const REPOS_SHOWN: usize = 10;
26
27/// Run the `stats` command.
28pub fn run(json_output: bool) -> Result<()> {
29    let registry = Registry::load()?;
30
31    if json_output {
32        return crate::json::emit(&crate::json::stats_document(&registry));
33    }
34
35    output::print_header("Lifetime");
36    output::print_info(&format!(
37        "Space reclaimed:   {}",
38        output::format_bytes_styled(registry.total_freed_bytes)
39    ));
40    // Its own line rather than added to the one above it. Both are space this tool gave
41    // back, but they are not interchangeable: the line above cost a reinstall in one
42    // repository, this one costs a download in every project on the disk.
43    output::print_info(&format!(
44        "Caches emptied:    {}",
45        output::format_bytes_styled(registry.total_cache_freed_bytes)
46    ));
47    output::print_info(&format!(
48        "Prune passes:      {}",
49        registry.total_pruned_count
50    ));
51    output::print_info(&format!(
52        "Repositories:      {} tracked",
53        registry.repo_count()
54    ));
55
56    print_last_pass(&registry);
57    print_recent_passes(&registry);
58    print_biggest_repositories(&registry);
59
60    Ok(())
61}
62
63/// The pass `devp restore --last-run` would undo.
64fn print_last_pass(registry: &Registry) {
65    output::print_header("Most recent pass");
66
67    let Some(last) = &registry.last_prune else {
68        output::print_info(
69            "Nothing recorded yet — `devp run --dry-run` shows what a pass would do.",
70        );
71        return;
72    };
73
74    let bytes: u64 = last.dirs.iter().map(|d| d.size_freed).sum();
75    output::print_info(&format!(
76        "{} ({}) — {} from {} {}",
77        last.at.format("%Y-%m-%d %H:%M UTC"),
78        describe_age(last.at),
79        output::format_bytes_styled(bytes),
80        last.dirs.len(),
81        output::plural(last.dirs.len(), "directory", "directories"),
82    ));
83    output::print_info("Put it back with: devp restore --last-run");
84}
85
86fn print_recent_passes(registry: &Registry) {
87    if registry.prune_history.is_empty() {
88        return;
89    }
90
91    output::print_header("Recent passes");
92    for summary in registry.prune_history.iter().rev().take(PASSES_SHOWN) {
93        use colored::Colorize;
94        // Pad before coloring: a `{:>10}` applied to a string carrying ANSI escapes
95        // counts the escapes as width and the column drifts.
96        println!(
97            "  {}   {}   {} {} across {} {}",
98            summary.at.format("%Y-%m-%d %H:%M"),
99            format!("{:>10}", output::format_bytes(summary.bytes_freed)).green(),
100            summary.dirs_removed,
101            output::plural(summary.dirs_removed, "directory", "directories"),
102            summary.repos_touched,
103            output::plural(summary.repos_touched, "repository", "repositories"),
104        );
105    }
106
107    let total = registry.prune_history.len();
108    if total > PASSES_SHOWN {
109        output::print_info(&format!(
110            "{total} passes recorded; showing the last {PASSES_SHOWN}."
111        ));
112    }
113}
114
115fn print_biggest_repositories(registry: &Registry) {
116    let mut ranked: Vec<_> = registry
117        .repositories
118        .iter()
119        .filter(|(_, entry)| entry.total_freed_bytes > 0)
120        .collect();
121
122    output::print_header("Biggest reclaims");
123
124    if ranked.is_empty() {
125        // The distinction matters on an upgraded machine: `total_freed_bytes` above can
126        // be gigabytes while every per-repository figure is still zero, and reading that
127        // as "nothing was ever pruned here" would be wrong.
128        output::print_info(&format!(
129            "No per-repository figures yet — these are recorded from {HISTORY_STARTS_AT} onward."
130        ));
131        return;
132    }
133
134    ranked.sort_by(|a, b| {
135        b.1.total_freed_bytes
136            .cmp(&a.1.total_freed_bytes)
137            .then_with(|| a.0.cmp(b.0))
138    });
139
140    for (path, entry) in ranked.iter().take(REPOS_SHOWN) {
141        use colored::Colorize;
142        let last = entry
143            .last_pruned_at
144            .map(|at| format!("last pruned {}", describe_age(at)))
145            .unwrap_or_else(|| "never pruned by this install".to_string());
146        println!(
147            "  {}   {}   ({last})",
148            format!("{:>10}", output::format_bytes(entry.total_freed_bytes)).green(),
149            output::styled_path(path),
150        );
151    }
152}
153
154/// "3 days ago", in the coarsest unit that is not a lie.
155fn describe_age(at: DateTime<Utc>) -> String {
156    let elapsed = Utc::now().signed_duration_since(at);
157    let days = elapsed.num_days();
158    if days >= 1 {
159        return format!(
160            "{days} {} ago",
161            output::plural(days as usize, "day", "days")
162        );
163    }
164    let hours = elapsed.num_hours();
165    if hours >= 1 {
166        return format!(
167            "{hours} {} ago",
168            output::plural(hours as usize, "hour", "hours")
169        );
170    }
171    let minutes = elapsed.num_minutes().max(0);
172    format!(
173        "{minutes} {} ago",
174        output::plural(minutes as usize, "minute", "minutes")
175    )
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use chrono::Duration;
182
183    #[test]
184    fn an_age_is_described_in_the_coarsest_unit_that_fits() {
185        assert_eq!(describe_age(Utc::now() - Duration::days(3)), "3 days ago");
186        assert_eq!(describe_age(Utc::now() - Duration::days(1)), "1 day ago");
187        assert_eq!(describe_age(Utc::now() - Duration::hours(5)), "5 hours ago");
188        assert_eq!(
189            describe_age(Utc::now() - Duration::minutes(2)),
190            "2 minutes ago"
191        );
192    }
193
194    #[test]
195    fn a_timestamp_in_the_future_does_not_render_as_negative() {
196        // Clock skew between the daemon and an interactive run is real, and
197        // "-1 minutes ago" is worse than rounding it to now.
198        assert_eq!(
199            describe_age(Utc::now() + Duration::minutes(5)),
200            "0 minutes ago"
201        );
202    }
203}