//! 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::{IndexOptions, SearchIndex, SearchOptions}; use searchi_service::HybridSearch; #[derive(Debug, Parser)] #[command(name = "searchi", version, about = "Search your local files offline")] struct Cli { #[command(subcommand)] command: Command, } #[derive(Debug, Subcommand)] enum Command { /// Set up the required Google model in the default local cache. Setup { /// Override the platform-local model cache directory. #[arg(long)] model_dir: Option, }, /// 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, /// Additional directory names to exclude; repeat this flag as needed. #[arg(long)] exclude: Vec, }, /// 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, /// Restrict results to this folder and descendants. #[arg(long)] folder: Option, /// File extension, with or without the leading dot. #[arg(long)] extension: Option, #[arg(long)] min_size: Option, #[arg(long)] max_size: Option, /// Inclusive modification time in Unix milliseconds. #[arg(long)] modified_after_ms: Option, #[arg(long)] modified_before_ms: Option, /// Enable hybrid search with explicitly provisioned local model assets. #[arg(long, requires = "embedding_worker")] model_dir: Option, /// Local native Google embedding worker, required with --model-dir. #[arg(long, requires = "model_dir")] embedding_worker: Option, #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..=8))] cpu_threads: u8, }, /// Build a persistent semantic generation from already indexed passages. Embed { #[arg(long)] db: PathBuf, #[arg(long)] model_dir: PathBuf, #[arg(long)] embedding_worker: PathBuf, #[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..=8))] cpu_threads: u8, }, /// Show the indexed file and content counts. Stats { #[arg(long)] db: PathBuf, #[arg(long)] json: bool, }, } fn main() -> Result<()> { match Cli::parse().command { Command::Setup { model_dir } => { let directory = model_dir .map(Ok) .unwrap_or_else(searchi_assets::default_model_directory)?; let progress = std::sync::atomic::AtomicU64::new(0); eprintln!("Setting up Google EmbeddingGemma 2; cached assets are reused (865 MB maximum download)."); searchi_assets::provision(&directory, &progress)?; println!("Google model ready: {}", directory.display()); } Command::Index { db, root, exclude } => { let mut options = IndexOptions::default(); options.excluded_directory_names.extend(exclude); let mut index = SearchIndex::open(&db) .with_context(|| format!("Cannot open index {}", db.display()))?; let report = index .index_folder_with_options(&root, &options) .with_context(|| format!("Cannot index folder {}", root.display()))?; index .prewarm() .context("Cannot prepare search statistics")?; println!( "Discovered: {} Indexed: {} Unchanged: {} Removed: {} Skipped: {} Extraction failures: {}", report.discovered, report.indexed, report.unchanged, report.removed, report.skipped, report.failures ); } Command::Search { db, query, limit, json, folder, extension, min_size, max_size, modified_after_ms, modified_before_ms, model_dir, embedding_worker, cpu_threads, } => { if query.trim().is_empty() { bail!("Search text cannot be empty"); } require_existing_index(&db)?; let options = SearchOptions { folder, extension, min_size, max_size, modified_after_unix_ms: modified_after_ms, modified_before_unix_ms: modified_before_ms, }; let results = if let (Some(model), Some(runtime)) = (model_dir, embedding_worker) { HybridSearch::open(&db, model, runtime, usize::from(cpu_threads))? .search(&query, usize::from(limit), &options) .context("Hybrid search failed")? } else { SearchIndex::open(&db) .with_context(|| format!("Cannot open index {}", db.display()))? .search_with_options(&query, usize::from(limit), &options) .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 let Some(source) = result.source { println!(" {} {}", source.kind, source.number); } if let Some(error) = result.extraction_error { println!(" Text extraction unavailable: {error}"); } if !result.snippet.is_empty() { println!(" {}", result.snippet.replace(['\r', '\n'], " ")); } } } } Command::Embed { db, model_dir, embedding_worker, cpu_threads, } => { require_existing_index(&db)?; let report = HybridSearch::open(&db, model_dir, embedding_worker, usize::from(cpu_threads))? .index_embeddings() .context("Semantic indexing failed; prior generation remains active")?; println!("{}", serde_json::to_string_pretty(&report)?); } 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 --root ` 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 requires_complete_semantic_configuration_and_bounded_cpu_budget() { assert!(Cli::try_parse_from([ "searchi", "search", "--db", "index.db", "query", "--model-dir", "models" ]) .is_err()); assert!(Cli::try_parse_from([ "searchi", "search", "--db", "index.db", "query", "--cpu-threads", "0" ]) .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, .. } )); } }