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_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,
}
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_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,
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,
}
}
}
#[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()
}
}
pub fn ensure_in_gitignore(repo_path: &Path, entry: &str) -> Result<()> {
let gitignore_path = repo_path.join(".gitignore");
if gitignore_path.exists() {
let content = fs::read_to_string(&gitignore_path)?;
if !content.lines().any(|line| line.trim() == entry) {
let mut file = fs::OpenOptions::new().append(true).open(&gitignore_path)?;
let prefix = if content.ends_with('\n') || content.is_empty() {
""
} else {
"\n"
};
writeln!(file, "{prefix}{entry}")?;
}
} else {
fs::write(&gitignore_path, format!("{entry}\n"))?;
}
Ok(())
}
pub fn canonical_key(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
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_gitignore(repo_path, constants::PER_REPO_CONFIG_FILE);
let _ = ensure_in_gitignore(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")?;
fs::write(&tmp_path, &contents)
.with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
fs::rename(&tmp_path, path)
.with_context(|| format!("Failed to rename temp registry to {}", path.display()))?;
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 {
self.repositories.remove(&canonical_key(path)).is_some()
}
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);
}
#[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:?}");
}
}