Skip to main content

dev_prune/
history.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4//! The per-pass prune log: what each pass deleted, and what asked it to.
5//!
6//! The registry already counted passes — four integers each, capped at fifty — which
7//! answers "how much has this saved me" and nothing else. It could not answer "what did
8//! the pass on the 12th actually delete", because the only full directory list it keeps
9//! is `last_prune`, and the next pass overwrites it. This file is that missing half.
10//!
11//! It is a separate file rather than another field on the registry because the two are
12//! read on opposite schedules. `registry.json` is parsed by every command and rewritten
13//! in full on every save; a growing per-directory record inside it would be a cost paid
14//! by `devp status`. This one is opened by `devp history` and appended to by a pass.
15//!
16//! Nothing here is load-bearing. A pass that cannot write its log entry still deleted the
17//! directories and still updated the registry, and `devp restore --last-run` reads the
18//! registry, not this. Every write is best-effort and every read tolerates a torn line:
19//! a pass killed between the `write_all` and the `sync_all` must cost one log entry, not
20//! the command that reads it.
21
22use std::fs;
23use std::path::{Path, PathBuf};
24
25use anyhow::{Context, Result};
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29use crate::config::{PrunedDir, Registry};
30use crate::constants;
31
32/// What started a prune pass.
33///
34/// The distinction the user is actually asking for when they open the log: an unattended
35/// pass that ran while they were asleep reads differently from one they typed. There are
36/// exactly three ways a prune starts — `devp run`, the scheduler's `devp run --daemon`,
37/// and the `[p]` key in `devp status` — and a Git hook is not one of them: the hook runs
38/// `devp link --quiet`, which registers a repository and deletes nothing.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Trigger {
42    /// Someone typed the command.
43    Manual,
44    /// The scheduled task, launch agent or systemd timer ran `run --daemon`.
45    Scheduled,
46    /// Selected with `[p]` from the `devp status` dashboard.
47    Dashboard,
48}
49
50impl Trigger {
51    /// Every trigger, in the order `devp stats` lists them.
52    ///
53    /// The report prints all three even at zero, because a `scheduled` line reading zero
54    /// is the answer to "is the daemon doing anything" — and iterating the variants from
55    /// here is what stops a fourth one from being added and silently never printed.
56    pub const ALL: [Trigger; 3] = [Trigger::Manual, Trigger::Scheduled, Trigger::Dashboard];
57
58    /// The word the report prints.
59    pub fn label(self) -> &'static str {
60        match self {
61            Trigger::Manual => "manual",
62            Trigger::Scheduled => "scheduled",
63            Trigger::Dashboard => "dashboard",
64        }
65    }
66
67    /// The trigger for a `devp run`, given whether the scheduler started it.
68    pub fn for_run(daemon: bool) -> Self {
69        if daemon {
70            Trigger::Scheduled
71        } else {
72            Trigger::Manual
73        }
74    }
75}
76
77/// One prune pass, in full.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct PassRecord {
80    /// When the pass started. Doubles as its identity — see [`record`].
81    pub at: DateTime<Utc>,
82    /// What started it.
83    pub trigger: Trigger,
84    /// The arguments the pass ran under, `argv[1..]`, unjoined.
85    ///
86    /// Stored as a list and joined only when printed. A repository path with a space in
87    /// it comes back out of a joined string as two arguments, and the whole point of
88    /// recording the flags is being able to read back what was really asked for.
89    #[serde(default)]
90    pub argv: Vec<String>,
91    /// The version of dev-prune that ran the pass.
92    #[serde(default)]
93    pub version: String,
94    /// Every directory it removed.
95    pub dirs: Vec<PrunedDir>,
96}
97
98impl PassRecord {
99    /// Bytes this pass reclaimed.
100    pub fn bytes_freed(&self) -> u64 {
101        self.dirs.iter().map(|d| d.size_freed).sum()
102    }
103
104    /// How many distinct repositories it touched.
105    pub fn repos_touched(&self) -> usize {
106        let mut paths: Vec<&PathBuf> = self.dirs.iter().map(|d| &d.repo_path).collect();
107        paths.sort();
108        paths.dedup();
109        paths.len()
110    }
111
112    /// The command line as a person would type it, for the report.
113    pub fn command_line(&self) -> String {
114        let args = self
115            .argv
116            .iter()
117            .map(|a| {
118                if a.contains(' ') {
119                    format!("\"{a}\"")
120                } else {
121                    a.clone()
122                }
123            })
124            .collect::<Vec<_>>()
125            .join(" ");
126        if args.is_empty() {
127            "devp".to_string()
128        } else {
129            format!("devp {args}")
130        }
131    }
132}
133
134/// Full path to the prune log.
135pub fn log_path() -> Result<PathBuf> {
136    Ok(Registry::config_dir()?.join(constants::PRUNE_LOG_FILENAME))
137}
138
139/// The arguments this process was invoked with, minus the program name.
140///
141/// Lossy on purpose: an argument that is not valid UTF-8 becomes its replacement form
142/// rather than dropping the whole record. A log entry that renders one path oddly is
143/// worth more than no entry at all.
144fn current_argv() -> Vec<String> {
145    std::env::args_os()
146        .skip(1)
147        .map(|a| a.to_string_lossy().into_owned())
148        .collect()
149}
150
151/// Record a pass, superseding this same pass's earlier entry.
152///
153/// `at` identifies the pass, exactly as it does for
154/// [`Registry::record_prune_progress`][crate::config::Registry::record_prune_progress]:
155/// a pass persists after every repository so a crash cannot strand it, and each of those
156/// calls carries the same timestamp and the growing directory list. Matching on `at`
157/// makes the last one win instead of writing one entry per repository.
158///
159/// Errors are swallowed. The caller has already deleted the directories.
160pub fn record(at: DateTime<Utc>, trigger: Trigger, dirs: &[PrunedDir]) {
161    if dirs.is_empty() {
162        return;
163    }
164    let record = PassRecord {
165        at,
166        trigger,
167        argv: current_argv(),
168        version: constants::VERSION.to_string(),
169        dirs: dirs.to_vec(),
170    };
171    if let Ok(path) = log_path() {
172        let _ = append_to(&path, record);
173    }
174}
175
176/// [`record`], against an explicit path. The testable half.
177pub fn append_to(path: &Path, record: PassRecord) -> Result<()> {
178    let mut records = load_from(path)?;
179    match records.iter().position(|r| r.at == record.at) {
180        Some(index) => records[index] = record,
181        None => records.push(record),
182    }
183    // Oldest first, so the overflow comes off the front — the same shape as the
184    // registry's own history, and for the same reason.
185    if records.len() > constants::PRUNE_LOG_LIMIT {
186        let excess = records.len() - constants::PRUNE_LOG_LIMIT;
187        records.drain(..excess);
188    }
189    write_to(path, &records)
190}
191
192/// Every recorded pass, oldest first.
193pub fn load() -> Result<Vec<PassRecord>> {
194    load_from(&log_path()?)
195}
196
197/// [`load`], against an explicit path.
198///
199/// A line that does not parse is skipped rather than failing the read. The last line is
200/// the one at risk — a pass killed mid-write leaves a partial one — and one truncated
201/// entry must not make every earlier pass unreadable forever.
202pub fn load_from(path: &Path) -> Result<Vec<PassRecord>> {
203    if !path.exists() {
204        return Ok(Vec::new());
205    }
206    let contents = fs::read_to_string(path)
207        .with_context(|| format!("Failed to read prune log at {}", path.display()))?;
208    Ok(contents
209        .lines()
210        .filter(|line| !line.trim().is_empty())
211        .filter_map(|line| serde_json::from_str::<PassRecord>(line).ok())
212        .collect())
213}
214
215/// Rewrite the log, atomically, using the same temp-and-rename dance as the registry.
216fn write_to(path: &Path, records: &[PassRecord]) -> Result<()> {
217    if let Some(parent) = path.parent() {
218        fs::create_dir_all(parent)
219            .with_context(|| format!("Failed to create config dir {}", parent.display()))?;
220    }
221    // Unique per process, for the reason `Registry::save_to` documents: a manual run and
222    // a scheduled pass can write at the same moment, and a shared temp name lets one
223    // rename the other's half-written file into place.
224    let tmp_path = path.with_extension(format!("jsonl.{}.tmp", std::process::id()));
225    let mut contents = String::new();
226    for record in records {
227        contents.push_str(&serde_json::to_string(record).context("Failed to serialize a pass")?);
228        contents.push('\n');
229    }
230    {
231        use std::io::Write;
232        let mut file = fs::File::create(&tmp_path)
233            .with_context(|| format!("Failed to write temp prune log {}", tmp_path.display()))?;
234        file.write_all(contents.as_bytes())
235            .with_context(|| format!("Failed to write temp prune log {}", tmp_path.display()))?;
236        file.sync_all()
237            .with_context(|| format!("Failed to flush temp prune log {}", tmp_path.display()))?;
238    }
239    fs::rename(&tmp_path, path)
240        .with_context(|| format!("Failed to rename temp prune log to {}", path.display()))
241}
242
243/// One pass as `devp history` sees it, from whichever source knows about it.
244///
245/// The two variants are not a design choice so much as an admission: this log starts at
246/// [`constants::PRUNE_LOG_STARTS_AT`], and every pass a machine ran before that upgrade
247/// exists only as the registry's four numbers. Showing those as a gap would read as data
248/// loss, and leaving them out entirely would make `devp history` disagree with the pass
249/// count `devp stats` prints two lines above it.
250#[derive(Debug, Clone)]
251pub enum Pass {
252    /// A pass this log recorded in full.
253    Detailed(PassRecord),
254    /// A pass known only from the registry's summary.
255    Summary {
256        at: DateTime<Utc>,
257        bytes_freed: u64,
258        dirs_removed: usize,
259        repos_touched: usize,
260        /// The directory list, when this happens to be the pass `last_prune` describes.
261        dirs: Option<Vec<PrunedDir>>,
262    },
263}
264
265impl Pass {
266    pub fn at(&self) -> DateTime<Utc> {
267        match self {
268            Pass::Detailed(r) => r.at,
269            Pass::Summary { at, .. } => *at,
270        }
271    }
272
273    pub fn bytes_freed(&self) -> u64 {
274        match self {
275            Pass::Detailed(r) => r.bytes_freed(),
276            Pass::Summary { bytes_freed, .. } => *bytes_freed,
277        }
278    }
279
280    pub fn dirs_removed(&self) -> usize {
281        match self {
282            Pass::Detailed(r) => r.dirs.len(),
283            Pass::Summary { dirs_removed, .. } => *dirs_removed,
284        }
285    }
286
287    pub fn repos_touched(&self) -> usize {
288        match self {
289            Pass::Detailed(r) => r.repos_touched(),
290            Pass::Summary { repos_touched, .. } => *repos_touched,
291        }
292    }
293
294    /// What started the pass, where that is known.
295    ///
296    /// `None` for a pass recovered from the registry summary: the summary has never
297    /// carried a trigger, so a machine that pruned before 1.17.0 cannot say afterwards
298    /// which of those passes it typed and which the scheduler ran.
299    pub fn trigger(&self) -> Option<Trigger> {
300        match self {
301            Pass::Detailed(r) => Some(r.trigger),
302            Pass::Summary { .. } => None,
303        }
304    }
305
306    /// The directory list, where one is known.
307    pub fn dirs(&self) -> Option<&[PrunedDir]> {
308        match self {
309            Pass::Detailed(r) => Some(&r.dirs),
310            Pass::Summary { dirs, .. } => dirs.as_deref(),
311        }
312    }
313}
314
315/// Every pass this machine can account for, newest first.
316///
317/// The log wins wherever both know about a pass: they are keyed by the same timestamp,
318/// and the log's copy carries the directory list and the flags.
319pub fn merged(records: Vec<PassRecord>, registry: &Registry) -> Vec<Pass> {
320    let mut passes: Vec<Pass> = Vec::with_capacity(records.len() + registry.prune_history.len());
321    let known: Vec<DateTime<Utc>> = records.iter().map(|r| r.at).collect();
322    passes.extend(records.into_iter().map(Pass::Detailed));
323
324    for summary in &registry.prune_history {
325        if known.contains(&summary.at) {
326            continue;
327        }
328        // `last_prune` and the newest summary describe the same pass, so on a machine
329        // upgrading into 1.17.0 the most recent pre-log pass still has its directories.
330        let dirs = registry
331            .last_prune
332            .as_ref()
333            .filter(|last| last.at == summary.at)
334            .map(|last| last.dirs.clone());
335        passes.push(Pass::Summary {
336            at: summary.at,
337            bytes_freed: summary.bytes_freed,
338            dirs_removed: summary.dirs_removed,
339            repos_touched: summary.repos_touched,
340            dirs,
341        });
342    }
343
344    passes.sort_by_key(|pass| std::cmp::Reverse(pass.at()));
345    passes
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::config::{LastPrune, PruneRunSummary};
352    use chrono::Duration;
353    use tempfile::TempDir;
354
355    fn dir(repo: &str, name: &str, bytes: u64) -> PrunedDir {
356        PrunedDir {
357            repo_path: PathBuf::from(repo),
358            bloat_dir: name.to_string(),
359            adapter: "npm".to_string(),
360            size_freed: bytes,
361            runtime: None,
362        }
363    }
364
365    fn record_at(at: DateTime<Utc>, dirs: Vec<PrunedDir>) -> PassRecord {
366        PassRecord {
367            at,
368            trigger: Trigger::Manual,
369            argv: vec!["run".to_string()],
370            version: "1.17.0".to_string(),
371            dirs,
372        }
373    }
374
375    #[test]
376    fn a_pass_that_saves_after_every_repository_is_one_entry_not_five() {
377        let tmp = TempDir::new().unwrap();
378        let path = tmp.path().join("prune-log.jsonl");
379        let at = Utc::now();
380
381        append_to(&path, record_at(at, vec![dir("/a", "node_modules", 100)])).unwrap();
382        append_to(
383            &path,
384            record_at(
385                at,
386                vec![
387                    dir("/a", "node_modules", 100),
388                    dir("/b", "node_modules", 200),
389                ],
390            ),
391        )
392        .unwrap();
393
394        let loaded = load_from(&path).unwrap();
395        assert_eq!(loaded.len(), 1);
396        assert_eq!(loaded[0].dirs.len(), 2);
397        assert_eq!(loaded[0].bytes_freed(), 300);
398        assert_eq!(loaded[0].repos_touched(), 2);
399    }
400
401    #[test]
402    fn a_torn_last_line_costs_one_pass_and_not_the_whole_log() {
403        // The failure this tolerance exists for: a machine powered off between the
404        // write and the flush. Every pass before it must still be readable.
405        let tmp = TempDir::new().unwrap();
406        let path = tmp.path().join("prune-log.jsonl");
407        let at = Utc::now();
408        append_to(&path, record_at(at, vec![dir("/a", "node_modules", 100)])).unwrap();
409
410        let mut raw = fs::read_to_string(&path).unwrap();
411        raw.push_str("{\"at\":\"2026-09-01T00:00:00Z\",\"trig");
412        fs::write(&path, raw).unwrap();
413
414        let loaded = load_from(&path).unwrap();
415        assert_eq!(loaded.len(), 1);
416        assert_eq!(loaded[0].at, at);
417    }
418
419    #[test]
420    fn the_oldest_passes_come_off_the_front_at_the_cap() {
421        let tmp = TempDir::new().unwrap();
422        let path = tmp.path().join("prune-log.jsonl");
423        let base = Utc::now();
424        for i in 0..(constants::PRUNE_LOG_LIMIT + 5) {
425            let at = base + Duration::seconds(i as i64);
426            append_to(&path, record_at(at, vec![dir("/a", "node_modules", 1)])).unwrap();
427        }
428        let loaded = load_from(&path).unwrap();
429        assert_eq!(loaded.len(), constants::PRUNE_LOG_LIMIT);
430        assert_eq!(loaded[0].at, base + Duration::seconds(5));
431    }
432
433    #[test]
434    fn a_missing_log_is_no_passes_and_not_an_error() {
435        let tmp = TempDir::new().unwrap();
436        let loaded = load_from(&tmp.path().join("absent.jsonl")).unwrap();
437        assert!(loaded.is_empty());
438    }
439
440    #[test]
441    fn passes_recorded_before_the_log_existed_still_appear() {
442        // The case that made this merge worth writing: an upgraded machine whose ten
443        // passes all predate the log. An empty `devp history` beside a `devp stats`
444        // reading "10 prune passes" is indistinguishable from a bug.
445        let mut registry = Registry::default();
446        let old = Utc::now() - Duration::days(30);
447        let newest = Utc::now() - Duration::days(1);
448        registry.prune_history = vec![
449            PruneRunSummary {
450                at: old,
451                bytes_freed: 500,
452                dirs_removed: 2,
453                repos_touched: 1,
454            },
455            PruneRunSummary {
456                at: newest,
457                bytes_freed: 900,
458                dirs_removed: 3,
459                repos_touched: 2,
460            },
461        ];
462        registry.last_prune = Some(LastPrune {
463            at: newest,
464            dirs: vec![dir("/a", "node_modules", 900)],
465        });
466
467        let passes = merged(Vec::new(), &registry);
468        assert_eq!(passes.len(), 2);
469        assert_eq!(passes[0].at(), newest);
470        // The newest pre-log pass keeps its directories, because `last_prune` has them.
471        assert!(passes[0].dirs().is_some());
472        assert!(passes[1].dirs().is_none());
473        assert_eq!(passes[1].bytes_freed(), 500);
474    }
475
476    #[test]
477    fn the_log_supersedes_the_registry_summary_of_the_same_pass() {
478        let mut registry = Registry::default();
479        let at = Utc::now();
480        registry.prune_history = vec![PruneRunSummary {
481            at,
482            bytes_freed: 300,
483            dirs_removed: 2,
484            repos_touched: 2,
485        }];
486        let passes = merged(
487            vec![record_at(
488                at,
489                vec![
490                    dir("/a", "node_modules", 100),
491                    dir("/b", "node_modules", 200),
492                ],
493            )],
494            &registry,
495        );
496        assert_eq!(passes.len(), 1);
497        assert!(matches!(passes[0], Pass::Detailed(_)));
498    }
499
500    #[test]
501    fn a_path_with_a_space_survives_the_round_trip_to_a_command_line() {
502        // Why `argv` is a list and not a joined string: joined, this reads as two
503        // arguments and the log stops being a record of what was asked for.
504        let record = PassRecord {
505            at: Utc::now(),
506            trigger: Trigger::Manual,
507            argv: vec!["run".to_string(), "C:\\My Code\\app".to_string()],
508            version: "1.17.0".to_string(),
509            dirs: vec![],
510        };
511        assert_eq!(record.command_line(), "devp run \"C:\\My Code\\app\"");
512    }
513
514    #[test]
515    fn the_scheduler_is_the_only_thing_that_records_a_scheduled_pass() {
516        assert_eq!(Trigger::for_run(true), Trigger::Scheduled);
517        assert_eq!(Trigger::for_run(false), Trigger::Manual);
518    }
519}