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"));
|
||||
}
|
||||
Reference in new issue
Block a user