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