2026-10-12 19:16:23 +02:00
|
|
|
|
//! Thin headless adapter around the shared local search engine.
|
2026-10-12 15:51:39 +02:00
|
|
|
|
|
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
|
|
|
|
use clap::{Parser, Subcommand};
|
2026-10-12 19:16:23 +02:00
|
|
|
|
use searchi_core::metrics::{MetricsMode, SearchResponse, TraceCollector};
|
2026-10-12 16:07:53 +02:00
|
|
|
|
use searchi_core::{IndexOptions, SearchIndex, SearchOptions};
|
2026-10-12 17:44:56 +02:00
|
|
|
|
use searchi_service::HybridSearch;
|
2026-10-12 20:28:38 +02:00
|
|
|
|
mod mcp;
|
2026-10-12 15:51:39 +02:00
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Parser)]
|
|
|
|
|
|
#[command(name = "searchi", version, about = "Search your local files offline")]
|
|
|
|
|
|
struct Cli {
|
|
|
|
|
|
#[command(subcommand)]
|
|
|
|
|
|
command: Command,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
|
|
|
|
enum Command {
|
2026-10-12 20:28:38 +02:00
|
|
|
|
/// Serve read-only local agent tools over MCP stdio (no HTTP listener).
|
|
|
|
|
|
Mcp {
|
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
|
db: PathBuf,
|
|
|
|
|
|
#[arg(long, requires = "embedding_worker")]
|
|
|
|
|
|
model_dir: Option<PathBuf>,
|
|
|
|
|
|
#[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,
|
|
|
|
|
|
},
|
2026-10-12 17:44:56 +02:00
|
|
|
|
/// 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>,
|
|
|
|
|
|
},
|
2026-10-12 15:51:39 +02:00
|
|
|
|
/// 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,
|
2026-10-12 16:07:53 +02:00
|
|
|
|
/// Additional directory names to exclude; repeat this flag as needed.
|
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
|
exclude: Vec<String>,
|
2026-10-12 15:51:39 +02:00
|
|
|
|
},
|
|
|
|
|
|
/// 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,
|
2026-10-12 19:16:23 +02:00
|
|
|
|
/// Include basic measured statistics in JSON output.
|
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
|
performance: bool,
|
|
|
|
|
|
/// Include bounded granular stage timings and diagnostic counters.
|
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
|
debug_performance: bool,
|
2026-10-12 16:07:53 +02:00
|
|
|
|
/// Restrict results to this folder and descendants.
|
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
|
folder: Option<PathBuf>,
|
2026-10-12 21:22:11 +02:00
|
|
|
|
/// Search indexed source-code files only; supports symbols and semantic code queries.
|
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
|
code_only: bool,
|
2026-10-12 16:07:53 +02:00
|
|
|
|
/// 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>,
|
2026-10-12 19:16:23 +02:00
|
|
|
|
/// Inclusive media duration limit in milliseconds.
|
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
|
min_duration_ms: Option<u32>,
|
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
|
max_duration_ms: Option<u32>,
|
2026-10-12 16:07:53 +02:00
|
|
|
|
/// Inclusive modification time in Unix milliseconds.
|
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
|
modified_after_ms: Option<i64>,
|
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
|
modified_before_ms: Option<i64>,
|
2026-10-12 17:44:56 +02:00
|
|
|
|
/// 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,
|
2026-10-12 19:16:23 +02:00
|
|
|
|
/// Standalone decoder; normally discovered beside the installed CLI.
|
|
|
|
|
|
#[arg(long, requires = "ffprobe")]
|
|
|
|
|
|
ffmpeg: Option<PathBuf>,
|
|
|
|
|
|
#[arg(long, requires = "ffmpeg")]
|
|
|
|
|
|
ffprobe: Option<PathBuf>,
|
|
|
|
|
|
/// Explicitly build only document vectors, without media.
|
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
|
text_only: bool,
|
2026-10-12 15:51:39 +02:00
|
|
|
|
},
|
|
|
|
|
|
/// Show the indexed file and content counts.
|
|
|
|
|
|
Stats {
|
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
|
db: PathBuf,
|
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
|
json: bool,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn main() -> Result<()> {
|
|
|
|
|
|
match Cli::parse().command {
|
2026-10-12 20:28:38 +02:00
|
|
|
|
Command::Mcp {
|
|
|
|
|
|
db,
|
|
|
|
|
|
model_dir,
|
|
|
|
|
|
embedding_worker,
|
|
|
|
|
|
cpu_threads,
|
|
|
|
|
|
} => {
|
|
|
|
|
|
require_existing_index(&db)?;
|
|
|
|
|
|
mcp::serve(db, model_dir, embedding_worker, usize::from(cpu_threads))?;
|
|
|
|
|
|
}
|
2026-10-12 17:44:56 +02:00
|
|
|
|
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());
|
|
|
|
|
|
}
|
2026-10-12 16:07:53 +02:00
|
|
|
|
Command::Index { db, root, exclude } => {
|
|
|
|
|
|
let mut options = IndexOptions::default();
|
|
|
|
|
|
options.excluded_directory_names.extend(exclude);
|
2026-10-12 15:51:39 +02:00
|
|
|
|
let mut index = SearchIndex::open(&db)
|
|
|
|
|
|
.with_context(|| format!("Cannot open index {}", db.display()))?;
|
|
|
|
|
|
let report = index
|
2026-10-12 16:07:53 +02:00
|
|
|
|
.index_folder_with_options(&root, &options)
|
2026-10-12 15:51:39 +02:00
|
|
|
|
.with_context(|| format!("Cannot index folder {}", root.display()))?;
|
2026-10-12 17:44:56 +02:00
|
|
|
|
index
|
|
|
|
|
|
.prewarm()
|
|
|
|
|
|
.context("Cannot prepare search statistics")?;
|
2026-10-12 15:51:39 +02:00
|
|
|
|
println!(
|
2026-10-12 16:07:53 +02:00
|
|
|
|
"Discovered: {} Indexed: {} Unchanged: {} Removed: {} Skipped: {} Extraction failures: {}",
|
|
|
|
|
|
report.discovered, report.indexed, report.unchanged, report.removed, report.skipped, report.failures
|
2026-10-12 15:51:39 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
Command::Search {
|
|
|
|
|
|
db,
|
|
|
|
|
|
query,
|
|
|
|
|
|
limit,
|
|
|
|
|
|
json,
|
2026-10-12 19:16:23 +02:00
|
|
|
|
performance,
|
|
|
|
|
|
debug_performance,
|
2026-10-12 16:07:53 +02:00
|
|
|
|
folder,
|
2026-10-12 21:22:11 +02:00
|
|
|
|
code_only,
|
2026-10-12 16:07:53 +02:00
|
|
|
|
extension,
|
|
|
|
|
|
min_size,
|
|
|
|
|
|
max_size,
|
2026-10-12 19:16:23 +02:00
|
|
|
|
min_duration_ms,
|
|
|
|
|
|
max_duration_ms,
|
2026-10-12 16:07:53 +02:00
|
|
|
|
modified_after_ms,
|
|
|
|
|
|
modified_before_ms,
|
2026-10-12 17:44:56 +02:00
|
|
|
|
model_dir,
|
|
|
|
|
|
embedding_worker,
|
|
|
|
|
|
cpu_threads,
|
2026-10-12 15:51:39 +02:00
|
|
|
|
} => {
|
|
|
|
|
|
if query.trim().is_empty() {
|
|
|
|
|
|
bail!("Search text cannot be empty");
|
|
|
|
|
|
}
|
|
|
|
|
|
require_existing_index(&db)?;
|
2026-10-12 17:44:56 +02:00
|
|
|
|
let options = SearchOptions {
|
|
|
|
|
|
folder,
|
2026-10-12 21:22:11 +02:00
|
|
|
|
code_only,
|
2026-10-12 17:44:56 +02:00
|
|
|
|
extension,
|
|
|
|
|
|
min_size,
|
|
|
|
|
|
max_size,
|
2026-10-12 19:16:23 +02:00
|
|
|
|
min_duration_ms,
|
|
|
|
|
|
max_duration_ms,
|
2026-10-12 17:44:56 +02:00
|
|
|
|
modified_after_unix_ms: modified_after_ms,
|
|
|
|
|
|
modified_before_unix_ms: modified_before_ms,
|
|
|
|
|
|
};
|
2026-10-12 19:16:23 +02:00
|
|
|
|
let mut trace = TraceCollector::new(if debug_performance {
|
|
|
|
|
|
MetricsMode::Debug
|
|
|
|
|
|
} else {
|
|
|
|
|
|
MetricsMode::Basic
|
|
|
|
|
|
});
|
2026-10-12 17:44:56 +02:00
|
|
|
|
let results = if let (Some(model), Some(runtime)) = (model_dir, embedding_worker) {
|
2026-10-12 19:16:23 +02:00
|
|
|
|
let mut service = trace.timed("cli.session_initialize", None, |_, _| {
|
2026-10-13 07:30:40 +02:00
|
|
|
|
HybridSearch::open_query(&db, model, runtime, usize::from(cpu_threads))
|
2026-10-12 19:16:23 +02:00
|
|
|
|
})?;
|
|
|
|
|
|
service
|
|
|
|
|
|
.search_traced(&query, usize::from(limit), &options, &mut trace, None)
|
2026-10-12 17:44:56 +02:00
|
|
|
|
.context("Hybrid search failed")?
|
|
|
|
|
|
} else {
|
2026-10-12 19:16:23 +02:00
|
|
|
|
let index = trace
|
|
|
|
|
|
.timed("cli.index_open", None, |_, _| SearchIndex::open(&db))
|
|
|
|
|
|
.with_context(|| format!("Cannot open index {}", db.display()))?;
|
|
|
|
|
|
index
|
|
|
|
|
|
.search_with_options_traced(
|
|
|
|
|
|
&query,
|
|
|
|
|
|
usize::from(limit),
|
|
|
|
|
|
&options,
|
|
|
|
|
|
&mut trace,
|
|
|
|
|
|
None,
|
|
|
|
|
|
)
|
2026-10-12 17:44:56 +02:00
|
|
|
|
.context("Search failed")?
|
|
|
|
|
|
};
|
2026-10-12 19:16:23 +02:00
|
|
|
|
let response = SearchResponse {
|
|
|
|
|
|
metrics: trace.finish(results.len()),
|
|
|
|
|
|
results,
|
|
|
|
|
|
};
|
2026-10-12 15:51:39 +02:00
|
|
|
|
if json {
|
2026-10-12 19:16:23 +02:00
|
|
|
|
if performance || debug_performance {
|
|
|
|
|
|
println!("{}", serde_json::to_string_pretty(&response)?);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
println!("{}", serde_json::to_string_pretty(&response.results)?);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if response.results.is_empty() {
|
2026-10-12 15:51:39 +02:00
|
|
|
|
println!("No matching files.");
|
|
|
|
|
|
} else {
|
2026-10-12 19:16:23 +02:00
|
|
|
|
for result in &response.results {
|
2026-10-12 15:51:39 +02:00
|
|
|
|
println!("{} [{}]", result.path, result.match_kind);
|
2026-10-12 19:16:23 +02:00
|
|
|
|
if let Some(source) = &result.source {
|
2026-10-12 16:07:53 +02:00
|
|
|
|
println!(" {} {}", source.kind, source.number);
|
|
|
|
|
|
}
|
2026-10-12 19:16:23 +02:00
|
|
|
|
if let Some(error) = &result.extraction_error {
|
2026-10-12 16:07:53 +02:00
|
|
|
|
println!(" Text extraction unavailable: {error}");
|
|
|
|
|
|
}
|
2026-10-12 15:51:39 +02:00
|
|
|
|
if !result.snippet.is_empty() {
|
|
|
|
|
|
println!(" {}", result.snippet.replace(['\r', '\n'], " "));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-12 19:16:23 +02:00
|
|
|
|
if !json {
|
|
|
|
|
|
eprintln!(
|
|
|
|
|
|
"{} results · {:.2} ms (includes CLI initialization)",
|
|
|
|
|
|
response.metrics.result_count, response.metrics.total_ms
|
|
|
|
|
|
);
|
|
|
|
|
|
if debug_performance {
|
|
|
|
|
|
eprintln!("{}", serde_json::to_string_pretty(&response.metrics)?);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-12 15:51:39 +02:00
|
|
|
|
}
|
2026-10-12 17:44:56 +02:00
|
|
|
|
Command::Embed {
|
|
|
|
|
|
db,
|
|
|
|
|
|
model_dir,
|
|
|
|
|
|
embedding_worker,
|
|
|
|
|
|
cpu_threads,
|
2026-10-12 19:16:23 +02:00
|
|
|
|
ffmpeg,
|
|
|
|
|
|
ffprobe,
|
|
|
|
|
|
text_only,
|
2026-10-12 17:44:56 +02:00
|
|
|
|
} => {
|
|
|
|
|
|
require_existing_index(&db)?;
|
2026-10-12 19:16:23 +02:00
|
|
|
|
let mut service =
|
|
|
|
|
|
HybridSearch::open(&db, model_dir, embedding_worker, usize::from(cpu_threads))?;
|
|
|
|
|
|
let report = if text_only {
|
|
|
|
|
|
service.index_embeddings()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let tools = resolve_media_tools(ffmpeg, ffprobe)?;
|
|
|
|
|
|
service
|
|
|
|
|
|
.index_embeddings_with_media(&tools, &searchi_service::MediaLimits::default())
|
|
|
|
|
|
}
|
|
|
|
|
|
.context("Semantic indexing failed; prior generation remains active")?;
|
2026-10-12 17:44:56 +02:00
|
|
|
|
println!("{}", serde_json::to_string_pretty(&report)?);
|
|
|
|
|
|
}
|
2026-10-12 15:51:39 +02:00
|
|
|
|
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(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 19:16:23 +02:00
|
|
|
|
fn resolve_media_tools(
|
|
|
|
|
|
ffmpeg: Option<PathBuf>,
|
|
|
|
|
|
ffprobe: Option<PathBuf>,
|
|
|
|
|
|
) -> Result<searchi_service::FfmpegTools> {
|
|
|
|
|
|
if let (Some(ffmpeg), Some(ffprobe)) = (ffmpeg, ffprobe) {
|
|
|
|
|
|
return searchi_service::FfmpegTools::new(ffmpeg, ffprobe);
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(tools) = searchi_service::FfmpegTools::from_environment()? {
|
|
|
|
|
|
return Ok(tools);
|
|
|
|
|
|
}
|
|
|
|
|
|
let development =
|
|
|
|
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.searchi/media-runtime/bin");
|
|
|
|
|
|
let directory = if cfg!(debug_assertions) && development.is_dir() {
|
|
|
|
|
|
development
|
|
|
|
|
|
} else {
|
|
|
|
|
|
std::env::current_exe()?
|
|
|
|
|
|
.parent()
|
|
|
|
|
|
.context("CLI installation directory is unavailable")?
|
|
|
|
|
|
.join("media-runtime/bin")
|
|
|
|
|
|
};
|
|
|
|
|
|
searchi_service::FfmpegTools::new(
|
|
|
|
|
|
directory.join(if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }),
|
|
|
|
|
|
directory.join(if cfg!(windows) { "ffprobe.exe" } else { "ffprobe" }),
|
|
|
|
|
|
).context("Media tools are missing from this installation; repair the headless package or explicitly use --text-only")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 15:51:39 +02:00
|
|
|
|
/// 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());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 17:44:56 +02:00
|
|
|
|
#[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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 15:51:39 +02:00
|
|
|
|
#[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,
|
|
|
|
|
|
..
|
|
|
|
|
|
}
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|