sqlite_graphrag/commands/
fts.rs1use crate::errors::AppError;
8use crate::output;
9use crate::paths::AppPaths;
10use crate::storage::connection::{open_ro, open_rw};
11use serde::Serialize;
12
13#[derive(clap::Args)]
15#[command(
16 about = "FTS5 full-text search index management",
17 after_long_help = "EXAMPLES:\n \
18 # Rebuild the full-text search index from memories table\n \
19 sqlite-graphrag fts rebuild\n\n \
20 # Check FTS5 index integrity\n \
21 sqlite-graphrag fts check --json\n\n \
22 # Show FTS5 index statistics\n \
23 sqlite-graphrag fts stats --json"
24)]
25pub struct FtsArgs {
26 #[command(subcommand)]
28 pub command: FtsSubcommand,
29}
30
31#[derive(clap::Subcommand)]
33pub enum FtsSubcommand {
34 #[command(after_long_help = "EXAMPLES:\n \
36 # Rebuild the full-text search index\n \
37 sqlite-graphrag fts rebuild\n\n \
38 # Rebuild with custom database path\n \
39 sqlite-graphrag fts rebuild --db /path/to/graphrag.sqlite")]
40 Rebuild(FtsRebuildArgs),
41 #[command(after_long_help = "EXAMPLES:\n \
43 # Check FTS5 index integrity\n \
44 sqlite-graphrag fts check\n\n \
45 # Check with custom database path\n \
46 sqlite-graphrag fts check --db /path/to/graphrag.sqlite")]
47 Check(FtsCheckArgs),
48 #[command(after_long_help = "EXAMPLES:\n \
50 # Show FTS5 index statistics\n \
51 sqlite-graphrag fts stats\n\n \
52 # Stats with custom database path\n \
53 sqlite-graphrag fts stats --db /path/to/graphrag.sqlite")]
54 Stats(FtsStatsArgs),
55}
56
57#[derive(clap::Args)]
59pub struct FtsRebuildArgs {
60 #[arg(long, hide = true)]
62 pub json: bool,
63 #[arg(long)]
65 pub db: Option<String>,
66}
67
68#[derive(clap::Args)]
70pub struct FtsCheckArgs {
71 #[arg(long, hide = true)]
73 pub json: bool,
74 #[arg(long)]
76 pub db: Option<String>,
77}
78
79#[derive(clap::Args)]
81pub struct FtsStatsArgs {
82 #[arg(long, hide = true)]
84 pub json: bool,
85 #[arg(long)]
87 pub db: Option<String>,
88}
89
90#[derive(Serialize)]
91struct FtsRebuildResponse {
92 action: String,
93 rows_indexed: i64,
94 elapsed_ms: u64,
95}
96
97#[derive(Serialize)]
98struct FtsCheckResponse {
99 action: String,
100 integrity_ok: bool,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 detail: Option<String>,
103 elapsed_ms: u64,
104}
105
106#[derive(Serialize)]
107struct FtsStatsResponse {
108 total_rows: i64,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 shadow_pages: Option<i64>,
111 fts_functional: bool,
112 elapsed_ms: u64,
113}
114
115pub fn run(args: FtsArgs) -> Result<(), AppError> {
120 match args.command {
121 FtsSubcommand::Rebuild(a) => run_rebuild(a),
122 FtsSubcommand::Check(a) => run_check(a),
123 FtsSubcommand::Stats(a) => run_stats(a),
124 }
125}
126
127fn run_rebuild(args: FtsRebuildArgs) -> Result<(), AppError> {
136 let start = std::time::Instant::now();
137 let paths = AppPaths::resolve(args.db.as_deref())?;
138 crate::storage::connection::ensure_db_ready(&paths)?;
139 let conn = open_rw(&paths.db)?;
140
141 let table_exists: bool = conn.query_row(
142 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='fts_memories'",
143 [],
144 |r| r.get::<_, i64>(0).map(|v| v > 0),
145 )?;
146 if !table_exists {
147 return Err(AppError::Validation(
148 "FTS5 table 'fts_memories' does not exist — run 'sqlite-graphrag init' first"
149 .to_string(),
150 ));
151 }
152
153 conn.execute_batch("INSERT INTO fts_memories(fts_memories) VALUES('rebuild');")?;
154
155 let rows: i64 = conn.query_row("SELECT COUNT(*) FROM fts_memories", [], |r| r.get(0))?;
156
157 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
158
159 output::emit_json(&FtsRebuildResponse {
160 action: "rebuilt".to_string(),
161 rows_indexed: rows,
162 elapsed_ms: start.elapsed().as_millis() as u64,
163 })?;
164
165 Ok(())
166}
167
168fn run_check(args: FtsCheckArgs) -> Result<(), AppError> {
182 let start = std::time::Instant::now();
183 let paths = AppPaths::resolve(args.db.as_deref())?;
184 crate::storage::connection::ensure_db_ready(&paths)?;
185 let conn = open_rw(&paths.db)?;
186
187 let integrity_ok = conn
188 .execute_batch("INSERT INTO fts_memories(fts_memories, rank) VALUES('integrity-check', 1);")
189 .is_ok();
190
191 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").ok();
192
193 output::emit_json(&FtsCheckResponse {
194 action: "checked".to_string(),
195 integrity_ok,
196 detail: if integrity_ok {
197 None
198 } else {
199 Some("FTS5 integrity-check failed — run 'sqlite-graphrag fts rebuild'".to_string())
200 },
201 elapsed_ms: start.elapsed().as_millis() as u64,
202 })?;
203
204 Ok(())
205}
206
207fn run_stats(args: FtsStatsArgs) -> Result<(), AppError> {
213 let start = std::time::Instant::now();
214 let paths = AppPaths::resolve(args.db.as_deref())?;
215 crate::storage::connection::ensure_db_ready(&paths)?;
216 let conn = open_ro(&paths.db)?;
217
218 let total_rows: i64 = conn.query_row("SELECT COUNT(*) FROM fts_memories", [], |r| r.get(0))?;
220
221 let shadow_pages: Option<i64> = conn
224 .query_row("SELECT COUNT(*) FROM fts_memories_data", [], |r| r.get(0))
225 .ok();
226
227 let fts_functional = conn
230 .execute_batch("SELECT * FROM fts_memories('*') LIMIT 0;")
231 .is_ok();
232
233 output::emit_json(&FtsStatsResponse {
234 total_rows,
235 shadow_pages,
236 fts_functional,
237 elapsed_ms: start.elapsed().as_millis() as u64,
238 })?;
239
240 Ok(())
241}
242
243pub fn check_fts_functional(conn: &rusqlite::Connection) -> Result<bool, AppError> {
255 let table_exists: bool = conn
256 .query_row(
257 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='fts_memories'",
258 [],
259 |r| r.get::<_, i64>(0).map(|v| v > 0),
260 )
261 .unwrap_or(false);
262 if !table_exists {
263 return Ok(false);
264 }
265 let liveness = conn
266 .execute_batch("SELECT * FROM fts_memories('*') LIMIT 0;")
267 .is_ok();
268 Ok(liveness)
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn fts_rebuild_response_serializes_all_fields() {
277 let resp = FtsRebuildResponse {
278 action: "rebuilt".to_string(),
279 rows_indexed: 42,
280 elapsed_ms: 10,
281 };
282 let json = serde_json::to_value(&resp).expect("serialization failed");
283 assert_eq!(json["action"], "rebuilt");
284 assert_eq!(json["rows_indexed"], 42i64);
285 assert_eq!(json["elapsed_ms"], 10u64);
286 }
287
288 #[test]
289 fn fts_check_response_integrity_ok_omits_detail() {
290 let resp = FtsCheckResponse {
291 action: "checked".to_string(),
292 integrity_ok: true,
293 detail: None,
294 elapsed_ms: 5,
295 };
296 let json = serde_json::to_value(&resp).expect("serialization failed");
297 assert_eq!(json["action"], "checked");
298 assert_eq!(json["integrity_ok"], true);
299 assert!(
300 json.get("detail").is_none(),
301 "detail must be absent when integrity_ok is true"
302 );
303 assert_eq!(json["elapsed_ms"], 5u64);
304 }
305
306 #[test]
307 fn fts_check_response_corruption_includes_detail() {
308 let resp = FtsCheckResponse {
309 action: "checked".to_string(),
310 integrity_ok: false,
311 detail: Some(
312 "FTS5 integrity-check failed — run 'sqlite-graphrag fts rebuild'".to_string(),
313 ),
314 elapsed_ms: 3,
315 };
316 let json = serde_json::to_value(&resp).expect("serialization failed");
317 assert_eq!(json["integrity_ok"], false);
318 assert!(
319 json["detail"].as_str().unwrap().contains("fts rebuild"),
320 "detail must mention the remediation command"
321 );
322 }
323
324 #[test]
325 fn fts_rebuild_response_elapsed_ms_non_negative() {
326 let resp = FtsRebuildResponse {
327 action: "rebuilt".to_string(),
328 rows_indexed: 0,
329 elapsed_ms: 0,
330 };
331 let json = serde_json::to_value(&resp).expect("serialization failed");
332 assert!(json["elapsed_ms"].as_u64().is_some());
333 }
334
335 #[test]
336 fn fts_check_response_elapsed_ms_non_negative() {
337 let resp = FtsCheckResponse {
338 action: "checked".to_string(),
339 integrity_ok: true,
340 detail: None,
341 elapsed_ms: 0,
342 };
343 let json = serde_json::to_value(&resp).expect("serialization failed");
344 assert!(json["elapsed_ms"].as_u64().is_some());
345 }
346
347 #[test]
348 fn fts_stats_response_serializes_all_fields() {
349 let resp = FtsStatsResponse {
350 total_rows: 150,
351 shadow_pages: Some(12),
352 fts_functional: true,
353 elapsed_ms: 8,
354 };
355 let json = serde_json::to_value(&resp).expect("serialization failed");
356 assert_eq!(json["total_rows"], 150i64);
357 assert_eq!(json["shadow_pages"], 12i64);
358 assert_eq!(json["fts_functional"], true);
359 assert_eq!(json["elapsed_ms"], 8u64);
360 }
361
362 #[test]
363 fn fts_stats_response_omits_shadow_pages_when_none() {
364 let resp = FtsStatsResponse {
365 total_rows: 0,
366 shadow_pages: None,
367 fts_functional: false,
368 elapsed_ms: 2,
369 };
370 let json = serde_json::to_value(&resp).expect("serialization failed");
371 assert!(
372 json.get("shadow_pages").is_none(),
373 "shadow_pages must be absent when None"
374 );
375 assert_eq!(json["fts_functional"], false);
376 }
377
378 #[test]
379 fn fts_stats_response_fts_not_functional() {
380 let resp = FtsStatsResponse {
381 total_rows: 5,
382 shadow_pages: None,
383 fts_functional: false,
384 elapsed_ms: 1,
385 };
386 let json = serde_json::to_value(&resp).expect("serialization failed");
387 assert_eq!(json["fts_functional"], false);
388 assert_eq!(json["total_rows"], 5i64);
389 }
390}