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 the scheduled pass looks for unregistered repositories by itself.
43 /// On by default; see [`constants::DEFAULT_AUTO_DISCOVER`].
44 #[serde(default = "default_auto_discover")]
45 pub auto_discover: bool,
46 /// Whether interactive confirmation is required before pruning.
47 #[serde(default = "default_require_confirmation")]
48 pub require_confirmation: bool,
49 /// Timeout in seconds for lockfile enforcement / CLI commands (default 600s = 10m).
50 #[serde(default = "default_command_timeout_secs")]
51 pub command_timeout_secs: u64,
52 /// Smallest bloat directory worth deleting, in MiB. `0` disables the floor.
53 ///
54 /// Below this size the reinstall costs more than the space is worth, so the
55 /// directory is not offered as a candidate at all.
56 #[serde(default = "default_min_size_mb")]
57 pub min_size_mb: u64,
58 /// Whether dev-prune asks GitHub for the latest release from time to time.
59 ///
60 /// On by default, and opt-*out* rather than opt-in: an out-of-date cleanup tool is a
61 /// tool whose safety fixes you do not have. The request sends nothing but itself —
62 /// no identifier, no configuration, no usage data. Turn it off with
63 /// `devp config set update_check false`.
64 #[serde(default = "default_update_check")]
65 pub update_check: bool,
66 /// How many directory levels below a repository root discovery descends.
67 ///
68 /// Six by default. A flat repository never notices; a monorepo that nests projects
69 /// under `packages/@scope/name/app` does. Raise it when `devp status` does not list
70 /// a project you know is there, and remember that the walk gets more expensive with
71 /// every level. Clamped to [`constants::MAX_SCAN_DEPTH_LIMIT`].
72 #[serde(default = "default_scan_depth")]
73 pub scan_depth: usize,
74 /// Whether cargo and go may run the sync command that rewrites tracked manifests.
75 ///
76 /// Off. See [`constants::DEFAULT_ALLOW_MANIFEST_REWRITE`] — with this off, both are
77 /// verified read-only and a project with no lockfile at all is simply not pruned.
78 #[serde(default = "default_allow_manifest_rewrite")]
79 pub allow_manifest_rewrite: bool,
80 /// Days between automatic release checks.
81 ///
82 /// Only the *automatic* check honours this; `devp update` always asks, because you
83 /// are standing there waiting for the answer.
84 #[serde(default = "default_update_check_interval_days")]
85 pub update_check_interval_days: i64,
86 /// How long the release check waits for GitHub before giving up, in seconds.
87 ///
88 /// Five is right on a normal connection and too short behind some corporate proxies,
89 /// which is the whole reason this is a setting rather than a constant.
90 #[serde(default = "default_update_check_timeout_secs")]
91 pub update_check_timeout_secs: u64,
92 /// Whether the setup pass may install the Git hooks *in front of* another tool's.
93 ///
94 /// Off. With it on, a `core.hooksPath` that belongs to husky is not a reason to skip:
95 /// dev-prune takes the slot and forwards every hook back to the directory it
96 /// displaced. Behaviour-preserving, but it is still someone else's setup, so it is
97 /// asked for rather than assumed. Same thing as `devp hook install --chain`.
98 #[serde(default = "default_auto_hooks_chain")]
99 pub auto_hooks_chain: bool,
100 /// Whether the opt-in Cargo adapter is active. Off by default, and the reason is
101 /// the same one that keeps `enable_gradle` off: Rust's `target/` is compiler
102 /// output. `cargo metadata --locked` proves the *crates* come back from
103 /// `Cargo.lock`, but nothing downloads a compiled artefact — the directory returns
104 /// only by rebuilding, which on a large workspace is minutes rather than the
105 /// seconds a dependency reinstall costs. See [`crate::adapters::cargo_adapter`].
106 #[serde(default)]
107 pub enable_cargo: bool,
108 /// Whether the opt-in Gradle build-tool adapter is active. Off by default:
109 /// `build/` comes back by recompiling the project, so nobody should find it
110 /// deleted without having asked. See [`crate::adapters::gradle`].
111 #[serde(default)]
112 pub enable_gradle: bool,
113 /// Whether the opt-in Maven build-tool adapter is active. Off by default, for the
114 /// same reason as `enable_gradle`. See [`crate::adapters::maven`].
115 #[serde(default)]
116 pub enable_maven: bool,
117 /// Whether the opt-in Swift Package Manager adapter is active. Off by default, for
118 /// the same reason as `enable_gradle`: `.build/` holds compiled modules and comes
119 /// back through `swift build`. See [`crate::adapters::swift`].
120 #[serde(default)]
121 pub enable_swift: bool,
122 /// Whether the opt-in Dart and Flutter adapter is active. Off by default: the pub
123 /// metadata in `.dart_tool/` is a second's work to restore, but the `build_runner`
124 /// and `flutter_build` caches beside it are compiler output and come back only by
125 /// recompiling. See [`crate::adapters::dart`].
126 #[serde(default)]
127 pub enable_dart: bool,
128 /// Whether the opt-in Mix build-tree adapter is active. Off by default, and separate
129 /// from the always-on `mix` adapter: that one deletes `deps/`, which comes back by
130 /// downloading, while `_build/` comes back only by recompiling the project and every
131 /// dependency in it. See [`crate::adapters::mix_build`].
132 #[serde(default)]
133 pub enable_mix_build: bool,
134 /// Whether the opt-in vcpkg adapter is active. Off by default: vcpkg builds every
135 /// port from source, so `vcpkg_installed/` comes back by compiling Boost or Qt
136 /// again rather than by downloading them. See [`crate::adapters::vcpkg`].
137 #[serde(default)]
138 pub enable_vcpkg: bool,
139
140 /// Whether the opt-in CMake build-tree adapter is active. Off by default: a build
141 /// tree is object files and linked binaries, and it comes back by compiling the
142 /// project again. See [`crate::adapters::cmake_build`].
143 #[serde(default)]
144 pub enable_cmake_build: bool,
145 /// Whether the opt-in .NET build-output adapter is active. Off by default: `bin/`
146 /// and `obj/` are compiled output, and they come back by running `dotnet build`
147 /// again. See [`crate::adapters::dotnet_build`].
148 #[serde(default)]
149 pub enable_dotnet_build: bool,
150 /// Idle days required before *build-tree* directories — everything the opt-in
151 /// adapters claim — are pruned.
152 ///
153 /// Separate from `idle_days` because the cost of being wrong is different: a
154 /// deleted `node_modules` is one `npm ci` away, a deleted Android `build/` is a
155 /// long recompile. Applied as `max(build_idle_days, idle_days)`.
156 #[serde(default = "default_build_idle_days")]
157 pub build_idle_days: u64,
158 /// Whether a newer release installs itself at the end of a prune pass, once the
159 /// periodic check has found one.
160 ///
161 /// On by default. A pruner that runs on a schedule is exactly the kind of tool
162 /// nobody thinks to upgrade, and an old one keeps whatever bug it shipped with
163 /// forever. What runs here is the download-and-replace half only: see
164 /// [`crate::commands::update::maybe_auto_update`], which never hands the machine to
165 /// a package manager unattended and stands aside entirely on WinGet, Scoop and
166 /// Homebrew, where the manager owns the upgrade.
167 #[serde(default = "default_auto_update")]
168 pub auto_update: bool,
169 /// Whether this copy stays on the version it is, whatever else is configured.
170 ///
171 /// Off by default, and turned on only by a person typing
172 /// `devp config set version_lock true`. While it is on, `auto_update` does not run
173 /// however it is set, `devp update --install` refuses, `devp install --channel`
174 /// refuses because moving channels installs the latest release, and the install
175 /// scripts leave the binary exactly where they find it. There is no flag that
176 /// bypasses it: releasing the pin is the same kind of decision as setting it, and
177 /// belongs to the same person.
178 ///
179 /// It exists because `auto_update = false` was never the whole answer. That setting
180 /// stops one path; a machine that has to keep shipping the same tool for a year --
181 /// a CI image, a reproduction that stops reproducing the moment the tool changes
182 /// underneath it, a locked-down build box -- also has to survive someone re-running
183 /// the install one-liner out of habit.
184 #[serde(default)]
185 pub version_lock: bool,
186 /// Adapters switched off by name, whatever their lockfiles say.
187 ///
188 /// A deny-list rather than twenty `enable_*` booleans, because the answer for
189 /// almost everyone is "none of them" and a list of exceptions says that in one
190 /// place. It is a *preference*, and the opposite of `enable_gradle` and friends:
191 /// those are off until asked for because deleting a build tree is expensive to
192 /// undo, whereas `node_modules` is safe to prune and merely something a particular
193 /// person may not want touched.
194 ///
195 /// Names are the adapter names `--only`/`--skip` take. Applied in
196 /// [`crate::adapters::detect_adapters`], so a disabled adapter is invisible to
197 /// every command at once rather than listed by `status` and skipped by `run`.
198 #[serde(default)]
199 pub disabled_adapters: Vec<String>,
200 /// Per-adapter idle windows, in days, keyed by adapter name.
201 ///
202 /// The one dial that is neither global nor per-repository: "wait longer before
203 /// touching Rust" is a statement about a *toolchain*, not about one checkout, and
204 /// before this it could only be said by moving the global window for everything.
205 ///
206 /// **A floor, never a bypass.** The value is applied as
207 /// `max(idle_days, adapter_idle_days[name])`, so it can only make an adapter wait
208 /// longer than the repository-level check already requires. A smaller number is
209 /// accepted and simply has no effect — the repository gate runs first and is the
210 /// same gate for every adapter, and letting one adapter lower it would be a
211 /// bypass of the idle check rather than a preference.
212 ///
213 /// `BTreeMap` rather than `HashMap` so the JSON round-trips in a stable order and
214 /// a diff of the registry file shows what actually changed.
215 #[serde(default)]
216 pub adapter_idle_days: BTreeMap<String, u64>,
217 /// Per-manager cache size caps, in gibibytes, keyed by cache manager name.
218 ///
219 /// A download cache is a bet that re-downloading costs more than the disk it
220 /// occupies, and the bet stops paying somewhere: a `uv` cache past ten gigabytes is
221 /// keeping wheels for Python versions the machine no longer has, and no repository's
222 /// lockfile will ever say so. This is where that ceiling is written down.
223 ///
224 /// **It never deletes anything on its own.** `devp caches` marks a cache over its
225 /// cap and `devp caches clear --over-cap` empties exactly those; nothing dev-prune
226 /// runs on a schedule touches a cache, which is a promise `devp caches` prints in
227 /// so many words and a size cap is not a reason to break.
228 ///
229 /// Keyed by the names `devp caches clear <MANAGER>` takes, not by adapter name.
230 /// They mostly agree — `npm`, `uv`, `cargo`, `go` — but `pip`, `nuget`, `conan`,
231 /// `conda`, `vcpkg` and `hex` are caches with no adapter, and `venv`, `terraform`
232 /// and `dart` are adapters with no cache. Empty by default: no cache is too big
233 /// until someone says what too big is.
234 #[serde(default)]
235 pub cache_max_gb: BTreeMap<String, u64>,
236 /// Language for dev-prune's own headings and summary lines.
237 ///
238 /// English by default, and English wherever a translation has not reached a string
239 /// yet -- see [`crate::i18n`] for what is translated and, more importantly, what is
240 /// not: `--json`, exit codes, flag names, config keys and the sentences a refusal
241 /// prints stay in English in every language, because they are a contract or a
242 /// diagnosis rather than prose.
243 ///
244 /// `DEV_PRUNE_LANG` overrides this for one invocation. An unrecognised code falls
245 /// back to English rather than failing.
246 #[serde(default = "default_language")]
247 pub language: String,
248 /// Settings this build has never heard of, carried through a save verbatim.
249 ///
250 /// A registry written by a newer dev-prune can hold keys this build does not
251 /// know, and every save rewrites the whole `settings` object — so without this,
252 /// one run of an older binary (a pinned CI image, a machine `version_lock` holds
253 /// back) silently erased the newer binary's configuration. `BTreeMap` for the
254 /// same stable-diff reason as `adapter_idle_days`.
255 #[serde(flatten, default)]
256 pub unknown_keys: BTreeMap<String, serde_json::Value>,
257}
258
259fn default_build_idle_days() -> u64 {
260 constants::DEFAULT_BUILD_IDLE_DAYS
261}
262
263fn default_require_confirmation() -> bool {
264 constants::DEFAULT_REQUIRE_CONFIRMATION
265}
266
267fn default_command_timeout_secs() -> u64 {
268 constants::DEFAULT_COMMAND_TIMEOUT_SECS
269}
270
271fn default_auto_hooks() -> bool {
272 constants::DEFAULT_AUTO_HOOKS
273}
274
275fn default_auto_setup() -> bool {
276 constants::DEFAULT_AUTO_SETUP
277}
278
279fn default_auto_config() -> bool {
280 constants::DEFAULT_AUTO_CONFIG
281}
282
283fn default_auto_discover() -> bool {
284 constants::DEFAULT_AUTO_DISCOVER
285}
286
287fn default_update_check() -> bool {
288 constants::DEFAULT_UPDATE_CHECK
289}
290
291fn default_auto_update() -> bool {
292 constants::DEFAULT_AUTO_UPDATE
293}
294
295fn default_min_size_mb() -> u64 {
296 constants::DEFAULT_MIN_SIZE_MB
297}
298
299fn default_scan_depth() -> usize {
300 constants::DEFAULT_SCAN_DEPTH
301}
302
303fn default_allow_manifest_rewrite() -> bool {
304 constants::DEFAULT_ALLOW_MANIFEST_REWRITE
305}
306
307fn default_update_check_interval_days() -> i64 {
308 constants::UPDATE_CHECK_INTERVAL_DAYS
309}
310
311fn default_update_check_timeout_secs() -> u64 {
312 constants::UPDATE_CHECK_TIMEOUT_SECS
313}
314
315fn default_auto_hooks_chain() -> bool {
316 constants::DEFAULT_AUTO_HOOKS_CHAIN
317}
318
319fn default_language() -> String {
320 constants::DEFAULT_LANGUAGE.to_string()
321}
322
323impl Default for Settings {
324 fn default() -> Self {
325 Self {
326 idle_days: constants::DEFAULT_IDLE_DAYS,
327 check_interval_days: constants::DEFAULT_CHECK_INTERVAL_DAYS,
328 auto_daemon: constants::DEFAULT_AUTO_DAEMON,
329 auto_hooks: constants::DEFAULT_AUTO_HOOKS,
330 auto_setup: constants::DEFAULT_AUTO_SETUP,
331 auto_config: constants::DEFAULT_AUTO_CONFIG,
332 auto_discover: constants::DEFAULT_AUTO_DISCOVER,
333 require_confirmation: constants::DEFAULT_REQUIRE_CONFIRMATION,
334 command_timeout_secs: constants::DEFAULT_COMMAND_TIMEOUT_SECS,
335 min_size_mb: constants::DEFAULT_MIN_SIZE_MB,
336 update_check: constants::DEFAULT_UPDATE_CHECK,
337 scan_depth: constants::DEFAULT_SCAN_DEPTH,
338 allow_manifest_rewrite: constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
339 update_check_interval_days: constants::UPDATE_CHECK_INTERVAL_DAYS,
340 update_check_timeout_secs: constants::UPDATE_CHECK_TIMEOUT_SECS,
341 auto_hooks_chain: constants::DEFAULT_AUTO_HOOKS_CHAIN,
342 enable_cargo: false,
343 enable_gradle: false,
344 enable_maven: false,
345 enable_swift: false,
346 enable_dart: false,
347 enable_mix_build: false,
348 enable_vcpkg: false,
349 enable_cmake_build: false,
350 enable_dotnet_build: false,
351 build_idle_days: constants::DEFAULT_BUILD_IDLE_DAYS,
352 auto_update: constants::DEFAULT_AUTO_UPDATE,
353 version_lock: constants::DEFAULT_VERSION_LOCK,
354 disabled_adapters: Vec::new(),
355 adapter_idle_days: BTreeMap::new(),
356 cache_max_gb: BTreeMap::new(),
357 language: constants::DEFAULT_LANGUAGE.to_string(),
358 unknown_keys: BTreeMap::new(),
359 }
360 }
361}
362
363/// Outcome of recording a repository's identity when it was registered.
364///
365/// Reported rather than silent: a registration that quietly absorbed another entry's
366/// prune history would be indistinguishable from one that lost it.
367#[derive(Debug, Clone, PartialEq, Eq)]
368pub enum Adoption {
369 /// No dead entry claimed this identity.
370 Nothing,
371 /// This registration took over the history of a path that no longer exists.
372 Moved(PathBuf),
373 /// More than one dead entry claims the identity, so none was chosen.
374 Ambiguous,
375}
376
377/// Metadata for a single registered repository.
378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
379pub struct RepoEntry {
380 /// Timestamp when the repo was added to the registry.
381 pub added_at: DateTime<Utc>,
382 /// Timestamp of the last successful prune, if any.
383 pub last_pruned_at: Option<DateTime<Utc>>,
384 /// Per-repo override for idle days (overrides global setting).
385 pub override_idle_days: Option<u64>,
386 /// Whether this repo is enabled for pruning.
387 pub enabled: bool,
388 /// Cumulative bytes reclaimed from this repository.
389 ///
390 /// Recorded from 1.1.0 onward. Registries written by 1.0.0 have no such figure and
391 /// deserialize to zero, so `devp stats` says where the number starts rather than
392 /// implying a repository pruned last March never freed anything.
393 #[serde(default)]
394 pub total_freed_bytes: u64,
395 /// The repository's root commit, recorded when it was registered.
396 ///
397 /// A repository that is moved keeps this; its path does not. Without it a moved
398 /// workspace registers as a brand new repository and its prune history is stranded
399 /// on a path that will never exist again. Registries written before 1.4.0 have none,
400 /// and re-registering the repository is what fills it in.
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub identity: Option<String>,
403}
404
405impl RepoEntry {
406 /// Creates a new `RepoEntry` with the current timestamp.
407 pub fn new() -> Self {
408 Self {
409 added_at: Utc::now(),
410 last_pruned_at: None,
411 override_idle_days: None,
412 enabled: true,
413 total_freed_bytes: 0,
414 identity: None,
415 }
416 }
417}
418
419impl Default for RepoEntry {
420 fn default() -> Self {
421 Self::new()
422 }
423}
424
425/// Resolve where a repository's shared git directory actually lives.
426///
427/// `.git` is a directory in an ordinary clone, but in worktrees and submodules it is a
428/// one-line `gitdir: <path>` pointer file — and a worktree's private gitdir in turn
429/// holds a `commondir` file pointing at the shared one, which is where `info/exclude`
430/// lives. Returns `None` when the path is not inside a git repository at all.
431fn git_common_dir(repo_path: &Path) -> Option<PathBuf> {
432 let dot_git = repo_path.join(".git");
433 let git_dir = if dot_git.is_dir() {
434 dot_git
435 } else {
436 let pointer = fs::read_to_string(&dot_git).ok()?;
437 let target = pointer.strip_prefix("gitdir:")?.trim();
438 let target = Path::new(target);
439 if target.is_absolute() {
440 target.to_path_buf()
441 } else {
442 repo_path.join(target)
443 }
444 };
445 if let Ok(common) = fs::read_to_string(git_dir.join("commondir")) {
446 let target = Path::new(common.trim());
447 if target.is_absolute() {
448 return Some(target.to_path_buf());
449 }
450 return Some(git_dir.join(target));
451 }
452 Some(git_dir)
453}
454
455/// Ensure an entry (e.g. ".devprune.json") is in the repository's `.git/info/exclude`.
456///
457/// The exclude file, not `.gitignore`: the config records one machine's preferences,
458/// and `.gitignore` is a tracked file shared by everyone who clones the repository —
459/// appending to it silently puts an uncommitted change in the user's diff. The exclude
460/// file gives the same "never shows up in `git status`" result without touching
461/// anything the repository tracks.
462pub fn ensure_in_git_exclude(repo_path: &Path, entry: &str) -> Result<()> {
463 let Some(git_dir) = git_common_dir(repo_path) else {
464 return Ok(());
465 };
466 let info_dir = git_dir.join("info");
467 fs::create_dir_all(&info_dir)?;
468 let exclude_path = info_dir.join("exclude");
469 if exclude_path.exists() {
470 let content = fs::read_to_string(&exclude_path)?;
471 if !content.lines().any(|line| line.trim() == entry) {
472 let mut file = fs::OpenOptions::new().append(true).open(&exclude_path)?;
473 let prefix = if content.ends_with('\n') || content.is_empty() {
474 ""
475 } else {
476 "\n"
477 };
478 writeln!(file, "{prefix}{entry}")?;
479 }
480 } else {
481 fs::write(&exclude_path, format!("{entry}\n"))?;
482 }
483 Ok(())
484}
485
486/// Normalise a repository path into the form used as a registry key.
487///
488/// Falls back to the path as given when it cannot be canonicalised (e.g. it no longer
489/// exists), so entries for deleted repos stay addressable.
490pub fn canonical_key(path: &Path) -> PathBuf {
491 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
492}
493
494/// Resolve `.` and `..` segments and anchor a relative path to the working directory,
495/// for paths that no longer exist and so cannot be canonicalised whole. The deepest
496/// ancestor that still exists is canonicalised and the missing tail re-appended:
497/// registry keys are canonical, and a deleted repo named through a symlinked parent —
498/// macOS's `/var` → `/private/var` temp tree being the everyday case — would otherwise
499/// spell the same directory through a different root and never compare equal.
500fn lexical_absolute(path: &Path) -> PathBuf {
501 use std::path::Component;
502 let mut out = if path.is_absolute() {
503 PathBuf::new()
504 } else {
505 std::env::current_dir().unwrap_or_default()
506 };
507 for comp in path.components() {
508 match comp {
509 Component::CurDir => {}
510 Component::ParentDir => {
511 out.pop();
512 }
513 other => out.push(other.as_os_str()),
514 }
515 }
516 let mut prefix = out.as_path();
517 while !prefix.as_os_str().is_empty() {
518 if let Ok(real) = prefix.canonicalize() {
519 if let Ok(tail) = out.strip_prefix(prefix) {
520 return real.join(tail);
521 }
522 break;
523 }
524 match prefix.parent() {
525 Some(parent) => prefix = parent,
526 None => break,
527 }
528 }
529 out
530}
531
532/// Whether two paths name the same directory, tolerating the differences
533/// canonicalisation normally absorbs: the Windows `\\?\` prefix, separator style,
534/// trailing separators, and case on Windows.
535fn loose_path_eq(a: &Path, b: &Path) -> bool {
536 let norm = |p: &Path| {
537 let s = p.to_string_lossy().replace('\\', "/");
538 let s = s.strip_prefix("//?/").unwrap_or(&s);
539 let s = s.trim_end_matches('/').to_string();
540 if cfg!(windows) { s.to_lowercase() } else { s }
541 };
542 norm(a) == norm(b)
543}
544
545/// Expand a leading `~` to the user's home directory.
546///
547/// POSIX shells do this before the argument ever reaches a program, so on Linux and
548/// macOS it is usually a no-op. PowerShell and cmd do not: they hand a native
549/// executable the literal three characters `~/C`, and `devp init ~/Code` — the exact
550/// line in the README and on the landing page — would register a directory called `~`
551/// sitting in the current working directory. Quoting defeats the expansion in *every*
552/// shell, so `devp init "~/Code"` needs this too.
553///
554/// Only a bare `~` or a `~` followed by a separator is expanded. `~alice` means "some
555/// other user's home" in shell syntax and cannot be resolved portably, and `~backup` is
556/// a perfectly ordinary directory name.
557pub fn expand_tilde(raw: &str) -> String {
558 let Some(rest) = raw.strip_prefix('~') else {
559 return raw.to_string();
560 };
561 if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\')) {
562 return raw.to_string();
563 }
564 let Some(home) = dirs::home_dir() else {
565 // No home directory to expand to. Handing back the literal `~` lets the caller
566 // fail with "no such directory", which is a better error than a silent guess.
567 return raw.to_string();
568 };
569 if rest.is_empty() {
570 return home.to_string_lossy().into_owned();
571 }
572 home.join(rest.trim_start_matches(['/', '\\']))
573 .to_string_lossy()
574 .into_owned()
575}
576
577/// Structured per-repository configuration file stored inside repo roots as `.devprune.json`.
578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
579pub struct PerRepoConfig {
580 /// JSON Schema reference URL for IDE IntelliSense and validation.
581 #[serde(rename = "$schema", default = "default_schema_url")]
582 pub schema: String,
583 /// Custom display name for this project in TUI and CLI status views.
584 #[serde(default)]
585 pub project_name: Option<String>,
586 /// Whether this repository is ignored/excluded from pruning.
587 #[serde(default)]
588 pub ignore: bool,
589 /// Disable global Git auto-registration hooks for this specific workspace.
590 #[serde(default)]
591 pub disable_hooks: bool,
592 /// Disable background daemon automated pruning pass for this specific workspace.
593 #[serde(default)]
594 pub disable_daemon: bool,
595 /// Custom override for idle days threshold (overrides global settings).
596 #[serde(default)]
597 pub override_idle_days: Option<u64>,
598 /// Custom override for the size floor, in MiB (overrides global `min_size_mb`).
599 ///
600 /// `Some(0)` is a meaningful value: it turns the floor off for this repository even
601 /// when a global floor is set.
602 #[serde(default)]
603 pub min_size_mb: Option<u64>,
604 /// Custom override for how deep discovery walks this repository.
605 ///
606 /// The setting that most often needs to differ per repository rather than globally:
607 /// one deeply-nested monorepo should not make every other repository pay for a
608 /// deeper walk. Clamped to [`constants::MAX_SCAN_DEPTH_LIMIT`] like the global one.
609 #[serde(default)]
610 pub scan_depth: Option<usize>,
611 /// What this project declares prunable beyond what an adapter can recognise.
612 #[serde(default, skip_serializing_if = "Option::is_none")]
613 pub prunable: Option<Prunable>,
614}
615
616/// The nested half of a repository's config: what this project says is rebuildable.
617///
618/// A section rather than a top-level key, because the keys above it are the whole of
619/// what a repository could say in 1.0.0 and the list of things it might want to say is
620/// not finished. Everything that arrives later and describes *what to delete* belongs
621/// under this heading with `directories`, so the file grows a section at a time instead
622/// of a scatter of top-level names nobody can group by eye.
623#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
624pub struct Prunable {
625 /// Directories dev-prune would never find on its own, each with its way back.
626 #[serde(default, skip_serializing_if = "Vec::is_empty")]
627 pub directories: Vec<DeclaredDir>,
628 /// Declared paths to leave alone on this machine, whoever declared them.
629 ///
630 /// `project.devprune.json` is committed, so one person's `scratch` is everybody's
631 /// `scratch`, and the teammate whose copy is holding something had no way to say so
632 /// short of editing a file the whole team shares. Same spelling as a `path` in
633 /// `directories`; the entry it names is skipped entirely.
634 #[serde(default, skip_serializing_if = "Vec::is_empty")]
635 pub exclude: Vec<String>,
636}
637
638/// One directory a project declares prunable, and the command that puts it back.
639///
640/// Every adapter in this tool earns the right to delete a directory by finding a
641/// lockfile that can rebuild it. A declaration is the same bargain made by hand: the
642/// project states the directory, and states what rebuilds it, and dev-prune checks that
643/// the stated command is one this machine could actually run before it deletes anything.
644///
645/// `rebuild` is required, and required is the point. An optional one would have made
646/// "delete this, I have no idea how to get it back" the path of least resistance in a
647/// file that gets committed and cloned.
648#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
649pub struct DeclaredDir {
650 /// Repository-relative, `/`-separated. Never absolute, never `..`, never `.git`.
651 pub path: String,
652 /// The command that rebuilds it. Shown, never run — see [`crate::declared`].
653 pub rebuild: String,
654 /// Why this is safe to lose, in the project's own words. Printed beside the path.
655 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub why: Option<String>,
657}
658
659// Deliberately absent: `allow_manifest_rewrite`.
660//
661// Only the settings whose right value depends on the *project* have a per-repository
662// form. `allow_manifest_rewrite` is a permission the user grants their own machine, and
663// — exactly as with `post_prune_command` below — nothing stops a project from committing
664// its `.devprune.json`: the `.git/info/exclude` entry [`PerRepoConfig::save_to_repo`]
665// writes is local to one clone and excludes nothing already tracked. A
666// per-repository form would therefore let a repository nobody has read grant itself the
667// right to have `cargo generate-lockfile` / `go mod tidy` rewrite its tracked manifests
668// during an unattended pass. The `auto_*` and `update_check*` settings describe the
669// machine rather than a project and would mean nothing here either.
670
671// Removed: `custom_bloat_dirs` and `post_prune_command`.
672//
673// Both were serialized, schema'd and documented but never read by any code path, so
674// setting them did nothing. `post_prune_command` is also not a feature that should be
675// reintroduced casually: nothing stops a project from committing its `.devprune.json`,
676// so honouring it would mean cloning an untrusted repository and running `devp` hands
677// that repository arbitrary code execution on the user's machine.
678
679fn default_schema_url() -> String {
680 if let Ok(config_dir) = Registry::config_dir() {
681 let local_schema = config_dir.join("bin").join("devprune.schema.json");
682 if local_schema.exists() {
683 // `file://` + `/` + an absolute path. Unix paths already start with a
684 // separator, so pasting them in unconditionally produced `file:////home/...`
685 // — four slashes, which editors reject, leaving the `$schema` link dead and
686 // no IntelliSense at all on the platform where most of them run.
687 return file_uri(&crate::output::clean_path(&local_schema));
688 }
689 }
690 constants::JSON_SCHEMA_URL.to_string()
691}
692
693/// A `file://` URI for an absolute path.
694///
695/// `file://` + `/` + the path. Unix paths already start with a separator, so pasting one
696/// in unconditionally produced `file:////home/...` — four slashes, which editors reject,
697/// leaving the `$schema` link dead and no IntelliSense at all on the platform where most
698/// of them run.
699fn file_uri(clean_path: &str) -> String {
700 format!("file:///{}", clean_path.trim_start_matches('/'))
701}
702
703impl Default for PerRepoConfig {
704 fn default() -> Self {
705 Self {
706 schema: default_schema_url(),
707 project_name: None,
708 ignore: false,
709 disable_hooks: false,
710 disable_daemon: false,
711 override_idle_days: None,
712 min_size_mb: None,
713 scan_depth: None,
714 prunable: None,
715 }
716 }
717}
718
719impl PerRepoConfig {
720 /// Load per-repo config from `.devprune.json`, or `None` when there is no such file.
721 ///
722 /// This is the only loader. There used to be a second one that returned `None` for a
723 /// file that failed to parse as well as for one that was absent, and every caller of
724 /// it then went on to act as though the repository had no configuration: the prune
725 /// pass ignored an `"ignore": true` it could not read, and the two workspace toggles
726 /// wrote a fresh default file straight over the user's broken one, taking every
727 /// override in it with them. A caller that genuinely does not care — the display-name
728 /// lookup — says so with `.ok().flatten()`.
729 pub fn load_with_diagnostics(repo_path: &Path) -> Result<Option<Self>, String> {
730 Ok(RepoConfigLayers::load(repo_path)?.effective())
731 }
732
733 /// Save per-repo config to `.devprune.json` in the repo root, and record it in the
734 /// repository's `.git/info/exclude` so it never shows up in `git status`.
735 pub fn save_to_repo(&self, repo_path: &Path) -> Result<()> {
736 let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
737 let content = serde_json::to_string_pretty(self)?;
738 fs::write(&config_file, content)?;
739 let _ = ensure_in_git_exclude(repo_path, constants::PER_REPO_CONFIG_FILE);
740 let _ = ensure_in_git_exclude(repo_path, constants::DEVPRUNE_IGNORE_FILE);
741 Ok(())
742 }
743
744 /// Which of a repository's config files exist and do not parse, and why.
745 ///
746 /// [`load_with_diagnostics`](Self::load_with_diagnostics) collapses both into one
747 /// refusal, which is the right answer for every reader: a config that cannot be read
748 /// is a repository dev-prune will not touch, whichever file it was in. `devp doctor`
749 /// is the one caller that has to know which, because it repairs the personal file by
750 /// renaming it aside and must never do that to a file the user has committed.
751 pub fn broken_files(repo_path: &Path) -> Vec<(&'static str, String)> {
752 [
753 constants::PROJECT_REPO_CONFIG_FILE,
754 constants::PER_REPO_CONFIG_FILE,
755 ]
756 .into_iter()
757 .filter_map(|name| match read_layer(&repo_path.join(name)) {
758 Err(e) => Some((name, e)),
759 Ok(_) => None,
760 })
761 .collect()
762 }
763
764 /// Keys a repository config file spells out that dev-prune does not read.
765 ///
766 /// Unknown keys are tolerated on purpose — a file written by a newer dev-prune must
767 /// not stop an older one from reading the keys it does know — so
768 /// `deny_unknown_fields` is the one fix this must never become. The cost of that
769 /// tolerance is that a typo (`idle_days` for `override_idle_days`) silently does
770 /// nothing, and nothing on this machine ever tells its author why. This is the
771 /// diagnostic half: `devp doctor` names each stray key, and behaviour changes
772 /// nowhere.
773 pub fn unknown_keys(repo_path: &Path) -> Vec<(&'static str, String)> {
774 const KNOWN: &[&str] = &[
775 "$schema",
776 "project_name",
777 "ignore",
778 "disable_hooks",
779 "disable_daemon",
780 "override_idle_days",
781 "min_size_mb",
782 "scan_depth",
783 "prunable",
784 ];
785 const KNOWN_PRUNABLE: &[&str] = &["directories", "exclude"];
786 const KNOWN_DIRECTORY: &[&str] = &["path", "rebuild", "why"];
787
788 let mut out = Vec::new();
789 for name in [
790 constants::PROJECT_REPO_CONFIG_FILE,
791 constants::PER_REPO_CONFIG_FILE,
792 ] {
793 let Ok(content) = fs::read_to_string(repo_path.join(name)) else {
794 continue;
795 };
796 let Ok(serde_json::Value::Object(map)) = serde_json::from_str(&content) else {
797 continue;
798 };
799 for key in map.keys().filter(|k| !KNOWN.contains(&k.as_str())) {
800 out.push((name, key.clone()));
801 }
802 let Some(serde_json::Value::Object(prunable)) = map.get("prunable") else {
803 continue;
804 };
805 for key in prunable
806 .keys()
807 .filter(|k| !KNOWN_PRUNABLE.contains(&k.as_str()))
808 {
809 out.push((name, format!("prunable.{key}")));
810 }
811 if let Some(serde_json::Value::Array(dirs)) = prunable.get("directories") {
812 for entry in dirs.iter().filter_map(|d| d.as_object()) {
813 for key in entry
814 .keys()
815 .filter(|k| !KNOWN_DIRECTORY.contains(&k.as_str()))
816 {
817 out.push((name, format!("prunable.directories[].{key}")));
818 }
819 }
820 }
821 }
822 out
823 }
824
825 /// The personal `.devprune.json` alone, for a caller about to write it back.
826 ///
827 /// [`load_with_diagnostics`](Self::load_with_diagnostics) answers "what is in force
828 /// here", which is the merge of both files and the right answer for everything that
829 /// reads. It is the wrong answer for anything that writes: saving it copies the
830 /// project file's values into the personal one, and the next edit to the project
831 /// file leaves that copy behind, silently overriding the file it was copied from.
832 pub fn load_personal_for_write(repo_path: &Path) -> Result<Option<Self>, String> {
833 Ok(RepoConfigLayers::load(repo_path)?
834 .personal_config()
835 .cloned())
836 }
837}
838
839/// Write a starter `project.devprune.json`: a schema link, and the empty section.
840///
841/// Deliberately not a serialized [`PerRepoConfig::default`]. Every scalar key the
842/// project file names is a key it wins, so writing all of them out would have `--team`
843/// quietly take over every setting in the `.devprune.json` beside it — including the
844/// ones that file was created to hold. An empty team file decides nothing until the team
845/// decides something, and the `$schema` link is what makes deciding it a matter of
846/// autocomplete rather than of remembering the key names.
847///
848/// The one thing written out is the empty `prunable.directories`, which decides nothing
849/// either — an empty list adds no directories. It is there because a section nobody can
850/// see is a section nobody fills in, and this is the file a person or an agent is
851/// expected to fill in.
852///
853/// No `ensure_in_git_exclude`, and that omission is the entire point. [`PerRepoConfig::
854/// save_to_repo`] hides what it writes because one person's overrides are nobody else's
855/// business; hiding this one would leave it identical to the file beside it and useful
856/// to nobody.
857pub fn write_project_starter(repo_path: &Path) -> Result<()> {
858 let file = repo_path.join(constants::PROJECT_REPO_CONFIG_FILE);
859 let starter = serde_json::json!({
860 // Deliberately not `default_schema_url()`: that prefers the locally installed
861 // schema copy, and this file is committed — the absolute path would be broken
862 // on every other clone and would publish the author's username with it.
863 // `effective()` already refuses to *read* a project-file `$schema` for the
864 // same reason; this stops one being written in the first place.
865 "$schema": constants::JSON_SCHEMA_URL,
866 "prunable": { "directories": [] },
867 });
868 fs::write(&file, serde_json::to_string_pretty(&starter)?)?;
869 Ok(())
870}
871
872/// Which of a repository's two config files an effective value came from.
873#[derive(Debug, Clone, Copy, PartialEq, Eq)]
874pub enum ConfigSource {
875 /// Spelled out in the committed `project.devprune.json`.
876 Project,
877 /// Spelled out in the git-excluded `.devprune.json`.
878 Personal,
879 /// In neither file, so whatever the global setting or the built-in default says.
880 Default,
881}
882
883impl ConfigSource {
884 /// The file this answer came from, or where to look when it came from no file.
885 pub fn label(self) -> &'static str {
886 match self {
887 Self::Project => constants::PROJECT_REPO_CONFIG_FILE,
888 Self::Personal => constants::PER_REPO_CONFIG_FILE,
889 Self::Default => "global setting",
890 }
891 }
892}
893
894/// A repository's configuration as the two files that can contribute to it.
895///
896/// The project file wins every scalar key it names, and the personal file answers the
897/// rest. That is the inverse of the usual local-overrides-committed convention, and
898/// deliberately so: the settings here are the ones a *project* decides, and a team that
899/// has written down "this repository is not worth pruning" wants that to survive a
900/// teammate's stale personal file rather than lose to it.
901///
902/// "Names a key" means the key is literally in the file. A project file silent on
903/// `ignore` does not overrule the personal one with serde's `false`, because a default
904/// filled in by the deserializer is not something anybody wrote down.
905///
906/// `prunable.directories` is the one thing that unions instead of winning. It is a list
907/// of separate declarations rather than a single decided value, so there is nothing for
908/// one file to win: "the team says this cache is rebuildable" and "so is this one on my
909/// machine" are both true at once, and a rule that let the committed file silence the
910/// personal list would delete somebody's own declaration the day their team wrote their
911/// first one. `prunable.exclude` unions for the opposite reason: a veto only ever
912/// deletes less, so it is safe to honour from whichever file wrote it.
913///
914/// Nothing here widens what a repository can ask for. Both files deserialize into the
915/// same [`PerRepoConfig`], so the two settings the type deliberately does not carry —
916/// `allow_manifest_rewrite` and `post_prune_command` — are still absent from both, and
917/// every field that is present is either display-only or scope-shaping. A committed
918/// `.devprune.json` has had exactly this reach since 1.0.0, since the `.git/info/exclude`
919/// entry is local to one clone and excludes nothing already tracked; the shared file
920/// makes that reach a named, documented file instead of an accident.
921pub struct RepoConfigLayers {
922 /// The committed file and the keys it actually spells out.
923 project: Option<(PerRepoConfig, HashSet<String>)>,
924 /// The git-excluded file and the keys it actually spells out.
925 personal: Option<(PerRepoConfig, HashSet<String>)>,
926}
927
928impl RepoConfigLayers {
929 /// Read both files. `Err` if either exists and does not parse.
930 pub fn load(repo_path: &Path) -> Result<Self, String> {
931 Ok(Self {
932 project: read_layer(&repo_path.join(constants::PROJECT_REPO_CONFIG_FILE))?,
933 personal: read_layer(&repo_path.join(constants::PER_REPO_CONFIG_FILE))?,
934 })
935 }
936
937 /// The merged configuration, or `None` when the repository has neither file.
938 ///
939 /// `None` rather than the defaults, because every caller of this treats "no config"
940 /// and "a config that happens to match the defaults" as the same thing to act on but
941 /// not the same thing to report.
942 pub fn effective(&self) -> Option<PerRepoConfig> {
943 if self.project.is_none() && self.personal.is_none() {
944 return None;
945 }
946 let base = self
947 .personal
948 .as_ref()
949 .map(|(c, _)| c.clone())
950 .unwrap_or_default();
951 let Some((project, keys)) = &self.project else {
952 return Some(base);
953 };
954 let said = |k: &str| keys.contains(k);
955 let declared = merge_declarations(project.prunable.as_ref(), base.prunable);
956 Some(PerRepoConfig {
957 // Never taken from the project file. `$schema` points at a validator, and the
958 // one this clone should resolve is the one this machine has —
959 // `default_schema_url` prefers a local copy when there is one, which a
960 // teammate's committed absolute path would override with a file that does
961 // not exist here.
962 schema: base.schema,
963 project_name: pick(
964 said("project_name"),
965 &project.project_name,
966 base.project_name,
967 ),
968 ignore: pick(said("ignore"), &project.ignore, base.ignore),
969 disable_hooks: pick(
970 said("disable_hooks"),
971 &project.disable_hooks,
972 base.disable_hooks,
973 ),
974 disable_daemon: pick(
975 said("disable_daemon"),
976 &project.disable_daemon,
977 base.disable_daemon,
978 ),
979 override_idle_days: pick(
980 said("override_idle_days"),
981 &project.override_idle_days,
982 base.override_idle_days,
983 ),
984 min_size_mb: pick(said("min_size_mb"), &project.min_size_mb, base.min_size_mb),
985 scan_depth: pick(said("scan_depth"), &project.scan_depth, base.scan_depth),
986 prunable: declared,
987 })
988 }
989
990 /// The committed file as it stands, before the personal one fills any gaps in.
991 pub fn project_config(&self) -> Option<&PerRepoConfig> {
992 self.project.as_ref().map(|(c, _)| c)
993 }
994
995 /// The personal file as it stands, before the project one overrules any of it.
996 pub fn personal_config(&self) -> Option<&PerRepoConfig> {
997 self.personal.as_ref().map(|(c, _)| c)
998 }
999
1000 /// Which file each setting's effective value came from.
1001 ///
1002 /// This is what gets shown instead of copying the project file's values into
1003 /// `.devprune.json` as a visible "mirror". A second copy of a value is a second copy
1004 /// free to drift from the first, and the question somebody actually has in front of
1005 /// two config files is not "what does each say" but "which one won".
1006 pub fn rows(&self) -> Vec<(&'static str, String, ConfigSource)> {
1007 let cfg = self.effective().unwrap_or_default();
1008 vec![
1009 (
1010 "project_name",
1011 opt(&cfg.project_name),
1012 self.source_of("project_name"),
1013 ),
1014 ("ignore", cfg.ignore.to_string(), self.source_of("ignore")),
1015 (
1016 "disable_hooks",
1017 cfg.disable_hooks.to_string(),
1018 self.source_of("disable_hooks"),
1019 ),
1020 (
1021 "disable_daemon",
1022 cfg.disable_daemon.to_string(),
1023 self.source_of("disable_daemon"),
1024 ),
1025 (
1026 "override_idle_days",
1027 opt(&cfg.override_idle_days),
1028 self.source_of("override_idle_days"),
1029 ),
1030 (
1031 "min_size_mb",
1032 opt(&cfg.min_size_mb),
1033 self.source_of("min_size_mb"),
1034 ),
1035 (
1036 "scan_depth",
1037 opt(&cfg.scan_depth),
1038 self.source_of("scan_depth"),
1039 ),
1040 ]
1041 }
1042
1043 /// Which file spelled this key out, in precedence order.
1044 pub fn source_of(&self, key: &str) -> ConfigSource {
1045 if self.project.as_ref().is_some_and(|(_, k)| k.contains(key)) {
1046 ConfigSource::Project
1047 } else if self.personal.as_ref().is_some_and(|(_, k)| k.contains(key)) {
1048 ConfigSource::Personal
1049 } else {
1050 ConfigSource::Default
1051 }
1052 }
1053}
1054
1055/// `project` when the project file named this key, `personal` otherwise.
1056fn pick<T: Clone>(project_said_so: bool, project: &T, personal: T) -> T {
1057 if project_said_so {
1058 project.clone()
1059 } else {
1060 personal
1061 }
1062}
1063
1064/// Both files' declarations, the committed ones first, one entry per path.
1065///
1066/// Deduplicated by path rather than by whole entry: two files naming the same directory
1067/// with two different `rebuild` commands is one directory, and the committed one is the
1068/// answer — a teammate whose personal file still names last year's build script should
1069/// get the project's current one, not a second delete of the same path.
1070///
1071/// `exclude` unions the same way and from either file. It can only ever take a directory
1072/// out of play, so there is nothing for the committed file to protect by winning it —
1073/// and the person who needs one is by definition the person that file is wrong for.
1074fn merge_declarations(project: Option<&Prunable>, personal: Option<Prunable>) -> Option<Prunable> {
1075 let mut directories: Vec<DeclaredDir> =
1076 project.map(|p| p.directories.clone()).unwrap_or_default();
1077 let mut exclude: Vec<String> = project.map(|p| p.exclude.clone()).unwrap_or_default();
1078 let personal = personal.unwrap_or_default();
1079 for dir in personal.directories {
1080 if !directories.iter().any(|d| d.path == dir.path) {
1081 directories.push(dir);
1082 }
1083 }
1084 for path in personal.exclude {
1085 if !exclude.contains(&path) {
1086 exclude.push(path);
1087 }
1088 }
1089 if directories.is_empty() && exclude.is_empty() {
1090 None
1091 } else {
1092 Some(Prunable {
1093 directories,
1094 exclude,
1095 })
1096 }
1097}
1098
1099/// How an unset optional reads in the provenance table.
1100fn opt<T: std::fmt::Display>(value: &Option<T>) -> String {
1101 value
1102 .as_ref()
1103 .map_or_else(|| "not set".to_string(), ToString::to_string)
1104}
1105
1106/// Parse one config file into its values and the set of keys it actually spells out.
1107fn read_layer(path: &Path) -> Result<Option<(PerRepoConfig, HashSet<String>)>, String> {
1108 if !path.exists() {
1109 return Ok(None);
1110 }
1111 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {e}"))?;
1112 // `clean_path`, like every other path this tool shows. `Display` on a canonicalised
1113 // Windows path leaks the `\\?\` extended-length prefix into an error message the user
1114 // is being asked to act on.
1115 let cfg = serde_json::from_str::<PerRepoConfig>(&content)
1116 .map_err(|e| format!("Syntax error in `{}`: {e}", crate::output::clean_path(path)))?;
1117 // The same text just deserialized into a struct, so it is a JSON object and this
1118 // cannot fail; it is parsed a second time only because serde has by then thrown away
1119 // the difference between a key the file set and a key it defaulted.
1120 let keys = serde_json::from_str::<HashMap<String, serde_json::Value>>(&content)
1121 .map(|m| m.into_keys().collect())
1122 .unwrap_or_default();
1123 Ok(Some((cfg, keys)))
1124}
1125
1126/// One directory a prune pass deleted.
1127///
1128/// Enough to put it back and nothing more: which repository it belonged to, which
1129/// project inside that repository owned it, and who verified it. No file list — the
1130/// lockfile is the record of the contents, which is the whole premise of the tool.
1131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1132pub struct PrunedDir {
1133 /// Repository root the directory belonged to.
1134 pub repo_path: PathBuf,
1135 /// Repository-relative label, `/`-separated: `node_modules`, `frontend/node_modules`.
1136 pub bloat_dir: String,
1137 /// Adapter that verified and deleted it.
1138 pub adapter: String,
1139 /// Bytes reclaimed.
1140 pub size_freed: u64,
1141 /// The language runtime the deleted directory was built against — `"3.12"` for a
1142 /// virtual environment created by Python 3.12 — so a restore can rebuild on that
1143 /// interpreter instead of on whatever happens to be first on `PATH` today.
1144 ///
1145 /// `None` for every manager that pins its own toolchain in the lockfile (cargo, npm,
1146 /// go) and for anything pruned before 1.4.0. Optional rather than required for that
1147 /// second reason: a `registry.json` written by an older version has to keep loading.
1148 #[serde(default, skip_serializing_if = "Option::is_none")]
1149 pub runtime: Option<String>,
1150}
1151
1152/// What the most recent prune pass deleted, for `devp restore --last-run`.
1153///
1154/// Only passes that actually deleted something are recorded. A later run that frees
1155/// nothing — everything was active, everything was already clean — leaves this alone,
1156/// because "put back what you just took" should still mean the pass that took something.
1157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1158pub struct LastPrune {
1159 /// When the pass ran.
1160 pub at: DateTime<Utc>,
1161 /// Every directory it removed.
1162 pub dirs: Vec<PrunedDir>,
1163}
1164
1165/// A one-line summary of a completed prune pass, for `devp stats`.
1166///
1167/// Deliberately not a second copy of [`LastPrune`]. That one exists so
1168/// `devp restore --last-run` can put files back, so it carries the full directory list
1169/// and only ever describes the most recent pass. This one is a trend line — four numbers
1170/// per pass, bounded by [`constants::PRUNE_HISTORY_LIMIT`] — and could not restore
1171/// anything if it wanted to.
1172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1173pub struct PruneRunSummary {
1174 /// When the pass ran.
1175 pub at: DateTime<Utc>,
1176 /// Bytes reclaimed by the pass.
1177 pub bytes_freed: u64,
1178 /// How many directories it removed.
1179 pub dirs_removed: usize,
1180 /// How many distinct repositories it touched.
1181 pub repos_touched: usize,
1182}
1183
1184/// The top-level registry structure persisted to disk.
1185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1186pub struct Registry {
1187 /// Schema version for forward compatibility.
1188 pub version: String,
1189 /// Global settings.
1190 pub settings: Settings,
1191 /// Map of canonical repo paths to their metadata.
1192 pub repositories: HashMap<PathBuf, RepoEntry>,
1193 /// Total cumulative bytes freed historically across all prune passes.
1194 #[serde(default)]
1195 pub total_freed_bytes: u64,
1196 /// Total bytes given back by `devp caches clear`, ever, on this machine.
1197 ///
1198 /// Kept apart from `total_freed_bytes` rather than folded into it, because the two
1199 /// cost different things to undo. A prune deletes what a lockfile proves it can
1200 /// rebuild, and getting it back is one reinstall in one repository; emptying a shared
1201 /// cache costs a download in every project on the disk. Not keyed by repository for
1202 /// the same reason: a package manager's cache belongs to none of them.
1203 ///
1204 /// Recorded from 1.9.0 onward, so a registry written before then deserializes to
1205 /// zero and starts counting from the next clear.
1206 #[serde(default)]
1207 pub total_cache_freed_bytes: u64,
1208 /// Total bytes given back by `devp caches clear <engine>`, ever, on this machine.
1209 ///
1210 /// A third counter rather than a third contributor to one of the other two, for the
1211 /// same reason they are already separate: undo cost. A prune costs one reinstall in
1212 /// one repository, a cache clear costs a download in every project on the disk, and
1213 /// an image costs a pull of the whole layer stack — or a rebuild, if the Dockerfile
1214 /// is still around. Three different bills should not be added up into one number and
1215 /// presented as a single achievement.
1216 ///
1217 /// Recorded from 1.17.0 onward, so a registry written before then deserializes to
1218 /// zero and starts counting from the next clear.
1219 #[serde(default)]
1220 pub total_container_freed_bytes: u64,
1221 /// How many prune passes have deleted something, ever.
1222 ///
1223 /// One per *pass*, not per repository and not per directory — a `devp run` that
1224 /// cleared eleven directories across four repositories counts once. Incremented in
1225 /// exactly one place, [`Registry::record_prune`], which is also where the pass is
1226 /// recorded for `devp restore --last-run`; keeping the two together is what stops
1227 /// them meaning different things depending on which command did the pruning.
1228 #[serde(default)]
1229 pub total_pruned_count: u64,
1230 /// List of repository paths added in the most recent init/link action (for devp undo).
1231 #[serde(default)]
1232 pub last_added_repos: Vec<PathBuf>,
1233 /// What the most recent prune pass deleted (for `devp restore --last-run`).
1234 #[serde(default)]
1235 pub last_prune: Option<LastPrune>,
1236 /// Summaries of recent prune passes, oldest first, for `devp stats`.
1237 ///
1238 /// Capped at [`constants::PRUNE_HISTORY_LIMIT`]. Recorded from 1.1.0 onward.
1239 #[serde(default)]
1240 pub prune_history: Vec<PruneRunSummary>,
1241 /// When the release check last ran, so it runs at most once every
1242 /// `UPDATE_CHECK_INTERVAL_DAYS` instead of on every command.
1243 #[serde(default)]
1244 pub last_update_check: Option<DateTime<Utc>>,
1245 /// The newest release seen by the last check, so the reminder survives until the
1246 /// user actually upgrades without needing the network again.
1247 #[serde(default)]
1248 pub latest_known_version: Option<String>,
1249 /// Whether that last check failed to get an answer.
1250 ///
1251 /// A failed check still advances `last_update_check` — retrying on every command
1252 /// while offline would stall everyday work — but it must not freeze
1253 /// `latest_known_version` for the whole interval: one timed-out request on this
1254 /// laptop left `auto_update` believing 1.14.0 was current for a week after 1.19.0
1255 /// shipped. With this recorded, the next check is due after a day instead.
1256 #[serde(default)]
1257 pub last_update_check_failed: bool,
1258 /// How fast each adapter has actually restored on this machine.
1259 ///
1260 /// Measured by `devp restore --last-run`, which is the one command that knows both
1261 /// how long a restore took and how many bytes it put back. Local only: nothing here
1262 /// is uploaded, compared against anyone else's machine, or used for anything except
1263 /// the estimate `devp status` prints. See `docs/PRIVACY.md`.
1264 #[serde(default)]
1265 pub restore_rates: BTreeMap<String, RestoreRate>,
1266}
1267
1268/// One adapter's observed restore throughput on this machine.
1269///
1270/// Totals rather than a stored average, because that is what lets a new measurement be
1271/// folded in without keeping the individual samples — and the individual samples are
1272/// per-repository, which is exactly the shape of data this tool has no business keeping.
1273#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
1274pub struct RestoreRate {
1275 /// How many restores this average is made of.
1276 pub samples: u32,
1277 /// Bytes those restores put back.
1278 pub bytes: u64,
1279 /// Milliseconds they took.
1280 pub millis: u64,
1281}
1282
1283impl RestoreRate {
1284 /// Bytes per second, or `None` when the record cannot support the division.
1285 pub fn bytes_per_sec(&self) -> Option<f64> {
1286 (self.samples > 0 && self.millis > 0 && self.bytes > 0)
1287 .then(|| self.bytes as f64 * 1000.0 / self.millis as f64)
1288 }
1289}
1290
1291impl Default for Registry {
1292 fn default() -> Self {
1293 Self {
1294 version: "1.0".to_string(),
1295 settings: Settings::default(),
1296 repositories: HashMap::new(),
1297 total_freed_bytes: 0,
1298 total_cache_freed_bytes: 0,
1299 total_container_freed_bytes: 0,
1300 total_pruned_count: 0,
1301 last_added_repos: Vec::new(),
1302 last_prune: None,
1303 prune_history: Vec::new(),
1304 last_update_check: None,
1305 latest_known_version: None,
1306 last_update_check_failed: false,
1307 restore_rates: BTreeMap::new(),
1308 }
1309 }
1310}
1311
1312impl Registry {
1313 /// Returns the path to the config directory (`~/.config/dev-prune/`).
1314 ///
1315 /// Uses the `dirs` crate to resolve the platform-specific config location:
1316 /// - Linux/macOS: `~/.config/dev-prune/`
1317 /// - Windows: `C:\Users\<user>\AppData\Roaming\dev-prune\` (or `~/.config/dev-prune/`)
1318 pub fn config_dir() -> Result<PathBuf> {
1319 if let Ok(override_dir) = std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE) {
1320 return Ok(PathBuf::from(override_dir));
1321 }
1322 let base = dirs::config_dir().context("Could not determine config directory")?;
1323 Ok(base.join(constants::CONFIG_DIR_NAME))
1324 }
1325
1326 /// Returns the full path to the registry file.
1327 pub fn registry_path() -> Result<PathBuf> {
1328 Ok(Self::config_dir()?.join(constants::REGISTRY_FILENAME))
1329 }
1330
1331 /// Loads the registry from disk, or the defaults when there is nothing to load.
1332 ///
1333 /// Reading does not write. This used to persist the default registry on the way
1334 /// out, which made `devp --dry-run init` create the very file it had just promised
1335 /// not to write and gave `devp status --json` — documented as a pure read — a side
1336 /// effect on first use. Every command that actually changes something calls
1337 /// [`Registry::save`], and that creates the directory as needed.
1338 pub fn load() -> Result<Self> {
1339 Self::load_from(&Self::registry_path()?)
1340 }
1341
1342 /// Loads the registry from a specific path (for testing or custom locations).
1343 ///
1344 /// Non-persisting, exactly like [`Registry::load`], which is implemented on top of
1345 /// it. The two used to disagree — this one wrote the defaults out when the file was
1346 /// missing — which is the sort of difference that makes a test pass while the
1347 /// behaviour it stands in for is broken.
1348 pub fn load_from(path: &Path) -> Result<Self> {
1349 if !path.exists() {
1350 return Ok(Registry::default());
1351 }
1352 let contents = fs::read_to_string(path)
1353 .with_context(|| format!("Failed to read registry at {}", path.display()))?;
1354 serde_json::from_str(&contents)
1355 .with_context(|| format!("Failed to parse registry at {}", path.display()))
1356 }
1357
1358 /// Saves the registry to disk atomically (write to temp, then rename).
1359 pub fn save(&self) -> Result<()> {
1360 let path = Self::registry_path()?;
1361 self.save_to(&path)
1362 }
1363
1364 /// Saves the registry to a specific path (for testing or custom locations).
1365 pub fn save_to(&self, path: &Path) -> Result<()> {
1366 if let Some(parent) = path.parent() {
1367 fs::create_dir_all(parent)
1368 .with_context(|| format!("Failed to create config dir {}", parent.display()))?;
1369 }
1370 // Unique per process. A manual run and the scheduled daemon pass can save at the
1371 // same moment; with a shared `registry.json.tmp`, one process could rename the
1372 // other's half-written file into place as a torn, unparseable registry.
1373 let tmp_path = path.with_extension(format!("json.{}.tmp", std::process::id()));
1374 let contents =
1375 serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
1376 {
1377 // `sync_all` before the rename, or the atomicity is only apparent: after a
1378 // power cut the rename can survive while the data does not, leaving the
1379 // registry as zero bytes — the one outcome this dance exists to prevent.
1380 use std::io::Write;
1381 let mut file = fs::File::create(&tmp_path)
1382 .with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
1383 file.write_all(contents.as_bytes())
1384 .with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
1385 file.sync_all()
1386 .with_context(|| format!("Failed to flush temp registry {}", tmp_path.display()))?;
1387 }
1388 fs::rename(&tmp_path, path)
1389 .with_context(|| format!("Failed to rename temp registry to {}", path.display()))?;
1390
1391 // A crash between write and rename strands that process's `.<pid>.tmp` forever.
1392 // Sweep siblings old enough that no live save can still own them.
1393 if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
1394 let prefix = format!("{}.", name.to_string_lossy());
1395 if let Ok(entries) = fs::read_dir(parent) {
1396 for entry in entries.flatten() {
1397 let file_name = entry.file_name();
1398 let file_name = file_name.to_string_lossy();
1399 if file_name.starts_with(&prefix)
1400 && file_name.ends_with(".tmp")
1401 && entry
1402 .metadata()
1403 .and_then(|m| m.modified())
1404 .ok()
1405 .and_then(|t| t.elapsed().ok())
1406 .is_some_and(|age| age.as_secs() > 3600)
1407 {
1408 let _ = fs::remove_file(entry.path());
1409 }
1410 }
1411 }
1412 }
1413 Ok(())
1414 }
1415
1416 /// Adds a repository to the registry. Returns `true` if newly added, `false` if already present.
1417 pub fn add_repo(&mut self, path: PathBuf) -> bool {
1418 // The registry is keyed by path, so `./foo`, `foo/`, and the absolute form
1419 // would otherwise register as three separate repositories.
1420 let path = canonical_key(&path);
1421 if self.repositories.contains_key(&path) {
1422 return false;
1423 }
1424 self.repositories.insert(path, RepoEntry::new());
1425 true
1426 }
1427
1428 /// Record `identity` against a registered repository, and hand it the history of the
1429 /// entry it moved away from.
1430 ///
1431 /// Called after `add_repo` from both `link` and `init`. When exactly one registered
1432 /// path no longer exists on disk and carries the same root commit, that entry is the
1433 /// same repository at its old location: its `added_at`, prune history and settings
1434 /// move across and the dead row is removed. Two dead entries claiming one identity
1435 /// is a clone, not a move, so nothing is guessed — the caller says so instead.
1436 ///
1437 /// Also the backfill path. Entries registered before 1.4.0 have no identity, so
1438 /// nothing they do can be recognised as a move; re-registering them records one, and
1439 /// a single `devp init ~/code` backfills the whole registry.
1440 pub fn adopt_moved_entry(&mut self, path: &Path, identity: Option<String>) -> Adoption {
1441 let key = canonical_key(path);
1442 let Some(identity) = identity else {
1443 return Adoption::Nothing;
1444 };
1445
1446 let mut claimants: Vec<PathBuf> = self
1447 .repositories
1448 .iter()
1449 .filter(|(p, e)| {
1450 **p != key && e.identity.as_deref() == Some(identity.as_str()) && !p.exists()
1451 })
1452 .map(|(p, _)| p.clone())
1453 .collect();
1454 // Deterministic: two dead entries with one identity is a report, not a coin toss,
1455 // and the report must read the same twice.
1456 claimants.sort();
1457
1458 let adopted = match claimants.len() {
1459 0 => Adoption::Nothing,
1460 1 => Adoption::Moved(claimants.remove(0)),
1461 _ => Adoption::Ambiguous,
1462 };
1463
1464 if let Adoption::Moved(ref old) = adopted
1465 && let Some(previous) = self.repositories.remove(old)
1466 {
1467 if let Some(entry) = self.repositories.get_mut(&key) {
1468 // Everything the old path had earned. `enabled` and the idle override
1469 // come across too: a repository the user had switched off did not switch
1470 // itself back on by being moved.
1471 entry.added_at = previous.added_at;
1472 entry.last_pruned_at = previous.last_pruned_at;
1473 entry.override_idle_days = previous.override_idle_days;
1474 entry.enabled = previous.enabled;
1475 entry.total_freed_bytes = previous.total_freed_bytes;
1476 }
1477 self.last_added_repos.retain(|p| p != old);
1478 }
1479
1480 if let Some(entry) = self.repositories.get_mut(&key) {
1481 entry.identity = Some(identity);
1482 }
1483 adopted
1484 }
1485
1486 /// Whether a registered repository still has no recorded identity.
1487 ///
1488 /// The global Git hook runs `devp link --quiet` on every commit, and backfilling
1489 /// unconditionally would shell out to git and rewrite the registry once per commit
1490 /// forever. This makes it once per repository.
1491 pub fn needs_identity(&self, path: &Path) -> bool {
1492 self.repositories
1493 .get(&canonical_key(path))
1494 .is_some_and(|e| e.identity.is_none())
1495 }
1496
1497 /// Removes a repository from the registry. Returns `true` if it was present.
1498 ///
1499 /// A repository that has been deleted from disk cannot be canonicalised any more,
1500 /// so `canonical_key` falls back to the path as typed — which never equals the
1501 /// canonical key it was registered under (on Windows those carry the `\\?\`
1502 /// prefix). Unlinking a deleted repository is the most ordinary reason to unlink
1503 /// at all, so a direct miss falls back to a lexical comparison.
1504 pub fn remove_repo(&mut self, path: &Path) -> bool {
1505 let target = lexical_absolute(path);
1506 let removed = if self.repositories.remove(&canonical_key(path)).is_some() {
1507 true
1508 } else {
1509 let found = self
1510 .repositories
1511 .keys()
1512 .find(|k| loose_path_eq(k, &target))
1513 .cloned();
1514 found.is_some_and(|k| self.repositories.remove(&k).is_some())
1515 };
1516 if removed {
1517 // The undo list stores the canonical `\\?\`-prefixed spelling, while a
1518 // deleted directory can only be named lexically — strict equality misses,
1519 // and the next `devp undo` "reverts" by removing nothing.
1520 self.last_added_repos.retain(|p| !loose_path_eq(p, &target));
1521 }
1522 removed
1523 }
1524
1525 // Removed: `repo_paths` and `effective_idle_days`.
1526 //
1527 // Neither had a caller outside this file's own tests. `effective_idle_days` had also
1528 // drifted from the rule the engine actually applies: it looked the repository up by
1529 // the path as given, where every write to `repositories` goes through
1530 // `canonical_key`, so `devp`'s own relative paths would have missed the entry and
1531 // silently returned the global threshold instead of the repository's override.
1532
1533 /// Credit `bytes_freed` to one repository, and to the machine-wide total.
1534 ///
1535 /// Safe to call once per repository or once per directory — every figure it touches
1536 /// is either a sum or a timestamp, so the two styles agree. Counting *passes* is
1537 /// deliberately not done here for exactly that reason; that lives in
1538 /// [`Registry::record_prune`], which is called once per pass.
1539 pub fn mark_pruned(&mut self, path: &Path, bytes_freed: u64) {
1540 // Same rule as every other accessor: the map is keyed by `canonical_key`, so a
1541 // raw lookup would silently skip the per-repo credit for a relative or
1542 // differently-spelled path while still growing the machine-wide total.
1543 if let Some(entry) = self.repositories.get_mut(&canonical_key(path)) {
1544 entry.last_pruned_at = Some(Utc::now());
1545 entry.total_freed_bytes += bytes_freed;
1546 }
1547 self.total_freed_bytes += bytes_freed;
1548 }
1549
1550 /// Credit `bytes` to the machine's running cache-clear total.
1551 pub fn record_cache_clear(&mut self, bytes: u64) {
1552 self.total_cache_freed_bytes += bytes;
1553 }
1554
1555 /// Credit `bytes` to the machine's running container-reclaim total.
1556 pub fn record_container_clear(&mut self, bytes: u64) {
1557 self.total_container_freed_bytes += bytes;
1558 }
1559
1560 /// Record what a prune pass deleted, replacing any earlier record.
1561 ///
1562 /// A pass that deleted nothing is not a pass worth remembering, so an empty list is
1563 /// ignored rather than stored — otherwise `devp run` on an already-clean machine
1564 /// would quietly throw away the record of the run the user actually wants back.
1565 ///
1566 /// This is the one place a prune pass is counted. It sets [`Registry::last_prune`],
1567 /// appends a [`PruneRunSummary`] to [`Registry::prune_history`] and bumps
1568 /// [`Registry::total_pruned_count`], because "a pass happened and it deleted things"
1569 /// is exactly the condition all three describe. Splitting them across call sites is
1570 /// how the counter previously came to mean repositories in `devp run` and directories
1571 /// in the `devp status` dashboard.
1572 /// Fold one measured restore into an adapter's running average.
1573 ///
1574 /// Ignores anything too quick to have been real work — see
1575 /// [`constants::RESTORE_RATE_MIN_MILLIS`] — because a manager that found everything
1576 /// still in its cache returns in a moment and would teach a throughput no cold
1577 /// restore can reach. That is the difference between an estimate that is optimistic
1578 /// and one that is wrong.
1579 pub fn record_restore(&mut self, adapter: &str, bytes: u64, millis: u64) {
1580 if bytes == 0 || millis < constants::RESTORE_RATE_MIN_MILLIS {
1581 return;
1582 }
1583 let rate = self.restore_rates.entry(adapter.to_string()).or_default();
1584 if rate.samples >= constants::RESTORE_RATE_SAMPLE_CAP {
1585 rate.samples /= 2;
1586 rate.bytes /= 2;
1587 rate.millis /= 2;
1588 }
1589 rate.samples += 1;
1590 rate.bytes = rate.bytes.saturating_add(bytes);
1591 rate.millis = rate.millis.saturating_add(millis);
1592 }
1593
1594 /// How long putting back `by_adapter` would take, from what this machine has
1595 /// measured.
1596 ///
1597 /// Returns the seconds and the bytes those seconds account for. Anything from an
1598 /// adapter that has never been timed here is left out of both, so a caller can say
1599 /// how much of the estimate is actually covered rather than quietly quoting a
1600 /// number for half the work. `None` when nothing is covered at all — an estimate
1601 /// with no measurement behind it is a guess, and this command does not print
1602 /// guesses.
1603 pub fn estimate_restore(&self, by_adapter: &[(String, u64)]) -> Option<(f64, u64)> {
1604 let mut secs = 0.0;
1605 let mut covered = 0u64;
1606 for (adapter, bytes) in by_adapter {
1607 let Some(rate) = self
1608 .restore_rates
1609 .get(adapter)
1610 .and_then(|r| r.bytes_per_sec())
1611 else {
1612 continue;
1613 };
1614 secs += *bytes as f64 / rate;
1615 covered = covered.saturating_add(*bytes);
1616 }
1617 (covered > 0).then_some((secs, covered))
1618 }
1619
1620 pub fn record_prune(&mut self, dirs: Vec<PrunedDir>) {
1621 self.record_prune_progress(Utc::now(), dirs);
1622 }
1623
1624 /// Record a pass's progress mid-flight, superseding this same pass's earlier record.
1625 ///
1626 /// `at` identifies the pass: a repeated call with the same timestamp replaces the
1627 /// history entry and `last_prune` it wrote before, rather than counting a second
1628 /// pass. This exists so a long pass can persist after every repository — a crash
1629 /// half-way through used to leave `devp restore --last-run` pointing at the
1630 /// *previous* pass, offering to reinstall directories that were never deleted while
1631 /// saying nothing about the ones that were.
1632 pub fn record_prune_progress(&mut self, at: DateTime<Utc>, dirs: Vec<PrunedDir>) {
1633 if dirs.is_empty() {
1634 return;
1635 }
1636 if self.prune_history.last().map(|s| s.at) == Some(at) {
1637 self.prune_history.pop();
1638 } else {
1639 self.total_pruned_count += 1;
1640 }
1641
1642 self.prune_history.push(PruneRunSummary {
1643 at,
1644 bytes_freed: dirs.iter().map(|d| d.size_freed).sum(),
1645 dirs_removed: dirs.len(),
1646 repos_touched: dirs
1647 .iter()
1648 .map(|d| &d.repo_path)
1649 .collect::<HashSet<_>>()
1650 .len(),
1651 });
1652 // Oldest first, so the overflow comes off the front.
1653 if self.prune_history.len() > constants::PRUNE_HISTORY_LIMIT {
1654 let excess = self.prune_history.len() - constants::PRUNE_HISTORY_LIMIT;
1655 self.prune_history.drain(..excess);
1656 }
1657
1658 self.last_prune = Some(LastPrune { at, dirs });
1659 }
1660
1661 /// Returns the number of registered repositories.
1662 pub fn repo_count(&self) -> usize {
1663 self.repositories.len()
1664 }
1665}
1666
1667#[cfg(test)]
1668mod tests {
1669 use super::*;
1670 use tempfile::TempDir;
1671
1672 fn test_registry_path(dir: &TempDir) -> PathBuf {
1673 dir.path().join("dev-prune").join("registry.json")
1674 }
1675
1676 fn a_pruned_dir(label: &str) -> PrunedDir {
1677 PrunedDir {
1678 repo_path: PathBuf::from("/repo"),
1679 bloat_dir: label.to_string(),
1680 adapter: "npm".to_string(),
1681 size_freed: 42,
1682 runtime: None,
1683 }
1684 }
1685
1686 #[test]
1687 fn cache_clears_accumulate_separately_from_prunes() {
1688 let dir = TempDir::new().expect("temp dir");
1689 let path = test_registry_path(&dir);
1690
1691 let mut registry = Registry::default();
1692 registry.mark_pruned(Path::new("/repo"), 42);
1693 registry.record_cache_clear(6_000_000_000);
1694 registry.record_cache_clear(2_000_000_000);
1695 registry.save_to(&path).expect("saved");
1696
1697 let reloaded = Registry::load_from(&path).expect("reloaded");
1698 assert_eq!(reloaded.total_cache_freed_bytes, 8_000_000_000);
1699 // The prune total is untouched by either clear. `devp stats` prints them as two
1700 // lines because emptying a shared cache is not the same promise as pruning one
1701 // repository, and one combined figure would answer neither question.
1702 assert_eq!(reloaded.total_freed_bytes, 42);
1703 }
1704
1705 #[test]
1706 fn a_registry_written_before_1_9_0_reads_the_cache_total_as_zero() {
1707 // The `#[serde(default)]`, exercised. Without it every registry on every machine
1708 // that upgraded would fail to parse, and `devp stats` would exit 1.
1709 let dir = TempDir::new().expect("temp dir");
1710 let path = test_registry_path(&dir);
1711 std::fs::create_dir_all(path.parent().expect("parent")).expect("config dir");
1712
1713 // Built by removing the one key 1.8.0 did not write, rather than hand-typed, so
1714 // this stays a test of the `default` and not of whichever unrelated field is
1715 // added to `Settings` next.
1716 let mut older = Registry {
1717 total_freed_bytes: 99,
1718 ..Default::default()
1719 };
1720 older.record_cache_clear(500);
1721 let mut document: serde_json::Value =
1722 serde_json::from_str(&serde_json::to_string(&older).expect("serialized"))
1723 .expect("re-parsed");
1724 assert!(
1725 document
1726 .as_object_mut()
1727 .expect("an object")
1728 .remove("total_cache_freed_bytes")
1729 .is_some(),
1730 "the field this test is about must be in the document to begin with"
1731 );
1732 std::fs::write(&path, document.to_string()).expect("wrote an older registry");
1733
1734 let registry = Registry::load_from(&path).expect("an older registry still parses");
1735 assert_eq!(registry.total_cache_freed_bytes, 0);
1736 assert_eq!(registry.total_freed_bytes, 99);
1737 }
1738
1739 #[test]
1740 fn a_setting_from_a_newer_version_survives_this_version_saving() {
1741 // The `#[serde(flatten)]` catch-all, exercised. A registry written by a newer
1742 // dev-prune can hold settings keys this build has never heard of, and every
1743 // save rewrites the whole `settings` object — so before the catch-all, one
1744 // run of an older binary (a pinned CI image, a machine `version_lock` holds
1745 // back) silently erased the newer binary's configuration.
1746 let dir = TempDir::new().expect("temp dir");
1747 let path = test_registry_path(&dir);
1748 std::fs::create_dir_all(path.parent().expect("parent")).expect("config dir");
1749
1750 let mut document: serde_json::Value =
1751 serde_json::from_str(&serde_json::to_string(&Registry::default()).expect("serialized"))
1752 .expect("re-parsed");
1753 document["settings"]["from_the_future"] = serde_json::json!({ "answer": 42 });
1754 std::fs::write(&path, document.to_string()).expect("wrote a newer registry");
1755
1756 let loaded = Registry::load_from(&path).expect("a newer registry still parses");
1757 loaded.save_to(&path).expect("saved");
1758
1759 let saved: serde_json::Value =
1760 serde_json::from_str(&std::fs::read_to_string(&path).expect("read back"))
1761 .expect("parsed");
1762 assert_eq!(saved["settings"]["from_the_future"]["answer"], 42);
1763 }
1764
1765 #[test]
1766 fn a_prune_that_deleted_nothing_does_not_erase_the_last_one() {
1767 // Otherwise a second `devp run` on an already-clean machine throws away the
1768 // record of the pass the user actually wants to undo.
1769 let mut registry = Registry::default();
1770 registry.record_prune(vec![a_pruned_dir("node_modules")]);
1771 let recorded = registry.last_prune.clone().expect("first pass recorded");
1772
1773 registry.record_prune(Vec::new());
1774
1775 assert_eq!(registry.last_prune, Some(recorded));
1776 }
1777
1778 #[test]
1779 fn a_later_prune_replaces_the_record() {
1780 let mut registry = Registry::default();
1781 registry.record_prune(vec![a_pruned_dir("node_modules")]);
1782 registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
1783
1784 let dirs = registry.last_prune.unwrap().dirs;
1785 assert_eq!(dirs.len(), 1);
1786 assert_eq!(dirs[0].bloat_dir, "frontend/node_modules");
1787 }
1788
1789 #[test]
1790 fn the_last_prune_record_survives_a_save_and_load() {
1791 // `restore --last-run` reads it out of a file written by a process that has
1792 // already exited, so the round trip is the whole feature.
1793 let dir = TempDir::new().unwrap();
1794 let path = test_registry_path(&dir);
1795
1796 let mut registry = Registry::default();
1797 registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
1798 registry.save_to(&path).unwrap();
1799
1800 let loaded = Registry::load_from(&path).unwrap();
1801 assert_eq!(loaded.last_prune, registry.last_prune);
1802 }
1803
1804 #[test]
1805 fn a_registry_written_before_the_field_existed_still_loads() {
1806 // The registry on disk predates `last_prune`; a missing key means "no pass
1807 // recorded", not a parse failure that would lock the user out of their config.
1808 let dir = TempDir::new().unwrap();
1809 let path = test_registry_path(&dir);
1810 fs::create_dir_all(path.parent().unwrap()).unwrap();
1811 fs::write(
1812 &path,
1813 r#"{"version":"1.0","settings":{"idle_days":15,"check_interval_days":2,
1814 "auto_daemon":true},"repositories":{}}"#,
1815 )
1816 .unwrap();
1817
1818 let loaded = Registry::load_from(&path).unwrap();
1819 assert_eq!(loaded.last_prune, None);
1820 }
1821
1822 #[test]
1823 fn a_leading_tilde_becomes_the_home_directory() {
1824 // The whole reason this exists: PowerShell hands `devp init ~/Code` straight
1825 // through, so without expansion the registry gains a repository at `.\~\Code`.
1826 let home = dirs::home_dir().expect("test host has a home directory");
1827
1828 assert_eq!(expand_tilde("~"), home.to_string_lossy());
1829 assert_eq!(
1830 expand_tilde("~/Code"),
1831 home.join("Code").to_string_lossy(),
1832 "forward slash, as typed in every shell"
1833 );
1834 assert_eq!(
1835 expand_tilde("~\\Code"),
1836 home.join("Code").to_string_lossy(),
1837 "backslash, as typed in PowerShell"
1838 );
1839 }
1840
1841 #[test]
1842 fn a_tilde_that_is_not_a_home_reference_is_left_alone() {
1843 // `~alice` is another user's home in shell syntax and cannot be resolved
1844 // portably; `~backup` and `./~tmp` are ordinary directory names. Rewriting any
1845 // of them would silently point the user at the wrong directory.
1846 for raw in ["~alice/Code", "~backup", "./~tmp", "Code~", "", "."] {
1847 assert_eq!(expand_tilde(raw), raw, "{raw} must survive untouched");
1848 }
1849 }
1850
1851 #[test]
1852 fn test_default_settings() {
1853 let settings = Settings::default();
1854 assert_eq!(settings.idle_days, 15);
1855 assert_eq!(settings.check_interval_days, 2);
1856 // On by default: dev-prune installs its own integrations, once per version,
1857 // and only the ones it finds missing.
1858 assert!(settings.auto_daemon);
1859 assert!(settings.auto_hooks);
1860 assert!(settings.auto_setup);
1861 }
1862
1863 #[test]
1864 fn settings_written_before_the_automation_toggles_existed_still_load() {
1865 // Real registries on disk predate `auto_hooks` / `auto_setup`; an upgrade must
1866 // read them rather than fail to parse and lose every registered repository.
1867 let json = r#"{
1868 "idle_days": 30,
1869 "check_interval_days": 2,
1870 "auto_daemon": false
1871 }"#;
1872 let settings: Settings = serde_json::from_str(json).unwrap();
1873 assert_eq!(settings.idle_days, 30);
1874 assert!(!settings.auto_daemon, "an explicit opt-out is preserved");
1875 assert!(settings.auto_hooks, "a missing key takes the default");
1876 assert!(settings.auto_setup);
1877 }
1878
1879 #[test]
1880 fn test_default_registry() {
1881 let registry = Registry::default();
1882 assert_eq!(registry.version, "1.0");
1883 assert_eq!(registry.settings, Settings::default());
1884 assert!(registry.repositories.is_empty());
1885 }
1886
1887 #[test]
1888 fn test_repo_entry_new() {
1889 let entry = RepoEntry::new();
1890 assert!(entry.enabled);
1891 assert!(entry.last_pruned_at.is_none());
1892 assert!(entry.override_idle_days.is_none());
1893 }
1894
1895 #[test]
1896 fn test_save_and_load() {
1897 let tmp = TempDir::new().unwrap();
1898 let path = test_registry_path(&tmp);
1899
1900 let mut registry = Registry::default();
1901 registry.add_repo(PathBuf::from("/test/repo"));
1902 registry.save_to(&path).unwrap();
1903
1904 let loaded = Registry::load_from(&path).unwrap();
1905 assert_eq!(loaded.repo_count(), 1);
1906 assert!(
1907 loaded
1908 .repositories
1909 .contains_key(&PathBuf::from("/test/repo"))
1910 );
1911 }
1912
1913 #[test]
1914 fn loading_a_missing_registry_yields_the_defaults_and_writes_nothing() {
1915 let tmp = TempDir::new().unwrap();
1916 let path = test_registry_path(&tmp);
1917
1918 let loaded = Registry::load_from(&path).unwrap();
1919 assert_eq!(loaded, Registry::default());
1920 // Reading is not writing. `devp --dry-run` and `devp status --json` both promise
1921 // to leave the disk alone, and both start by loading the registry.
1922 assert!(!path.exists(), "loading the registry created it");
1923 }
1924
1925 #[test]
1926 fn test_add_repo_returns_true_for_new() {
1927 let mut registry = Registry::default();
1928 assert!(registry.add_repo(PathBuf::from("/test/repo")));
1929 }
1930
1931 #[test]
1932 fn test_add_repo_returns_false_for_duplicate() {
1933 let mut registry = Registry::default();
1934 registry.add_repo(PathBuf::from("/test/repo"));
1935 assert!(!registry.add_repo(PathBuf::from("/test/repo")));
1936 }
1937
1938 #[test]
1939 fn test_remove_repo() {
1940 let mut registry = Registry::default();
1941 registry.add_repo(PathBuf::from("/test/repo"));
1942 assert!(registry.remove_repo(Path::new("/test/repo")));
1943 assert!(!registry.remove_repo(Path::new("/test/repo")));
1944 assert_eq!(registry.repo_count(), 0);
1945 }
1946
1947 /// macOS puts temp trees behind `/var` → `/private/var`, so a repo registered
1948 /// through the symlink is keyed under the real path — and once deleted, the
1949 /// symlinked spelling cannot be canonicalised whole. The lexical fallback must
1950 /// resolve the surviving parent, or unlink reports "not registered" for a
1951 /// directory the user is looking at in their own prompt.
1952 #[cfg(unix)]
1953 #[test]
1954 fn a_deleted_repo_named_through_a_symlinked_parent_still_unlinks() {
1955 let tmp = TempDir::new().unwrap();
1956 let real_parent = tmp.path().join("real");
1957 std::fs::create_dir(&real_parent).unwrap();
1958 let alias = tmp.path().join("alias");
1959 std::os::unix::fs::symlink(&real_parent, &alias).unwrap();
1960
1961 let repo = real_parent.join("repo");
1962 std::fs::create_dir(&repo).unwrap();
1963 let mut registry = Registry::default();
1964 registry.add_repo(alias.join("repo"));
1965 std::fs::remove_dir(&repo).unwrap();
1966
1967 assert!(registry.remove_repo(&alias.join("repo")));
1968 assert_eq!(registry.repo_count(), 0);
1969 }
1970
1971 #[test]
1972 fn test_mark_pruned() {
1973 let mut registry = Registry::default();
1974 registry.add_repo(PathBuf::from("/test/repo"));
1975 assert!(
1976 registry.repositories[&PathBuf::from("/test/repo")]
1977 .last_pruned_at
1978 .is_none()
1979 );
1980 registry.mark_pruned(Path::new("/test/repo"), 1024);
1981 assert!(
1982 registry.repositories[&PathBuf::from("/test/repo")]
1983 .last_pruned_at
1984 .is_some()
1985 );
1986 assert_eq!(registry.total_freed_bytes, 1024);
1987 // Not the pass counter — that is `record_prune`'s job, once per pass.
1988 assert_eq!(registry.total_pruned_count, 0);
1989 }
1990
1991 #[test]
1992 fn a_pass_is_counted_once_however_much_it_deleted() {
1993 // The counter is published as `prune_passes`, and it used to be incremented once
1994 // per repository by `devp run` and once per *directory* by the status dashboard,
1995 // so the same work produced a different number depending on where it started.
1996 let mut registry = Registry::default();
1997 registry.add_repo(PathBuf::from("/repo"));
1998
1999 registry.mark_pruned(Path::new("/repo"), 1024);
2000 registry.mark_pruned(Path::new("/repo"), 1024);
2001 registry.record_prune(vec![
2002 a_pruned_dir("node_modules"),
2003 a_pruned_dir("frontend/node_modules"),
2004 ]);
2005
2006 assert_eq!(registry.total_pruned_count, 1);
2007
2008 registry.record_prune(vec![a_pruned_dir("target")]);
2009 assert_eq!(registry.total_pruned_count, 2);
2010
2011 // A pass that deleted nothing is not a pass.
2012 registry.record_prune(Vec::new());
2013 assert_eq!(registry.total_pruned_count, 2);
2014 }
2015
2016 #[test]
2017 fn mark_pruned_credits_the_repo_under_its_canonical_key() {
2018 // On Windows, `canonicalize` yields a `\\?\`-prefixed path, so a registry keyed
2019 // by the canonical form and a `mark_pruned` looking up the raw form would miss —
2020 // growing the machine-wide total while the repository's own figure stayed zero.
2021 let tmp = TempDir::new().unwrap();
2022 let raw = tmp.path().to_path_buf();
2023
2024 let mut registry = Registry::default();
2025 registry.add_repo(raw.clone());
2026 registry.mark_pruned(&raw, 1024);
2027
2028 let entry = ®istry.repositories[&canonical_key(&raw)];
2029 assert_eq!(entry.total_freed_bytes, 1024);
2030 assert!(entry.last_pruned_at.is_some());
2031 assert_eq!(registry.total_freed_bytes, 1024);
2032 }
2033
2034 #[test]
2035 fn each_repository_accumulates_its_own_total() {
2036 // `devp stats` ranks repositories against each other, so the per-repo figure has
2037 // to be a running total and not the size of the most recent pass.
2038 let mut registry = Registry::default();
2039 registry.add_repo(PathBuf::from("/test/repo"));
2040 registry.add_repo(PathBuf::from("/test/other"));
2041
2042 registry.mark_pruned(Path::new("/test/repo"), 1024);
2043 registry.mark_pruned(Path::new("/test/repo"), 2048);
2044 registry.mark_pruned(Path::new("/test/other"), 512);
2045
2046 assert_eq!(
2047 registry.repositories[&PathBuf::from("/test/repo")].total_freed_bytes,
2048 3072
2049 );
2050 assert_eq!(
2051 registry.repositories[&PathBuf::from("/test/other")].total_freed_bytes,
2052 512
2053 );
2054 assert_eq!(registry.total_freed_bytes, 3584);
2055 }
2056
2057 #[test]
2058 fn the_prune_history_summarises_the_pass() {
2059 let mut registry = Registry::default();
2060 registry.record_prune(vec![
2061 a_pruned_dir("node_modules"),
2062 a_pruned_dir("frontend/node_modules"),
2063 ]);
2064
2065 let summary = registry.prune_history.last().expect("pass summarised");
2066 assert_eq!(summary.bytes_freed, 84);
2067 assert_eq!(summary.dirs_removed, 2);
2068 // Both fixtures live under `/repo`, so this is one repository, not two.
2069 assert_eq!(summary.repos_touched, 1);
2070 }
2071
2072 #[test]
2073 fn the_prune_history_is_capped_and_drops_the_oldest() {
2074 // The registry is rewritten in full on every save, so an uncapped list would grow
2075 // the file forever on a machine running the scheduled pass.
2076 let mut registry = Registry::default();
2077 for _ in 0..constants::PRUNE_HISTORY_LIMIT + 5 {
2078 registry.record_prune(vec![a_pruned_dir("node_modules")]);
2079 }
2080
2081 assert_eq!(registry.prune_history.len(), constants::PRUNE_HISTORY_LIMIT);
2082 let first = registry.prune_history.first().unwrap().at;
2083 let last = registry.prune_history.last().unwrap().at;
2084 assert!(first <= last, "oldest first");
2085 }
2086
2087 #[test]
2088 fn test_repo_count() {
2089 let mut registry = Registry::default();
2090 assert_eq!(registry.repo_count(), 0);
2091 registry.add_repo(PathBuf::from("/a"));
2092 registry.add_repo(PathBuf::from("/b"));
2093 assert_eq!(registry.repo_count(), 2);
2094 }
2095
2096 #[test]
2097 fn a_local_schema_uri_has_exactly_three_slashes_on_either_platform() {
2098 assert_eq!(
2099 file_uri("/home/dev/.config/dev-prune/bin/devprune.schema.json"),
2100 "file:///home/dev/.config/dev-prune/bin/devprune.schema.json"
2101 );
2102 assert_eq!(
2103 file_uri("C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"),
2104 "file:///C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"
2105 );
2106 }
2107
2108 #[test]
2109 fn a_broken_per_repo_config_is_an_error_rather_than_an_absent_one() {
2110 // The distinction the whole tool leans on: "no config" means take the defaults,
2111 // "unreadable config" means refuse — never overwrite, never prune on a guess.
2112 let tmp = TempDir::new().unwrap();
2113 let repo = tmp.path();
2114 assert_eq!(PerRepoConfig::load_with_diagnostics(repo), Ok(None));
2115
2116 fs::write(
2117 repo.join(constants::PER_REPO_CONFIG_FILE),
2118 r#"{ "ignore": true, }"#,
2119 )
2120 .unwrap();
2121 let err = PerRepoConfig::load_with_diagnostics(repo).unwrap_err();
2122 assert!(err.contains("Syntax error"), "{err}");
2123
2124 fs::write(
2125 repo.join(constants::PER_REPO_CONFIG_FILE),
2126 r#"{ "ignore": true }"#,
2127 )
2128 .unwrap();
2129 assert!(
2130 PerRepoConfig::load_with_diagnostics(repo)
2131 .unwrap()
2132 .unwrap()
2133 .ignore
2134 );
2135 }
2136
2137 /// A typo'd key must be pointed out and must not refuse the file: the same
2138 /// tolerance that lets a newer dev-prune's file load in an older one is what makes
2139 /// the typo silent everywhere else.
2140 #[test]
2141 fn a_typo_key_is_reported_but_never_refused() {
2142 let tmp = TempDir::new().unwrap();
2143 let repo = tmp.path();
2144 fs::write(
2145 repo.join(constants::PER_REPO_CONFIG_FILE),
2146 r#"{ "ignore": true, "idle_days": 30, "prunable": { "directores": [] } }"#,
2147 )
2148 .unwrap();
2149
2150 let cfg = PerRepoConfig::load_with_diagnostics(repo).unwrap().unwrap();
2151 assert!(cfg.ignore, "the keys the file spells right still apply");
2152
2153 let unknown: Vec<String> = PerRepoConfig::unknown_keys(repo)
2154 .into_iter()
2155 .map(|(_, k)| k)
2156 .collect();
2157 assert_eq!(unknown, vec!["idle_days", "prunable.directores"]);
2158 }
2159
2160 /// Drift guard: a field added to [`PerRepoConfig`] without extending the known-key
2161 /// list would make doctor warn about a key the tool itself wrote.
2162 #[test]
2163 fn every_key_the_type_serializes_is_a_known_key() {
2164 let tmp = TempDir::new().unwrap();
2165 let repo = tmp.path();
2166 let full = PerRepoConfig {
2167 project_name: Some("x".into()),
2168 ignore: true,
2169 disable_hooks: true,
2170 disable_daemon: true,
2171 override_idle_days: Some(1),
2172 min_size_mb: Some(1),
2173 scan_depth: Some(1),
2174 prunable: Some(Prunable {
2175 directories: vec![DeclaredDir {
2176 path: "scratch".into(),
2177 rebuild: "echo not needed".into(),
2178 why: Some("scratch".into()),
2179 }],
2180 exclude: vec!["dist".into()],
2181 }),
2182 ..PerRepoConfig::default()
2183 };
2184 fs::write(
2185 repo.join(constants::PER_REPO_CONFIG_FILE),
2186 serde_json::to_string_pretty(&full).unwrap(),
2187 )
2188 .unwrap();
2189 assert_eq!(PerRepoConfig::unknown_keys(repo), Vec::new());
2190 }
2191
2192 #[test]
2193 fn the_project_file_wins_the_keys_it_names_and_no_others() {
2194 let tmp = TempDir::new().unwrap();
2195 let repo = tmp.path();
2196
2197 // A team file that says one thing, and a personal file that says three.
2198 fs::write(
2199 repo.join(constants::PROJECT_REPO_CONFIG_FILE),
2200 r#"{ "ignore": true }"#,
2201 )
2202 .unwrap();
2203 fs::write(
2204 repo.join(constants::PER_REPO_CONFIG_FILE),
2205 r#"{ "ignore": false, "scan_depth": 12, "project_name": "mine" }"#,
2206 )
2207 .unwrap();
2208
2209 let cfg = PerRepoConfig::load_with_diagnostics(repo).unwrap().unwrap();
2210 assert!(cfg.ignore, "the committed file decides the key it names");
2211 assert_eq!(
2212 cfg.scan_depth,
2213 Some(12),
2214 "and decides nothing about the keys it does not"
2215 );
2216 assert_eq!(cfg.project_name.as_deref(), Some("mine"));
2217
2218 let layers = RepoConfigLayers::load(repo).unwrap();
2219 assert_eq!(layers.source_of("ignore"), ConfigSource::Project);
2220 assert_eq!(layers.source_of("scan_depth"), ConfigSource::Personal);
2221 assert_eq!(layers.source_of("min_size_mb"), ConfigSource::Default);
2222 }
2223
2224 #[test]
2225 fn a_serde_default_is_not_a_project_decision() {
2226 // The whole reason the key set is carried around. Serde fills `ignore` in as
2227 // `false` for a file that never mentioned it, and a merge that could not tell
2228 // those apart would have every project file silently un-ignoring repositories
2229 // its author had said nothing about.
2230 let tmp = TempDir::new().unwrap();
2231 let repo = tmp.path();
2232 fs::write(
2233 repo.join(constants::PROJECT_REPO_CONFIG_FILE),
2234 r#"{ "scan_depth": 3 }"#,
2235 )
2236 .unwrap();
2237 fs::write(
2238 repo.join(constants::PER_REPO_CONFIG_FILE),
2239 r#"{ "ignore": true }"#,
2240 )
2241 .unwrap();
2242
2243 let cfg = PerRepoConfig::load_with_diagnostics(repo).unwrap().unwrap();
2244 assert!(cfg.ignore);
2245 assert_eq!(cfg.scan_depth, Some(3));
2246 }
2247
2248 #[test]
2249 fn a_broken_project_file_is_named_and_never_healed_in_place() {
2250 let tmp = TempDir::new().unwrap();
2251 let repo = tmp.path();
2252 fs::write(
2253 repo.join(constants::PROJECT_REPO_CONFIG_FILE),
2254 r#"{ "ignore": true, }"#,
2255 )
2256 .unwrap();
2257
2258 // Same refusal as a broken personal file: nothing reads a config it cannot
2259 // parse, whichever file it was in.
2260 assert!(
2261 PerRepoConfig::load_with_diagnostics(repo)
2262 .unwrap_err()
2263 .contains("Syntax error")
2264 );
2265
2266 // But the repair path has to know which file, because one of them is tracked.
2267 let broken = PerRepoConfig::broken_files(repo);
2268 assert_eq!(broken.len(), 1);
2269 assert_eq!(broken[0].0, constants::PROJECT_REPO_CONFIG_FILE);
2270 }
2271
2272 #[test]
2273 fn a_new_project_file_is_visible_to_git_and_decides_nothing() {
2274 let tmp = TempDir::new().unwrap();
2275 let repo = tmp.path();
2276 fs::create_dir_all(repo.join(".git").join("info")).unwrap();
2277 fs::write(
2278 repo.join(constants::PER_REPO_CONFIG_FILE),
2279 r#"{ "ignore": true, "scan_depth": 9 }"#,
2280 )
2281 .unwrap();
2282
2283 write_project_starter(repo).unwrap();
2284
2285 // `save_to_repo` hides what it writes; this one must not, or the file is a
2286 // per-machine file with a misleading name.
2287 let exclude = repo.join(".git").join("info").join("exclude");
2288 let listed = fs::read_to_string(&exclude).unwrap_or_default();
2289 assert!(
2290 !listed.contains(constants::PROJECT_REPO_CONFIG_FILE),
2291 "the committed file must never be excluded: {listed}"
2292 );
2293
2294 // And creating it must not have quietly taken over the file beside it. A
2295 // serialized `PerRepoConfig::default()` would name all seven keys and therefore
2296 // win all seven.
2297 let layers = RepoConfigLayers::load(repo).unwrap();
2298 assert_eq!(layers.source_of("ignore"), ConfigSource::Personal);
2299 let cfg = layers.effective().unwrap();
2300 assert!(cfg.ignore);
2301 assert_eq!(cfg.scan_depth, Some(9));
2302
2303 // The empty section is written so it can be seen and filled in, which means it
2304 // has to be inert until somebody fills it in.
2305 assert!(cfg.prunable.is_none(), "an empty list declares nothing");
2306 }
2307
2308 #[test]
2309 fn the_starter_schema_is_the_public_url_never_a_machine_path() {
2310 let tmp = TempDir::new().unwrap();
2311 let repo = tmp.path();
2312 fs::create_dir_all(repo.join(".git").join("info")).unwrap();
2313
2314 write_project_starter(repo).unwrap();
2315
2316 let written = fs::read_to_string(repo.join(constants::PROJECT_REPO_CONFIG_FILE)).unwrap();
2317 let parsed: serde_json::Value = serde_json::from_str(&written).unwrap();
2318 // The personal file prefers the locally installed schema copy — right for a
2319 // git-excluded file, and a username leak in a committed one.
2320 assert_eq!(parsed["$schema"], constants::JSON_SCHEMA_URL);
2321 assert!(!written.contains("file://"), "{written}");
2322 }
2323
2324 #[test]
2325 fn a_write_back_never_copies_the_project_answer_into_the_personal_file() {
2326 // The drift this feature would otherwise create: `devp config --update` and the
2327 // workspace toggles all read-modify-write `.devprune.json`, and a merged read
2328 // would bake the team's value into one person's file, where it outlives the
2329 // next edit to the file it came from.
2330 let tmp = TempDir::new().unwrap();
2331 let repo = tmp.path();
2332 fs::write(
2333 repo.join(constants::PROJECT_REPO_CONFIG_FILE),
2334 r#"{ "ignore": true }"#,
2335 )
2336 .unwrap();
2337 fs::write(
2338 repo.join(constants::PER_REPO_CONFIG_FILE),
2339 r#"{ "scan_depth": 4 }"#,
2340 )
2341 .unwrap();
2342
2343 let personal = PerRepoConfig::load_personal_for_write(repo)
2344 .unwrap()
2345 .unwrap();
2346 assert!(!personal.ignore, "the project answer must not travel");
2347 assert_eq!(personal.scan_depth, Some(4));
2348 }
2349
2350 #[test]
2351 fn a_personal_exclusion_vetoes_a_declaration_the_project_committed() {
2352 // The conflict the key exists for: the committed file says `scratch` is
2353 // rebuildable, and on this one machine `scratch` is holding something. The way
2354 // out must not be editing a file the whole team shares.
2355 let tmp = TempDir::new().unwrap();
2356 let repo = tmp.path();
2357 fs::write(
2358 repo.join(constants::PROJECT_REPO_CONFIG_FILE),
2359 r#"{ "prunable": { "directories": [
2360 { "path": "scratch", "rebuild": "make scratch" }
2361 ] } }"#,
2362 )
2363 .unwrap();
2364 fs::write(
2365 repo.join(constants::PER_REPO_CONFIG_FILE),
2366 r#"{ "prunable": { "exclude": ["scratch"] } }"#,
2367 )
2368 .unwrap();
2369
2370 let prunable = PerRepoConfig::load_with_diagnostics(repo)
2371 .unwrap()
2372 .unwrap()
2373 .prunable
2374 .unwrap();
2375
2376 // The declaration survives the merge and is vetoed when it is resolved, so
2377 // deleting the exclusion later puts the directory back in play without anyone
2378 // having to re-declare it.
2379 assert_eq!(prunable.directories.len(), 1);
2380 assert_eq!(prunable.exclude, ["scratch"]);
2381 }
2382
2383 #[test]
2384 fn declarations_from_both_files_add_up_rather_than_one_silencing_the_other() {
2385 let tmp = TempDir::new().unwrap();
2386 let repo = tmp.path();
2387 fs::write(
2388 repo.join(constants::PROJECT_REPO_CONFIG_FILE),
2389 r#"{ "prunable": { "directories": [
2390 { "path": "tools/vendor", "rebuild": "make vendor" },
2391 { "path": ".cache/shared", "rebuild": "make cache" }
2392 ] } }"#,
2393 )
2394 .unwrap();
2395 fs::write(
2396 repo.join(constants::PER_REPO_CONFIG_FILE),
2397 r#"{ "prunable": { "directories": [
2398 { "path": ".cache/shared", "rebuild": "an old script I wrote" },
2399 { "path": "scratch", "rebuild": "make scratch" }
2400 ] } }"#,
2401 )
2402 .unwrap();
2403
2404 let dirs = PerRepoConfig::load_with_diagnostics(repo)
2405 .unwrap()
2406 .unwrap()
2407 .prunable
2408 .unwrap()
2409 .directories;
2410
2411 // Every key above this one is decided by one file or the other. A list is not a
2412 // decision, so nobody's entry is dropped for having been written by the wrong
2413 // person.
2414 let paths: Vec<&str> = dirs.iter().map(|d| d.path.as_str()).collect();
2415 assert_eq!(paths, ["tools/vendor", ".cache/shared", "scratch"]);
2416
2417 // One path is still one directory, and the committed answer is the current one.
2418 assert_eq!(dirs[1].rebuild, "make cache");
2419 }
2420
2421 #[test]
2422 fn test_serialization_roundtrip() {
2423 let mut registry = Registry::default();
2424 registry.settings.idle_days = 30;
2425 registry.add_repo(PathBuf::from("/test/repo"));
2426
2427 let json = serde_json::to_string_pretty(®istry).unwrap();
2428 let deserialized: Registry = serde_json::from_str(&json).unwrap();
2429 assert_eq!(registry.settings.idle_days, deserialized.settings.idle_days);
2430 assert_eq!(registry.repo_count(), deserialized.repo_count());
2431 }
2432
2433 #[test]
2434 fn test_atomic_save_leaves_no_tmp() {
2435 let tmp = TempDir::new().unwrap();
2436 let path = test_registry_path(&tmp);
2437
2438 let registry = Registry::default();
2439 registry.save_to(&path).unwrap();
2440
2441 assert!(path.exists());
2442 // Nothing but the registry itself may remain — a leftover `*.tmp` would mean the
2443 // rename never happened.
2444 let leftovers: Vec<_> = fs::read_dir(path.parent().unwrap())
2445 .unwrap()
2446 .flatten()
2447 .filter(|e| e.path() != path)
2448 .collect();
2449 assert!(leftovers.is_empty(), "leftover files: {leftovers:?}");
2450 }
2451
2452 #[test]
2453 fn exclude_entry_lands_in_git_info_exclude_not_gitignore() {
2454 let tmp = TempDir::new().unwrap();
2455 let repo = tmp.path();
2456 fs::create_dir(repo.join(".git")).unwrap();
2457
2458 ensure_in_git_exclude(repo, ".devprune.json").unwrap();
2459
2460 let exclude = fs::read_to_string(repo.join(".git/info/exclude")).unwrap();
2461 assert!(exclude.lines().any(|l| l == ".devprune.json"));
2462 // The whole point of using the exclude file: the shared, tracked `.gitignore`
2463 // must never be created or touched.
2464 assert!(!repo.join(".gitignore").exists());
2465 }
2466
2467 #[test]
2468 fn exclude_entry_is_appended_once_and_preserves_existing_lines() {
2469 let tmp = TempDir::new().unwrap();
2470 let repo = tmp.path();
2471 fs::create_dir_all(repo.join(".git/info")).unwrap();
2472 // No trailing newline, deliberately — the append must not glue two entries
2473 // onto one line.
2474 fs::write(repo.join(".git/info/exclude"), "*.log").unwrap();
2475
2476 ensure_in_git_exclude(repo, ".devprune.json").unwrap();
2477 ensure_in_git_exclude(repo, ".devprune.json").unwrap();
2478
2479 let exclude = fs::read_to_string(repo.join(".git/info/exclude")).unwrap();
2480 let lines: Vec<_> = exclude.lines().collect();
2481 assert_eq!(lines, vec!["*.log", ".devprune.json"]);
2482 }
2483
2484 #[test]
2485 fn exclude_follows_a_gitdir_pointer_file() {
2486 // Worktrees and submodules have a one-line `.git` *file*, and a worktree's
2487 // private gitdir points at the shared one via `commondir` — where the real
2488 // `info/exclude` lives.
2489 let tmp = TempDir::new().unwrap();
2490 let shared = tmp.path().join("main-clone/.git");
2491 let worktree_gitdir = shared.join("worktrees/wt");
2492 fs::create_dir_all(&worktree_gitdir).unwrap();
2493 fs::write(worktree_gitdir.join("commondir"), "../..\n").unwrap();
2494
2495 let wt = tmp.path().join("wt");
2496 fs::create_dir(&wt).unwrap();
2497 fs::write(
2498 wt.join(".git"),
2499 format!("gitdir: {}\n", worktree_gitdir.display()),
2500 )
2501 .unwrap();
2502
2503 ensure_in_git_exclude(&wt, ".devprune.json").unwrap();
2504
2505 let exclude = fs::read_to_string(shared.join("info/exclude")).unwrap();
2506 assert!(exclude.lines().any(|l| l == ".devprune.json"));
2507 }
2508
2509 #[test]
2510 fn exclude_is_a_no_op_outside_a_git_repository() {
2511 let tmp = TempDir::new().unwrap();
2512
2513 ensure_in_git_exclude(tmp.path(), ".devprune.json").unwrap();
2514
2515 assert!(!tmp.path().join(".git").exists());
2516 assert!(!tmp.path().join(".gitignore").exists());
2517 }
2518 /// A repository that moved is recognised, and arrives with everything it had earned.
2519 #[test]
2520 fn adopt_moved_entry_transfers_history() {
2521 let mut reg = Registry::default();
2522 let old = PathBuf::from("/nowhere/old-home/project");
2523 let mut entry = RepoEntry::new();
2524 entry.identity = Some("abc1234def".into());
2525 entry.total_freed_bytes = 4096;
2526 entry.enabled = false;
2527 entry.override_idle_days = Some(90);
2528 reg.repositories.insert(old.clone(), entry);
2529
2530 let new = std::env::temp_dir().join("devprune-adopt-live");
2531 reg.repositories.insert(new.clone(), RepoEntry::new());
2532
2533 let outcome = reg.adopt_moved_entry(&new, Some("abc1234def".into()));
2534 assert_eq!(outcome, Adoption::Moved(old.clone()));
2535 assert!(!reg.repositories.contains_key(&old));
2536
2537 let moved = ®.repositories[&canonical_key(&new)];
2538 assert_eq!(moved.total_freed_bytes, 4096);
2539 // A repository the user had switched off did not switch itself back on by
2540 // being moved.
2541 assert!(!moved.enabled);
2542 assert_eq!(moved.override_idle_days, Some(90));
2543 assert_eq!(moved.identity.as_deref(), Some("abc1234def"));
2544 }
2545
2546 /// Two dead entries with one root commit are clones, not a move. Nothing is guessed.
2547 #[test]
2548 fn adopt_moved_entry_refuses_to_guess_between_two() {
2549 let mut reg = Registry::default();
2550 for name in ["/nowhere/a", "/nowhere/b"] {
2551 let mut entry = RepoEntry::new();
2552 entry.identity = Some("shared".into());
2553 reg.repositories.insert(PathBuf::from(name), entry);
2554 }
2555 let new = std::env::temp_dir().join("devprune-adopt-ambiguous");
2556 reg.repositories.insert(new.clone(), RepoEntry::new());
2557
2558 assert_eq!(
2559 reg.adopt_moved_entry(&new, Some("shared".into())),
2560 Adoption::Ambiguous
2561 );
2562 assert_eq!(reg.repositories.len(), 3);
2563 // The identity is still recorded, so the next registration can recognise it
2564 // once the duplicates are cleared.
2565 assert_eq!(
2566 reg.repositories[&canonical_key(&new)].identity.as_deref(),
2567 Some("shared")
2568 );
2569 }
2570
2571 /// An entry whose path still exists is not a move, however matching its history.
2572 #[test]
2573 fn adopt_moved_entry_never_takes_from_a_live_path() {
2574 let dir = tempfile::tempdir().unwrap();
2575 let live = dir.path().join("live");
2576 std::fs::create_dir(&live).unwrap();
2577
2578 let mut reg = Registry::default();
2579 let mut entry = RepoEntry::new();
2580 entry.identity = Some("same".into());
2581 entry.total_freed_bytes = 999;
2582 reg.repositories.insert(canonical_key(&live), entry);
2583
2584 let other = dir.path().join("other");
2585 std::fs::create_dir(&other).unwrap();
2586 reg.repositories
2587 .insert(canonical_key(&other), RepoEntry::new());
2588
2589 assert_eq!(
2590 reg.adopt_moved_entry(&other, Some("same".into())),
2591 Adoption::Nothing
2592 );
2593 assert_eq!(
2594 reg.repositories[&canonical_key(&live)].total_freed_bytes,
2595 999
2596 );
2597 }
2598
2599 /// A repository with no commits has no identity, so nothing is adopted and nothing
2600 /// is recorded — a guess would be worse than the dead entry it replaced.
2601 #[test]
2602 fn adopt_moved_entry_ignores_a_missing_identity() {
2603 let mut reg = Registry::default();
2604 let mut entry = RepoEntry::new();
2605 entry.identity = Some("orphan".into());
2606 reg.repositories
2607 .insert(PathBuf::from("/nowhere/gone"), entry);
2608 let new = std::env::temp_dir().join("devprune-adopt-unborn");
2609 reg.repositories.insert(new.clone(), RepoEntry::new());
2610
2611 assert_eq!(reg.adopt_moved_entry(&new, None), Adoption::Nothing);
2612 assert_eq!(reg.repositories.len(), 2);
2613 assert!(reg.needs_identity(&new));
2614 }
2615
2616 #[test]
2617 fn a_restore_too_quick_to_be_real_teaches_nothing() {
2618 // A manager that found everything still in its cache returns in a moment. Folding
2619 // that into the average would claim a throughput no cold restore can reach, and
2620 // the estimate exists precisely to describe a cold one.
2621 let mut reg = Registry::default();
2622 reg.record_restore("npm", 500_000_000, 10);
2623 reg.record_restore("npm", 0, 60_000);
2624 assert!(reg.restore_rates.is_empty(), "{:?}", reg.restore_rates);
2625
2626 reg.record_restore("npm", 500_000_000, 60_000);
2627 assert_eq!(reg.restore_rates["npm"].samples, 1);
2628 }
2629
2630 #[test]
2631 fn the_average_forgets_the_disk_the_machine_no_longer_has() {
2632 let mut reg = Registry::default();
2633 for _ in 0..constants::RESTORE_RATE_SAMPLE_CAP {
2634 reg.record_restore("npm", 1_000_000, 1_000);
2635 }
2636 assert_eq!(
2637 reg.restore_rates["npm"].samples,
2638 constants::RESTORE_RATE_SAMPLE_CAP
2639 );
2640
2641 // The cap is a halving, not a ceiling: the next sample still lands, on top of
2642 // half of what came before.
2643 reg.record_restore("npm", 1_000_000, 1_000);
2644 let rate = ®.restore_rates["npm"];
2645 assert_eq!(rate.samples, constants::RESTORE_RATE_SAMPLE_CAP / 2 + 1);
2646 assert!(rate.bytes_per_sec().is_some());
2647 }
2648
2649 #[test]
2650 fn an_estimate_with_nothing_measured_is_not_offered() {
2651 // Never a zero and never a guess: a machine that has not restored anything yet
2652 // has no honest answer to "how long is this to undo", so it does not print one.
2653 let reg = Registry::default();
2654 assert!(reg.estimate_restore(&[("npm".into(), 1_000_000)]).is_none());
2655 }
2656
2657 #[test]
2658 fn an_untimed_adapter_is_left_out_of_the_coverage() {
2659 // Half an answer, reported as half. Counting cargo's bytes at npm's speed would
2660 // be the one thing worse than saying nothing.
2661 let mut reg = Registry::default();
2662 reg.record_restore("npm", 10_000_000, 10_000);
2663 let (secs, covered) = reg
2664 .estimate_restore(&[("npm".into(), 10_000_000), ("cargo".into(), 90_000_000)])
2665 .expect("npm alone is enough to answer for npm");
2666 assert_eq!(covered, 10_000_000, "cargo has never been timed here");
2667 assert!((secs - 10.0).abs() < 0.01, "{secs}");
2668 }
2669}