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