1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Trigger {
42 Manual,
44 Scheduled,
46 Dashboard,
48}
49
50impl Trigger {
51 pub const ALL: [Trigger; 3] = [Trigger::Manual, Trigger::Scheduled, Trigger::Dashboard];
57
58 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 pub fn for_run(daemon: bool) -> Self {
69 if daemon {
70 Trigger::Scheduled
71 } else {
72 Trigger::Manual
73 }
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct PassRecord {
80 pub at: DateTime<Utc>,
82 pub trigger: Trigger,
84 #[serde(default)]
90 pub argv: Vec<String>,
91 #[serde(default)]
93 pub version: String,
94 pub dirs: Vec<PrunedDir>,
96}
97
98impl PassRecord {
99 pub fn bytes_freed(&self) -> u64 {
101 self.dirs.iter().map(|d| d.size_freed).sum()
102 }
103
104 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 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
134pub fn log_path() -> Result<PathBuf> {
136 Ok(Registry::config_dir()?.join(constants::PRUNE_LOG_FILENAME))
137}
138
139fn 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
151pub 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
176pub 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 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
192pub fn load() -> Result<Vec<PassRecord>> {
194 load_from(&log_path()?)
195}
196
197pub 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
215fn 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 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#[derive(Debug, Clone)]
251pub enum Pass {
252 Detailed(PassRecord),
254 Summary {
256 at: DateTime<Utc>,
257 bytes_freed: u64,
258 dirs_removed: usize,
259 repos_touched: usize,
260 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 pub fn trigger(&self) -> Option<Trigger> {
300 match self {
301 Pass::Detailed(r) => Some(r.trigger),
302 Pass::Summary { .. } => None,
303 }
304 }
305
306 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
315pub 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 ®istry.prune_history {
325 if known.contains(&summary.at) {
326 continue;
327 }
328 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 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 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(), ®istry);
468 assert_eq!(passes.len(), 2);
469 assert_eq!(passes[0].at(), newest);
470 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 ®istry,
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 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}