minni 0.1.0

Local memory, task, and codebase indexing tool for AI agents
Documentation
use crate::db::Database;
use crate::indexer::{IndexConfig, Indexer};
use crate::models::downloader::{ensure_dense_model_available, DENSE_MODEL_ID, MODEL_ID};
use crate::search::{
    read_metadata, write_metadata, AnnIndex, BM25Index, DenseRetriever, IndexMetadata,
};
use anyhow::{bail, Result};
use colored::Colorize;
use std::env;
use std::path::PathBuf;

pub fn run(path: Option<PathBuf>, force: bool) -> Result<()> {
    let project_root = path.unwrap_or_else(|| env::current_dir().unwrap());

    if !Database::minni_dir_exists(&project_root) {
        println!(
            "{} Minni not initialized. Run {} first.",
            "Error:".red(),
            "minni init".cyan()
        );
        println!(
            "    Tip: Run {} to diagnose setup issues.",
            "minni doctor".cyan()
        );
        bail!("minni is not initialized in {}", project_root.display());
    }

    let db = Database::open(&project_root)?;

    let config = IndexConfig {
        force_reindex: force,
        ..Default::default()
    };

    let mut indexer = Indexer::new(&db, config);

    println!("{} Indexing {}...", "".blue(), project_root.display());
    println!();

    let (result, new_chunks) = indexer.index_directory(&project_root)?;

    // Build BM25 index (incremental)
    println!();
    println!("{} Building BM25 search index...", "".blue());

    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");

    // Determine expected metadata
    let model_signature = format!("{}+{}", MODEL_ID, DENSE_MODEL_ID);
    let expected_meta = IndexMetadata::expected(BM25Index::schema_signature()?, &model_signature);

    // Check for schema/model mismatch — force full rebuild if needed
    let force_rebuild = force || {
        match read_metadata(&meta_path)? {
            None => true, // no existing metadata → rebuild
            Some(stored) => !stored.matches(&expected_meta),
        }
    };

    let bm25_index = BM25Index::new(&bm25_index_path)?;

    if force_rebuild {
        // Full rebuild: clear and re-index everything
        bm25_index.clear()?;
        let all_chunks = db.get_all_chunks()?;
        bm25_index.index_chunks(&all_chunks)?;
    } else {
        // Incremental: remove stale docs, add new docs
        bm25_index.delete_by_chunk_ids(&result.old_chunk_ids)?;
        bm25_index.index_chunks(&new_chunks)?;
    }

    // Build dense embedding index for semantic retrieval
    println!("{} Building dense embedding index...", "".blue());
    match ensure_dense_model_available() {
        Ok(dense_model_path) => {
            let mut dense = DenseRetriever::new(&dense_model_path)?;
            if force_rebuild {
                let all_chunks = db.get_all_chunks()?;
                let texts: Vec<String> = all_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 all_chunks.iter().zip(vectors.iter()) {
                    db.upsert_embedding(&chunk.id, vector)?;
                }
            } else {
                db.delete_embeddings_by_chunk_ids(&result.old_chunk_ids)?;
                if !new_chunks.is_empty() {
                    let texts: Vec<String> = new_chunks
                        .iter()
                        .map(|c| format!("{}\n{}", c.file_path, c.content))
                        .collect();
                    let vectors = dense.embed_texts(&texts)?;
                    for (chunk, vector) in new_chunks.iter().zip(vectors.iter()) {
                        db.upsert_embedding(&chunk.id, vector)?;
                    }
                }
            }
            println!("{} Dense embedding index built!", "".green());
            rebuild_ann_index(&db, &ann_index_path)?;
            println!("{} ANN index built!", "".green());
        }
        Err(e) => {
            println!(
                "{} Dense model unavailable — continuing with BM25-only retrieval.",
                "!".yellow()
            );
            println!("    Reason: {}", e);
            println!(
                "    Tip: Run {} to check model and network status.",
                "minni doctor".cyan()
            );
            db.clear_embeddings()?;
            if ann_index_path.exists() {
                std::fs::remove_file(&ann_index_path)?;
            }
        }
    }

    // Write index metadata for compatibility checks
    write_metadata(&meta_path, &expected_meta)?;

    println!("{} BM25 index built!", "".green());

    println!();
    println!("{} Indexing complete!", "".green());
    println!(
        "  Files indexed: {}",
        result.files_indexed.to_string().cyan()
    );
    println!(
        "  Chunks created: {}",
        result.chunks_created.to_string().cyan()
    );
    println!(
        "  Edges created: {}",
        result.edges_created.to_string().cyan()
    );
    println!(
        "  Edges resolved: {}/{}",
        result.edges_resolved.to_string().cyan(),
        result.edges_total.to_string().cyan()
    );
    println!("  Files skipped: {}", result.skipped.to_string().yellow());
    println!(
        "  Files deleted: {}",
        result.files_deleted.to_string().yellow()
    );

    if !result.errors.is_empty() {
        println!();
        println!("{} Errors:", "!".yellow());
        for error in &result.errors[..result.errors.len().min(10)] {
            println!("  - {}", error);
        }
        if result.errors.len() > 10 {
            println!("  ... and {} more", result.errors.len() - 10);
        }
    }

    Ok(())
}

fn rebuild_ann_index(db: &Database, ann_index_path: &std::path::Path) -> Result<()> {
    let embeddings = db.get_all_embeddings()?;
    if let Some(index) = AnnIndex::build(&embeddings)? {
        index.save(ann_index_path)?;
    } else if ann_index_path.exists() {
        std::fs::remove_file(ann_index_path)?;
    }
    Ok(())
}