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(registry.total_freed_bytes)
39 ));
40 output::print_info(&format!(
41 "Prune passes: {}",
42 registry.total_pruned_count
43 ));
44 output::print_info(&format!(
45 "Repositories: {} tracked",
46 registry.repo_count()
47 ));
48
49 print_last_pass(®istry);
50 print_recent_passes(®istry);
51 print_biggest_repositories(®istry);
52
53 Ok(())
54}
55
56fn print_last_pass(registry: &Registry) {
58 output::print_header("Most recent pass");
59
60 let Some(last) = ®istry.last_prune else {
61 output::print_info(
62 "Nothing recorded yet — `devp run --dry-run` shows what a pass would do.",
63 );
64 return;
65 };
66
67 let bytes: u64 = last.dirs.iter().map(|d| d.size_freed).sum();
68 output::print_info(&format!(
69 "{} ({}) — {} from {} {}",
70 last.at.format("%Y-%m-%d %H:%M UTC"),
71 describe_age(last.at),
72 output::format_bytes(bytes),
73 last.dirs.len(),
74 output::plural(last.dirs.len(), "directory", "directories"),
75 ));
76 output::print_info("Put it back with: devp restore --last-run");
77}
78
79fn print_recent_passes(registry: &Registry) {
80 if registry.prune_history.is_empty() {
81 return;
82 }
83
84 output::print_header("Recent passes");
85 for summary in registry.prune_history.iter().rev().take(PASSES_SHOWN) {
86 println!(
87 " {} {:>10} {} {} across {} {}",
88 summary.at.format("%Y-%m-%d %H:%M"),
89 output::format_bytes(summary.bytes_freed),
90 summary.dirs_removed,
91 output::plural(summary.dirs_removed, "directory", "directories"),
92 summary.repos_touched,
93 output::plural(summary.repos_touched, "repository", "repositories"),
94 );
95 }
96
97 let total = registry.prune_history.len();
98 if total > PASSES_SHOWN {
99 output::print_info(&format!(
100 "{total} passes recorded; showing the last {PASSES_SHOWN}."
101 ));
102 }
103}
104
105fn print_biggest_repositories(registry: &Registry) {
106 let mut ranked: Vec<_> = registry
107 .repositories
108 .iter()
109 .filter(|(_, entry)| entry.total_freed_bytes > 0)
110 .collect();
111
112 output::print_header("Biggest reclaims");
113
114 if ranked.is_empty() {
115 output::print_info(&format!(
119 "No per-repository figures yet — these are recorded from {HISTORY_STARTS_AT} onward."
120 ));
121 return;
122 }
123
124 ranked.sort_by(|a, b| {
125 b.1.total_freed_bytes
126 .cmp(&a.1.total_freed_bytes)
127 .then_with(|| a.0.cmp(b.0))
128 });
129
130 for (path, entry) in ranked.iter().take(REPOS_SHOWN) {
131 let last = entry
132 .last_pruned_at
133 .map(|at| format!("last pruned {}", describe_age(at)))
134 .unwrap_or_else(|| "never pruned by this install".to_string());
135 println!(
136 " {:>10} {} ({last})",
137 output::format_bytes(entry.total_freed_bytes),
138 output::clean_path(path),
139 );
140 }
141
142 output::print_info(&format!(
143 "Per-repository totals are recorded from {HISTORY_STARTS_AT} onward."
144 ));
145}
146
147fn describe_age(at: DateTime<Utc>) -> String {
149 let elapsed = Utc::now().signed_duration_since(at);
150 let days = elapsed.num_days();
151 if days >= 1 {
152 return format!(
153 "{days} {} ago",
154 output::plural(days as usize, "day", "days")
155 );
156 }
157 let hours = elapsed.num_hours();
158 if hours >= 1 {
159 return format!(
160 "{hours} {} ago",
161 output::plural(hours as usize, "hour", "hours")
162 );
163 }
164 let minutes = elapsed.num_minutes().max(0);
165 format!(
166 "{minutes} {} ago",
167 output::plural(minutes as usize, "minute", "minutes")
168 )
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use chrono::Duration;
175
176 #[test]
177 fn an_age_is_described_in_the_coarsest_unit_that_fits() {
178 assert_eq!(describe_age(Utc::now() - Duration::days(3)), "3 days ago");
179 assert_eq!(describe_age(Utc::now() - Duration::days(1)), "1 day ago");
180 assert_eq!(describe_age(Utc::now() - Duration::hours(5)), "5 hours ago");
181 assert_eq!(
182 describe_age(Utc::now() - Duration::minutes(2)),
183 "2 minutes ago"
184 );
185 }
186
187 #[test]
188 fn a_timestamp_in_the_future_does_not_render_as_negative() {
189 assert_eq!(
192 describe_age(Utc::now() + Duration::minutes(5)),
193 "0 minutes ago"
194 );
195 }
196}