sqlite_graphrag/commands/
cache.rs1use 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 ClearModels(ClearModelsArgs),
31 List(CacheListArgs),
33 Stats(CacheListArgs),
35}
36
37#[derive(clap::Args)]
38pub struct CacheListArgs {
39 #[arg(long)]
41 pub json: bool,
42}
43
44#[derive(clap::Args)]
45pub struct ClearModelsArgs {
46 #[arg(long, default_value_t = false, help = "Skip confirmation prompt")]
48 pub yes: bool,
49 #[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 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 let paths = AppPaths::resolve(None)?;
75 let models_dir = paths.models.clone();
76
77 if !args.yes {
78 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 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
177fn 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 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}