ssrm 1.0.0

CLI tool to remove macOS screenshots and screen recordings
Documentation
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() {
        // Various valid 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());
        // Invalid formats
        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()); // lowercase
        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());
        // Invalid
        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()); // wrong format
        assert!(parse_date("2024/01/15").is_none()); // wrong separator
        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() {
        // Ensure static regexes compile without panic
        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"));
    }
}