dev_prune/config.rs
1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Configuration and registry management for dev-prune.
5//
6// This module handles persistent storage of:
7// - Global settings (idle threshold, check interval, daemon toggle)
8// - Registered repository paths and their metadata
9//
10// All data is stored in `~/.config/dev-prune/registry.json`.
11
12use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::io::Write as _;
15use std::path::{Path, PathBuf};
16
17use anyhow::{Context, Result};
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20
21use crate::constants;
22
23/// Global settings that control prune behavior.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct Settings {
26 /// Number of inactive days before a repo is eligible for pruning.
27 pub idle_days: u64,
28 /// Interval in days between automated daemon checks.
29 pub check_interval_days: u64,
30 /// Whether the setup pass installs the OS scheduler. On by default.
31 pub auto_daemon: bool,
32 /// Whether the setup pass installs the global Git hooks. On by default.
33 #[serde(default = "default_auto_hooks")]
34 pub auto_hooks: bool,
35 /// Whether dev-prune installs its own missing integrations. On by default.
36 #[serde(default = "default_auto_setup")]
37 pub auto_setup: bool,
38 /// Whether interactive confirmation is required before pruning.
39 #[serde(default = "default_require_confirmation")]
40 pub require_confirmation: bool,
41 /// Timeout in seconds for lockfile enforcement / CLI commands (default 600s = 10m).
42 #[serde(default = "default_command_timeout_secs")]
43 pub command_timeout_secs: u64,
44 /// Smallest bloat directory worth deleting, in MiB. `0` disables the floor.
45 ///
46 /// Below this size the reinstall costs more than the space is worth, so the
47 /// directory is not offered as a candidate at all.
48 #[serde(default = "default_min_size_mb")]
49 pub min_size_mb: u64,
50 /// Whether dev-prune asks GitHub for the latest release from time to time.
51 ///
52 /// On by default, and opt-*out* rather than opt-in: an out-of-date cleanup tool is a
53 /// tool whose safety fixes you do not have. The request sends nothing but itself —
54 /// no identifier, no configuration, no usage data. Turn it off with
55 /// `devp config set update_check false`.
56 #[serde(default = "default_update_check")]
57 pub update_check: bool,
58 /// How many directory levels below a repository root discovery descends.
59 ///
60 /// Six by default. A flat repository never notices; a monorepo that nests projects
61 /// under `packages/@scope/name/app` does. Raise it when `devp status` does not list
62 /// a project you know is there, and remember that the walk gets more expensive with
63 /// every level. Clamped to [`constants::MAX_SCAN_DEPTH_LIMIT`].
64 #[serde(default = "default_scan_depth")]
65 pub scan_depth: usize,
66 /// Whether cargo and go may run the sync command that rewrites tracked manifests.
67 ///
68 /// Off. See [`constants::DEFAULT_ALLOW_MANIFEST_REWRITE`] — with this off, both are
69 /// verified read-only and a project with no lockfile at all is simply not pruned.
70 #[serde(default = "default_allow_manifest_rewrite")]
71 pub allow_manifest_rewrite: bool,
72 /// Days between automatic release checks.
73 ///
74 /// Only the *automatic* check honours this; `devp update` always asks, because you
75 /// are standing there waiting for the answer.
76 #[serde(default = "default_update_check_interval_days")]
77 pub update_check_interval_days: i64,
78 /// How long the release check waits for GitHub before giving up, in seconds.
79 ///
80 /// Five is right on a normal connection and too short behind some corporate proxies,
81 /// which is the whole reason this is a setting rather than a constant.
82 #[serde(default = "default_update_check_timeout_secs")]
83 pub update_check_timeout_secs: u64,
84 /// Whether the setup pass may install the Git hooks *in front of* another tool's.
85 ///
86 /// Off. With it on, a `core.hooksPath` that belongs to husky is not a reason to skip:
87 /// dev-prune takes the slot and forwards every hook back to the directory it
88 /// displaced. Behaviour-preserving, but it is still someone else's setup, so it is
89 /// asked for rather than assumed. Same thing as `devp hook install --chain`.
90 #[serde(default = "default_auto_hooks_chain")]
91 pub auto_hooks_chain: bool,
92}
93
94fn default_require_confirmation() -> bool {
95 constants::DEFAULT_REQUIRE_CONFIRMATION
96}
97
98fn default_command_timeout_secs() -> u64 {
99 constants::DEFAULT_COMMAND_TIMEOUT_SECS
100}
101
102fn default_auto_hooks() -> bool {
103 constants::DEFAULT_AUTO_HOOKS
104}
105
106fn default_auto_setup() -> bool {
107 constants::DEFAULT_AUTO_SETUP
108}
109
110fn default_update_check() -> bool {
111 constants::DEFAULT_UPDATE_CHECK
112}
113
114fn default_min_size_mb() -> u64 {
115 constants::DEFAULT_MIN_SIZE_MB
116}
117
118fn default_scan_depth() -> usize {
119 constants::DEFAULT_SCAN_DEPTH
120}
121
122fn default_allow_manifest_rewrite() -> bool {
123 constants::DEFAULT_ALLOW_MANIFEST_REWRITE
124}
125
126fn default_update_check_interval_days() -> i64 {
127 constants::UPDATE_CHECK_INTERVAL_DAYS
128}
129
130fn default_update_check_timeout_secs() -> u64 {
131 constants::UPDATE_CHECK_TIMEOUT_SECS
132}
133
134fn default_auto_hooks_chain() -> bool {
135 constants::DEFAULT_AUTO_HOOKS_CHAIN
136}
137
138impl Default for Settings {
139 fn default() -> Self {
140 Self {
141 idle_days: constants::DEFAULT_IDLE_DAYS,
142 check_interval_days: constants::DEFAULT_CHECK_INTERVAL_DAYS,
143 auto_daemon: constants::DEFAULT_AUTO_DAEMON,
144 auto_hooks: constants::DEFAULT_AUTO_HOOKS,
145 auto_setup: constants::DEFAULT_AUTO_SETUP,
146 require_confirmation: constants::DEFAULT_REQUIRE_CONFIRMATION,
147 command_timeout_secs: constants::DEFAULT_COMMAND_TIMEOUT_SECS,
148 min_size_mb: constants::DEFAULT_MIN_SIZE_MB,
149 update_check: constants::DEFAULT_UPDATE_CHECK,
150 scan_depth: constants::DEFAULT_SCAN_DEPTH,
151 allow_manifest_rewrite: constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
152 update_check_interval_days: constants::UPDATE_CHECK_INTERVAL_DAYS,
153 update_check_timeout_secs: constants::UPDATE_CHECK_TIMEOUT_SECS,
154 auto_hooks_chain: constants::DEFAULT_AUTO_HOOKS_CHAIN,
155 }
156 }
157}
158
159/// Metadata for a single registered repository.
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161pub struct RepoEntry {
162 /// Timestamp when the repo was added to the registry.
163 pub added_at: DateTime<Utc>,
164 /// Timestamp of the last successful prune, if any.
165 pub last_pruned_at: Option<DateTime<Utc>>,
166 /// Per-repo override for idle days (overrides global setting).
167 pub override_idle_days: Option<u64>,
168 /// Whether this repo is enabled for pruning.
169 pub enabled: bool,
170 /// Cumulative bytes reclaimed from this repository.
171 ///
172 /// Recorded from 1.1.0 onward. Registries written by 1.0.0 have no such figure and
173 /// deserialize to zero, so `devp stats` says where the number starts rather than
174 /// implying a repository pruned last March never freed anything.
175 #[serde(default)]
176 pub total_freed_bytes: u64,
177}
178
179impl RepoEntry {
180 /// Creates a new `RepoEntry` with the current timestamp.
181 pub fn new() -> Self {
182 Self {
183 added_at: Utc::now(),
184 last_pruned_at: None,
185 override_idle_days: None,
186 enabled: true,
187 total_freed_bytes: 0,
188 }
189 }
190}
191
192impl Default for RepoEntry {
193 fn default() -> Self {
194 Self::new()
195 }
196}
197
198/// Helper to ensure an entry (e.g. ".devprune.json") is present in the repository's `.gitignore`.
199/// If `.gitignore` doesn't exist, it creates it and adds the entry.
200pub fn ensure_in_gitignore(repo_path: &Path, entry: &str) -> Result<()> {
201 let gitignore_path = repo_path.join(".gitignore");
202 if gitignore_path.exists() {
203 let content = fs::read_to_string(&gitignore_path)?;
204 if !content.lines().any(|line| line.trim() == entry) {
205 let mut file = fs::OpenOptions::new().append(true).open(&gitignore_path)?;
206 let prefix = if content.ends_with('\n') || content.is_empty() {
207 ""
208 } else {
209 "\n"
210 };
211 writeln!(file, "{prefix}{entry}")?;
212 }
213 } else {
214 fs::write(&gitignore_path, format!("{entry}\n"))?;
215 }
216 Ok(())
217}
218
219/// Normalise a repository path into the form used as a registry key.
220///
221/// Falls back to the path as given when it cannot be canonicalised (e.g. it no longer
222/// exists), so entries for deleted repos stay addressable.
223pub fn canonical_key(path: &Path) -> PathBuf {
224 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
225}
226
227/// Expand a leading `~` to the user's home directory.
228///
229/// POSIX shells do this before the argument ever reaches a program, so on Linux and
230/// macOS it is usually a no-op. PowerShell and cmd do not: they hand a native
231/// executable the literal three characters `~/C`, and `devp init ~/Code` — the exact
232/// line in the README and on the landing page — would register a directory called `~`
233/// sitting in the current working directory. Quoting defeats the expansion in *every*
234/// shell, so `devp init "~/Code"` needs this too.
235///
236/// Only a bare `~` or a `~` followed by a separator is expanded. `~alice` means "some
237/// other user's home" in shell syntax and cannot be resolved portably, and `~backup` is
238/// a perfectly ordinary directory name.
239pub fn expand_tilde(raw: &str) -> String {
240 let Some(rest) = raw.strip_prefix('~') else {
241 return raw.to_string();
242 };
243 if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\')) {
244 return raw.to_string();
245 }
246 let Some(home) = dirs::home_dir() else {
247 // No home directory to expand to. Handing back the literal `~` lets the caller
248 // fail with "no such directory", which is a better error than a silent guess.
249 return raw.to_string();
250 };
251 if rest.is_empty() {
252 return home.to_string_lossy().into_owned();
253 }
254 home.join(rest.trim_start_matches(['/', '\\']))
255 .to_string_lossy()
256 .into_owned()
257}
258
259/// Structured per-repository configuration file stored inside repo roots as `.devprune.json`.
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
261pub struct PerRepoConfig {
262 /// JSON Schema reference URL for IDE IntelliSense and validation.
263 #[serde(rename = "$schema", default = "default_schema_url")]
264 pub schema: String,
265 /// Custom display name for this project in TUI and CLI status views.
266 #[serde(default)]
267 pub project_name: Option<String>,
268 /// Whether this repository is ignored/excluded from pruning.
269 #[serde(default)]
270 pub ignore: bool,
271 /// Disable global Git auto-registration hooks for this specific workspace.
272 #[serde(default)]
273 pub disable_hooks: bool,
274 /// Disable background daemon automated pruning pass for this specific workspace.
275 #[serde(default)]
276 pub disable_daemon: bool,
277 /// Custom override for idle days threshold (overrides global settings).
278 #[serde(default)]
279 pub override_idle_days: Option<u64>,
280 /// Custom override for the size floor, in MiB (overrides global `min_size_mb`).
281 ///
282 /// `Some(0)` is a meaningful value: it turns the floor off for this repository even
283 /// when a global floor is set.
284 #[serde(default)]
285 pub min_size_mb: Option<u64>,
286 /// Custom override for how deep discovery walks this repository.
287 ///
288 /// The setting that most often needs to differ per repository rather than globally:
289 /// one deeply-nested monorepo should not make every other repository pay for a
290 /// deeper walk. Clamped to [`constants::MAX_SCAN_DEPTH_LIMIT`] like the global one.
291 #[serde(default)]
292 pub scan_depth: Option<usize>,
293}
294
295// Deliberately absent: `allow_manifest_rewrite`.
296//
297// Only the settings whose right value depends on the *project* have a per-repository
298// form. `allow_manifest_rewrite` is a permission the user grants their own machine, and
299// — exactly as with `post_prune_command` below — nothing stops a project from committing
300// its `.devprune.json` despite the `.gitignore` entry [`PerRepoConfig::save`] writes. A
301// per-repository form would therefore let a repository nobody has read grant itself the
302// right to have `cargo generate-lockfile` / `go mod tidy` rewrite its tracked manifests
303// during an unattended pass. The `auto_*` and `update_check*` settings describe the
304// machine rather than a project and would mean nothing here either.
305
306// Removed: `custom_bloat_dirs` and `post_prune_command`.
307//
308// Both were serialized, schema'd and documented but never read by any code path, so
309// setting them did nothing. `post_prune_command` is also not a feature that should be
310// reintroduced casually: nothing stops a project from committing its `.devprune.json`,
311// so honouring it would mean cloning an untrusted repository and running `devp` hands
312// that repository arbitrary code execution on the user's machine.
313
314fn default_schema_url() -> String {
315 if let Ok(config_dir) = Registry::config_dir() {
316 let local_schema = config_dir.join("bin").join("devprune.schema.json");
317 if local_schema.exists() {
318 // `file://` + `/` + an absolute path. Unix paths already start with a
319 // separator, so pasting them in unconditionally produced `file:////home/...`
320 // — four slashes, which editors reject, leaving the `$schema` link dead and
321 // no IntelliSense at all on the platform where most of them run.
322 return file_uri(&crate::output::clean_path(&local_schema));
323 }
324 }
325 constants::JSON_SCHEMA_URL.to_string()
326}
327
328/// A `file://` URI for an absolute path.
329///
330/// `file://` + `/` + the path. Unix paths already start with a separator, so pasting one
331/// in unconditionally produced `file:////home/...` — four slashes, which editors reject,
332/// leaving the `$schema` link dead and no IntelliSense at all on the platform where most
333/// of them run.
334fn file_uri(clean_path: &str) -> String {
335 format!("file:///{}", clean_path.trim_start_matches('/'))
336}
337
338impl Default for PerRepoConfig {
339 fn default() -> Self {
340 Self {
341 schema: default_schema_url(),
342 project_name: None,
343 ignore: false,
344 disable_hooks: false,
345 disable_daemon: false,
346 override_idle_days: None,
347 min_size_mb: None,
348 scan_depth: None,
349 }
350 }
351}
352
353impl PerRepoConfig {
354 /// Load per-repo config from `.devprune.json`, or `None` when there is no such file.
355 ///
356 /// This is the only loader. There used to be a second one that returned `None` for a
357 /// file that failed to parse as well as for one that was absent, and every caller of
358 /// it then went on to act as though the repository had no configuration: the prune
359 /// pass ignored an `"ignore": true` it could not read, and the two workspace toggles
360 /// wrote a fresh default file straight over the user's broken one, taking every
361 /// override in it with them. A caller that genuinely does not care — the display-name
362 /// lookup — says so with `.ok().flatten()`.
363 pub fn load_with_diagnostics(repo_path: &Path) -> Result<Option<Self>, String> {
364 let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
365 if !config_file.exists() {
366 return Ok(None);
367 }
368 let content =
369 fs::read_to_string(&config_file).map_err(|e| format!("Failed to read file: {e}"))?;
370 match serde_json::from_str::<Self>(&content) {
371 Ok(cfg) => Ok(Some(cfg)),
372 // `clean_path`, like every other path this tool shows. `Display` on a
373 // canonicalised Windows path leaks the `\\?\` extended-length prefix into an
374 // error message the user is being asked to act on.
375 Err(e) => Err(format!(
376 "Syntax error in `{}`: {e}",
377 crate::output::clean_path(&config_file)
378 )),
379 }
380 }
381
382 /// Save per-repo config to `.devprune.json` in the repo root and auto-update `.gitignore`.
383 pub fn save_to_repo(&self, repo_path: &Path) -> Result<()> {
384 let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
385 let content = serde_json::to_string_pretty(self)?;
386 fs::write(&config_file, content)?;
387 let _ = ensure_in_gitignore(repo_path, constants::PER_REPO_CONFIG_FILE);
388 let _ = ensure_in_gitignore(repo_path, constants::DEVPRUNE_IGNORE_FILE);
389 Ok(())
390 }
391}
392
393/// One directory a prune pass deleted.
394///
395/// Enough to put it back and nothing more: which repository it belonged to, which
396/// project inside that repository owned it, and who verified it. No file list — the
397/// lockfile is the record of the contents, which is the whole premise of the tool.
398#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
399pub struct PrunedDir {
400 /// Repository root the directory belonged to.
401 pub repo_path: PathBuf,
402 /// Repository-relative label, `/`-separated: `node_modules`, `frontend/node_modules`.
403 pub bloat_dir: String,
404 /// Adapter that verified and deleted it.
405 pub adapter: String,
406 /// Bytes reclaimed.
407 pub size_freed: u64,
408}
409
410/// What the most recent prune pass deleted, for `devp restore --last-run`.
411///
412/// Only passes that actually deleted something are recorded. A later run that frees
413/// nothing — everything was active, everything was already clean — leaves this alone,
414/// because "put back what you just took" should still mean the pass that took something.
415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
416pub struct LastPrune {
417 /// When the pass ran.
418 pub at: DateTime<Utc>,
419 /// Every directory it removed.
420 pub dirs: Vec<PrunedDir>,
421}
422
423/// A one-line summary of a completed prune pass, for `devp stats`.
424///
425/// Deliberately not a second copy of [`LastPrune`]. That one exists so
426/// `devp restore --last-run` can put files back, so it carries the full directory list
427/// and only ever describes the most recent pass. This one is a trend line — four numbers
428/// per pass, bounded by [`constants::PRUNE_HISTORY_LIMIT`] — and could not restore
429/// anything if it wanted to.
430#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
431pub struct PruneRunSummary {
432 /// When the pass ran.
433 pub at: DateTime<Utc>,
434 /// Bytes reclaimed by the pass.
435 pub bytes_freed: u64,
436 /// How many directories it removed.
437 pub dirs_removed: usize,
438 /// How many distinct repositories it touched.
439 pub repos_touched: usize,
440}
441
442/// The top-level registry structure persisted to disk.
443#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
444pub struct Registry {
445 /// Schema version for forward compatibility.
446 pub version: String,
447 /// Global settings.
448 pub settings: Settings,
449 /// Map of canonical repo paths to their metadata.
450 pub repositories: HashMap<PathBuf, RepoEntry>,
451 /// Total cumulative bytes freed historically across all prune passes.
452 #[serde(default)]
453 pub total_freed_bytes: u64,
454 /// How many prune passes have deleted something, ever.
455 ///
456 /// One per *pass*, not per repository and not per directory — a `devp run` that
457 /// cleared eleven directories across four repositories counts once. Incremented in
458 /// exactly one place, [`Registry::record_prune`], which is also where the pass is
459 /// recorded for `devp restore --last-run`; keeping the two together is what stops
460 /// them meaning different things depending on which command did the pruning.
461 #[serde(default)]
462 pub total_pruned_count: u64,
463 /// List of repository paths added in the most recent init/link action (for devp undo).
464 #[serde(default)]
465 pub last_added_repos: Vec<PathBuf>,
466 /// What the most recent prune pass deleted (for `devp restore --last-run`).
467 #[serde(default)]
468 pub last_prune: Option<LastPrune>,
469 /// Summaries of recent prune passes, oldest first, for `devp stats`.
470 ///
471 /// Capped at [`constants::PRUNE_HISTORY_LIMIT`]. Recorded from 1.1.0 onward.
472 #[serde(default)]
473 pub prune_history: Vec<PruneRunSummary>,
474 /// When the release check last ran, so it runs at most once every
475 /// `UPDATE_CHECK_INTERVAL_DAYS` instead of on every command.
476 #[serde(default)]
477 pub last_update_check: Option<DateTime<Utc>>,
478 /// The newest release seen by the last check, so the reminder survives until the
479 /// user actually upgrades without needing the network again.
480 #[serde(default)]
481 pub latest_known_version: Option<String>,
482}
483
484impl Default for Registry {
485 fn default() -> Self {
486 Self {
487 version: "1.0".to_string(),
488 settings: Settings::default(),
489 repositories: HashMap::new(),
490 total_freed_bytes: 0,
491 total_pruned_count: 0,
492 last_added_repos: Vec::new(),
493 last_prune: None,
494 prune_history: Vec::new(),
495 last_update_check: None,
496 latest_known_version: None,
497 }
498 }
499}
500
501impl Registry {
502 /// Returns the path to the config directory (`~/.config/dev-prune/`).
503 ///
504 /// Uses the `dirs` crate to resolve the platform-specific config location:
505 /// - Linux/macOS: `~/.config/dev-prune/`
506 /// - Windows: `C:\Users\<user>\AppData\Roaming\dev-prune\` (or `~/.config/dev-prune/`)
507 pub fn config_dir() -> Result<PathBuf> {
508 if let Ok(override_dir) = std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE) {
509 return Ok(PathBuf::from(override_dir));
510 }
511 let base = dirs::config_dir().context("Could not determine config directory")?;
512 Ok(base.join(constants::CONFIG_DIR_NAME))
513 }
514
515 /// Returns the full path to the registry file.
516 pub fn registry_path() -> Result<PathBuf> {
517 Ok(Self::config_dir()?.join(constants::REGISTRY_FILENAME))
518 }
519
520 /// Loads the registry from disk, or the defaults when there is nothing to load.
521 ///
522 /// Reading does not write. This used to persist the default registry on the way
523 /// out, which made `devp --dry-run init` create the very file it had just promised
524 /// not to write and gave `devp status --json` — documented as a pure read — a side
525 /// effect on first use. Every command that actually changes something calls
526 /// [`Registry::save`], and that creates the directory as needed.
527 pub fn load() -> Result<Self> {
528 Self::load_from(&Self::registry_path()?)
529 }
530
531 /// Loads the registry from a specific path (for testing or custom locations).
532 ///
533 /// Non-persisting, exactly like [`Registry::load`], which is implemented on top of
534 /// it. The two used to disagree — this one wrote the defaults out when the file was
535 /// missing — which is the sort of difference that makes a test pass while the
536 /// behaviour it stands in for is broken.
537 pub fn load_from(path: &Path) -> Result<Self> {
538 if !path.exists() {
539 return Ok(Registry::default());
540 }
541 let contents = fs::read_to_string(path)
542 .with_context(|| format!("Failed to read registry at {}", path.display()))?;
543 serde_json::from_str(&contents)
544 .with_context(|| format!("Failed to parse registry at {}", path.display()))
545 }
546
547 /// Saves the registry to disk atomically (write to temp, then rename).
548 pub fn save(&self) -> Result<()> {
549 let path = Self::registry_path()?;
550 self.save_to(&path)
551 }
552
553 /// Saves the registry to a specific path (for testing or custom locations).
554 pub fn save_to(&self, path: &Path) -> Result<()> {
555 if let Some(parent) = path.parent() {
556 fs::create_dir_all(parent)
557 .with_context(|| format!("Failed to create config dir {}", parent.display()))?;
558 }
559 // Unique per process. A manual run and the scheduled daemon pass can save at the
560 // same moment; with a shared `registry.json.tmp`, one process could rename the
561 // other's half-written file into place as a torn, unparseable registry.
562 let tmp_path = path.with_extension(format!("json.{}.tmp", std::process::id()));
563 let contents =
564 serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
565 fs::write(&tmp_path, &contents)
566 .with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
567 fs::rename(&tmp_path, path)
568 .with_context(|| format!("Failed to rename temp registry to {}", path.display()))?;
569 Ok(())
570 }
571
572 /// Adds a repository to the registry. Returns `true` if newly added, `false` if already present.
573 pub fn add_repo(&mut self, path: PathBuf) -> bool {
574 // The registry is keyed by path, so `./foo`, `foo/`, and the absolute form
575 // would otherwise register as three separate repositories.
576 let path = canonical_key(&path);
577 if self.repositories.contains_key(&path) {
578 return false;
579 }
580 self.repositories.insert(path, RepoEntry::new());
581 true
582 }
583
584 /// Removes a repository from the registry. Returns `true` if it was present.
585 pub fn remove_repo(&mut self, path: &Path) -> bool {
586 self.repositories.remove(&canonical_key(path)).is_some()
587 }
588
589 // Removed: `repo_paths` and `effective_idle_days`.
590 //
591 // Neither had a caller outside this file's own tests. `effective_idle_days` had also
592 // drifted from the rule the engine actually applies: it looked the repository up by
593 // the path as given, where every write to `repositories` goes through
594 // `canonical_key`, so `devp`'s own relative paths would have missed the entry and
595 // silently returned the global threshold instead of the repository's override.
596
597 /// Credit `bytes_freed` to one repository, and to the machine-wide total.
598 ///
599 /// Safe to call once per repository or once per directory — every figure it touches
600 /// is either a sum or a timestamp, so the two styles agree. Counting *passes* is
601 /// deliberately not done here for exactly that reason; that lives in
602 /// [`Registry::record_prune`], which is called once per pass.
603 pub fn mark_pruned(&mut self, path: &Path, bytes_freed: u64) {
604 // Same rule as every other accessor: the map is keyed by `canonical_key`, so a
605 // raw lookup would silently skip the per-repo credit for a relative or
606 // differently-spelled path while still growing the machine-wide total.
607 if let Some(entry) = self.repositories.get_mut(&canonical_key(path)) {
608 entry.last_pruned_at = Some(Utc::now());
609 entry.total_freed_bytes += bytes_freed;
610 }
611 self.total_freed_bytes += bytes_freed;
612 }
613
614 /// Record what a prune pass deleted, replacing any earlier record.
615 ///
616 /// A pass that deleted nothing is not a pass worth remembering, so an empty list is
617 /// ignored rather than stored — otherwise `devp run` on an already-clean machine
618 /// would quietly throw away the record of the run the user actually wants back.
619 ///
620 /// This is the one place a prune pass is counted. It sets [`Registry::last_prune`],
621 /// appends a [`PruneRunSummary`] to [`Registry::prune_history`] and bumps
622 /// [`Registry::total_pruned_count`], because "a pass happened and it deleted things"
623 /// is exactly the condition all three describe. Splitting them across call sites is
624 /// how the counter previously came to mean repositories in `devp run` and directories
625 /// in the `devp status` dashboard.
626 pub fn record_prune(&mut self, dirs: Vec<PrunedDir>) {
627 self.record_prune_progress(Utc::now(), dirs);
628 }
629
630 /// Record a pass's progress mid-flight, superseding this same pass's earlier record.
631 ///
632 /// `at` identifies the pass: a repeated call with the same timestamp replaces the
633 /// history entry and `last_prune` it wrote before, rather than counting a second
634 /// pass. This exists so a long pass can persist after every repository — a crash
635 /// half-way through used to leave `devp restore --last-run` pointing at the
636 /// *previous* pass, offering to reinstall directories that were never deleted while
637 /// saying nothing about the ones that were.
638 pub fn record_prune_progress(&mut self, at: DateTime<Utc>, dirs: Vec<PrunedDir>) {
639 if dirs.is_empty() {
640 return;
641 }
642 if self.prune_history.last().map(|s| s.at) == Some(at) {
643 self.prune_history.pop();
644 } else {
645 self.total_pruned_count += 1;
646 }
647
648 self.prune_history.push(PruneRunSummary {
649 at,
650 bytes_freed: dirs.iter().map(|d| d.size_freed).sum(),
651 dirs_removed: dirs.len(),
652 repos_touched: dirs
653 .iter()
654 .map(|d| &d.repo_path)
655 .collect::<HashSet<_>>()
656 .len(),
657 });
658 // Oldest first, so the overflow comes off the front.
659 if self.prune_history.len() > constants::PRUNE_HISTORY_LIMIT {
660 let excess = self.prune_history.len() - constants::PRUNE_HISTORY_LIMIT;
661 self.prune_history.drain(..excess);
662 }
663
664 self.last_prune = Some(LastPrune { at, dirs });
665 }
666
667 /// Returns the number of registered repositories.
668 pub fn repo_count(&self) -> usize {
669 self.repositories.len()
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676 use tempfile::TempDir;
677
678 fn test_registry_path(dir: &TempDir) -> PathBuf {
679 dir.path().join("dev-prune").join("registry.json")
680 }
681
682 fn a_pruned_dir(label: &str) -> PrunedDir {
683 PrunedDir {
684 repo_path: PathBuf::from("/repo"),
685 bloat_dir: label.to_string(),
686 adapter: "npm".to_string(),
687 size_freed: 42,
688 }
689 }
690
691 #[test]
692 fn a_prune_that_deleted_nothing_does_not_erase_the_last_one() {
693 // Otherwise a second `devp run` on an already-clean machine throws away the
694 // record of the pass the user actually wants to undo.
695 let mut registry = Registry::default();
696 registry.record_prune(vec![a_pruned_dir("node_modules")]);
697 let recorded = registry.last_prune.clone().expect("first pass recorded");
698
699 registry.record_prune(Vec::new());
700
701 assert_eq!(registry.last_prune, Some(recorded));
702 }
703
704 #[test]
705 fn a_later_prune_replaces_the_record() {
706 let mut registry = Registry::default();
707 registry.record_prune(vec![a_pruned_dir("node_modules")]);
708 registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
709
710 let dirs = registry.last_prune.unwrap().dirs;
711 assert_eq!(dirs.len(), 1);
712 assert_eq!(dirs[0].bloat_dir, "frontend/node_modules");
713 }
714
715 #[test]
716 fn the_last_prune_record_survives_a_save_and_load() {
717 // `restore --last-run` reads it out of a file written by a process that has
718 // already exited, so the round trip is the whole feature.
719 let dir = TempDir::new().unwrap();
720 let path = test_registry_path(&dir);
721
722 let mut registry = Registry::default();
723 registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
724 registry.save_to(&path).unwrap();
725
726 let loaded = Registry::load_from(&path).unwrap();
727 assert_eq!(loaded.last_prune, registry.last_prune);
728 }
729
730 #[test]
731 fn a_registry_written_before_the_field_existed_still_loads() {
732 // The registry on disk predates `last_prune`; a missing key means "no pass
733 // recorded", not a parse failure that would lock the user out of their config.
734 let dir = TempDir::new().unwrap();
735 let path = test_registry_path(&dir);
736 fs::create_dir_all(path.parent().unwrap()).unwrap();
737 fs::write(
738 &path,
739 r#"{"version":"1.0","settings":{"idle_days":15,"check_interval_days":2,
740 "auto_daemon":true},"repositories":{}}"#,
741 )
742 .unwrap();
743
744 let loaded = Registry::load_from(&path).unwrap();
745 assert_eq!(loaded.last_prune, None);
746 }
747
748 #[test]
749 fn a_leading_tilde_becomes_the_home_directory() {
750 // The whole reason this exists: PowerShell hands `devp init ~/Code` straight
751 // through, so without expansion the registry gains a repository at `.\~\Code`.
752 let home = dirs::home_dir().expect("test host has a home directory");
753
754 assert_eq!(expand_tilde("~"), home.to_string_lossy());
755 assert_eq!(
756 expand_tilde("~/Code"),
757 home.join("Code").to_string_lossy(),
758 "forward slash, as typed in every shell"
759 );
760 assert_eq!(
761 expand_tilde("~\\Code"),
762 home.join("Code").to_string_lossy(),
763 "backslash, as typed in PowerShell"
764 );
765 }
766
767 #[test]
768 fn a_tilde_that_is_not_a_home_reference_is_left_alone() {
769 // `~alice` is another user's home in shell syntax and cannot be resolved
770 // portably; `~backup` and `./~tmp` are ordinary directory names. Rewriting any
771 // of them would silently point the user at the wrong directory.
772 for raw in ["~alice/Code", "~backup", "./~tmp", "Code~", "", "."] {
773 assert_eq!(expand_tilde(raw), raw, "{raw} must survive untouched");
774 }
775 }
776
777 #[test]
778 fn test_default_settings() {
779 let settings = Settings::default();
780 assert_eq!(settings.idle_days, 15);
781 assert_eq!(settings.check_interval_days, 2);
782 // On by default: dev-prune installs its own integrations, once per version,
783 // and only the ones it finds missing.
784 assert!(settings.auto_daemon);
785 assert!(settings.auto_hooks);
786 assert!(settings.auto_setup);
787 }
788
789 #[test]
790 fn settings_written_before_the_automation_toggles_existed_still_load() {
791 // Real registries on disk predate `auto_hooks` / `auto_setup`; an upgrade must
792 // read them rather than fail to parse and lose every registered repository.
793 let json = r#"{
794 "idle_days": 30,
795 "check_interval_days": 2,
796 "auto_daemon": false
797 }"#;
798 let settings: Settings = serde_json::from_str(json).unwrap();
799 assert_eq!(settings.idle_days, 30);
800 assert!(!settings.auto_daemon, "an explicit opt-out is preserved");
801 assert!(settings.auto_hooks, "a missing key takes the default");
802 assert!(settings.auto_setup);
803 }
804
805 #[test]
806 fn test_default_registry() {
807 let registry = Registry::default();
808 assert_eq!(registry.version, "1.0");
809 assert_eq!(registry.settings, Settings::default());
810 assert!(registry.repositories.is_empty());
811 }
812
813 #[test]
814 fn test_repo_entry_new() {
815 let entry = RepoEntry::new();
816 assert!(entry.enabled);
817 assert!(entry.last_pruned_at.is_none());
818 assert!(entry.override_idle_days.is_none());
819 }
820
821 #[test]
822 fn test_save_and_load() {
823 let tmp = TempDir::new().unwrap();
824 let path = test_registry_path(&tmp);
825
826 let mut registry = Registry::default();
827 registry.add_repo(PathBuf::from("/test/repo"));
828 registry.save_to(&path).unwrap();
829
830 let loaded = Registry::load_from(&path).unwrap();
831 assert_eq!(loaded.repo_count(), 1);
832 assert!(
833 loaded
834 .repositories
835 .contains_key(&PathBuf::from("/test/repo"))
836 );
837 }
838
839 #[test]
840 fn loading_a_missing_registry_yields_the_defaults_and_writes_nothing() {
841 let tmp = TempDir::new().unwrap();
842 let path = test_registry_path(&tmp);
843
844 let loaded = Registry::load_from(&path).unwrap();
845 assert_eq!(loaded, Registry::default());
846 // Reading is not writing. `devp --dry-run` and `devp status --json` both promise
847 // to leave the disk alone, and both start by loading the registry.
848 assert!(!path.exists(), "loading the registry created it");
849 }
850
851 #[test]
852 fn test_add_repo_returns_true_for_new() {
853 let mut registry = Registry::default();
854 assert!(registry.add_repo(PathBuf::from("/test/repo")));
855 }
856
857 #[test]
858 fn test_add_repo_returns_false_for_duplicate() {
859 let mut registry = Registry::default();
860 registry.add_repo(PathBuf::from("/test/repo"));
861 assert!(!registry.add_repo(PathBuf::from("/test/repo")));
862 }
863
864 #[test]
865 fn test_remove_repo() {
866 let mut registry = Registry::default();
867 registry.add_repo(PathBuf::from("/test/repo"));
868 assert!(registry.remove_repo(Path::new("/test/repo")));
869 assert!(!registry.remove_repo(Path::new("/test/repo")));
870 assert_eq!(registry.repo_count(), 0);
871 }
872
873 #[test]
874 fn test_mark_pruned() {
875 let mut registry = Registry::default();
876 registry.add_repo(PathBuf::from("/test/repo"));
877 assert!(
878 registry.repositories[&PathBuf::from("/test/repo")]
879 .last_pruned_at
880 .is_none()
881 );
882 registry.mark_pruned(Path::new("/test/repo"), 1024);
883 assert!(
884 registry.repositories[&PathBuf::from("/test/repo")]
885 .last_pruned_at
886 .is_some()
887 );
888 assert_eq!(registry.total_freed_bytes, 1024);
889 // Not the pass counter — that is `record_prune`'s job, once per pass.
890 assert_eq!(registry.total_pruned_count, 0);
891 }
892
893 #[test]
894 fn a_pass_is_counted_once_however_much_it_deleted() {
895 // The counter is published as `prune_passes`, and it used to be incremented once
896 // per repository by `devp run` and once per *directory* by the status dashboard,
897 // so the same work produced a different number depending on where it started.
898 let mut registry = Registry::default();
899 registry.add_repo(PathBuf::from("/repo"));
900
901 registry.mark_pruned(Path::new("/repo"), 1024);
902 registry.mark_pruned(Path::new("/repo"), 1024);
903 registry.record_prune(vec![
904 a_pruned_dir("node_modules"),
905 a_pruned_dir("frontend/node_modules"),
906 ]);
907
908 assert_eq!(registry.total_pruned_count, 1);
909
910 registry.record_prune(vec![a_pruned_dir("target")]);
911 assert_eq!(registry.total_pruned_count, 2);
912
913 // A pass that deleted nothing is not a pass.
914 registry.record_prune(Vec::new());
915 assert_eq!(registry.total_pruned_count, 2);
916 }
917
918 #[test]
919 fn mark_pruned_credits_the_repo_under_its_canonical_key() {
920 // On Windows, `canonicalize` yields a `\\?\`-prefixed path, so a registry keyed
921 // by the canonical form and a `mark_pruned` looking up the raw form would miss —
922 // growing the machine-wide total while the repository's own figure stayed zero.
923 let tmp = TempDir::new().unwrap();
924 let raw = tmp.path().to_path_buf();
925
926 let mut registry = Registry::default();
927 registry.add_repo(raw.clone());
928 registry.mark_pruned(&raw, 1024);
929
930 let entry = ®istry.repositories[&canonical_key(&raw)];
931 assert_eq!(entry.total_freed_bytes, 1024);
932 assert!(entry.last_pruned_at.is_some());
933 assert_eq!(registry.total_freed_bytes, 1024);
934 }
935
936 #[test]
937 fn each_repository_accumulates_its_own_total() {
938 // `devp stats` ranks repositories against each other, so the per-repo figure has
939 // to be a running total and not the size of the most recent pass.
940 let mut registry = Registry::default();
941 registry.add_repo(PathBuf::from("/test/repo"));
942 registry.add_repo(PathBuf::from("/test/other"));
943
944 registry.mark_pruned(Path::new("/test/repo"), 1024);
945 registry.mark_pruned(Path::new("/test/repo"), 2048);
946 registry.mark_pruned(Path::new("/test/other"), 512);
947
948 assert_eq!(
949 registry.repositories[&PathBuf::from("/test/repo")].total_freed_bytes,
950 3072
951 );
952 assert_eq!(
953 registry.repositories[&PathBuf::from("/test/other")].total_freed_bytes,
954 512
955 );
956 assert_eq!(registry.total_freed_bytes, 3584);
957 }
958
959 #[test]
960 fn the_prune_history_summarises_the_pass() {
961 let mut registry = Registry::default();
962 registry.record_prune(vec![
963 a_pruned_dir("node_modules"),
964 a_pruned_dir("frontend/node_modules"),
965 ]);
966
967 let summary = registry.prune_history.last().expect("pass summarised");
968 assert_eq!(summary.bytes_freed, 84);
969 assert_eq!(summary.dirs_removed, 2);
970 // Both fixtures live under `/repo`, so this is one repository, not two.
971 assert_eq!(summary.repos_touched, 1);
972 }
973
974 #[test]
975 fn the_prune_history_is_capped_and_drops_the_oldest() {
976 // The registry is rewritten in full on every save, so an uncapped list would grow
977 // the file forever on a machine running the scheduled pass.
978 let mut registry = Registry::default();
979 for _ in 0..constants::PRUNE_HISTORY_LIMIT + 5 {
980 registry.record_prune(vec![a_pruned_dir("node_modules")]);
981 }
982
983 assert_eq!(registry.prune_history.len(), constants::PRUNE_HISTORY_LIMIT);
984 let first = registry.prune_history.first().unwrap().at;
985 let last = registry.prune_history.last().unwrap().at;
986 assert!(first <= last, "oldest first");
987 }
988
989 #[test]
990 fn test_repo_count() {
991 let mut registry = Registry::default();
992 assert_eq!(registry.repo_count(), 0);
993 registry.add_repo(PathBuf::from("/a"));
994 registry.add_repo(PathBuf::from("/b"));
995 assert_eq!(registry.repo_count(), 2);
996 }
997
998 #[test]
999 fn a_local_schema_uri_has_exactly_three_slashes_on_either_platform() {
1000 assert_eq!(
1001 file_uri("/home/dev/.config/dev-prune/bin/devprune.schema.json"),
1002 "file:///home/dev/.config/dev-prune/bin/devprune.schema.json"
1003 );
1004 assert_eq!(
1005 file_uri("C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"),
1006 "file:///C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"
1007 );
1008 }
1009
1010 #[test]
1011 fn a_broken_per_repo_config_is_an_error_rather_than_an_absent_one() {
1012 // The distinction the whole tool leans on: "no config" means take the defaults,
1013 // "unreadable config" means refuse — never overwrite, never prune on a guess.
1014 let tmp = TempDir::new().unwrap();
1015 let repo = tmp.path();
1016 assert_eq!(PerRepoConfig::load_with_diagnostics(repo), Ok(None));
1017
1018 fs::write(
1019 repo.join(constants::PER_REPO_CONFIG_FILE),
1020 r#"{ "ignore": true, }"#,
1021 )
1022 .unwrap();
1023 let err = PerRepoConfig::load_with_diagnostics(repo).unwrap_err();
1024 assert!(err.contains("Syntax error"), "{err}");
1025
1026 fs::write(
1027 repo.join(constants::PER_REPO_CONFIG_FILE),
1028 r#"{ "ignore": true }"#,
1029 )
1030 .unwrap();
1031 assert!(
1032 PerRepoConfig::load_with_diagnostics(repo)
1033 .unwrap()
1034 .unwrap()
1035 .ignore
1036 );
1037 }
1038
1039 #[test]
1040 fn test_serialization_roundtrip() {
1041 let mut registry = Registry::default();
1042 registry.settings.idle_days = 30;
1043 registry.add_repo(PathBuf::from("/test/repo"));
1044
1045 let json = serde_json::to_string_pretty(®istry).unwrap();
1046 let deserialized: Registry = serde_json::from_str(&json).unwrap();
1047 assert_eq!(registry.settings.idle_days, deserialized.settings.idle_days);
1048 assert_eq!(registry.repo_count(), deserialized.repo_count());
1049 }
1050
1051 #[test]
1052 fn test_atomic_save_leaves_no_tmp() {
1053 let tmp = TempDir::new().unwrap();
1054 let path = test_registry_path(&tmp);
1055
1056 let registry = Registry::default();
1057 registry.save_to(&path).unwrap();
1058
1059 assert!(path.exists());
1060 // Nothing but the registry itself may remain — a leftover `*.tmp` would mean the
1061 // rename never happened.
1062 let leftovers: Vec<_> = fs::read_dir(path.parent().unwrap())
1063 .unwrap()
1064 .flatten()
1065 .filter(|e| e.path() != path)
1066 .collect();
1067 assert!(leftovers.is_empty(), "leftover files: {leftovers:?}");
1068 }
1069}