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}
50
51/// Run the `history` command.
52pub fn run(args: &HistoryArgs) -> Result<()> {
53    let registry = Registry::load()?;
54    let passes = history::merged(history::load()?, &registry);
55
56    // Checked once, before anything branches on the output mode: `--pass 40 --json` on
57    // a machine with ten passes is the same question with no answer as `--pass 40`, and
58    // an empty `passes` array would read as "that pass deleted nothing".
59    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
80/// Exit 2 for a pass number nobody has.
81fn 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
91/// One line per pass, newest first.
92fn 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            // Not "unknown": the pass is not mysterious, the format was younger than it.
113            Pass::Summary { .. } => ("—".to_string(), " (totals only)".to_string()),
114        };
115        // Padded before colouring: a width applied to a string carrying ANSI escapes
116        // counts the escapes as width and the column drifts.
117        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
149/// One pass in full.
150fn 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
198/// The directory list, grouped under the repository each one belonged to.
199fn 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    // Only a terminal has a scrollback to overflow. Redirected or piped, the caller has
216    // asked for the whole thing and has somewhere to put it.
217    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
253/// Write the whole log to a file, and say where it went.
254fn 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
275/// Where `--export` writes.
276///
277/// A bare `--export` goes to the documents folder, which is the one directory every
278/// desktop has and nothing else writes to on its own. An argument that is an existing
279/// directory gets the same filename inside it, because `--export .` meaning "overwrite
280/// the current directory" is not what anyone types it for.
281pub 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
295/// "3 days ago", in the coarsest unit that is not a lie.
296fn 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        // `devp history --export .` must not try to overwrite the directory itself.
362        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}