1use std::path::{Path, PathBuf};
18
19use anyhow::{Context, Result};
20use chrono::{DateTime, Utc};
21use colored::Colorize;
22
23use crate::config::{PrunedDir, Registry};
24use crate::constants::PRUNE_LOG_STARTS_AT;
25use crate::history::{self, Pass};
26use crate::output;
27
28const PASSES_SHOWN: usize = 20;
30
31const DETAIL_DIRS_SHOWN: usize = 200;
36
37pub struct HistoryArgs {
39 pub pass: Option<usize>,
41 pub limit: Option<usize>,
43 pub all: bool,
45 pub json: bool,
47 pub export: Option<Option<PathBuf>>,
49}
50
51pub fn run(args: &HistoryArgs) -> Result<()> {
53 let registry = Registry::load()?;
54 let passes = history::merged(history::load()?, ®istry);
55
56 if let Some(n) = args.pass {
60 check_pass_number(&passes, n)?;
61 }
62
63 if let Some(destination) = &args.export {
64 return export(&passes, args.pass, destination.as_deref());
65 }
66
67 if args.json {
68 return crate::json::emit(&crate::json::history_document(&passes, args.pass));
69 }
70
71 match args.pass {
72 Some(n) => print_one_pass(&passes, n),
73 None => {
74 print_pass_list(&passes, args);
75 Ok(())
76 }
77 }
78}
79
80fn check_pass_number(passes: &[Pass], number: usize) -> Result<()> {
82 if number == 0 || number > passes.len() {
83 return Err(anyhow::Error::new(crate::UsageError(format!(
84 "There is no pass #{number}. {} recorded; `devp history` lists them, newest first.",
85 passes.len()
86 ))));
87 }
88 Ok(())
89}
90
91fn print_pass_list(passes: &[Pass], args: &HistoryArgs) {
93 output::print_header("Prune passes");
94
95 if passes.is_empty() {
96 output::print_info(
97 "Nothing recorded yet — `devp run --dry-run` shows what a pass would do.",
98 );
99 return;
100 }
101
102 let shown = if args.all {
103 passes.len()
104 } else {
105 args.limit.unwrap_or(PASSES_SHOWN).min(passes.len())
106 };
107
108 for (index, pass) in passes.iter().take(shown).enumerate() {
109 let number = index + 1;
110 let (started_by, note) = match pass {
111 Pass::Detailed(record) => (record.trigger.label().to_string(), String::new()),
112 Pass::Summary { .. } => ("—".to_string(), " (totals only)".to_string()),
114 };
115 println!(
118 " {} {} {} {:<10} {} {}, {} {}{}",
119 format!("#{number}").dimmed(),
120 pass.at().format("%Y-%m-%d %H:%M"),
121 format!("{:>10}", output::format_bytes(pass.bytes_freed())).green(),
122 started_by,
123 pass.dirs_removed(),
124 output::plural(pass.dirs_removed(), "directory", "directories"),
125 pass.repos_touched(),
126 output::plural(pass.repos_touched(), "repository", "repositories"),
127 note.dimmed(),
128 );
129 }
130
131 if shown < passes.len() {
132 output::print_info(&format!(
133 "{} passes recorded; showing the most recent {shown}. `--all` for every one.",
134 passes.len()
135 ));
136 }
137
138 if passes.iter().any(|p| matches!(p, Pass::Summary { .. })) {
139 output::print_dimmed(&format!(
140 " Passes marked \"totals only\" ran before {PRUNE_LOG_STARTS_AT}, which is where \
141 the per-directory log starts."
142 ));
143 }
144
145 output::print_info("What one pass deleted: devp history --pass 1");
146 output::print_info("All of it, as a file: devp history --export");
147}
148
149fn print_one_pass(passes: &[Pass], number: usize) -> Result<()> {
151 check_pass_number(passes, number)?;
152 let pass = &passes[number - 1];
153
154 output::print_header(&format!("Pass #{number}"));
155 output::print_info(&format!(
156 "When {} ({})",
157 pass.at().format("%Y-%m-%d %H:%M UTC"),
158 describe_age(pass.at()),
159 ));
160
161 match pass {
162 Pass::Detailed(record) => {
163 output::print_info(&format!("Started by {}", record.trigger.label()));
164 output::print_info(&format!("Command {}", record.command_line()));
165 if !record.version.is_empty() {
166 output::print_info(&format!("Version dev-prune {}", record.version));
167 }
168 }
169 Pass::Summary { .. } => {
170 output::print_info(&format!(
171 "Started by not recorded — this pass predates {PRUNE_LOG_STARTS_AT}"
172 ));
173 }
174 }
175
176 output::print_info(&format!(
177 "Freed {} from {} {} in {} {}",
178 output::format_bytes_styled(pass.bytes_freed()),
179 pass.dirs_removed(),
180 output::plural(pass.dirs_removed(), "directory", "directories"),
181 pass.repos_touched(),
182 output::plural(pass.repos_touched(), "repository", "repositories"),
183 ));
184
185 let Some(dirs) = pass.dirs() else {
186 output::print_header("Directories");
187 output::print_info(&format!(
188 "Not recorded. Only the totals above were kept before {PRUNE_LOG_STARTS_AT}; every \
189 pass from that release on carries its full list."
190 ));
191 return Ok(());
192 };
193
194 print_directories(dirs, number);
195 Ok(())
196}
197
198fn print_directories(dirs: &[PrunedDir], number: usize) {
200 use std::io::IsTerminal;
201
202 output::print_header("Directories");
203
204 let mut grouped: Vec<(&PathBuf, Vec<&PrunedDir>)> = Vec::new();
205 for dir in dirs {
206 match grouped.iter_mut().find(|(repo, _)| *repo == &dir.repo_path) {
207 Some((_, entries)) => entries.push(dir),
208 None => grouped.push((&dir.repo_path, vec![dir])),
209 }
210 }
211 grouped.sort_by_key(|(_, entries)| {
212 std::cmp::Reverse(entries.iter().map(|d| d.size_freed).sum::<u64>())
213 });
214
215 let budget = if std::io::stdout().is_terminal() {
218 DETAIL_DIRS_SHOWN
219 } else {
220 usize::MAX
221 };
222 let mut printed = 0usize;
223
224 for (repo, entries) in &grouped {
225 if printed >= budget {
226 break;
227 }
228 println!(" {}", output::styled_path(repo));
229 for dir in entries {
230 if printed >= budget {
231 break;
232 }
233 println!(
234 " {} {} {}",
235 format!("{:>10}", output::format_bytes(dir.size_freed)).green(),
236 output::pad_display(&dir.bloat_dir, 32),
237 output::styled_adapter(&dir.adapter),
238 );
239 printed += 1;
240 }
241 }
242
243 if printed < dirs.len() {
244 output::print_info(&format!(
245 "{} more not shown. `devp history --pass {number} --json` or `devp history --export` \
246 has all {}.",
247 dirs.len() - printed,
248 dirs.len(),
249 ));
250 }
251}
252
253fn export(passes: &[Pass], only: Option<usize>, destination: Option<&Path>) -> Result<()> {
255 let path = resolve_export_path(destination)?;
256 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
257 std::fs::create_dir_all(parent)
258 .with_context(|| format!("Failed to create {}", parent.display()))?;
259 }
260 let document = crate::json::history_document(passes, only);
261 let contents =
262 serde_json::to_string_pretty(&document).context("Failed to serialize history")?;
263 std::fs::write(&path, &contents)
264 .with_context(|| format!("Failed to write {}", path.display()))?;
265
266 let written = if only.is_some() { 1 } else { passes.len() };
267 output::print_success(&format!(
268 "{written} {} written to {}",
269 output::plural(written, "pass", "passes"),
270 output::clean_path(&path),
271 ));
272 Ok(())
273}
274
275pub fn resolve_export_path(destination: Option<&Path>) -> Result<PathBuf> {
282 let name = format!("dev-prune-history-{}.json", Utc::now().format("%Y-%m-%d"));
283 match destination {
284 Some(path) if path.is_dir() => Ok(path.join(name)),
285 Some(path) => Ok(path.to_path_buf()),
286 None => {
287 let base = dirs::document_dir()
288 .or_else(dirs::home_dir)
289 .context("Could not find a documents or home directory to export into. Pass a path: `devp history --export <FILE>`")?;
290 Ok(base.join(name))
291 }
292 }
293}
294
295fn describe_age(at: DateTime<Utc>) -> String {
297 let elapsed = Utc::now().signed_duration_since(at);
298 let days = elapsed.num_days();
299 if days >= 1 {
300 return format!(
301 "{days} {} ago",
302 output::plural(days as usize, "day", "days")
303 );
304 }
305 let hours = elapsed.num_hours();
306 if hours >= 1 {
307 return format!(
308 "{hours} {} ago",
309 output::plural(hours as usize, "hour", "hours")
310 );
311 }
312 let minutes = elapsed.num_minutes().max(0);
313 format!(
314 "{minutes} {} ago",
315 output::plural(minutes as usize, "minute", "minutes")
316 )
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::history::{PassRecord, Trigger};
323 use tempfile::TempDir;
324
325 fn pass(at: DateTime<Utc>) -> Pass {
326 Pass::Detailed(PassRecord {
327 at,
328 trigger: Trigger::Manual,
329 argv: vec!["run".to_string()],
330 version: "1.17.0".to_string(),
331 dirs: vec![PrunedDir {
332 repo_path: PathBuf::from("/a"),
333 bloat_dir: "node_modules".to_string(),
334 adapter: "npm".to_string(),
335 size_freed: 100,
336 runtime: None,
337 }],
338 })
339 }
340
341 #[test]
342 fn a_pass_number_nobody_has_is_a_usage_error_not_an_empty_report() {
343 let passes = vec![pass(Utc::now())];
344 for n in [0usize, 2, 99] {
345 let err = print_one_pass(&passes, n).unwrap_err();
346 assert!(
347 err.downcast_ref::<crate::UsageError>().is_some(),
348 "--pass {n} should exit 2"
349 );
350 }
351 }
352
353 #[test]
354 fn asking_for_a_pass_on_an_empty_log_is_a_usage_error() {
355 let err = print_one_pass(&[], 1).unwrap_err();
356 assert!(err.downcast_ref::<crate::UsageError>().is_some());
357 }
358
359 #[test]
360 fn exporting_into_a_directory_keeps_the_generated_filename() {
361 let tmp = TempDir::new().unwrap();
363 let path = resolve_export_path(Some(tmp.path())).unwrap();
364 assert_eq!(path.parent().unwrap(), tmp.path());
365 assert!(
366 path.file_name()
367 .unwrap()
368 .to_string_lossy()
369 .starts_with("dev-prune-history-")
370 );
371 }
372
373 #[test]
374 fn exporting_to_a_named_file_uses_exactly_that_name() {
375 let tmp = TempDir::new().unwrap();
376 let target = tmp.path().join("mine.json");
377 assert_eq!(resolve_export_path(Some(&target)).unwrap(), target);
378 }
379
380 #[test]
381 fn an_export_writes_a_document_that_parses() {
382 let tmp = TempDir::new().unwrap();
383 let target = tmp.path().join("nested").join("history.json");
384 export(&[pass(Utc::now())], None, Some(&target)).unwrap();
385 let raw = std::fs::read_to_string(&target).unwrap();
386 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
387 assert_eq!(parsed["command"], "history");
388 assert_eq!(parsed["passes"].as_array().unwrap().len(), 1);
389 }
390}