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

+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,
..
}
));
}
}