use owo_colors::OwoColorize;
use regex::Regex;
use serde::Deserialize;
use std::{fs, io::{self, Write}, path::PathBuf, sync::LazyLock};
pub static SCREENSHOT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^Screenshot.+(\d{4})-(\d{2})-(\d{2}).+at.+\d{1,2}\.\d{2}\.\d{2}.+[AP]M.*\.(png|jpg|jpeg|heic)$").unwrap()
});
pub static RECORDING_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^Screen Recording.+(\d{4})-(\d{2})-(\d{2}).+at.+\d{1,2}\.\d{2}\.\d{2}.+[AP]M.*\.(mov|mp4)$").unwrap()
});
#[derive(Debug, Clone, Default)]
pub struct Config {
pub dry_run: bool,
pub use_trash: bool,
pub include_videos: bool,
pub skip_confirm: bool,
pub recursive: bool,
pub quiet: bool,
pub verbose: bool,
pub before: Option<chrono::NaiveDate>,
pub after: Option<chrono::NaiveDate>,
pub default_dir: Option<PathBuf>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct ConfigFile {
pub use_trash: Option<bool>,
pub include_videos: Option<bool>,
pub recursive: Option<bool>,
pub quiet: Option<bool>,
pub verbose: Option<bool>,
pub default_dir: Option<String>,
}
impl ConfigFile {
pub fn load() -> Option<Self> {
let path = dirs::config_dir()?.join("ssrm.toml");
fs::read_to_string(&path).ok().and_then(|s| toml::from_str(&s).ok())
}
}
impl Config {
pub fn with_file_defaults(mut self, file: Option<ConfigFile>) -> Self {
let Some(f) = file else { return self };
self.use_trash = self.use_trash || f.use_trash.unwrap_or(false);
self.include_videos = self.include_videos || f.include_videos.unwrap_or(false);
self.recursive = self.recursive || f.recursive.unwrap_or(false);
self.quiet = self.quiet || f.quiet.unwrap_or(false);
self.verbose = self.verbose || f.verbose.unwrap_or(false);
self.default_dir = f.default_dir.map(PathBuf::from);
self
}
}
#[derive(Debug, Default)]
pub struct RemovalStats {
pub found: usize,
pub removed: usize,
pub bytes_freed: u64,
pub failed: usize,
}
pub fn matches_file(name: &str, include_videos: bool) -> Option<(i32, u32, u32)> {
let caps = SCREENSHOT_RE.captures(name).or_else(|| {
if include_videos { RECORDING_RE.captures(name) } else { None }
})?;
Some((caps[1].parse().ok()?, caps[2].parse().ok()?, caps[3].parse().ok()?))
}
pub fn find_files(target: &PathBuf, cfg: &Config) -> io::Result<Vec<PathBuf>> {
let mut files = Vec::new();
collect_files(target, cfg, &mut files)?;
Ok(files)
}
fn collect_files(dir: &PathBuf, cfg: &Config, out: &mut Vec<PathBuf>) -> io::Result<()> {
if cfg.verbose { eprintln!("[scan] {}", dir.display()); }
for entry in fs::read_dir(dir)?.filter_map(Result::ok) {
let path = entry.path();
if path.is_dir() && cfg.recursive {
let _ = collect_files(&path, cfg, out);
} else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if let Some((y, m, d)) = matches_file(name, cfg.include_videos) {
let date = chrono::NaiveDate::from_ymd_opt(y, m, d);
let in_range = date.map(|d| {
cfg.before.map_or(true, |b| d < b) && cfg.after.map_or(true, |a| d > a)
}).unwrap_or(true);
if in_range {
if cfg.verbose { eprintln!("[match] {} ({})", name, chrono::NaiveDate::from_ymd_opt(y, m, d).map_or("?".into(), |d| d.to_string())); }
out.push(path);
} else if cfg.verbose {
eprintln!("[skip] {} (out of date range)", name);
}
}
}
}
Ok(())
}
pub fn remove_files(files: &[PathBuf], cfg: &Config) -> RemovalStats {
let mut stats = RemovalStats { found: files.len(), ..Default::default() };
for path in files {
let size = path.metadata().map(|m| m.len()).unwrap_or(0);
if cfg.verbose { eprintln!("[rm] {} ({} bytes)", path.display(), size); }
let result = if cfg.use_trash {
trash::delete(path).map_err(|e| e.to_string())
} else {
fs::remove_file(path).map_err(|e| e.to_string())
};
match result {
Ok(_) => {
stats.removed += 1;
stats.bytes_freed += size;
if !cfg.quiet {
let action = if cfg.use_trash { "Trashed" } else { "Removed" };
println!(" {} {}", format!("{}:", action).green(), path.file_name().unwrap().to_string_lossy());
}
}
Err(e) => {
stats.failed += 1;
if !cfg.quiet { eprintln!(" {} {} - {}", "Failed:".red(), path.display(), e); }
}
}
}
stats
}
pub fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut size = bytes as f64;
let mut unit = 0;
while size >= 1024.0 && unit < UNITS.len() - 1 {
size /= 1024.0;
unit += 1;
}
if unit == 0 { format!("{} {}", bytes, UNITS[0]) }
else { format!("{:.1} {}", size, UNITS[unit]) }
}
pub fn confirm(prompt: &str) -> bool {
print!("{}", prompt);
io::stdout().flush().ok();
let mut input = String::new();
io::stdin().read_line(&mut input).is_ok() && matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
}
pub fn parse_date(s: &str) -> Option<chrono::NaiveDate> {
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use tempfile::tempdir;
#[test]
fn test_screenshot_match() {
assert!(matches_file("Screenshot 2024-01-15 at 10.30.45 AM.png", false).is_some());
assert!(matches_file("Screenshot 2024-01-15 at 10.30.45\u{202F}AM.png", false).is_some());
assert!(matches_file("random.png", false).is_none());
}
#[test]
fn test_screenshot_formats() {
assert!(matches_file("Screenshot 2024-12-31 at 11.59.59 PM.png", false).is_some());
assert!(matches_file("Screenshot 2020-01-01 at 1.00.00 AM.jpg", false).is_some());
assert!(matches_file("Screenshot 2024-06-15 at 9.30.00 AM.jpeg", false).is_some());
assert!(matches_file("Screenshot 2024-06-15 at 9.30.00 AM.heic", false).is_some());
assert!(matches_file("Screenshot 2024-01-15.png", false).is_none());
assert!(matches_file("screenshot 2024-01-15 at 10.30.45 AM.png", false).is_none()); assert!(matches_file("Screenshot 2024-01-15 at 10.30.45 AM.gif", false).is_none());
}
#[test]
fn test_recording_match() {
assert!(matches_file("Screen Recording 2024-03-20 at 2.15.30 PM.mov", true).is_some());
assert!(matches_file("Screen Recording 2024-03-20 at 2.15.30 PM.mov", false).is_none());
}
#[test]
fn test_recording_formats() {
assert!(matches_file("Screen Recording 2024-03-20 at 2.15.30 PM.mp4", true).is_some());
assert!(matches_file("Screen Recording 2024-03-20 at 2.15.30 PM.mov", true).is_some());
assert!(matches_file("Screen Recording 2024-03-20 at 2.15.30 PM.avi", true).is_none());
assert!(matches_file("screen recording 2024-03-20 at 2.15.30 PM.mov", true).is_none());
}
#[test]
fn test_date_extraction() {
let (y, m, d) = matches_file("Screenshot 2024-01-15 at 10.30.45 AM.png", false).unwrap();
assert_eq!((y, m, d), (2024, 1, 15));
let (y, m, d) = matches_file("Screen Recording 2023-12-31 at 5.00.00 PM.mov", true).unwrap();
assert_eq!((y, m, d), (2023, 12, 31));
}
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(500), "500 B");
assert_eq!(format_bytes(1023), "1023 B");
assert_eq!(format_bytes(1024), "1.0 KB");
assert_eq!(format_bytes(1536), "1.5 KB");
assert_eq!(format_bytes(1_048_576), "1.0 MB");
assert_eq!(format_bytes(1_073_741_824), "1.0 GB");
assert_eq!(format_bytes(1_099_511_627_776), "1.0 TB");
}
#[test]
fn test_parse_date() {
assert_eq!(parse_date("2024-01-15"), Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 15).unwrap()));
assert_eq!(parse_date("2000-12-31"), Some(chrono::NaiveDate::from_ymd_opt(2000, 12, 31).unwrap()));
assert!(parse_date("invalid").is_none());
assert!(parse_date("01-15-2024").is_none()); assert!(parse_date("2024/01/15").is_none()); assert!(parse_date("").is_none());
}
#[test]
fn test_config_defaults() {
let cfg = Config::default();
assert!(!cfg.dry_run);
assert!(!cfg.use_trash);
assert!(!cfg.include_videos);
assert!(!cfg.skip_confirm);
assert!(!cfg.recursive);
assert!(!cfg.quiet);
assert!(!cfg.verbose);
assert!(cfg.before.is_none());
assert!(cfg.after.is_none());
assert!(cfg.default_dir.is_none());
}
#[test]
fn test_config_with_file_defaults() {
let cfg = Config::default();
let file = ConfigFile {
use_trash: Some(true),
include_videos: Some(true),
recursive: Some(true),
quiet: Some(false),
verbose: Some(true),
default_dir: Some("/tmp".into()),
};
let merged = cfg.with_file_defaults(Some(file));
assert!(merged.use_trash);
assert!(merged.include_videos);
assert!(merged.recursive);
assert!(!merged.quiet);
assert!(merged.verbose);
assert_eq!(merged.default_dir, Some(PathBuf::from("/tmp")));
}
#[test]
fn test_config_with_none_file() {
let cfg = Config { dry_run: true, ..Default::default() };
let merged = cfg.with_file_defaults(None);
assert!(merged.dry_run);
assert!(!merged.use_trash);
}
#[test]
fn test_removal_stats_default() {
let stats = RemovalStats::default();
assert_eq!(stats.found, 0);
assert_eq!(stats.removed, 0);
assert_eq!(stats.bytes_freed, 0);
assert_eq!(stats.failed, 0);
}
#[test]
fn test_find_files_empty_dir() {
let dir = tempdir().unwrap();
let cfg = Config::default();
let files = find_files(&dir.path().to_path_buf(), &cfg).unwrap();
assert!(files.is_empty());
}
#[test]
fn test_find_files_with_screenshot() {
let dir = tempdir().unwrap();
let screenshot = dir.path().join("Screenshot 2024-01-15 at 10.30.45 AM.png");
File::create(&screenshot).unwrap();
let cfg = Config::default();
let files = find_files(&dir.path().to_path_buf(), &cfg).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0], screenshot);
}
#[test]
fn test_find_files_ignores_non_screenshots() {
let dir = tempdir().unwrap();
File::create(dir.path().join("random.png")).unwrap();
File::create(dir.path().join("document.pdf")).unwrap();
let cfg = Config::default();
let files = find_files(&dir.path().to_path_buf(), &cfg).unwrap();
assert!(files.is_empty());
}
#[test]
fn test_find_files_recursive() {
let dir = tempdir().unwrap();
let subdir = dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
File::create(dir.path().join("Screenshot 2024-01-15 at 10.30.45 AM.png")).unwrap();
File::create(subdir.join("Screenshot 2024-02-20 at 3.00.00 PM.png")).unwrap();
let cfg = Config { recursive: true, ..Default::default() };
let files = find_files(&dir.path().to_path_buf(), &cfg).unwrap();
assert_eq!(files.len(), 2);
}
#[test]
fn test_find_files_non_recursive() {
let dir = tempdir().unwrap();
let subdir = dir.path().join("subdir");
fs::create_dir(&subdir).unwrap();
File::create(dir.path().join("Screenshot 2024-01-15 at 10.30.45 AM.png")).unwrap();
File::create(subdir.join("Screenshot 2024-02-20 at 3.00.00 PM.png")).unwrap();
let cfg = Config::default();
let files = find_files(&dir.path().to_path_buf(), &cfg).unwrap();
assert_eq!(files.len(), 1);
}
#[test]
fn test_find_files_date_filter_before() {
let dir = tempdir().unwrap();
File::create(dir.path().join("Screenshot 2024-01-15 at 10.30.45 AM.png")).unwrap();
File::create(dir.path().join("Screenshot 2024-06-15 at 10.30.45 AM.png")).unwrap();
let cfg = Config { before: parse_date("2024-03-01"), ..Default::default() };
let files = find_files(&dir.path().to_path_buf(), &cfg).unwrap();
assert_eq!(files.len(), 1);
}
#[test]
fn test_find_files_date_filter_after() {
let dir = tempdir().unwrap();
File::create(dir.path().join("Screenshot 2024-01-15 at 10.30.45 AM.png")).unwrap();
File::create(dir.path().join("Screenshot 2024-06-15 at 10.30.45 AM.png")).unwrap();
let cfg = Config { after: parse_date("2024-03-01"), ..Default::default() };
let files = find_files(&dir.path().to_path_buf(), &cfg).unwrap();
assert_eq!(files.len(), 1);
}
#[test]
fn test_find_files_videos() {
let dir = tempdir().unwrap();
File::create(dir.path().join("Screenshot 2024-01-15 at 10.30.45 AM.png")).unwrap();
File::create(dir.path().join("Screen Recording 2024-01-15 at 10.30.45 AM.mov")).unwrap();
let cfg_no_vid = Config::default();
assert_eq!(find_files(&dir.path().to_path_buf(), &cfg_no_vid).unwrap().len(), 1);
let cfg_with_vid = Config { include_videos: true, ..Default::default() };
assert_eq!(find_files(&dir.path().to_path_buf(), &cfg_with_vid).unwrap().len(), 2);
}
#[test]
fn test_regex_patterns_compiled() {
assert!(SCREENSHOT_RE.is_match("Screenshot 2024-01-15 at 10.30.45 AM.png"));
assert!(RECORDING_RE.is_match("Screen Recording 2024-01-15 at 10.30.45 AM.mov"));
}
}