sqlite_graphrag/commands/
cache.rs1use 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")]
23pub struct CacheArgs {
25 #[command(subcommand)]
27 pub command: CacheCommands,
28}
29
30#[derive(clap::Subcommand)]
32pub enum CacheCommands {
33 ClearModels(ClearModelsArgs),
35 List(CacheListArgs),
37 Stats(CacheListArgs),
39}
40
41#[derive(clap::Args)]
43pub struct CacheListArgs {
44 #[arg(long)]
46 pub json: bool,
47 #[command(flatten)]
49 pub db_noop: DbNoopArgs,
50}
51
52#[derive(clap::Args)]
54pub struct ClearModelsArgs {
55 #[arg(long, default_value_t = false, help = "Skip confirmation prompt")]
57 pub yes: bool,
58 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
60 pub json: bool,
61 #[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 elapsed_ms: u64,
74}
75
76pub 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 let paths = AppPaths::resolve(None)?;
90 let models_dir = paths.models.clone();
91
92 if !args.yes {
93 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 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
192fn 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 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 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}