feat: add persistent CPU search engine and CLI with lifecycle tests
Core CI / core (ubuntu-latest) (push) Successful in 1m0s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s

This commit is contained in:
karl committed 2026-10-12 15:51:39 +02:00
1 parent aae803771c
commit f77112db97
19 files changed
+1688

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
[package]
name = "searchi-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
serde.workspace = true
rusqlite = { version = "0.32", features = ["bundled"] }
walkdir = "2"
[dev-dependencies]
tempfile = "3"
+49
View File
@@ -0,0 +1,49 @@
# Searchi core
The core has no desktop or inference dependencies. Its current retrieval engine
combines literal filename matching with SQLite FTS5 BM25 keyword search. It does
not implement semantic search.
`SearchIndex::open` creates or migrates schema version 2. Current-schema opens do
not acquire a write lock. WAL allows independent readers to use the committed
index while reconciliation runs. All indexing changes are committed atomically;
a traversal or supported-file read error rolls back the entire reconciliation.
`index_folder` applies `IndexOptions::default()`: directories named `.git`,
`node_modules`, and `target` are pruned. `index_folder_with_options` lets callers
replace those names. Matching directory names are ASCII case insensitive. An
explicit root is always traversed, even if its name is excluded. Symlinks are not
followed. The index's own database, WAL, and shared-memory files are excluded.
Metadata includes byte size and modification time at nanosecond resolution.
Unchanged files bypass extraction. `IndexReport.indexed` counts newly extracted
or updated records; `unchanged` counts reused records; `skipped` counts new or
changed records whose content could not be extracted because of format, encoding,
or size. Unsupported files remain searchable by filename. Content reads are
limited to 2 MiB. Reconciliation tracks seen files in SQLite rather than retaining
every path in process memory.
`search_with_options` supports canonical folder boundaries, extension, inclusive
size bounds, and inclusive Unix millisecond modification-time bounds. Filters
apply before limiting results. `search` retains the unfiltered API. Query text is
escaped as literal FTS terms, not interpreted as operators. Results are capped at
1,000 and queries at 4,096 bytes.
## Known limits
- Filename substring lookup uses `instr` and scans metadata. A dedicated substring
index and benchmarks are needed before claiming million-file latency targets.
- Files changed without changing size or modification time require a future
forced refresh/content-fingerprint option. Timestamp precision depends on the
filesystem. Indexing does not yet run a filesystem watcher.
- Metadata modification times before the Unix epoch or beyond the nanosecond
integer range are explicit errors. Unicode filename case folding is currently
limited by SQLite's built-in `lower` function.
- Lossy path conversion does not preserve non-Unicode native filenames. This must
be addressed before Linux production support for arbitrary byte filenames.
- The `files.root` ownership model does not yet support overlapping selected
roots independently. Use disjoint roots until root memberships are implemented.
- Extraction runs in the caller and a large reconciliation holds one transaction.
Bounded worker isolation, persistent jobs, and crash-resumable scans remain
production milestones.
- PDF, DOCX, OCR, media extraction, and real model inference remain unimplemented.
+482
View File
@@ -0,0 +1,482 @@
//! Offline lexical search engine shared by the CLI and desktop shell.
//!
//! Reconciliation is atomic. Incomplete directory walks never delete records.
//! Text extraction is deliberately bounded; binary document extractors belong in
//! isolated workers in the next milestone, not in this indexing transaction.
use anyhow::{bail, Context, Result};
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
use std::{
fs,
io::Read,
path::{Path, PathBuf},
time::UNIX_EPOCH,
};
use walkdir::WalkDir;
const SCHEMA_VERSION: i64 = 2;
const MAX_TEXT_BYTES: u64 = 2 * 1024 * 1024;
const MAX_RESULTS: usize = 1000;
#[derive(Debug, Default, Serialize)]
pub struct IndexReport {
pub discovered: usize,
pub indexed: usize,
pub removed: usize,
pub skipped: usize,
pub unchanged: usize,
}
/// Names of directories to prune anywhere below an explicitly chosen root.
#[derive(Debug, Clone)]
pub struct IndexOptions {
pub excluded_directory_names: Vec<String>,
}
impl Default for IndexOptions {
fn default() -> Self {
Self {
excluded_directory_names: vec![".git".into(), "node_modules".into(), "target".into()],
}
}
}
/// Filters are independent of query syntax and apply before the result limit.
/// The folder is canonicalized and includes descendants, with a path-boundary check.
#[derive(Debug, Default, Clone)]
pub struct SearchOptions {
pub folder: Option<PathBuf>,
pub extension: Option<String>,
pub min_size: Option<u64>,
pub max_size: Option<u64>,
pub modified_after_unix_ms: Option<i64>,
pub modified_before_unix_ms: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct SearchResult {
pub path: String,
pub filename: String,
pub snippet: String,
pub score: f64,
pub match_kind: String,
}
#[derive(Debug, Serialize)]
pub struct IndexStats {
pub files: usize,
pub content_files: usize,
}
pub struct SearchIndex {
connection: Connection,
database_path: Option<PathBuf>,
}
impl SearchIndex {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let mut connection = Connection::open(path).context("open search database")?;
let database_path = if path == Path::new(":memory:") {
None
} else {
Some(fs::canonicalize(path).context("resolve database path")?)
};
connection.busy_timeout(std::time::Duration::from_secs(5))?;
let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
if !(0..=SCHEMA_VERSION).contains(&version) {
bail!("unsupported database version {version}; expected {SCHEMA_VERSION}");
}
if version != SCHEMA_VERSION {
connection.execute_batch("PRAGMA journal_mode=WAL;")?;
let initialization =
connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
// Another opener may have completed initialization while we waited.
let version: i64 =
initialization.query_row("PRAGMA user_version", [], |row| row.get(0))?;
if !(0..=SCHEMA_VERSION).contains(&version) {
bail!("unsupported database version {version}");
}
initialization.execute_batch(
"
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE,
root TEXT NOT NULL, filename TEXT NOT NULL, content TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT -1, mtime INTEGER NOT NULL DEFAULT -1,
extension TEXT NOT NULL DEFAULT '');
CREATE INDEX IF NOT EXISTS files_root ON files(root);
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(filename, path, content,
content='files', content_rowid='id', tokenize='unicode61');
CREATE TRIGGER IF NOT EXISTS files_insert AFTER INSERT ON files BEGIN
INSERT INTO search(rowid, filename, path, content)
VALUES (new.id, new.filename, new.path, new.content); END;
CREATE TRIGGER IF NOT EXISTS files_delete AFTER DELETE ON files BEGIN
INSERT INTO search(search, rowid, filename, path, content)
VALUES ('delete', old.id, old.filename, old.path, old.content); END;
CREATE TRIGGER IF NOT EXISTS files_update AFTER UPDATE ON files BEGIN
INSERT INTO search(search, rowid, filename, path, content)
VALUES ('delete', old.id, old.filename, old.path, old.content);
INSERT INTO search(rowid, filename, path, content)
VALUES (new.id, new.filename, new.path, new.content); END;
",
)?;
if version == 1 {
initialization.execute_batch(
"
ALTER TABLE files ADD COLUMN size INTEGER NOT NULL DEFAULT -1;
ALTER TABLE files ADD COLUMN mtime INTEGER NOT NULL DEFAULT -1;
ALTER TABLE files ADD COLUMN extension TEXT NOT NULL DEFAULT '';
PRAGMA user_version=2;",
)?;
} else {
initialization.execute_batch("PRAGMA user_version=2;")?;
}
initialization.commit()?;
}
Ok(Self {
connection,
database_path,
})
}
/// Reconcile one explicitly selected root. Symlinks are never followed.
/// Every file receives a filename record, including unsupported formats.
pub fn index_folder(&mut self, root: impl AsRef<Path>) -> Result<IndexReport> {
self.index_folder_with_options(root, &IndexOptions::default())
}
pub fn index_folder_with_options(
&mut self,
root: impl AsRef<Path>,
options: &IndexOptions,
) -> Result<IndexReport> {
let root = fs::canonicalize(root).context("resolve index root")?;
if !root.is_dir() {
bail!("index root must be a directory");
}
let root_key = root.to_string_lossy().into_owned();
let transaction = self.connection.transaction()?;
transaction.execute_batch(
"CREATE TEMP TABLE IF NOT EXISTS seen_paths(path TEXT PRIMARY KEY);
DELETE FROM seen_paths;",
)?;
let mut report = IndexReport::default();
for entry in WalkDir::new(&root)
.follow_links(false)
.into_iter()
.filter_entry(|entry| {
entry.depth() == 0
|| !entry.file_type().is_dir()
|| !options.excluded_directory_names.iter().any(|name| {
entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(name)
})
})
{
let entry = entry.context("directory traversal failed; reconciliation rolled back")?;
if !entry.file_type().is_file() {
continue;
}
if let Some(database) = &self.database_path {
let path_key = entry.path().to_string_lossy();
let db_key = database.to_string_lossy();
if path_key == db_key
|| path_key == format!("{db_key}-wal")
|| path_key == format!("{db_key}-shm")
{
continue;
}
}
let path = entry.path().to_string_lossy().into_owned();
let filename = entry.file_name().to_string_lossy().into_owned();
transaction.execute("INSERT INTO seen_paths(path) VALUES (?1)", [&path])?;
report.discovered += 1;
let metadata = entry.metadata()?;
let size = i64::try_from(metadata.len()).context("file exceeds supported size")?;
let modified = metadata
.modified()?
.duration_since(UNIX_EPOCH)
.context("file timestamp predates Unix epoch")?;
let mtime = i64::try_from(modified.as_nanos())
.context("file timestamp exceeds supported range")?;
let extension = entry
.path()
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let previous: Option<(i64, i64, String)> = transaction
.query_row(
"SELECT size,mtime,root FROM files WHERE path=?1",
[&path],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.optional()?;
if previous == Some((size, mtime, root_key.clone())) {
report.unchanged += 1;
continue;
}
let content = extract_text(entry.path()).with_context(|| format!("read {path}"))?;
if content.is_none() {
report.skipped += 1;
}
transaction.execute(
"INSERT INTO files(path,root,filename,content,size,mtime,extension) VALUES (?1,?2,?3,?4,?5,?6,?7)
ON CONFLICT(path) DO UPDATE SET root=excluded.root, filename=excluded.filename,
content=excluded.content, size=excluded.size, mtime=excluded.mtime,
extension=excluded.extension",
params![path, root_key, filename, content.unwrap_or_default(), size, mtime, extension],
)?;
report.indexed += 1;
}
report.removed = transaction.execute(
"DELETE FROM files WHERE root=?1
AND NOT EXISTS (SELECT 1 FROM seen_paths WHERE seen_paths.path=files.path)",
[&root_key],
)?;
transaction.execute("DELETE FROM seen_paths", [])?;
transaction.commit()?;
Ok(report)
}
/// Literal input is escaped before FTS parsing; user text cannot inject operators.
/// Exact filenames precede partial filenames, which precede content matches.
/// Filename substring matching currently scans metadata; a dedicated substring
/// index is required before claiming million-file latency targets.
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
self.search_with_options(query, limit, &SearchOptions::default())
}
pub fn search_with_options(
&self,
query: &str,
limit: usize,
options: &SearchOptions,
) -> Result<Vec<SearchResult>> {
if options
.min_size
.zip(options.max_size)
.is_some_and(|(min, max)| min > max)
{
bail!("minimum size exceeds maximum size");
}
if options
.modified_after_unix_ms
.zip(options.modified_before_unix_ms)
.is_some_and(|(min, max)| min > max)
{
bail!("modified-after exceeds modified-before");
}
let folder = options
.folder
.as_ref()
.map(fs::canonicalize)
.transpose()?
.map(|path| path.to_string_lossy().into_owned());
let folder_prefix = folder.as_ref().map(|value| {
format!(
"{}{}",
value.trim_end_matches(['/', '\\']),
std::path::MAIN_SEPARATOR
)
});
let extension = options
.extension
.as_ref()
.map(|value| value.trim_start_matches('.').to_ascii_lowercase());
let min_size = options
.min_size
.map(i64::try_from)
.transpose()
.context("minimum size exceeds supported range")?;
let max_size = options
.max_size
.map(i64::try_from)
.transpose()
.context("maximum size exceeds supported range")?;
let query = query.trim();
if query.is_empty() || limit == 0 {
return Ok(Vec::new());
}
if query.len() > 4096 {
bail!("query exceeds 4096 bytes");
}
let fts = query
.split_whitespace()
.map(|term| format!("\"{}\"", term.replace('"', "\"\"")))
.collect::<Vec<_>>()
.join(" AND ");
let mut statement = self.connection.prepare(
"WITH hits AS (
SELECT rowid AS id, bm25(search, 8.0, 2.0, 1.0) AS rank,
snippet(search, 2, '', '', ' … ', 24) AS excerpt
FROM search WHERE search MATCH ?1)
SELECT f.path, f.filename, COALESCE(h.excerpt, ''), COALESCE(h.rank, 0),
CASE WHEN lower(f.filename)=lower(?2) THEN 0
WHEN instr(lower(f.filename), lower(?2)) > 0 THEN 1 ELSE 2 END AS tier
FROM files f LEFT JOIN hits h ON h.id=f.id
WHERE (h.id IS NOT NULL OR instr(lower(f.filename), lower(?2)) > 0)
AND (?4 IS NULL OR substr(f.path,1,length(?4))=?4)
AND (?5 IS NULL OR f.extension=?5)
AND (?6 IS NULL OR f.size>=?6) AND (?7 IS NULL OR f.size<=?7)
AND (?8 IS NULL OR f.mtime/1000000>=?8) AND (?9 IS NULL OR f.mtime/1000000<=?9)
ORDER BY tier, h.rank, f.path LIMIT ?3",
)?;
let results = statement
.query_map(
params![
fts,
query,
limit.min(MAX_RESULTS) as i64,
folder_prefix,
extension,
min_size,
max_size,
options.modified_after_unix_ms,
options.modified_before_unix_ms
],
|row| {
let tier: i64 = row.get(4)?;
Ok(SearchResult {
path: row.get(0)?,
filename: row.get(1)?,
snippet: row.get(2)?,
score: -row.get::<_, f64>(3)?,
match_kind: match tier {
0 => "exact_filename",
1 => "filename",
_ => "content",
}
.to_owned(),
})
},
)?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(results)
}
pub fn stats(&self) -> Result<IndexStats> {
Ok(self.connection.query_row(
"SELECT count(*), count(NULLIF(content, '')) FROM files",
[],
|row| {
Ok(IndexStats {
files: row.get(0)?,
content_files: row.get(1)?,
})
},
)?)
}
}
fn extract_text(path: &Path) -> Result<Option<String>> {
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if !matches!(
extension.as_str(),
"txt"
| "md"
| "rs"
| "py"
| "js"
| "ts"
| "tsx"
| "jsx"
| "json"
| "toml"
| "yaml"
| "yml"
| "csv"
| "html"
| "css"
| "xml"
| "log"
| "sh"
) {
return Ok(None);
}
let file = fs::File::open(path)?;
if file.metadata()?.len() > MAX_TEXT_BYTES {
return Ok(None);
}
let mut bytes = Vec::new();
file.take(MAX_TEXT_BYTES + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_TEXT_BYTES || bytes.contains(&0) {
return Ok(None);
}
Ok(String::from_utf8(bytes).ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn persistence_updates_deletes_and_exact_priority() -> Result<()> {
let folder = tempfile::tempdir()?;
let root = folder.path().join("docs");
fs::create_dir(&root)?;
let db = folder.path().join("index.db");
fs::write(root.join("contract.txt"), "supplier cancellation agreement")?;
fs::write(root.join("other.txt"), "contract contract contract")?;
let mut index = SearchIndex::open(&db)?;
assert_eq!(index.index_folder(&root)?.indexed, 2);
assert_eq!(
index.search("contract.txt", 10)?[0].match_kind,
"exact_filename"
);
assert_eq!(index.search("cancellation", 10)?.len(), 1);
drop(index);
let mut index = SearchIndex::open(&db)?;
assert_eq!(index.stats()?.files, 2);
fs::write(root.join("contract.txt"), "replacement document")?;
fs::rename(root.join("other.txt"), root.join("renamed.txt"))?;
let report = index.index_folder(&root)?;
assert_eq!(report.removed, 1);
assert!(index.search("cancellation", 10)?.is_empty());
assert!(index.search("other.txt", 10)?.is_empty());
assert_eq!(index.search("renamed.txt", 10)?.len(), 1);
fs::remove_file(root.join("contract.txt"))?;
index.index_folder(&root)?;
assert_eq!(index.stats()?.files, 1);
Ok(())
}
#[test]
fn safe_queries_limits_and_unsupported_files() -> Result<()> {
let root = tempfile::tempdir()?;
fs::write(
root.path().join("notes.md"),
"German Kündigung and English cancellation",
)?;
fs::write(root.path().join("photo.jpg"), [0, 1, 2])?;
let mut index = SearchIndex::open(":memory:")?;
assert_eq!(index.index_folder(root.path())?.skipped, 1);
assert_eq!(index.search("Kündigung", 1)?.len(), 1);
assert!(index.search("\" OR *", 10)?.is_empty());
assert!(index.search("", 10)?.is_empty());
assert!(index.search("notes", 0)?.is_empty());
assert_eq!(index.search("photo.jpg", 10)?.len(), 1);
Ok(())
}
#[test]
fn invalid_root_preserves_index_and_unknown_schema_is_rejected() -> Result<()> {
let folder = tempfile::tempdir()?;
fs::write(folder.path().join("notes.txt"), "persistent evidence")?;
let mut index = SearchIndex::open(":memory:")?;
index.index_folder(folder.path())?;
assert!(index.index_folder(folder.path().join("missing")).is_err());
assert_eq!(index.search("evidence", 10)?.len(), 1);
let db = folder.path().join("future.db");
let connection = Connection::open(&db)?;
connection.execute_batch("PRAGMA user_version=999")?;
drop(connection);
assert!(SearchIndex::open(db).is_err());
Ok(())
}
}
+136
View File
@@ -0,0 +1,136 @@
use anyhow::Result;
use searchi_core::{IndexOptions, SearchIndex, SearchOptions};
use std::fs;
#[test]
fn exclusions_database_and_unchanged_metadata() -> Result<()> {
let root = tempfile::tempdir()?;
for directory in [".git", "node_modules", "target", "private"] {
fs::create_dir(root.path().join(directory))?;
fs::write(root.path().join(directory).join("hidden.txt"), "secret")?;
}
fs::write(root.path().join("visible.txt"), "original content")?;
let mut index = SearchIndex::open(root.path().join("index.db"))?;
let options = IndexOptions {
excluded_directory_names: vec![
".git".into(),
"node_modules".into(),
"target".into(),
"private".into(),
],
};
assert_eq!(
index
.index_folder_with_options(root.path(), &options)?
.indexed,
1
);
assert_eq!(index.stats()?.files, 1);
let unchanged = index.index_folder_with_options(root.path(), &options)?;
assert_eq!(unchanged.unchanged, 1);
assert_eq!(unchanged.indexed, 0);
assert!(index.search("secret", 10)?.is_empty());
assert!(index.search("index.db", 10)?.is_empty());
fs::write(
root.path().join("visible.txt"),
"changed and longer content",
)?;
assert_eq!(
index
.index_folder_with_options(root.path(), &options)?
.indexed,
1
);
assert!(index.search("original", 10)?.is_empty());
assert_eq!(index.search("changed", 10)?.len(), 1);
// A new exclusion removes previously indexed entries from this root.
let mut options = IndexOptions::default();
index.index_folder_with_options(root.path(), &options)?;
assert_eq!(index.search("secret", 10)?.len(), 1);
options.excluded_directory_names.push("private".into());
assert_eq!(
index
.index_folder_with_options(root.path(), &options)?
.removed,
1
);
Ok(())
}
#[test]
fn filters_apply_before_limit_and_respect_folder_boundaries() -> Result<()> {
let root = tempfile::tempdir()?;
let docs = root.path().join("docs");
let sibling = root.path().join("docs-more");
fs::create_dir(&docs)?;
fs::create_dir(&sibling)?;
fs::write(docs.join("shared.md"), "shared short")?;
fs::write(docs.join("shared.txt"), "shared considerably longer")?;
fs::write(sibling.join("shared.md"), "shared sibling")?;
let mut index = SearchIndex::open(":memory:")?;
index.index_folder(root.path())?;
let options = SearchOptions {
folder: Some(docs),
extension: Some(".MD".into()),
max_size: Some(15),
..Default::default()
};
let results = index.search_with_options("shared", 1, &options)?;
assert_eq!(results.len(), 1);
assert_eq!(results[0].filename, "shared.md");
assert!(!results[0].path.contains("docs-more"));
let options = SearchOptions {
min_size: Some(20),
..Default::default()
};
assert_eq!(index.search_with_options("shared", 10, &options)?.len(), 1);
let options = SearchOptions {
modified_before_unix_ms: Some(0),
..Default::default()
};
assert!(index
.search_with_options("shared", 10, &options)?
.is_empty());
let options = SearchOptions {
min_size: Some(20),
max_size: Some(10),
..Default::default()
};
assert!(index.search_with_options("shared", 10, &options).is_err());
Ok(())
}
#[test]
fn migrates_v1_database_without_losing_search_records() -> Result<()> {
let root = tempfile::tempdir()?;
let db = root.path().join("legacy.db");
let connection = rusqlite::Connection::open(&db)?;
connection.execute_batch(
"CREATE TABLE files(id INTEGER PRIMARY KEY, path TEXT NOT NULL UNIQUE,
root TEXT NOT NULL, filename TEXT NOT NULL, content TEXT NOT NULL);
PRAGMA user_version=1;",
)?;
drop(connection);
let mut index = SearchIndex::open(&db)?;
fs::write(root.path().join("sample.txt"), "migration evidence")?;
index.index_folder(root.path())?;
assert_eq!(index.search("evidence", 10)?.len(), 1);
Ok(())
}
#[test]
fn readers_open_and_search_during_uncommitted_writer() -> Result<()> {
let root = tempfile::tempdir()?;
let db = root.path().join("index.db");
fs::write(root.path().join("notes.txt"), "committed evidence")?;
let mut index = SearchIndex::open(&db)?;
index.index_folder(root.path())?;
let writer = rusqlite::Connection::open(&db)?;
writer.execute_batch("BEGIN IMMEDIATE; UPDATE files SET content='uncommitted replacement';")?;
// Opening a current schema must not issue DDL or acquire a write lock.
let reader = SearchIndex::open(&db)?;
assert_eq!(reader.search("evidence", 10)?.len(), 1);
assert!(reader.search("replacement", 10)?.is_empty());
writer.execute_batch("ROLLBACK;")?;
Ok(())
}