1use 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#[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 #[serde(default)]
176 pub total_freed_bytes: u64,
177}
178
179impl RepoEntry {
180 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
198fn git_common_dir(repo_path: &Path) -> Option<PathBuf> {
205 let dot_git = repo_path.join(".git");
206 let git_dir = if dot_git.is_dir() {
207 dot_git
208 } else {
209 let pointer = fs::read_to_string(&dot_git).ok()?;
210 let target = pointer.strip_prefix("gitdir:")?.trim();
211 let target = Path::new(target);
212 if target.is_absolute() {
213 target.to_path_buf()
214 } else {
215 repo_path.join(target)
216 }
217 };
218 if let Ok(common) = fs::read_to_string(git_dir.join("commondir")) {
219 let target = Path::new(common.trim());
220 if target.is_absolute() {
221 return Some(target.to_path_buf());
222 }
223 return Some(git_dir.join(target));
224 }
225 Some(git_dir)
226}
227
228pub fn ensure_in_git_exclude(repo_path: &Path, entry: &str) -> Result<()> {
236 let Some(git_dir) = git_common_dir(repo_path) else {
237 return Ok(());
238 };
239 let info_dir = git_dir.join("info");
240 fs::create_dir_all(&info_dir)?;
241 let exclude_path = info_dir.join("exclude");
242 if exclude_path.exists() {
243 let content = fs::read_to_string(&exclude_path)?;
244 if !content.lines().any(|line| line.trim() == entry) {
245 let mut file = fs::OpenOptions::new().append(true).open(&exclude_path)?;
246 let prefix = if content.ends_with('\n') || content.is_empty() {
247 ""
248 } else {
249 "\n"
250 };
251 writeln!(file, "{prefix}{entry}")?;
252 }
253 } else {
254 fs::write(&exclude_path, format!("{entry}\n"))?;
255 }
256 Ok(())
257}
258
259pub fn canonical_key(path: &Path) -> PathBuf {
264 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
265}
266
267pub fn expand_tilde(raw: &str) -> String {
280 let Some(rest) = raw.strip_prefix('~') else {
281 return raw.to_string();
282 };
283 if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\')) {
284 return raw.to_string();
285 }
286 let Some(home) = dirs::home_dir() else {
287 return raw.to_string();
290 };
291 if rest.is_empty() {
292 return home.to_string_lossy().into_owned();
293 }
294 home.join(rest.trim_start_matches(['/', '\\']))
295 .to_string_lossy()
296 .into_owned()
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
301pub struct PerRepoConfig {
302 #[serde(rename = "$schema", default = "default_schema_url")]
304 pub schema: String,
305 #[serde(default)]
307 pub project_name: Option<String>,
308 #[serde(default)]
310 pub ignore: bool,
311 #[serde(default)]
313 pub disable_hooks: bool,
314 #[serde(default)]
316 pub disable_daemon: bool,
317 #[serde(default)]
319 pub override_idle_days: Option<u64>,
320 #[serde(default)]
325 pub min_size_mb: Option<u64>,
326 #[serde(default)]
332 pub scan_depth: Option<usize>,
333}
334
335fn default_schema_url() -> String {
356 if let Ok(config_dir) = Registry::config_dir() {
357 let local_schema = config_dir.join("bin").join("devprune.schema.json");
358 if local_schema.exists() {
359 return file_uri(&crate::output::clean_path(&local_schema));
364 }
365 }
366 constants::JSON_SCHEMA_URL.to_string()
367}
368
369fn file_uri(clean_path: &str) -> String {
376 format!("file:///{}", clean_path.trim_start_matches('/'))
377}
378
379impl Default for PerRepoConfig {
380 fn default() -> Self {
381 Self {
382 schema: default_schema_url(),
383 project_name: None,
384 ignore: false,
385 disable_hooks: false,
386 disable_daemon: false,
387 override_idle_days: None,
388 min_size_mb: None,
389 scan_depth: None,
390 }
391 }
392}
393
394impl PerRepoConfig {
395 pub fn load_with_diagnostics(repo_path: &Path) -> Result<Option<Self>, String> {
405 let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
406 if !config_file.exists() {
407 return Ok(None);
408 }
409 let content =
410 fs::read_to_string(&config_file).map_err(|e| format!("Failed to read file: {e}"))?;
411 match serde_json::from_str::<Self>(&content) {
412 Ok(cfg) => Ok(Some(cfg)),
413 Err(e) => Err(format!(
417 "Syntax error in `{}`: {e}",
418 crate::output::clean_path(&config_file)
419 )),
420 }
421 }
422
423 pub fn save_to_repo(&self, repo_path: &Path) -> Result<()> {
426 let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
427 let content = serde_json::to_string_pretty(self)?;
428 fs::write(&config_file, content)?;
429 let _ = ensure_in_git_exclude(repo_path, constants::PER_REPO_CONFIG_FILE);
430 let _ = ensure_in_git_exclude(repo_path, constants::DEVPRUNE_IGNORE_FILE);
431 Ok(())
432 }
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
441pub struct PrunedDir {
442 pub repo_path: PathBuf,
444 pub bloat_dir: String,
446 pub adapter: String,
448 pub size_freed: u64,
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
458pub struct LastPrune {
459 pub at: DateTime<Utc>,
461 pub dirs: Vec<PrunedDir>,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
473pub struct PruneRunSummary {
474 pub at: DateTime<Utc>,
476 pub bytes_freed: u64,
478 pub dirs_removed: usize,
480 pub repos_touched: usize,
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
486pub struct Registry {
487 pub version: String,
489 pub settings: Settings,
491 pub repositories: HashMap<PathBuf, RepoEntry>,
493 #[serde(default)]
495 pub total_freed_bytes: u64,
496 #[serde(default)]
504 pub total_pruned_count: u64,
505 #[serde(default)]
507 pub last_added_repos: Vec<PathBuf>,
508 #[serde(default)]
510 pub last_prune: Option<LastPrune>,
511 #[serde(default)]
515 pub prune_history: Vec<PruneRunSummary>,
516 #[serde(default)]
519 pub last_update_check: Option<DateTime<Utc>>,
520 #[serde(default)]
523 pub latest_known_version: Option<String>,
524}
525
526impl Default for Registry {
527 fn default() -> Self {
528 Self {
529 version: "1.0".to_string(),
530 settings: Settings::default(),
531 repositories: HashMap::new(),
532 total_freed_bytes: 0,
533 total_pruned_count: 0,
534 last_added_repos: Vec::new(),
535 last_prune: None,
536 prune_history: Vec::new(),
537 last_update_check: None,
538 latest_known_version: None,
539 }
540 }
541}
542
543impl Registry {
544 pub fn config_dir() -> Result<PathBuf> {
550 if let Ok(override_dir) = std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE) {
551 return Ok(PathBuf::from(override_dir));
552 }
553 let base = dirs::config_dir().context("Could not determine config directory")?;
554 Ok(base.join(constants::CONFIG_DIR_NAME))
555 }
556
557 pub fn registry_path() -> Result<PathBuf> {
559 Ok(Self::config_dir()?.join(constants::REGISTRY_FILENAME))
560 }
561
562 pub fn load() -> Result<Self> {
570 Self::load_from(&Self::registry_path()?)
571 }
572
573 pub fn load_from(path: &Path) -> Result<Self> {
580 if !path.exists() {
581 return Ok(Registry::default());
582 }
583 let contents = fs::read_to_string(path)
584 .with_context(|| format!("Failed to read registry at {}", path.display()))?;
585 serde_json::from_str(&contents)
586 .with_context(|| format!("Failed to parse registry at {}", path.display()))
587 }
588
589 pub fn save(&self) -> Result<()> {
591 let path = Self::registry_path()?;
592 self.save_to(&path)
593 }
594
595 pub fn save_to(&self, path: &Path) -> Result<()> {
597 if let Some(parent) = path.parent() {
598 fs::create_dir_all(parent)
599 .with_context(|| format!("Failed to create config dir {}", parent.display()))?;
600 }
601 let tmp_path = path.with_extension(format!("json.{}.tmp", std::process::id()));
605 let contents =
606 serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
607 fs::write(&tmp_path, &contents)
608 .with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
609 fs::rename(&tmp_path, path)
610 .with_context(|| format!("Failed to rename temp registry to {}", path.display()))?;
611 Ok(())
612 }
613
614 pub fn add_repo(&mut self, path: PathBuf) -> bool {
616 let path = canonical_key(&path);
619 if self.repositories.contains_key(&path) {
620 return false;
621 }
622 self.repositories.insert(path, RepoEntry::new());
623 true
624 }
625
626 pub fn remove_repo(&mut self, path: &Path) -> bool {
628 self.repositories.remove(&canonical_key(path)).is_some()
629 }
630
631 pub fn mark_pruned(&mut self, path: &Path, bytes_freed: u64) {
646 if let Some(entry) = self.repositories.get_mut(&canonical_key(path)) {
650 entry.last_pruned_at = Some(Utc::now());
651 entry.total_freed_bytes += bytes_freed;
652 }
653 self.total_freed_bytes += bytes_freed;
654 }
655
656 pub fn record_prune(&mut self, dirs: Vec<PrunedDir>) {
669 self.record_prune_progress(Utc::now(), dirs);
670 }
671
672 pub fn record_prune_progress(&mut self, at: DateTime<Utc>, dirs: Vec<PrunedDir>) {
681 if dirs.is_empty() {
682 return;
683 }
684 if self.prune_history.last().map(|s| s.at) == Some(at) {
685 self.prune_history.pop();
686 } else {
687 self.total_pruned_count += 1;
688 }
689
690 self.prune_history.push(PruneRunSummary {
691 at,
692 bytes_freed: dirs.iter().map(|d| d.size_freed).sum(),
693 dirs_removed: dirs.len(),
694 repos_touched: dirs
695 .iter()
696 .map(|d| &d.repo_path)
697 .collect::<HashSet<_>>()
698 .len(),
699 });
700 if self.prune_history.len() > constants::PRUNE_HISTORY_LIMIT {
702 let excess = self.prune_history.len() - constants::PRUNE_HISTORY_LIMIT;
703 self.prune_history.drain(..excess);
704 }
705
706 self.last_prune = Some(LastPrune { at, dirs });
707 }
708
709 pub fn repo_count(&self) -> usize {
711 self.repositories.len()
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use tempfile::TempDir;
719
720 fn test_registry_path(dir: &TempDir) -> PathBuf {
721 dir.path().join("dev-prune").join("registry.json")
722 }
723
724 fn a_pruned_dir(label: &str) -> PrunedDir {
725 PrunedDir {
726 repo_path: PathBuf::from("/repo"),
727 bloat_dir: label.to_string(),
728 adapter: "npm".to_string(),
729 size_freed: 42,
730 }
731 }
732
733 #[test]
734 fn a_prune_that_deleted_nothing_does_not_erase_the_last_one() {
735 let mut registry = Registry::default();
738 registry.record_prune(vec![a_pruned_dir("node_modules")]);
739 let recorded = registry.last_prune.clone().expect("first pass recorded");
740
741 registry.record_prune(Vec::new());
742
743 assert_eq!(registry.last_prune, Some(recorded));
744 }
745
746 #[test]
747 fn a_later_prune_replaces_the_record() {
748 let mut registry = Registry::default();
749 registry.record_prune(vec![a_pruned_dir("node_modules")]);
750 registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
751
752 let dirs = registry.last_prune.unwrap().dirs;
753 assert_eq!(dirs.len(), 1);
754 assert_eq!(dirs[0].bloat_dir, "frontend/node_modules");
755 }
756
757 #[test]
758 fn the_last_prune_record_survives_a_save_and_load() {
759 let dir = TempDir::new().unwrap();
762 let path = test_registry_path(&dir);
763
764 let mut registry = Registry::default();
765 registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
766 registry.save_to(&path).unwrap();
767
768 let loaded = Registry::load_from(&path).unwrap();
769 assert_eq!(loaded.last_prune, registry.last_prune);
770 }
771
772 #[test]
773 fn a_registry_written_before_the_field_existed_still_loads() {
774 let dir = TempDir::new().unwrap();
777 let path = test_registry_path(&dir);
778 fs::create_dir_all(path.parent().unwrap()).unwrap();
779 fs::write(
780 &path,
781 r#"{"version":"1.0","settings":{"idle_days":15,"check_interval_days":2,
782 "auto_daemon":true},"repositories":{}}"#,
783 )
784 .unwrap();
785
786 let loaded = Registry::load_from(&path).unwrap();
787 assert_eq!(loaded.last_prune, None);
788 }
789
790 #[test]
791 fn a_leading_tilde_becomes_the_home_directory() {
792 let home = dirs::home_dir().expect("test host has a home directory");
795
796 assert_eq!(expand_tilde("~"), home.to_string_lossy());
797 assert_eq!(
798 expand_tilde("~/Code"),
799 home.join("Code").to_string_lossy(),
800 "forward slash, as typed in every shell"
801 );
802 assert_eq!(
803 expand_tilde("~\\Code"),
804 home.join("Code").to_string_lossy(),
805 "backslash, as typed in PowerShell"
806 );
807 }
808
809 #[test]
810 fn a_tilde_that_is_not_a_home_reference_is_left_alone() {
811 for raw in ["~alice/Code", "~backup", "./~tmp", "Code~", "", "."] {
815 assert_eq!(expand_tilde(raw), raw, "{raw} must survive untouched");
816 }
817 }
818
819 #[test]
820 fn test_default_settings() {
821 let settings = Settings::default();
822 assert_eq!(settings.idle_days, 15);
823 assert_eq!(settings.check_interval_days, 2);
824 assert!(settings.auto_daemon);
827 assert!(settings.auto_hooks);
828 assert!(settings.auto_setup);
829 }
830
831 #[test]
832 fn settings_written_before_the_automation_toggles_existed_still_load() {
833 let json = r#"{
836 "idle_days": 30,
837 "check_interval_days": 2,
838 "auto_daemon": false
839 }"#;
840 let settings: Settings = serde_json::from_str(json).unwrap();
841 assert_eq!(settings.idle_days, 30);
842 assert!(!settings.auto_daemon, "an explicit opt-out is preserved");
843 assert!(settings.auto_hooks, "a missing key takes the default");
844 assert!(settings.auto_setup);
845 }
846
847 #[test]
848 fn test_default_registry() {
849 let registry = Registry::default();
850 assert_eq!(registry.version, "1.0");
851 assert_eq!(registry.settings, Settings::default());
852 assert!(registry.repositories.is_empty());
853 }
854
855 #[test]
856 fn test_repo_entry_new() {
857 let entry = RepoEntry::new();
858 assert!(entry.enabled);
859 assert!(entry.last_pruned_at.is_none());
860 assert!(entry.override_idle_days.is_none());
861 }
862
863 #[test]
864 fn test_save_and_load() {
865 let tmp = TempDir::new().unwrap();
866 let path = test_registry_path(&tmp);
867
868 let mut registry = Registry::default();
869 registry.add_repo(PathBuf::from("/test/repo"));
870 registry.save_to(&path).unwrap();
871
872 let loaded = Registry::load_from(&path).unwrap();
873 assert_eq!(loaded.repo_count(), 1);
874 assert!(
875 loaded
876 .repositories
877 .contains_key(&PathBuf::from("/test/repo"))
878 );
879 }
880
881 #[test]
882 fn loading_a_missing_registry_yields_the_defaults_and_writes_nothing() {
883 let tmp = TempDir::new().unwrap();
884 let path = test_registry_path(&tmp);
885
886 let loaded = Registry::load_from(&path).unwrap();
887 assert_eq!(loaded, Registry::default());
888 assert!(!path.exists(), "loading the registry created it");
891 }
892
893 #[test]
894 fn test_add_repo_returns_true_for_new() {
895 let mut registry = Registry::default();
896 assert!(registry.add_repo(PathBuf::from("/test/repo")));
897 }
898
899 #[test]
900 fn test_add_repo_returns_false_for_duplicate() {
901 let mut registry = Registry::default();
902 registry.add_repo(PathBuf::from("/test/repo"));
903 assert!(!registry.add_repo(PathBuf::from("/test/repo")));
904 }
905
906 #[test]
907 fn test_remove_repo() {
908 let mut registry = Registry::default();
909 registry.add_repo(PathBuf::from("/test/repo"));
910 assert!(registry.remove_repo(Path::new("/test/repo")));
911 assert!(!registry.remove_repo(Path::new("/test/repo")));
912 assert_eq!(registry.repo_count(), 0);
913 }
914
915 #[test]
916 fn test_mark_pruned() {
917 let mut registry = Registry::default();
918 registry.add_repo(PathBuf::from("/test/repo"));
919 assert!(
920 registry.repositories[&PathBuf::from("/test/repo")]
921 .last_pruned_at
922 .is_none()
923 );
924 registry.mark_pruned(Path::new("/test/repo"), 1024);
925 assert!(
926 registry.repositories[&PathBuf::from("/test/repo")]
927 .last_pruned_at
928 .is_some()
929 );
930 assert_eq!(registry.total_freed_bytes, 1024);
931 assert_eq!(registry.total_pruned_count, 0);
933 }
934
935 #[test]
936 fn a_pass_is_counted_once_however_much_it_deleted() {
937 let mut registry = Registry::default();
941 registry.add_repo(PathBuf::from("/repo"));
942
943 registry.mark_pruned(Path::new("/repo"), 1024);
944 registry.mark_pruned(Path::new("/repo"), 1024);
945 registry.record_prune(vec![
946 a_pruned_dir("node_modules"),
947 a_pruned_dir("frontend/node_modules"),
948 ]);
949
950 assert_eq!(registry.total_pruned_count, 1);
951
952 registry.record_prune(vec![a_pruned_dir("target")]);
953 assert_eq!(registry.total_pruned_count, 2);
954
955 registry.record_prune(Vec::new());
957 assert_eq!(registry.total_pruned_count, 2);
958 }
959
960 #[test]
961 fn mark_pruned_credits_the_repo_under_its_canonical_key() {
962 let tmp = TempDir::new().unwrap();
966 let raw = tmp.path().to_path_buf();
967
968 let mut registry = Registry::default();
969 registry.add_repo(raw.clone());
970 registry.mark_pruned(&raw, 1024);
971
972 let entry = ®istry.repositories[&canonical_key(&raw)];
973 assert_eq!(entry.total_freed_bytes, 1024);
974 assert!(entry.last_pruned_at.is_some());
975 assert_eq!(registry.total_freed_bytes, 1024);
976 }
977
978 #[test]
979 fn each_repository_accumulates_its_own_total() {
980 let mut registry = Registry::default();
983 registry.add_repo(PathBuf::from("/test/repo"));
984 registry.add_repo(PathBuf::from("/test/other"));
985
986 registry.mark_pruned(Path::new("/test/repo"), 1024);
987 registry.mark_pruned(Path::new("/test/repo"), 2048);
988 registry.mark_pruned(Path::new("/test/other"), 512);
989
990 assert_eq!(
991 registry.repositories[&PathBuf::from("/test/repo")].total_freed_bytes,
992 3072
993 );
994 assert_eq!(
995 registry.repositories[&PathBuf::from("/test/other")].total_freed_bytes,
996 512
997 );
998 assert_eq!(registry.total_freed_bytes, 3584);
999 }
1000
1001 #[test]
1002 fn the_prune_history_summarises_the_pass() {
1003 let mut registry = Registry::default();
1004 registry.record_prune(vec![
1005 a_pruned_dir("node_modules"),
1006 a_pruned_dir("frontend/node_modules"),
1007 ]);
1008
1009 let summary = registry.prune_history.last().expect("pass summarised");
1010 assert_eq!(summary.bytes_freed, 84);
1011 assert_eq!(summary.dirs_removed, 2);
1012 assert_eq!(summary.repos_touched, 1);
1014 }
1015
1016 #[test]
1017 fn the_prune_history_is_capped_and_drops_the_oldest() {
1018 let mut registry = Registry::default();
1021 for _ in 0..constants::PRUNE_HISTORY_LIMIT + 5 {
1022 registry.record_prune(vec![a_pruned_dir("node_modules")]);
1023 }
1024
1025 assert_eq!(registry.prune_history.len(), constants::PRUNE_HISTORY_LIMIT);
1026 let first = registry.prune_history.first().unwrap().at;
1027 let last = registry.prune_history.last().unwrap().at;
1028 assert!(first <= last, "oldest first");
1029 }
1030
1031 #[test]
1032 fn test_repo_count() {
1033 let mut registry = Registry::default();
1034 assert_eq!(registry.repo_count(), 0);
1035 registry.add_repo(PathBuf::from("/a"));
1036 registry.add_repo(PathBuf::from("/b"));
1037 assert_eq!(registry.repo_count(), 2);
1038 }
1039
1040 #[test]
1041 fn a_local_schema_uri_has_exactly_three_slashes_on_either_platform() {
1042 assert_eq!(
1043 file_uri("/home/dev/.config/dev-prune/bin/devprune.schema.json"),
1044 "file:///home/dev/.config/dev-prune/bin/devprune.schema.json"
1045 );
1046 assert_eq!(
1047 file_uri("C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"),
1048 "file:///C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"
1049 );
1050 }
1051
1052 #[test]
1053 fn a_broken_per_repo_config_is_an_error_rather_than_an_absent_one() {
1054 let tmp = TempDir::new().unwrap();
1057 let repo = tmp.path();
1058 assert_eq!(PerRepoConfig::load_with_diagnostics(repo), Ok(None));
1059
1060 fs::write(
1061 repo.join(constants::PER_REPO_CONFIG_FILE),
1062 r#"{ "ignore": true, }"#,
1063 )
1064 .unwrap();
1065 let err = PerRepoConfig::load_with_diagnostics(repo).unwrap_err();
1066 assert!(err.contains("Syntax error"), "{err}");
1067
1068 fs::write(
1069 repo.join(constants::PER_REPO_CONFIG_FILE),
1070 r#"{ "ignore": true }"#,
1071 )
1072 .unwrap();
1073 assert!(
1074 PerRepoConfig::load_with_diagnostics(repo)
1075 .unwrap()
1076 .unwrap()
1077 .ignore
1078 );
1079 }
1080
1081 #[test]
1082 fn test_serialization_roundtrip() {
1083 let mut registry = Registry::default();
1084 registry.settings.idle_days = 30;
1085 registry.add_repo(PathBuf::from("/test/repo"));
1086
1087 let json = serde_json::to_string_pretty(®istry).unwrap();
1088 let deserialized: Registry = serde_json::from_str(&json).unwrap();
1089 assert_eq!(registry.settings.idle_days, deserialized.settings.idle_days);
1090 assert_eq!(registry.repo_count(), deserialized.repo_count());
1091 }
1092
1093 #[test]
1094 fn test_atomic_save_leaves_no_tmp() {
1095 let tmp = TempDir::new().unwrap();
1096 let path = test_registry_path(&tmp);
1097
1098 let registry = Registry::default();
1099 registry.save_to(&path).unwrap();
1100
1101 assert!(path.exists());
1102 let leftovers: Vec<_> = fs::read_dir(path.parent().unwrap())
1105 .unwrap()
1106 .flatten()
1107 .filter(|e| e.path() != path)
1108 .collect();
1109 assert!(leftovers.is_empty(), "leftover files: {leftovers:?}");
1110 }
1111
1112 #[test]
1113 fn exclude_entry_lands_in_git_info_exclude_not_gitignore() {
1114 let tmp = TempDir::new().unwrap();
1115 let repo = tmp.path();
1116 fs::create_dir(repo.join(".git")).unwrap();
1117
1118 ensure_in_git_exclude(repo, ".devprune.json").unwrap();
1119
1120 let exclude = fs::read_to_string(repo.join(".git/info/exclude")).unwrap();
1121 assert!(exclude.lines().any(|l| l == ".devprune.json"));
1122 assert!(!repo.join(".gitignore").exists());
1125 }
1126
1127 #[test]
1128 fn exclude_entry_is_appended_once_and_preserves_existing_lines() {
1129 let tmp = TempDir::new().unwrap();
1130 let repo = tmp.path();
1131 fs::create_dir_all(repo.join(".git/info")).unwrap();
1132 fs::write(repo.join(".git/info/exclude"), "*.log").unwrap();
1135
1136 ensure_in_git_exclude(repo, ".devprune.json").unwrap();
1137 ensure_in_git_exclude(repo, ".devprune.json").unwrap();
1138
1139 let exclude = fs::read_to_string(repo.join(".git/info/exclude")).unwrap();
1140 let lines: Vec<_> = exclude.lines().collect();
1141 assert_eq!(lines, vec!["*.log", ".devprune.json"]);
1142 }
1143
1144 #[test]
1145 fn exclude_follows_a_gitdir_pointer_file() {
1146 let tmp = TempDir::new().unwrap();
1150 let shared = tmp.path().join("main-clone/.git");
1151 let worktree_gitdir = shared.join("worktrees/wt");
1152 fs::create_dir_all(&worktree_gitdir).unwrap();
1153 fs::write(worktree_gitdir.join("commondir"), "../..\n").unwrap();
1154
1155 let wt = tmp.path().join("wt");
1156 fs::create_dir(&wt).unwrap();
1157 fs::write(
1158 wt.join(".git"),
1159 format!("gitdir: {}\n", worktree_gitdir.display()),
1160 )
1161 .unwrap();
1162
1163 ensure_in_git_exclude(&wt, ".devprune.json").unwrap();
1164
1165 let exclude = fs::read_to_string(shared.join("info/exclude")).unwrap();
1166 assert!(exclude.lines().any(|l| l == ".devprune.json"));
1167 }
1168
1169 #[test]
1170 fn exclude_is_a_no_op_outside_a_git_repository() {
1171 let tmp = TempDir::new().unwrap();
1172
1173 ensure_in_git_exclude(tmp.path(), ".devprune.json").unwrap();
1174
1175 assert!(!tmp.path().join(".git").exists());
1176 assert!(!tmp.path().join(".gitignore").exists());
1177 }
1178}