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