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;
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}
171
172impl RepoEntry {
173    /// Creates a new `RepoEntry` with the current timestamp.
174    pub fn new() -> Self {
175        Self {
176            added_at: Utc::now(),
177            last_pruned_at: None,
178            override_idle_days: None,
179            enabled: true,
180        }
181    }
182}
183
184impl Default for RepoEntry {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190/// Helper to ensure an entry (e.g. ".devprune.json") is present in the repository's `.gitignore`.
191/// If `.gitignore` doesn't exist, it creates it and adds the entry.
192pub fn ensure_in_gitignore(repo_path: &Path, entry: &str) -> Result<()> {
193    let gitignore_path = repo_path.join(".gitignore");
194    if gitignore_path.exists() {
195        let content = fs::read_to_string(&gitignore_path)?;
196        if !content.lines().any(|line| line.trim() == entry) {
197            let mut file = fs::OpenOptions::new().append(true).open(&gitignore_path)?;
198            let prefix = if content.ends_with('\n') || content.is_empty() {
199                ""
200            } else {
201                "\n"
202            };
203            writeln!(file, "{prefix}{entry}")?;
204        }
205    } else {
206        fs::write(&gitignore_path, format!("{entry}\n"))?;
207    }
208    Ok(())
209}
210
211/// Normalise a repository path into the form used as a registry key.
212///
213/// Falls back to the path as given when it cannot be canonicalised (e.g. it no longer
214/// exists), so entries for deleted repos stay addressable.
215pub fn canonical_key(path: &Path) -> PathBuf {
216    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
217}
218
219/// Expand a leading `~` to the user's home directory.
220///
221/// POSIX shells do this before the argument ever reaches a program, so on Linux and
222/// macOS it is usually a no-op. PowerShell and cmd do not: they hand a native
223/// executable the literal three characters `~/C`, and `devp init ~/Code` — the exact
224/// line in the README and on the landing page — would register a directory called `~`
225/// sitting in the current working directory. Quoting defeats the expansion in *every*
226/// shell, so `devp init "~/Code"` needs this too.
227///
228/// Only a bare `~` or a `~` followed by a separator is expanded. `~alice` means "some
229/// other user's home" in shell syntax and cannot be resolved portably, and `~backup` is
230/// a perfectly ordinary directory name.
231pub fn expand_tilde(raw: &str) -> String {
232    let Some(rest) = raw.strip_prefix('~') else {
233        return raw.to_string();
234    };
235    if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\')) {
236        return raw.to_string();
237    }
238    let Some(home) = dirs::home_dir() else {
239        // No home directory to expand to. Handing back the literal `~` lets the caller
240        // fail with "no such directory", which is a better error than a silent guess.
241        return raw.to_string();
242    };
243    if rest.is_empty() {
244        return home.to_string_lossy().into_owned();
245    }
246    home.join(rest.trim_start_matches(['/', '\\']))
247        .to_string_lossy()
248        .into_owned()
249}
250
251/// Structured per-repository configuration file stored inside repo roots as `.devprune.json`.
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
253pub struct PerRepoConfig {
254    /// JSON Schema reference URL for IDE IntelliSense and validation.
255    #[serde(rename = "$schema", default = "default_schema_url")]
256    pub schema: String,
257    /// Custom display name for this project in TUI and CLI status views.
258    #[serde(default)]
259    pub project_name: Option<String>,
260    /// Whether this repository is ignored/excluded from pruning.
261    #[serde(default)]
262    pub ignore: bool,
263    /// Disable global Git auto-registration hooks for this specific workspace.
264    #[serde(default)]
265    pub disable_hooks: bool,
266    /// Disable background daemon automated pruning pass for this specific workspace.
267    #[serde(default)]
268    pub disable_daemon: bool,
269    /// Custom override for idle days threshold (overrides global settings).
270    #[serde(default)]
271    pub override_idle_days: Option<u64>,
272    /// Custom override for the size floor, in MiB (overrides global `min_size_mb`).
273    ///
274    /// `Some(0)` is a meaningful value: it turns the floor off for this repository even
275    /// when a global floor is set.
276    #[serde(default)]
277    pub min_size_mb: Option<u64>,
278    /// Custom override for how deep discovery walks this repository.
279    ///
280    /// The setting that most often needs to differ per repository rather than globally:
281    /// one deeply-nested monorepo should not make every other repository pay for a
282    /// deeper walk. Clamped to [`constants::MAX_SCAN_DEPTH_LIMIT`] like the global one.
283    #[serde(default)]
284    pub scan_depth: Option<usize>,
285}
286
287// Deliberately absent: `allow_manifest_rewrite`.
288//
289// Only the settings whose right value depends on the *project* have a per-repository
290// form. `allow_manifest_rewrite` is a permission the user grants their own machine, and
291// — exactly as with `post_prune_command` below — nothing stops a project from committing
292// its `.devprune.json` despite the `.gitignore` entry [`PerRepoConfig::save`] writes. A
293// per-repository form would therefore let a repository nobody has read grant itself the
294// right to have `cargo generate-lockfile` / `go mod tidy` rewrite its tracked manifests
295// during an unattended pass. The `auto_*` and `update_check*` settings describe the
296// machine rather than a project and would mean nothing here either.
297
298// Removed: `custom_bloat_dirs` and `post_prune_command`.
299//
300// Both were serialized, schema'd and documented but never read by any code path, so
301// setting them did nothing. `post_prune_command` is also not a feature that should be
302// reintroduced casually: nothing stops a project from committing its `.devprune.json`,
303// so honouring it would mean cloning an untrusted repository and running `devp` hands
304// that repository arbitrary code execution on the user's machine.
305
306fn default_schema_url() -> String {
307    if let Ok(config_dir) = Registry::config_dir() {
308        let local_schema = config_dir.join("bin").join("devprune.schema.json");
309        if local_schema.exists() {
310            // `file://` + `/` + an absolute path. Unix paths already start with a
311            // separator, so pasting them in unconditionally produced `file:////home/...`
312            // — four slashes, which editors reject, leaving the `$schema` link dead and
313            // no IntelliSense at all on the platform where most of them run.
314            return file_uri(&crate::output::clean_path(&local_schema));
315        }
316    }
317    constants::JSON_SCHEMA_URL.to_string()
318}
319
320/// A `file://` URI for an absolute path.
321///
322/// `file://` + `/` + the path. Unix paths already start with a separator, so pasting one
323/// in unconditionally produced `file:////home/...` — four slashes, which editors reject,
324/// leaving the `$schema` link dead and no IntelliSense at all on the platform where most
325/// of them run.
326fn file_uri(clean_path: &str) -> String {
327    format!("file:///{}", clean_path.trim_start_matches('/'))
328}
329
330impl Default for PerRepoConfig {
331    fn default() -> Self {
332        Self {
333            schema: default_schema_url(),
334            project_name: None,
335            ignore: false,
336            disable_hooks: false,
337            disable_daemon: false,
338            override_idle_days: None,
339            min_size_mb: None,
340            scan_depth: None,
341        }
342    }
343}
344
345impl PerRepoConfig {
346    /// Load per-repo config from `.devprune.json`, or `None` when there is no such file.
347    ///
348    /// This is the only loader. There used to be a second one that returned `None` for a
349    /// file that failed to parse as well as for one that was absent, and every caller of
350    /// it then went on to act as though the repository had no configuration: the prune
351    /// pass ignored an `"ignore": true` it could not read, and the two workspace toggles
352    /// wrote a fresh default file straight over the user's broken one, taking every
353    /// override in it with them. A caller that genuinely does not care — the display-name
354    /// lookup — says so with `.ok().flatten()`.
355    pub fn load_with_diagnostics(repo_path: &Path) -> Result<Option<Self>, String> {
356        let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
357        if !config_file.exists() {
358            return Ok(None);
359        }
360        let content =
361            fs::read_to_string(&config_file).map_err(|e| format!("Failed to read file: {e}"))?;
362        match serde_json::from_str::<Self>(&content) {
363            Ok(cfg) => Ok(Some(cfg)),
364            // `clean_path`, like every other path this tool shows. `Display` on a
365            // canonicalised Windows path leaks the `\\?\` extended-length prefix into an
366            // error message the user is being asked to act on.
367            Err(e) => Err(format!(
368                "Syntax error in `{}`: {e}",
369                crate::output::clean_path(&config_file)
370            )),
371        }
372    }
373
374    /// Save per-repo config to `.devprune.json` in the repo root and auto-update `.gitignore`.
375    pub fn save_to_repo(&self, repo_path: &Path) -> Result<()> {
376        let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
377        let content = serde_json::to_string_pretty(self)?;
378        fs::write(&config_file, content)?;
379        let _ = ensure_in_gitignore(repo_path, constants::PER_REPO_CONFIG_FILE);
380        let _ = ensure_in_gitignore(repo_path, constants::DEVPRUNE_IGNORE_FILE);
381        Ok(())
382    }
383}
384
385/// One directory a prune pass deleted.
386///
387/// Enough to put it back and nothing more: which repository it belonged to, which
388/// project inside that repository owned it, and who verified it. No file list — the
389/// lockfile is the record of the contents, which is the whole premise of the tool.
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
391pub struct PrunedDir {
392    /// Repository root the directory belonged to.
393    pub repo_path: PathBuf,
394    /// Repository-relative label, `/`-separated: `node_modules`, `frontend/node_modules`.
395    pub bloat_dir: String,
396    /// Adapter that verified and deleted it.
397    pub adapter: String,
398    /// Bytes reclaimed.
399    pub size_freed: u64,
400}
401
402/// What the most recent prune pass deleted, for `devp restore --last-run`.
403///
404/// Only passes that actually deleted something are recorded. A later run that frees
405/// nothing — everything was active, everything was already clean — leaves this alone,
406/// because "put back what you just took" should still mean the pass that took something.
407#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
408pub struct LastPrune {
409    /// When the pass ran.
410    pub at: DateTime<Utc>,
411    /// Every directory it removed.
412    pub dirs: Vec<PrunedDir>,
413}
414
415/// The top-level registry structure persisted to disk.
416#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
417pub struct Registry {
418    /// Schema version for forward compatibility.
419    pub version: String,
420    /// Global settings.
421    pub settings: Settings,
422    /// Map of canonical repo paths to their metadata.
423    pub repositories: HashMap<PathBuf, RepoEntry>,
424    /// Total cumulative bytes freed historically across all prune passes.
425    #[serde(default)]
426    pub total_freed_bytes: u64,
427    /// Total count of successful prune operations executed historically.
428    #[serde(default)]
429    pub total_pruned_count: u64,
430    /// List of repository paths added in the most recent init/link action (for devp undo).
431    #[serde(default)]
432    pub last_added_repos: Vec<PathBuf>,
433    /// What the most recent prune pass deleted (for `devp restore --last-run`).
434    #[serde(default)]
435    pub last_prune: Option<LastPrune>,
436    /// When the release check last ran, so it runs at most once every
437    /// `UPDATE_CHECK_INTERVAL_DAYS` instead of on every command.
438    #[serde(default)]
439    pub last_update_check: Option<DateTime<Utc>>,
440    /// The newest release seen by the last check, so the reminder survives until the
441    /// user actually upgrades without needing the network again.
442    #[serde(default)]
443    pub latest_known_version: Option<String>,
444}
445
446impl Default for Registry {
447    fn default() -> Self {
448        Self {
449            version: "1.0".to_string(),
450            settings: Settings::default(),
451            repositories: HashMap::new(),
452            total_freed_bytes: 0,
453            total_pruned_count: 0,
454            last_added_repos: Vec::new(),
455            last_prune: None,
456            last_update_check: None,
457            latest_known_version: None,
458        }
459    }
460}
461
462impl Registry {
463    /// Returns the path to the config directory (`~/.config/dev-prune/`).
464    ///
465    /// Uses the `dirs` crate to resolve the platform-specific config location:
466    /// - Linux/macOS: `~/.config/dev-prune/`
467    /// - Windows: `C:\Users\<user>\AppData\Roaming\dev-prune\` (or `~/.config/dev-prune/`)
468    pub fn config_dir() -> Result<PathBuf> {
469        if let Ok(override_dir) = std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE) {
470            return Ok(PathBuf::from(override_dir));
471        }
472        let base = dirs::config_dir().context("Could not determine config directory")?;
473        Ok(base.join(constants::CONFIG_DIR_NAME))
474    }
475
476    /// Returns the full path to the registry file.
477    pub fn registry_path() -> Result<PathBuf> {
478        Ok(Self::config_dir()?.join(constants::REGISTRY_FILENAME))
479    }
480
481    /// Loads the registry from disk, or the defaults when there is nothing to load.
482    ///
483    /// Reading does not write. This used to persist the default registry on the way
484    /// out, which made `devp --dry-run init` create the very file it had just promised
485    /// not to write and gave `devp status --json` — documented as a pure read — a side
486    /// effect on first use. Every command that actually changes something calls
487    /// [`Registry::save`], and that creates the directory as needed.
488    pub fn load() -> Result<Self> {
489        Self::load_from(&Self::registry_path()?)
490    }
491
492    /// Loads the registry from a specific path (for testing or custom locations).
493    ///
494    /// Non-persisting, exactly like [`Registry::load`], which is implemented on top of
495    /// it. The two used to disagree — this one wrote the defaults out when the file was
496    /// missing — which is the sort of difference that makes a test pass while the
497    /// behaviour it stands in for is broken.
498    pub fn load_from(path: &Path) -> Result<Self> {
499        if !path.exists() {
500            return Ok(Registry::default());
501        }
502        let contents = fs::read_to_string(path)
503            .with_context(|| format!("Failed to read registry at {}", path.display()))?;
504        serde_json::from_str(&contents)
505            .with_context(|| format!("Failed to parse registry at {}", path.display()))
506    }
507
508    /// Saves the registry to disk atomically (write to temp, then rename).
509    pub fn save(&self) -> Result<()> {
510        let path = Self::registry_path()?;
511        self.save_to(&path)
512    }
513
514    /// Saves the registry to a specific path (for testing or custom locations).
515    pub fn save_to(&self, path: &Path) -> Result<()> {
516        if let Some(parent) = path.parent() {
517            fs::create_dir_all(parent)
518                .with_context(|| format!("Failed to create config dir {}", parent.display()))?;
519        }
520        let tmp_path = path.with_extension("json.tmp");
521        let contents =
522            serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
523        fs::write(&tmp_path, &contents)
524            .with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
525        fs::rename(&tmp_path, path)
526            .with_context(|| format!("Failed to rename temp registry to {}", path.display()))?;
527        Ok(())
528    }
529
530    /// Adds a repository to the registry. Returns `true` if newly added, `false` if already present.
531    pub fn add_repo(&mut self, path: PathBuf) -> bool {
532        // The registry is keyed by path, so `./foo`, `foo/`, and the absolute form
533        // would otherwise register as three separate repositories.
534        let path = canonical_key(&path);
535        if self.repositories.contains_key(&path) {
536            return false;
537        }
538        self.repositories.insert(path, RepoEntry::new());
539        true
540    }
541
542    /// Removes a repository from the registry. Returns `true` if it was present.
543    pub fn remove_repo(&mut self, path: &Path) -> bool {
544        self.repositories.remove(&canonical_key(path)).is_some()
545    }
546
547    // Removed: `repo_paths` and `effective_idle_days`.
548    //
549    // Neither had a caller outside this file's own tests. `effective_idle_days` had also
550    // drifted from the rule the engine actually applies: it looked the repository up by
551    // the path as given, where every write to `repositories` goes through
552    // `canonical_key`, so `devp`'s own relative paths would have missed the entry and
553    // silently returned the global threshold instead of the repository's override.
554
555    /// Marks a repo as pruned with the current timestamp and updates cumulative historical metrics.
556    pub fn mark_pruned(&mut self, path: &Path, bytes_freed: u64) {
557        if let Some(entry) = self.repositories.get_mut(path) {
558            entry.last_pruned_at = Some(Utc::now());
559        }
560        self.total_freed_bytes += bytes_freed;
561        self.total_pruned_count += 1;
562    }
563
564    /// Record what a prune pass deleted, replacing any earlier record.
565    ///
566    /// A pass that deleted nothing is not a pass worth remembering, so an empty list is
567    /// ignored rather than stored — otherwise `devp run` on an already-clean machine
568    /// would quietly throw away the record of the run the user actually wants back.
569    pub fn record_prune(&mut self, dirs: Vec<PrunedDir>) {
570        if dirs.is_empty() {
571            return;
572        }
573        self.last_prune = Some(LastPrune {
574            at: Utc::now(),
575            dirs,
576        });
577    }
578
579    /// Returns the number of registered repositories.
580    pub fn repo_count(&self) -> usize {
581        self.repositories.len()
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use tempfile::TempDir;
589
590    fn test_registry_path(dir: &TempDir) -> PathBuf {
591        dir.path().join("dev-prune").join("registry.json")
592    }
593
594    fn a_pruned_dir(label: &str) -> PrunedDir {
595        PrunedDir {
596            repo_path: PathBuf::from("/repo"),
597            bloat_dir: label.to_string(),
598            adapter: "npm".to_string(),
599            size_freed: 42,
600        }
601    }
602
603    #[test]
604    fn a_prune_that_deleted_nothing_does_not_erase_the_last_one() {
605        // Otherwise a second `devp run` on an already-clean machine throws away the
606        // record of the pass the user actually wants to undo.
607        let mut registry = Registry::default();
608        registry.record_prune(vec![a_pruned_dir("node_modules")]);
609        let recorded = registry.last_prune.clone().expect("first pass recorded");
610
611        registry.record_prune(Vec::new());
612
613        assert_eq!(registry.last_prune, Some(recorded));
614    }
615
616    #[test]
617    fn a_later_prune_replaces_the_record() {
618        let mut registry = Registry::default();
619        registry.record_prune(vec![a_pruned_dir("node_modules")]);
620        registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
621
622        let dirs = registry.last_prune.unwrap().dirs;
623        assert_eq!(dirs.len(), 1);
624        assert_eq!(dirs[0].bloat_dir, "frontend/node_modules");
625    }
626
627    #[test]
628    fn the_last_prune_record_survives_a_save_and_load() {
629        // `restore --last-run` reads it out of a file written by a process that has
630        // already exited, so the round trip is the whole feature.
631        let dir = TempDir::new().unwrap();
632        let path = test_registry_path(&dir);
633
634        let mut registry = Registry::default();
635        registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
636        registry.save_to(&path).unwrap();
637
638        let loaded = Registry::load_from(&path).unwrap();
639        assert_eq!(loaded.last_prune, registry.last_prune);
640    }
641
642    #[test]
643    fn a_registry_written_before_the_field_existed_still_loads() {
644        // The registry on disk predates `last_prune`; a missing key means "no pass
645        // recorded", not a parse failure that would lock the user out of their config.
646        let dir = TempDir::new().unwrap();
647        let path = test_registry_path(&dir);
648        fs::create_dir_all(path.parent().unwrap()).unwrap();
649        fs::write(
650            &path,
651            r#"{"version":"1.0","settings":{"idle_days":15,"check_interval_days":2,
652               "auto_daemon":true},"repositories":{}}"#,
653        )
654        .unwrap();
655
656        let loaded = Registry::load_from(&path).unwrap();
657        assert_eq!(loaded.last_prune, None);
658    }
659
660    #[test]
661    fn a_leading_tilde_becomes_the_home_directory() {
662        // The whole reason this exists: PowerShell hands `devp init ~/Code` straight
663        // through, so without expansion the registry gains a repository at `.\~\Code`.
664        let home = dirs::home_dir().expect("test host has a home directory");
665
666        assert_eq!(expand_tilde("~"), home.to_string_lossy());
667        assert_eq!(
668            expand_tilde("~/Code"),
669            home.join("Code").to_string_lossy(),
670            "forward slash, as typed in every shell"
671        );
672        assert_eq!(
673            expand_tilde("~\\Code"),
674            home.join("Code").to_string_lossy(),
675            "backslash, as typed in PowerShell"
676        );
677    }
678
679    #[test]
680    fn a_tilde_that_is_not_a_home_reference_is_left_alone() {
681        // `~alice` is another user's home in shell syntax and cannot be resolved
682        // portably; `~backup` and `./~tmp` are ordinary directory names. Rewriting any
683        // of them would silently point the user at the wrong directory.
684        for raw in ["~alice/Code", "~backup", "./~tmp", "Code~", "", "."] {
685            assert_eq!(expand_tilde(raw), raw, "{raw} must survive untouched");
686        }
687    }
688
689    #[test]
690    fn test_default_settings() {
691        let settings = Settings::default();
692        assert_eq!(settings.idle_days, 15);
693        assert_eq!(settings.check_interval_days, 2);
694        // On by default: dev-prune installs its own integrations, once per version,
695        // and only the ones it finds missing.
696        assert!(settings.auto_daemon);
697        assert!(settings.auto_hooks);
698        assert!(settings.auto_setup);
699    }
700
701    #[test]
702    fn settings_written_before_the_automation_toggles_existed_still_load() {
703        // Real registries on disk predate `auto_hooks` / `auto_setup`; an upgrade must
704        // read them rather than fail to parse and lose every registered repository.
705        let json = r#"{
706            "idle_days": 30,
707            "check_interval_days": 2,
708            "auto_daemon": false
709        }"#;
710        let settings: Settings = serde_json::from_str(json).unwrap();
711        assert_eq!(settings.idle_days, 30);
712        assert!(!settings.auto_daemon, "an explicit opt-out is preserved");
713        assert!(settings.auto_hooks, "a missing key takes the default");
714        assert!(settings.auto_setup);
715    }
716
717    #[test]
718    fn test_default_registry() {
719        let registry = Registry::default();
720        assert_eq!(registry.version, "1.0");
721        assert_eq!(registry.settings, Settings::default());
722        assert!(registry.repositories.is_empty());
723    }
724
725    #[test]
726    fn test_repo_entry_new() {
727        let entry = RepoEntry::new();
728        assert!(entry.enabled);
729        assert!(entry.last_pruned_at.is_none());
730        assert!(entry.override_idle_days.is_none());
731    }
732
733    #[test]
734    fn test_save_and_load() {
735        let tmp = TempDir::new().unwrap();
736        let path = test_registry_path(&tmp);
737
738        let mut registry = Registry::default();
739        registry.add_repo(PathBuf::from("/test/repo"));
740        registry.save_to(&path).unwrap();
741
742        let loaded = Registry::load_from(&path).unwrap();
743        assert_eq!(loaded.repo_count(), 1);
744        assert!(
745            loaded
746                .repositories
747                .contains_key(&PathBuf::from("/test/repo"))
748        );
749    }
750
751    #[test]
752    fn loading_a_missing_registry_yields_the_defaults_and_writes_nothing() {
753        let tmp = TempDir::new().unwrap();
754        let path = test_registry_path(&tmp);
755
756        let loaded = Registry::load_from(&path).unwrap();
757        assert_eq!(loaded, Registry::default());
758        // Reading is not writing. `devp --dry-run` and `devp status --json` both promise
759        // to leave the disk alone, and both start by loading the registry.
760        assert!(!path.exists(), "loading the registry created it");
761    }
762
763    #[test]
764    fn test_add_repo_returns_true_for_new() {
765        let mut registry = Registry::default();
766        assert!(registry.add_repo(PathBuf::from("/test/repo")));
767    }
768
769    #[test]
770    fn test_add_repo_returns_false_for_duplicate() {
771        let mut registry = Registry::default();
772        registry.add_repo(PathBuf::from("/test/repo"));
773        assert!(!registry.add_repo(PathBuf::from("/test/repo")));
774    }
775
776    #[test]
777    fn test_remove_repo() {
778        let mut registry = Registry::default();
779        registry.add_repo(PathBuf::from("/test/repo"));
780        assert!(registry.remove_repo(Path::new("/test/repo")));
781        assert!(!registry.remove_repo(Path::new("/test/repo")));
782        assert_eq!(registry.repo_count(), 0);
783    }
784
785    #[test]
786    fn test_mark_pruned() {
787        let mut registry = Registry::default();
788        registry.add_repo(PathBuf::from("/test/repo"));
789        assert!(
790            registry.repositories[&PathBuf::from("/test/repo")]
791                .last_pruned_at
792                .is_none()
793        );
794        registry.mark_pruned(Path::new("/test/repo"), 1024);
795        assert!(
796            registry.repositories[&PathBuf::from("/test/repo")]
797                .last_pruned_at
798                .is_some()
799        );
800        assert_eq!(registry.total_freed_bytes, 1024);
801        assert_eq!(registry.total_pruned_count, 1);
802    }
803
804    #[test]
805    fn test_repo_count() {
806        let mut registry = Registry::default();
807        assert_eq!(registry.repo_count(), 0);
808        registry.add_repo(PathBuf::from("/a"));
809        registry.add_repo(PathBuf::from("/b"));
810        assert_eq!(registry.repo_count(), 2);
811    }
812
813    #[test]
814    fn a_local_schema_uri_has_exactly_three_slashes_on_either_platform() {
815        assert_eq!(
816            file_uri("/home/dev/.config/dev-prune/bin/devprune.schema.json"),
817            "file:///home/dev/.config/dev-prune/bin/devprune.schema.json"
818        );
819        assert_eq!(
820            file_uri("C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"),
821            "file:///C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"
822        );
823    }
824
825    #[test]
826    fn a_broken_per_repo_config_is_an_error_rather_than_an_absent_one() {
827        // The distinction the whole tool leans on: "no config" means take the defaults,
828        // "unreadable config" means refuse — never overwrite, never prune on a guess.
829        let tmp = TempDir::new().unwrap();
830        let repo = tmp.path();
831        assert_eq!(PerRepoConfig::load_with_diagnostics(repo), Ok(None));
832
833        fs::write(
834            repo.join(constants::PER_REPO_CONFIG_FILE),
835            r#"{ "ignore": true, }"#,
836        )
837        .unwrap();
838        let err = PerRepoConfig::load_with_diagnostics(repo).unwrap_err();
839        assert!(err.contains("Syntax error"), "{err}");
840
841        fs::write(
842            repo.join(constants::PER_REPO_CONFIG_FILE),
843            r#"{ "ignore": true }"#,
844        )
845        .unwrap();
846        assert!(
847            PerRepoConfig::load_with_diagnostics(repo)
848                .unwrap()
849                .unwrap()
850                .ignore
851        );
852    }
853
854    #[test]
855    fn test_serialization_roundtrip() {
856        let mut registry = Registry::default();
857        registry.settings.idle_days = 30;
858        registry.add_repo(PathBuf::from("/test/repo"));
859
860        let json = serde_json::to_string_pretty(&registry).unwrap();
861        let deserialized: Registry = serde_json::from_str(&json).unwrap();
862        assert_eq!(registry.settings.idle_days, deserialized.settings.idle_days);
863        assert_eq!(registry.repo_count(), deserialized.repo_count());
864    }
865
866    #[test]
867    fn test_atomic_save_leaves_no_tmp() {
868        let tmp = TempDir::new().unwrap();
869        let path = test_registry_path(&tmp);
870
871        let registry = Registry::default();
872        registry.save_to(&path).unwrap();
873
874        let tmp_path = path.with_extension("json.tmp");
875        assert!(!tmp_path.exists());
876        assert!(path.exists());
877    }
878}