1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct Settings {
26 pub idle_days: u64,
28 pub check_interval_days: u64,
30 pub auto_daemon: bool,
32 #[serde(default = "default_auto_hooks")]
34 pub auto_hooks: bool,
35 #[serde(default = "default_auto_setup")]
37 pub auto_setup: bool,
38 #[serde(default = "default_require_confirmation")]
40 pub require_confirmation: bool,
41 #[serde(default = "default_command_timeout_secs")]
43 pub command_timeout_secs: u64,
44 #[serde(default = "default_min_size_mb")]
49 pub min_size_mb: u64,
50 #[serde(default = "default_update_check")]
57 pub update_check: bool,
58 #[serde(default = "default_scan_depth")]
65 pub scan_depth: usize,
66 #[serde(default = "default_allow_manifest_rewrite")]
71 pub allow_manifest_rewrite: bool,
72 #[serde(default = "default_update_check_interval_days")]
77 pub update_check_interval_days: i64,
78 #[serde(default = "default_update_check_timeout_secs")]
83 pub update_check_timeout_secs: u64,
84 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161pub struct RepoEntry {
162 pub added_at: DateTime<Utc>,
164 pub last_pruned_at: Option<DateTime<Utc>>,
166 pub override_idle_days: Option<u64>,
168 pub enabled: bool,
170}
171
172impl RepoEntry {
173 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
190pub 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
211pub fn canonical_key(path: &Path) -> PathBuf {
216 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
217}
218
219pub 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
253pub struct PerRepoConfig {
254 #[serde(rename = "$schema", default = "default_schema_url")]
256 pub schema: String,
257 #[serde(default)]
259 pub project_name: Option<String>,
260 #[serde(default)]
262 pub ignore: bool,
263 #[serde(default)]
265 pub disable_hooks: bool,
266 #[serde(default)]
268 pub disable_daemon: bool,
269 #[serde(default)]
271 pub override_idle_days: Option<u64>,
272 #[serde(default)]
277 pub min_size_mb: Option<u64>,
278 #[serde(default)]
284 pub scan_depth: Option<usize>,
285}
286
287fn 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 return file_uri(&crate::output::clean_path(&local_schema));
315 }
316 }
317 constants::JSON_SCHEMA_URL.to_string()
318}
319
320fn 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 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 Err(e) => Err(format!(
368 "Syntax error in `{}`: {e}",
369 crate::output::clean_path(&config_file)
370 )),
371 }
372 }
373
374 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
391pub struct PrunedDir {
392 pub repo_path: PathBuf,
394 pub bloat_dir: String,
396 pub adapter: String,
398 pub size_freed: u64,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
408pub struct LastPrune {
409 pub at: DateTime<Utc>,
411 pub dirs: Vec<PrunedDir>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
417pub struct Registry {
418 pub version: String,
420 pub settings: Settings,
422 pub repositories: HashMap<PathBuf, RepoEntry>,
424 #[serde(default)]
426 pub total_freed_bytes: u64,
427 #[serde(default)]
429 pub total_pruned_count: u64,
430 #[serde(default)]
432 pub last_added_repos: Vec<PathBuf>,
433 #[serde(default)]
435 pub last_prune: Option<LastPrune>,
436 #[serde(default)]
439 pub last_update_check: Option<DateTime<Utc>>,
440 #[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 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 pub fn registry_path() -> Result<PathBuf> {
478 Ok(Self::config_dir()?.join(constants::REGISTRY_FILENAME))
479 }
480
481 pub fn load() -> Result<Self> {
489 Self::load_from(&Self::registry_path()?)
490 }
491
492 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 pub fn save(&self) -> Result<()> {
510 let path = Self::registry_path()?;
511 self.save_to(&path)
512 }
513
514 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 pub fn add_repo(&mut self, path: PathBuf) -> bool {
532 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 pub fn remove_repo(&mut self, path: &Path) -> bool {
544 self.repositories.remove(&canonical_key(path)).is_some()
545 }
546
547 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 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 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 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 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 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 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 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 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 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 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 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(®istry).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}