Skip to main content

mars_agents/cli/
cache.rs

1//! `mars cache` — manage the global source cache.
2
3use crate::error::MarsError;
4use crate::source::GlobalCache;
5
6use super::output;
7
8/// Arguments for `mars cache`.
9#[derive(Debug, clap::Args)]
10pub struct CacheArgs {
11    #[command(subcommand)]
12    pub command: CacheCommand,
13}
14
15#[derive(Debug, clap::Subcommand)]
16pub enum CacheCommand {
17    /// Remove all cached sources (archives + git clones).
18    Clean(CacheCleanArgs),
19
20    /// Show cache location and disk usage.
21    Info(CacheInfoArgs),
22}
23
24/// Arguments for `mars cache clean`.
25#[derive(Debug, clap::Args)]
26pub struct CacheCleanArgs {}
27
28/// Arguments for `mars cache info`.
29#[derive(Debug, clap::Args)]
30pub struct CacheInfoArgs {}
31
32/// Run `mars cache <subcommand>`.
33pub fn run(args: &CacheArgs, json: bool) -> Result<i32, MarsError> {
34    match &args.command {
35        CacheCommand::Clean(_) => run_clean(json),
36        CacheCommand::Info(_) => run_info(json),
37    }
38}
39
40fn run_clean(json: bool) -> Result<i32, MarsError> {
41    let cache = GlobalCache::new()?;
42
43    let archives = dir_size(&cache.archives_dir());
44    let git = dir_size(&cache.git_dir());
45
46    // Remove contents but keep the directory structure
47    remove_dir_contents(&cache.archives_dir())?;
48    remove_dir_contents(&cache.git_dir())?;
49
50    let total = archives + git;
51
52    if json {
53        let payload = serde_json::json!({
54            "freed_bytes": total,
55            "archives_bytes": archives,
56            "git_bytes": git,
57        });
58        println!("{}", payload);
59    } else {
60        output::print_info(&format!(
61            "cleaned {} (archives: {}, git: {})",
62            format_bytes(total),
63            format_bytes(archives),
64            format_bytes(git),
65        ));
66    }
67
68    Ok(0)
69}
70
71fn run_info(json: bool) -> Result<i32, MarsError> {
72    let cache = GlobalCache::new()?;
73
74    let archives = dir_size(&cache.archives_dir());
75    let git = dir_size(&cache.git_dir());
76    let total = archives + git;
77    let path = cache.root.display().to_string();
78
79    if json {
80        let payload = serde_json::json!({
81            "path": path,
82            "total_bytes": total,
83            "archives_bytes": archives,
84            "git_bytes": git,
85        });
86        println!("{}", payload);
87    } else {
88        println!("path:     {path}");
89        println!("total:    {}", format_bytes(total));
90        println!("archives: {}", format_bytes(archives));
91        println!("git:      {}", format_bytes(git));
92    }
93
94    Ok(0)
95}
96
97/// Calculate total size of all files in a directory tree.
98fn dir_size(path: &std::path::Path) -> u64 {
99    if !path.exists() {
100        return 0;
101    }
102    walkdir::WalkDir::new(path)
103        .into_iter()
104        .filter_map(|e| e.ok())
105        .filter(|e| e.file_type().is_file())
106        .filter_map(|e| e.metadata().ok())
107        .map(|m| m.len())
108        .sum()
109}
110
111/// Remove all contents of a directory without removing the directory itself.
112fn remove_dir_contents(path: &std::path::Path) -> Result<(), MarsError> {
113    if !path.exists() {
114        return Ok(());
115    }
116    for entry in std::fs::read_dir(path)? {
117        let entry = entry?;
118        let entry_path = entry.path();
119        if entry_path.is_dir() {
120            std::fs::remove_dir_all(&entry_path)?;
121        } else {
122            std::fs::remove_file(&entry_path)?;
123        }
124    }
125    Ok(())
126}
127
128/// Format bytes as human-readable string.
129fn format_bytes(bytes: u64) -> String {
130    if bytes < 1024 {
131        format!("{bytes} B")
132    } else if bytes < 1024 * 1024 {
133        format!("{:.1} KB", bytes as f64 / 1024.0)
134    } else if bytes < 1024 * 1024 * 1024 {
135        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
136    } else {
137        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn format_bytes_ranges() {
147        assert_eq!(format_bytes(0), "0 B");
148        assert_eq!(format_bytes(512), "512 B");
149        assert_eq!(format_bytes(1024), "1.0 KB");
150        assert_eq!(format_bytes(1536), "1.5 KB");
151        assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
152        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
153    }
154
155    #[test]
156    fn dir_size_empty() {
157        let dir = tempfile::TempDir::new().unwrap();
158        assert_eq!(dir_size(dir.path()), 0);
159    }
160
161    #[test]
162    fn dir_size_with_files() {
163        let dir = tempfile::TempDir::new().unwrap();
164        std::fs::write(dir.path().join("a.txt"), "hello").unwrap();
165        std::fs::write(dir.path().join("b.txt"), "world!").unwrap();
166        assert_eq!(dir_size(dir.path()), 11); // 5 + 6
167    }
168
169    #[test]
170    fn dir_size_nonexistent() {
171        assert_eq!(dir_size(std::path::Path::new("/nonexistent/path")), 0);
172    }
173
174    #[test]
175    fn remove_dir_contents_clears_files() {
176        let dir = tempfile::TempDir::new().unwrap();
177        std::fs::write(dir.path().join("a.txt"), "hello").unwrap();
178        std::fs::create_dir_all(dir.path().join("sub")).unwrap();
179        std::fs::write(dir.path().join("sub").join("b.txt"), "world").unwrap();
180
181        remove_dir_contents(dir.path()).unwrap();
182
183        assert!(dir.path().exists()); // directory itself survives
184        assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); // but empty
185    }
186
187    #[test]
188    fn remove_dir_contents_nonexistent_ok() {
189        assert!(remove_dir_contents(std::path::Path::new("/nonexistent")).is_ok());
190    }
191}