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