use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::constants;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Settings {
pub idle_days: u64,
pub check_interval_days: u64,
pub auto_daemon: bool,
#[serde(default = "default_auto_hooks")]
pub auto_hooks: bool,
#[serde(default = "default_auto_setup")]
pub auto_setup: bool,
#[serde(default = "default_auto_config")]
pub auto_config: bool,
#[serde(default = "default_require_confirmation")]
pub require_confirmation: bool,
#[serde(default = "default_command_timeout_secs")]
pub command_timeout_secs: u64,
#[serde(default = "default_min_size_mb")]
pub min_size_mb: u64,
#[serde(default = "default_update_check")]
pub update_check: bool,
#[serde(default = "default_scan_depth")]
pub scan_depth: usize,
#[serde(default = "default_allow_manifest_rewrite")]
pub allow_manifest_rewrite: bool,
#[serde(default = "default_update_check_interval_days")]
pub update_check_interval_days: i64,
#[serde(default = "default_update_check_timeout_secs")]
pub update_check_timeout_secs: u64,
#[serde(default = "default_auto_hooks_chain")]
pub auto_hooks_chain: bool,
#[serde(default)]
pub enable_gradle: bool,
#[serde(default)]
pub enable_maven: bool,
#[serde(default = "default_build_idle_days")]
pub build_idle_days: u64,
#[serde(default)]
pub auto_update: bool,
}
fn default_build_idle_days() -> u64 {
constants::DEFAULT_BUILD_IDLE_DAYS
}
fn default_require_confirmation() -> bool {
constants::DEFAULT_REQUIRE_CONFIRMATION
}
fn default_command_timeout_secs() -> u64 {
constants::DEFAULT_COMMAND_TIMEOUT_SECS
}
fn default_auto_hooks() -> bool {
constants::DEFAULT_AUTO_HOOKS
}
fn default_auto_setup() -> bool {
constants::DEFAULT_AUTO_SETUP
}
fn default_auto_config() -> bool {
constants::DEFAULT_AUTO_CONFIG
}
fn default_update_check() -> bool {
constants::DEFAULT_UPDATE_CHECK
}
fn default_min_size_mb() -> u64 {
constants::DEFAULT_MIN_SIZE_MB
}
fn default_scan_depth() -> usize {
constants::DEFAULT_SCAN_DEPTH
}
fn default_allow_manifest_rewrite() -> bool {
constants::DEFAULT_ALLOW_MANIFEST_REWRITE
}
fn default_update_check_interval_days() -> i64 {
constants::UPDATE_CHECK_INTERVAL_DAYS
}
fn default_update_check_timeout_secs() -> u64 {
constants::UPDATE_CHECK_TIMEOUT_SECS
}
fn default_auto_hooks_chain() -> bool {
constants::DEFAULT_AUTO_HOOKS_CHAIN
}
impl Default for Settings {
fn default() -> Self {
Self {
idle_days: constants::DEFAULT_IDLE_DAYS,
check_interval_days: constants::DEFAULT_CHECK_INTERVAL_DAYS,
auto_daemon: constants::DEFAULT_AUTO_DAEMON,
auto_hooks: constants::DEFAULT_AUTO_HOOKS,
auto_setup: constants::DEFAULT_AUTO_SETUP,
auto_config: constants::DEFAULT_AUTO_CONFIG,
require_confirmation: constants::DEFAULT_REQUIRE_CONFIRMATION,
command_timeout_secs: constants::DEFAULT_COMMAND_TIMEOUT_SECS,
min_size_mb: constants::DEFAULT_MIN_SIZE_MB,
update_check: constants::DEFAULT_UPDATE_CHECK,
scan_depth: constants::DEFAULT_SCAN_DEPTH,
allow_manifest_rewrite: constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
update_check_interval_days: constants::UPDATE_CHECK_INTERVAL_DAYS,
update_check_timeout_secs: constants::UPDATE_CHECK_TIMEOUT_SECS,
auto_hooks_chain: constants::DEFAULT_AUTO_HOOKS_CHAIN,
enable_gradle: false,
enable_maven: false,
build_idle_days: constants::DEFAULT_BUILD_IDLE_DAYS,
auto_update: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RepoEntry {
pub added_at: DateTime<Utc>,
pub last_pruned_at: Option<DateTime<Utc>>,
pub override_idle_days: Option<u64>,
pub enabled: bool,
#[serde(default)]
pub total_freed_bytes: u64,
}
impl RepoEntry {
pub fn new() -> Self {
Self {
added_at: Utc::now(),
last_pruned_at: None,
override_idle_days: None,
enabled: true,
total_freed_bytes: 0,
}
}
}
impl Default for RepoEntry {
fn default() -> Self {
Self::new()
}
}
fn git_common_dir(repo_path: &Path) -> Option<PathBuf> {
let dot_git = repo_path.join(".git");
let git_dir = if dot_git.is_dir() {
dot_git
} else {
let pointer = fs::read_to_string(&dot_git).ok()?;
let target = pointer.strip_prefix("gitdir:")?.trim();
let target = Path::new(target);
if target.is_absolute() {
target.to_path_buf()
} else {
repo_path.join(target)
}
};
if let Ok(common) = fs::read_to_string(git_dir.join("commondir")) {
let target = Path::new(common.trim());
if target.is_absolute() {
return Some(target.to_path_buf());
}
return Some(git_dir.join(target));
}
Some(git_dir)
}
pub fn ensure_in_git_exclude(repo_path: &Path, entry: &str) -> Result<()> {
let Some(git_dir) = git_common_dir(repo_path) else {
return Ok(());
};
let info_dir = git_dir.join("info");
fs::create_dir_all(&info_dir)?;
let exclude_path = info_dir.join("exclude");
if exclude_path.exists() {
let content = fs::read_to_string(&exclude_path)?;
if !content.lines().any(|line| line.trim() == entry) {
let mut file = fs::OpenOptions::new().append(true).open(&exclude_path)?;
let prefix = if content.ends_with('\n') || content.is_empty() {
""
} else {
"\n"
};
writeln!(file, "{prefix}{entry}")?;
}
} else {
fs::write(&exclude_path, format!("{entry}\n"))?;
}
Ok(())
}
pub fn canonical_key(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn lexical_absolute(path: &Path) -> PathBuf {
use std::path::Component;
let mut out = if path.is_absolute() {
PathBuf::new()
} else {
std::env::current_dir().unwrap_or_default()
};
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
let mut prefix = out.as_path();
while !prefix.as_os_str().is_empty() {
if let Ok(real) = prefix.canonicalize() {
if let Ok(tail) = out.strip_prefix(prefix) {
return real.join(tail);
}
break;
}
match prefix.parent() {
Some(parent) => prefix = parent,
None => break,
}
}
out
}
fn loose_path_eq(a: &Path, b: &Path) -> bool {
let norm = |p: &Path| {
let s = p.to_string_lossy().replace('\\', "/");
let s = s.strip_prefix("//?/").unwrap_or(&s);
let s = s.trim_end_matches('/').to_string();
if cfg!(windows) { s.to_lowercase() } else { s }
};
norm(a) == norm(b)
}
pub fn expand_tilde(raw: &str) -> String {
let Some(rest) = raw.strip_prefix('~') else {
return raw.to_string();
};
if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\')) {
return raw.to_string();
}
let Some(home) = dirs::home_dir() else {
return raw.to_string();
};
if rest.is_empty() {
return home.to_string_lossy().into_owned();
}
home.join(rest.trim_start_matches(['/', '\\']))
.to_string_lossy()
.into_owned()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PerRepoConfig {
#[serde(rename = "$schema", default = "default_schema_url")]
pub schema: String,
#[serde(default)]
pub project_name: Option<String>,
#[serde(default)]
pub ignore: bool,
#[serde(default)]
pub disable_hooks: bool,
#[serde(default)]
pub disable_daemon: bool,
#[serde(default)]
pub override_idle_days: Option<u64>,
#[serde(default)]
pub min_size_mb: Option<u64>,
#[serde(default)]
pub scan_depth: Option<usize>,
}
fn default_schema_url() -> String {
if let Ok(config_dir) = Registry::config_dir() {
let local_schema = config_dir.join("bin").join("devprune.schema.json");
if local_schema.exists() {
return file_uri(&crate::output::clean_path(&local_schema));
}
}
constants::JSON_SCHEMA_URL.to_string()
}
fn file_uri(clean_path: &str) -> String {
format!("file:///{}", clean_path.trim_start_matches('/'))
}
impl Default for PerRepoConfig {
fn default() -> Self {
Self {
schema: default_schema_url(),
project_name: None,
ignore: false,
disable_hooks: false,
disable_daemon: false,
override_idle_days: None,
min_size_mb: None,
scan_depth: None,
}
}
}
impl PerRepoConfig {
pub fn load_with_diagnostics(repo_path: &Path) -> Result<Option<Self>, String> {
let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
if !config_file.exists() {
return Ok(None);
}
let content =
fs::read_to_string(&config_file).map_err(|e| format!("Failed to read file: {e}"))?;
match serde_json::from_str::<Self>(&content) {
Ok(cfg) => Ok(Some(cfg)),
Err(e) => Err(format!(
"Syntax error in `{}`: {e}",
crate::output::clean_path(&config_file)
)),
}
}
pub fn save_to_repo(&self, repo_path: &Path) -> Result<()> {
let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
let content = serde_json::to_string_pretty(self)?;
fs::write(&config_file, content)?;
let _ = ensure_in_git_exclude(repo_path, constants::PER_REPO_CONFIG_FILE);
let _ = ensure_in_git_exclude(repo_path, constants::DEVPRUNE_IGNORE_FILE);
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PrunedDir {
pub repo_path: PathBuf,
pub bloat_dir: String,
pub adapter: String,
pub size_freed: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LastPrune {
pub at: DateTime<Utc>,
pub dirs: Vec<PrunedDir>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PruneRunSummary {
pub at: DateTime<Utc>,
pub bytes_freed: u64,
pub dirs_removed: usize,
pub repos_touched: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Registry {
pub version: String,
pub settings: Settings,
pub repositories: HashMap<PathBuf, RepoEntry>,
#[serde(default)]
pub total_freed_bytes: u64,
#[serde(default)]
pub total_pruned_count: u64,
#[serde(default)]
pub last_added_repos: Vec<PathBuf>,
#[serde(default)]
pub last_prune: Option<LastPrune>,
#[serde(default)]
pub prune_history: Vec<PruneRunSummary>,
#[serde(default)]
pub last_update_check: Option<DateTime<Utc>>,
#[serde(default)]
pub latest_known_version: Option<String>,
}
impl Default for Registry {
fn default() -> Self {
Self {
version: "1.0".to_string(),
settings: Settings::default(),
repositories: HashMap::new(),
total_freed_bytes: 0,
total_pruned_count: 0,
last_added_repos: Vec::new(),
last_prune: None,
prune_history: Vec::new(),
last_update_check: None,
latest_known_version: None,
}
}
}
impl Registry {
pub fn config_dir() -> Result<PathBuf> {
if let Ok(override_dir) = std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE) {
return Ok(PathBuf::from(override_dir));
}
let base = dirs::config_dir().context("Could not determine config directory")?;
Ok(base.join(constants::CONFIG_DIR_NAME))
}
pub fn registry_path() -> Result<PathBuf> {
Ok(Self::config_dir()?.join(constants::REGISTRY_FILENAME))
}
pub fn load() -> Result<Self> {
Self::load_from(&Self::registry_path()?)
}
pub fn load_from(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Registry::default());
}
let contents = fs::read_to_string(path)
.with_context(|| format!("Failed to read registry at {}", path.display()))?;
serde_json::from_str(&contents)
.with_context(|| format!("Failed to parse registry at {}", path.display()))
}
pub fn save(&self) -> Result<()> {
let path = Self::registry_path()?;
self.save_to(&path)
}
pub fn save_to(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config dir {}", parent.display()))?;
}
let tmp_path = path.with_extension(format!("json.{}.tmp", std::process::id()));
let contents =
serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
{
use std::io::Write;
let mut file = fs::File::create(&tmp_path)
.with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
file.write_all(contents.as_bytes())
.with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
file.sync_all()
.with_context(|| format!("Failed to flush temp registry {}", tmp_path.display()))?;
}
fs::rename(&tmp_path, path)
.with_context(|| format!("Failed to rename temp registry to {}", path.display()))?;
if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) {
let prefix = format!("{}.", name.to_string_lossy());
if let Ok(entries) = fs::read_dir(parent) {
for entry in entries.flatten() {
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy();
if file_name.starts_with(&prefix)
&& file_name.ends_with(".tmp")
&& entry
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age.as_secs() > 3600)
{
let _ = fs::remove_file(entry.path());
}
}
}
}
Ok(())
}
pub fn add_repo(&mut self, path: PathBuf) -> bool {
let path = canonical_key(&path);
if self.repositories.contains_key(&path) {
return false;
}
self.repositories.insert(path, RepoEntry::new());
true
}
pub fn remove_repo(&mut self, path: &Path) -> bool {
let target = lexical_absolute(path);
let removed = if self.repositories.remove(&canonical_key(path)).is_some() {
true
} else {
let found = self
.repositories
.keys()
.find(|k| loose_path_eq(k, &target))
.cloned();
found.is_some_and(|k| self.repositories.remove(&k).is_some())
};
if removed {
self.last_added_repos.retain(|p| !loose_path_eq(p, &target));
}
removed
}
pub fn mark_pruned(&mut self, path: &Path, bytes_freed: u64) {
if let Some(entry) = self.repositories.get_mut(&canonical_key(path)) {
entry.last_pruned_at = Some(Utc::now());
entry.total_freed_bytes += bytes_freed;
}
self.total_freed_bytes += bytes_freed;
}
pub fn record_prune(&mut self, dirs: Vec<PrunedDir>) {
self.record_prune_progress(Utc::now(), dirs);
}
pub fn record_prune_progress(&mut self, at: DateTime<Utc>, dirs: Vec<PrunedDir>) {
if dirs.is_empty() {
return;
}
if self.prune_history.last().map(|s| s.at) == Some(at) {
self.prune_history.pop();
} else {
self.total_pruned_count += 1;
}
self.prune_history.push(PruneRunSummary {
at,
bytes_freed: dirs.iter().map(|d| d.size_freed).sum(),
dirs_removed: dirs.len(),
repos_touched: dirs
.iter()
.map(|d| &d.repo_path)
.collect::<HashSet<_>>()
.len(),
});
if self.prune_history.len() > constants::PRUNE_HISTORY_LIMIT {
let excess = self.prune_history.len() - constants::PRUNE_HISTORY_LIMIT;
self.prune_history.drain(..excess);
}
self.last_prune = Some(LastPrune { at, dirs });
}
pub fn repo_count(&self) -> usize {
self.repositories.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_registry_path(dir: &TempDir) -> PathBuf {
dir.path().join("dev-prune").join("registry.json")
}
fn a_pruned_dir(label: &str) -> PrunedDir {
PrunedDir {
repo_path: PathBuf::from("/repo"),
bloat_dir: label.to_string(),
adapter: "npm".to_string(),
size_freed: 42,
}
}
#[test]
fn a_prune_that_deleted_nothing_does_not_erase_the_last_one() {
let mut registry = Registry::default();
registry.record_prune(vec![a_pruned_dir("node_modules")]);
let recorded = registry.last_prune.clone().expect("first pass recorded");
registry.record_prune(Vec::new());
assert_eq!(registry.last_prune, Some(recorded));
}
#[test]
fn a_later_prune_replaces_the_record() {
let mut registry = Registry::default();
registry.record_prune(vec![a_pruned_dir("node_modules")]);
registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
let dirs = registry.last_prune.unwrap().dirs;
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].bloat_dir, "frontend/node_modules");
}
#[test]
fn the_last_prune_record_survives_a_save_and_load() {
let dir = TempDir::new().unwrap();
let path = test_registry_path(&dir);
let mut registry = Registry::default();
registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
registry.save_to(&path).unwrap();
let loaded = Registry::load_from(&path).unwrap();
assert_eq!(loaded.last_prune, registry.last_prune);
}
#[test]
fn a_registry_written_before_the_field_existed_still_loads() {
let dir = TempDir::new().unwrap();
let path = test_registry_path(&dir);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
r#"{"version":"1.0","settings":{"idle_days":15,"check_interval_days":2,
"auto_daemon":true},"repositories":{}}"#,
)
.unwrap();
let loaded = Registry::load_from(&path).unwrap();
assert_eq!(loaded.last_prune, None);
}
#[test]
fn a_leading_tilde_becomes_the_home_directory() {
let home = dirs::home_dir().expect("test host has a home directory");
assert_eq!(expand_tilde("~"), home.to_string_lossy());
assert_eq!(
expand_tilde("~/Code"),
home.join("Code").to_string_lossy(),
"forward slash, as typed in every shell"
);
assert_eq!(
expand_tilde("~\\Code"),
home.join("Code").to_string_lossy(),
"backslash, as typed in PowerShell"
);
}
#[test]
fn a_tilde_that_is_not_a_home_reference_is_left_alone() {
for raw in ["~alice/Code", "~backup", "./~tmp", "Code~", "", "."] {
assert_eq!(expand_tilde(raw), raw, "{raw} must survive untouched");
}
}
#[test]
fn test_default_settings() {
let settings = Settings::default();
assert_eq!(settings.idle_days, 15);
assert_eq!(settings.check_interval_days, 2);
assert!(settings.auto_daemon);
assert!(settings.auto_hooks);
assert!(settings.auto_setup);
}
#[test]
fn settings_written_before_the_automation_toggles_existed_still_load() {
let json = r#"{
"idle_days": 30,
"check_interval_days": 2,
"auto_daemon": false
}"#;
let settings: Settings = serde_json::from_str(json).unwrap();
assert_eq!(settings.idle_days, 30);
assert!(!settings.auto_daemon, "an explicit opt-out is preserved");
assert!(settings.auto_hooks, "a missing key takes the default");
assert!(settings.auto_setup);
}
#[test]
fn test_default_registry() {
let registry = Registry::default();
assert_eq!(registry.version, "1.0");
assert_eq!(registry.settings, Settings::default());
assert!(registry.repositories.is_empty());
}
#[test]
fn test_repo_entry_new() {
let entry = RepoEntry::new();
assert!(entry.enabled);
assert!(entry.last_pruned_at.is_none());
assert!(entry.override_idle_days.is_none());
}
#[test]
fn test_save_and_load() {
let tmp = TempDir::new().unwrap();
let path = test_registry_path(&tmp);
let mut registry = Registry::default();
registry.add_repo(PathBuf::from("/test/repo"));
registry.save_to(&path).unwrap();
let loaded = Registry::load_from(&path).unwrap();
assert_eq!(loaded.repo_count(), 1);
assert!(
loaded
.repositories
.contains_key(&PathBuf::from("/test/repo"))
);
}
#[test]
fn loading_a_missing_registry_yields_the_defaults_and_writes_nothing() {
let tmp = TempDir::new().unwrap();
let path = test_registry_path(&tmp);
let loaded = Registry::load_from(&path).unwrap();
assert_eq!(loaded, Registry::default());
assert!(!path.exists(), "loading the registry created it");
}
#[test]
fn test_add_repo_returns_true_for_new() {
let mut registry = Registry::default();
assert!(registry.add_repo(PathBuf::from("/test/repo")));
}
#[test]
fn test_add_repo_returns_false_for_duplicate() {
let mut registry = Registry::default();
registry.add_repo(PathBuf::from("/test/repo"));
assert!(!registry.add_repo(PathBuf::from("/test/repo")));
}
#[test]
fn test_remove_repo() {
let mut registry = Registry::default();
registry.add_repo(PathBuf::from("/test/repo"));
assert!(registry.remove_repo(Path::new("/test/repo")));
assert!(!registry.remove_repo(Path::new("/test/repo")));
assert_eq!(registry.repo_count(), 0);
}
#[cfg(unix)]
#[test]
fn a_deleted_repo_named_through_a_symlinked_parent_still_unlinks() {
let tmp = TempDir::new().unwrap();
let real_parent = tmp.path().join("real");
std::fs::create_dir(&real_parent).unwrap();
let alias = tmp.path().join("alias");
std::os::unix::fs::symlink(&real_parent, &alias).unwrap();
let repo = real_parent.join("repo");
std::fs::create_dir(&repo).unwrap();
let mut registry = Registry::default();
registry.add_repo(alias.join("repo"));
std::fs::remove_dir(&repo).unwrap();
assert!(registry.remove_repo(&alias.join("repo")));
assert_eq!(registry.repo_count(), 0);
}
#[test]
fn test_mark_pruned() {
let mut registry = Registry::default();
registry.add_repo(PathBuf::from("/test/repo"));
assert!(
registry.repositories[&PathBuf::from("/test/repo")]
.last_pruned_at
.is_none()
);
registry.mark_pruned(Path::new("/test/repo"), 1024);
assert!(
registry.repositories[&PathBuf::from("/test/repo")]
.last_pruned_at
.is_some()
);
assert_eq!(registry.total_freed_bytes, 1024);
assert_eq!(registry.total_pruned_count, 0);
}
#[test]
fn a_pass_is_counted_once_however_much_it_deleted() {
let mut registry = Registry::default();
registry.add_repo(PathBuf::from("/repo"));
registry.mark_pruned(Path::new("/repo"), 1024);
registry.mark_pruned(Path::new("/repo"), 1024);
registry.record_prune(vec![
a_pruned_dir("node_modules"),
a_pruned_dir("frontend/node_modules"),
]);
assert_eq!(registry.total_pruned_count, 1);
registry.record_prune(vec![a_pruned_dir("target")]);
assert_eq!(registry.total_pruned_count, 2);
registry.record_prune(Vec::new());
assert_eq!(registry.total_pruned_count, 2);
}
#[test]
fn mark_pruned_credits_the_repo_under_its_canonical_key() {
let tmp = TempDir::new().unwrap();
let raw = tmp.path().to_path_buf();
let mut registry = Registry::default();
registry.add_repo(raw.clone());
registry.mark_pruned(&raw, 1024);
let entry = ®istry.repositories[&canonical_key(&raw)];
assert_eq!(entry.total_freed_bytes, 1024);
assert!(entry.last_pruned_at.is_some());
assert_eq!(registry.total_freed_bytes, 1024);
}
#[test]
fn each_repository_accumulates_its_own_total() {
let mut registry = Registry::default();
registry.add_repo(PathBuf::from("/test/repo"));
registry.add_repo(PathBuf::from("/test/other"));
registry.mark_pruned(Path::new("/test/repo"), 1024);
registry.mark_pruned(Path::new("/test/repo"), 2048);
registry.mark_pruned(Path::new("/test/other"), 512);
assert_eq!(
registry.repositories[&PathBuf::from("/test/repo")].total_freed_bytes,
3072
);
assert_eq!(
registry.repositories[&PathBuf::from("/test/other")].total_freed_bytes,
512
);
assert_eq!(registry.total_freed_bytes, 3584);
}
#[test]
fn the_prune_history_summarises_the_pass() {
let mut registry = Registry::default();
registry.record_prune(vec![
a_pruned_dir("node_modules"),
a_pruned_dir("frontend/node_modules"),
]);
let summary = registry.prune_history.last().expect("pass summarised");
assert_eq!(summary.bytes_freed, 84);
assert_eq!(summary.dirs_removed, 2);
assert_eq!(summary.repos_touched, 1);
}
#[test]
fn the_prune_history_is_capped_and_drops_the_oldest() {
let mut registry = Registry::default();
for _ in 0..constants::PRUNE_HISTORY_LIMIT + 5 {
registry.record_prune(vec![a_pruned_dir("node_modules")]);
}
assert_eq!(registry.prune_history.len(), constants::PRUNE_HISTORY_LIMIT);
let first = registry.prune_history.first().unwrap().at;
let last = registry.prune_history.last().unwrap().at;
assert!(first <= last, "oldest first");
}
#[test]
fn test_repo_count() {
let mut registry = Registry::default();
assert_eq!(registry.repo_count(), 0);
registry.add_repo(PathBuf::from("/a"));
registry.add_repo(PathBuf::from("/b"));
assert_eq!(registry.repo_count(), 2);
}
#[test]
fn a_local_schema_uri_has_exactly_three_slashes_on_either_platform() {
assert_eq!(
file_uri("/home/dev/.config/dev-prune/bin/devprune.schema.json"),
"file:///home/dev/.config/dev-prune/bin/devprune.schema.json"
);
assert_eq!(
file_uri("C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"),
"file:///C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"
);
}
#[test]
fn a_broken_per_repo_config_is_an_error_rather_than_an_absent_one() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
assert_eq!(PerRepoConfig::load_with_diagnostics(repo), Ok(None));
fs::write(
repo.join(constants::PER_REPO_CONFIG_FILE),
r#"{ "ignore": true, }"#,
)
.unwrap();
let err = PerRepoConfig::load_with_diagnostics(repo).unwrap_err();
assert!(err.contains("Syntax error"), "{err}");
fs::write(
repo.join(constants::PER_REPO_CONFIG_FILE),
r#"{ "ignore": true }"#,
)
.unwrap();
assert!(
PerRepoConfig::load_with_diagnostics(repo)
.unwrap()
.unwrap()
.ignore
);
}
#[test]
fn test_serialization_roundtrip() {
let mut registry = Registry::default();
registry.settings.idle_days = 30;
registry.add_repo(PathBuf::from("/test/repo"));
let json = serde_json::to_string_pretty(®istry).unwrap();
let deserialized: Registry = serde_json::from_str(&json).unwrap();
assert_eq!(registry.settings.idle_days, deserialized.settings.idle_days);
assert_eq!(registry.repo_count(), deserialized.repo_count());
}
#[test]
fn test_atomic_save_leaves_no_tmp() {
let tmp = TempDir::new().unwrap();
let path = test_registry_path(&tmp);
let registry = Registry::default();
registry.save_to(&path).unwrap();
assert!(path.exists());
let leftovers: Vec<_> = fs::read_dir(path.parent().unwrap())
.unwrap()
.flatten()
.filter(|e| e.path() != path)
.collect();
assert!(leftovers.is_empty(), "leftover files: {leftovers:?}");
}
#[test]
fn exclude_entry_lands_in_git_info_exclude_not_gitignore() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
fs::create_dir(repo.join(".git")).unwrap();
ensure_in_git_exclude(repo, ".devprune.json").unwrap();
let exclude = fs::read_to_string(repo.join(".git/info/exclude")).unwrap();
assert!(exclude.lines().any(|l| l == ".devprune.json"));
assert!(!repo.join(".gitignore").exists());
}
#[test]
fn exclude_entry_is_appended_once_and_preserves_existing_lines() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
fs::create_dir_all(repo.join(".git/info")).unwrap();
fs::write(repo.join(".git/info/exclude"), "*.log").unwrap();
ensure_in_git_exclude(repo, ".devprune.json").unwrap();
ensure_in_git_exclude(repo, ".devprune.json").unwrap();
let exclude = fs::read_to_string(repo.join(".git/info/exclude")).unwrap();
let lines: Vec<_> = exclude.lines().collect();
assert_eq!(lines, vec!["*.log", ".devprune.json"]);
}
#[test]
fn exclude_follows_a_gitdir_pointer_file() {
let tmp = TempDir::new().unwrap();
let shared = tmp.path().join("main-clone/.git");
let worktree_gitdir = shared.join("worktrees/wt");
fs::create_dir_all(&worktree_gitdir).unwrap();
fs::write(worktree_gitdir.join("commondir"), "../..\n").unwrap();
let wt = tmp.path().join("wt");
fs::create_dir(&wt).unwrap();
fs::write(
wt.join(".git"),
format!("gitdir: {}\n", worktree_gitdir.display()),
)
.unwrap();
ensure_in_git_exclude(&wt, ".devprune.json").unwrap();
let exclude = fs::read_to_string(shared.join("info/exclude")).unwrap();
assert!(exclude.lines().any(|l| l == ".devprune.json"));
}
#[test]
fn exclude_is_a_no_op_outside_a_git_repository() {
let tmp = TempDir::new().unwrap();
ensure_in_git_exclude(tmp.path(), ".devprune.json").unwrap();
assert!(!tmp.path().join(".git").exists());
assert!(!tmp.path().join(".gitignore").exists());
}
}