Core CI / core (ubuntu-latest) (push) Failing after 3m27s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m45s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
277 lines
9.2 KiB
Rust
277 lines
9.2 KiB
Rust
//! 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<PathBuf>,
|
||
},
|
||
/// 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<String>,
|
||
},
|
||
/// 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<PathBuf>,
|
||
/// File extension, with or without the leading dot.
|
||
#[arg(long)]
|
||
extension: Option<String>,
|
||
#[arg(long)]
|
||
min_size: Option<u64>,
|
||
#[arg(long)]
|
||
max_size: Option<u64>,
|
||
/// Inclusive modification time in Unix milliseconds.
|
||
#[arg(long)]
|
||
modified_after_ms: Option<i64>,
|
||
#[arg(long)]
|
||
modified_before_ms: Option<i64>,
|
||
/// Enable hybrid search with explicitly provisioned local model assets.
|
||
#[arg(long, requires = "embedding_worker")]
|
||
model_dir: Option<PathBuf>,
|
||
/// Local native Google embedding worker, required with --model-dir.
|
||
#[arg(long, requires = "model_dir")]
|
||
embedding_worker: Option<PathBuf>,
|
||
#[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 <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 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,
|
||
..
|
||
}
|
||
));
|
||
}
|
||
}
|