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.
1158 lines
42 KiB
Rust
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));
|
|
}
|
|
}
|