maclean 1.0.0

Find and reclaim disk space on macOS
use std::path::PathBuf;

use crate::core::{
    IssueKind, Item, Module, ModuleInfo, ModuleScan, ReclaimContext, ReclaimError, ReclaimResult,
    Relevance, Safety, ScanContext, ScheduleTarget, delete_contents, dir_size_in, format_bytes,
};

pub struct SpotifyModule;

impl SpotifyModule {
    fn cache_dir(ctx: &ScanContext) -> PathBuf {
        ctx.path("spotify", "cache", "Library/Caches/com.spotify.client")
    }

    fn app_dir(ctx: &ScanContext) -> PathBuf {
        ctx.path("spotify", "app", "Library/Application Support/Spotify")
    }
}

impl Module for SpotifyModule {
    fn id(&self) -> &'static str {
        "spotify"
    }
    fn name(&self) -> &'static str {
        "Spotify"
    }
    fn description(&self) -> &'static str {
        "Offline/stream cache used by the Spotify desktop app"
    }

    fn schedule_targets(&self) -> Vec<ScheduleTarget> {
        vec![ScheduleTarget::new(
            "spotify:cache",
            "Spotify cache",
            "Offline and stream cache for the desktop app",
        )]
    }

    fn paths(&self) -> Vec<(&'static str, &'static str)> {
        vec![
            ("cache", "Library/Caches/com.spotify.client"),
            ("app", "Library/Application Support/Spotify"),
        ]
    }

    fn info(&self, ctx: &ScanContext) -> ModuleInfo {
        ModuleInfo::new(self.id(), self.name(), self.description())
            .finds("Cached audio Spotify downloaded while streaming")
            .finds("Browser/renderer caches from Spotify's embedded Chromium")
            .effect("Playlists, logins, and downloads marked for offline are not stored here")
            .effect("Spotify re-downloads audio as you play it, so the cache grows back")
            .location(Self::cache_dir(ctx))
            .location(Self::app_dir(ctx))
    }

    fn relevance(&self, ctx: &ScanContext) -> Relevance {
        let path = Self::cache_dir(ctx);
        if path.is_dir() {
            Relevance::yes(format!("cache directory exists at {}", path.display()))
        } else {
            Relevance::no("no Spotify cache directory (app may never have run here)")
        }
    }

    fn scan(&self, ctx: &ScanContext, relevance: Relevance) -> ModuleScan {
        let mut scan = ModuleScan::new(self.id(), self.name(), relevance);
        let path = Self::cache_dir(ctx);
        let mut children = Vec::new();

        if let Ok(entries) = std::fs::read_dir(&path) {
            for entry in entries.flatten() {
                if ctx.cancelled() {
                    return scan;
                }
                let child = entry.path();
                let report = dir_size_in(&child, ctx);
                scan.issues.extend(report.issues);
                if report.bytes == 0 {
                    continue;
                }
                let name = entry.file_name().to_string_lossy().into_owned();
                children.push(
                    Item::new(self.id(), format!("spotify:cache:{name}"), name)
                        .with_summary(child.display().to_string())
                        .with_bytes(report.bytes)
                        .with_path(child)
                        .with_safety(Safety::Safe)
                        .with_reclaimable(true)
                        .with_detail("Kind", "Spotify cache subfolder"),
                );
            }
        }

        let total: u64 = children.iter().map(|c| c.bytes).sum();
        if total == 0 {
            return scan;
        }

        scan.items.push(
            Item::new(self.id(), "spotify:cache", "Spotify cache")
                .with_summary(format!(
                    "Streamed audio and app cache in {}",
                    path.display()
                ))
                .with_bytes(total)
                .with_path(path.clone())
                .with_safety(Safety::Safe)
                .with_reclaimable(true)
                .clean_whole()
                .with_detail("Path", path.display().to_string())
                .with_detail("Rebuilds", "Yes, as you keep listening")
                .with_note("Deletes the contents of the cache folder, not the folder itself.")
                .with_note(
                    "Your account, playlists, and settings live elsewhere and are untouched.",
                )
                .with_children(children)
                .prune_children(10 * 1000 * 1000, 12),
        );
        scan
    }

    fn reclaim(&self, item: &Item, ctx: &ReclaimContext) -> Result<ReclaimResult, ReclaimError> {
        let Some(path) = item.paths.first() else {
            return Err(ReclaimError::new(&item.id, IssueKind::Warning, "no path"));
        };
        let bytes = delete_contents(item, path, ctx)?;
        Ok(ReclaimResult::ok(
            &item.id,
            bytes,
            format!("cleared {} ({})", item.title, format_bytes(bytes)),
            ctx.dry_run,
        ))
    }
}