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