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