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