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

+16
View File
@@ -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
+28
View File
@@ -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.
+153
View File
@@ -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,
..
}
));
}
}
+90
View File
@@ -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"));
}