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 `link` and `init` write a default `.devprune.json` into repositories
39 /// they register. Off by default; see [`constants::DEFAULT_AUTO_CONFIG`].
40 #[serde(default = "default_auto_config")]
41 pub auto_config: bool,
42 /// Whether interactive confirmation is required before pruning.
43 #[serde(default = "default_require_confirmation")]
44 pub require_confirmation: bool,
45 /// Timeout in seconds for lockfile enforcement / CLI commands (default 600s = 10m).
46 #[serde(default = "default_command_timeout_secs")]
47 pub command_timeout_secs: u64,
48 /// Smallest bloat directory worth deleting, in MiB. `0` disables the floor.
49 ///
50 /// Below this size the reinstall costs more than the space is worth, so the
51 /// directory is not offered as a candidate at all.
52 #[serde(default = "default_min_size_mb")]
53 pub min_size_mb: u64,
54 /// Whether dev-prune asks GitHub for the latest release from time to time.
55 ///
56 /// On by default, and opt-*out* rather than opt-in: an out-of-date cleanup tool is a
57 /// tool whose safety fixes you do not have. The request sends nothing but itself —
58 /// no identifier, no configuration, no usage data. Turn it off with
59 /// `devp config set update_check false`.
60 #[serde(default = "default_update_check")]
61 pub update_check: bool,
62 /// How many directory levels below a repository root discovery descends.
63 ///
64 /// Six by default. A flat repository never notices; a monorepo that nests projects
65 /// under `packages/@scope/name/app` does. Raise it when `devp status` does not list
66 /// a project you know is there, and remember that the walk gets more expensive with
67 /// every level. Clamped to [`constants::MAX_SCAN_DEPTH_LIMIT`].
68 #[serde(default = "default_scan_depth")]
69 pub scan_depth: usize,
70 /// Whether cargo and go may run the sync command that rewrites tracked manifests.
71 ///
72 /// Off. See [`constants::DEFAULT_ALLOW_MANIFEST_REWRITE`] — with this off, both are
73 /// verified read-only and a project with no lockfile at all is simply not pruned.
74 #[serde(default = "default_allow_manifest_rewrite")]
75 pub allow_manifest_rewrite: bool,
76 /// Days between automatic release checks.
77 ///
78 /// Only the *automatic* check honours this; `devp update` always asks, because you
79 /// are standing there waiting for the answer.
80 #[serde(default = "default_update_check_interval_days")]
81 pub update_check_interval_days: i64,
82 /// How long the release check waits for GitHub before giving up, in seconds.
83 ///
84 /// Five is right on a normal connection and too short behind some corporate proxies,
85 /// which is the whole reason this is a setting rather than a constant.
86 #[serde(default = "default_update_check_timeout_secs")]
87 pub update_check_timeout_secs: u64,
88 /// Whether the setup pass may install the Git hooks *in front of* another tool's.
89 ///
90 /// Off. With it on, a `core.hooksPath` that belongs to husky is not a reason to skip:
91 /// dev-prune takes the slot and forwards every hook back to the directory it
92 /// displaced. Behaviour-preserving, but it is still someone else's setup, so it is
93 /// asked for rather than assumed. Same thing as `devp hook install --chain`.
94 #[serde(default = "default_auto_hooks_chain")]
95 pub auto_hooks_chain: bool,
96 /// Whether the opt-in Gradle build-tool adapter is active. Off by default:
97 /// `build/` comes back by recompiling the project, so nobody should find it
98 /// deleted without having asked. See [`crate::adapters::gradle`].
99 #[serde(default)]
100 pub enable_gradle: bool,
101 /// Whether the opt-in Maven build-tool adapter is active. Off by default, for the
102 /// same reason as `enable_gradle`. See [`crate::adapters::maven`].
103 #[serde(default)]
104 pub enable_maven: bool,
105 /// Idle days required before *build-tool* directories (gradle, maven) are pruned.
106 ///
107 /// Separate from `idle_days` because the cost of being wrong is different: a
108 /// deleted `node_modules` is one `npm ci` away, a deleted Android `build/` is a
109 /// long recompile. Applied as `max(build_idle_days, idle_days)`.
110 #[serde(default = "default_build_idle_days")]
111 pub build_idle_days: u64,
112 /// Whether `devp update --install` runs by itself at the end of a prune pass when
113 /// a newer release is known. Off by default: replacing the binary is visible,
114 /// channel-specific behaviour the user opts into.
115 #[serde(default)]
116 pub auto_update: bool,
117}
118
119fn default_build_idle_days() -> u64 {
120 constants::DEFAULT_BUILD_IDLE_DAYS
121}
122
123fn default_require_confirmation() -> bool {
124 constants::DEFAULT_REQUIRE_CONFIRMATION
125}
126
127fn default_command_timeout_secs() -> u64 {
128 constants::DEFAULT_COMMAND_TIMEOUT_SECS
129}
130
131fn default_auto_hooks() -> bool {
132 constants::DEFAULT_AUTO_HOOKS
133}
134
135fn default_auto_setup() -> bool {
136 constants::DEFAULT_AUTO_SETUP
137}
138
139fn default_auto_config() -> bool {
140 constants::DEFAULT_AUTO_CONFIG
141}
142
143fn default_update_check() -> bool {
144 constants::DEFAULT_UPDATE_CHECK
145}
146
147fn default_min_size_mb() -> u64 {
148 constants::DEFAULT_MIN_SIZE_MB
149}
150
151fn default_scan_depth() -> usize {
152 constants::DEFAULT_SCAN_DEPTH
153}
154
155fn default_allow_manifest_rewrite() -> bool {
156 constants::DEFAULT_ALLOW_MANIFEST_REWRITE
157}
158
159fn default_update_check_interval_days() -> i64 {
160 constants::UPDATE_CHECK_INTERVAL_DAYS
161}
162
163fn default_update_check_timeout_secs() -> u64 {
164 constants::UPDATE_CHECK_TIMEOUT_SECS
165}
166
167fn default_auto_hooks_chain() -> bool {
168 constants::DEFAULT_AUTO_HOOKS_CHAIN
169}
170
171impl Default for Settings {
172 fn default() -> Self {
173 Self {
174 idle_days: constants::DEFAULT_IDLE_DAYS,
175 check_interval_days: constants::DEFAULT_CHECK_INTERVAL_DAYS,
176 auto_daemon: constants::DEFAULT_AUTO_DAEMON,
177 auto_hooks: constants::DEFAULT_AUTO_HOOKS,
178 auto_setup: constants::DEFAULT_AUTO_SETUP,
179 auto_config: constants::DEFAULT_AUTO_CONFIG,
180 require_confirmation: constants::DEFAULT_REQUIRE_CONFIRMATION,
181 command_timeout_secs: constants::DEFAULT_COMMAND_TIMEOUT_SECS,
182 min_size_mb: constants::DEFAULT_MIN_SIZE_MB,
183 update_check: constants::DEFAULT_UPDATE_CHECK,
184 scan_depth: constants::DEFAULT_SCAN_DEPTH,
185 allow_manifest_rewrite: constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
186 update_check_interval_days: constants::UPDATE_CHECK_INTERVAL_DAYS,
187 update_check_timeout_secs: constants::UPDATE_CHECK_TIMEOUT_SECS,
188 auto_hooks_chain: constants::DEFAULT_AUTO_HOOKS_CHAIN,
189 enable_gradle: false,
190 enable_maven: false,
191 build_idle_days: constants::DEFAULT_BUILD_IDLE_DAYS,
192 auto_update: false,
193 }
194 }
195}
196
197/// Metadata for a single registered repository.
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
199pub struct RepoEntry {
200 /// Timestamp when the repo was added to the registry.
201 pub added_at: DateTime<Utc>,
202 /// Timestamp of the last successful prune, if any.
203 pub last_pruned_at: Option<DateTime<Utc>>,
204 /// Per-repo override for idle days (overrides global setting).
205 pub override_idle_days: Option<u64>,
206 /// Whether this repo is enabled for pruning.
207 pub enabled: bool,
208 /// Cumulative bytes reclaimed from this repository.
209 ///
210 /// Recorded from 1.1.0 onward. Registries written by 1.0.0 have no such figure and
211 /// deserialize to zero, so `devp stats` says where the number starts rather than
212 /// implying a repository pruned last March never freed anything.
213 #[serde(default)]
214 pub total_freed_bytes: u64,
215}
216
217impl RepoEntry {
218 /// Creates a new `RepoEntry` with the current timestamp.
219 pub fn new() -> Self {
220 Self {
221 added_at: Utc::now(),
222 last_pruned_at: None,
223 override_idle_days: None,
224 enabled: true,
225 total_freed_bytes: 0,
226 }
227 }
228}
229
230impl Default for RepoEntry {
231 fn default() -> Self {
232 Self::new()
233 }
234}
235
236/// Resolve where a repository's shared git directory actually lives.
237///
238/// `.git` is a directory in an ordinary clone, but in worktrees and submodules it is a
239/// one-line `gitdir: <path>` pointer file — and a worktree's private gitdir in turn
240/// holds a `commondir` file pointing at the shared one, which is where `info/exclude`
241/// lives. Returns `None` when the path is not inside a git repository at all.
242fn git_common_dir(repo_path: &Path) -> Option<PathBuf> {
243 let dot_git = repo_path.join(".git");
244 let git_dir = if dot_git.is_dir() {
245 dot_git
246 } else {
247 let pointer = fs::read_to_string(&dot_git).ok()?;
248 let target = pointer.strip_prefix("gitdir:")?.trim();
249 let target = Path::new(target);
250 if target.is_absolute() {
251 target.to_path_buf()
252 } else {
253 repo_path.join(target)
254 }
255 };
256 if let Ok(common) = fs::read_to_string(git_dir.join("commondir")) {
257 let target = Path::new(common.trim());
258 if target.is_absolute() {
259 return Some(target.to_path_buf());
260 }
261 return Some(git_dir.join(target));
262 }
263 Some(git_dir)
264}
265
266/// Ensure an entry (e.g. ".devprune.json") is in the repository's `.git/info/exclude`.
267///
268/// The exclude file, not `.gitignore`: the config records one machine's preferences,
269/// and `.gitignore` is a tracked file shared by everyone who clones the repository —
270/// appending to it silently puts an uncommitted change in the user's diff. The exclude
271/// file gives the same "never shows up in `git status`" result without touching
272/// anything the repository tracks.
273pub fn ensure_in_git_exclude(repo_path: &Path, entry: &str) -> Result<()> {
274 let Some(git_dir) = git_common_dir(repo_path) else {
275 return Ok(());
276 };
277 let info_dir = git_dir.join("info");
278 fs::create_dir_all(&info_dir)?;
279 let exclude_path = info_dir.join("exclude");
280 if exclude_path.exists() {
281 let content = fs::read_to_string(&exclude_path)?;
282 if !content.lines().any(|line| line.trim() == entry) {
283 let mut file = fs::OpenOptions::new().append(true).open(&exclude_path)?;
284 let prefix = if content.ends_with('\n') || content.is_empty() {
285 ""
286 } else {
287 "\n"
288 };
289 writeln!(file, "{prefix}{entry}")?;
290 }
291 } else {
292 fs::write(&exclude_path, format!("{entry}\n"))?;
293 }
294 Ok(())
295}
296
297/// Normalise a repository path into the form used as a registry key.
298///
299/// Falls back to the path as given when it cannot be canonicalised (e.g. it no longer
300/// exists), so entries for deleted repos stay addressable.
301pub fn canonical_key(path: &Path) -> PathBuf {
302 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
303}
304
305/// Resolve `.` and `..` segments and anchor a relative path to the working directory,
306/// for paths that no longer exist and so cannot be canonicalised whole. The deepest
307/// ancestor that still exists is canonicalised and the missing tail re-appended:
308/// registry keys are canonical, and a deleted repo named through a symlinked parent —
309/// macOS's `/var` → `/private/var` temp tree being the everyday case — would otherwise
310/// spell the same directory through a different root and never compare equal.
311fn lexical_absolute(path: &Path) -> PathBuf {
312 use std::path::Component;
313 let mut out = if path.is_absolute() {
314 PathBuf::new()
315 } else {
316 std::env::current_dir().unwrap_or_default()
317 };
318 for comp in path.components() {
319 match comp {
320 Component::CurDir => {}
321 Component::ParentDir => {
322 out.pop();
323 }
324 other => out.push(other.as_os_str()),
325 }
326 }
327 let mut prefix = out.as_path();
328 while !prefix.as_os_str().is_empty() {
329 if let Ok(real) = prefix.canonicalize() {
330 if let Ok(tail) = out.strip_prefix(prefix) {
331 return real.join(tail);
332 }
333 break;
334 }
335 match prefix.parent() {
336 Some(parent) => prefix = parent,
337 None => break,
338 }
339 }
340 out
341}
342
343/// Whether two paths name the same directory, tolerating the differences
344/// canonicalisation normally absorbs: the Windows `\\?\` prefix, separator style,
345/// trailing separators, and case on Windows.
346fn loose_path_eq(a: &Path, b: &Path) -> bool {
347 let norm = |p: &Path| {
348 let s = p.to_string_lossy().replace('\\', "/");
349 let s = s.strip_prefix("//?/").unwrap_or(&s);
350 let s = s.trim_end_matches('/').to_string();
351 if cfg!(windows) { s.to_lowercase() } else { s }
352 };
353 norm(a) == norm(b)
354}
355
356/// Expand a leading `~` to the user's home directory.
357///
358/// POSIX shells do this before the argument ever reaches a program, so on Linux and
359/// macOS it is usually a no-op. PowerShell and cmd do not: they hand a native
360/// executable the literal three characters `~/C`, and `devp init ~/Code` — the exact
361/// line in the README and on the landing page — would register a directory called `~`
362/// sitting in the current working directory. Quoting defeats the expansion in *every*
363/// shell, so `devp init "~/Code"` needs this too.
364///
365/// Only a bare `~` or a `~` followed by a separator is expanded. `~alice` means "some
366/// other user's home" in shell syntax and cannot be resolved portably, and `~backup` is
367/// a perfectly ordinary directory name.
368pub fn expand_tilde(raw: &str) -> String {
369 let Some(rest) = raw.strip_prefix('~') else {
370 return raw.to_string();
371 };
372 if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\')) {
373 return raw.to_string();
374 }
375 let Some(home) = dirs::home_dir() else {
376 // No home directory to expand to. Handing back the literal `~` lets the caller
377 // fail with "no such directory", which is a better error than a silent guess.
378 return raw.to_string();
379 };
380 if rest.is_empty() {
381 return home.to_string_lossy().into_owned();
382 }
383 home.join(rest.trim_start_matches(['/', '\\']))
384 .to_string_lossy()
385 .into_owned()
386}
387
388/// Structured per-repository configuration file stored inside repo roots as `.devprune.json`.
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
390pub struct PerRepoConfig {
391 /// JSON Schema reference URL for IDE IntelliSense and validation.
392 #[serde(rename = "$schema", default = "default_schema_url")]
393 pub schema: String,
394 /// Custom display name for this project in TUI and CLI status views.
395 #[serde(default)]
396 pub project_name: Option<String>,
397 /// Whether this repository is ignored/excluded from pruning.
398 #[serde(default)]
399 pub ignore: bool,
400 /// Disable global Git auto-registration hooks for this specific workspace.
401 #[serde(default)]
402 pub disable_hooks: bool,
403 /// Disable background daemon automated pruning pass for this specific workspace.
404 #[serde(default)]
405 pub disable_daemon: bool,
406 /// Custom override for idle days threshold (overrides global settings).
407 #[serde(default)]
408 pub override_idle_days: Option<u64>,
409 /// Custom override for the size floor, in MiB (overrides global `min_size_mb`).
410 ///
411 /// `Some(0)` is a meaningful value: it turns the floor off for this repository even
412 /// when a global floor is set.
413 #[serde(default)]
414 pub min_size_mb: Option<u64>,
415 /// Custom override for how deep discovery walks this repository.
416 ///
417 /// The setting that most often needs to differ per repository rather than globally:
418 /// one deeply-nested monorepo should not make every other repository pay for a
419 /// deeper walk. Clamped to [`constants::MAX_SCAN_DEPTH_LIMIT`] like the global one.
420 #[serde(default)]
421 pub scan_depth: Option<usize>,
422}
423
424// Deliberately absent: `allow_manifest_rewrite`.
425//
426// Only the settings whose right value depends on the *project* have a per-repository
427// form. `allow_manifest_rewrite` is a permission the user grants their own machine, and
428// — exactly as with `post_prune_command` below — nothing stops a project from committing
429// its `.devprune.json`: the `.git/info/exclude` entry [`PerRepoConfig::save_to_repo`]
430// writes is local to one clone and excludes nothing already tracked. A
431// per-repository form would therefore let a repository nobody has read grant itself the
432// right to have `cargo generate-lockfile` / `go mod tidy` rewrite its tracked manifests
433// during an unattended pass. The `auto_*` and `update_check*` settings describe the
434// machine rather than a project and would mean nothing here either.
435
436// Removed: `custom_bloat_dirs` and `post_prune_command`.
437//
438// Both were serialized, schema'd and documented but never read by any code path, so
439// setting them did nothing. `post_prune_command` is also not a feature that should be
440// reintroduced casually: nothing stops a project from committing its `.devprune.json`,
441// so honouring it would mean cloning an untrusted repository and running `devp` hands
442// that repository arbitrary code execution on the user's machine.
443
444fn default_schema_url() -> String {
445 if let Ok(config_dir) = Registry::config_dir() {
446 let local_schema = config_dir.join("bin").join("devprune.schema.json");
447 if local_schema.exists() {
448 // `file://` + `/` + an absolute path. Unix paths already start with a
449 // separator, so pasting them in unconditionally produced `file:////home/...`
450 // — four slashes, which editors reject, leaving the `$schema` link dead and
451 // no IntelliSense at all on the platform where most of them run.
452 return file_uri(&crate::output::clean_path(&local_schema));
453 }
454 }
455 constants::JSON_SCHEMA_URL.to_string()
456}
457
458/// A `file://` URI for an absolute path.
459///
460/// `file://` + `/` + the path. Unix paths already start with a separator, so pasting one
461/// in unconditionally produced `file:////home/...` — four slashes, which editors reject,
462/// leaving the `$schema` link dead and no IntelliSense at all on the platform where most
463/// of them run.
464fn file_uri(clean_path: &str) -> String {
465 format!("file:///{}", clean_path.trim_start_matches('/'))
466}
467
468impl Default for PerRepoConfig {
469 fn default() -> Self {
470 Self {
471 schema: default_schema_url(),
472 project_name: None,
473 ignore: false,
474 disable_hooks: false,
475 disable_daemon: false,
476 override_idle_days: None,
477 min_size_mb: None,
478 scan_depth: None,
479 }
480 }
481}
482
483impl PerRepoConfig {
484 /// Load per-repo config from `.devprune.json`, or `None` when there is no such file.
485 ///
486 /// This is the only loader. There used to be a second one that returned `None` for a
487 /// file that failed to parse as well as for one that was absent, and every caller of
488 /// it then went on to act as though the repository had no configuration: the prune
489 /// pass ignored an `"ignore": true` it could not read, and the two workspace toggles
490 /// wrote a fresh default file straight over the user's broken one, taking every
491 /// override in it with them. A caller that genuinely does not care — the display-name
492 /// lookup — says so with `.ok().flatten()`.
493 pub fn load_with_diagnostics(repo_path: &Path) -> Result<Option<Self>, String> {
494 let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
495 if !config_file.exists() {
496 return Ok(None);
497 }
498 let content =
499 fs::read_to_string(&config_file).map_err(|e| format!("Failed to read file: {e}"))?;
500 match serde_json::from_str::<Self>(&content) {
501 Ok(cfg) => Ok(Some(cfg)),
502 // `clean_path`, like every other path this tool shows. `Display` on a
503 // canonicalised Windows path leaks the `\\?\` extended-length prefix into an
504 // error message the user is being asked to act on.
505 Err(e) => Err(format!(
506 "Syntax error in `{}`: {e}",
507 crate::output::clean_path(&config_file)
508 )),
509 }
510 }
511
512 /// Save per-repo config to `.devprune.json` in the repo root, and record it in the
513 /// repository's `.git/info/exclude` so it never shows up in `git status`.
514 pub fn save_to_repo(&self, repo_path: &Path) -> Result<()> {
515 let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
516 let content = serde_json::to_string_pretty(self)?;
517 fs::write(&config_file, content)?;
518 let _ = ensure_in_git_exclude(repo_path, constants::PER_REPO_CONFIG_FILE);
519 let _ = ensure_in_git_exclude(repo_path, constants::DEVPRUNE_IGNORE_FILE);
520 Ok(())
521 }
522}
523
524/// One directory a prune pass deleted.
525///
526/// Enough to put it back and nothing more: which repository it belonged to, which
527/// project inside that repository owned it, and who verified it. No file list — the
528/// lockfile is the record of the contents, which is the whole premise of the tool.
529#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
530pub struct PrunedDir {
531 /// Repository root the directory belonged to.
532 pub repo_path: PathBuf,
533 /// Repository-relative label, `/`-separated: `node_modules`, `frontend/node_modules`.
534 pub bloat_dir: String,
535 /// Adapter that verified and deleted it.
536 pub adapter: String,
537 /// Bytes reclaimed.
538 pub size_freed: u64,
539}
540
541/// What the most recent prune pass deleted, for `devp restore --last-run`.
542///
543/// Only passes that actually deleted something are recorded. A later run that frees
544/// nothing — everything was active, everything was already clean — leaves this alone,
545/// because "put back what you just took" should still mean the pass that took something.
546#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
547pub struct LastPrune {
548 /// When the pass ran.
549 pub at: DateTime<Utc>,
550 /// Every directory it removed.
551 pub dirs: Vec<PrunedDir>,
552}
553
554/// A one-line summary of a completed prune pass, for `devp stats`.
555///
556/// Deliberately not a second copy of [`LastPrune`]. That one exists so
557/// `devp restore --last-run` can put files back, so it carries the full directory list
558/// and only ever describes the most recent pass. This one is a trend line — four numbers
559/// per pass, bounded by [`constants::PRUNE_HISTORY_LIMIT`] — and could not restore
560/// anything if it wanted to.
561#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
562pub struct PruneRunSummary {
563 /// When the pass ran.
564 pub at: DateTime<Utc>,
565 /// Bytes reclaimed by the pass.
566 pub bytes_freed: u64,
567 /// How many directories it removed.
568 pub dirs_removed: usize,
569 /// How many distinct repositories it touched.
570 pub repos_touched: usize,
571}
572
573/// The top-level registry structure persisted to disk.
574#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
575pub struct Registry {
576 /// Schema version for forward compatibility.
577 pub version: String,
578 /// Global settings.
579 pub settings: Settings,
580 /// Map of canonical repo paths to their metadata.
581 pub repositories: HashMap<PathBuf, RepoEntry>,
582 /// Total cumulative bytes freed historically across all prune passes.
583 #[serde(default)]
584 pub total_freed_bytes: u64,
585 /// How many prune passes have deleted something, ever.
586 ///
587 /// One per *pass*, not per repository and not per directory — a `devp run` that
588 /// cleared eleven directories across four repositories counts once. Incremented in
589 /// exactly one place, [`Registry::record_prune`], which is also where the pass is
590 /// recorded for `devp restore --last-run`; keeping the two together is what stops
591 /// them meaning different things depending on which command did the pruning.
592 #[serde(default)]
593 pub total_pruned_count: u64,
594 /// List of repository paths added in the most recent init/link action (for devp undo).
595 #[serde(default)]
596 pub last_added_repos: Vec<PathBuf>,
597 /// What the most recent prune pass deleted (for `devp restore --last-run`).
598 #[serde(default)]
599 pub last_prune: Option<LastPrune>,
600 /// Summaries of recent prune passes, oldest first, for `devp stats`.
601 ///
602 /// Capped at [`constants::PRUNE_HISTORY_LIMIT`]. Recorded from 1.1.0 onward.
603 #[serde(default)]
604 pub prune_history: Vec<PruneRunSummary>,
605 /// When the release check last ran, so it runs at most once every
606 /// `UPDATE_CHECK_INTERVAL_DAYS` instead of on every command.
607 #[serde(default)]
608 pub last_update_check: Option<DateTime<Utc>>,
609 /// The newest release seen by the last check, so the reminder survives until the
610 /// user actually upgrades without needing the network again.
611 #[serde(default)]
612 pub latest_known_version: Option<String>,
613}
614
615impl Default for Registry {
616 fn default() -> Self {
617 Self {
618 version: "1.0".to_string(),
619 settings: Settings::default(),
620 repositories: HashMap::new(),
621 total_freed_bytes: 0,
622 total_pruned_count: 0,
623 last_added_repos: Vec::new(),
624 last_prune: None,
625 prune_history: Vec::new(),
626 last_update_check: None,
627 latest_known_version: None,
628 }
629 }
630}
631
632impl Registry {
633 /// Returns the path to the config directory (`~/.config/dev-prune/`).
634 ///
635 /// Uses the `dirs` crate to resolve the platform-specific config location:
636 /// - Linux/macOS: `~/.config/dev-prune/`
637 /// - Windows: `C:\Users\<user>\AppData\Roaming\dev-prune\` (or `~/.config/dev-prune/`)
638 pub fn config_dir() -> Result<PathBuf> {
639 if let Ok(override_dir) = std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE) {
640 return Ok(PathBuf::from(override_dir));
641 }
642 let base = dirs::config_dir().context("Could not determine config directory")?;
643 Ok(base.join(constants::CONFIG_DIR_NAME))
644 }
645
646 /// Returns the full path to the registry file.
647 pub fn registry_path() -> Result<PathBuf> {
648 Ok(Self::config_dir()?.join(constants::REGISTRY_FILENAME))
649 }
650
651 /// Loads the registry from disk, or the defaults when there is nothing to load.
652 ///
653 /// Reading does not write. This used to persist the default registry on the way
654 /// out, which made `devp --dry-run init` create the very file it had just promised
655 /// not to write and gave `devp status --json` — documented as a pure read — a side
656 /// effect on first use. Every command that actually changes something calls
657 /// [`Registry::save`], and that creates the directory as needed.
658 pub fn load() -> Result<Self> {
659 Self::load_from(&Self::registry_path()?)
660 }
661
662 /// Loads the registry from a specific path (for testing or custom locations).
663 ///
664 /// Non-persisting, exactly like [`Registry::load`], which is implemented on top of
665 /// it. The two used to disagree — this one wrote the defaults out when the file was
666 /// missing — which is the sort of difference that makes a test pass while the
667 /// behaviour it stands in for is broken.
668 pub fn load_from(path: &Path) -> Result<Self> {
669 if !path.exists() {
670 return Ok(Registry::default());
671 }
672 let contents = fs::read_to_string(path)
673 .with_context(|| format!("Failed to read registry at {}", path.display()))?;
674 serde_json::from_str(&contents)
675 .with_context(|| format!("Failed to parse registry at {}", path.display()))
676 }
677
678 /// Saves the registry to disk atomically (write to temp, then rename).
679 pub fn save(&self) -> Result<()> {
680 let path = Self::registry_path()?;
681 self.save_to(&path)
682 }
683
684 /// Saves the registry to a specific path (for testing or custom locations).
685 pub fn save_to(&self, path: &Path) -> Result<()> {
686 if let Some(parent) = path.parent() {
687 fs::create_dir_all(parent)
688 .with_context(|| format!("Failed to create config dir {}", parent.display()))?;
689 }
690 // Unique per process. A manual run and the scheduled daemon pass can save at the
691 // same moment; with a shared `registry.json.tmp`, one process could rename the
692 // other's half-written file into place as a torn, unparseable registry.
693 let tmp_path = path.with_extension(format!("json.{}.tmp", std::process::id()));
694 let contents =
695 serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
696 {
697 // `sync_all` before the rename, or the atomicity is only apparent: after a
698 // power cut the rename can survive while the data does not, leaving the
699 // registry as zero bytes — the one outcome this dance exists to prevent.
700 use std::io::Write;
701 let mut file = fs::File::create(&tmp_path)
702 .with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
703 file.write_all(contents.as_bytes())
704 .with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
705 file.sync_all()
706 .with_context(|| format!("Failed to flush temp registry {}", tmp_path.display()))?;
707 }
708 fs::rename(&tmp_path, path)
709 .with_context(|| format!("Failed to rename temp registry to {}", path.display()))?;
710
711 // A crash between write and rename strands that process's `.<pid>.tmp` forever.
712 // Sweep siblings old enough that no live save can still own them.
713 if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
714 let prefix = format!("{}.", name.to_string_lossy());
715 if let Ok(entries) = fs::read_dir(parent) {
716 for entry in entries.flatten() {
717 let file_name = entry.file_name();
718 let file_name = file_name.to_string_lossy();
719 if file_name.starts_with(&prefix)
720 && file_name.ends_with(".tmp")
721 && entry
722 .metadata()
723 .and_then(|m| m.modified())
724 .ok()
725 .and_then(|t| t.elapsed().ok())
726 .is_some_and(|age| age.as_secs() > 3600)
727 {
728 let _ = fs::remove_file(entry.path());
729 }
730 }
731 }
732 }
733 Ok(())
734 }
735
736 /// Adds a repository to the registry. Returns `true` if newly added, `false` if already present.
737 pub fn add_repo(&mut self, path: PathBuf) -> bool {
738 // The registry is keyed by path, so `./foo`, `foo/`, and the absolute form
739 // would otherwise register as three separate repositories.
740 let path = canonical_key(&path);
741 if self.repositories.contains_key(&path) {
742 return false;
743 }
744 self.repositories.insert(path, RepoEntry::new());
745 true
746 }
747
748 /// Removes a repository from the registry. Returns `true` if it was present.
749 ///
750 /// A repository that has been deleted from disk cannot be canonicalised any more,
751 /// so `canonical_key` falls back to the path as typed — which never equals the
752 /// canonical key it was registered under (on Windows those carry the `\\?\`
753 /// prefix). Unlinking a deleted repository is the most ordinary reason to unlink
754 /// at all, so a direct miss falls back to a lexical comparison.
755 pub fn remove_repo(&mut self, path: &Path) -> bool {
756 let target = lexical_absolute(path);
757 let removed = if self.repositories.remove(&canonical_key(path)).is_some() {
758 true
759 } else {
760 let found = self
761 .repositories
762 .keys()
763 .find(|k| loose_path_eq(k, &target))
764 .cloned();
765 found.is_some_and(|k| self.repositories.remove(&k).is_some())
766 };
767 if removed {
768 // The undo list stores the canonical `\\?\`-prefixed spelling, while a
769 // deleted directory can only be named lexically — strict equality misses,
770 // and the next `devp undo` "reverts" by removing nothing.
771 self.last_added_repos.retain(|p| !loose_path_eq(p, &target));
772 }
773 removed
774 }
775
776 // Removed: `repo_paths` and `effective_idle_days`.
777 //
778 // Neither had a caller outside this file's own tests. `effective_idle_days` had also
779 // drifted from the rule the engine actually applies: it looked the repository up by
780 // the path as given, where every write to `repositories` goes through
781 // `canonical_key`, so `devp`'s own relative paths would have missed the entry and
782 // silently returned the global threshold instead of the repository's override.
783
784 /// Credit `bytes_freed` to one repository, and to the machine-wide total.
785 ///
786 /// Safe to call once per repository or once per directory — every figure it touches
787 /// is either a sum or a timestamp, so the two styles agree. Counting *passes* is
788 /// deliberately not done here for exactly that reason; that lives in
789 /// [`Registry::record_prune`], which is called once per pass.
790 pub fn mark_pruned(&mut self, path: &Path, bytes_freed: u64) {
791 // Same rule as every other accessor: the map is keyed by `canonical_key`, so a
792 // raw lookup would silently skip the per-repo credit for a relative or
793 // differently-spelled path while still growing the machine-wide total.
794 if let Some(entry) = self.repositories.get_mut(&canonical_key(path)) {
795 entry.last_pruned_at = Some(Utc::now());
796 entry.total_freed_bytes += bytes_freed;
797 }
798 self.total_freed_bytes += bytes_freed;
799 }
800
801 /// Record what a prune pass deleted, replacing any earlier record.
802 ///
803 /// A pass that deleted nothing is not a pass worth remembering, so an empty list is
804 /// ignored rather than stored — otherwise `devp run` on an already-clean machine
805 /// would quietly throw away the record of the run the user actually wants back.
806 ///
807 /// This is the one place a prune pass is counted. It sets [`Registry::last_prune`],
808 /// appends a [`PruneRunSummary`] to [`Registry::prune_history`] and bumps
809 /// [`Registry::total_pruned_count`], because "a pass happened and it deleted things"
810 /// is exactly the condition all three describe. Splitting them across call sites is
811 /// how the counter previously came to mean repositories in `devp run` and directories
812 /// in the `devp status` dashboard.
813 pub fn record_prune(&mut self, dirs: Vec<PrunedDir>) {
814 self.record_prune_progress(Utc::now(), dirs);
815 }
816
817 /// Record a pass's progress mid-flight, superseding this same pass's earlier record.
818 ///
819 /// `at` identifies the pass: a repeated call with the same timestamp replaces the
820 /// history entry and `last_prune` it wrote before, rather than counting a second
821 /// pass. This exists so a long pass can persist after every repository — a crash
822 /// half-way through used to leave `devp restore --last-run` pointing at the
823 /// *previous* pass, offering to reinstall directories that were never deleted while
824 /// saying nothing about the ones that were.
825 pub fn record_prune_progress(&mut self, at: DateTime<Utc>, dirs: Vec<PrunedDir>) {
826 if dirs.is_empty() {
827 return;
828 }
829 if self.prune_history.last().map(|s| s.at) == Some(at) {
830 self.prune_history.pop();
831 } else {
832 self.total_pruned_count += 1;
833 }
834
835 self.prune_history.push(PruneRunSummary {
836 at,
837 bytes_freed: dirs.iter().map(|d| d.size_freed).sum(),
838 dirs_removed: dirs.len(),
839 repos_touched: dirs
840 .iter()
841 .map(|d| &d.repo_path)
842 .collect::<HashSet<_>>()
843 .len(),
844 });
845 // Oldest first, so the overflow comes off the front.
846 if self.prune_history.len() > constants::PRUNE_HISTORY_LIMIT {
847 let excess = self.prune_history.len() - constants::PRUNE_HISTORY_LIMIT;
848 self.prune_history.drain(..excess);
849 }
850
851 self.last_prune = Some(LastPrune { at, dirs });
852 }
853
854 /// Returns the number of registered repositories.
855 pub fn repo_count(&self) -> usize {
856 self.repositories.len()
857 }
858}
859
860#[cfg(test)]
861mod tests {
862 use super::*;
863 use tempfile::TempDir;
864
865 fn test_registry_path(dir: &TempDir) -> PathBuf {
866 dir.path().join("dev-prune").join("registry.json")
867 }
868
869 fn a_pruned_dir(label: &str) -> PrunedDir {
870 PrunedDir {
871 repo_path: PathBuf::from("/repo"),
872 bloat_dir: label.to_string(),
873 adapter: "npm".to_string(),
874 size_freed: 42,
875 }
876 }
877
878 #[test]
879 fn a_prune_that_deleted_nothing_does_not_erase_the_last_one() {
880 // Otherwise a second `devp run` on an already-clean machine throws away the
881 // record of the pass the user actually wants to undo.
882 let mut registry = Registry::default();
883 registry.record_prune(vec![a_pruned_dir("node_modules")]);
884 let recorded = registry.last_prune.clone().expect("first pass recorded");
885
886 registry.record_prune(Vec::new());
887
888 assert_eq!(registry.last_prune, Some(recorded));
889 }
890
891 #[test]
892 fn a_later_prune_replaces_the_record() {
893 let mut registry = Registry::default();
894 registry.record_prune(vec![a_pruned_dir("node_modules")]);
895 registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
896
897 let dirs = registry.last_prune.unwrap().dirs;
898 assert_eq!(dirs.len(), 1);
899 assert_eq!(dirs[0].bloat_dir, "frontend/node_modules");
900 }
901
902 #[test]
903 fn the_last_prune_record_survives_a_save_and_load() {
904 // `restore --last-run` reads it out of a file written by a process that has
905 // already exited, so the round trip is the whole feature.
906 let dir = TempDir::new().unwrap();
907 let path = test_registry_path(&dir);
908
909 let mut registry = Registry::default();
910 registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
911 registry.save_to(&path).unwrap();
912
913 let loaded = Registry::load_from(&path).unwrap();
914 assert_eq!(loaded.last_prune, registry.last_prune);
915 }
916
917 #[test]
918 fn a_registry_written_before_the_field_existed_still_loads() {
919 // The registry on disk predates `last_prune`; a missing key means "no pass
920 // recorded", not a parse failure that would lock the user out of their config.
921 let dir = TempDir::new().unwrap();
922 let path = test_registry_path(&dir);
923 fs::create_dir_all(path.parent().unwrap()).unwrap();
924 fs::write(
925 &path,
926 r#"{"version":"1.0","settings":{"idle_days":15,"check_interval_days":2,
927 "auto_daemon":true},"repositories":{}}"#,
928 )
929 .unwrap();
930
931 let loaded = Registry::load_from(&path).unwrap();
932 assert_eq!(loaded.last_prune, None);
933 }
934
935 #[test]
936 fn a_leading_tilde_becomes_the_home_directory() {
937 // The whole reason this exists: PowerShell hands `devp init ~/Code` straight
938 // through, so without expansion the registry gains a repository at `.\~\Code`.
939 let home = dirs::home_dir().expect("test host has a home directory");
940
941 assert_eq!(expand_tilde("~"), home.to_string_lossy());
942 assert_eq!(
943 expand_tilde("~/Code"),
944 home.join("Code").to_string_lossy(),
945 "forward slash, as typed in every shell"
946 );
947 assert_eq!(
948 expand_tilde("~\\Code"),
949 home.join("Code").to_string_lossy(),
950 "backslash, as typed in PowerShell"
951 );
952 }
953
954 #[test]
955 fn a_tilde_that_is_not_a_home_reference_is_left_alone() {
956 // `~alice` is another user's home in shell syntax and cannot be resolved
957 // portably; `~backup` and `./~tmp` are ordinary directory names. Rewriting any
958 // of them would silently point the user at the wrong directory.
959 for raw in ["~alice/Code", "~backup", "./~tmp", "Code~", "", "."] {
960 assert_eq!(expand_tilde(raw), raw, "{raw} must survive untouched");
961 }
962 }
963
964 #[test]
965 fn test_default_settings() {
966 let settings = Settings::default();
967 assert_eq!(settings.idle_days, 15);
968 assert_eq!(settings.check_interval_days, 2);
969 // On by default: dev-prune installs its own integrations, once per version,
970 // and only the ones it finds missing.
971 assert!(settings.auto_daemon);
972 assert!(settings.auto_hooks);
973 assert!(settings.auto_setup);
974 }
975
976 #[test]
977 fn settings_written_before_the_automation_toggles_existed_still_load() {
978 // Real registries on disk predate `auto_hooks` / `auto_setup`; an upgrade must
979 // read them rather than fail to parse and lose every registered repository.
980 let json = r#"{
981 "idle_days": 30,
982 "check_interval_days": 2,
983 "auto_daemon": false
984 }"#;
985 let settings: Settings = serde_json::from_str(json).unwrap();
986 assert_eq!(settings.idle_days, 30);
987 assert!(!settings.auto_daemon, "an explicit opt-out is preserved");
988 assert!(settings.auto_hooks, "a missing key takes the default");
989 assert!(settings.auto_setup);
990 }
991
992 #[test]
993 fn test_default_registry() {
994 let registry = Registry::default();
995 assert_eq!(registry.version, "1.0");
996 assert_eq!(registry.settings, Settings::default());
997 assert!(registry.repositories.is_empty());
998 }
999
1000 #[test]
1001 fn test_repo_entry_new() {
1002 let entry = RepoEntry::new();
1003 assert!(entry.enabled);
1004 assert!(entry.last_pruned_at.is_none());
1005 assert!(entry.override_idle_days.is_none());
1006 }
1007
1008 #[test]
1009 fn test_save_and_load() {
1010 let tmp = TempDir::new().unwrap();
1011 let path = test_registry_path(&tmp);
1012
1013 let mut registry = Registry::default();
1014 registry.add_repo(PathBuf::from("/test/repo"));
1015 registry.save_to(&path).unwrap();
1016
1017 let loaded = Registry::load_from(&path).unwrap();
1018 assert_eq!(loaded.repo_count(), 1);
1019 assert!(
1020 loaded
1021 .repositories
1022 .contains_key(&PathBuf::from("/test/repo"))
1023 );
1024 }
1025
1026 #[test]
1027 fn loading_a_missing_registry_yields_the_defaults_and_writes_nothing() {
1028 let tmp = TempDir::new().unwrap();
1029 let path = test_registry_path(&tmp);
1030
1031 let loaded = Registry::load_from(&path).unwrap();
1032 assert_eq!(loaded, Registry::default());
1033 // Reading is not writing. `devp --dry-run` and `devp status --json` both promise
1034 // to leave the disk alone, and both start by loading the registry.
1035 assert!(!path.exists(), "loading the registry created it");
1036 }
1037
1038 #[test]
1039 fn test_add_repo_returns_true_for_new() {
1040 let mut registry = Registry::default();
1041 assert!(registry.add_repo(PathBuf::from("/test/repo")));
1042 }
1043
1044 #[test]
1045 fn test_add_repo_returns_false_for_duplicate() {
1046 let mut registry = Registry::default();
1047 registry.add_repo(PathBuf::from("/test/repo"));
1048 assert!(!registry.add_repo(PathBuf::from("/test/repo")));
1049 }
1050
1051 #[test]
1052 fn test_remove_repo() {
1053 let mut registry = Registry::default();
1054 registry.add_repo(PathBuf::from("/test/repo"));
1055 assert!(registry.remove_repo(Path::new("/test/repo")));
1056 assert!(!registry.remove_repo(Path::new("/test/repo")));
1057 assert_eq!(registry.repo_count(), 0);
1058 }
1059
1060 /// macOS puts temp trees behind `/var` → `/private/var`, so a repo registered
1061 /// through the symlink is keyed under the real path — and once deleted, the
1062 /// symlinked spelling cannot be canonicalised whole. The lexical fallback must
1063 /// resolve the surviving parent, or unlink reports "not registered" for a
1064 /// directory the user is looking at in their own prompt.
1065 #[cfg(unix)]
1066 #[test]
1067 fn a_deleted_repo_named_through_a_symlinked_parent_still_unlinks() {
1068 let tmp = TempDir::new().unwrap();
1069 let real_parent = tmp.path().join("real");
1070 std::fs::create_dir(&real_parent).unwrap();
1071 let alias = tmp.path().join("alias");
1072 std::os::unix::fs::symlink(&real_parent, &alias).unwrap();
1073
1074 let repo = real_parent.join("repo");
1075 std::fs::create_dir(&repo).unwrap();
1076 let mut registry = Registry::default();
1077 registry.add_repo(alias.join("repo"));
1078 std::fs::remove_dir(&repo).unwrap();
1079
1080 assert!(registry.remove_repo(&alias.join("repo")));
1081 assert_eq!(registry.repo_count(), 0);
1082 }
1083
1084 #[test]
1085 fn test_mark_pruned() {
1086 let mut registry = Registry::default();
1087 registry.add_repo(PathBuf::from("/test/repo"));
1088 assert!(
1089 registry.repositories[&PathBuf::from("/test/repo")]
1090 .last_pruned_at
1091 .is_none()
1092 );
1093 registry.mark_pruned(Path::new("/test/repo"), 1024);
1094 assert!(
1095 registry.repositories[&PathBuf::from("/test/repo")]
1096 .last_pruned_at
1097 .is_some()
1098 );
1099 assert_eq!(registry.total_freed_bytes, 1024);
1100 // Not the pass counter — that is `record_prune`'s job, once per pass.
1101 assert_eq!(registry.total_pruned_count, 0);
1102 }
1103
1104 #[test]
1105 fn a_pass_is_counted_once_however_much_it_deleted() {
1106 // The counter is published as `prune_passes`, and it used to be incremented once
1107 // per repository by `devp run` and once per *directory* by the status dashboard,
1108 // so the same work produced a different number depending on where it started.
1109 let mut registry = Registry::default();
1110 registry.add_repo(PathBuf::from("/repo"));
1111
1112 registry.mark_pruned(Path::new("/repo"), 1024);
1113 registry.mark_pruned(Path::new("/repo"), 1024);
1114 registry.record_prune(vec![
1115 a_pruned_dir("node_modules"),
1116 a_pruned_dir("frontend/node_modules"),
1117 ]);
1118
1119 assert_eq!(registry.total_pruned_count, 1);
1120
1121 registry.record_prune(vec![a_pruned_dir("target")]);
1122 assert_eq!(registry.total_pruned_count, 2);
1123
1124 // A pass that deleted nothing is not a pass.
1125 registry.record_prune(Vec::new());
1126 assert_eq!(registry.total_pruned_count, 2);
1127 }
1128
1129 #[test]
1130 fn mark_pruned_credits_the_repo_under_its_canonical_key() {
1131 // On Windows, `canonicalize` yields a `\\?\`-prefixed path, so a registry keyed
1132 // by the canonical form and a `mark_pruned` looking up the raw form would miss —
1133 // growing the machine-wide total while the repository's own figure stayed zero.
1134 let tmp = TempDir::new().unwrap();
1135 let raw = tmp.path().to_path_buf();
1136
1137 let mut registry = Registry::default();
1138 registry.add_repo(raw.clone());
1139 registry.mark_pruned(&raw, 1024);
1140
1141 let entry = ®istry.repositories[&canonical_key(&raw)];
1142 assert_eq!(entry.total_freed_bytes, 1024);
1143 assert!(entry.last_pruned_at.is_some());
1144 assert_eq!(registry.total_freed_bytes, 1024);
1145 }
1146
1147 #[test]
1148 fn each_repository_accumulates_its_own_total() {
1149 // `devp stats` ranks repositories against each other, so the per-repo figure has
1150 // to be a running total and not the size of the most recent pass.
1151 let mut registry = Registry::default();
1152 registry.add_repo(PathBuf::from("/test/repo"));
1153 registry.add_repo(PathBuf::from("/test/other"));
1154
1155 registry.mark_pruned(Path::new("/test/repo"), 1024);
1156 registry.mark_pruned(Path::new("/test/repo"), 2048);
1157 registry.mark_pruned(Path::new("/test/other"), 512);
1158
1159 assert_eq!(
1160 registry.repositories[&PathBuf::from("/test/repo")].total_freed_bytes,
1161 3072
1162 );
1163 assert_eq!(
1164 registry.repositories[&PathBuf::from("/test/other")].total_freed_bytes,
1165 512
1166 );
1167 assert_eq!(registry.total_freed_bytes, 3584);
1168 }
1169
1170 #[test]
1171 fn the_prune_history_summarises_the_pass() {
1172 let mut registry = Registry::default();
1173 registry.record_prune(vec![
1174 a_pruned_dir("node_modules"),
1175 a_pruned_dir("frontend/node_modules"),
1176 ]);
1177
1178 let summary = registry.prune_history.last().expect("pass summarised");
1179 assert_eq!(summary.bytes_freed, 84);
1180 assert_eq!(summary.dirs_removed, 2);
1181 // Both fixtures live under `/repo`, so this is one repository, not two.
1182 assert_eq!(summary.repos_touched, 1);
1183 }
1184
1185 #[test]
1186 fn the_prune_history_is_capped_and_drops_the_oldest() {
1187 // The registry is rewritten in full on every save, so an uncapped list would grow
1188 // the file forever on a machine running the scheduled pass.
1189 let mut registry = Registry::default();
1190 for _ in 0..constants::PRUNE_HISTORY_LIMIT + 5 {
1191 registry.record_prune(vec![a_pruned_dir("node_modules")]);
1192 }
1193
1194 assert_eq!(registry.prune_history.len(), constants::PRUNE_HISTORY_LIMIT);
1195 let first = registry.prune_history.first().unwrap().at;
1196 let last = registry.prune_history.last().unwrap().at;
1197 assert!(first <= last, "oldest first");
1198 }
1199
1200 #[test]
1201 fn test_repo_count() {
1202 let mut registry = Registry::default();
1203 assert_eq!(registry.repo_count(), 0);
1204 registry.add_repo(PathBuf::from("/a"));
1205 registry.add_repo(PathBuf::from("/b"));
1206 assert_eq!(registry.repo_count(), 2);
1207 }
1208
1209 #[test]
1210 fn a_local_schema_uri_has_exactly_three_slashes_on_either_platform() {
1211 assert_eq!(
1212 file_uri("/home/dev/.config/dev-prune/bin/devprune.schema.json"),
1213 "file:///home/dev/.config/dev-prune/bin/devprune.schema.json"
1214 );
1215 assert_eq!(
1216 file_uri("C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"),
1217 "file:///C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"
1218 );
1219 }
1220
1221 #[test]
1222 fn a_broken_per_repo_config_is_an_error_rather_than_an_absent_one() {
1223 // The distinction the whole tool leans on: "no config" means take the defaults,
1224 // "unreadable config" means refuse — never overwrite, never prune on a guess.
1225 let tmp = TempDir::new().unwrap();
1226 let repo = tmp.path();
1227 assert_eq!(PerRepoConfig::load_with_diagnostics(repo), Ok(None));
1228
1229 fs::write(
1230 repo.join(constants::PER_REPO_CONFIG_FILE),
1231 r#"{ "ignore": true, }"#,
1232 )
1233 .unwrap();
1234 let err = PerRepoConfig::load_with_diagnostics(repo).unwrap_err();
1235 assert!(err.contains("Syntax error"), "{err}");
1236
1237 fs::write(
1238 repo.join(constants::PER_REPO_CONFIG_FILE),
1239 r#"{ "ignore": true }"#,
1240 )
1241 .unwrap();
1242 assert!(
1243 PerRepoConfig::load_with_diagnostics(repo)
1244 .unwrap()
1245 .unwrap()
1246 .ignore
1247 );
1248 }
1249
1250 #[test]
1251 fn test_serialization_roundtrip() {
1252 let mut registry = Registry::default();
1253 registry.settings.idle_days = 30;
1254 registry.add_repo(PathBuf::from("/test/repo"));
1255
1256 let json = serde_json::to_string_pretty(®istry).unwrap();
1257 let deserialized: Registry = serde_json::from_str(&json).unwrap();
1258 assert_eq!(registry.settings.idle_days, deserialized.settings.idle_days);
1259 assert_eq!(registry.repo_count(), deserialized.repo_count());
1260 }
1261
1262 #[test]
1263 fn test_atomic_save_leaves_no_tmp() {
1264 let tmp = TempDir::new().unwrap();
1265 let path = test_registry_path(&tmp);
1266
1267 let registry = Registry::default();
1268 registry.save_to(&path).unwrap();
1269
1270 assert!(path.exists());
1271 // Nothing but the registry itself may remain — a leftover `*.tmp` would mean the
1272 // rename never happened.
1273 let leftovers: Vec<_> = fs::read_dir(path.parent().unwrap())
1274 .unwrap()
1275 .flatten()
1276 .filter(|e| e.path() != path)
1277 .collect();
1278 assert!(leftovers.is_empty(), "leftover files: {leftovers:?}");
1279 }
1280
1281 #[test]
1282 fn exclude_entry_lands_in_git_info_exclude_not_gitignore() {
1283 let tmp = TempDir::new().unwrap();
1284 let repo = tmp.path();
1285 fs::create_dir(repo.join(".git")).unwrap();
1286
1287 ensure_in_git_exclude(repo, ".devprune.json").unwrap();
1288
1289 let exclude = fs::read_to_string(repo.join(".git/info/exclude")).unwrap();
1290 assert!(exclude.lines().any(|l| l == ".devprune.json"));
1291 // The whole point of using the exclude file: the shared, tracked `.gitignore`
1292 // must never be created or touched.
1293 assert!(!repo.join(".gitignore").exists());
1294 }
1295
1296 #[test]
1297 fn exclude_entry_is_appended_once_and_preserves_existing_lines() {
1298 let tmp = TempDir::new().unwrap();
1299 let repo = tmp.path();
1300 fs::create_dir_all(repo.join(".git/info")).unwrap();
1301 // No trailing newline, deliberately — the append must not glue two entries
1302 // onto one line.
1303 fs::write(repo.join(".git/info/exclude"), "*.log").unwrap();
1304
1305 ensure_in_git_exclude(repo, ".devprune.json").unwrap();
1306 ensure_in_git_exclude(repo, ".devprune.json").unwrap();
1307
1308 let exclude = fs::read_to_string(repo.join(".git/info/exclude")).unwrap();
1309 let lines: Vec<_> = exclude.lines().collect();
1310 assert_eq!(lines, vec!["*.log", ".devprune.json"]);
1311 }
1312
1313 #[test]
1314 fn exclude_follows_a_gitdir_pointer_file() {
1315 // Worktrees and submodules have a one-line `.git` *file*, and a worktree's
1316 // private gitdir points at the shared one via `commondir` — where the real
1317 // `info/exclude` lives.
1318 let tmp = TempDir::new().unwrap();
1319 let shared = tmp.path().join("main-clone/.git");
1320 let worktree_gitdir = shared.join("worktrees/wt");
1321 fs::create_dir_all(&worktree_gitdir).unwrap();
1322 fs::write(worktree_gitdir.join("commondir"), "../..\n").unwrap();
1323
1324 let wt = tmp.path().join("wt");
1325 fs::create_dir(&wt).unwrap();
1326 fs::write(
1327 wt.join(".git"),
1328 format!("gitdir: {}\n", worktree_gitdir.display()),
1329 )
1330 .unwrap();
1331
1332 ensure_in_git_exclude(&wt, ".devprune.json").unwrap();
1333
1334 let exclude = fs::read_to_string(shared.join("info/exclude")).unwrap();
1335 assert!(exclude.lines().any(|l| l == ".devprune.json"));
1336 }
1337
1338 #[test]
1339 fn exclude_is_a_no_op_outside_a_git_repository() {
1340 let tmp = TempDir::new().unwrap();
1341
1342 ensure_in_git_exclude(tmp.path(), ".devprune.json").unwrap();
1343
1344 assert!(!tmp.path().join(".git").exists());
1345 assert!(!tmp.path().join(".gitignore").exists());
1346 }
1347}