use crate::errors::AppError;
use crate::output;
use crate::paths::AppPaths;
use crate::storage::connection::{open_ro, open_rw};
use serde::Serialize;
#[derive(clap::Args)]
#[command(about = "FTS5 full-text search index management")]
pub struct FtsArgs {
#[command(subcommand)]
pub command: FtsSubcommand,
}
#[derive(clap::Subcommand)]
pub enum FtsSubcommand {
#[command(after_long_help = "EXAMPLES:\n \
# Rebuild the full-text search index\n \
sqlite-graphrag fts rebuild\n\n \
# Rebuild with custom database path\n \
sqlite-graphrag fts rebuild --db /path/to/graphrag.sqlite")]
Rebuild(FtsRebuildArgs),
#[command(after_long_help = "EXAMPLES:\n \
# Check FTS5 index integrity\n \
sqlite-graphrag fts check\n\n \
# Check with custom database path\n \
sqlite-graphrag fts check --db /path/to/graphrag.sqlite")]
Check(FtsCheckArgs),
#[command(after_long_help = "EXAMPLES:\n \
# Show FTS5 index statistics\n \
sqlite-graphrag fts stats\n\n \
# Stats with custom database path\n \
sqlite-graphrag fts stats --db /path/to/graphrag.sqlite")]
Stats(FtsStatsArgs),
}
#[derive(clap::Args)]
pub struct FtsRebuildArgs {
#[arg(long, hide = true)]
pub json: bool,
#[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
pub db: Option<String>,
}
#[derive(clap::Args)]
pub struct FtsCheckArgs {
#[arg(long, hide = true)]
pub json: bool,
#[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
pub db: Option<String>,
}
#[derive(clap::Args)]
pub struct FtsStatsArgs {
#[arg(long, hide = true)]
pub json: bool,
#[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
pub db: Option<String>,
}
#[derive(Serialize)]
struct FtsRebuildResponse {
action: String,
rows_indexed: i64,
elapsed_ms: u64,
}
#[derive(Serialize)]
struct FtsCheckResponse {
action: String,
integrity_ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
elapsed_ms: u64,
}
#[derive(Serialize)]
struct FtsStatsResponse {
total_rows: i64,
#[serde(skip_serializing_if = "Option::is_none")]
shadow_pages: Option<i64>,
fts_functional: bool,
elapsed_ms: u64,
}
pub fn run(args: FtsArgs) -> Result<(), AppError> {
match args.command {
FtsSubcommand::Rebuild(a) => run_rebuild(a),
FtsSubcommand::Check(a) => run_check(a),
FtsSubcommand::Stats(a) => run_stats(a),
}
}
fn run_rebuild(args: FtsRebuildArgs) -> Result<(), AppError> {
let start = std::time::Instant::now();
let paths = AppPaths::resolve(args.db.as_deref())?;
crate::storage::connection::ensure_db_ready(&paths)?;
let conn = open_rw(&paths.db)?;
conn.execute_batch("INSERT INTO fts_memories(fts_memories) VALUES('rebuild');")?;
let rows: i64 = conn.query_row("SELECT COUNT(*) FROM fts_memories", [], |r| r.get(0))?;
output::emit_json(&FtsRebuildResponse {
action: "rebuilt".to_string(),
rows_indexed: rows,
elapsed_ms: start.elapsed().as_millis() as u64,
})?;
Ok(())
}
fn run_check(args: FtsCheckArgs) -> Result<(), AppError> {
let start = std::time::Instant::now();
let paths = AppPaths::resolve(args.db.as_deref())?;
crate::storage::connection::ensure_db_ready(&paths)?;
let conn = open_rw(&paths.db)?;
let integrity_ok = conn
.execute_batch("INSERT INTO fts_memories(fts_memories, rank) VALUES('integrity-check', 1);")
.is_ok();
output::emit_json(&FtsCheckResponse {
action: "checked".to_string(),
integrity_ok,
detail: if integrity_ok {
None
} else {
Some("FTS5 integrity-check failed — run 'sqlite-graphrag fts rebuild'".to_string())
},
elapsed_ms: start.elapsed().as_millis() as u64,
})?;
Ok(())
}
fn run_stats(args: FtsStatsArgs) -> Result<(), AppError> {
let start = std::time::Instant::now();
let paths = AppPaths::resolve(args.db.as_deref())?;
crate::storage::connection::ensure_db_ready(&paths)?;
let conn = open_ro(&paths.db)?;
let total_rows: i64 = conn.query_row("SELECT COUNT(*) FROM fts_memories", [], |r| r.get(0))?;
let shadow_pages: Option<i64> = conn
.query_row("SELECT COUNT(*) FROM fts_memories_data", [], |r| r.get(0))
.ok();
let fts_functional = conn
.execute_batch("SELECT * FROM fts_memories('*') LIMIT 0;")
.is_ok();
output::emit_json(&FtsStatsResponse {
total_rows,
shadow_pages,
fts_functional,
elapsed_ms: start.elapsed().as_millis() as u64,
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fts_rebuild_response_serializes_all_fields() {
let resp = FtsRebuildResponse {
action: "rebuilt".to_string(),
rows_indexed: 42,
elapsed_ms: 10,
};
let json = serde_json::to_value(&resp).expect("serialization failed");
assert_eq!(json["action"], "rebuilt");
assert_eq!(json["rows_indexed"], 42i64);
assert_eq!(json["elapsed_ms"], 10u64);
}
#[test]
fn fts_check_response_integrity_ok_omits_detail() {
let resp = FtsCheckResponse {
action: "checked".to_string(),
integrity_ok: true,
detail: None,
elapsed_ms: 5,
};
let json = serde_json::to_value(&resp).expect("serialization failed");
assert_eq!(json["action"], "checked");
assert_eq!(json["integrity_ok"], true);
assert!(
json.get("detail").is_none(),
"detail must be absent when integrity_ok is true"
);
assert_eq!(json["elapsed_ms"], 5u64);
}
#[test]
fn fts_check_response_corruption_includes_detail() {
let resp = FtsCheckResponse {
action: "checked".to_string(),
integrity_ok: false,
detail: Some(
"FTS5 integrity-check failed — run 'sqlite-graphrag fts rebuild'".to_string(),
),
elapsed_ms: 3,
};
let json = serde_json::to_value(&resp).expect("serialization failed");
assert_eq!(json["integrity_ok"], false);
assert!(
json["detail"].as_str().unwrap().contains("fts rebuild"),
"detail must mention the remediation command"
);
}
#[test]
fn fts_rebuild_response_elapsed_ms_non_negative() {
let resp = FtsRebuildResponse {
action: "rebuilt".to_string(),
rows_indexed: 0,
elapsed_ms: 0,
};
let json = serde_json::to_value(&resp).expect("serialization failed");
assert!(json["elapsed_ms"].as_u64().is_some());
}
#[test]
fn fts_check_response_elapsed_ms_non_negative() {
let resp = FtsCheckResponse {
action: "checked".to_string(),
integrity_ok: true,
detail: None,
elapsed_ms: 0,
};
let json = serde_json::to_value(&resp).expect("serialization failed");
assert!(json["elapsed_ms"].as_u64().is_some());
}
#[test]
fn fts_stats_response_serializes_all_fields() {
let resp = FtsStatsResponse {
total_rows: 150,
shadow_pages: Some(12),
fts_functional: true,
elapsed_ms: 8,
};
let json = serde_json::to_value(&resp).expect("serialization failed");
assert_eq!(json["total_rows"], 150i64);
assert_eq!(json["shadow_pages"], 12i64);
assert_eq!(json["fts_functional"], true);
assert_eq!(json["elapsed_ms"], 8u64);
}
#[test]
fn fts_stats_response_omits_shadow_pages_when_none() {
let resp = FtsStatsResponse {
total_rows: 0,
shadow_pages: None,
fts_functional: false,
elapsed_ms: 2,
};
let json = serde_json::to_value(&resp).expect("serialization failed");
assert!(
json.get("shadow_pages").is_none(),
"shadow_pages must be absent when None"
);
assert_eq!(json["fts_functional"], false);
}
#[test]
fn fts_stats_response_fts_not_functional() {
let resp = FtsStatsResponse {
total_rows: 5,
shadow_pages: None,
fts_functional: false,
elapsed_ms: 1,
};
let json = serde_json::to_value(&resp).expect("serialization failed");
assert_eq!(json["fts_functional"], false);
assert_eq!(json["total_rows"], 5i64);
}
}