dev_prune/commands/
stats.rs1use anyhow::Result;
15use chrono::{DateTime, Utc};
16
17use crate::config::Registry;
18use crate::constants::HISTORY_STARTS_AT;
19use crate::output;
20
21const PASSES_SHOWN: usize = 10;
23
24const REPOS_SHOWN: usize = 10;
26
27pub 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(®istry));
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 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(®istry);
57 print_recent_passes(®istry);
58 print_biggest_repositories(®istry);
59
60 Ok(())
61}
62
63fn print_last_pass(registry: &Registry) {
65 output::print_header("Most recent pass");
66
67 let Some(last) = ®istry.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 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 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
154fn 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 assert_eq!(
199 describe_age(Utc::now() + Duration::minutes(5)),
200 "0 minutes ago"
201 );
202 }
203}