Skip to main content

sqlite_graphrag/commands/
cache.rs

1//! Handler for the `cache` CLI subcommand and its nested operations.
2//!
3//! Manages cached resources such as the multilingual-e5-small ONNX model
4//! downloaded into the XDG cache directory on first `init`. Used to reclaim
5//! disk space or recover from corrupted cache state.
6
7use crate::errors::AppError;
8use crate::output;
9use crate::paths::AppPaths;
10use serde::Serialize;
11
12#[derive(clap::Args)]
13#[command(after_long_help = "EXAMPLES:\n  \
14    # Remove cached embedding/NER model files (forces re-download on next init)\n  \
15    sqlite-graphrag cache clear-models\n\n  \
16    # Skip the confirmation prompt\n  \
17    sqlite-graphrag cache clear-models --yes\n\n  \
18    # List cached model files\n  \
19    sqlite-graphrag cache list\n\n  \
20    # List cached model files as JSON\n  \
21    sqlite-graphrag cache list --json")]
22pub struct CacheArgs {
23    #[command(subcommand)]
24    pub command: CacheCommands,
25}
26
27#[derive(clap::Subcommand)]
28pub enum CacheCommands {
29    /// Remove cached embedding/NER model files (forces re-download on next `init`).
30    ClearModels(ClearModelsArgs),
31    /// List cached embedding/NER model files with sizes and total disk usage.
32    List(CacheListArgs),
33    /// Alias of `list` (GAP-E2E-09 — agents often call `cache stats`).
34    Stats(CacheListArgs),
35}
36
37#[derive(clap::Args)]
38pub struct CacheListArgs {
39    /// Output as JSON.
40    #[arg(long)]
41    pub json: bool,
42}
43
44#[derive(clap::Args)]
45pub struct ClearModelsArgs {
46    /// Skip confirmation prompt and proceed with deletion immediately.
47    #[arg(long, default_value_t = false, help = "Skip confirmation prompt")]
48    pub yes: bool,
49    /// Output format: json (default), text, or markdown.
50    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
51    pub json: bool,
52}
53
54#[derive(Serialize)]
55struct ClearModelsResponse {
56    cache_path: String,
57    existed: bool,
58    bytes_freed: u64,
59    files_removed: usize,
60    /// Total execution time in milliseconds from handler start to serialisation.
61    elapsed_ms: u64,
62}
63
64pub fn run(args: CacheArgs) -> Result<(), AppError> {
65    match args.command {
66        CacheCommands::ClearModels(a) => clear_models(a),
67        CacheCommands::List(a) | CacheCommands::Stats(a) => run_list(a),
68    }
69}
70
71fn clear_models(args: ClearModelsArgs) -> Result<(), AppError> {
72    let inicio = std::time::Instant::now();
73    // Resolve the canonical models directory through AppPaths (XDG cache).
74    let paths = AppPaths::resolve(None)?;
75    let models_dir = paths.models.clone();
76
77    if !args.yes {
78        // For machine consumption stay deterministic: refuse without --yes.
79        return Err(AppError::Validation(
80            "destructive operation: pass --yes to confirm cache deletion".to_string(),
81        ));
82    }
83
84    let existed = models_dir.exists();
85    let mut bytes_freed: u64 = 0;
86    let mut files_removed: usize = 0;
87
88    if existed {
89        bytes_freed = dir_size(&models_dir).unwrap_or(0);
90        files_removed = count_files(&models_dir).unwrap_or(0);
91        std::fs::remove_dir_all(&models_dir)?;
92    }
93
94    output::emit_json(&ClearModelsResponse {
95        cache_path: models_dir.display().to_string(),
96        existed,
97        bytes_freed,
98        files_removed,
99        elapsed_ms: inicio.elapsed().as_millis() as u64,
100    })?;
101
102    Ok(())
103}
104
105#[derive(Serialize)]
106struct CacheFileEntry {
107    name: String,
108    path: String,
109    size_bytes: u64,
110    modified_at: String,
111}
112
113#[derive(Serialize)]
114struct CacheListResponse {
115    schema_version: u32,
116    cache_path: String,
117    files: Vec<CacheFileEntry>,
118    total_bytes: u64,
119    total_human: String,
120}
121
122fn format_bytes_human(bytes: u64) -> String {
123    const KB: u64 = 1024;
124    const MB: u64 = KB * 1024;
125    const GB: u64 = MB * 1024;
126    if bytes >= GB {
127        format!("{:.1} GB", bytes as f64 / GB as f64)
128    } else if bytes >= MB {
129        format!("{:.1} MB", bytes as f64 / MB as f64)
130    } else if bytes >= KB {
131        format!("{:.1} KB", bytes as f64 / KB as f64)
132    } else {
133        format!("{bytes} B")
134    }
135}
136
137fn collect_cache_files(
138    dir: &std::path::Path,
139    base: &std::path::Path,
140    entries: &mut Vec<CacheFileEntry>,
141) -> std::io::Result<()> {
142    for entry in std::fs::read_dir(dir)? {
143        let entry = entry?;
144        let meta = entry.metadata()?;
145        let path = entry.path();
146        if meta.is_dir() {
147            collect_cache_files(&path, base, entries)?;
148        } else {
149            let size_bytes = meta.len();
150            let relative = path.strip_prefix(base).unwrap_or(&path);
151            let name = relative.to_string_lossy().into_owned();
152            let modified_at = meta
153                .modified()
154                .ok()
155                .map(|t| {
156                    let secs = t
157                        .duration_since(std::time::UNIX_EPOCH)
158                        .unwrap_or_default()
159                        .as_secs();
160                    // Format as RFC 3339 (UTC) without chrono dependency.
161                    let secs_i64 = secs as i64;
162                    let (y, mo, d, h, mi, s) = epoch_to_ymd_hms(secs_i64);
163                    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
164                })
165                .unwrap_or_else(|| "unknown".to_string());
166            entries.push(CacheFileEntry {
167                name,
168                path: path.display().to_string(),
169                size_bytes,
170                modified_at,
171            });
172        }
173    }
174    Ok(())
175}
176
177/// Converts Unix epoch seconds to (year, month, day, hour, minute, second) UTC.
178fn epoch_to_ymd_hms(secs: i64) -> (i32, u8, u8, u8, u8, u8) {
179    let s = (secs % 60) as u8;
180    let total_min = secs / 60;
181    let mi = (total_min % 60) as u8;
182    let total_h = total_min / 60;
183    let h = (total_h % 24) as u8;
184    let mut days = total_h / 24;
185    // Compute year/month/day from days since epoch (1970-01-01).
186    let mut y = 1970i32;
187    loop {
188        let days_in_y = if is_leap(y) { 366 } else { 365 };
189        if days < days_in_y {
190            break;
191        }
192        days -= days_in_y;
193        y += 1;
194    }
195    let leap = is_leap(y);
196    let months = [
197        31u8,
198        if leap { 29 } else { 28 },
199        31,
200        30,
201        31,
202        30,
203        31,
204        31,
205        30,
206        31,
207        30,
208        31,
209    ];
210    let mut mo = 1u8;
211    for &days_in_m in &months {
212        if days < days_in_m as i64 {
213            break;
214        }
215        days -= days_in_m as i64;
216        mo += 1;
217    }
218    let d = (days + 1) as u8;
219    (y, mo, d, h, mi, s)
220}
221
222fn is_leap(y: i32) -> bool {
223    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
224}
225
226fn run_list(args: CacheListArgs) -> Result<(), AppError> {
227    let paths = AppPaths::resolve(None)?;
228    let models_dir = &paths.models;
229
230    let mut entries: Vec<CacheFileEntry> = Vec::with_capacity(4);
231    if models_dir.exists() {
232        collect_cache_files(models_dir, models_dir, &mut entries).map_err(AppError::Io)?;
233    }
234
235    entries.sort_unstable_by(|a, b| a.name.cmp(&b.name));
236    let total_bytes: u64 = entries.iter().map(|e| e.size_bytes).sum();
237    let total_human = format_bytes_human(total_bytes);
238    let n_files = entries.len();
239
240    if args.json {
241        output::emit_json(&CacheListResponse {
242            schema_version: 1,
243            cache_path: models_dir.display().to_string(),
244            files: entries,
245            total_bytes,
246            total_human,
247        })?;
248    } else if entries.is_empty() {
249        output::emit_text("(empty)");
250    } else {
251        for e in &entries {
252            output::emit_text(&format!(
253                "{:<40} {:>10}  {}",
254                e.name,
255                format_bytes_human(e.size_bytes),
256                e.modified_at
257            ));
258        }
259        output::emit_text(&format!("\nTOTAL: {n_files} files, {total_human}"));
260    }
261
262    Ok(())
263}
264
265fn dir_size(path: &std::path::Path) -> std::io::Result<u64> {
266    let mut total = 0u64;
267    for entry in std::fs::read_dir(path)? {
268        let entry = entry?;
269        let meta = entry.metadata()?;
270        if meta.is_dir() {
271            total = total.saturating_add(dir_size(&entry.path()).unwrap_or(0));
272        } else {
273            total = total.saturating_add(meta.len());
274        }
275    }
276    Ok(total)
277}
278
279fn count_files(path: &std::path::Path) -> std::io::Result<usize> {
280    let mut count = 0usize;
281    for entry in std::fs::read_dir(path)? {
282        let entry = entry?;
283        let meta = entry.metadata()?;
284        if meta.is_dir() {
285            count = count.saturating_add(count_files(&entry.path()).unwrap_or(0));
286        } else {
287            count += 1;
288        }
289    }
290    Ok(count)
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn clear_models_response_serializes_all_fields() {
299        let resp = ClearModelsResponse {
300            cache_path: "/tmp/sqlite-graphrag/models".to_string(),
301            existed: true,
302            bytes_freed: 465_000_000,
303            files_removed: 14,
304            elapsed_ms: 12,
305        };
306        let json = serde_json::to_value(&resp).expect("serialization");
307        assert_eq!(json["existed"], true);
308        assert_eq!(json["bytes_freed"], 465_000_000u64);
309        assert_eq!(json["files_removed"], 14);
310        assert_eq!(json["elapsed_ms"], 12);
311    }
312
313    #[test]
314    fn clear_models_without_yes_returns_validation_error() {
315        let args = ClearModelsArgs {
316            yes: false,
317            json: false,
318        };
319        let result = clear_models(args);
320        assert!(matches!(result, Err(AppError::Validation(_))));
321    }
322}