use crate::cli::init::ensure_initialized;
use crate::db::Database;
use crate::models::downloader::{
ensure_dense_model_available, ensure_model_available, DENSE_MODEL_ID, MODEL_ID,
};
use crate::search::{
read_metadata, write_metadata, AnnIndex, BM25SearchResult, DenseRetriever, HybridSearch,
IndexMetadata, SearchFilters, SearchMeta,
};
use anyhow::{Context, Result};
use colored::Colorize;
use serde::Serialize;
use std::collections::HashMap;
use std::env;
use std::path::Path;
pub fn run(
query: &str,
limit: usize,
json: bool,
path: Option<String>,
lang: Option<String>,
chunk_type: Option<String>,
symbol: Option<String>,
before: Option<usize>,
after: Option<usize>,
context: Option<usize>,
) -> Result<()> {
let project_root = env::current_dir()?;
ensure_initialized(&project_root, true)?;
let db = Database::open(&project_root)?;
let stats = db.get_stats()?;
if stats.chunk_count == 0 {
println!(
"{} No indexed content found. Try running {} or check that there are supported source files.",
"!".yellow(),
"minni index".cyan()
);
return Ok(());
}
let bm25_index_path = project_root.join(".minni").join("bm25_index");
let meta_path = bm25_index_path.join("minni_meta.json");
let ann_index_path = project_root.join(".minni").join("ann_index.json");
let model_path = match ensure_model_available() {
Ok(path) => Some(path),
Err(e) => {
println!(
"{} Reranker model unavailable — using BM25-only search.",
"!".yellow()
);
println!(" Reason: {}", e);
println!(
" Tip: Run {} to check model and network status.",
"minni doctor".cyan()
);
None
}
};
let dense_model_path = match ensure_dense_model_available() {
Ok(path) => Some(path),
Err(e) => {
println!(
"{} Dense model unavailable — using BM25 + optional reranking only.",
"!".yellow()
);
println!(" Reason: {}", e);
println!(
" Tip: Run {} to check model and network status.",
"minni doctor".cyan()
);
None
}
};
ensure_bm25_compatible(&bm25_index_path, &meta_path, &ann_index_path, &db)?;
let embeddings = db.get_all_embeddings().unwrap_or_default();
let chunk_lookup = build_chunk_lookup(&db)?;
let ann_index = match AnnIndex::load(&ann_index_path) {
Ok(index) => index,
Err(e) => {
println!(
"{} ANN index unavailable — falling back to dense full scan.",
"!".yellow()
);
println!(" Reason: {}", e);
None
}
};
let mut search = HybridSearch::new(
&bm25_index_path,
model_path.as_deref(),
dense_model_path.as_deref(),
ann_index,
embeddings,
chunk_lookup,
)
.context("Failed to initialize search")?;
println!("{} Searching for: {}", "→".blue(), query.cyan());
println!();
let filters = SearchFilters {
path,
language: lang,
chunk_type,
symbol,
};
let (results, meta) = if filters.is_active() {
search.search_with_filters(query, limit, Some(&filters))?
} else {
search.search(query, limit)?
};
if results.is_empty() {
println!("{} No results found.", "!".yellow());
return Ok(());
}
let (before, after) = normalize_context(before, after, context);
let max_score = results
.iter()
.map(|r| r.score)
.fold(f32::NEG_INFINITY, f32::max);
let min_score = results
.iter()
.map(|r| r.score)
.fold(f32::INFINITY, f32::min);
let score_range = (max_score - min_score).max(0.001);
if json {
let search_results: Vec<JsonSearchResult> = results
.iter()
.enumerate()
.map(|(i, result)| {
let relevance = ((result.score - min_score) / score_range * 100.0).round() as u32;
let relevance = if i == 0 { 100 } else { relevance.min(99) };
let snippet = build_snippet(&project_root, result, before, after);
let referenced_by_count = result
.symbol_name
.as_deref()
.filter(|s| !s.is_empty())
.map(|s| db.get_referenced_by_count(s))
.transpose()?
.unwrap_or(0);
Ok(JsonSearchResult {
rank: i + 1,
relevance,
chunk_id: result.chunk_id.clone(),
file_path: result.file_path.clone(),
start_line: result.start_line,
end_line: result.end_line,
chunk_type: result.chunk_type.clone(),
language: result.language.clone(),
symbol_name: result.symbol_name.clone(),
content: result.content.clone(),
snippet: JsonSnippet {
start_line: snippet.start_line,
end_line: snippet.end_line,
lines: snippet.lines,
},
parent_symbol: result.parent_symbol.clone(),
signature: result.signature.clone(),
doc_comment: result.doc_comment.clone(),
module_path: result.module_path.clone(),
referenced_by_count,
})
})
.collect::<Result<Vec<_>>>()?;
let response = JsonSearchResponse {
meta,
results: search_results,
};
println!("{}", serde_json::to_string_pretty(&response)?);
return Ok(());
}
println!(
"Search mode: {} | {} candidates | {}ms",
meta.mode.cyan(),
meta.total_candidates,
meta.search_time_ms
);
if let Some(reason) = &meta.fallback_reason {
println!("{} Reranker skipped: {}", "!".yellow(), reason);
}
println!();
for (i, result) in results.iter().enumerate() {
let relevance = ((result.score - min_score) / score_range * 100.0).round() as u32;
let relevance = if i == 0 { 100 } else { relevance.min(99) };
let score_str = format!("{}%", relevance);
let score_color = if relevance >= 70 {
score_str.green()
} else if relevance >= 40 {
score_str.yellow()
} else {
score_str.red()
};
println!(
"{} {} (relevance: {})",
format!("[{}]", i + 1).cyan(),
result.file_path.blue(),
score_color
);
let mut meta = format!(
"Lines {}-{} | {}",
result.start_line, result.end_line, result.chunk_type
);
if let Some(symbol) = &result.symbol_name {
if !symbol.is_empty() {
meta.push_str(&format!(" | {}", symbol));
}
}
if !result.language.is_empty() {
meta.push_str(&format!(" | {}", result.language));
}
println!(" {}", meta.magenta());
let snippet = build_snippet(&project_root, result, before, after);
let lines = snippet.lines;
for line in &lines {
let display_line = if line.len() > 100 {
format!("{}...", &line[..100])
} else {
line.to_string()
};
println!(" │ {}", display_line.dimmed());
}
if snippet.truncated {
println!(" │ {}", "...".dimmed());
}
println!();
}
Ok(())
}
#[derive(Debug)]
struct Snippet {
lines: Vec<String>,
start_line: usize,
end_line: usize,
truncated: bool,
}
#[derive(Serialize)]
struct JsonSnippet {
start_line: usize,
end_line: usize,
lines: Vec<String>,
}
fn is_zero(v: &usize) -> bool {
*v == 0
}
#[derive(Serialize)]
struct JsonSearchResponse {
meta: SearchMeta,
results: Vec<JsonSearchResult>,
}
#[derive(Serialize)]
struct JsonSearchResult {
rank: usize,
relevance: u32,
chunk_id: String,
file_path: String,
start_line: usize,
end_line: usize,
chunk_type: String,
language: String,
symbol_name: Option<String>,
content: String,
snippet: JsonSnippet,
#[serde(skip_serializing_if = "Option::is_none")]
parent_symbol: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
signature: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
doc_comment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
module_path: Option<String>,
#[serde(skip_serializing_if = "is_zero")]
referenced_by_count: usize,
}
fn normalize_context(
before: Option<usize>,
after: Option<usize>,
context: Option<usize>,
) -> (usize, usize) {
let mut before = before.unwrap_or(0);
let mut after = after.unwrap_or(0);
if let Some(context) = context {
if before == 0 {
before = context;
}
if after == 0 {
after = context;
}
}
(before, after)
}
fn build_snippet(
project_root: &Path,
result: &crate::search::SearchResult,
before: usize,
after: usize,
) -> Snippet {
if before == 0 && after == 0 {
let lines: Vec<String> = result
.content
.lines()
.take(5)
.map(|s| s.to_string())
.collect();
let end_line = result.start_line + lines.len().saturating_sub(1);
let truncated = result.content.lines().count() > lines.len();
return Snippet {
lines,
start_line: result.start_line,
end_line,
truncated,
};
}
let abs_path = project_root.join(&result.file_path);
if let Ok(content) = std::fs::read_to_string(&abs_path) {
let all_lines: Vec<&str> = content.lines().collect();
if all_lines.is_empty() {
return Snippet {
lines: Vec::new(),
start_line: result.start_line,
end_line: result.end_line,
truncated: false,
};
}
let start = result.start_line.saturating_sub(1);
let end = result.end_line.saturating_sub(1);
let from = start.saturating_sub(before);
let to = (end + after).min(all_lines.len().saturating_sub(1));
let lines: Vec<String> = all_lines[from..=to].iter().map(|s| s.to_string()).collect();
return Snippet {
lines,
start_line: from + 1,
end_line: to + 1,
truncated: false,
};
}
let lines: Vec<String> = result
.content
.lines()
.take(5)
.map(|s| s.to_string())
.collect();
let end_line = result.start_line + lines.len().saturating_sub(1);
let truncated = result.content.lines().count() > lines.len();
Snippet {
lines,
start_line: result.start_line,
end_line,
truncated,
}
}
fn ensure_bm25_compatible(
bm25_index_path: &Path,
meta_path: &Path,
ann_index_path: &Path,
db: &Database,
) -> Result<()> {
let model_signature = format!("{}+{}", MODEL_ID, DENSE_MODEL_ID);
let expected = IndexMetadata::expected(
crate::search::BM25Index::schema_signature()?,
&model_signature,
);
let current = read_metadata(meta_path)?;
let needs_rebuild = match current {
Some(meta) => !meta.matches(&expected),
None => true,
};
if !needs_rebuild {
return Ok(());
}
println!(
"{} Rebuilding BM25 index due to schema/model changes...",
"→".blue()
);
if bm25_index_path.exists() {
std::fs::remove_dir_all(bm25_index_path)?;
}
let bm25_index = crate::search::BM25Index::new(bm25_index_path)?;
let chunks = db.get_all_chunks()?;
bm25_index.index_chunks(&chunks)?;
if let Ok(dense_model_path) = ensure_dense_model_available() {
let mut dense = DenseRetriever::new(&dense_model_path)?;
let texts: Vec<String> = chunks
.iter()
.map(|c| format!("{}\n{}", c.file_path, c.content))
.collect();
let vectors = dense.embed_texts(&texts)?;
db.clear_embeddings()?;
for (chunk, vector) in chunks.iter().zip(vectors.iter()) {
db.upsert_embedding(&chunk.id, vector)?;
}
if let Some(index) = AnnIndex::build(&db.get_all_embeddings()?)? {
index.save(ann_index_path)?;
} else if ann_index_path.exists() {
std::fs::remove_file(ann_index_path)?;
}
}
write_metadata(meta_path, &expected)?;
Ok(())
}
fn build_chunk_lookup(db: &Database) -> Result<HashMap<String, BM25SearchResult>> {
let chunks = db.get_all_chunks()?;
let mut lookup = HashMap::with_capacity(chunks.len());
for chunk in chunks {
lookup.insert(
chunk.id.clone(),
BM25SearchResult {
chunk_id: chunk.id,
file_path: chunk.file_path,
content: chunk.content,
start_line: chunk.start_line as usize,
end_line: chunk.end_line as usize,
chunk_type: chunk.chunk_type,
language: chunk.language,
symbol_name: chunk.symbol_name,
score: 0.0,
parent_symbol: chunk.parent_symbol,
signature: chunk.signature,
doc_comment: chunk.doc_comment,
module_path: chunk.module_path,
},
);
}
Ok(lookup)
}