Skip to main content

dev_prune/
config.rs

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