Skip to main content

dev_prune/commands/
history.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Handler for `dev-prune history`.
5//
6// `devp stats` says 27 GiB across 10 passes. This says which passes, what each one was
7// asked to do, and what it took โ€” the question anyone asks straight after reading the
8// total, and the one the tool could not answer until 1.17.0 because only the totals were
9// ever kept.
10//
11// The output is deliberately two commands rather than one long one. A pass that cleared
12// forty repositories has hundreds of directories in it, and a report that prints them all
13// by default is a report nobody reads: the list is one line per pass, and the detail is
14// asked for by number. `--export` exists for the case where the answer genuinely is
15// "all of it" โ€” a file is a better place for that than a scrollback buffer.
16
17use 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
28/// How many passes the compact list shows without `--limit` or `--all`.
29const PASSES_SHOWN: usize = 20;
30
31/// How many directories one pass's detail prints to a terminal before it stops.
32///
33/// Only to a terminal. A redirect or a pipe has somewhere to put the rest, and silently
34/// truncating what someone asked to be written to a file would be the worse failure.
35const DETAIL_DIRS_SHOWN: usize = 200;
36
37/// Options for the `history` command, from the CLI.
38pub struct HistoryArgs {
39    /// Show one pass in full. 1 is the most recent.
40    pub pass: Option<usize>,
41    /// How many passes the list shows.
42    pub limit: Option<usize>,
43    /// Show every recorded pass.
44    pub all: bool,
45    /// Emit the whole log as one JSON document.
46    pub json: bool,
47    /// Write the JSON document to a file. `Some(None)` means the default location.
48    pub export: Option<Option<PathBuf>>,
49    /// Show leaderboard high scores.
50    pub scores: bool,
51}
52
53/// Run the `history` command.
54pub fn run(args: &HistoryArgs) -> Result<()> {
55    let registry = Registry::load()?;
56    let passes = history::merged(history::load()?, &registry);
57
58    if args.scores {
59        return print_scores(&passes, args.json);
60    }
61
62    // Checked once, before anything branches on the output mode: `--pass 40 --json` on
63    // a machine with ten passes is the same question with no answer as `--pass 40`, and
64    // an empty `passes` array would read as "that pass deleted nothing".
65    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
86/// One single-cleanup record: bytes, when, and the repository's name where it is known.
87type SingleCleanup = (u64, DateTime<Utc>, Option<String>);
88/// One whole-pass record: bytes and when.
89type TotalRun = (u64, DateTime<Utc>);
90
91/// The two leaderboards behind `devp scores`: the most one repository gave back in a
92/// single pass, and the most one whole pass gave back.
93///
94/// A `Summary` pass without a directory list only yields a single-cleanup record when
95/// it touched exactly one repository, because that is the only case where the pass
96/// total and the per-repository figure are the same number. Guessing a split for the
97/// others would put invented records on the board.
98fn 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
152/// The document emitted by `devp scores --json` and `devp history --scores --json`.
153///
154/// The command key is spelled the way flag variants are everywhere else in the JSON
155/// contract (`status --drift`, `caches clear`): by the flag, on the command that owns
156/// the data.
157fn 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
207/// High-scores leaderboard: the best single cleanup and the best total run on record.
208pub 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
272/// Exit 2 for a pass number nobody has.
273fn 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
283/// One line per pass, newest first.
284fn 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            // Not "unknown": the pass is not mysterious, the format was younger than it.
305            Pass::Summary { .. } => ("โ€”".to_string(), " (totals only)".to_string()),
306        };
307        // Padded before colouring: a width applied to a string carrying ANSI escapes
308        // counts the escapes as width and the column drifts.
309        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
341/// One pass in full.
342fn 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
390/// The directory list, grouped under the repository each one belonged to.
391fn 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    // Only a terminal has a scrollback to overflow. Redirected or piped, the caller has
408    // asked for the whole thing and has somewhere to put it.
409    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
445/// Write the whole log to a file, and say where it went.
446fn 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
467/// Where `--export` writes.
468///
469/// A bare `--export` goes to the documents folder, which is the one directory every
470/// desktop has and nothing else writes to on its own. An argument that is an existing
471/// directory gets the same filename inside it, because `--export .` meaning "overwrite
472/// the current directory" is not what anyone types it for.
473pub 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
487/// "3 days ago", in the coarsest unit that is not a lie.
488fn 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        // `devp history --export .` must not try to overwrite the directory itself.
554        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        // A totals-only pass that touched one repository is a single-cleanup record
618        // too: the pass total and the per-repository figure are the same number.
619        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}