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
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:
1 parent
aae803771c
commit
f77112db97
19 files changed
+1688
No files matched your search
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "searchi-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Local, offline Searchi indexing and search command line interface"
|
||||
|
||||
[[bin]]
|
||||
name = "searchi"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
searchi-core = { path = "../searchi-core" }
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,28 @@
|
||||
# Searchi CLI
|
||||
|
||||
The CLI is a thin adapter over `searchi-core`; it performs no network requests or
|
||||
GPU setup. Index only folders you explicitly choose.
|
||||
|
||||
```powershell
|
||||
cargo run -p searchi-cli -- index --db ./searchi.db --root ./documents
|
||||
cargo run -p searchi-cli -- search --db ./searchi.db "termination clause" --limit 10
|
||||
cargo run -p searchi-cli -- search --db ./searchi.db "invoice" --json
|
||||
cargo run -p searchi-cli -- stats --db ./searchi.db --json
|
||||
```
|
||||
|
||||
The same arguments work on Linux and macOS. After building, invoke the `searchi`
|
||||
binary directly instead of `cargo run -p searchi-cli --`.
|
||||
|
||||
`index` creates the database if needed; its parent directory must already exist.
|
||||
`search` and `stats` require an existing database so a mistyped path does not
|
||||
silently create an empty index. Failures exit with a nonzero status and include
|
||||
the affected folder or database path. Result limits must be between 1 and 1000.
|
||||
|
||||
`search --json` emits an array of engine results, including the path, filename,
|
||||
snippet, ranking score, and match kind. An empty search emits `[]`. `stats --json`
|
||||
emits an object with `files` and `content_files`. JSON is the scripting interface;
|
||||
the human-readable output may change as preview support develops.
|
||||
|
||||
The foundation CLI searches filenames and extracted text. Semantic inference,
|
||||
document formats beyond the core extractor, and desktop integration remain
|
||||
tracked in the project roadmap; this interface does not fabricate embeddings.
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Thin command-line adapter around the shared, CPU-only search engine.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use searchi_core::SearchIndex;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "searchi", version, about = "Search your local files offline")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Discover files and refresh an index for one folder.
|
||||
Index {
|
||||
/// SQLite index file; parent directory must exist.
|
||||
#[arg(long)]
|
||||
db: PathBuf,
|
||||
/// Folder to index recursively.
|
||||
#[arg(long)]
|
||||
root: PathBuf,
|
||||
},
|
||||
/// Search filenames and indexed document content.
|
||||
Search {
|
||||
#[arg(long)]
|
||||
db: PathBuf,
|
||||
/// Search text; quote phrases containing spaces in your shell.
|
||||
query: String,
|
||||
/// Maximum number of returned results (1–1000).
|
||||
#[arg(long, default_value_t = 20, value_parser = clap::value_parser!(u16).range(1..=1000))]
|
||||
limit: u16,
|
||||
/// Emit a JSON array for scripting.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Show the indexed file and content counts.
|
||||
Stats {
|
||||
#[arg(long)]
|
||||
db: PathBuf,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
match Cli::parse().command {
|
||||
Command::Index { db, root } => {
|
||||
let mut index = SearchIndex::open(&db)
|
||||
.with_context(|| format!("Cannot open index {}", db.display()))?;
|
||||
let report = index
|
||||
.index_folder(&root)
|
||||
.with_context(|| format!("Cannot index folder {}", root.display()))?;
|
||||
println!(
|
||||
"Discovered: {} Indexed: {} Removed: {} Skipped: {}",
|
||||
report.discovered, report.indexed, report.removed, report.skipped
|
||||
);
|
||||
}
|
||||
Command::Search {
|
||||
db,
|
||||
query,
|
||||
limit,
|
||||
json,
|
||||
} => {
|
||||
if query.trim().is_empty() {
|
||||
bail!("Search text cannot be empty");
|
||||
}
|
||||
require_existing_index(&db)?;
|
||||
let index = SearchIndex::open(&db)
|
||||
.with_context(|| format!("Cannot open index {}", db.display()))?;
|
||||
let results = index
|
||||
.search(&query, usize::from(limit))
|
||||
.context("Search failed")?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&results)?);
|
||||
} else if results.is_empty() {
|
||||
println!("No matching files.");
|
||||
} else {
|
||||
for result in results {
|
||||
println!("{} [{}]", result.path, result.match_kind);
|
||||
if !result.snippet.is_empty() {
|
||||
println!(" {}", result.snippet.replace(['\r', '\n'], " "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Command::Stats { db, json } => {
|
||||
require_existing_index(&db)?;
|
||||
let index = SearchIndex::open(&db)
|
||||
.with_context(|| format!("Cannot open index {}", db.display()))?;
|
||||
let stats = index.stats().context("Cannot read index statistics")?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&stats)?);
|
||||
} else {
|
||||
println!(
|
||||
"Files: {} Files with searchable content: {}",
|
||||
stats.files, stats.content_files
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read commands must not silently create a new empty database after a typo.
|
||||
fn require_existing_index(db: &std::path::Path) -> Result<()> {
|
||||
if !db.is_file() {
|
||||
bail!(
|
||||
"Index {} does not exist; run `searchi index --db <path> --root <folder>` first",
|
||||
db.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_or_excessive_result_limits() {
|
||||
for limit in ["0", "1001"] {
|
||||
assert!(Cli::try_parse_from([
|
||||
"searchi", "search", "--db", "index.db", "hello", "--limit", limit
|
||||
])
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_json_search_and_quoted_query() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"searchi",
|
||||
"search",
|
||||
"--db",
|
||||
"index.db",
|
||||
"termination clause",
|
||||
"--json",
|
||||
])
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
cli.command,
|
||||
Command::Search {
|
||||
json: true,
|
||||
limit: 20,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Exercise the executable boundary: exit status, durable indexing, and JSON.
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
process::{Command, Output},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"searchi-cli-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_ID.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn run(&self, args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_searchi"))
|
||||
.current_dir(&self.0)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexes_persists_and_returns_search_and_stats_json() {
|
||||
let fixture = Fixture::new();
|
||||
fs::create_dir(fixture.0.join("documents")).unwrap();
|
||||
fs::write(
|
||||
fixture.0.join("documents/contract.txt"),
|
||||
"Supplier termination clause requires sixty days notice.",
|
||||
)
|
||||
.unwrap();
|
||||
let indexed = fixture.run(&["index", "--db", "index.db", "--root", "documents"]);
|
||||
assert!(
|
||||
indexed.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&indexed.stderr)
|
||||
);
|
||||
|
||||
// Each command is a new process, so retrieval proves the index survived exit.
|
||||
let searched = fixture.run(&["search", "--db", "index.db", "termination", "--json"]);
|
||||
assert!(
|
||||
searched.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&searched.stderr)
|
||||
);
|
||||
let results: serde_json::Value = serde_json::from_slice(&searched.stdout).unwrap();
|
||||
let results = results.as_array().unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0]["filename"], "contract.txt");
|
||||
assert!(results[0]["snippet"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("termination"));
|
||||
|
||||
let stats = fixture.run(&["stats", "--db", "index.db", "--json"]);
|
||||
assert!(stats.status.success());
|
||||
let stats: serde_json::Value = serde_json::from_slice(&stats.stdout).unwrap();
|
||||
assert_eq!(stats["files"], 1);
|
||||
assert_eq!(stats["content_files"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_nonzero_and_missing_database_is_not_created() {
|
||||
let fixture = Fixture::new();
|
||||
let missing = fixture.run(&["search", "--db", "missing.db", "invoice", "--json"]);
|
||||
assert!(!missing.status.success());
|
||||
assert!(String::from_utf8_lossy(&missing.stderr).contains("does not exist"));
|
||||
assert!(!fixture.0.join("missing.db").exists());
|
||||
assert!(missing.stdout.is_empty());
|
||||
|
||||
let bad_root = fixture.run(&["index", "--db", "index.db", "--root", "missing-folder"]);
|
||||
assert!(!bad_root.status.success());
|
||||
assert!(String::from_utf8_lossy(&bad_root.stderr).contains("missing-folder"));
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in new issue
Block a user