minni 0.1.1

Local memory, task, and codebase indexing tool for AI agents
Documentation
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!();
    }
}