sasurahime 0.1.10

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 crate::progress::ProgressReporter;
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, reporter: &dyn ProgressReporter) -> 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;
            if !selections.is_empty() {
                reporter.progress_init(self.name(), selections.len());
            }
            for (j, &i) in selections.iter().enumerate() {
                let m = &models[i];
                reporter.progress_tick(Path::new(&m.name), j + 1, m.size);
                self.runner.run("ollama", &["rm", &m.name])?;
                total += m.size;
                println!(
                    "[ollama] removed: {} (freed {})",
                    m.name,
                    crate::format::format_bytes(m.size)
                );
            }
            if !selections.is_empty() {
                reporter.progress_finish();
            }
            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);
    }
}