use crate::errors::Error;
use crate::output::{DoctorFtsProject, DoctorFtsResponse, print_json};
use crate::sqlite::Database;
use crate::sqlite::fts::FtsDesyncReport;
use rusqlite::{Connection, OpenFlags};
use std::collections::HashMap;
use std::path::Path;
use std::process::ExitCode;
fn wrap_rusqlite_busy<T>(result: Result<T, rusqlite::Error>) -> Result<T, Error> {
match result {
Ok(v) => Ok(v),
Err(e) if e.to_string().contains("database is locked") => Err(Error::Config(
"Database is locked. Another process (likely the MCP server) is holding a lock. Stop the MCP server and retry.".to_string(),
)),
Err(e) => Err(Error::Config(e.to_string())),
}
}
fn wrap_sqlite_busy<T>(result: Result<T, crate::sqlite::Error>) -> Result<T, Error> {
match result {
Ok(v) => Ok(v),
Err(e) if e.to_string().contains("database is locked") => Err(Error::Config(
"Database is locked. Another process (likely the MCP server) is holding a lock. Stop the MCP server and retry.".to_string(),
)),
Err(e) => Err(e.into()),
}
}
fn wrap_errors_busy<T>(result: Result<T, Error>) -> Result<T, Error> {
match result {
Ok(v) => Ok(v),
Err(Error::SqliteModule(msg)) if msg.contains("database is locked") => Err(Error::Config(
"Database is locked. Another process (likely the MCP server) is holding a lock. Stop the MCP server and retry.".to_string(),
)),
Err(e) => Err(e),
}
}
#[derive(Default)]
struct MemoryCounts {
total: usize,
by_project: HashMap<String, usize>,
}
#[allow(dead_code)] pub fn handle_doctor_fts(
db_path: &Path,
project_filter: Option<&str>,
repair: bool,
json: bool,
) -> Result<ExitCode, Error> {
if repair {
handle_doctor_fts_repair(db_path, project_filter, json)
} else {
handle_doctor_fts_detect(db_path, project_filter, json)
}
}
fn handle_doctor_fts_detect(
db_path: &Path,
project_filter: Option<&str>,
json: bool,
) -> Result<ExitCode, Error> {
let db = Database::from_conn(wrap_rusqlite_busy(Connection::open_with_flags(
db_path,
OpenFlags::SQLITE_OPEN_READ_ONLY,
))?);
let report: FtsDesyncReport = wrap_sqlite_busy(db.detect_fts_desync())?;
let counts = wrap_errors_busy(memory_counts(&db))?;
let response = build_response(&report, &counts, project_filter, false, 0);
if json {
print_json(&response);
} else {
print_human_detect(&response);
}
Ok(ExitCode::SUCCESS)
}
fn handle_doctor_fts_repair(
db_path: &Path,
project_filter: Option<&str>,
json: bool,
) -> Result<ExitCode, Error> {
let pre = Database::from_conn(wrap_rusqlite_busy(Connection::open_with_flags(
db_path,
OpenFlags::SQLITE_OPEN_READ_ONLY,
))?);
let pre_report: FtsDesyncReport = wrap_sqlite_busy(pre.detect_fts_desync())?;
if !pre_report.is_desynced() {
let counts = wrap_errors_busy(memory_counts(&pre))?;
let response = build_response(&pre_report, &counts, project_filter, true, 0);
emit(&response, json);
return Ok(ExitCode::SUCCESS);
}
let db = open_read_write(db_path)?;
db.rebuild_fts()?;
let post_report: FtsDesyncReport = wrap_sqlite_busy(db.detect_fts_desync())?;
let counts = wrap_errors_busy(memory_counts(&db))?;
let actions = post_report.total_desynced() + pre_report.total_desynced();
let response = build_response(&post_report, &counts, project_filter, true, actions);
emit(&response, json);
Ok(ExitCode::SUCCESS)
}
fn open_read_write(db_path: &Path) -> Result<Database, Error> {
let db = Database::open(db_path).map_err(|e| {
let msg = e.to_string();
if msg.contains("database is locked") {
return Error::Config(
"Database is locked. Another process (likely the MCP server) is holding a lock. Stop the MCP server and retry.".to_string(),
);
}
Error::Config(msg)
})?;
db.set_busy_timeout(std::time::Duration::ZERO)?;
Ok(db)
}
fn emit(response: &DoctorFtsResponse, json: bool) {
if json {
print_json(response);
} else {
print_human_detect(response);
}
}
fn memory_counts(db: &Database) -> Result<MemoryCounts, Error> {
let mut counts = MemoryCounts::default();
for pid in db.list_all_project_ids()? {
let c = db.count_rows_for_project(&pid)?;
counts.total += c;
*counts.by_project.entry(pid).or_insert(0) += c;
}
Ok(counts)
}
fn build_response(
report: &FtsDesyncReport,
counts: &MemoryCounts,
project_filter: Option<&str>,
repaired: bool,
actions: usize,
) -> DoctorFtsResponse {
let in_sync = !report.is_desynced();
let mut underpopulated: Vec<DoctorFtsProject> = Vec::new();
let projects: Vec<String> = match project_filter {
Some(filter) => {
if report
.underpopulated_by_project
.get(filter)
.copied()
.unwrap_or(0)
> 0
{
vec![filter.to_string()]
} else {
Vec::new()
}
}
None => report.underpopulated_by_project.keys().cloned().collect(),
};
for pid in projects {
let missing = report
.underpopulated_by_project
.get(&pid)
.copied()
.unwrap_or(0);
let total = counts.by_project.get(&pid).copied().unwrap_or(0);
underpopulated.push(DoctorFtsProject {
project_id: pid.clone(),
memory_rows: total,
missing_from_fts: missing,
});
}
underpopulated.sort_by(|a, b| a.project_id.cmp(&b.project_id));
DoctorFtsResponse {
in_sync,
underpopulated_by_project: underpopulated,
orphan_rows: report.orphans,
total_desynced: report.total_desynced(),
repaired,
actions,
}
}
fn print_human_detect(response: &DoctorFtsResponse) {
if response.in_sync {
if response.repaired {
println!("FTS index is in sync. No rebuild needed (0 actions).");
} else {
println!("FTS index is in sync with the memories table.");
}
return;
}
for p in &response.underpopulated_by_project {
println!(
"Warning: project '{}' has {} memories row(s) missing from the FTS index ({} total).",
p.project_id, p.missing_from_fts, p.memory_rows
);
}
if response.orphan_rows > 0 {
println!(
"Warning: {} FTS row(s) have no matching memories row (orphan).",
response.orphan_rows
);
}
println!(
"FTS index is desynced: {} total desynced row(s). Run 'vipune doctor --fts --repair' to rebuild.",
response.total_desynced
);
}