use std::fs; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use anyhow::{Context, Result, bail}; use clap::Parser; use serde::Serialize; const DEFAULT_DATA_DIR: &str = "/var/lib/activitywatch"; const TMP_ARCHIVE_PATTERNS: &[NamePattern] = &[ NamePattern::PrefixSuffix("activitywatch-", ".zip"), NamePattern::PrefixSuffix("hayabusa-", ".zip"), NamePattern::Exact("aw-hayabusa-profiles.txt"), ]; const TMP_WEBUI_PATTERNS: &[NamePattern] = &[ NamePattern::Exact("aw-worktime-ui-bridge.py"), NamePattern::Exact("views-default.json"), NamePattern::Exact("apply_webui_ru_patch.out"), ]; #[derive(Debug, Parser)] #[command(about = "Safely prune ActivityWatch local app state and temporary artifacts")] struct Cli { #[arg(long)] data_dir: Option, #[arg(long)] backup_dir: Option, #[arg(long)] browser_smoke_dir: Option, #[arg(long)] tmp_dir: Option, #[arg(long, default_value_t = 7)] backup_retention_days: u64, #[arg(long, default_value_t = 2)] backup_keep_last_db: usize, #[arg(long, default_value_t = 2)] backup_keep_last_json: usize, #[arg(long, default_value_t = 24)] browser_smoke_keep_runs: usize, #[arg(long, default_value_t = 1)] browser_smoke_retention_days: u64, #[arg(long, default_value_t = 1)] tmp_archive_retention_days: u64, #[arg(long, default_value_t = 2)] tmp_webui_retention_days: u64, #[arg(long, default_value_t = false)] apply: bool, #[arg(long, default_value_t = false)] json: bool, } #[derive(Debug, Clone)] struct Config { data_dir: PathBuf, backup_dir: PathBuf, browser_smoke_dir: PathBuf, tmp_dir: PathBuf, backup_retention_days: u64, backup_keep_last_db: usize, backup_keep_last_json: usize, browser_smoke_keep_runs: usize, browser_smoke_retention_days: u64, tmp_archive_retention_days: u64, tmp_webui_retention_days: u64, apply: bool, json: bool, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum ItemKind { File, Directory, } #[derive(Debug, Clone, Serialize)] struct PruneItem { path: PathBuf, kind: ItemKind, reason: String, age_days: Option, size_bytes: u64, } #[derive(Debug, Serialize)] struct Summary { apply: bool, planned: usize, deleted: usize, failed: usize, bytes: u64, items: Vec, errors: Vec, } #[derive(Debug, Clone, Copy)] enum NamePattern { Exact(&'static str), PrefixSuffix(&'static str, &'static str), } impl NamePattern { fn matches(self, name: &str) -> bool { match self { Self::Exact(expected) => name == expected, Self::PrefixSuffix(prefix, suffix) => { name.starts_with(prefix) && name.ends_with(suffix) } } } } fn main() { let code = match run() { Ok(code) => code, Err(err) => { eprintln!("{err:#}"); 1 } }; std::process::exit(code); } fn run() -> Result { let cfg = Config::from_cli(Cli::parse()); let mut items = Vec::new(); fs::create_dir_all(&cfg.backup_dir) .with_context(|| format!("create backup dir {}", cfg.backup_dir.display()))?; plan_backup_group( &cfg.backup_dir.join("db"), cfg.backup_keep_last_db, cfg.backup_retention_days, "backup_db", &mut items, )?; plan_backup_group( &cfg.backup_dir, cfg.backup_keep_last_json, cfg.backup_retention_days, "backup_root", &mut items, )?; plan_browser_smoke(&cfg, &mut items)?; plan_tmp( &cfg.tmp_dir, TMP_ARCHIVE_PATTERNS, cfg.tmp_archive_retention_days, "tmp_archive", &mut items, )?; plan_tmp( &cfg.tmp_dir, TMP_WEBUI_PATTERNS, cfg.tmp_webui_retention_days, "tmp_webui", &mut items, )?; validate_plan(&cfg, &items)?; let mut summary = Summary { apply: cfg.apply, planned: items.len(), deleted: 0, failed: 0, bytes: items.iter().map(|item| item.size_bytes).sum(), items, errors: Vec::new(), }; if cfg.apply { apply_plan(&mut summary); } print_summary(&summary, cfg.json)?; if summary.failed == 0 { Ok(0) } else { Ok(1) } } impl Config { fn from_cli(cli: Cli) -> Self { let data_dir = cli .data_dir .or_else(|| env_path("AW_DATA_DIR")) .unwrap_or_else(|| PathBuf::from(DEFAULT_DATA_DIR)); let backup_dir = cli .backup_dir .or_else(|| env_path("AW_BACKUP_DIR")) .unwrap_or_else(|| data_dir.join("backups")); let browser_smoke_dir = cli .browser_smoke_dir .or_else(|| env_path("AW_BROWSER_SMOKE_OUTPUT_DIR")) .unwrap_or_else(|| data_dir.join("browser-smoke")); let tmp_dir = cli .tmp_dir .or_else(|| env_path("AW_TMP_DIR")) .unwrap_or_else(|| PathBuf::from("/tmp")); Self { data_dir, backup_dir, browser_smoke_dir, tmp_dir, backup_retention_days: env_u64("AW_BACKUP_RETENTION_DAYS", cli.backup_retention_days), backup_keep_last_db: env_usize("AW_BACKUP_KEEP_LAST_DB", cli.backup_keep_last_db), backup_keep_last_json: env_usize("AW_BACKUP_KEEP_LAST_JSON", cli.backup_keep_last_json), browser_smoke_keep_runs: env_usize( "AW_BROWSER_SMOKE_KEEP_RUNS", cli.browser_smoke_keep_runs, ), browser_smoke_retention_days: env_u64( "AW_BROWSER_SMOKE_RETENTION_DAYS", cli.browser_smoke_retention_days, ), tmp_archive_retention_days: env_u64( "AW_TMP_ARCHIVE_RETENTION_DAYS", cli.tmp_archive_retention_days, ), tmp_webui_retention_days: env_u64( "AW_TMP_WEBUI_RETENTION_DAYS", cli.tmp_webui_retention_days, ), apply: cli.apply, json: cli.json, } } } fn plan_backup_group( dir: &Path, keep_last: usize, keep_days: u64, reason: &str, items: &mut Vec, ) -> Result<()> { let mut files = list_files(dir)?; files.sort_by(|a, b| b.modified.cmp(&a.modified)); let cutoff = cutoff(keep_days); for (idx, candidate) in files.into_iter().enumerate() { if idx < keep_last || candidate.modified >= cutoff || is_rollback_critical(&candidate.path) { continue; } items.push(candidate.into_item(reason)); } Ok(()) } fn plan_browser_smoke(cfg: &Config, items: &mut Vec) -> Result<()> { let mut dirs = list_run_dirs(&cfg.browser_smoke_dir)?; dirs.sort_by(|a, b| b.modified.cmp(&a.modified)); let cutoff = cutoff(cfg.browser_smoke_retention_days); for (idx, candidate) in dirs.into_iter().enumerate() { if idx < cfg.browser_smoke_keep_runs || candidate.modified >= cutoff { continue; } items.push(candidate.into_item("browser_smoke_run")); } Ok(()) } fn plan_tmp( dir: &Path, patterns: &[NamePattern], keep_days: u64, reason: &str, items: &mut Vec, ) -> Result<()> { if !dir.exists() { return Ok(()); } let cutoff = cutoff(keep_days); for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? { let entry = entry?; let file_type = entry.file_type()?; if !file_type.is_file() && !file_type.is_symlink() { continue; } let name = entry.file_name(); let name = name.to_string_lossy(); if !patterns.iter().any(|pattern| pattern.matches(&name)) { continue; } let meta = entry.metadata()?; let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); if modified >= cutoff { continue; } items.push( Candidate { path: entry.path(), kind: ItemKind::File, modified, size_bytes: meta.len(), } .into_item(reason), ); } Ok(()) } #[derive(Debug)] struct Candidate { path: PathBuf, kind: ItemKind, modified: SystemTime, size_bytes: u64, } impl Candidate { fn into_item(self, reason: &str) -> PruneItem { PruneItem { path: self.path, kind: self.kind, reason: reason.to_string(), age_days: age_days(self.modified), size_bytes: self.size_bytes, } } } fn list_files(dir: &Path) -> Result> { if !dir.exists() { return Ok(Vec::new()); } let mut files = Vec::new(); for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? { let entry = entry?; if !entry.file_type()?.is_file() { continue; } let meta = entry.metadata()?; files.push(Candidate { path: entry.path(), kind: ItemKind::File, modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH), size_bytes: meta.len(), }); } Ok(files) } fn list_run_dirs(dir: &Path) -> Result> { if !dir.exists() { return Ok(Vec::new()); } let mut dirs = Vec::new(); for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? { let entry = entry?; if !entry.file_type()?.is_dir() { continue; } let name = entry.file_name(); let name = name.to_string_lossy(); if !looks_like_browser_smoke_run(&name) { continue; } let meta = entry.metadata()?; dirs.push(Candidate { path: entry.path(), kind: ItemKind::Directory, modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH), size_bytes: dir_size(&entry.path()).unwrap_or(0), }); } Ok(dirs) } fn looks_like_browser_smoke_run(name: &str) -> bool { name.len() >= 20 && name.starts_with("20") && name.contains('T') && name .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') } fn validate_plan(cfg: &Config, items: &[PruneItem]) -> Result<()> { for item in items { if is_rollback_critical(&item.path) { bail!( "refusing to delete rollback-critical path {}", item.path.display() ); } let allowed = match item.reason.as_str() { "backup_db" => is_under_or_equal(&item.path, &cfg.backup_dir.join("db")), "backup_root" => is_under_or_equal(&item.path, &cfg.backup_dir), "browser_smoke_run" => is_under_or_equal(&item.path, &cfg.browser_smoke_dir), "tmp_archive" | "tmp_webui" => is_under_or_equal(&item.path, &cfg.tmp_dir), _ => false, }; if !allowed { bail!( "refusing to delete path outside allowlist {}", item.path.display() ); } if item.reason == "backup_root" && item.path.parent() != Some(cfg.backup_dir.as_path()) { bail!("refusing nested backup_root delete {}", item.path.display()); } if item.path == cfg.data_dir || item.path == cfg.backup_dir || item.path == cfg.browser_smoke_dir { bail!("refusing to delete root directory {}", item.path.display()); } let name = item .path .file_name() .and_then(|name| name.to_str()) .unwrap_or(""); if is_sqlite_db_name(name) && item.reason != "backup_db" { bail!( "refusing to delete SQLite DB outside backup_db {}", item.path.display() ); } } Ok(()) } fn apply_plan(summary: &mut Summary) { for item in summary.items.clone() { let result = match item.kind { ItemKind::File => fs::remove_file(&item.path), ItemKind::Directory => fs::remove_dir_all(&item.path), }; match result { Ok(()) => summary.deleted += 1, Err(err) if !item.path.exists() => { summary.deleted += 1; summary .errors .push(format!("already gone: {} ({err})", item.path.display())); } Err(err) => { summary.failed += 1; summary .errors .push(format!("{}: {err}", item.path.display())); } } } } fn print_summary(summary: &Summary, json: bool) -> Result<()> { if json { println!("{}", serde_json::to_string_pretty(summary)?); return Ok(()); } let mode = if summary.apply { "apply" } else { "dry-run" }; println!( "aw-prune-local-state: mode={mode} planned={} deleted={} failed={} bytes={}", summary.planned, summary.deleted, summary.failed, summary.bytes ); for item in &summary.items { let verb = if summary.apply { "DELETE" } else { "WOULD_DELETE" }; println!( "{verb} {:?} {} reason={} age_days={} bytes={}", item.kind, item.path.display(), item.reason, item.age_days .map(|value| value.to_string()) .unwrap_or_else(|| "unknown".to_string()), item.size_bytes ); } for err in &summary.errors { eprintln!("WARN {err}"); } Ok(()) } fn cutoff(days: u64) -> SystemTime { SystemTime::now() .checked_sub(Duration::from_secs(days.saturating_mul(86_400))) .unwrap_or(SystemTime::UNIX_EPOCH) } fn age_days(modified: SystemTime) -> Option { SystemTime::now() .duration_since(modified) .ok() .map(|age| age.as_secs() / 86_400) } fn dir_size(path: &Path) -> Result { let mut total = 0; for entry in fs::read_dir(path).with_context(|| format!("read {}", path.display()))? { let entry = entry?; let file_type = entry.file_type()?; let child = entry.path(); if file_type.is_dir() { total += dir_size(&child).unwrap_or(0); } else if file_type.is_file() || file_type.is_symlink() { total += entry.metadata().map(|meta| meta.len()).unwrap_or(0); } } Ok(total) } fn is_under_or_equal(path: &Path, root: &Path) -> bool { path == root || path.starts_with(root) } fn is_rollback_critical(path: &Path) -> bool { let text = path.to_string_lossy().to_ascii_lowercase(); text.contains("switch-backups") || text.contains("before-rust") || text.contains("rollback") || text.contains("pre-switch") } fn is_sqlite_db_name(name: &str) -> bool { name.ends_with(".sqlite") || name.ends_with(".sqlite3") || name.ends_with(".db") || name.ends_with(".db-shm") || name.ends_with(".db-wal") } fn env_path(name: &str) -> Option { std::env::var(name) .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .map(PathBuf::from) } fn env_u64(name: &str, default: u64) -> u64 { std::env::var(name) .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(default) } fn env_usize(name: &str, default: usize) -> usize { std::env::var(name) .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(default) } #[cfg(test)] mod tests { use super::*; use std::fs::File; #[test] fn browser_smoke_run_name_is_narrow() { assert!(looks_like_browser_smoke_run("2026-06-02T03-02-19-530Z")); assert!(!looks_like_browser_smoke_run(".cache")); assert!(!looks_like_browser_smoke_run("latest-result.json")); } #[test] fn rollback_critical_paths_are_protected() { assert!(is_rollback_critical(Path::new( "/var/lib/activitywatch/health/switch-backups/file" ))); assert!(is_rollback_critical(Path::new( "/var/lib/activitywatch/backups/db/before-rust.sqlite" ))); } #[test] fn backup_group_keeps_newest_even_with_zero_retention() { let tmp = tempfile::tempdir().expect("tempdir"); let old = tmp.path().join("old.json"); let new = tmp.path().join("new.json"); File::create(&old).expect("old"); std::thread::sleep(Duration::from_millis(5)); File::create(&new).expect("new"); let mut items = Vec::new(); plan_backup_group(tmp.path(), 1, 0, "backup_root", &mut items).expect("plan"); assert_eq!(items.len(), 1); assert_eq!(items[0].path, old); } #[test] fn validation_rejects_sqlite_outside_backup_db() { let tmp = tempfile::tempdir().expect("tempdir"); let cfg = Config { data_dir: tmp.path().to_path_buf(), backup_dir: tmp.path().join("backups"), browser_smoke_dir: tmp.path().join("browser-smoke"), tmp_dir: tmp.path().join("tmp"), backup_retention_days: 1, backup_keep_last_db: 1, backup_keep_last_json: 1, browser_smoke_keep_runs: 1, browser_smoke_retention_days: 1, tmp_archive_retention_days: 1, tmp_webui_retention_days: 1, apply: false, json: false, }; let item = PruneItem { path: cfg.browser_smoke_dir.join("state.db"), kind: ItemKind::File, reason: "browser_smoke_run".to_string(), age_days: Some(2), size_bytes: 1, }; assert!(validate_plan(&cfg, &[item]).is_err()); } }