Files
Kigi-CLI/crates/codegen/kigi-shell/src/session/storage/search_fts.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

1158 lines
42 KiB
Rust

//! SQLite-backed FTS5 index for session search.
//!
//! Modelled after the memory system's `MemoryIndex` / `schema.rs`, but
//! purpose-built for searching across *sessions* (titles + user prompts).
//!
//! ## Schema
//!
//! - `meta` — key-value metadata (schema version)
//! - `session_docs` — one row per session (title, content, content_hash)
//! - `session_docs_fts` — content-synced FTS5 over title + content (not cwd)
//!
//! FTS is kept in sync with `session_docs` via `AFTER INSERT/UPDATE/DELETE`
//! triggers so callers never need to touch the FTS table directly.
//! The `cwd` column is intentionally excluded from the FTS table — it is a
//! filter dimension only, applied via JOIN on `session_docs`.
use kigi_sqlite_journal::JournalMode;
use rusqlite::{Connection, OptionalExtension, params};
/// Bump when making breaking schema changes that require dropping and
/// recreating tables, or to force a rebuild of stale index content
/// (v3 → v4: messages with JSON escapes were silently dropped at indexing).
const SCHEMA_VERSION: &str = "4";
/// A document to be indexed for session search.
#[derive(Debug, Clone)]
pub struct SessionDoc {
pub session_id: String,
pub cwd: String,
pub updated_at_unix: i64,
pub title: String,
/// Concatenated user prompts (the searchable body).
pub content: String,
/// blake3 hash of `content` — used to skip redundant upserts.
pub content_hash: String,
/// Byte offset in `updates.jsonl` up to which content has been indexed.
/// Used for delta indexing: on subsequent updates, only bytes after this
/// offset are parsed and merged with existing content.
pub last_indexed_offset: u64,
}
/// State of a previously indexed session, returned by
/// [`SessionSearchIndex::get_session_index_state`].
#[derive(Debug, Clone)]
pub struct SessionIndexState {
pub content: String,
pub content_hash: String,
pub last_indexed_offset: u64,
}
/// A single search result row.
#[derive(Debug, Clone)]
pub struct SessionSearchRow {
pub session_id: String,
pub cwd: String,
pub title: String,
pub updated_at_unix: i64,
pub score: f32,
pub matched_fields: Vec<String>,
pub snippet: Option<String>,
}
/// Result of a `SessionSearchIndex::query()` call.
#[derive(Debug, Clone)]
pub struct QueryResult {
pub results: Vec<SessionSearchRow>,
pub next_offset: Option<usize>,
pub total_estimate: Option<usize>,
}
/// Wraps a `rusqlite::Connection` pointing at `session_search.sqlite`.
pub struct SessionSearchIndex {
db: Connection,
}
impl SessionSearchIndex {
/// Open (or create) the FTS index at `db_path`.
///
/// Creates the schema and triggers on first use. When the stored schema
/// version is OLDER than [`SCHEMA_VERSION`], drops and recreates all
/// tables (simple migration strategy for an index that can be rebuilt)
/// and deletes the `last_bootstrap_at` completed-bootstrap marker so the
/// wipe is observable to bootstrap/staleness checks.
/// A NEWER stored version is tolerated read/write without dropping.
pub fn open_or_create(db_path: &std::path::Path) -> Result<Self, rusqlite::Error> {
if let Some(parent) = db_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// The mode decision statfs's the parent dir created above.
Self::open_with_journal_mode(db_path, JournalMode::for_db_path(db_path))
}
/// Open with an explicit journal mode — the seam tests use to exercise
/// the network-filesystem decision on a local disk.
fn open_with_journal_mode(
db_path: &std::path::Path,
journal_mode: JournalMode,
) -> Result<Self, rusqlite::Error> {
// busy_timeout + journal pragma live in the helper (see JournalMode::open).
let db = journal_mode.open(db_path)?;
// Check existing schema version
let stored_version: Option<String> = db
.query_row(
"SELECT value FROM meta WHERE key = 'session_search_schema_version'",
[],
|row| row.get(0),
)
.optional()
.unwrap_or(None);
// One-way ratchet: drop only on UPGRADE (stored < current). Multiple
// kigi generations share this DB (stable vs alpha); an equality check
// made each binary wipe the other's index in a ping-pong that left
// search empty mid-rebootstrap. A newer index is safe to read: bumps
// regenerate content only (table schema is column-identical), and the
// newer binary re-upserts any rows we write via content-hash mismatch.
// `None` = fresh DB; a non-integer stored value = legacy/corrupt → 0.
let current: u64 = SCHEMA_VERSION
.parse()
.expect("SCHEMA_VERSION is an integer");
let stored: Option<u64> = stored_version.as_deref().map(|v| v.parse().unwrap_or(0));
let owned_by_newer = stored.is_some_and(|s| s > current);
if stored.is_some_and(|s| s < current) {
// Discard the stale index AND its completed-bootstrap marker in
// one transaction. Binaries that predate the one-way ratchet
// (schema ≤ 3) still wipe a newer index on open, re-stamp their
// own version, and rewrite `last_bootstrap_at` when their
// bootstrap finishes. If that marker survived this upgrade drop,
// a "did a bootstrap complete?" check would trust it and never
// repopulate the now-empty tables — leaving content search
// permanently empty (the query path itself performs this drop).
// Deleting the marker makes the wipe observable: the search
// manager re-runs a full bootstrap and remote sync correctly
// treats the local index as stale. All other `meta` keys are
// preserved.
db.execute_batch(
"
BEGIN;
DROP TRIGGER IF EXISTS session_docs_ai;
DROP TRIGGER IF EXISTS session_docs_ad;
DROP TRIGGER IF EXISTS session_docs_au;
DROP TABLE IF EXISTS session_docs_fts;
DROP TABLE IF EXISTS session_docs;
DELETE FROM meta WHERE key = 'last_bootstrap_at';
COMMIT;
",
)?;
} else if owned_by_newer {
tracing::debug!(
stored = stored.unwrap_or_default(),
current,
"session search index owned by a newer schema version; keeping tables"
);
}
// Create tables + content-synced FTS5 with auto-sync triggers
db.execute_batch(
"
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS session_docs (
session_id TEXT PRIMARY KEY,
cwd TEXT NOT NULL,
updated_at INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
content_hash TEXT NOT NULL
);
-- FTS only indexes title + content (searchable columns).
-- cwd is NOT in the FTS table — it is a filter dimension only,
-- applied via JOIN on session_docs.
CREATE VIRTUAL TABLE IF NOT EXISTS session_docs_fts USING fts5(
title,
content,
content='session_docs',
content_rowid='rowid'
);
-- Keep FTS in sync via triggers so callers only touch session_docs.
CREATE TRIGGER IF NOT EXISTS session_docs_ai AFTER INSERT ON session_docs BEGIN
INSERT INTO session_docs_fts(rowid, title, content)
VALUES (new.rowid, new.title, new.content);
END;
CREATE TRIGGER IF NOT EXISTS session_docs_ad AFTER DELETE ON session_docs BEGIN
INSERT INTO session_docs_fts(session_docs_fts, rowid, title, content)
VALUES ('delete', old.rowid, old.title, old.content);
END;
CREATE TRIGGER IF NOT EXISTS session_docs_au AFTER UPDATE ON session_docs BEGIN
INSERT INTO session_docs_fts(session_docs_fts, rowid, title, content)
VALUES ('delete', old.rowid, old.title, old.content);
INSERT INTO session_docs_fts(rowid, title, content)
VALUES (new.rowid, new.title, new.content);
END;
",
)?;
// Add last_indexed_offset column (idempotent migration).
match db.execute(
"ALTER TABLE session_docs ADD COLUMN last_indexed_offset INTEGER NOT NULL DEFAULT 0",
[],
) {
Ok(_) => {}
Err(e) => {
let msg = e.to_string();
if !msg.contains("duplicate column") {
return Err(e);
}
}
}
// Persist schema version — but never regress the row a newer
// generation owns (it would re-trigger that binary's upgrade drop).
if stored != Some(current) && !owned_by_newer {
db.execute(
"INSERT OR REPLACE INTO meta(key, value) \
VALUES ('session_search_schema_version', ?1)",
params![SCHEMA_VERSION],
)?;
}
Ok(Self { db })
}
/// Insert or update a session document in the index.
///
/// The content-synced FTS triggers handle updating `session_docs_fts`
/// automatically.
pub fn upsert_doc(&self, doc: &SessionDoc) -> Result<(), rusqlite::Error> {
self.db.execute(
"INSERT INTO session_docs(session_id, cwd, updated_at, title, content, content_hash, last_indexed_offset)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(session_id) DO UPDATE SET
cwd = excluded.cwd,
updated_at = excluded.updated_at,
title = excluded.title,
content = excluded.content,
content_hash = excluded.content_hash,
last_indexed_offset = excluded.last_indexed_offset",
params![
doc.session_id,
doc.cwd,
doc.updated_at_unix,
doc.title,
doc.content,
doc.content_hash,
doc.last_indexed_offset as i64
],
)?;
Ok(())
}
/// Insert a session document only if no row exists for its `session_id`.
///
/// Atomic alternative to a check-then-insert: the index DB is shared
/// across processes, so a two-step gate could clobber a full-content row
/// written between the check and the insert.
pub fn insert_doc_if_absent(&self, doc: &SessionDoc) -> Result<(), rusqlite::Error> {
self.db.execute(
"INSERT INTO session_docs(session_id, cwd, updated_at, title, content, content_hash, last_indexed_offset)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(session_id) DO NOTHING",
params![
doc.session_id,
doc.cwd,
doc.updated_at_unix,
doc.title,
doc.content,
doc.content_hash,
doc.last_indexed_offset as i64
],
)?;
Ok(())
}
/// Remove a session document from the index.
pub fn delete_doc(&self, session_id: &str) -> Result<(), rusqlite::Error> {
self.db.execute(
"DELETE FROM session_docs WHERE session_id = ?1",
params![session_id],
)?;
Ok(())
}
/// Return the stored content_hash for a session, if any.
///
/// Used to skip redundant upserts when content hasn't changed.
pub fn get_content_hash(&self, session_id: &str) -> Result<Option<String>, rusqlite::Error> {
self.db
.query_row(
"SELECT content_hash FROM session_docs WHERE session_id = ?1",
params![session_id],
|row| row.get(0),
)
.optional()
}
/// Return the full index state for a session: content, content_hash,
/// and last_indexed_offset. Used by the delta indexing path.
pub fn get_session_index_state(
&self,
session_id: &str,
) -> Result<Option<SessionIndexState>, rusqlite::Error> {
self.db
.query_row(
"SELECT content, content_hash, last_indexed_offset FROM session_docs WHERE session_id = ?1",
params![session_id],
|row| {
let content: String = row.get(0)?;
let content_hash: String = row.get(1)?;
let offset: i64 = row.get(2)?;
Ok(SessionIndexState {
content,
content_hash,
last_indexed_offset: offset as u64,
})
},
)
.optional()
}
/// Update only the `last_indexed_offset` for a session without touching
/// content or hash (avoids firing FTS triggers when content is unchanged).
pub fn update_indexed_offset(
&self,
session_id: &str,
offset: u64,
) -> Result<(), rusqlite::Error> {
self.db.execute(
"UPDATE session_docs SET last_indexed_offset = ?2 WHERE session_id = ?1",
params![session_id, offset as i64],
)?;
Ok(())
}
/// Read a value from the `meta` key-value table.
pub fn get_meta(&self, key: &str) -> Result<Option<String>, rusqlite::Error> {
self.db
.query_row(
"SELECT value FROM meta WHERE key = ?1",
params![key],
|row| row.get(0),
)
.optional()
}
/// Write a value to the `meta` key-value table (insert or replace).
pub fn set_meta(&self, key: &str, value: &str) -> Result<(), rusqlite::Error> {
self.db.execute(
"INSERT OR REPLACE INTO meta(key, value) VALUES (?1, ?2)",
params![key, value],
)?;
Ok(())
}
/// Return all session IDs currently in the index.
///
/// Used during reindex to detect and prune orphaned entries.
pub fn all_indexed_session_ids(&self) -> Result<Vec<String>, rusqlite::Error> {
let mut stmt = self.db.prepare("SELECT session_id FROM session_docs")?;
let ids = stmt
.query_map([], |row| row.get(0))?
.collect::<Result<Vec<String>, _>>()?;
Ok(ids)
}
/// Run a BM25-ranked FTS5 query over indexed sessions.
///
/// Multi-token queries require every token (AND) first; when that
/// intersection matches nothing the query reruns as an OR so partial
/// matches still surface. Returns `(results, next_offset, total_estimate)`.
pub fn query(
&self,
query: &str,
cwd: Option<&str>,
limit: usize,
offset: usize,
include_content: bool,
) -> Result<QueryResult, rusqlite::Error> {
let Some((and_query, or_query)) = Self::build_match_queries(query) else {
return Ok(QueryResult {
results: Vec::new(),
next_offset: None,
total_estimate: Some(0),
});
};
let result = self.run_match_query(&and_query, cwd, limit, offset, include_content)?;
// Gate the fallback on the total (not the page) so every offset of one
// logical query is served by the same match string.
if result.total_estimate == Some(0) && and_query != or_query {
return self.run_match_query(&or_query, cwd, limit, offset, include_content);
}
Ok(result)
}
/// Execute one FTS5 MATCH string; `total_estimate` is computed with the
/// same match string that produced the rows.
fn run_match_query(
&self,
match_query: &str,
cwd: Option<&str>,
limit: usize,
offset: usize,
include_content: bool,
) -> Result<QueryResult, rusqlite::Error> {
let total: i64 = self.db.query_row(
"SELECT COUNT(*)
FROM session_docs_fts
JOIN session_docs d ON d.rowid = session_docs_fts.rowid
WHERE session_docs_fts MATCH ?1
AND (?2 IS NULL OR d.cwd = ?2)",
params![match_query, cwd],
|row| row.get(0),
)?;
let snippet_expr = if include_content {
"snippet(session_docs_fts, 1, '[', ']', ' … ', 18)"
} else {
"NULL"
};
// BM25 weights: title=10.0, content=1.0
let sql = format!(
"SELECT
d.session_id,
d.cwd,
d.title,
d.updated_at,
bm25(session_docs_fts, 10.0, 1.0) AS rank,
{snippet_expr} AS snippet,
highlight(session_docs_fts, 0, '\x01', '\x02') AS hl_title,
highlight(session_docs_fts, 1, '\x01', '\x02') AS hl_content
FROM session_docs_fts
JOIN session_docs d ON d.rowid = session_docs_fts.rowid
WHERE session_docs_fts MATCH ?1
AND (?2 IS NULL OR d.cwd = ?2)
ORDER BY rank ASC, d.updated_at DESC, d.session_id ASC
LIMIT ?3 OFFSET ?4"
);
let mut stmt = self.db.prepare(&sql)?;
let rows = stmt.query_map(
params![match_query, cwd, limit as i64, offset as i64],
|row| {
let session_id: String = row.get("session_id")?;
let row_cwd: String = row.get("cwd")?;
let title: String = row.get("title")?;
let updated_at_unix: i64 = row.get("updated_at")?;
let rank: f64 = row.get("rank")?;
let snippet: Option<String> = row.get("snippet")?;
let hl_title: String = row.get("hl_title")?;
let hl_content: String = row.get("hl_content")?;
let score = if rank.is_finite() {
-(rank as f32)
} else {
0.0
};
let mut matched_fields = Vec::new();
if hl_title.contains('\x01') {
matched_fields.push("title".to_string());
}
if hl_content.contains('\x01') {
matched_fields.push("content".to_string());
}
if matched_fields.is_empty() {
matched_fields.push("content".to_string());
}
Ok(SessionSearchRow {
session_id,
cwd: row_cwd,
title,
updated_at_unix,
score,
matched_fields,
snippet,
})
},
)?;
let results: Vec<SessionSearchRow> = rows.collect::<Result<_, _>>()?;
let total_usize = usize::try_from(total).unwrap_or(0);
let next_offset = (offset + results.len() < total_usize).then_some(offset + results.len());
Ok(QueryResult {
results,
next_offset,
total_estimate: Some(total_usize),
})
}
/// Build the AND-joined and OR-joined FTS5 MATCH strings for a query.
///
/// The strings are identical for single-token queries, which lets the
/// caller skip the fallback rerun.
fn build_match_queries(query: &str) -> Option<(String, String)> {
let prefixes: Vec<String> = query
.split_whitespace()
.flat_map(Self::sanitize_token)
.map(Self::token_prefix)
.collect();
if prefixes.is_empty() {
let fallback = query.trim();
if fallback.is_empty() {
return None;
}
let cleaned = fallback.replace('"', "");
let phrase = format!("\"{cleaned}\" *");
return Some((phrase.clone(), phrase));
}
Some((prefixes.join(" AND "), prefixes.join(" OR ")))
}
/// Split a query word on every stripped character instead of gluing the
/// fragments: `session_picker.rs` must search as `session_picker` + `rs`,
/// not as the never-indexed `session_pickerrs`. Fragments without any
/// alphanumeric (`-`, `->`, `_`) are dropped — they tokenize to empty
/// phrases, and an empty phrase inside an AND silently matches nothing.
fn sanitize_token(token: &str) -> impl Iterator<Item = &str> {
token
.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))
.filter(|part| part.chars().any(|c| c.is_ascii_alphanumeric()))
}
/// One quoted FTS5 prefix per token, stemmed on the query side only.
///
/// Plural queries reach singular docs by searching the shorter stem
/// (`sessions` → `session*`, `caches` → `cach*`); the trailing `*` covers
/// the reverse direction and typed stems like `ing`/`ed`, so no OR-group
/// is needed — a `(base OR stem)` group double-counts bm25 and ranks
/// inflected docs above exact matches. Short (< 4) words, identifiers
/// with digits/`_`/`-`, and `ss`-tail words (`pass`, `class`) stay exact.
fn token_prefix(token: &str) -> String {
let stem = if token.len() < 4 || !token.chars().all(|c| c.is_ascii_alphabetic()) {
token
} else {
let lower = token.to_ascii_lowercase();
if lower.ends_with("es") {
// The stem's prefix `*` also covers `e`-singulars (caches → cach*).
&token[..token.len() - 2]
} else if lower.ends_with('s') && !lower.ends_with("ss") {
&token[..token.len() - 1]
} else {
token
}
};
format!("\"{stem}\" *")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_doc(id: &str, title: &str, content: &str) -> SessionDoc {
SessionDoc {
session_id: id.to_string(),
cwd: "/test/workspace".to_string(),
updated_at_unix: 1700000000,
title: title.to_string(),
content: content.to_string(),
content_hash: blake3::hash(content.as_bytes()).to_hex().to_string(),
last_indexed_offset: 0,
}
}
fn open(tmp: &TempDir) -> SessionSearchIndex {
SessionSearchIndex::open_or_create(&tmp.path().join("session_search.sqlite")).unwrap()
}
#[test]
fn test_open_or_create_is_idempotent() {
let tmp = TempDir::new().unwrap();
let _i1 = open(&tmp);
let _i2 = open(&tmp);
}
fn journal_mode(index: &SessionSearchIndex) -> String {
index
.db
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
.unwrap()
}
#[test]
fn test_open_or_create_uses_wal_on_local_fs() {
// Ambient kill-switch would override the decision; skip if set.
if std::env::var("KIGI_SQLITE_JOURNAL_MODE").is_ok() {
return;
}
let tmp = TempDir::new().unwrap();
assert_eq!(journal_mode(&open(&tmp)), "wal");
}
#[test]
fn test_network_mode_uses_fresh_per_host_truncate_db() {
// Network mode opens a per-host sibling of the given path (the
// legacy shared file is left untouched — a live old binary can flip
// it back to WAL at any time) in rollback-journal mode, and the
// index is fully usable there.
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("session_search.sqlite");
let index =
SessionSearchIndex::open_with_journal_mode(&path, JournalMode::Truncate).unwrap();
assert_eq!(journal_mode(&index), "truncate");
index
.upsert_doc(&test_doc("s1", "NFS crash", "sigbus walIndexTryHdr"))
.unwrap();
let hits = index.query("sigbus", None, 10, 0, false).unwrap();
assert_eq!(hits.results.len(), 1);
drop(index);
let eff = JournalMode::Truncate.effective_db_path(&path);
assert_ne!(eff, path);
let base = eff.display().to_string();
assert!(!std::fs::exists(format!("{base}-wal")).unwrap());
assert!(!std::fs::exists(format!("{base}-shm")).unwrap());
}
#[test]
fn test_version_mismatch_drops_docs_and_preserves_unrelated_meta() {
let tmp = TempDir::new().unwrap();
{
let index = open(&tmp);
index
.upsert_doc(&test_doc("s1", "Rust debugging", "borrow checker"))
.unwrap();
index.set_meta("last_bootstrap_at", "1700000000").unwrap();
index.set_meta("last_upload_at", "1700000001").unwrap();
}
{
// Guard against the drop branch firing on every open: a reopen at
// the current version must keep the docs.
let same_version = open(&tmp);
assert_eq!(
same_version.all_indexed_session_ids().unwrap(),
vec!["s1".to_string()],
"docs must survive a same-version reopen"
);
// Simulate a database written by an older schema version.
same_version
.set_meta("session_search_schema_version", "3")
.unwrap();
assert_eq!(
same_version
.get_meta("session_search_schema_version")
.unwrap()
.as_deref(),
Some("3"),
"version downgrade must take effect for the migration to fire"
);
}
let reopened = open(&tmp);
assert!(
reopened.all_indexed_session_ids().unwrap().is_empty(),
"stale docs must be dropped on version mismatch"
);
assert_eq!(
reopened
.get_meta("session_search_schema_version")
.unwrap()
.as_deref(),
Some(SCHEMA_VERSION),
"schema version must be rewritten to current"
);
// The drop batch invalidates the completed-bootstrap marker (the
// dropped tables no longer reflect a completed bootstrap) but leaves
// every other `meta` key alone.
assert_eq!(
reopened.get_meta("last_bootstrap_at").unwrap(),
None,
"the completed-bootstrap marker must be invalidated by the drop"
);
assert_eq!(
reopened.get_meta("last_upload_at").unwrap().as_deref(),
Some("1700000001"),
"unrelated meta keys must survive the drop"
);
// Recreated tables + FTS triggers must be functional end-to-end.
reopened
.upsert_doc(&test_doc("s2", "Python profiling", "flamegraph"))
.unwrap();
let qr = reopened.query("python", None, 10, 0, false).unwrap();
assert_eq!(qr.total_estimate, Some(1));
assert_eq!(qr.results[0].session_id, "s2");
}
#[test]
fn test_newer_version_index_is_tolerated_not_dropped() {
let tmp = TempDir::new().unwrap();
{
let index = open(&tmp);
index
.upsert_doc(&test_doc("s1", "Rust debugging", "borrow checker"))
.unwrap();
// Simulate an index owned by a newer kigi generation that has
// completed a bootstrap.
index
.set_meta("session_search_schema_version", "5")
.unwrap();
index.set_meta("last_bootstrap_at", "1700000000").unwrap();
}
let reopened = open(&tmp);
assert_eq!(
reopened.all_indexed_session_ids().unwrap(),
vec!["s1".to_string()],
"docs must survive an older binary opening a newer index"
);
assert_eq!(
reopened
.get_meta("session_search_schema_version")
.unwrap()
.as_deref(),
Some("5"),
"the newer generation keeps ownership of the version row"
);
assert_eq!(
reopened.get_meta("last_bootstrap_at").unwrap().as_deref(),
Some("1700000000"),
"no drop happened, so the newer index's bootstrap marker must survive"
);
// The tolerated index must stay fully usable for the older binary.
let qr = reopened.query("borrow", None, 10, 0, false).unwrap();
assert_eq!(qr.results[0].session_id, "s1");
}
#[test]
fn test_corrupt_version_row_drops_index() {
let tmp = TempDir::new().unwrap();
{
let index = open(&tmp);
index
.upsert_doc(&test_doc("s1", "Rust debugging", "borrow checker"))
.unwrap();
index
.set_meta("session_search_schema_version", "garbage")
.unwrap();
}
let reopened = open(&tmp);
assert!(
reopened.all_indexed_session_ids().unwrap().is_empty(),
"a corrupt version row must drop and rebuild"
);
assert_eq!(
reopened
.get_meta("session_search_schema_version")
.unwrap()
.as_deref(),
Some(SCHEMA_VERSION),
"rebuild rewrites the current version"
);
}
/// Repro: the on-disk state left behind by a pre-ratchet binary that
/// wiped the shared DB and ran its own bootstrap — a v3-stamped index
/// with a *recent* bootstrap marker. Pins that the current binary's open
/// drops the tables AND deletes the marker together (see the drop batch
/// in `open_or_create`); a surviving marker would suppress re-bootstrap
/// over empty tables.
#[test]
fn test_upgrade_drop_invalidates_completed_bootstrap_marker() {
let tmp = TempDir::new().unwrap();
{
let index = open(&tmp);
index
.upsert_doc(&test_doc("s1", "old-binary doc", "indexed by v3"))
.unwrap();
index
.set_meta("session_search_schema_version", "3")
.unwrap();
index.set_meta("last_bootstrap_at", "1783393389").unwrap();
}
let reopened = open(&tmp);
assert!(
reopened.all_indexed_session_ids().unwrap().is_empty(),
"v3 docs must be dropped on upgrade"
);
assert_eq!(
reopened
.get_meta("session_search_schema_version")
.unwrap()
.as_deref(),
Some(SCHEMA_VERSION),
"upgrade must stamp the current version"
);
assert_eq!(
reopened.get_meta("last_bootstrap_at").unwrap(),
None,
"the stale bootstrap marker must not survive the upgrade drop, \
or the wiped index would be treated as fully bootstrapped"
);
// A subsequent bootstrap can repopulate and re-stamp the marker.
reopened
.upsert_doc(&test_doc("s2", "fresh doc", "indexed by v4"))
.unwrap();
reopened
.set_meta("last_bootstrap_at", "1783393999")
.unwrap();
let qr = reopened.query("fresh", None, 10, 0, false).unwrap();
assert_eq!(qr.results[0].session_id, "s2");
}
#[test]
fn test_upsert_and_query() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
index
.upsert_doc(&test_doc(
"s1",
"Rust debugging",
"fix the borrow checker issue",
))
.unwrap();
let qr = index.query("rust", None, 10, 0, false).unwrap();
assert_eq!(qr.total_estimate, Some(1));
assert_eq!(qr.results[0].session_id, "s1");
assert!(qr.results[0].score > 0.0);
assert!(qr.results[0].matched_fields.contains(&"title".to_string()));
}
#[test]
fn test_upsert_updates_existing() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
index
.upsert_doc(&test_doc("s1", "Old title", "old content"))
.unwrap();
index
.upsert_doc(&test_doc("s1", "New title about kubernetes", "new content"))
.unwrap();
let old = index.query("old", None, 10, 0, false).unwrap();
assert!(
old.results.is_empty(),
"old content should not be searchable"
);
let new = index.query("kubernetes", None, 10, 0, false).unwrap();
assert_eq!(new.results.len(), 1);
}
#[test]
fn test_delete_doc() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
index
.upsert_doc(&test_doc("s1", "Delete me", "some content about python"))
.unwrap();
assert_eq!(index.all_indexed_session_ids().unwrap().len(), 1);
index.delete_doc("s1").unwrap();
assert!(index.all_indexed_session_ids().unwrap().is_empty());
assert!(
index
.query("python", None, 10, 0, false)
.unwrap()
.results
.is_empty()
);
}
#[test]
fn test_content_hash_dedup() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
let doc = test_doc("s1", "Title", "body");
index.upsert_doc(&doc).unwrap();
assert_eq!(
index.get_content_hash("s1").unwrap().as_deref(),
Some(doc.content_hash.as_str())
);
assert_eq!(index.get_content_hash("nonexistent").unwrap(), None);
}
#[test]
fn test_insert_doc_if_absent_never_overwrites() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
let full = test_doc("s1", "Rust debugging", "borrow checker");
index.upsert_doc(&full).unwrap();
// Conflict arm: an existing (fuller) row must be left untouched.
index
.insert_doc_if_absent(&test_doc("s1", "placeholder", ""))
.unwrap();
assert_eq!(
index.get_content_hash("s1").unwrap().as_deref(),
Some(full.content_hash.as_str()),
"existing row must not be downgraded to the placeholder"
);
let qr = index.query("borrow", None, 10, 0, false).unwrap();
assert_eq!(
qr.results[0].session_id, "s1",
"full content must remain FTS-queryable after the no-op insert"
);
// Insert arm: a new id must land and fire the FTS trigger.
index
.insert_doc_if_absent(&test_doc("s2", "Python profiling", ""))
.unwrap();
let qr = index.query("python", None, 10, 0, false).unwrap();
assert_eq!(qr.total_estimate, Some(1));
assert_eq!(qr.results[0].session_id, "s2");
}
#[test]
fn test_query_cwd_filter() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
let mut doc_a = test_doc("s1", "Rust project", "cargo build");
doc_a.cwd = "/workspace/a".to_string();
let mut doc_b = test_doc("s2", "Rust library", "cargo test");
doc_b.cwd = "/workspace/b".to_string();
index.upsert_doc(&doc_a).unwrap();
index.upsert_doc(&doc_b).unwrap();
let all = index.query("rust", None, 10, 0, false).unwrap();
assert_eq!(all.results.len(), 2);
let filtered = index
.query("rust", Some("/workspace/a"), 10, 0, false)
.unwrap();
assert_eq!(filtered.results.len(), 1);
assert_eq!(filtered.results[0].session_id, "s1");
}
#[test]
fn test_query_pagination() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
for i in 0..5 {
index
.upsert_doc(&test_doc(
&format!("s{i}"),
&format!("Session {i}"),
&format!("rust content {i}"),
))
.unwrap();
}
let page1 = index.query("rust", None, 2, 0, false).unwrap();
assert_eq!(page1.results.len(), 2);
assert_eq!(page1.total_estimate, Some(5));
assert_eq!(page1.next_offset, Some(2));
let page2 = index.query("rust", None, 2, 2, false).unwrap();
assert_eq!(page2.results.len(), 2);
}
#[test]
fn test_query_with_snippets() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
index
.upsert_doc(&test_doc(
"s1",
"Debugging session",
"the rust borrow checker was causing lifetime errors in the parser",
))
.unwrap();
let qr = index.query("borrow checker", None, 10, 0, true).unwrap();
assert_eq!(qr.results.len(), 1);
assert!(qr.results[0].snippet.is_some());
}
#[test]
fn test_query_empty_string() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
index.upsert_doc(&test_doc("s1", "Title", "body")).unwrap();
let qr = index.query("", None, 10, 0, false).unwrap();
assert!(qr.results.is_empty());
assert_eq!(qr.total_estimate, Some(0));
}
#[test]
fn test_query_special_chars_sanitized() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
index
.upsert_doc(&test_doc("s1", "Title", "hello world"))
.unwrap();
// Special chars should be stripped, leaving "hello"
let qr = index.query("hello!!!", None, 10, 0, false).unwrap();
assert_eq!(qr.results.len(), 1);
}
#[test]
fn test_matched_fields_title_vs_content() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
index
.upsert_doc(&test_doc(
"s1",
"kubernetes deployment",
"unrelated body text",
))
.unwrap();
let qr = index.query("kubernetes", None, 10, 0, false).unwrap();
assert_eq!(qr.results.len(), 1);
assert!(qr.results[0].matched_fields.contains(&"title".to_string()));
}
/// cwd is a filter dimension, not a search dimension. A term that only
/// appears in the cwd must never cause a session to match.
#[test]
fn test_cwd_not_searchable() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
let mut doc = test_doc("s1", "unrelated title", "unrelated content");
doc.cwd = "/Users/alice/workspace/supercalifragilistic".to_string();
index.upsert_doc(&doc).unwrap();
let qr = index
.query("supercalifragilistic", None, 10, 0, false)
.unwrap();
assert!(
qr.results.is_empty(),
"cwd-only term must not match, got {} results",
qr.results.len()
);
}
#[test]
fn test_query_filename_tokens_split() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
index
.upsert_doc(&test_doc(
"s1",
"Fix list rendering",
"the bug lives in session_picker.rs near the filter",
))
.unwrap();
// Pins splitting on stripped chars: gluing the fragments produced the
// never-indexed token `session_pickerrs`, so this query found nothing.
let qr = index
.query("session_picker.rs", None, 10, 0, false)
.unwrap();
assert_eq!(qr.total_estimate, Some(1));
assert_eq!(qr.results[0].session_id, "s1");
}
#[test]
fn test_query_and_first_with_or_fallback() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
index
.upsert_doc(&test_doc(
"s1",
"Borrow both",
"fix the borrow checker issue",
))
.unwrap();
index
.upsert_doc(&test_doc("s2", "Borrow only", "borrow money from the bank"))
.unwrap();
index
.upsert_doc(&test_doc("s3", "Tokio doc", "tokio runtime setup"))
.unwrap();
index
.upsert_doc(&test_doc("s4", "Sqlite doc", "sqlite index tuning"))
.unwrap();
// AND has hits: only the doc matching every token is returned, so
// partial matches cannot dilute the result set.
let qr = index.query("borrow checker", None, 10, 0, false).unwrap();
assert_eq!(qr.total_estimate, Some(1));
assert_eq!(qr.results[0].session_id, "s1");
// A separator-only word (`->`) must be dropped, not become an empty
// phrase that silently makes the whole AND match nothing.
let qr = index.query("fix -> borrow", None, 10, 0, false).unwrap();
assert_eq!(qr.total_estimate, Some(1));
assert_eq!(qr.results[0].session_id, "s1");
// No doc has both tokens: the OR rerun surfaces the partial matches.
let qr = index.query("tokio sqlite", None, 10, 0, false).unwrap();
assert_eq!(qr.total_estimate, Some(2));
let ids: Vec<&str> = qr.results.iter().map(|r| r.session_id.as_str()).collect();
assert!(
ids.contains(&"s3") && ids.contains(&"s4"),
"OR fallback must return both partial matches: {ids:?}"
);
}
#[test]
fn test_query_plural_variants() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
index
.upsert_doc(&test_doc("s1", "Plural doc", "resumed sessions list"))
.unwrap();
index
.upsert_doc(&test_doc("s2", "Singular doc", "resume the session flow"))
.unwrap();
// Plural query, singular doc: pins the query-side stem — without it
// `sessions*` cannot prefix-match `session`.
let qr = index.query("sessions", None, 10, 0, false).unwrap();
let ids: Vec<&str> = qr.results.iter().map(|r| r.session_id.as_str()).collect();
assert!(
ids.contains(&"s2"),
"singular doc must match a plural query: {ids:?}"
);
// Singular query, plural doc: pins the prefix-`*` coverage that makes
// an added plural variant unnecessary.
let qr = index.query("session", None, 10, 0, false).unwrap();
let ids: Vec<&str> = qr.results.iter().map(|r| r.session_id.as_str()).collect();
assert!(
ids.contains(&"s1"),
"plural doc must match a singular query: {ids:?}"
);
}
#[test]
fn test_query_pure_symbol_fallback() {
let tmp = TempDir::new().unwrap();
let index = open(&tmp);
index.upsert_doc(&test_doc("s1", "Title", "body")).unwrap();
// No indexable characters: the raw-phrase fallback must not error.
let qr = index.query("…", None, 10, 0, false).unwrap();
assert!(qr.results.is_empty());
assert_eq!(qr.total_estimate, Some(0));
}
}