Files
searchi/crates/searchi-cli/src/main.rs
T
karl caf6a2f82e
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
feat: integrate Google semantic search and branded desktop launcher
2026-10-12 17:44:56 +02:00

277 lines
9.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
..
}
));
}
}