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