sasurahime 0.1.6

macOS developer cache cleaner — scan and wipe stale caches from 40+ tools
use crate::cleaner::{CleanResult, Cleaner, ScanResult, ScanStatus};
use crate::command::CommandRunner;
use crate::format::dir_size;
use anyhow::Result;
use std::path::{Path, PathBuf};

pub struct OllamaCleaner {
    models_dir: PathBuf,
    runner: Box<dyn CommandRunner>,
}

#[derive(Debug, Clone)]
pub struct OllamaModel {
    pub name: String,
    pub size: u64,
}

impl OllamaCleaner {
    pub fn new(home: &Path, runner: Box<dyn CommandRunner>) -> Self {
        Self {
            models_dir: home.join(".ollama/models"),
            runner,
        }
    }

    pub fn list_models(&self) -> Result<Vec<OllamaModel>> {
        if !self.runner.exists("ollama") {
            return Ok(vec![]);
        }
        let output = self.runner.run("ollama", &["list"])?;
        let stdout = String::from_utf8_lossy(&output.stdout);
        let mut models = Vec::new();
        for line in stdout.lines().skip(1) {
            let parts: Vec<&str> = line.split_whitespace().collect();
            if parts.len() >= 3 {
                let name = parts[0].to_string();
                let size = parse_model_size(parts.get(2).unwrap_or(&"0B"));
                models.push(OllamaModel { name, size });
            }
        }
        Ok(models)
    }

    fn total_size(&self) -> u64 {
        if let Ok(models) = self.list_models() {
            let cli_total: u64 = models.iter().map(|m| m.size).sum();
            if cli_total > 0 { return cli_total; }
        }
        if self.models_dir.exists() { dir_size(&self.models_dir) } else { 0 }
    }
}

fn parse_model_size(s: &str) -> u64 {
    let s = s.trim();
    if let Some(n) = s.strip_suffix("GB") {
        let v: f64 = n.trim().parse().unwrap_or(0.0);
        (v * 1_073_741_824.0) as u64
    } else if let Some(n) = s.strip_suffix("MB") {
        let v: f64 = n.trim().parse().unwrap_or(0.0);
        (v * 1_048_576.0) as u64
    } else if let Some(n) = s.strip_suffix("KB") {
        let v: f64 = n.trim().parse().unwrap_or(0.0);
        (v * 1_024.0) as u64
    } else {
        0
    }
}

impl Cleaner for OllamaCleaner {
    fn name(&self) -> &'static str {
        "ollama"
    }

    fn detect(&self) -> ScanResult {
        let bytes = self.total_size();
        if bytes == 0 {
            return ScanResult { name: self.name(), status: ScanStatus::NotFound };
        }
        ScanResult { name: self.name(), status: ScanStatus::Pruneable(bytes) }
    }

    fn clean(&self, dry_run: bool) -> Result<CleanResult> {
        if self.runner.exists("ollama") {
            let models = self.list_models()?;
            if models.is_empty() {
                if self.models_dir.exists() && dir_size(&self.models_dir) > 0 {
                    return self.clean_fallback(dry_run);
                }
                println!("[ollama] no models found");
                return Ok(CleanResult { name: self.name(), bytes_freed: 0 });
            }

            if dry_run {
                println!("[ollama] dry-run: {} models", models.len());
                for m in &models {
                    println!("  would remove: {} ({})", m.name, crate::format::format_bytes(m.size));
                }
                return Ok(CleanResult { name: self.name(), bytes_freed: 0 });
            }

            let items: Vec<String> = models.iter().map(|m| {
                format!("{:<24}  {}", m.name, crate::format::format_bytes(m.size))
            }).collect();
            let defaults: Vec<bool> = vec![true; models.len()];

            println!("\nOllama models in ~/.ollama/models/:\n");
            let selections = dialoguer::MultiSelect::new()
                .items(&items)
                .defaults(&defaults)
                .interact()?;

            if selections.is_empty() {
                println!("[ollama] nothing selected");
                return Ok(CleanResult { name: self.name(), bytes_freed: 0 });
            }

            let mut total: u64 = 0;
            for &i in &selections {
                let m = &models[i];
                self.runner.run("ollama", &["rm", &m.name])?;
                total += m.size;
                println!("[ollama] removed: {} (freed {})", m.name, crate::format::format_bytes(m.size));
            }
            return Ok(CleanResult { name: self.name(), bytes_freed: total });
        }

        self.clean_fallback(dry_run)
    }
}

impl OllamaCleaner {
    fn clean_fallback(&self, dry_run: bool) -> Result<CleanResult> {
        let dir = &self.models_dir;
        if !dir.exists() {
            return Ok(CleanResult { name: self.name(), bytes_freed: 0 });
        }
        let size = dir_size(dir);
        if dry_run {
            println!("[ollama] would remove: {} ({})", dir.display(), crate::format::format_bytes(size));
            return Ok(CleanResult { name: self.name(), bytes_freed: 0 });
        }
        let path_str = dir.to_string_lossy();
        let _ = self.runner.run("chflags", &["-R", "nouchg", &path_str]);
        crate::trash::delete_path(dir)?;
        println!("[ollama] removed: {}", dir.display());
        Ok(CleanResult { name: self.name(), bytes_freed: size })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::command::CommandRunner;
    use std::os::unix::process::ExitStatusExt;

    struct MockOllamaRunner {
        list_output: String,
    }
    impl CommandRunner for MockOllamaRunner {
        fn run(&self, program: &str, args: &[&str]) -> Result<std::process::Output> {
            assert_eq!(program, "ollama");
            if args == ["list"] {
                Ok(std::process::Output {
                    status: std::process::ExitStatus::from_raw(0),
                    stdout: self.list_output.as_bytes().to_vec(),
                    stderr: vec![],
                })
            } else if args.first() == Some(&"rm") {
                Ok(std::process::Output {
                    status: std::process::ExitStatus::from_raw(0),
                    stdout: vec![],
                    stderr: vec![],
                })
            } else {
                panic!("unexpected args: {args:?}");
            }
        }
        fn exists(&self, program: &str) -> bool { program == "ollama" }
    }

    #[test]
    fn list_models_parses_ollama_output() {
        let output = "NAME\tID\tSIZE\tMODIFIED\nllama3.2:3b\tabc123\t2.0GB\t2 days ago\n";
        let runner = MockOllamaRunner { list_output: output.to_string() };
        let tmp = tempfile::TempDir::new().unwrap();
        let cleaner = OllamaCleaner::new(tmp.path(), Box::new(runner));
        let models = cleaner.list_models().unwrap();
        assert_eq!(models.len(), 1);
        assert_eq!(models[0].name, "llama3.2:3b");
        assert_eq!(models[0].size, (2.0_f64 * 1_073_741_824.0) as u64);
    }

    #[test]
    fn parse_model_size_gb() {
        assert_eq!(parse_model_size("4.7GB"), (4.7_f64 * 1_073_741_824.0) as u64);
    }

    #[test]
    fn parse_model_size_mb() {
        assert_eq!(parse_model_size("234MB"), (234.0_f64 * 1_048_576.0) as u64);
    }
}