sasurahime 0.1.8

macOS developer cache cleaner — scan and wipe stale caches from 40+ tools
use indicatif::{ProgressBar, ProgressStyle};
use std::path::Path;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::time::Duration;
use std::time::Instant;

pub trait ProgressReporter: Send + Sync {
    fn show_spinner(&self) -> bool;
    fn progress_init(&self, label: &str, total: usize);
    fn progress_tick(&self, path: &Path, current: usize, size_bytes: u64);
    fn progress_finish(&self);
}

fn spinner_style() -> &'static ProgressStyle {
    static STYLE: OnceLock<ProgressStyle> = OnceLock::new();
    STYLE.get_or_init(|| {
        ProgressStyle::default_spinner()
            .template("{spinner:.green} {msg}")
            .expect("valid indicatif template")
    })
}

pub fn with_spinner<R>(msg: &str, f: impl FnOnce() -> R) -> R {
    let pb = ProgressBar::new_spinner();
    pb.set_style(spinner_style().clone());
    pb.set_message(msg.to_string());
    pb.enable_steady_tick(Duration::from_millis(100));
    let result = f();
    pb.finish_and_clear();
    eprintln!("{msg} [OK]");
    result
}

pub struct VerboseProgress {
    pb: Mutex<Option<ProgressBar>>,
    last_tick: Mutex<Option<Instant>>,
}

impl VerboseProgress {
    pub fn new() -> Self {
        Self {
            pb: Mutex::new(None),
            last_tick: Mutex::new(None),
        }
    }
}

impl ProgressReporter for VerboseProgress {
    fn show_spinner(&self) -> bool {
        true
    }

    fn progress_init(&self, label: &str, total: usize) {
        let pb = ProgressBar::new(total as u64);
        pb.set_style(
            ProgressStyle::default_bar()
                .template(
                    "{spinner:.green} [{elapsed_precise}] {bar:30.cyan/blue} {pos}/{len} ETA {eta}",
                )
                .expect("valid indicatif template")
                .progress_chars("=> "),
        );
        pb.set_message(format!("Cleaning {label}..."));
        pb.enable_steady_tick(Duration::from_millis(100));
        *self.pb.lock().unwrap() = Some(pb);
    }

    fn progress_tick(&self, path: &Path, current: usize, size_bytes: u64) {
        if let Some(ref pb) = *self.pb.lock().unwrap() {
            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");

            let speed_str = self
                .last_tick
                .lock()
                .unwrap()
                .map(|start| {
                    let elapsed = start.elapsed();
                    let secs = elapsed.as_secs_f64().max(0.001);
                    format_speed(size_bytes, secs)
                })
                .unwrap_or_default();

            *self.last_tick.lock().unwrap() = Some(Instant::now());

            pb.set_message(format!(
                "{name}{speed_str} ({}/{})",
                current,
                pb.length().unwrap_or(0)
            ));
            pb.set_position(current as u64);
        }
    }

    fn progress_finish(&self) {
        if let Some(pb) = self.pb.lock().unwrap().take() {
            pb.finish_and_clear();
        }
    }
}

pub struct DeepSuppressReporter;

impl ProgressReporter for DeepSuppressReporter {
    fn show_spinner(&self) -> bool {
        false
    }
    fn progress_init(&self, _label: &str, _total: usize) {}
    fn progress_tick(&self, _path: &Path, _current: usize, _size_bytes: u64) {}
    fn progress_finish(&self) {}
}

pub struct SuppressReporter;

impl ProgressReporter for SuppressReporter {
    fn show_spinner(&self) -> bool {
        true
    }
    fn progress_init(&self, _label: &str, _total: usize) {}
    fn progress_tick(&self, _path: &Path, _current: usize, _size_bytes: u64) {}
    fn progress_finish(&self) {}
}

pub fn build_reporter_from_flags(suppress: bool, deep_suppress: bool) -> Box<dyn ProgressReporter> {
    if deep_suppress {
        Box::new(DeepSuppressReporter)
    } else if suppress {
        Box::new(SuppressReporter)
    } else {
        Box::new(VerboseProgress::new())
    }
}

pub fn merge_suppress_flags(
    cli_suppress: bool,
    cli_deep_suppress: bool,
    cfg_suppress: bool,
    cfg_deep_suppress: bool,
) -> (bool, bool) {
    let suppress = cli_suppress || cfg_suppress;
    let deep_suppress = cli_deep_suppress || cfg_deep_suppress;
    if deep_suppress {
        (false, true)
    } else {
        (suppress, false)
    }
}

fn format_speed(size_bytes: u64, elapsed_secs: f64) -> String {
    if size_bytes == 0 || elapsed_secs <= 0.0 {
        return String::new();
    }
    let mb = size_bytes as f64 / 1_048_576.0;
    let secs = elapsed_secs.max(0.001);
    format!(", {:.1} MB/s", mb / secs)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    #[test]
    fn with_spinner_returns_value() {
        let result = with_spinner("test", || 42);
        assert_eq!(result, 42);
    }

    #[test]
    fn verbose_progress_shows_spinner() {
        let reporter = VerboseProgress::new();
        assert!(reporter.show_spinner());
    }

    #[test]
    fn deep_suppress_reporter_hides_spinner() {
        let reporter = DeepSuppressReporter;
        assert!(!reporter.show_spinner());
    }

    #[test]
    fn suppress_reporter_shows_spinner() {
        let reporter = SuppressReporter;
        assert!(reporter.show_spinner());
    }

    #[test]
    fn verbose_progress_lifecycle() {
        let reporter = VerboseProgress::new();
        let path = Path::new("/tmp/test.log");
        reporter.progress_init("test", 5);
        reporter.progress_tick(path, 1, 1024);
        reporter.progress_tick(path, 2, 2048);
        reporter.progress_finish();
        assert!(reporter.show_spinner());
    }

    #[test]
    fn suppress_reporter_progress_is_noop() {
        let reporter = SuppressReporter;
        reporter.progress_init("test", 5);
        reporter.progress_tick(Path::new("/x"), 1, 512);
        reporter.progress_finish();
    }

    #[test]
    fn deep_suppress_reporter_progress_is_noop() {
        let reporter = DeepSuppressReporter;
        reporter.progress_init("test", 5);
        reporter.progress_tick(Path::new("/x"), 1, 512);
        reporter.progress_finish();
    }

    #[test]
    fn build_reporter_default_verbose() {
        let r = build_reporter_from_flags(false, false);
        assert!(r.show_spinner());
    }

    #[test]
    fn build_reporter_deep_suppress_wins_over_suppress() {
        let r = build_reporter_from_flags(true, true);
        assert!(!r.show_spinner());
    }

    #[test]
    fn build_reporter_suppress_shows_spinner() {
        let r = build_reporter_from_flags(true, false);
        assert!(r.show_spinner());
    }

    #[test]
    fn merge_flags_cli_suppress_or_config() {
        let (s, d) = merge_suppress_flags(true, false, false, false);
        assert!(s);
        assert!(!d);
    }

    #[test]
    fn merge_flags_config_suppress_applied() {
        let (s, _d) = merge_suppress_flags(false, false, true, false);
        assert!(s);
    }

    #[test]
    fn merge_flags_deep_wins_over_suppress() {
        let (_s, d) = merge_suppress_flags(true, true, false, false);
        assert!(d);
    }

    #[test]
    fn format_speed_shows_mb_per_sec() {
        let s = format_speed(10_485_760, 2.0);
        assert_eq!(s, ", 5.0 MB/s");
    }

    #[test]
    fn format_speed_zero_bytes_returns_empty() {
        let s = format_speed(0, 2.0);
        assert_eq!(s, "");
    }

    #[test]
    fn format_speed_zero_elapsed_returns_empty() {
        let s = format_speed(1024, 0.0);
        assert_eq!(s, "");
    }

    #[test]
    fn format_speed_small_values() {
        let s = format_speed(1_048_576, 10.0);
        assert_eq!(s, ", 0.1 MB/s");
    }
}