use crate::cli::init::ensure_initialized;
use crate::db::Database;
use crate::search::BM25Index;
use anyhow::Result;
use colored::Colorize;
use serde::Serialize;
use std::collections::{BTreeSet, HashMap};
use std::env;
#[derive(Debug, Clone, Serialize)]
struct ResearchFinding {
rank: usize,
chunk_id: String,
file_path: String,
start_line: usize,
end_line: usize,
chunk_type: String,
language: String,
symbol_name: Option<String>,
aggregate_score: f32,
pass_hits: usize,
matched_queries: Vec<String>,
snippet: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
struct ResearchReport {
question: String,
queries: Vec<String>,
findings: Vec<ResearchFinding>,
files_covered: usize,
queries_with_hits: usize,
}
#[derive(Debug, Clone)]
struct AggregateCandidate {
chunk_id: String,
file_path: String,
content: String,
start_line: usize,
end_line: usize,
chunk_type: String,
language: String,
symbol_name: Option<String>,
aggregate_score: f32,
matched_query_indices: BTreeSet<usize>,
}
pub fn run(question: &str, passes: usize, limit: usize, json: bool) -> 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. Run {} first.",
"!".yellow(),
"minni index".cyan()
);
return Ok(());
}
let bm25_index_path = project_root.join(".minni").join("bm25_index");
let bm25 = BM25Index::new(&bm25_index_path)?;
let queries = build_queries(question, passes.max(1));
let mut aggregated: HashMap<String, AggregateCandidate> = HashMap::new();
let per_query_limit = (limit * 5).max(20);
let mut queries_with_hits = 0usize;
for (idx, q) in queries.iter().enumerate() {
let results = bm25.search(q, per_query_limit)?;
if !results.is_empty() {
queries_with_hits += 1;
}
for hit in results {
let entry =
aggregated
.entry(hit.chunk_id.clone())
.or_insert_with(|| AggregateCandidate {
chunk_id: hit.chunk_id.clone(),
file_path: hit.file_path.clone(),
content: hit.content.clone(),
start_line: hit.start_line,
end_line: hit.end_line,
chunk_type: hit.chunk_type.clone(),
language: hit.language.clone(),
symbol_name: hit.symbol_name.clone(),
aggregate_score: 0.0,
matched_query_indices: BTreeSet::new(),
});
entry.aggregate_score += hit.score;
entry.matched_query_indices.insert(idx);
}
}
let mut ranked: Vec<AggregateCandidate> = aggregated.into_values().collect();
ranked.sort_by(|a, b| {
b.aggregate_score
.partial_cmp(&a.aggregate_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(
b.matched_query_indices
.len()
.cmp(&a.matched_query_indices.len()),
)
});
ranked.truncate(limit);
let findings: Vec<ResearchFinding> = ranked
.into_iter()
.enumerate()
.map(|(idx, c)| {
let matched_queries: Vec<String> = c
.matched_query_indices
.iter()
.map(|i| queries[*i].clone())
.collect();
ResearchFinding {
rank: idx + 1,
chunk_id: c.chunk_id,
file_path: c.file_path,
start_line: c.start_line,
end_line: c.end_line,
chunk_type: c.chunk_type,
language: c.language,
symbol_name: c.symbol_name,
aggregate_score: c.aggregate_score,
pass_hits: matched_queries.len(),
matched_queries,
snippet: c.content.lines().take(6).map(|s| s.to_string()).collect(),
}
})
.collect();
let files_covered = findings
.iter()
.map(|f| f.file_path.as_str())
.collect::<BTreeSet<_>>()
.len();
let report = ResearchReport {
question: question.to_string(),
queries,
findings,
files_covered,
queries_with_hits,
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
return Ok(());
}
print_human(&report);
Ok(())
}
fn build_queries(question: &str, passes: usize) -> Vec<String> {
let mut queries = Vec::new();
queries.push(question.to_string());
if passes == 1 {
return queries;
}
queries.push(format!("impact {}", question));
if passes == 2 {
return queries;
}
queries.push(format!("risk {}", question));
if passes == 3 {
return queries;
}
let keywords: Vec<&str> = question
.split_whitespace()
.map(|w| w.trim_matches(|c: char| !c.is_alphanumeric() && c != '_'))
.filter(|w| w.len() >= 3)
.collect();
for i in 0..passes.saturating_sub(3) {
if keywords.is_empty() {
break;
}
let focus = keywords[i % keywords.len()];
queries.push(format!("{} integration", focus));
}
queries
}
fn print_human(report: &ResearchReport) {
println!("{} {}", "→".blue(), "Research query".cyan());
println!(" {}", report.question);
println!();
println!("{} Query passes: {}", "-".blue(), report.queries.len());
println!(
"{} Queries with hits: {}",
"-".blue(),
report.queries_with_hits
);
println!("{} Files covered: {}", "-".blue(), report.files_covered);
println!();
for finding in &report.findings {
println!(
"{} {} (score: {:.2}, hits: {})",
format!("[{}]", finding.rank).cyan(),
finding.file_path.blue(),
finding.aggregate_score,
finding.pass_hits
);
println!(
" Lines {}-{} | {} | {}",
finding.start_line,
finding.end_line,
finding.chunk_type.magenta(),
finding.language.magenta()
);
if let Some(symbol) = &finding.symbol_name {
if !symbol.is_empty() {
println!(" Symbol: {}", symbol);
}
}
for line in &finding.snippet {
println!(" │ {}", line.dimmed());
}
println!();
}
}