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}
228
229fn default_build_idle_days() -> u64 {
230    constants::DEFAULT_BUILD_IDLE_DAYS
231}
232
233fn default_require_confirmation() -> bool {
234    constants::DEFAULT_REQUIRE_CONFIRMATION
235}
236
237fn default_command_timeout_secs() -> u64 {
238    constants::DEFAULT_COMMAND_TIMEOUT_SECS
239}
240
241fn default_auto_hooks() -> bool {
242    constants::DEFAULT_AUTO_HOOKS
243}
244
245fn default_auto_setup() -> bool {
246    constants::DEFAULT_AUTO_SETUP
247}
248
249fn default_auto_config() -> bool {
250    constants::DEFAULT_AUTO_CONFIG
251}
252
253fn default_update_check() -> bool {
254    constants::DEFAULT_UPDATE_CHECK
255}
256
257fn default_auto_update() -> bool {
258    constants::DEFAULT_AUTO_UPDATE
259}
260
261fn default_min_size_mb() -> u64 {
262    constants::DEFAULT_MIN_SIZE_MB
263}
264
265fn default_scan_depth() -> usize {
266    constants::DEFAULT_SCAN_DEPTH
267}
268
269fn default_allow_manifest_rewrite() -> bool {
270    constants::DEFAULT_ALLOW_MANIFEST_REWRITE
271}
272
273fn default_update_check_interval_days() -> i64 {
274    constants::UPDATE_CHECK_INTERVAL_DAYS
275}
276
277fn default_update_check_timeout_secs() -> u64 {
278    constants::UPDATE_CHECK_TIMEOUT_SECS
279}
280
281fn default_auto_hooks_chain() -> bool {
282    constants::DEFAULT_AUTO_HOOKS_CHAIN
283}
284
285impl Default for Settings {
286    fn default() -> Self {
287        Self {
288            idle_days: constants::DEFAULT_IDLE_DAYS,
289            check_interval_days: constants::DEFAULT_CHECK_INTERVAL_DAYS,
290            auto_daemon: constants::DEFAULT_AUTO_DAEMON,
291            auto_hooks: constants::DEFAULT_AUTO_HOOKS,
292            auto_setup: constants::DEFAULT_AUTO_SETUP,
293            auto_config: constants::DEFAULT_AUTO_CONFIG,
294            require_confirmation: constants::DEFAULT_REQUIRE_CONFIRMATION,
295            command_timeout_secs: constants::DEFAULT_COMMAND_TIMEOUT_SECS,
296            min_size_mb: constants::DEFAULT_MIN_SIZE_MB,
297            update_check: constants::DEFAULT_UPDATE_CHECK,
298            scan_depth: constants::DEFAULT_SCAN_DEPTH,
299            allow_manifest_rewrite: constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
300            update_check_interval_days: constants::UPDATE_CHECK_INTERVAL_DAYS,
301            update_check_timeout_secs: constants::UPDATE_CHECK_TIMEOUT_SECS,
302            auto_hooks_chain: constants::DEFAULT_AUTO_HOOKS_CHAIN,
303            enable_cargo: false,
304            enable_gradle: false,
305            enable_maven: false,
306            enable_swift: false,
307            enable_dart: false,
308            enable_mix_build: false,
309            enable_vcpkg: false,
310            enable_cmake_build: false,
311            build_idle_days: constants::DEFAULT_BUILD_IDLE_DAYS,
312            auto_update: constants::DEFAULT_AUTO_UPDATE,
313            version_lock: constants::DEFAULT_VERSION_LOCK,
314            disabled_adapters: Vec::new(),
315            adapter_idle_days: BTreeMap::new(),
316            cache_max_gb: BTreeMap::new(),
317        }
318    }
319}
320
321/// Outcome of recording a repository's identity when it was registered.
322///
323/// Reported rather than silent: a registration that quietly absorbed another entry's
324/// prune history would be indistinguishable from one that lost it.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub enum Adoption {
327    /// No dead entry claimed this identity.
328    Nothing,
329    /// This registration took over the history of a path that no longer exists.
330    Moved(PathBuf),
331    /// More than one dead entry claims the identity, so none was chosen.
332    Ambiguous,
333}
334
335/// Metadata for a single registered repository.
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
337pub struct RepoEntry {
338    /// Timestamp when the repo was added to the registry.
339    pub added_at: DateTime<Utc>,
340    /// Timestamp of the last successful prune, if any.
341    pub last_pruned_at: Option<DateTime<Utc>>,
342    /// Per-repo override for idle days (overrides global setting).
343    pub override_idle_days: Option<u64>,
344    /// Whether this repo is enabled for pruning.
345    pub enabled: bool,
346    /// Cumulative bytes reclaimed from this repository.
347    ///
348    /// Recorded from 1.1.0 onward. Registries written by 1.0.0 have no such figure and
349    /// deserialize to zero, so `devp stats` says where the number starts rather than
350    /// implying a repository pruned last March never freed anything.
351    #[serde(default)]
352    pub total_freed_bytes: u64,
353    /// The repository's root commit, recorded when it was registered.
354    ///
355    /// A repository that is moved keeps this; its path does not. Without it a moved
356    /// workspace registers as a brand new repository and its prune history is stranded
357    /// on a path that will never exist again. Registries written before 1.4.0 have none,
358    /// and re-registering the repository is what fills it in.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub identity: Option<String>,
361}
362
363impl RepoEntry {
364    /// Creates a new `RepoEntry` with the current timestamp.
365    pub fn new() -> Self {
366        Self {
367            added_at: Utc::now(),
368            last_pruned_at: None,
369            override_idle_days: None,
370            enabled: true,
371            total_freed_bytes: 0,
372            identity: None,
373        }
374    }
375}
376
377impl Default for RepoEntry {
378    fn default() -> Self {
379        Self::new()
380    }
381}
382
383/// Resolve where a repository's shared git directory actually lives.
384///
385/// `.git` is a directory in an ordinary clone, but in worktrees and submodules it is a
386/// one-line `gitdir: <path>` pointer file — and a worktree's private gitdir in turn
387/// holds a `commondir` file pointing at the shared one, which is where `info/exclude`
388/// lives. Returns `None` when the path is not inside a git repository at all.
389fn git_common_dir(repo_path: &Path) -> Option<PathBuf> {
390    let dot_git = repo_path.join(".git");
391    let git_dir = if dot_git.is_dir() {
392        dot_git
393    } else {
394        let pointer = fs::read_to_string(&dot_git).ok()?;
395        let target = pointer.strip_prefix("gitdir:")?.trim();
396        let target = Path::new(target);
397        if target.is_absolute() {
398            target.to_path_buf()
399        } else {
400            repo_path.join(target)
401        }
402    };
403    if let Ok(common) = fs::read_to_string(git_dir.join("commondir")) {
404        let target = Path::new(common.trim());
405        if target.is_absolute() {
406            return Some(target.to_path_buf());
407        }
408        return Some(git_dir.join(target));
409    }
410    Some(git_dir)
411}
412
413/// Ensure an entry (e.g. ".devprune.json") is in the repository's `.git/info/exclude`.
414///
415/// The exclude file, not `.gitignore`: the config records one machine's preferences,
416/// and `.gitignore` is a tracked file shared by everyone who clones the repository —
417/// appending to it silently puts an uncommitted change in the user's diff. The exclude
418/// file gives the same "never shows up in `git status`" result without touching
419/// anything the repository tracks.
420pub fn ensure_in_git_exclude(repo_path: &Path, entry: &str) -> Result<()> {
421    let Some(git_dir) = git_common_dir(repo_path) else {
422        return Ok(());
423    };
424    let info_dir = git_dir.join("info");
425    fs::create_dir_all(&info_dir)?;
426    let exclude_path = info_dir.join("exclude");
427    if exclude_path.exists() {
428        let content = fs::read_to_string(&exclude_path)?;
429        if !content.lines().any(|line| line.trim() == entry) {
430            let mut file = fs::OpenOptions::new().append(true).open(&exclude_path)?;
431            let prefix = if content.ends_with('\n') || content.is_empty() {
432                ""
433            } else {
434                "\n"
435            };
436            writeln!(file, "{prefix}{entry}")?;
437        }
438    } else {
439        fs::write(&exclude_path, format!("{entry}\n"))?;
440    }
441    Ok(())
442}
443
444/// Normalise a repository path into the form used as a registry key.
445///
446/// Falls back to the path as given when it cannot be canonicalised (e.g. it no longer
447/// exists), so entries for deleted repos stay addressable.
448pub fn canonical_key(path: &Path) -> PathBuf {
449    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
450}
451
452/// Resolve `.` and `..` segments and anchor a relative path to the working directory,
453/// for paths that no longer exist and so cannot be canonicalised whole. The deepest
454/// ancestor that still exists is canonicalised and the missing tail re-appended:
455/// registry keys are canonical, and a deleted repo named through a symlinked parent —
456/// macOS's `/var` → `/private/var` temp tree being the everyday case — would otherwise
457/// spell the same directory through a different root and never compare equal.
458fn lexical_absolute(path: &Path) -> PathBuf {
459    use std::path::Component;
460    let mut out = if path.is_absolute() {
461        PathBuf::new()
462    } else {
463        std::env::current_dir().unwrap_or_default()
464    };
465    for comp in path.components() {
466        match comp {
467            Component::CurDir => {}
468            Component::ParentDir => {
469                out.pop();
470            }
471            other => out.push(other.as_os_str()),
472        }
473    }
474    let mut prefix = out.as_path();
475    while !prefix.as_os_str().is_empty() {
476        if let Ok(real) = prefix.canonicalize() {
477            if let Ok(tail) = out.strip_prefix(prefix) {
478                return real.join(tail);
479            }
480            break;
481        }
482        match prefix.parent() {
483            Some(parent) => prefix = parent,
484            None => break,
485        }
486    }
487    out
488}
489
490/// Whether two paths name the same directory, tolerating the differences
491/// canonicalisation normally absorbs: the Windows `\\?\` prefix, separator style,
492/// trailing separators, and case on Windows.
493fn loose_path_eq(a: &Path, b: &Path) -> bool {
494    let norm = |p: &Path| {
495        let s = p.to_string_lossy().replace('\\', "/");
496        let s = s.strip_prefix("//?/").unwrap_or(&s);
497        let s = s.trim_end_matches('/').to_string();
498        if cfg!(windows) { s.to_lowercase() } else { s }
499    };
500    norm(a) == norm(b)
501}
502
503/// Expand a leading `~` to the user's home directory.
504///
505/// POSIX shells do this before the argument ever reaches a program, so on Linux and
506/// macOS it is usually a no-op. PowerShell and cmd do not: they hand a native
507/// executable the literal three characters `~/C`, and `devp init ~/Code` — the exact
508/// line in the README and on the landing page — would register a directory called `~`
509/// sitting in the current working directory. Quoting defeats the expansion in *every*
510/// shell, so `devp init "~/Code"` needs this too.
511///
512/// Only a bare `~` or a `~` followed by a separator is expanded. `~alice` means "some
513/// other user's home" in shell syntax and cannot be resolved portably, and `~backup` is
514/// a perfectly ordinary directory name.
515pub fn expand_tilde(raw: &str) -> String {
516    let Some(rest) = raw.strip_prefix('~') else {
517        return raw.to_string();
518    };
519    if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\')) {
520        return raw.to_string();
521    }
522    let Some(home) = dirs::home_dir() else {
523        // No home directory to expand to. Handing back the literal `~` lets the caller
524        // fail with "no such directory", which is a better error than a silent guess.
525        return raw.to_string();
526    };
527    if rest.is_empty() {
528        return home.to_string_lossy().into_owned();
529    }
530    home.join(rest.trim_start_matches(['/', '\\']))
531        .to_string_lossy()
532        .into_owned()
533}
534
535/// Structured per-repository configuration file stored inside repo roots as `.devprune.json`.
536#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
537pub struct PerRepoConfig {
538    /// JSON Schema reference URL for IDE IntelliSense and validation.
539    #[serde(rename = "$schema", default = "default_schema_url")]
540    pub schema: String,
541    /// Custom display name for this project in TUI and CLI status views.
542    #[serde(default)]
543    pub project_name: Option<String>,
544    /// Whether this repository is ignored/excluded from pruning.
545    #[serde(default)]
546    pub ignore: bool,
547    /// Disable global Git auto-registration hooks for this specific workspace.
548    #[serde(default)]
549    pub disable_hooks: bool,
550    /// Disable background daemon automated pruning pass for this specific workspace.
551    #[serde(default)]
552    pub disable_daemon: bool,
553    /// Custom override for idle days threshold (overrides global settings).
554    #[serde(default)]
555    pub override_idle_days: Option<u64>,
556    /// Custom override for the size floor, in MiB (overrides global `min_size_mb`).
557    ///
558    /// `Some(0)` is a meaningful value: it turns the floor off for this repository even
559    /// when a global floor is set.
560    #[serde(default)]
561    pub min_size_mb: Option<u64>,
562    /// Custom override for how deep discovery walks this repository.
563    ///
564    /// The setting that most often needs to differ per repository rather than globally:
565    /// one deeply-nested monorepo should not make every other repository pay for a
566    /// deeper walk. Clamped to [`constants::MAX_SCAN_DEPTH_LIMIT`] like the global one.
567    #[serde(default)]
568    pub scan_depth: Option<usize>,
569}
570
571// Deliberately absent: `allow_manifest_rewrite`.
572//
573// Only the settings whose right value depends on the *project* have a per-repository
574// form. `allow_manifest_rewrite` is a permission the user grants their own machine, and
575// — exactly as with `post_prune_command` below — nothing stops a project from committing
576// its `.devprune.json`: the `.git/info/exclude` entry [`PerRepoConfig::save_to_repo`]
577// writes is local to one clone and excludes nothing already tracked. A
578// per-repository form would therefore let a repository nobody has read grant itself the
579// right to have `cargo generate-lockfile` / `go mod tidy` rewrite its tracked manifests
580// during an unattended pass. The `auto_*` and `update_check*` settings describe the
581// machine rather than a project and would mean nothing here either.
582
583// Removed: `custom_bloat_dirs` and `post_prune_command`.
584//
585// Both were serialized, schema'd and documented but never read by any code path, so
586// setting them did nothing. `post_prune_command` is also not a feature that should be
587// reintroduced casually: nothing stops a project from committing its `.devprune.json`,
588// so honouring it would mean cloning an untrusted repository and running `devp` hands
589// that repository arbitrary code execution on the user's machine.
590
591fn default_schema_url() -> String {
592    if let Ok(config_dir) = Registry::config_dir() {
593        let local_schema = config_dir.join("bin").join("devprune.schema.json");
594        if local_schema.exists() {
595            // `file://` + `/` + an absolute path. Unix paths already start with a
596            // separator, so pasting them in unconditionally produced `file:////home/...`
597            // — four slashes, which editors reject, leaving the `$schema` link dead and
598            // no IntelliSense at all on the platform where most of them run.
599            return file_uri(&crate::output::clean_path(&local_schema));
600        }
601    }
602    constants::JSON_SCHEMA_URL.to_string()
603}
604
605/// A `file://` URI for an absolute path.
606///
607/// `file://` + `/` + the path. Unix paths already start with a separator, so pasting one
608/// in unconditionally produced `file:////home/...` — four slashes, which editors reject,
609/// leaving the `$schema` link dead and no IntelliSense at all on the platform where most
610/// of them run.
611fn file_uri(clean_path: &str) -> String {
612    format!("file:///{}", clean_path.trim_start_matches('/'))
613}
614
615impl Default for PerRepoConfig {
616    fn default() -> Self {
617        Self {
618            schema: default_schema_url(),
619            project_name: None,
620            ignore: false,
621            disable_hooks: false,
622            disable_daemon: false,
623            override_idle_days: None,
624            min_size_mb: None,
625            scan_depth: None,
626        }
627    }
628}
629
630impl PerRepoConfig {
631    /// Load per-repo config from `.devprune.json`, or `None` when there is no such file.
632    ///
633    /// This is the only loader. There used to be a second one that returned `None` for a
634    /// file that failed to parse as well as for one that was absent, and every caller of
635    /// it then went on to act as though the repository had no configuration: the prune
636    /// pass ignored an `"ignore": true` it could not read, and the two workspace toggles
637    /// wrote a fresh default file straight over the user's broken one, taking every
638    /// override in it with them. A caller that genuinely does not care — the display-name
639    /// lookup — says so with `.ok().flatten()`.
640    pub fn load_with_diagnostics(repo_path: &Path) -> Result<Option<Self>, String> {
641        let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
642        if !config_file.exists() {
643            return Ok(None);
644        }
645        let content =
646            fs::read_to_string(&config_file).map_err(|e| format!("Failed to read file: {e}"))?;
647        match serde_json::from_str::<Self>(&content) {
648            Ok(cfg) => Ok(Some(cfg)),
649            // `clean_path`, like every other path this tool shows. `Display` on a
650            // canonicalised Windows path leaks the `\\?\` extended-length prefix into an
651            // error message the user is being asked to act on.
652            Err(e) => Err(format!(
653                "Syntax error in `{}`: {e}",
654                crate::output::clean_path(&config_file)
655            )),
656        }
657    }
658
659    /// Save per-repo config to `.devprune.json` in the repo root, and record it in the
660    /// repository's `.git/info/exclude` so it never shows up in `git status`.
661    pub fn save_to_repo(&self, repo_path: &Path) -> Result<()> {
662        let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
663        let content = serde_json::to_string_pretty(self)?;
664        fs::write(&config_file, content)?;
665        let _ = ensure_in_git_exclude(repo_path, constants::PER_REPO_CONFIG_FILE);
666        let _ = ensure_in_git_exclude(repo_path, constants::DEVPRUNE_IGNORE_FILE);
667        Ok(())
668    }
669}
670
671/// One directory a prune pass deleted.
672///
673/// Enough to put it back and nothing more: which repository it belonged to, which
674/// project inside that repository owned it, and who verified it. No file list — the
675/// lockfile is the record of the contents, which is the whole premise of the tool.
676#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
677pub struct PrunedDir {
678    /// Repository root the directory belonged to.
679    pub repo_path: PathBuf,
680    /// Repository-relative label, `/`-separated: `node_modules`, `frontend/node_modules`.
681    pub bloat_dir: String,
682    /// Adapter that verified and deleted it.
683    pub adapter: String,
684    /// Bytes reclaimed.
685    pub size_freed: u64,
686    /// The language runtime the deleted directory was built against — `"3.12"` for a
687    /// virtual environment created by Python 3.12 — so a restore can rebuild on that
688    /// interpreter instead of on whatever happens to be first on `PATH` today.
689    ///
690    /// `None` for every manager that pins its own toolchain in the lockfile (cargo, npm,
691    /// go) and for anything pruned before 1.4.0. Optional rather than required for that
692    /// second reason: a `registry.json` written by an older version has to keep loading.
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub runtime: Option<String>,
695}
696
697/// What the most recent prune pass deleted, for `devp restore --last-run`.
698///
699/// Only passes that actually deleted something are recorded. A later run that frees
700/// nothing — everything was active, everything was already clean — leaves this alone,
701/// because "put back what you just took" should still mean the pass that took something.
702#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
703pub struct LastPrune {
704    /// When the pass ran.
705    pub at: DateTime<Utc>,
706    /// Every directory it removed.
707    pub dirs: Vec<PrunedDir>,
708}
709
710/// A one-line summary of a completed prune pass, for `devp stats`.
711///
712/// Deliberately not a second copy of [`LastPrune`]. That one exists so
713/// `devp restore --last-run` can put files back, so it carries the full directory list
714/// and only ever describes the most recent pass. This one is a trend line — four numbers
715/// per pass, bounded by [`constants::PRUNE_HISTORY_LIMIT`] — and could not restore
716/// anything if it wanted to.
717#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
718pub struct PruneRunSummary {
719    /// When the pass ran.
720    pub at: DateTime<Utc>,
721    /// Bytes reclaimed by the pass.
722    pub bytes_freed: u64,
723    /// How many directories it removed.
724    pub dirs_removed: usize,
725    /// How many distinct repositories it touched.
726    pub repos_touched: usize,
727}
728
729/// The top-level registry structure persisted to disk.
730#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
731pub struct Registry {
732    /// Schema version for forward compatibility.
733    pub version: String,
734    /// Global settings.
735    pub settings: Settings,
736    /// Map of canonical repo paths to their metadata.
737    pub repositories: HashMap<PathBuf, RepoEntry>,
738    /// Total cumulative bytes freed historically across all prune passes.
739    #[serde(default)]
740    pub total_freed_bytes: u64,
741    /// Total bytes given back by `devp caches clear`, ever, on this machine.
742    ///
743    /// Kept apart from `total_freed_bytes` rather than folded into it, because the two
744    /// cost different things to undo. A prune deletes what a lockfile proves it can
745    /// rebuild, and getting it back is one reinstall in one repository; emptying a shared
746    /// cache costs a download in every project on the disk. Not keyed by repository for
747    /// the same reason: a package manager's cache belongs to none of them.
748    ///
749    /// Recorded from 1.9.0 onward, so a registry written before then deserializes to
750    /// zero and starts counting from the next clear.
751    #[serde(default)]
752    pub total_cache_freed_bytes: u64,
753    /// How many prune passes have deleted something, ever.
754    ///
755    /// One per *pass*, not per repository and not per directory — a `devp run` that
756    /// cleared eleven directories across four repositories counts once. Incremented in
757    /// exactly one place, [`Registry::record_prune`], which is also where the pass is
758    /// recorded for `devp restore --last-run`; keeping the two together is what stops
759    /// them meaning different things depending on which command did the pruning.
760    #[serde(default)]
761    pub total_pruned_count: u64,
762    /// List of repository paths added in the most recent init/link action (for devp undo).
763    #[serde(default)]
764    pub last_added_repos: Vec<PathBuf>,
765    /// What the most recent prune pass deleted (for `devp restore --last-run`).
766    #[serde(default)]
767    pub last_prune: Option<LastPrune>,
768    /// Summaries of recent prune passes, oldest first, for `devp stats`.
769    ///
770    /// Capped at [`constants::PRUNE_HISTORY_LIMIT`]. Recorded from 1.1.0 onward.
771    #[serde(default)]
772    pub prune_history: Vec<PruneRunSummary>,
773    /// When the release check last ran, so it runs at most once every
774    /// `UPDATE_CHECK_INTERVAL_DAYS` instead of on every command.
775    #[serde(default)]
776    pub last_update_check: Option<DateTime<Utc>>,
777    /// The newest release seen by the last check, so the reminder survives until the
778    /// user actually upgrades without needing the network again.
779    #[serde(default)]
780    pub latest_known_version: Option<String>,
781    /// How fast each adapter has actually restored on this machine.
782    ///
783    /// Measured by `devp restore --last-run`, which is the one command that knows both
784    /// how long a restore took and how many bytes it put back. Local only: nothing here
785    /// is uploaded, compared against anyone else's machine, or used for anything except
786    /// the estimate `devp status` prints. See `docs/PRIVACY.md`.
787    #[serde(default)]
788    pub restore_rates: BTreeMap<String, RestoreRate>,
789}
790
791/// One adapter's observed restore throughput on this machine.
792///
793/// Totals rather than a stored average, because that is what lets a new measurement be
794/// folded in without keeping the individual samples — and the individual samples are
795/// per-repository, which is exactly the shape of data this tool has no business keeping.
796#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
797pub struct RestoreRate {
798    /// How many restores this average is made of.
799    pub samples: u32,
800    /// Bytes those restores put back.
801    pub bytes: u64,
802    /// Milliseconds they took.
803    pub millis: u64,
804}
805
806impl RestoreRate {
807    /// Bytes per second, or `None` when the record cannot support the division.
808    pub fn bytes_per_sec(&self) -> Option<f64> {
809        (self.samples > 0 && self.millis > 0 && self.bytes > 0)
810            .then(|| self.bytes as f64 * 1000.0 / self.millis as f64)
811    }
812}
813
814impl Default for Registry {
815    fn default() -> Self {
816        Self {
817            version: "1.0".to_string(),
818            settings: Settings::default(),
819            repositories: HashMap::new(),
820            total_freed_bytes: 0,
821            total_cache_freed_bytes: 0,
822            total_pruned_count: 0,
823            last_added_repos: Vec::new(),
824            last_prune: None,
825            prune_history: Vec::new(),
826            last_update_check: None,
827            latest_known_version: None,
828            restore_rates: BTreeMap::new(),
829        }
830    }
831}
832
833impl Registry {
834    /// Returns the path to the config directory (`~/.config/dev-prune/`).
835    ///
836    /// Uses the `dirs` crate to resolve the platform-specific config location:
837    /// - Linux/macOS: `~/.config/dev-prune/`
838    /// - Windows: `C:\Users\<user>\AppData\Roaming\dev-prune\` (or `~/.config/dev-prune/`)
839    pub fn config_dir() -> Result<PathBuf> {
840        if let Ok(override_dir) = std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE) {
841            return Ok(PathBuf::from(override_dir));
842        }
843        let base = dirs::config_dir().context("Could not determine config directory")?;
844        Ok(base.join(constants::CONFIG_DIR_NAME))
845    }
846
847    /// Returns the full path to the registry file.
848    pub fn registry_path() -> Result<PathBuf> {
849        Ok(Self::config_dir()?.join(constants::REGISTRY_FILENAME))
850    }
851
852    /// Loads the registry from disk, or the defaults when there is nothing to load.
853    ///
854    /// Reading does not write. This used to persist the default registry on the way
855    /// out, which made `devp --dry-run init` create the very file it had just promised
856    /// not to write and gave `devp status --json` — documented as a pure read — a side
857    /// effect on first use. Every command that actually changes something calls
858    /// [`Registry::save`], and that creates the directory as needed.
859    pub fn load() -> Result<Self> {
860        Self::load_from(&Self::registry_path()?)
861    }
862
863    /// Loads the registry from a specific path (for testing or custom locations).
864    ///
865    /// Non-persisting, exactly like [`Registry::load`], which is implemented on top of
866    /// it. The two used to disagree — this one wrote the defaults out when the file was
867    /// missing — which is the sort of difference that makes a test pass while the
868    /// behaviour it stands in for is broken.
869    pub fn load_from(path: &Path) -> Result<Self> {
870        if !path.exists() {
871            return Ok(Registry::default());
872        }
873        let contents = fs::read_to_string(path)
874            .with_context(|| format!("Failed to read registry at {}", path.display()))?;
875        serde_json::from_str(&contents)
876            .with_context(|| format!("Failed to parse registry at {}", path.display()))
877    }
878
879    /// Saves the registry to disk atomically (write to temp, then rename).
880    pub fn save(&self) -> Result<()> {
881        let path = Self::registry_path()?;
882        self.save_to(&path)
883    }
884
885    /// Saves the registry to a specific path (for testing or custom locations).
886    pub fn save_to(&self, path: &Path) -> Result<()> {
887        if let Some(parent) = path.parent() {
888            fs::create_dir_all(parent)
889                .with_context(|| format!("Failed to create config dir {}", parent.display()))?;
890        }
891        // Unique per process. A manual run and the scheduled daemon pass can save at the
892        // same moment; with a shared `registry.json.tmp`, one process could rename the
893        // other's half-written file into place as a torn, unparseable registry.
894        let tmp_path = path.with_extension(format!("json.{}.tmp", std::process::id()));
895        let contents =
896            serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
897        {
898            // `sync_all` before the rename, or the atomicity is only apparent: after a
899            // power cut the rename can survive while the data does not, leaving the
900            // registry as zero bytes — the one outcome this dance exists to prevent.
901            use std::io::Write;
902            let mut file = fs::File::create(&tmp_path)
903                .with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
904            file.write_all(contents.as_bytes())
905                .with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
906            file.sync_all()
907                .with_context(|| format!("Failed to flush temp registry {}", tmp_path.display()))?;
908        }
909        fs::rename(&tmp_path, path)
910            .with_context(|| format!("Failed to rename temp registry to {}", path.display()))?;
911
912        // A crash between write and rename strands that process's `.<pid>.tmp` forever.
913        // Sweep siblings old enough that no live save can still own them.
914        if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
915            let prefix = format!("{}.", name.to_string_lossy());
916            if let Ok(entries) = fs::read_dir(parent) {
917                for entry in entries.flatten() {
918                    let file_name = entry.file_name();
919                    let file_name = file_name.to_string_lossy();
920                    if file_name.starts_with(&prefix)
921                        && file_name.ends_with(".tmp")
922                        && entry
923                            .metadata()
924                            .and_then(|m| m.modified())
925                            .ok()
926                            .and_then(|t| t.elapsed().ok())
927                            .is_some_and(|age| age.as_secs() > 3600)
928                    {
929                        let _ = fs::remove_file(entry.path());
930                    }
931                }
932            }
933        }
934        Ok(())
935    }
936
937    /// Adds a repository to the registry. Returns `true` if newly added, `false` if already present.
938    pub fn add_repo(&mut self, path: PathBuf) -> bool {
939        // The registry is keyed by path, so `./foo`, `foo/`, and the absolute form
940        // would otherwise register as three separate repositories.
941        let path = canonical_key(&path);
942        if self.repositories.contains_key(&path) {
943            return false;
944        }
945        self.repositories.insert(path, RepoEntry::new());
946        true
947    }
948
949    /// Record `identity` against a registered repository, and hand it the history of the
950    /// entry it moved away from.
951    ///
952    /// Called after `add_repo` from both `link` and `init`. When exactly one registered
953    /// path no longer exists on disk and carries the same root commit, that entry is the
954    /// same repository at its old location: its `added_at`, prune history and settings
955    /// move across and the dead row is removed. Two dead entries claiming one identity
956    /// is a clone, not a move, so nothing is guessed — the caller says so instead.
957    ///
958    /// Also the backfill path. Entries registered before 1.4.0 have no identity, so
959    /// nothing they do can be recognised as a move; re-registering them records one, and
960    /// a single `devp init ~/code` backfills the whole registry.
961    pub fn adopt_moved_entry(&mut self, path: &Path, identity: Option<String>) -> Adoption {
962        let key = canonical_key(path);
963        let Some(identity) = identity else {
964            return Adoption::Nothing;
965        };
966
967        let mut claimants: Vec<PathBuf> = self
968            .repositories
969            .iter()
970            .filter(|(p, e)| {
971                **p != key && e.identity.as_deref() == Some(identity.as_str()) && !p.exists()
972            })
973            .map(|(p, _)| p.clone())
974            .collect();
975        // Deterministic: two dead entries with one identity is a report, not a coin toss,
976        // and the report must read the same twice.
977        claimants.sort();
978
979        let adopted = match claimants.len() {
980            0 => Adoption::Nothing,
981            1 => Adoption::Moved(claimants.remove(0)),
982            _ => Adoption::Ambiguous,
983        };
984
985        if let Adoption::Moved(ref old) = adopted
986            && let Some(previous) = self.repositories.remove(old)
987        {
988            if let Some(entry) = self.repositories.get_mut(&key) {
989                // Everything the old path had earned. `enabled` and the idle override
990                // come across too: a repository the user had switched off did not switch
991                // itself back on by being moved.
992                entry.added_at = previous.added_at;
993                entry.last_pruned_at = previous.last_pruned_at;
994                entry.override_idle_days = previous.override_idle_days;
995                entry.enabled = previous.enabled;
996                entry.total_freed_bytes = previous.total_freed_bytes;
997            }
998            self.last_added_repos.retain(|p| p != old);
999        }
1000
1001        if let Some(entry) = self.repositories.get_mut(&key) {
1002            entry.identity = Some(identity);
1003        }
1004        adopted
1005    }
1006
1007    /// Whether a registered repository still has no recorded identity.
1008    ///
1009    /// The global Git hook runs `devp link --quiet` on every commit, and backfilling
1010    /// unconditionally would shell out to git and rewrite the registry once per commit
1011    /// forever. This makes it once per repository.
1012    pub fn needs_identity(&self, path: &Path) -> bool {
1013        self.repositories
1014            .get(&canonical_key(path))
1015            .is_some_and(|e| e.identity.is_none())
1016    }
1017
1018    /// Removes a repository from the registry. Returns `true` if it was present.
1019    ///
1020    /// A repository that has been deleted from disk cannot be canonicalised any more,
1021    /// so `canonical_key` falls back to the path as typed — which never equals the
1022    /// canonical key it was registered under (on Windows those carry the `\\?\`
1023    /// prefix). Unlinking a deleted repository is the most ordinary reason to unlink
1024    /// at all, so a direct miss falls back to a lexical comparison.
1025    pub fn remove_repo(&mut self, path: &Path) -> bool {
1026        let target = lexical_absolute(path);
1027        let removed = if self.repositories.remove(&canonical_key(path)).is_some() {
1028            true
1029        } else {
1030            let found = self
1031                .repositories
1032                .keys()
1033                .find(|k| loose_path_eq(k, &target))
1034                .cloned();
1035            found.is_some_and(|k| self.repositories.remove(&k).is_some())
1036        };
1037        if removed {
1038            // The undo list stores the canonical `\\?\`-prefixed spelling, while a
1039            // deleted directory can only be named lexically — strict equality misses,
1040            // and the next `devp undo` "reverts" by removing nothing.
1041            self.last_added_repos.retain(|p| !loose_path_eq(p, &target));
1042        }
1043        removed
1044    }
1045
1046    // Removed: `repo_paths` and `effective_idle_days`.
1047    //
1048    // Neither had a caller outside this file's own tests. `effective_idle_days` had also
1049    // drifted from the rule the engine actually applies: it looked the repository up by
1050    // the path as given, where every write to `repositories` goes through
1051    // `canonical_key`, so `devp`'s own relative paths would have missed the entry and
1052    // silently returned the global threshold instead of the repository's override.
1053
1054    /// Credit `bytes_freed` to one repository, and to the machine-wide total.
1055    ///
1056    /// Safe to call once per repository or once per directory — every figure it touches
1057    /// is either a sum or a timestamp, so the two styles agree. Counting *passes* is
1058    /// deliberately not done here for exactly that reason; that lives in
1059    /// [`Registry::record_prune`], which is called once per pass.
1060    pub fn mark_pruned(&mut self, path: &Path, bytes_freed: u64) {
1061        // Same rule as every other accessor: the map is keyed by `canonical_key`, so a
1062        // raw lookup would silently skip the per-repo credit for a relative or
1063        // differently-spelled path while still growing the machine-wide total.
1064        if let Some(entry) = self.repositories.get_mut(&canonical_key(path)) {
1065            entry.last_pruned_at = Some(Utc::now());
1066            entry.total_freed_bytes += bytes_freed;
1067        }
1068        self.total_freed_bytes += bytes_freed;
1069    }
1070
1071    /// Credit `bytes` to the machine's running cache-clear total.
1072    pub fn record_cache_clear(&mut self, bytes: u64) {
1073        self.total_cache_freed_bytes += bytes;
1074    }
1075
1076    /// Record what a prune pass deleted, replacing any earlier record.
1077    ///
1078    /// A pass that deleted nothing is not a pass worth remembering, so an empty list is
1079    /// ignored rather than stored — otherwise `devp run` on an already-clean machine
1080    /// would quietly throw away the record of the run the user actually wants back.
1081    ///
1082    /// This is the one place a prune pass is counted. It sets [`Registry::last_prune`],
1083    /// appends a [`PruneRunSummary`] to [`Registry::prune_history`] and bumps
1084    /// [`Registry::total_pruned_count`], because "a pass happened and it deleted things"
1085    /// is exactly the condition all three describe. Splitting them across call sites is
1086    /// how the counter previously came to mean repositories in `devp run` and directories
1087    /// in the `devp status` dashboard.
1088    /// Fold one measured restore into an adapter's running average.
1089    ///
1090    /// Ignores anything too quick to have been real work — see
1091    /// [`constants::RESTORE_RATE_MIN_MILLIS`] — because a manager that found everything
1092    /// still in its cache returns in a moment and would teach a throughput no cold
1093    /// restore can reach. That is the difference between an estimate that is optimistic
1094    /// and one that is wrong.
1095    pub fn record_restore(&mut self, adapter: &str, bytes: u64, millis: u64) {
1096        if bytes == 0 || millis < constants::RESTORE_RATE_MIN_MILLIS {
1097            return;
1098        }
1099        let rate = self.restore_rates.entry(adapter.to_string()).or_default();
1100        if rate.samples >= constants::RESTORE_RATE_SAMPLE_CAP {
1101            rate.samples /= 2;
1102            rate.bytes /= 2;
1103            rate.millis /= 2;
1104        }
1105        rate.samples += 1;
1106        rate.bytes = rate.bytes.saturating_add(bytes);
1107        rate.millis = rate.millis.saturating_add(millis);
1108    }
1109
1110    /// How long putting back `by_adapter` would take, from what this machine has
1111    /// measured.
1112    ///
1113    /// Returns the seconds and the bytes those seconds account for. Anything from an
1114    /// adapter that has never been timed here is left out of both, so a caller can say
1115    /// how much of the estimate is actually covered rather than quietly quoting a
1116    /// number for half the work. `None` when nothing is covered at all — an estimate
1117    /// with no measurement behind it is a guess, and this command does not print
1118    /// guesses.
1119    pub fn estimate_restore(&self, by_adapter: &[(String, u64)]) -> Option<(f64, u64)> {
1120        let mut secs = 0.0;
1121        let mut covered = 0u64;
1122        for (adapter, bytes) in by_adapter {
1123            let Some(rate) = self
1124                .restore_rates
1125                .get(adapter)
1126                .and_then(|r| r.bytes_per_sec())
1127            else {
1128                continue;
1129            };
1130            secs += *bytes as f64 / rate;
1131            covered = covered.saturating_add(*bytes);
1132        }
1133        (covered > 0).then_some((secs, covered))
1134    }
1135
1136    pub fn record_prune(&mut self, dirs: Vec<PrunedDir>) {
1137        self.record_prune_progress(Utc::now(), dirs);
1138    }
1139
1140    /// Record a pass's progress mid-flight, superseding this same pass's earlier record.
1141    ///
1142    /// `at` identifies the pass: a repeated call with the same timestamp replaces the
1143    /// history entry and `last_prune` it wrote before, rather than counting a second
1144    /// pass. This exists so a long pass can persist after every repository — a crash
1145    /// half-way through used to leave `devp restore --last-run` pointing at the
1146    /// *previous* pass, offering to reinstall directories that were never deleted while
1147    /// saying nothing about the ones that were.
1148    pub fn record_prune_progress(&mut self, at: DateTime<Utc>, dirs: Vec<PrunedDir>) {
1149        if dirs.is_empty() {
1150            return;
1151        }
1152        if self.prune_history.last().map(|s| s.at) == Some(at) {
1153            self.prune_history.pop();
1154        } else {
1155            self.total_pruned_count += 1;
1156        }
1157
1158        self.prune_history.push(PruneRunSummary {
1159            at,
1160            bytes_freed: dirs.iter().map(|d| d.size_freed).sum(),
1161            dirs_removed: dirs.len(),
1162            repos_touched: dirs
1163                .iter()
1164                .map(|d| &d.repo_path)
1165                .collect::<HashSet<_>>()
1166                .len(),
1167        });
1168        // Oldest first, so the overflow comes off the front.
1169        if self.prune_history.len() > constants::PRUNE_HISTORY_LIMIT {
1170            let excess = self.prune_history.len() - constants::PRUNE_HISTORY_LIMIT;
1171            self.prune_history.drain(..excess);
1172        }
1173
1174        self.last_prune = Some(LastPrune { at, dirs });
1175    }
1176
1177    /// Returns the number of registered repositories.
1178    pub fn repo_count(&self) -> usize {
1179        self.repositories.len()
1180    }
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185    use super::*;
1186    use tempfile::TempDir;
1187
1188    fn test_registry_path(dir: &TempDir) -> PathBuf {
1189        dir.path().join("dev-prune").join("registry.json")
1190    }
1191
1192    fn a_pruned_dir(label: &str) -> PrunedDir {
1193        PrunedDir {
1194            repo_path: PathBuf::from("/repo"),
1195            bloat_dir: label.to_string(),
1196            adapter: "npm".to_string(),
1197            size_freed: 42,
1198            runtime: None,
1199        }
1200    }
1201
1202    #[test]
1203    fn cache_clears_accumulate_separately_from_prunes() {
1204        let dir = TempDir::new().expect("temp dir");
1205        let path = test_registry_path(&dir);
1206
1207        let mut registry = Registry::default();
1208        registry.mark_pruned(Path::new("/repo"), 42);
1209        registry.record_cache_clear(6_000_000_000);
1210        registry.record_cache_clear(2_000_000_000);
1211        registry.save_to(&path).expect("saved");
1212
1213        let reloaded = Registry::load_from(&path).expect("reloaded");
1214        assert_eq!(reloaded.total_cache_freed_bytes, 8_000_000_000);
1215        // The prune total is untouched by either clear. `devp stats` prints them as two
1216        // lines because emptying a shared cache is not the same promise as pruning one
1217        // repository, and one combined figure would answer neither question.
1218        assert_eq!(reloaded.total_freed_bytes, 42);
1219    }
1220
1221    #[test]
1222    fn a_registry_written_before_1_9_0_reads_the_cache_total_as_zero() {
1223        // The `#[serde(default)]`, exercised. Without it every registry on every machine
1224        // that upgraded would fail to parse, and `devp stats` would exit 1.
1225        let dir = TempDir::new().expect("temp dir");
1226        let path = test_registry_path(&dir);
1227        std::fs::create_dir_all(path.parent().expect("parent")).expect("config dir");
1228
1229        // Built by removing the one key 1.8.0 did not write, rather than hand-typed, so
1230        // this stays a test of the `default` and not of whichever unrelated field is
1231        // added to `Settings` next.
1232        let mut older = Registry {
1233            total_freed_bytes: 99,
1234            ..Default::default()
1235        };
1236        older.record_cache_clear(500);
1237        let mut document: serde_json::Value =
1238            serde_json::from_str(&serde_json::to_string(&older).expect("serialized"))
1239                .expect("re-parsed");
1240        assert!(
1241            document
1242                .as_object_mut()
1243                .expect("an object")
1244                .remove("total_cache_freed_bytes")
1245                .is_some(),
1246            "the field this test is about must be in the document to begin with"
1247        );
1248        std::fs::write(&path, document.to_string()).expect("wrote an older registry");
1249
1250        let registry = Registry::load_from(&path).expect("an older registry still parses");
1251        assert_eq!(registry.total_cache_freed_bytes, 0);
1252        assert_eq!(registry.total_freed_bytes, 99);
1253    }
1254
1255    #[test]
1256    fn a_prune_that_deleted_nothing_does_not_erase_the_last_one() {
1257        // Otherwise a second `devp run` on an already-clean machine throws away the
1258        // record of the pass the user actually wants to undo.
1259        let mut registry = Registry::default();
1260        registry.record_prune(vec![a_pruned_dir("node_modules")]);
1261        let recorded = registry.last_prune.clone().expect("first pass recorded");
1262
1263        registry.record_prune(Vec::new());
1264
1265        assert_eq!(registry.last_prune, Some(recorded));
1266    }
1267
1268    #[test]
1269    fn a_later_prune_replaces_the_record() {
1270        let mut registry = Registry::default();
1271        registry.record_prune(vec![a_pruned_dir("node_modules")]);
1272        registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
1273
1274        let dirs = registry.last_prune.unwrap().dirs;
1275        assert_eq!(dirs.len(), 1);
1276        assert_eq!(dirs[0].bloat_dir, "frontend/node_modules");
1277    }
1278
1279    #[test]
1280    fn the_last_prune_record_survives_a_save_and_load() {
1281        // `restore --last-run` reads it out of a file written by a process that has
1282        // already exited, so the round trip is the whole feature.
1283        let dir = TempDir::new().unwrap();
1284        let path = test_registry_path(&dir);
1285
1286        let mut registry = Registry::default();
1287        registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
1288        registry.save_to(&path).unwrap();
1289
1290        let loaded = Registry::load_from(&path).unwrap();
1291        assert_eq!(loaded.last_prune, registry.last_prune);
1292    }
1293
1294    #[test]
1295    fn a_registry_written_before_the_field_existed_still_loads() {
1296        // The registry on disk predates `last_prune`; a missing key means "no pass
1297        // recorded", not a parse failure that would lock the user out of their config.
1298        let dir = TempDir::new().unwrap();
1299        let path = test_registry_path(&dir);
1300        fs::create_dir_all(path.parent().unwrap()).unwrap();
1301        fs::write(
1302            &path,
1303            r#"{"version":"1.0","settings":{"idle_days":15,"check_interval_days":2,
1304               "auto_daemon":true},"repositories":{}}"#,
1305        )
1306        .unwrap();
1307
1308        let loaded = Registry::load_from(&path).unwrap();
1309        assert_eq!(loaded.last_prune, None);
1310    }
1311
1312    #[test]
1313    fn a_leading_tilde_becomes_the_home_directory() {
1314        // The whole reason this exists: PowerShell hands `devp init ~/Code` straight
1315        // through, so without expansion the registry gains a repository at `.\~\Code`.
1316        let home = dirs::home_dir().expect("test host has a home directory");
1317
1318        assert_eq!(expand_tilde("~"), home.to_string_lossy());
1319        assert_eq!(
1320            expand_tilde("~/Code"),
1321            home.join("Code").to_string_lossy(),
1322            "forward slash, as typed in every shell"
1323        );
1324        assert_eq!(
1325            expand_tilde("~\\Code"),
1326            home.join("Code").to_string_lossy(),
1327            "backslash, as typed in PowerShell"
1328        );
1329    }
1330
1331    #[test]
1332    fn a_tilde_that_is_not_a_home_reference_is_left_alone() {
1333        // `~alice` is another user's home in shell syntax and cannot be resolved
1334        // portably; `~backup` and `./~tmp` are ordinary directory names. Rewriting any
1335        // of them would silently point the user at the wrong directory.
1336        for raw in ["~alice/Code", "~backup", "./~tmp", "Code~", "", "."] {
1337            assert_eq!(expand_tilde(raw), raw, "{raw} must survive untouched");
1338        }
1339    }
1340
1341    #[test]
1342    fn test_default_settings() {
1343        let settings = Settings::default();
1344        assert_eq!(settings.idle_days, 15);
1345        assert_eq!(settings.check_interval_days, 2);
1346        // On by default: dev-prune installs its own integrations, once per version,
1347        // and only the ones it finds missing.
1348        assert!(settings.auto_daemon);
1349        assert!(settings.auto_hooks);
1350        assert!(settings.auto_setup);
1351    }
1352
1353    #[test]
1354    fn settings_written_before_the_automation_toggles_existed_still_load() {
1355        // Real registries on disk predate `auto_hooks` / `auto_setup`; an upgrade must
1356        // read them rather than fail to parse and lose every registered repository.
1357        let json = r#"{
1358            "idle_days": 30,
1359            "check_interval_days": 2,
1360            "auto_daemon": false
1361        }"#;
1362        let settings: Settings = serde_json::from_str(json).unwrap();
1363        assert_eq!(settings.idle_days, 30);
1364        assert!(!settings.auto_daemon, "an explicit opt-out is preserved");
1365        assert!(settings.auto_hooks, "a missing key takes the default");
1366        assert!(settings.auto_setup);
1367    }
1368
1369    #[test]
1370    fn test_default_registry() {
1371        let registry = Registry::default();
1372        assert_eq!(registry.version, "1.0");
1373        assert_eq!(registry.settings, Settings::default());
1374        assert!(registry.repositories.is_empty());
1375    }
1376
1377    #[test]
1378    fn test_repo_entry_new() {
1379        let entry = RepoEntry::new();
1380        assert!(entry.enabled);
1381        assert!(entry.last_pruned_at.is_none());
1382        assert!(entry.override_idle_days.is_none());
1383    }
1384
1385    #[test]
1386    fn test_save_and_load() {
1387        let tmp = TempDir::new().unwrap();
1388        let path = test_registry_path(&tmp);
1389
1390        let mut registry = Registry::default();
1391        registry.add_repo(PathBuf::from("/test/repo"));
1392        registry.save_to(&path).unwrap();
1393
1394        let loaded = Registry::load_from(&path).unwrap();
1395        assert_eq!(loaded.repo_count(), 1);
1396        assert!(
1397            loaded
1398                .repositories
1399                .contains_key(&PathBuf::from("/test/repo"))
1400        );
1401    }
1402
1403    #[test]
1404    fn loading_a_missing_registry_yields_the_defaults_and_writes_nothing() {
1405        let tmp = TempDir::new().unwrap();
1406        let path = test_registry_path(&tmp);
1407
1408        let loaded = Registry::load_from(&path).unwrap();
1409        assert_eq!(loaded, Registry::default());
1410        // Reading is not writing. `devp --dry-run` and `devp status --json` both promise
1411        // to leave the disk alone, and both start by loading the registry.
1412        assert!(!path.exists(), "loading the registry created it");
1413    }
1414
1415    #[test]
1416    fn test_add_repo_returns_true_for_new() {
1417        let mut registry = Registry::default();
1418        assert!(registry.add_repo(PathBuf::from("/test/repo")));
1419    }
1420
1421    #[test]
1422    fn test_add_repo_returns_false_for_duplicate() {
1423        let mut registry = Registry::default();
1424        registry.add_repo(PathBuf::from("/test/repo"));
1425        assert!(!registry.add_repo(PathBuf::from("/test/repo")));
1426    }
1427
1428    #[test]
1429    fn test_remove_repo() {
1430        let mut registry = Registry::default();
1431        registry.add_repo(PathBuf::from("/test/repo"));
1432        assert!(registry.remove_repo(Path::new("/test/repo")));
1433        assert!(!registry.remove_repo(Path::new("/test/repo")));
1434        assert_eq!(registry.repo_count(), 0);
1435    }
1436
1437    /// macOS puts temp trees behind `/var` → `/private/var`, so a repo registered
1438    /// through the symlink is keyed under the real path — and once deleted, the
1439    /// symlinked spelling cannot be canonicalised whole. The lexical fallback must
1440    /// resolve the surviving parent, or unlink reports "not registered" for a
1441    /// directory the user is looking at in their own prompt.
1442    #[cfg(unix)]
1443    #[test]
1444    fn a_deleted_repo_named_through_a_symlinked_parent_still_unlinks() {
1445        let tmp = TempDir::new().unwrap();
1446        let real_parent = tmp.path().join("real");
1447        std::fs::create_dir(&real_parent).unwrap();
1448        let alias = tmp.path().join("alias");
1449        std::os::unix::fs::symlink(&real_parent, &alias).unwrap();
1450
1451        let repo = real_parent.join("repo");
1452        std::fs::create_dir(&repo).unwrap();
1453        let mut registry = Registry::default();
1454        registry.add_repo(alias.join("repo"));
1455        std::fs::remove_dir(&repo).unwrap();
1456
1457        assert!(registry.remove_repo(&alias.join("repo")));
1458        assert_eq!(registry.repo_count(), 0);
1459    }
1460
1461    #[test]
1462    fn test_mark_pruned() {
1463        let mut registry = Registry::default();
1464        registry.add_repo(PathBuf::from("/test/repo"));
1465        assert!(
1466            registry.repositories[&PathBuf::from("/test/repo")]
1467                .last_pruned_at
1468                .is_none()
1469        );
1470        registry.mark_pruned(Path::new("/test/repo"), 1024);
1471        assert!(
1472            registry.repositories[&PathBuf::from("/test/repo")]
1473                .last_pruned_at
1474                .is_some()
1475        );
1476        assert_eq!(registry.total_freed_bytes, 1024);
1477        // Not the pass counter — that is `record_prune`'s job, once per pass.
1478        assert_eq!(registry.total_pruned_count, 0);
1479    }
1480
1481    #[test]
1482    fn a_pass_is_counted_once_however_much_it_deleted() {
1483        // The counter is published as `prune_passes`, and it used to be incremented once
1484        // per repository by `devp run` and once per *directory* by the status dashboard,
1485        // so the same work produced a different number depending on where it started.
1486        let mut registry = Registry::default();
1487        registry.add_repo(PathBuf::from("/repo"));
1488
1489        registry.mark_pruned(Path::new("/repo"), 1024);
1490        registry.mark_pruned(Path::new("/repo"), 1024);
1491        registry.record_prune(vec![
1492            a_pruned_dir("node_modules"),
1493            a_pruned_dir("frontend/node_modules"),
1494        ]);
1495
1496        assert_eq!(registry.total_pruned_count, 1);
1497
1498        registry.record_prune(vec![a_pruned_dir("target")]);
1499        assert_eq!(registry.total_pruned_count, 2);
1500
1501        // A pass that deleted nothing is not a pass.
1502        registry.record_prune(Vec::new());
1503        assert_eq!(registry.total_pruned_count, 2);
1504    }
1505
1506    #[test]
1507    fn mark_pruned_credits_the_repo_under_its_canonical_key() {
1508        // On Windows, `canonicalize` yields a `\\?\`-prefixed path, so a registry keyed
1509        // by the canonical form and a `mark_pruned` looking up the raw form would miss —
1510        // growing the machine-wide total while the repository's own figure stayed zero.
1511        let tmp = TempDir::new().unwrap();
1512        let raw = tmp.path().to_path_buf();
1513
1514        let mut registry = Registry::default();
1515        registry.add_repo(raw.clone());
1516        registry.mark_pruned(&raw, 1024);
1517
1518        let entry = &registry.repositories[&canonical_key(&raw)];
1519        assert_eq!(entry.total_freed_bytes, 1024);
1520        assert!(entry.last_pruned_at.is_some());
1521        assert_eq!(registry.total_freed_bytes, 1024);
1522    }
1523
1524    #[test]
1525    fn each_repository_accumulates_its_own_total() {
1526        // `devp stats` ranks repositories against each other, so the per-repo figure has
1527        // to be a running total and not the size of the most recent pass.
1528        let mut registry = Registry::default();
1529        registry.add_repo(PathBuf::from("/test/repo"));
1530        registry.add_repo(PathBuf::from("/test/other"));
1531
1532        registry.mark_pruned(Path::new("/test/repo"), 1024);
1533        registry.mark_pruned(Path::new("/test/repo"), 2048);
1534        registry.mark_pruned(Path::new("/test/other"), 512);
1535
1536        assert_eq!(
1537            registry.repositories[&PathBuf::from("/test/repo")].total_freed_bytes,
1538            3072
1539        );
1540        assert_eq!(
1541            registry.repositories[&PathBuf::from("/test/other")].total_freed_bytes,
1542            512
1543        );
1544        assert_eq!(registry.total_freed_bytes, 3584);
1545    }
1546
1547    #[test]
1548    fn the_prune_history_summarises_the_pass() {
1549        let mut registry = Registry::default();
1550        registry.record_prune(vec![
1551            a_pruned_dir("node_modules"),
1552            a_pruned_dir("frontend/node_modules"),
1553        ]);
1554
1555        let summary = registry.prune_history.last().expect("pass summarised");
1556        assert_eq!(summary.bytes_freed, 84);
1557        assert_eq!(summary.dirs_removed, 2);
1558        // Both fixtures live under `/repo`, so this is one repository, not two.
1559        assert_eq!(summary.repos_touched, 1);
1560    }
1561
1562    #[test]
1563    fn the_prune_history_is_capped_and_drops_the_oldest() {
1564        // The registry is rewritten in full on every save, so an uncapped list would grow
1565        // the file forever on a machine running the scheduled pass.
1566        let mut registry = Registry::default();
1567        for _ in 0..constants::PRUNE_HISTORY_LIMIT + 5 {
1568            registry.record_prune(vec![a_pruned_dir("node_modules")]);
1569        }
1570
1571        assert_eq!(registry.prune_history.len(), constants::PRUNE_HISTORY_LIMIT);
1572        let first = registry.prune_history.first().unwrap().at;
1573        let last = registry.prune_history.last().unwrap().at;
1574        assert!(first <= last, "oldest first");
1575    }
1576
1577    #[test]
1578    fn test_repo_count() {
1579        let mut registry = Registry::default();
1580        assert_eq!(registry.repo_count(), 0);
1581        registry.add_repo(PathBuf::from("/a"));
1582        registry.add_repo(PathBuf::from("/b"));
1583        assert_eq!(registry.repo_count(), 2);
1584    }
1585
1586    #[test]
1587    fn a_local_schema_uri_has_exactly_three_slashes_on_either_platform() {
1588        assert_eq!(
1589            file_uri("/home/dev/.config/dev-prune/bin/devprune.schema.json"),
1590            "file:///home/dev/.config/dev-prune/bin/devprune.schema.json"
1591        );
1592        assert_eq!(
1593            file_uri("C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"),
1594            "file:///C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"
1595        );
1596    }
1597
1598    #[test]
1599    fn a_broken_per_repo_config_is_an_error_rather_than_an_absent_one() {
1600        // The distinction the whole tool leans on: "no config" means take the defaults,
1601        // "unreadable config" means refuse — never overwrite, never prune on a guess.
1602        let tmp = TempDir::new().unwrap();
1603        let repo = tmp.path();
1604        assert_eq!(PerRepoConfig::load_with_diagnostics(repo), Ok(None));
1605
1606        fs::write(
1607            repo.join(constants::PER_REPO_CONFIG_FILE),
1608            r#"{ "ignore": true, }"#,
1609        )
1610        .unwrap();
1611        let err = PerRepoConfig::load_with_diagnostics(repo).unwrap_err();
1612        assert!(err.contains("Syntax error"), "{err}");
1613
1614        fs::write(
1615            repo.join(constants::PER_REPO_CONFIG_FILE),
1616            r#"{ "ignore": true }"#,
1617        )
1618        .unwrap();
1619        assert!(
1620            PerRepoConfig::load_with_diagnostics(repo)
1621                .unwrap()
1622                .unwrap()
1623                .ignore
1624        );
1625    }
1626
1627    #[test]
1628    fn test_serialization_roundtrip() {
1629        let mut registry = Registry::default();
1630        registry.settings.idle_days = 30;
1631        registry.add_repo(PathBuf::from("/test/repo"));
1632
1633        let json = serde_json::to_string_pretty(&registry).unwrap();
1634        let deserialized: Registry = serde_json::from_str(&json).unwrap();
1635        assert_eq!(registry.settings.idle_days, deserialized.settings.idle_days);
1636        assert_eq!(registry.repo_count(), deserialized.repo_count());
1637    }
1638
1639    #[test]
1640    fn test_atomic_save_leaves_no_tmp() {
1641        let tmp = TempDir::new().unwrap();
1642        let path = test_registry_path(&tmp);
1643
1644        let registry = Registry::default();
1645        registry.save_to(&path).unwrap();
1646
1647        assert!(path.exists());
1648        // Nothing but the registry itself may remain — a leftover `*.tmp` would mean the
1649        // rename never happened.
1650        let leftovers: Vec<_> = fs::read_dir(path.parent().unwrap())
1651            .unwrap()
1652            .flatten()
1653            .filter(|e| e.path() != path)
1654            .collect();
1655        assert!(leftovers.is_empty(), "leftover files: {leftovers:?}");
1656    }
1657
1658    #[test]
1659    fn exclude_entry_lands_in_git_info_exclude_not_gitignore() {
1660        let tmp = TempDir::new().unwrap();
1661        let repo = tmp.path();
1662        fs::create_dir(repo.join(".git")).unwrap();
1663
1664        ensure_in_git_exclude(repo, ".devprune.json").unwrap();
1665
1666        let exclude = fs::read_to_string(repo.join(".git/info/exclude")).unwrap();
1667        assert!(exclude.lines().any(|l| l == ".devprune.json"));
1668        // The whole point of using the exclude file: the shared, tracked `.gitignore`
1669        // must never be created or touched.
1670        assert!(!repo.join(".gitignore").exists());
1671    }
1672
1673    #[test]
1674    fn exclude_entry_is_appended_once_and_preserves_existing_lines() {
1675        let tmp = TempDir::new().unwrap();
1676        let repo = tmp.path();
1677        fs::create_dir_all(repo.join(".git/info")).unwrap();
1678        // No trailing newline, deliberately — the append must not glue two entries
1679        // onto one line.
1680        fs::write(repo.join(".git/info/exclude"), "*.log").unwrap();
1681
1682        ensure_in_git_exclude(repo, ".devprune.json").unwrap();
1683        ensure_in_git_exclude(repo, ".devprune.json").unwrap();
1684
1685        let exclude = fs::read_to_string(repo.join(".git/info/exclude")).unwrap();
1686        let lines: Vec<_> = exclude.lines().collect();
1687        assert_eq!(lines, vec!["*.log", ".devprune.json"]);
1688    }
1689
1690    #[test]
1691    fn exclude_follows_a_gitdir_pointer_file() {
1692        // Worktrees and submodules have a one-line `.git` *file*, and a worktree's
1693        // private gitdir points at the shared one via `commondir` — where the real
1694        // `info/exclude` lives.
1695        let tmp = TempDir::new().unwrap();
1696        let shared = tmp.path().join("main-clone/.git");
1697        let worktree_gitdir = shared.join("worktrees/wt");
1698        fs::create_dir_all(&worktree_gitdir).unwrap();
1699        fs::write(worktree_gitdir.join("commondir"), "../..\n").unwrap();
1700
1701        let wt = tmp.path().join("wt");
1702        fs::create_dir(&wt).unwrap();
1703        fs::write(
1704            wt.join(".git"),
1705            format!("gitdir: {}\n", worktree_gitdir.display()),
1706        )
1707        .unwrap();
1708
1709        ensure_in_git_exclude(&wt, ".devprune.json").unwrap();
1710
1711        let exclude = fs::read_to_string(shared.join("info/exclude")).unwrap();
1712        assert!(exclude.lines().any(|l| l == ".devprune.json"));
1713    }
1714
1715    #[test]
1716    fn exclude_is_a_no_op_outside_a_git_repository() {
1717        let tmp = TempDir::new().unwrap();
1718
1719        ensure_in_git_exclude(tmp.path(), ".devprune.json").unwrap();
1720
1721        assert!(!tmp.path().join(".git").exists());
1722        assert!(!tmp.path().join(".gitignore").exists());
1723    }
1724    /// A repository that moved is recognised, and arrives with everything it had earned.
1725    #[test]
1726    fn adopt_moved_entry_transfers_history() {
1727        let mut reg = Registry::default();
1728        let old = PathBuf::from("/nowhere/old-home/project");
1729        let mut entry = RepoEntry::new();
1730        entry.identity = Some("abc1234def".into());
1731        entry.total_freed_bytes = 4096;
1732        entry.enabled = false;
1733        entry.override_idle_days = Some(90);
1734        reg.repositories.insert(old.clone(), entry);
1735
1736        let new = std::env::temp_dir().join("devprune-adopt-live");
1737        reg.repositories.insert(new.clone(), RepoEntry::new());
1738
1739        let outcome = reg.adopt_moved_entry(&new, Some("abc1234def".into()));
1740        assert_eq!(outcome, Adoption::Moved(old.clone()));
1741        assert!(!reg.repositories.contains_key(&old));
1742
1743        let moved = &reg.repositories[&canonical_key(&new)];
1744        assert_eq!(moved.total_freed_bytes, 4096);
1745        // A repository the user had switched off did not switch itself back on by
1746        // being moved.
1747        assert!(!moved.enabled);
1748        assert_eq!(moved.override_idle_days, Some(90));
1749        assert_eq!(moved.identity.as_deref(), Some("abc1234def"));
1750    }
1751
1752    /// Two dead entries with one root commit are clones, not a move. Nothing is guessed.
1753    #[test]
1754    fn adopt_moved_entry_refuses_to_guess_between_two() {
1755        let mut reg = Registry::default();
1756        for name in ["/nowhere/a", "/nowhere/b"] {
1757            let mut entry = RepoEntry::new();
1758            entry.identity = Some("shared".into());
1759            reg.repositories.insert(PathBuf::from(name), entry);
1760        }
1761        let new = std::env::temp_dir().join("devprune-adopt-ambiguous");
1762        reg.repositories.insert(new.clone(), RepoEntry::new());
1763
1764        assert_eq!(
1765            reg.adopt_moved_entry(&new, Some("shared".into())),
1766            Adoption::Ambiguous
1767        );
1768        assert_eq!(reg.repositories.len(), 3);
1769        // The identity is still recorded, so the next registration can recognise it
1770        // once the duplicates are cleared.
1771        assert_eq!(
1772            reg.repositories[&canonical_key(&new)].identity.as_deref(),
1773            Some("shared")
1774        );
1775    }
1776
1777    /// An entry whose path still exists is not a move, however matching its history.
1778    #[test]
1779    fn adopt_moved_entry_never_takes_from_a_live_path() {
1780        let dir = tempfile::tempdir().unwrap();
1781        let live = dir.path().join("live");
1782        std::fs::create_dir(&live).unwrap();
1783
1784        let mut reg = Registry::default();
1785        let mut entry = RepoEntry::new();
1786        entry.identity = Some("same".into());
1787        entry.total_freed_bytes = 999;
1788        reg.repositories.insert(canonical_key(&live), entry);
1789
1790        let other = dir.path().join("other");
1791        std::fs::create_dir(&other).unwrap();
1792        reg.repositories
1793            .insert(canonical_key(&other), RepoEntry::new());
1794
1795        assert_eq!(
1796            reg.adopt_moved_entry(&other, Some("same".into())),
1797            Adoption::Nothing
1798        );
1799        assert_eq!(
1800            reg.repositories[&canonical_key(&live)].total_freed_bytes,
1801            999
1802        );
1803    }
1804
1805    /// A repository with no commits has no identity, so nothing is adopted and nothing
1806    /// is recorded — a guess would be worse than the dead entry it replaced.
1807    #[test]
1808    fn adopt_moved_entry_ignores_a_missing_identity() {
1809        let mut reg = Registry::default();
1810        let mut entry = RepoEntry::new();
1811        entry.identity = Some("orphan".into());
1812        reg.repositories
1813            .insert(PathBuf::from("/nowhere/gone"), entry);
1814        let new = std::env::temp_dir().join("devprune-adopt-unborn");
1815        reg.repositories.insert(new.clone(), RepoEntry::new());
1816
1817        assert_eq!(reg.adopt_moved_entry(&new, None), Adoption::Nothing);
1818        assert_eq!(reg.repositories.len(), 2);
1819        assert!(reg.needs_identity(&new));
1820    }
1821
1822    #[test]
1823    fn a_restore_too_quick_to_be_real_teaches_nothing() {
1824        // A manager that found everything still in its cache returns in a moment. Folding
1825        // that into the average would claim a throughput no cold restore can reach, and
1826        // the estimate exists precisely to describe a cold one.
1827        let mut reg = Registry::default();
1828        reg.record_restore("npm", 500_000_000, 10);
1829        reg.record_restore("npm", 0, 60_000);
1830        assert!(reg.restore_rates.is_empty(), "{:?}", reg.restore_rates);
1831
1832        reg.record_restore("npm", 500_000_000, 60_000);
1833        assert_eq!(reg.restore_rates["npm"].samples, 1);
1834    }
1835
1836    #[test]
1837    fn the_average_forgets_the_disk_the_machine_no_longer_has() {
1838        let mut reg = Registry::default();
1839        for _ in 0..constants::RESTORE_RATE_SAMPLE_CAP {
1840            reg.record_restore("npm", 1_000_000, 1_000);
1841        }
1842        assert_eq!(
1843            reg.restore_rates["npm"].samples,
1844            constants::RESTORE_RATE_SAMPLE_CAP
1845        );
1846
1847        // The cap is a halving, not a ceiling: the next sample still lands, on top of
1848        // half of what came before.
1849        reg.record_restore("npm", 1_000_000, 1_000);
1850        let rate = &reg.restore_rates["npm"];
1851        assert_eq!(rate.samples, constants::RESTORE_RATE_SAMPLE_CAP / 2 + 1);
1852        assert!(rate.bytes_per_sec().is_some());
1853    }
1854
1855    #[test]
1856    fn an_estimate_with_nothing_measured_is_not_offered() {
1857        // Never a zero and never a guess: a machine that has not restored anything yet
1858        // has no honest answer to "how long is this to undo", so it does not print one.
1859        let reg = Registry::default();
1860        assert!(reg.estimate_restore(&[("npm".into(), 1_000_000)]).is_none());
1861    }
1862
1863    #[test]
1864    fn an_untimed_adapter_is_left_out_of_the_coverage() {
1865        // Half an answer, reported as half. Counting cargo's bytes at npm's speed would
1866        // be the one thing worse than saying nothing.
1867        let mut reg = Registry::default();
1868        reg.record_restore("npm", 10_000_000, 10_000);
1869        let (secs, covered) = reg
1870            .estimate_restore(&[("npm".into(), 10_000_000), ("cargo".into(), 90_000_000)])
1871            .expect("npm alone is enough to answer for npm");
1872        assert_eq!(covered, 10_000_000, "cargo has never been timed here");
1873        assert!((secs - 10.0).abs() < 0.01, "{secs}");
1874    }
1875}