Skip to main content

sqlite_graphrag/commands/
fts.rs

1//! Handler for the `fts` CLI subcommand family.
2//!
3//! Provides two maintenance operations for the FTS5 full-text search index:
4//! - `rebuild`: drops and reconstructs the index from the `memories` table.
5//! - `check`: runs the FTS5 integrity-check without modifying the index.
6
7use crate::errors::AppError;
8use crate::output;
9use crate::paths::AppPaths;
10use crate::storage::connection::{open_ro, open_rw};
11use serde::Serialize;
12
13/// Arguments for the `fts` subcommand family.
14#[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    /// Subcommand to execute.
27    #[command(subcommand)]
28    pub command: FtsSubcommand,
29}
30
31/// Subcommands nested under `fts`.
32#[derive(clap::Subcommand)]
33pub enum FtsSubcommand {
34    /// Rebuild the FTS5 index from the memories table.
35    #[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    /// Run FTS5 integrity-check without modifying the index.
42    #[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    /// Show FTS5 index statistics (row count, shadow pages, functional status).
49    #[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/// Arguments for `fts rebuild`.
58#[derive(clap::Args)]
59pub struct FtsRebuildArgs {
60    /// No-op; JSON is always emitted on stdout.
61    #[arg(long, hide = true)]
62    pub json: bool,
63    /// Path to the SQLite database file.
64    #[arg(long)]
65    pub db: Option<String>,
66}
67
68/// Arguments for `fts check`.
69#[derive(clap::Args)]
70pub struct FtsCheckArgs {
71    /// No-op; JSON is always emitted on stdout.
72    #[arg(long, hide = true)]
73    pub json: bool,
74    /// Path to the SQLite database file.
75    #[arg(long)]
76    pub db: Option<String>,
77}
78
79/// Arguments for `fts stats`.
80#[derive(clap::Args)]
81pub struct FtsStatsArgs {
82    /// No-op; JSON is always emitted on stdout.
83    #[arg(long, hide = true)]
84    pub json: bool,
85    /// Path to the SQLite database file.
86    #[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
115/// Dispatch entry point called from `main`.
116///
117/// # Errors
118/// Propagates any [`AppError`] raised by the underlying subcommand.
119pub 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
127/// Rebuilds the FTS5 index by issuing the `'rebuild'` special command.
128///
129/// The FTS5 `INSERT INTO fts_memories(fts_memories) VALUES('rebuild')` statement
130/// drops all index data and re-populates it from the content table in a single
131/// transaction. Use this after bulk imports or when `fts check` reports a failure.
132///
133/// # Errors
134/// Returns [`AppError::Database`] on any SQLite failure.
135fn 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
168/// Runs the FTS5 integrity-check without modifying the index.
169///
170/// The FTS5 integrity-check is triggered by:
171/// ```sql
172/// INSERT INTO fts_memories(fts_memories, rank) VALUES('integrity-check', 1);
173/// ```
174/// SQLite raises an error if the index is corrupt, so a successful `execute_batch`
175/// means the index is healthy. On failure, `integrity_ok` is `false` and the
176/// `detail` field carries an actionable hint.
177///
178/// # Errors
179/// Returns [`AppError`] only on unexpected I/O or path resolution failures;
180/// an FTS5 corruption is reported as `integrity_ok: false`, not as a Rust error.
181fn 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
207/// Returns FTS5 index statistics: total indexed rows, shadow table page count (best-effort),
208/// and a functional liveness check.
209///
210/// # Errors
211/// Returns [`AppError`] only on unexpected I/O or path resolution failures.
212fn 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    // 1. Total indexed rows in the FTS5 content table.
219    let total_rows: i64 = conn.query_row("SELECT COUNT(*) FROM fts_memories", [], |r| r.get(0))?;
220
221    // 2. Shadow pages — queries the internal `_data` shadow table.
222    //    This may not exist on all SQLite builds; treat any failure as None.
223    let shadow_pages: Option<i64> = conn
224        .query_row("SELECT COUNT(*) FROM fts_memories_data", [], |r| r.get(0))
225        .ok();
226
227    // 3. Functional liveness: SELECT with FTS5 match syntax against a wildcard.
228    //    A successful LIMIT 0 query confirms the FTS5 module is operational.
229    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
243/// Public helper: returns `true` when the FTS5 module is loadable AND the
244/// `fts_memories` virtual table exists AND a wildcard MATCH query succeeds.
245///
246/// Used by [`crate::commands::optimize`] to skip the (potentially minute-long)
247/// FTS5 rebuild when the index is already healthy. Also used by `health` and
248/// by future `vec check` implementations.
249///
250/// # Errors
251/// Returns `Err(AppError::Database)` only when the connection cannot be opened
252/// for reasons unrelated to FTS5 itself (permission denied, corrupted file).
253/// A missing FTS5 module or table is reported as `Ok(false)`.
254pub 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}