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 pub scores: bool,
51}
52
53pub fn run(args: &HistoryArgs) -> Result<()> {
55 let registry = Registry::load()?;
56 let passes = history::merged(history::load()?, ®istry);
57
58 if args.scores {
59 return print_scores(&passes, args.json);
60 }
61
62 if let Some(n) = args.pass {
66 check_pass_number(&passes, n)?;
67 }
68
69 if let Some(destination) = &args.export {
70 return export(&passes, args.pass, destination.as_deref());
71 }
72
73 if args.json {
74 return crate::json::emit(&crate::json::history_document(&passes, args.pass));
75 }
76
77 match args.pass {
78 Some(n) => print_one_pass(&passes, n),
79 None => {
80 print_pass_list(&passes, args);
81 Ok(())
82 }
83 }
84}
85
86type SingleCleanup = (u64, DateTime<Utc>, Option<String>);
88type TotalRun = (u64, DateTime<Utc>);
90
91fn collect_scores(passes: &[Pass]) -> (Vec<SingleCleanup>, Vec<TotalRun>) {
99 let mut single_cleanups: Vec<(u64, DateTime<Utc>, Option<String>)> = Vec::new();
100 for pass in passes {
101 match pass {
102 Pass::Detailed(record) => {
103 let mut per_repo: std::collections::HashMap<PathBuf, u64> =
104 std::collections::HashMap::new();
105 for d in &record.dirs {
106 *per_repo.entry(d.repo_path.clone()).or_insert(0) += d.size_freed;
107 }
108 for (repo, bytes) in per_repo {
109 if bytes > 0 {
110 let name = repo.file_name().map(|f| f.to_string_lossy().to_string());
111 single_cleanups.push((bytes, record.at, name));
112 }
113 }
114 }
115 Pass::Summary {
116 at,
117 bytes_freed,
118 repos_touched,
119 dirs,
120 ..
121 } => {
122 if let Some(dirs) = dirs {
123 let mut per_repo: std::collections::HashMap<PathBuf, u64> =
124 std::collections::HashMap::new();
125 for d in dirs {
126 *per_repo.entry(d.repo_path.clone()).or_insert(0) += d.size_freed;
127 }
128 for (repo, bytes) in per_repo {
129 if bytes > 0 {
130 let name = repo.file_name().map(|f| f.to_string_lossy().to_string());
131 single_cleanups.push((bytes, *at, name));
132 }
133 }
134 } else if *repos_touched == 1 && *bytes_freed > 0 {
135 single_cleanups.push((*bytes_freed, *at, None));
136 }
137 }
138 }
139 }
140 single_cleanups.sort_by_key(|entry| std::cmp::Reverse(entry.0));
141
142 let mut total_runs: Vec<(u64, DateTime<Utc>)> = passes
143 .iter()
144 .filter(|p| p.bytes_freed() > 0)
145 .map(|p| (p.bytes_freed(), p.at()))
146 .collect();
147 total_runs.sort_by_key(|entry| std::cmp::Reverse(entry.0));
148
149 (single_cleanups, total_runs)
150}
151
152fn scores_document(
158 single_cleanups: &[(u64, DateTime<Utc>, Option<String>)],
159 total_runs: &[(u64, DateTime<Utc>)],
160) -> serde_json::Value {
161 use serde_json::json;
162 let medals = ["Gold", "Silver", "Bronze"];
163 let medals_emojis = ["๐ฅ", "๐ฅ", "๐ฅ"];
164
165 let single_cleanup_json: Vec<serde_json::Value> = single_cleanups
166 .iter()
167 .take(3)
168 .enumerate()
169 .map(|(i, (bytes, date, repo))| {
170 json!({
171 "rank": i + 1,
172 "medal": medals[i],
173 "emoji": medals_emojis[i],
174 "bytes": bytes,
175 "formatted_bytes": output::format_bytes(*bytes),
176 "date": date.format("%Y-%m-%d").to_string(),
177 "repo": repo,
178 })
179 })
180 .collect();
181
182 let total_run_json: Vec<serde_json::Value> = total_runs
183 .iter()
184 .take(3)
185 .enumerate()
186 .map(|(i, (bytes, date))| {
187 json!({
188 "rank": i + 1,
189 "medal": medals[i],
190 "emoji": medals_emojis[i],
191 "bytes": bytes,
192 "formatted_bytes": output::format_bytes(*bytes),
193 "date": date.format("%Y-%m-%d").to_string(),
194 })
195 })
196 .collect();
197
198 json!({
199 "schema": crate::json::SCHEMA_VERSION,
200 "version": crate::constants::VERSION,
201 "command": "history --scores",
202 "single_cleanup": single_cleanup_json,
203 "total_run": total_run_json,
204 })
205}
206
207pub fn print_scores(passes: &[Pass], json: bool) -> Result<()> {
209 let (single_cleanups, total_runs) = collect_scores(passes);
210
211 if json {
212 return crate::json::emit(&scores_document(&single_cleanups, &total_runs));
213 }
214
215 if single_cleanups.is_empty() && total_runs.is_empty() {
216 output::print_header("High Scores Leaderboard");
217 output::print_info(
218 "No prune passes recorded yet. Run `devp run` to earn your first cleanup badge!",
219 );
220 return Ok(());
221 }
222
223 println!();
224 println!(" {}", "โโโโ โ
SINGLE CLEANUP โ
โโโโ".cyan().bold());
225 if single_cleanups.is_empty() {
226 println!(" {}", "No single repository records yet".dimmed());
227 } else {
228 for (i, (bytes, date, repo)) in single_cleanups.iter().take(3).enumerate() {
229 match i {
230 0 => println!(" {}", "๐ฅ Gold".yellow().bold()),
231 1 => println!(" {}", "๐ฅ Silver".white().bold()),
232 _ => println!(" {}", "๐ฅ Bronze".bright_yellow().bold()),
233 }
234 let repo_tag = repo
235 .as_ref()
236 .map(|r| format!(" ({})", r).cyan().to_string())
237 .unwrap_or_default();
238 println!(
239 " {} ยท {}{}",
240 output::format_bytes(*bytes).green().bold(),
241 date.format("%Y-%m-%d").to_string().dimmed(),
242 repo_tag
243 );
244 println!(" {}", "โโโโโโโโโโโโโโโโโโโโโโโโโโโโ".dimmed());
245 }
246 }
247
248 println!();
249 println!(" {}", "โโโโ โ
TOTAL RUN โ
โโโโ".cyan().bold());
250 if total_runs.is_empty() {
251 println!(" {}", "No total run records yet".dimmed());
252 } else {
253 for (i, (bytes, date)) in total_runs.iter().take(3).enumerate() {
254 match i {
255 0 => println!(" {}", "๐ฅ Gold".yellow().bold()),
256 1 => println!(" {}", "๐ฅ Silver".white().bold()),
257 _ => println!(" {}", "๐ฅ Bronze".bright_yellow().bold()),
258 }
259 println!(
260 " {} ยท {}",
261 output::format_bytes(*bytes).green().bold(),
262 date.format("%Y-%m-%d").to_string().dimmed()
263 );
264 println!(" {}", "โโโโโโโโโโโโโโโโโโโโโโโโโโโโ".dimmed());
265 }
266 }
267 println!();
268
269 Ok(())
270}
271
272fn check_pass_number(passes: &[Pass], number: usize) -> Result<()> {
274 if number == 0 || number > passes.len() {
275 return Err(anyhow::Error::new(crate::UsageError(format!(
276 "There is no pass #{number}. {} recorded; `devp history` lists them, newest first.",
277 passes.len()
278 ))));
279 }
280 Ok(())
281}
282
283fn print_pass_list(passes: &[Pass], args: &HistoryArgs) {
285 output::print_header("Prune passes");
286
287 if passes.is_empty() {
288 output::print_info(
289 "Nothing recorded yet โ `devp run --dry-run` shows what a pass would do.",
290 );
291 return;
292 }
293
294 let shown = if args.all {
295 passes.len()
296 } else {
297 args.limit.unwrap_or(PASSES_SHOWN).min(passes.len())
298 };
299
300 for (index, pass) in passes.iter().take(shown).enumerate() {
301 let number = index + 1;
302 let (started_by, note) = match pass {
303 Pass::Detailed(record) => (record.trigger.label().to_string(), String::new()),
304 Pass::Summary { .. } => ("โ".to_string(), " (totals only)".to_string()),
306 };
307 println!(
310 " {} {} {} {:<10} {} {}, {} {}{}",
311 format!("#{number}").dimmed(),
312 pass.at().format("%Y-%m-%d %H:%M"),
313 format!("{:>10}", output::format_bytes(pass.bytes_freed())).green(),
314 started_by,
315 pass.dirs_removed(),
316 output::plural(pass.dirs_removed(), "directory", "directories"),
317 pass.repos_touched(),
318 output::plural(pass.repos_touched(), "repository", "repositories"),
319 note.dimmed(),
320 );
321 }
322
323 if shown < passes.len() {
324 output::print_info(&format!(
325 "{} passes recorded; showing the most recent {shown}. `--all` for every one.",
326 passes.len()
327 ));
328 }
329
330 if passes.iter().any(|p| matches!(p, Pass::Summary { .. })) {
331 output::print_dimmed(&format!(
332 " Passes marked \"totals only\" ran before {PRUNE_LOG_STARTS_AT}, which is where \
333 the per-directory log starts."
334 ));
335 }
336
337 output::print_info("What one pass deleted: devp history --pass 1");
338 output::print_info("All of it, as a file: devp history --export");
339}
340
341fn print_one_pass(passes: &[Pass], number: usize) -> Result<()> {
343 check_pass_number(passes, number)?;
344 let pass = &passes[number - 1];
345
346 output::print_header(&format!("Pass #{number}"));
347 output::print_info(&format!(
348 "When {} ({})",
349 pass.at().format("%Y-%m-%d %H:%M UTC"),
350 describe_age(pass.at()),
351 ));
352
353 match pass {
354 Pass::Detailed(record) => {
355 output::print_info(&format!("Started by {}", record.trigger.label()));
356 output::print_info(&format!("Command {}", record.command_line()));
357 if !record.version.is_empty() {
358 output::print_info(&format!("Version dev-prune {}", record.version));
359 }
360 }
361 Pass::Summary { .. } => {
362 output::print_info(&format!(
363 "Started by not recorded โ this pass predates {PRUNE_LOG_STARTS_AT}"
364 ));
365 }
366 }
367
368 output::print_info(&format!(
369 "Freed {} from {} {} in {} {}",
370 output::format_bytes_styled(pass.bytes_freed()),
371 pass.dirs_removed(),
372 output::plural(pass.dirs_removed(), "directory", "directories"),
373 pass.repos_touched(),
374 output::plural(pass.repos_touched(), "repository", "repositories"),
375 ));
376
377 let Some(dirs) = pass.dirs() else {
378 output::print_header("Directories");
379 output::print_info(&format!(
380 "Not recorded. Only the totals above were kept before {PRUNE_LOG_STARTS_AT}; every \
381 pass from that release on carries its full list."
382 ));
383 return Ok(());
384 };
385
386 print_directories(dirs, number);
387 Ok(())
388}
389
390fn print_directories(dirs: &[PrunedDir], number: usize) {
392 use std::io::IsTerminal;
393
394 output::print_header("Directories");
395
396 let mut grouped: Vec<(&PathBuf, Vec<&PrunedDir>)> = Vec::new();
397 for dir in dirs {
398 match grouped.iter_mut().find(|(repo, _)| *repo == &dir.repo_path) {
399 Some((_, entries)) => entries.push(dir),
400 None => grouped.push((&dir.repo_path, vec![dir])),
401 }
402 }
403 grouped.sort_by_key(|(_, entries)| {
404 std::cmp::Reverse(entries.iter().map(|d| d.size_freed).sum::<u64>())
405 });
406
407 let budget = if std::io::stdout().is_terminal() {
410 DETAIL_DIRS_SHOWN
411 } else {
412 usize::MAX
413 };
414 let mut printed = 0usize;
415
416 for (repo, entries) in &grouped {
417 if printed >= budget {
418 break;
419 }
420 println!(" {}", output::styled_path(repo));
421 for dir in entries {
422 if printed >= budget {
423 break;
424 }
425 println!(
426 " {} {} {}",
427 format!("{:>10}", output::format_bytes(dir.size_freed)).green(),
428 output::pad_display(&dir.bloat_dir, 32),
429 output::styled_adapter(&dir.adapter),
430 );
431 printed += 1;
432 }
433 }
434
435 if printed < dirs.len() {
436 output::print_info(&format!(
437 "{} more not shown. `devp history --pass {number} --json` or `devp history --export` \
438 has all {}.",
439 dirs.len() - printed,
440 dirs.len(),
441 ));
442 }
443}
444
445fn export(passes: &[Pass], only: Option<usize>, destination: Option<&Path>) -> Result<()> {
447 let path = resolve_export_path(destination)?;
448 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
449 std::fs::create_dir_all(parent)
450 .with_context(|| format!("Failed to create {}", parent.display()))?;
451 }
452 let document = crate::json::history_document(passes, only);
453 let contents =
454 serde_json::to_string_pretty(&document).context("Failed to serialize history")?;
455 std::fs::write(&path, &contents)
456 .with_context(|| format!("Failed to write {}", path.display()))?;
457
458 let written = if only.is_some() { 1 } else { passes.len() };
459 output::print_success(&format!(
460 "{written} {} written to {}",
461 output::plural(written, "pass", "passes"),
462 output::clean_path(&path),
463 ));
464 Ok(())
465}
466
467pub fn resolve_export_path(destination: Option<&Path>) -> Result<PathBuf> {
474 let name = format!("dev-prune-history-{}.json", Utc::now().format("%Y-%m-%d"));
475 match destination {
476 Some(path) if path.is_dir() => Ok(path.join(name)),
477 Some(path) => Ok(path.to_path_buf()),
478 None => {
479 let base = dirs::document_dir()
480 .or_else(dirs::home_dir)
481 .context("Could not find a documents or home directory to export into. Pass a path: `devp history --export <FILE>`")?;
482 Ok(base.join(name))
483 }
484 }
485}
486
487fn describe_age(at: DateTime<Utc>) -> String {
489 let elapsed = Utc::now().signed_duration_since(at);
490 let days = elapsed.num_days();
491 if days >= 1 {
492 return format!(
493 "{days} {} ago",
494 output::plural(days as usize, "day", "days")
495 );
496 }
497 let hours = elapsed.num_hours();
498 if hours >= 1 {
499 return format!(
500 "{hours} {} ago",
501 output::plural(hours as usize, "hour", "hours")
502 );
503 }
504 let minutes = elapsed.num_minutes().max(0);
505 format!(
506 "{minutes} {} ago",
507 output::plural(minutes as usize, "minute", "minutes")
508 )
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use crate::history::{PassRecord, Trigger};
515 use tempfile::TempDir;
516
517 fn pass(at: DateTime<Utc>) -> Pass {
518 Pass::Detailed(PassRecord {
519 at,
520 trigger: Trigger::Manual,
521 argv: vec!["run".to_string()],
522 version: "1.17.0".to_string(),
523 dirs: vec![PrunedDir {
524 repo_path: PathBuf::from("/a"),
525 bloat_dir: "node_modules".to_string(),
526 adapter: "npm".to_string(),
527 size_freed: 100,
528 runtime: None,
529 }],
530 })
531 }
532
533 #[test]
534 fn a_pass_number_nobody_has_is_a_usage_error_not_an_empty_report() {
535 let passes = vec![pass(Utc::now())];
536 for n in [0usize, 2, 99] {
537 let err = print_one_pass(&passes, n).unwrap_err();
538 assert!(
539 err.downcast_ref::<crate::UsageError>().is_some(),
540 "--pass {n} should exit 2"
541 );
542 }
543 }
544
545 #[test]
546 fn asking_for_a_pass_on_an_empty_log_is_a_usage_error() {
547 let err = print_one_pass(&[], 1).unwrap_err();
548 assert!(err.downcast_ref::<crate::UsageError>().is_some());
549 }
550
551 #[test]
552 fn exporting_into_a_directory_keeps_the_generated_filename() {
553 let tmp = TempDir::new().unwrap();
555 let path = resolve_export_path(Some(tmp.path())).unwrap();
556 assert_eq!(path.parent().unwrap(), tmp.path());
557 assert!(
558 path.file_name()
559 .unwrap()
560 .to_string_lossy()
561 .starts_with("dev-prune-history-")
562 );
563 }
564
565 #[test]
566 fn exporting_to_a_named_file_uses_exactly_that_name() {
567 let tmp = TempDir::new().unwrap();
568 let target = tmp.path().join("mine.json");
569 assert_eq!(resolve_export_path(Some(&target)).unwrap(), target);
570 }
571
572 #[test]
573 fn an_export_writes_a_document_that_parses() {
574 let tmp = TempDir::new().unwrap();
575 let target = tmp.path().join("nested").join("history.json");
576 export(&[pass(Utc::now())], None, Some(&target)).unwrap();
577 let raw = std::fs::read_to_string(&target).unwrap();
578 let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
579 assert_eq!(parsed["command"], "history");
580 assert_eq!(parsed["passes"].as_array().unwrap().len(), 1);
581 }
582
583 #[test]
584 fn an_empty_log_still_prints_a_scores_report() {
585 assert!(print_scores(&[], false).is_ok());
586 assert!(print_scores(&[], true).is_ok());
587 let doc = scores_document(&[], &[]);
588 assert_eq!(doc["command"], "history --scores");
589 assert_eq!(doc["single_cleanup"].as_array().unwrap().len(), 0);
590 assert_eq!(doc["total_run"].as_array().unwrap().len(), 0);
591 }
592
593 #[test]
594 fn scores_rank_the_biggest_cleanup_and_the_biggest_run_first() {
595 let p1 = Pass::Detailed(PassRecord {
596 at: Utc::now(),
597 trigger: crate::history::Trigger::Manual,
598 argv: vec![],
599 version: "1.23.0".to_string(),
600 dirs: vec![
601 PrunedDir {
602 repo_path: PathBuf::from("/repo1"),
603 bloat_dir: "node_modules".to_string(),
604 adapter: "npm".to_string(),
605 size_freed: 1000,
606 runtime: None,
607 },
608 PrunedDir {
609 repo_path: PathBuf::from("/repo2"),
610 bloat_dir: "target".to_string(),
611 adapter: "cargo".to_string(),
612 size_freed: 5000,
613 runtime: None,
614 },
615 ],
616 });
617 let p2 = Pass::Summary {
620 at: Utc::now(),
621 bytes_freed: 10000,
622 dirs_removed: 1,
623 repos_touched: 1,
624 dirs: None,
625 };
626 let (singles, totals) = collect_scores(&[p1, p2]);
627
628 let single_bytes: Vec<u64> = singles.iter().map(|s| s.0).collect();
629 assert_eq!(single_bytes, vec![10000, 5000, 1000]);
630 assert_eq!(singles[1].2.as_deref(), Some("repo2"));
631 assert_eq!(singles[0].2, None, "a totals-only pass names no repository");
632
633 let total_bytes: Vec<u64> = totals.iter().map(|t| t.0).collect();
634 assert_eq!(total_bytes, vec![10000, 6000]);
635
636 let doc = scores_document(&singles, &totals);
637 assert_eq!(doc["command"], "history --scores");
638 assert_eq!(doc["schema"], crate::json::SCHEMA_VERSION);
639 assert_eq!(doc["single_cleanup"][0]["rank"], 1);
640 assert_eq!(doc["single_cleanup"][0]["bytes"], 10000);
641 assert_eq!(doc["single_cleanup"][0]["medal"], "Gold");
642 assert_eq!(doc["total_run"][1]["bytes"], 6000);
643 }
644}