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