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 interactive confirmation is required before pruning.
39    #[serde(default = "default_require_confirmation")]
40    pub require_confirmation: bool,
41    /// Timeout in seconds for lockfile enforcement / CLI commands (default 600s = 10m).
42    #[serde(default = "default_command_timeout_secs")]
43    pub command_timeout_secs: u64,
44    /// Smallest bloat directory worth deleting, in MiB. `0` disables the floor.
45    ///
46    /// Below this size the reinstall costs more than the space is worth, so the
47    /// directory is not offered as a candidate at all.
48    #[serde(default = "default_min_size_mb")]
49    pub min_size_mb: u64,
50    /// Whether dev-prune asks GitHub for the latest release from time to time.
51    ///
52    /// On by default, and opt-*out* rather than opt-in: an out-of-date cleanup tool is a
53    /// tool whose safety fixes you do not have. The request sends nothing but itself —
54    /// no identifier, no configuration, no usage data. Turn it off with
55    /// `devp config set update_check false`.
56    #[serde(default = "default_update_check")]
57    pub update_check: bool,
58    /// How many directory levels below a repository root discovery descends.
59    ///
60    /// Six by default. A flat repository never notices; a monorepo that nests projects
61    /// under `packages/@scope/name/app` does. Raise it when `devp status` does not list
62    /// a project you know is there, and remember that the walk gets more expensive with
63    /// every level. Clamped to [`constants::MAX_SCAN_DEPTH_LIMIT`].
64    #[serde(default = "default_scan_depth")]
65    pub scan_depth: usize,
66    /// Whether cargo and go may run the sync command that rewrites tracked manifests.
67    ///
68    /// Off. See [`constants::DEFAULT_ALLOW_MANIFEST_REWRITE`] — with this off, both are
69    /// verified read-only and a project with no lockfile at all is simply not pruned.
70    #[serde(default = "default_allow_manifest_rewrite")]
71    pub allow_manifest_rewrite: bool,
72    /// Days between automatic release checks.
73    ///
74    /// Only the *automatic* check honours this; `devp update` always asks, because you
75    /// are standing there waiting for the answer.
76    #[serde(default = "default_update_check_interval_days")]
77    pub update_check_interval_days: i64,
78    /// How long the release check waits for GitHub before giving up, in seconds.
79    ///
80    /// Five is right on a normal connection and too short behind some corporate proxies,
81    /// which is the whole reason this is a setting rather than a constant.
82    #[serde(default = "default_update_check_timeout_secs")]
83    pub update_check_timeout_secs: u64,
84    /// Whether the setup pass may install the Git hooks *in front of* another tool's.
85    ///
86    /// Off. With it on, a `core.hooksPath` that belongs to husky is not a reason to skip:
87    /// dev-prune takes the slot and forwards every hook back to the directory it
88    /// displaced. Behaviour-preserving, but it is still someone else's setup, so it is
89    /// asked for rather than assumed. Same thing as `devp hook install --chain`.
90    #[serde(default = "default_auto_hooks_chain")]
91    pub auto_hooks_chain: bool,
92}
93
94fn default_require_confirmation() -> bool {
95    constants::DEFAULT_REQUIRE_CONFIRMATION
96}
97
98fn default_command_timeout_secs() -> u64 {
99    constants::DEFAULT_COMMAND_TIMEOUT_SECS
100}
101
102fn default_auto_hooks() -> bool {
103    constants::DEFAULT_AUTO_HOOKS
104}
105
106fn default_auto_setup() -> bool {
107    constants::DEFAULT_AUTO_SETUP
108}
109
110fn default_update_check() -> bool {
111    constants::DEFAULT_UPDATE_CHECK
112}
113
114fn default_min_size_mb() -> u64 {
115    constants::DEFAULT_MIN_SIZE_MB
116}
117
118fn default_scan_depth() -> usize {
119    constants::DEFAULT_SCAN_DEPTH
120}
121
122fn default_allow_manifest_rewrite() -> bool {
123    constants::DEFAULT_ALLOW_MANIFEST_REWRITE
124}
125
126fn default_update_check_interval_days() -> i64 {
127    constants::UPDATE_CHECK_INTERVAL_DAYS
128}
129
130fn default_update_check_timeout_secs() -> u64 {
131    constants::UPDATE_CHECK_TIMEOUT_SECS
132}
133
134fn default_auto_hooks_chain() -> bool {
135    constants::DEFAULT_AUTO_HOOKS_CHAIN
136}
137
138impl Default for Settings {
139    fn default() -> Self {
140        Self {
141            idle_days: constants::DEFAULT_IDLE_DAYS,
142            check_interval_days: constants::DEFAULT_CHECK_INTERVAL_DAYS,
143            auto_daemon: constants::DEFAULT_AUTO_DAEMON,
144            auto_hooks: constants::DEFAULT_AUTO_HOOKS,
145            auto_setup: constants::DEFAULT_AUTO_SETUP,
146            require_confirmation: constants::DEFAULT_REQUIRE_CONFIRMATION,
147            command_timeout_secs: constants::DEFAULT_COMMAND_TIMEOUT_SECS,
148            min_size_mb: constants::DEFAULT_MIN_SIZE_MB,
149            update_check: constants::DEFAULT_UPDATE_CHECK,
150            scan_depth: constants::DEFAULT_SCAN_DEPTH,
151            allow_manifest_rewrite: constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
152            update_check_interval_days: constants::UPDATE_CHECK_INTERVAL_DAYS,
153            update_check_timeout_secs: constants::UPDATE_CHECK_TIMEOUT_SECS,
154            auto_hooks_chain: constants::DEFAULT_AUTO_HOOKS_CHAIN,
155        }
156    }
157}
158
159/// Metadata for a single registered repository.
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161pub struct RepoEntry {
162    /// Timestamp when the repo was added to the registry.
163    pub added_at: DateTime<Utc>,
164    /// Timestamp of the last successful prune, if any.
165    pub last_pruned_at: Option<DateTime<Utc>>,
166    /// Per-repo override for idle days (overrides global setting).
167    pub override_idle_days: Option<u64>,
168    /// Whether this repo is enabled for pruning.
169    pub enabled: bool,
170    /// Cumulative bytes reclaimed from this repository.
171    ///
172    /// Recorded from 1.1.0 onward. Registries written by 1.0.0 have no such figure and
173    /// deserialize to zero, so `devp stats` says where the number starts rather than
174    /// implying a repository pruned last March never freed anything.
175    #[serde(default)]
176    pub total_freed_bytes: u64,
177}
178
179impl RepoEntry {
180    /// Creates a new `RepoEntry` with the current timestamp.
181    pub fn new() -> Self {
182        Self {
183            added_at: Utc::now(),
184            last_pruned_at: None,
185            override_idle_days: None,
186            enabled: true,
187            total_freed_bytes: 0,
188        }
189    }
190}
191
192impl Default for RepoEntry {
193    fn default() -> Self {
194        Self::new()
195    }
196}
197
198/// Helper to ensure an entry (e.g. ".devprune.json") is present in the repository's `.gitignore`.
199/// If `.gitignore` doesn't exist, it creates it and adds the entry.
200pub fn ensure_in_gitignore(repo_path: &Path, entry: &str) -> Result<()> {
201    let gitignore_path = repo_path.join(".gitignore");
202    if gitignore_path.exists() {
203        let content = fs::read_to_string(&gitignore_path)?;
204        if !content.lines().any(|line| line.trim() == entry) {
205            let mut file = fs::OpenOptions::new().append(true).open(&gitignore_path)?;
206            let prefix = if content.ends_with('\n') || content.is_empty() {
207                ""
208            } else {
209                "\n"
210            };
211            writeln!(file, "{prefix}{entry}")?;
212        }
213    } else {
214        fs::write(&gitignore_path, format!("{entry}\n"))?;
215    }
216    Ok(())
217}
218
219/// Normalise a repository path into the form used as a registry key.
220///
221/// Falls back to the path as given when it cannot be canonicalised (e.g. it no longer
222/// exists), so entries for deleted repos stay addressable.
223pub fn canonical_key(path: &Path) -> PathBuf {
224    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
225}
226
227/// Expand a leading `~` to the user's home directory.
228///
229/// POSIX shells do this before the argument ever reaches a program, so on Linux and
230/// macOS it is usually a no-op. PowerShell and cmd do not: they hand a native
231/// executable the literal three characters `~/C`, and `devp init ~/Code` — the exact
232/// line in the README and on the landing page — would register a directory called `~`
233/// sitting in the current working directory. Quoting defeats the expansion in *every*
234/// shell, so `devp init "~/Code"` needs this too.
235///
236/// Only a bare `~` or a `~` followed by a separator is expanded. `~alice` means "some
237/// other user's home" in shell syntax and cannot be resolved portably, and `~backup` is
238/// a perfectly ordinary directory name.
239pub fn expand_tilde(raw: &str) -> String {
240    let Some(rest) = raw.strip_prefix('~') else {
241        return raw.to_string();
242    };
243    if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\')) {
244        return raw.to_string();
245    }
246    let Some(home) = dirs::home_dir() else {
247        // No home directory to expand to. Handing back the literal `~` lets the caller
248        // fail with "no such directory", which is a better error than a silent guess.
249        return raw.to_string();
250    };
251    if rest.is_empty() {
252        return home.to_string_lossy().into_owned();
253    }
254    home.join(rest.trim_start_matches(['/', '\\']))
255        .to_string_lossy()
256        .into_owned()
257}
258
259/// Structured per-repository configuration file stored inside repo roots as `.devprune.json`.
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
261pub struct PerRepoConfig {
262    /// JSON Schema reference URL for IDE IntelliSense and validation.
263    #[serde(rename = "$schema", default = "default_schema_url")]
264    pub schema: String,
265    /// Custom display name for this project in TUI and CLI status views.
266    #[serde(default)]
267    pub project_name: Option<String>,
268    /// Whether this repository is ignored/excluded from pruning.
269    #[serde(default)]
270    pub ignore: bool,
271    /// Disable global Git auto-registration hooks for this specific workspace.
272    #[serde(default)]
273    pub disable_hooks: bool,
274    /// Disable background daemon automated pruning pass for this specific workspace.
275    #[serde(default)]
276    pub disable_daemon: bool,
277    /// Custom override for idle days threshold (overrides global settings).
278    #[serde(default)]
279    pub override_idle_days: Option<u64>,
280    /// Custom override for the size floor, in MiB (overrides global `min_size_mb`).
281    ///
282    /// `Some(0)` is a meaningful value: it turns the floor off for this repository even
283    /// when a global floor is set.
284    #[serde(default)]
285    pub min_size_mb: Option<u64>,
286    /// Custom override for how deep discovery walks this repository.
287    ///
288    /// The setting that most often needs to differ per repository rather than globally:
289    /// one deeply-nested monorepo should not make every other repository pay for a
290    /// deeper walk. Clamped to [`constants::MAX_SCAN_DEPTH_LIMIT`] like the global one.
291    #[serde(default)]
292    pub scan_depth: Option<usize>,
293}
294
295// Deliberately absent: `allow_manifest_rewrite`.
296//
297// Only the settings whose right value depends on the *project* have a per-repository
298// form. `allow_manifest_rewrite` is a permission the user grants their own machine, and
299// — exactly as with `post_prune_command` below — nothing stops a project from committing
300// its `.devprune.json` despite the `.gitignore` entry [`PerRepoConfig::save`] writes. A
301// per-repository form would therefore let a repository nobody has read grant itself the
302// right to have `cargo generate-lockfile` / `go mod tidy` rewrite its tracked manifests
303// during an unattended pass. The `auto_*` and `update_check*` settings describe the
304// machine rather than a project and would mean nothing here either.
305
306// Removed: `custom_bloat_dirs` and `post_prune_command`.
307//
308// Both were serialized, schema'd and documented but never read by any code path, so
309// setting them did nothing. `post_prune_command` is also not a feature that should be
310// reintroduced casually: nothing stops a project from committing its `.devprune.json`,
311// so honouring it would mean cloning an untrusted repository and running `devp` hands
312// that repository arbitrary code execution on the user's machine.
313
314fn default_schema_url() -> String {
315    if let Ok(config_dir) = Registry::config_dir() {
316        let local_schema = config_dir.join("bin").join("devprune.schema.json");
317        if local_schema.exists() {
318            // `file://` + `/` + an absolute path. Unix paths already start with a
319            // separator, so pasting them in unconditionally produced `file:////home/...`
320            // — four slashes, which editors reject, leaving the `$schema` link dead and
321            // no IntelliSense at all on the platform where most of them run.
322            return file_uri(&crate::output::clean_path(&local_schema));
323        }
324    }
325    constants::JSON_SCHEMA_URL.to_string()
326}
327
328/// A `file://` URI for an absolute path.
329///
330/// `file://` + `/` + the path. Unix paths already start with a separator, so pasting one
331/// in unconditionally produced `file:////home/...` — four slashes, which editors reject,
332/// leaving the `$schema` link dead and no IntelliSense at all on the platform where most
333/// of them run.
334fn file_uri(clean_path: &str) -> String {
335    format!("file:///{}", clean_path.trim_start_matches('/'))
336}
337
338impl Default for PerRepoConfig {
339    fn default() -> Self {
340        Self {
341            schema: default_schema_url(),
342            project_name: None,
343            ignore: false,
344            disable_hooks: false,
345            disable_daemon: false,
346            override_idle_days: None,
347            min_size_mb: None,
348            scan_depth: None,
349        }
350    }
351}
352
353impl PerRepoConfig {
354    /// Load per-repo config from `.devprune.json`, or `None` when there is no such file.
355    ///
356    /// This is the only loader. There used to be a second one that returned `None` for a
357    /// file that failed to parse as well as for one that was absent, and every caller of
358    /// it then went on to act as though the repository had no configuration: the prune
359    /// pass ignored an `"ignore": true` it could not read, and the two workspace toggles
360    /// wrote a fresh default file straight over the user's broken one, taking every
361    /// override in it with them. A caller that genuinely does not care — the display-name
362    /// lookup — says so with `.ok().flatten()`.
363    pub fn load_with_diagnostics(repo_path: &Path) -> Result<Option<Self>, String> {
364        let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
365        if !config_file.exists() {
366            return Ok(None);
367        }
368        let content =
369            fs::read_to_string(&config_file).map_err(|e| format!("Failed to read file: {e}"))?;
370        match serde_json::from_str::<Self>(&content) {
371            Ok(cfg) => Ok(Some(cfg)),
372            // `clean_path`, like every other path this tool shows. `Display` on a
373            // canonicalised Windows path leaks the `\\?\` extended-length prefix into an
374            // error message the user is being asked to act on.
375            Err(e) => Err(format!(
376                "Syntax error in `{}`: {e}",
377                crate::output::clean_path(&config_file)
378            )),
379        }
380    }
381
382    /// Save per-repo config to `.devprune.json` in the repo root and auto-update `.gitignore`.
383    pub fn save_to_repo(&self, repo_path: &Path) -> Result<()> {
384        let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
385        let content = serde_json::to_string_pretty(self)?;
386        fs::write(&config_file, content)?;
387        let _ = ensure_in_gitignore(repo_path, constants::PER_REPO_CONFIG_FILE);
388        let _ = ensure_in_gitignore(repo_path, constants::DEVPRUNE_IGNORE_FILE);
389        Ok(())
390    }
391}
392
393/// One directory a prune pass deleted.
394///
395/// Enough to put it back and nothing more: which repository it belonged to, which
396/// project inside that repository owned it, and who verified it. No file list — the
397/// lockfile is the record of the contents, which is the whole premise of the tool.
398#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
399pub struct PrunedDir {
400    /// Repository root the directory belonged to.
401    pub repo_path: PathBuf,
402    /// Repository-relative label, `/`-separated: `node_modules`, `frontend/node_modules`.
403    pub bloat_dir: String,
404    /// Adapter that verified and deleted it.
405    pub adapter: String,
406    /// Bytes reclaimed.
407    pub size_freed: u64,
408}
409
410/// What the most recent prune pass deleted, for `devp restore --last-run`.
411///
412/// Only passes that actually deleted something are recorded. A later run that frees
413/// nothing — everything was active, everything was already clean — leaves this alone,
414/// because "put back what you just took" should still mean the pass that took something.
415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
416pub struct LastPrune {
417    /// When the pass ran.
418    pub at: DateTime<Utc>,
419    /// Every directory it removed.
420    pub dirs: Vec<PrunedDir>,
421}
422
423/// A one-line summary of a completed prune pass, for `devp stats`.
424///
425/// Deliberately not a second copy of [`LastPrune`]. That one exists so
426/// `devp restore --last-run` can put files back, so it carries the full directory list
427/// and only ever describes the most recent pass. This one is a trend line — four numbers
428/// per pass, bounded by [`constants::PRUNE_HISTORY_LIMIT`] — and could not restore
429/// anything if it wanted to.
430#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
431pub struct PruneRunSummary {
432    /// When the pass ran.
433    pub at: DateTime<Utc>,
434    /// Bytes reclaimed by the pass.
435    pub bytes_freed: u64,
436    /// How many directories it removed.
437    pub dirs_removed: usize,
438    /// How many distinct repositories it touched.
439    pub repos_touched: usize,
440}
441
442/// The top-level registry structure persisted to disk.
443#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
444pub struct Registry {
445    /// Schema version for forward compatibility.
446    pub version: String,
447    /// Global settings.
448    pub settings: Settings,
449    /// Map of canonical repo paths to their metadata.
450    pub repositories: HashMap<PathBuf, RepoEntry>,
451    /// Total cumulative bytes freed historically across all prune passes.
452    #[serde(default)]
453    pub total_freed_bytes: u64,
454    /// How many prune passes have deleted something, ever.
455    ///
456    /// One per *pass*, not per repository and not per directory — a `devp run` that
457    /// cleared eleven directories across four repositories counts once. Incremented in
458    /// exactly one place, [`Registry::record_prune`], which is also where the pass is
459    /// recorded for `devp restore --last-run`; keeping the two together is what stops
460    /// them meaning different things depending on which command did the pruning.
461    #[serde(default)]
462    pub total_pruned_count: u64,
463    /// List of repository paths added in the most recent init/link action (for devp undo).
464    #[serde(default)]
465    pub last_added_repos: Vec<PathBuf>,
466    /// What the most recent prune pass deleted (for `devp restore --last-run`).
467    #[serde(default)]
468    pub last_prune: Option<LastPrune>,
469    /// Summaries of recent prune passes, oldest first, for `devp stats`.
470    ///
471    /// Capped at [`constants::PRUNE_HISTORY_LIMIT`]. Recorded from 1.1.0 onward.
472    #[serde(default)]
473    pub prune_history: Vec<PruneRunSummary>,
474    /// When the release check last ran, so it runs at most once every
475    /// `UPDATE_CHECK_INTERVAL_DAYS` instead of on every command.
476    #[serde(default)]
477    pub last_update_check: Option<DateTime<Utc>>,
478    /// The newest release seen by the last check, so the reminder survives until the
479    /// user actually upgrades without needing the network again.
480    #[serde(default)]
481    pub latest_known_version: Option<String>,
482}
483
484impl Default for Registry {
485    fn default() -> Self {
486        Self {
487            version: "1.0".to_string(),
488            settings: Settings::default(),
489            repositories: HashMap::new(),
490            total_freed_bytes: 0,
491            total_pruned_count: 0,
492            last_added_repos: Vec::new(),
493            last_prune: None,
494            prune_history: Vec::new(),
495            last_update_check: None,
496            latest_known_version: None,
497        }
498    }
499}
500
501impl Registry {
502    /// Returns the path to the config directory (`~/.config/dev-prune/`).
503    ///
504    /// Uses the `dirs` crate to resolve the platform-specific config location:
505    /// - Linux/macOS: `~/.config/dev-prune/`
506    /// - Windows: `C:\Users\<user>\AppData\Roaming\dev-prune\` (or `~/.config/dev-prune/`)
507    pub fn config_dir() -> Result<PathBuf> {
508        if let Ok(override_dir) = std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE) {
509            return Ok(PathBuf::from(override_dir));
510        }
511        let base = dirs::config_dir().context("Could not determine config directory")?;
512        Ok(base.join(constants::CONFIG_DIR_NAME))
513    }
514
515    /// Returns the full path to the registry file.
516    pub fn registry_path() -> Result<PathBuf> {
517        Ok(Self::config_dir()?.join(constants::REGISTRY_FILENAME))
518    }
519
520    /// Loads the registry from disk, or the defaults when there is nothing to load.
521    ///
522    /// Reading does not write. This used to persist the default registry on the way
523    /// out, which made `devp --dry-run init` create the very file it had just promised
524    /// not to write and gave `devp status --json` — documented as a pure read — a side
525    /// effect on first use. Every command that actually changes something calls
526    /// [`Registry::save`], and that creates the directory as needed.
527    pub fn load() -> Result<Self> {
528        Self::load_from(&Self::registry_path()?)
529    }
530
531    /// Loads the registry from a specific path (for testing or custom locations).
532    ///
533    /// Non-persisting, exactly like [`Registry::load`], which is implemented on top of
534    /// it. The two used to disagree — this one wrote the defaults out when the file was
535    /// missing — which is the sort of difference that makes a test pass while the
536    /// behaviour it stands in for is broken.
537    pub fn load_from(path: &Path) -> Result<Self> {
538        if !path.exists() {
539            return Ok(Registry::default());
540        }
541        let contents = fs::read_to_string(path)
542            .with_context(|| format!("Failed to read registry at {}", path.display()))?;
543        serde_json::from_str(&contents)
544            .with_context(|| format!("Failed to parse registry at {}", path.display()))
545    }
546
547    /// Saves the registry to disk atomically (write to temp, then rename).
548    pub fn save(&self) -> Result<()> {
549        let path = Self::registry_path()?;
550        self.save_to(&path)
551    }
552
553    /// Saves the registry to a specific path (for testing or custom locations).
554    pub fn save_to(&self, path: &Path) -> Result<()> {
555        if let Some(parent) = path.parent() {
556            fs::create_dir_all(parent)
557                .with_context(|| format!("Failed to create config dir {}", parent.display()))?;
558        }
559        // Unique per process. A manual run and the scheduled daemon pass can save at the
560        // same moment; with a shared `registry.json.tmp`, one process could rename the
561        // other's half-written file into place as a torn, unparseable registry.
562        let tmp_path = path.with_extension(format!("json.{}.tmp", std::process::id()));
563        let contents =
564            serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
565        fs::write(&tmp_path, &contents)
566            .with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
567        fs::rename(&tmp_path, path)
568            .with_context(|| format!("Failed to rename temp registry to {}", path.display()))?;
569        Ok(())
570    }
571
572    /// Adds a repository to the registry. Returns `true` if newly added, `false` if already present.
573    pub fn add_repo(&mut self, path: PathBuf) -> bool {
574        // The registry is keyed by path, so `./foo`, `foo/`, and the absolute form
575        // would otherwise register as three separate repositories.
576        let path = canonical_key(&path);
577        if self.repositories.contains_key(&path) {
578            return false;
579        }
580        self.repositories.insert(path, RepoEntry::new());
581        true
582    }
583
584    /// Removes a repository from the registry. Returns `true` if it was present.
585    pub fn remove_repo(&mut self, path: &Path) -> bool {
586        self.repositories.remove(&canonical_key(path)).is_some()
587    }
588
589    // Removed: `repo_paths` and `effective_idle_days`.
590    //
591    // Neither had a caller outside this file's own tests. `effective_idle_days` had also
592    // drifted from the rule the engine actually applies: it looked the repository up by
593    // the path as given, where every write to `repositories` goes through
594    // `canonical_key`, so `devp`'s own relative paths would have missed the entry and
595    // silently returned the global threshold instead of the repository's override.
596
597    /// Credit `bytes_freed` to one repository, and to the machine-wide total.
598    ///
599    /// Safe to call once per repository or once per directory — every figure it touches
600    /// is either a sum or a timestamp, so the two styles agree. Counting *passes* is
601    /// deliberately not done here for exactly that reason; that lives in
602    /// [`Registry::record_prune`], which is called once per pass.
603    pub fn mark_pruned(&mut self, path: &Path, bytes_freed: u64) {
604        // Same rule as every other accessor: the map is keyed by `canonical_key`, so a
605        // raw lookup would silently skip the per-repo credit for a relative or
606        // differently-spelled path while still growing the machine-wide total.
607        if let Some(entry) = self.repositories.get_mut(&canonical_key(path)) {
608            entry.last_pruned_at = Some(Utc::now());
609            entry.total_freed_bytes += bytes_freed;
610        }
611        self.total_freed_bytes += bytes_freed;
612    }
613
614    /// Record what a prune pass deleted, replacing any earlier record.
615    ///
616    /// A pass that deleted nothing is not a pass worth remembering, so an empty list is
617    /// ignored rather than stored — otherwise `devp run` on an already-clean machine
618    /// would quietly throw away the record of the run the user actually wants back.
619    ///
620    /// This is the one place a prune pass is counted. It sets [`Registry::last_prune`],
621    /// appends a [`PruneRunSummary`] to [`Registry::prune_history`] and bumps
622    /// [`Registry::total_pruned_count`], because "a pass happened and it deleted things"
623    /// is exactly the condition all three describe. Splitting them across call sites is
624    /// how the counter previously came to mean repositories in `devp run` and directories
625    /// in the `devp status` dashboard.
626    pub fn record_prune(&mut self, dirs: Vec<PrunedDir>) {
627        self.record_prune_progress(Utc::now(), dirs);
628    }
629
630    /// Record a pass's progress mid-flight, superseding this same pass's earlier record.
631    ///
632    /// `at` identifies the pass: a repeated call with the same timestamp replaces the
633    /// history entry and `last_prune` it wrote before, rather than counting a second
634    /// pass. This exists so a long pass can persist after every repository — a crash
635    /// half-way through used to leave `devp restore --last-run` pointing at the
636    /// *previous* pass, offering to reinstall directories that were never deleted while
637    /// saying nothing about the ones that were.
638    pub fn record_prune_progress(&mut self, at: DateTime<Utc>, dirs: Vec<PrunedDir>) {
639        if dirs.is_empty() {
640            return;
641        }
642        if self.prune_history.last().map(|s| s.at) == Some(at) {
643            self.prune_history.pop();
644        } else {
645            self.total_pruned_count += 1;
646        }
647
648        self.prune_history.push(PruneRunSummary {
649            at,
650            bytes_freed: dirs.iter().map(|d| d.size_freed).sum(),
651            dirs_removed: dirs.len(),
652            repos_touched: dirs
653                .iter()
654                .map(|d| &d.repo_path)
655                .collect::<HashSet<_>>()
656                .len(),
657        });
658        // Oldest first, so the overflow comes off the front.
659        if self.prune_history.len() > constants::PRUNE_HISTORY_LIMIT {
660            let excess = self.prune_history.len() - constants::PRUNE_HISTORY_LIMIT;
661            self.prune_history.drain(..excess);
662        }
663
664        self.last_prune = Some(LastPrune { at, dirs });
665    }
666
667    /// Returns the number of registered repositories.
668    pub fn repo_count(&self) -> usize {
669        self.repositories.len()
670    }
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use tempfile::TempDir;
677
678    fn test_registry_path(dir: &TempDir) -> PathBuf {
679        dir.path().join("dev-prune").join("registry.json")
680    }
681
682    fn a_pruned_dir(label: &str) -> PrunedDir {
683        PrunedDir {
684            repo_path: PathBuf::from("/repo"),
685            bloat_dir: label.to_string(),
686            adapter: "npm".to_string(),
687            size_freed: 42,
688        }
689    }
690
691    #[test]
692    fn a_prune_that_deleted_nothing_does_not_erase_the_last_one() {
693        // Otherwise a second `devp run` on an already-clean machine throws away the
694        // record of the pass the user actually wants to undo.
695        let mut registry = Registry::default();
696        registry.record_prune(vec![a_pruned_dir("node_modules")]);
697        let recorded = registry.last_prune.clone().expect("first pass recorded");
698
699        registry.record_prune(Vec::new());
700
701        assert_eq!(registry.last_prune, Some(recorded));
702    }
703
704    #[test]
705    fn a_later_prune_replaces_the_record() {
706        let mut registry = Registry::default();
707        registry.record_prune(vec![a_pruned_dir("node_modules")]);
708        registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
709
710        let dirs = registry.last_prune.unwrap().dirs;
711        assert_eq!(dirs.len(), 1);
712        assert_eq!(dirs[0].bloat_dir, "frontend/node_modules");
713    }
714
715    #[test]
716    fn the_last_prune_record_survives_a_save_and_load() {
717        // `restore --last-run` reads it out of a file written by a process that has
718        // already exited, so the round trip is the whole feature.
719        let dir = TempDir::new().unwrap();
720        let path = test_registry_path(&dir);
721
722        let mut registry = Registry::default();
723        registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
724        registry.save_to(&path).unwrap();
725
726        let loaded = Registry::load_from(&path).unwrap();
727        assert_eq!(loaded.last_prune, registry.last_prune);
728    }
729
730    #[test]
731    fn a_registry_written_before_the_field_existed_still_loads() {
732        // The registry on disk predates `last_prune`; a missing key means "no pass
733        // recorded", not a parse failure that would lock the user out of their config.
734        let dir = TempDir::new().unwrap();
735        let path = test_registry_path(&dir);
736        fs::create_dir_all(path.parent().unwrap()).unwrap();
737        fs::write(
738            &path,
739            r#"{"version":"1.0","settings":{"idle_days":15,"check_interval_days":2,
740               "auto_daemon":true},"repositories":{}}"#,
741        )
742        .unwrap();
743
744        let loaded = Registry::load_from(&path).unwrap();
745        assert_eq!(loaded.last_prune, None);
746    }
747
748    #[test]
749    fn a_leading_tilde_becomes_the_home_directory() {
750        // The whole reason this exists: PowerShell hands `devp init ~/Code` straight
751        // through, so without expansion the registry gains a repository at `.\~\Code`.
752        let home = dirs::home_dir().expect("test host has a home directory");
753
754        assert_eq!(expand_tilde("~"), home.to_string_lossy());
755        assert_eq!(
756            expand_tilde("~/Code"),
757            home.join("Code").to_string_lossy(),
758            "forward slash, as typed in every shell"
759        );
760        assert_eq!(
761            expand_tilde("~\\Code"),
762            home.join("Code").to_string_lossy(),
763            "backslash, as typed in PowerShell"
764        );
765    }
766
767    #[test]
768    fn a_tilde_that_is_not_a_home_reference_is_left_alone() {
769        // `~alice` is another user's home in shell syntax and cannot be resolved
770        // portably; `~backup` and `./~tmp` are ordinary directory names. Rewriting any
771        // of them would silently point the user at the wrong directory.
772        for raw in ["~alice/Code", "~backup", "./~tmp", "Code~", "", "."] {
773            assert_eq!(expand_tilde(raw), raw, "{raw} must survive untouched");
774        }
775    }
776
777    #[test]
778    fn test_default_settings() {
779        let settings = Settings::default();
780        assert_eq!(settings.idle_days, 15);
781        assert_eq!(settings.check_interval_days, 2);
782        // On by default: dev-prune installs its own integrations, once per version,
783        // and only the ones it finds missing.
784        assert!(settings.auto_daemon);
785        assert!(settings.auto_hooks);
786        assert!(settings.auto_setup);
787    }
788
789    #[test]
790    fn settings_written_before_the_automation_toggles_existed_still_load() {
791        // Real registries on disk predate `auto_hooks` / `auto_setup`; an upgrade must
792        // read them rather than fail to parse and lose every registered repository.
793        let json = r#"{
794            "idle_days": 30,
795            "check_interval_days": 2,
796            "auto_daemon": false
797        }"#;
798        let settings: Settings = serde_json::from_str(json).unwrap();
799        assert_eq!(settings.idle_days, 30);
800        assert!(!settings.auto_daemon, "an explicit opt-out is preserved");
801        assert!(settings.auto_hooks, "a missing key takes the default");
802        assert!(settings.auto_setup);
803    }
804
805    #[test]
806    fn test_default_registry() {
807        let registry = Registry::default();
808        assert_eq!(registry.version, "1.0");
809        assert_eq!(registry.settings, Settings::default());
810        assert!(registry.repositories.is_empty());
811    }
812
813    #[test]
814    fn test_repo_entry_new() {
815        let entry = RepoEntry::new();
816        assert!(entry.enabled);
817        assert!(entry.last_pruned_at.is_none());
818        assert!(entry.override_idle_days.is_none());
819    }
820
821    #[test]
822    fn test_save_and_load() {
823        let tmp = TempDir::new().unwrap();
824        let path = test_registry_path(&tmp);
825
826        let mut registry = Registry::default();
827        registry.add_repo(PathBuf::from("/test/repo"));
828        registry.save_to(&path).unwrap();
829
830        let loaded = Registry::load_from(&path).unwrap();
831        assert_eq!(loaded.repo_count(), 1);
832        assert!(
833            loaded
834                .repositories
835                .contains_key(&PathBuf::from("/test/repo"))
836        );
837    }
838
839    #[test]
840    fn loading_a_missing_registry_yields_the_defaults_and_writes_nothing() {
841        let tmp = TempDir::new().unwrap();
842        let path = test_registry_path(&tmp);
843
844        let loaded = Registry::load_from(&path).unwrap();
845        assert_eq!(loaded, Registry::default());
846        // Reading is not writing. `devp --dry-run` and `devp status --json` both promise
847        // to leave the disk alone, and both start by loading the registry.
848        assert!(!path.exists(), "loading the registry created it");
849    }
850
851    #[test]
852    fn test_add_repo_returns_true_for_new() {
853        let mut registry = Registry::default();
854        assert!(registry.add_repo(PathBuf::from("/test/repo")));
855    }
856
857    #[test]
858    fn test_add_repo_returns_false_for_duplicate() {
859        let mut registry = Registry::default();
860        registry.add_repo(PathBuf::from("/test/repo"));
861        assert!(!registry.add_repo(PathBuf::from("/test/repo")));
862    }
863
864    #[test]
865    fn test_remove_repo() {
866        let mut registry = Registry::default();
867        registry.add_repo(PathBuf::from("/test/repo"));
868        assert!(registry.remove_repo(Path::new("/test/repo")));
869        assert!(!registry.remove_repo(Path::new("/test/repo")));
870        assert_eq!(registry.repo_count(), 0);
871    }
872
873    #[test]
874    fn test_mark_pruned() {
875        let mut registry = Registry::default();
876        registry.add_repo(PathBuf::from("/test/repo"));
877        assert!(
878            registry.repositories[&PathBuf::from("/test/repo")]
879                .last_pruned_at
880                .is_none()
881        );
882        registry.mark_pruned(Path::new("/test/repo"), 1024);
883        assert!(
884            registry.repositories[&PathBuf::from("/test/repo")]
885                .last_pruned_at
886                .is_some()
887        );
888        assert_eq!(registry.total_freed_bytes, 1024);
889        // Not the pass counter — that is `record_prune`'s job, once per pass.
890        assert_eq!(registry.total_pruned_count, 0);
891    }
892
893    #[test]
894    fn a_pass_is_counted_once_however_much_it_deleted() {
895        // The counter is published as `prune_passes`, and it used to be incremented once
896        // per repository by `devp run` and once per *directory* by the status dashboard,
897        // so the same work produced a different number depending on where it started.
898        let mut registry = Registry::default();
899        registry.add_repo(PathBuf::from("/repo"));
900
901        registry.mark_pruned(Path::new("/repo"), 1024);
902        registry.mark_pruned(Path::new("/repo"), 1024);
903        registry.record_prune(vec![
904            a_pruned_dir("node_modules"),
905            a_pruned_dir("frontend/node_modules"),
906        ]);
907
908        assert_eq!(registry.total_pruned_count, 1);
909
910        registry.record_prune(vec![a_pruned_dir("target")]);
911        assert_eq!(registry.total_pruned_count, 2);
912
913        // A pass that deleted nothing is not a pass.
914        registry.record_prune(Vec::new());
915        assert_eq!(registry.total_pruned_count, 2);
916    }
917
918    #[test]
919    fn mark_pruned_credits_the_repo_under_its_canonical_key() {
920        // On Windows, `canonicalize` yields a `\\?\`-prefixed path, so a registry keyed
921        // by the canonical form and a `mark_pruned` looking up the raw form would miss —
922        // growing the machine-wide total while the repository's own figure stayed zero.
923        let tmp = TempDir::new().unwrap();
924        let raw = tmp.path().to_path_buf();
925
926        let mut registry = Registry::default();
927        registry.add_repo(raw.clone());
928        registry.mark_pruned(&raw, 1024);
929
930        let entry = &registry.repositories[&canonical_key(&raw)];
931        assert_eq!(entry.total_freed_bytes, 1024);
932        assert!(entry.last_pruned_at.is_some());
933        assert_eq!(registry.total_freed_bytes, 1024);
934    }
935
936    #[test]
937    fn each_repository_accumulates_its_own_total() {
938        // `devp stats` ranks repositories against each other, so the per-repo figure has
939        // to be a running total and not the size of the most recent pass.
940        let mut registry = Registry::default();
941        registry.add_repo(PathBuf::from("/test/repo"));
942        registry.add_repo(PathBuf::from("/test/other"));
943
944        registry.mark_pruned(Path::new("/test/repo"), 1024);
945        registry.mark_pruned(Path::new("/test/repo"), 2048);
946        registry.mark_pruned(Path::new("/test/other"), 512);
947
948        assert_eq!(
949            registry.repositories[&PathBuf::from("/test/repo")].total_freed_bytes,
950            3072
951        );
952        assert_eq!(
953            registry.repositories[&PathBuf::from("/test/other")].total_freed_bytes,
954            512
955        );
956        assert_eq!(registry.total_freed_bytes, 3584);
957    }
958
959    #[test]
960    fn the_prune_history_summarises_the_pass() {
961        let mut registry = Registry::default();
962        registry.record_prune(vec![
963            a_pruned_dir("node_modules"),
964            a_pruned_dir("frontend/node_modules"),
965        ]);
966
967        let summary = registry.prune_history.last().expect("pass summarised");
968        assert_eq!(summary.bytes_freed, 84);
969        assert_eq!(summary.dirs_removed, 2);
970        // Both fixtures live under `/repo`, so this is one repository, not two.
971        assert_eq!(summary.repos_touched, 1);
972    }
973
974    #[test]
975    fn the_prune_history_is_capped_and_drops_the_oldest() {
976        // The registry is rewritten in full on every save, so an uncapped list would grow
977        // the file forever on a machine running the scheduled pass.
978        let mut registry = Registry::default();
979        for _ in 0..constants::PRUNE_HISTORY_LIMIT + 5 {
980            registry.record_prune(vec![a_pruned_dir("node_modules")]);
981        }
982
983        assert_eq!(registry.prune_history.len(), constants::PRUNE_HISTORY_LIMIT);
984        let first = registry.prune_history.first().unwrap().at;
985        let last = registry.prune_history.last().unwrap().at;
986        assert!(first <= last, "oldest first");
987    }
988
989    #[test]
990    fn test_repo_count() {
991        let mut registry = Registry::default();
992        assert_eq!(registry.repo_count(), 0);
993        registry.add_repo(PathBuf::from("/a"));
994        registry.add_repo(PathBuf::from("/b"));
995        assert_eq!(registry.repo_count(), 2);
996    }
997
998    #[test]
999    fn a_local_schema_uri_has_exactly_three_slashes_on_either_platform() {
1000        assert_eq!(
1001            file_uri("/home/dev/.config/dev-prune/bin/devprune.schema.json"),
1002            "file:///home/dev/.config/dev-prune/bin/devprune.schema.json"
1003        );
1004        assert_eq!(
1005            file_uri("C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"),
1006            "file:///C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"
1007        );
1008    }
1009
1010    #[test]
1011    fn a_broken_per_repo_config_is_an_error_rather_than_an_absent_one() {
1012        // The distinction the whole tool leans on: "no config" means take the defaults,
1013        // "unreadable config" means refuse — never overwrite, never prune on a guess.
1014        let tmp = TempDir::new().unwrap();
1015        let repo = tmp.path();
1016        assert_eq!(PerRepoConfig::load_with_diagnostics(repo), Ok(None));
1017
1018        fs::write(
1019            repo.join(constants::PER_REPO_CONFIG_FILE),
1020            r#"{ "ignore": true, }"#,
1021        )
1022        .unwrap();
1023        let err = PerRepoConfig::load_with_diagnostics(repo).unwrap_err();
1024        assert!(err.contains("Syntax error"), "{err}");
1025
1026        fs::write(
1027            repo.join(constants::PER_REPO_CONFIG_FILE),
1028            r#"{ "ignore": true }"#,
1029        )
1030        .unwrap();
1031        assert!(
1032            PerRepoConfig::load_with_diagnostics(repo)
1033                .unwrap()
1034                .unwrap()
1035                .ignore
1036        );
1037    }
1038
1039    #[test]
1040    fn test_serialization_roundtrip() {
1041        let mut registry = Registry::default();
1042        registry.settings.idle_days = 30;
1043        registry.add_repo(PathBuf::from("/test/repo"));
1044
1045        let json = serde_json::to_string_pretty(&registry).unwrap();
1046        let deserialized: Registry = serde_json::from_str(&json).unwrap();
1047        assert_eq!(registry.settings.idle_days, deserialized.settings.idle_days);
1048        assert_eq!(registry.repo_count(), deserialized.repo_count());
1049    }
1050
1051    #[test]
1052    fn test_atomic_save_leaves_no_tmp() {
1053        let tmp = TempDir::new().unwrap();
1054        let path = test_registry_path(&tmp);
1055
1056        let registry = Registry::default();
1057        registry.save_to(&path).unwrap();
1058
1059        assert!(path.exists());
1060        // Nothing but the registry itself may remain — a leftover `*.tmp` would mean the
1061        // rename never happened.
1062        let leftovers: Vec<_> = fs::read_dir(path.parent().unwrap())
1063            .unwrap()
1064            .flatten()
1065            .filter(|e| e.path() != path)
1066            .collect();
1067        assert!(leftovers.is_empty(), "leftover files: {leftovers:?}");
1068    }
1069}