Skip to main content

null_e/caches/
mod.rs

1//! Global developer cache detection and cleaning
2//!
3//! This module handles system-wide package manager caches like ~/.npm, ~/.cargo/registry, etc.
4//! These are separate from project-specific artifacts (node_modules, target).
5
6use crate::error::{DevSweepError, Result};
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9use std::time::SystemTime;
10
11/// A global developer cache location
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct GlobalCache {
14    /// Human-readable name
15    pub name: String,
16    /// Short identifier
17    pub id: &'static str,
18    /// Icon/emoji for display
19    pub icon: &'static str,
20    /// Full path to the cache directory
21    pub path: PathBuf,
22    /// Size in bytes
23    pub size: u64,
24    /// Number of files
25    pub file_count: u64,
26    /// Last modification time
27    pub last_modified: Option<SystemTime>,
28    /// Official clean command (if available)
29    pub clean_command: Option<&'static str>,
30    /// Description of what this cache contains
31    pub description: &'static str,
32}
33
34impl GlobalCache {
35    /// Check if this cache exists
36    pub fn exists(&self) -> bool {
37        self.path.exists() && self.path.is_dir()
38    }
39
40    /// Get age in days since last modification
41    pub fn age_days(&self) -> Option<u64> {
42        self.last_modified
43            .and_then(|t| t.elapsed().ok())
44            .map(|d| d.as_secs() / 86400)
45    }
46
47    /// Format the last used time
48    pub fn last_used_display(&self) -> String {
49        match self.age_days() {
50            Some(0) => "today".to_string(),
51            Some(1) => "yesterday".to_string(),
52            Some(d) if d < 7 => format!("{} days ago", d),
53            Some(d) if d < 30 => format!("{} weeks ago", d / 7),
54            Some(d) if d < 365 => format!("{} months ago", d / 30),
55            Some(d) => format!("{} years ago", d / 365),
56            None => "unknown".to_string(),
57        }
58    }
59}
60
61/// Definition of a known cache location
62#[derive(Debug, Clone)]
63pub struct CacheDefinition {
64    pub id: &'static str,
65    pub name: &'static str,
66    pub icon: &'static str,
67    /// Paths relative to home directory
68    pub paths: &'static [&'static str],
69    /// Official clean command
70    pub clean_command: Option<&'static str>,
71    pub description: &'static str,
72}
73
74/// All known global cache locations
75pub fn known_caches() -> Vec<CacheDefinition> {
76    vec![
77        // ═══════════════════════════════════════════════════════════════
78        // JavaScript/Node.js Ecosystem
79        // ═══════════════════════════════════════════════════════════════
80        CacheDefinition {
81            id: "npm",
82            name: "npm cache",
83            icon: "📦",
84            paths: &[".npm/_cacache"],
85            clean_command: Some("npm cache clean --force"),
86            description: "Cached npm packages and metadata",
87        },
88        CacheDefinition {
89            id: "yarn",
90            name: "Yarn cache",
91            icon: "🧶",
92            paths: &[".yarn/cache", ".cache/yarn"],
93            clean_command: Some("yarn cache clean"),
94            description: "Cached Yarn packages",
95        },
96        CacheDefinition {
97            id: "pnpm",
98            name: "pnpm store",
99            icon: "📦",
100            paths: &[".pnpm-store", ".local/share/pnpm/store"],
101            clean_command: Some("pnpm store prune"),
102            description: "Global pnpm content-addressable store",
103        },
104        CacheDefinition {
105            id: "bun",
106            name: "Bun cache",
107            icon: "🥟",
108            paths: &[".bun/install/cache"],
109            clean_command: None,
110            description: "Cached Bun packages",
111        },
112        CacheDefinition {
113            id: "deno",
114            name: "Deno cache",
115            icon: "🦕",
116            paths: &[".cache/deno", ".deno"],
117            clean_command: Some("deno cache --reload"),
118            description: "Cached Deno modules and compiled scripts",
119        },
120
121        // ═══════════════════════════════════════════════════════════════
122        // Python Ecosystem
123        // ═══════════════════════════════════════════════════════════════
124        CacheDefinition {
125            id: "pip",
126            name: "pip cache",
127            icon: "🐍",
128            paths: &[".cache/pip", "Library/Caches/pip"],
129            clean_command: Some("pip cache purge"),
130            description: "Cached pip wheels and HTTP responses",
131        },
132        CacheDefinition {
133            id: "uv",
134            name: "uv cache",
135            icon: "⚡",
136            paths: &[".cache/uv"],
137            clean_command: Some("uv cache clean"),
138            description: "Cached uv packages (fast Python installer)",
139        },
140        CacheDefinition {
141            id: "poetry",
142            name: "Poetry cache",
143            icon: "📜",
144            paths: &[".cache/pypoetry", "Library/Caches/pypoetry"],
145            clean_command: Some("poetry cache clear --all ."),
146            description: "Cached Poetry packages and virtualenvs",
147        },
148        CacheDefinition {
149            id: "pipenv",
150            name: "Pipenv cache",
151            icon: "🐍",
152            paths: &[".cache/pipenv"],
153            clean_command: None,
154            description: "Cached Pipenv packages",
155        },
156        CacheDefinition {
157            id: "conda",
158            name: "Conda cache",
159            icon: "🐍",
160            paths: &[".conda/pkgs", "anaconda3/pkgs", "miniconda3/pkgs"],
161            clean_command: Some("conda clean --all"),
162            description: "Cached Conda packages",
163        },
164
165        // ═══════════════════════════════════════════════════════════════
166        // Rust Ecosystem
167        // ═══════════════════════════════════════════════════════════════
168        CacheDefinition {
169            id: "cargo-registry",
170            name: "Cargo registry",
171            icon: "🦀",
172            paths: &[".cargo/registry"],
173            clean_command: None, // Cargo 1.75+ has auto GC
174            description: "Downloaded crate sources and indices",
175        },
176        CacheDefinition {
177            id: "cargo-git",
178            name: "Cargo git",
179            icon: "🦀",
180            paths: &[".cargo/git"],
181            clean_command: None,
182            description: "Git dependencies cache",
183        },
184
185        // ═══════════════════════════════════════════════════════════════
186        // Go Ecosystem
187        // ═══════════════════════════════════════════════════════════════
188        CacheDefinition {
189            id: "go-mod",
190            name: "Go modules",
191            icon: "🐹",
192            paths: &["go/pkg/mod"],
193            clean_command: Some("go clean -modcache"),
194            description: "Downloaded Go module cache",
195        },
196        CacheDefinition {
197            id: "go-build",
198            name: "Go build cache",
199            icon: "🐹",
200            paths: &[".cache/go-build", "Library/Caches/go-build"],
201            clean_command: Some("go clean -cache"),
202            description: "Go build artifacts cache",
203        },
204
205        // ═══════════════════════════════════════════════════════════════
206        // JVM Ecosystem
207        // ═══════════════════════════════════════════════════════════════
208        CacheDefinition {
209            id: "gradle",
210            name: "Gradle cache",
211            icon: "🐘",
212            paths: &[".gradle/caches"],
213            clean_command: None, // Manual or gradle --stop && rm
214            description: "Gradle dependencies and build cache",
215        },
216        CacheDefinition {
217            id: "maven",
218            name: "Maven repository",
219            icon: "🪶",
220            paths: &[".m2/repository"],
221            clean_command: None,
222            description: "Maven local repository",
223        },
224        CacheDefinition {
225            id: "sbt",
226            name: "SBT cache",
227            icon: "📦",
228            paths: &[".sbt", ".ivy2/cache"],
229            clean_command: None,
230            description: "SBT and Ivy dependency cache",
231        },
232
233        // ═══════════════════════════════════════════════════════════════
234        // .NET Ecosystem
235        // ═══════════════════════════════════════════════════════════════
236        CacheDefinition {
237            id: "nuget",
238            name: "NuGet cache",
239            icon: "🔷",
240            paths: &[".nuget/packages"],
241            clean_command: Some("dotnet nuget locals all --clear"),
242            description: "NuGet package cache",
243        },
244
245        // ═══════════════════════════════════════════════════════════════
246        // Ruby Ecosystem
247        // ═══════════════════════════════════════════════════════════════
248        CacheDefinition {
249            id: "gem",
250            name: "Ruby gems",
251            icon: "💎",
252            paths: &[".gem", ".local/share/gem"],
253            clean_command: Some("gem cleanup"),
254            description: "Installed Ruby gems",
255        },
256        CacheDefinition {
257            id: "bundler",
258            name: "Bundler cache",
259            icon: "💎",
260            paths: &[".bundle/cache"],
261            clean_command: Some("bundle clean --force"),
262            description: "Bundler gem cache",
263        },
264
265        // ═══════════════════════════════════════════════════════════════
266        // PHP Ecosystem
267        // ═══════════════════════════════════════════════════════════════
268        CacheDefinition {
269            id: "composer",
270            name: "Composer cache",
271            icon: "🎼",
272            paths: &[".composer/cache", ".cache/composer"],
273            clean_command: Some("composer clear-cache"),
274            description: "Composer package cache",
275        },
276
277        // ═══════════════════════════════════════════════════════════════
278        // Mobile Development
279        // ═══════════════════════════════════════════════════════════════
280        CacheDefinition {
281            id: "cocoapods",
282            name: "CocoaPods cache",
283            icon: "🍫",
284            paths: &["Library/Caches/CocoaPods"],
285            clean_command: Some("pod cache clean --all"),
286            description: "CocoaPods specs and pod cache",
287        },
288        CacheDefinition {
289            id: "pub",
290            name: "Dart/Flutter pub",
291            icon: "🎯",
292            paths: &[".pub-cache"],
293            clean_command: None,
294            description: "Dart and Flutter package cache",
295        },
296        CacheDefinition {
297            id: "android-gradle",
298            name: "Android Gradle",
299            icon: "🤖",
300            paths: &[".android/cache", ".android/build-cache"],
301            clean_command: None,
302            description: "Android build cache",
303        },
304
305        // ═══════════════════════════════════════════════════════════════
306        // ML/AI Ecosystem
307        // ═══════════════════════════════════════════════════════════════
308        CacheDefinition {
309            id: "huggingface",
310            name: "Hugging Face cache",
311            icon: "🤗",
312            paths: &[".cache/huggingface"],
313            clean_command: None,
314            description: "Downloaded ML models and datasets",
315        },
316        CacheDefinition {
317            id: "torch",
318            name: "PyTorch cache",
319            icon: "🔥",
320            paths: &[".cache/torch"],
321            clean_command: None,
322            description: "PyTorch model hub cache",
323        },
324
325        // ═══════════════════════════════════════════════════════════════
326        // Other Tools
327        // ═══════════════════════════════════════════════════════════════
328        CacheDefinition {
329            id: "homebrew",
330            name: "Homebrew cache",
331            icon: "🍺",
332            paths: &["Library/Caches/Homebrew"],
333            clean_command: Some("brew cleanup --prune=all"),
334            description: "Downloaded Homebrew bottles and source",
335        },
336        CacheDefinition {
337            id: "cypress",
338            name: "Cypress cache",
339            icon: "🌲",
340            paths: &[".cache/Cypress", "Library/Caches/Cypress"],
341            clean_command: Some("cypress cache clear"),
342            description: "Cypress browser binaries",
343        },
344        CacheDefinition {
345            id: "playwright",
346            name: "Playwright cache",
347            icon: "🎭",
348            paths: &[".cache/ms-playwright", "Library/Caches/ms-playwright"],
349            clean_command: None,
350            description: "Playwright browser binaries",
351        },
352        CacheDefinition {
353            id: "electron",
354            name: "Electron cache",
355            icon: "⚛️",
356            paths: &[".cache/electron", "Library/Caches/electron"],
357            clean_command: None,
358            description: "Electron framework binaries",
359        },
360    ]
361}
362
363/// Detect all existing global caches
364pub fn detect_caches() -> Result<Vec<GlobalCache>> {
365    let home = dirs::home_dir()
366        .ok_or_else(|| DevSweepError::Config("Could not find home directory".into()))?;
367
368    let definitions = known_caches();
369    let mut caches = Vec::new();
370
371    for def in definitions {
372        // Try each possible path for this cache
373        for rel_path in def.paths {
374            let full_path = home.join(rel_path);
375
376            if full_path.exists() && full_path.is_dir() {
377                // Found this cache!
378                let mut cache = GlobalCache {
379                    name: def.name.to_string(),
380                    id: def.id,
381                    icon: def.icon,
382                    path: full_path.clone(),
383                    size: 0,
384                    file_count: 0,
385                    last_modified: None,
386                    clean_command: def.clean_command,
387                    description: def.description,
388                };
389
390                // Get last modified time
391                if let Ok(meta) = std::fs::metadata(&full_path) {
392                    cache.last_modified = meta.modified().ok();
393                }
394
395                caches.push(cache);
396                break; // Found one path, don't check others
397            }
398        }
399    }
400
401    Ok(caches)
402}
403
404/// Calculate size for a single cache (can be slow for large caches)
405pub fn calculate_cache_size(cache: &mut GlobalCache) -> Result<()> {
406    use rayon::prelude::*;
407    use walkdir::WalkDir;
408
409    if !cache.path.exists() {
410        return Ok(());
411    }
412
413    let entries: Vec<_> = WalkDir::new(&cache.path)
414        .into_iter()
415        .filter_map(|e| e.ok())
416        .collect();
417
418    let (size, count): (u64, u64) = entries
419        .par_iter()
420        .filter_map(|entry| entry.metadata().ok())
421        .filter(|m| m.is_file())
422        .fold(
423            || (0u64, 0u64),
424            |(size, count), m| (size + m.len(), count + 1),
425        )
426        .reduce(|| (0, 0), |(s1, c1), (s2, c2)| (s1 + s2, c1 + c2));
427
428    cache.size = size;
429    cache.file_count = count;
430
431    Ok(())
432}
433
434/// Calculate sizes for all caches in parallel
435pub fn calculate_all_sizes(caches: &mut [GlobalCache]) -> Result<()> {
436    use rayon::prelude::*;
437
438    // Calculate sizes in parallel
439    caches.par_iter_mut().for_each(|cache| {
440        let _ = calculate_cache_size(cache);
441    });
442
443    Ok(())
444}
445
446/// Clean a cache using the official command if available, otherwise rm -rf
447pub fn clean_cache(cache: &GlobalCache, use_official_command: bool) -> Result<CleanResult> {
448    if !cache.path.exists() {
449        return Ok(CleanResult {
450            success: true,
451            bytes_freed: 0,
452            method: CleanMethod::NotFound,
453        });
454    }
455
456    let size_before = cache.size;
457
458    // Try official command first if requested
459    if use_official_command {
460        if let Some(cmd) = cache.clean_command {
461            let result = run_clean_command(cmd);
462            if result.is_ok() {
463                return Ok(CleanResult {
464                    success: true,
465                    bytes_freed: size_before,
466                    method: CleanMethod::OfficialCommand(cmd.to_string()),
467                });
468            }
469            // Fall through to manual deletion if command fails
470        }
471    }
472
473    // Manual deletion
474    match crate::trash::delete_path(&cache.path, crate::trash::DeleteMethod::Permanent) {
475        Ok(_) => Ok(CleanResult {
476            success: true,
477            bytes_freed: size_before,
478            method: CleanMethod::ManualDelete,
479        }),
480        Err(e) => Err(e),
481    }
482}
483
484/// Run an official clean command
485fn run_clean_command(cmd: &str) -> Result<()> {
486    use std::process::Command;
487
488    let parts: Vec<&str> = cmd.split_whitespace().collect();
489    if parts.is_empty() {
490        return Err(DevSweepError::Config("Empty clean command".into()));
491    }
492
493    let output = Command::new(parts[0])
494        .args(&parts[1..])
495        .output()
496        .map_err(|e| DevSweepError::Io(e))?;
497
498    if output.status.success() {
499        Ok(())
500    } else {
501        Err(DevSweepError::CleanFailed {
502            path: PathBuf::from(cmd),
503            reason: String::from_utf8_lossy(&output.stderr).to_string(),
504        })
505    }
506}
507
508/// Result of cleaning a cache
509#[derive(Debug)]
510pub struct CleanResult {
511    pub success: bool,
512    pub bytes_freed: u64,
513    pub method: CleanMethod,
514}
515
516/// How a cache was cleaned
517#[derive(Debug)]
518pub enum CleanMethod {
519    OfficialCommand(String),
520    ManualDelete,
521    NotFound,
522}
523
524/// Summary of all cache operations
525#[derive(Debug, Default)]
526pub struct CachesSummary {
527    pub total_caches: usize,
528    pub total_size: u64,
529    pub total_files: u64,
530    pub cleaned_count: usize,
531    pub bytes_freed: u64,
532}
533
534impl CachesSummary {
535    pub fn from_caches(caches: &[GlobalCache]) -> Self {
536        Self {
537            total_caches: caches.len(),
538            total_size: caches.iter().map(|c| c.size).sum(),
539            total_files: caches.iter().map(|c| c.file_count).sum(),
540            ..Default::default()
541        }
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[test]
550    fn test_known_caches_not_empty() {
551        let caches = known_caches();
552        assert!(!caches.is_empty());
553        assert!(caches.len() > 20); // We defined 25+ caches
554    }
555
556    #[test]
557    fn test_cache_age_display() {
558        let mut cache = GlobalCache {
559            name: "test".into(),
560            id: "test",
561            icon: "📦",
562            path: PathBuf::from("/tmp/test"),
563            size: 0,
564            file_count: 0,
565            last_modified: Some(SystemTime::now()),
566            clean_command: None,
567            description: "test",
568        };
569
570        assert_eq!(cache.last_used_display(), "today");
571    }
572
573    #[test]
574    fn test_detect_caches() {
575        // This will detect real caches on the system
576        let caches = detect_caches().unwrap();
577        // At minimum, most dev machines have at least one cache
578        // But don't assert > 0 as CI might not have any
579        println!("Found {} caches", caches.len());
580    }
581}