minni 0.1.0

Local memory, task, and codebase indexing tool for AI agents
Documentation
use crate::cli::init::ensure_initialized;
use crate::db::Database;
use anyhow::Result;
use colored::Colorize;
use serde::Serialize;
use std::collections::HashSet;
use std::env;

/// Human-readable definition entry for the `refs` command.
#[derive(Serialize)]
struct JsonDefinition {
    file_path: String,
    start_line: u32,
    end_line: u32,
    chunk_type: String,
    symbol_name: Option<String>,
    // Always serialized (null when absent) for a stable JSON schema.
    signature: Option<String>,
    module_path: Option<String>,
}

#[derive(Serialize)]
struct JsonImportedBy {
    source_file: String,
    target_symbol: String,
    edge_type: String,
}

#[derive(Serialize)]
struct JsonImport {
    target_symbol: String,
    edge_type: String,
    target_file: Option<String>,
}

#[derive(Serialize)]
struct JsonRefsOutput {
    query: String,
    definitions: Vec<JsonDefinition>,
    imported_by: Vec<JsonImportedBy>,
    imports: Vec<JsonImport>,
}

pub fn run(symbol: &str, json: bool) -> Result<()> {
    let project_root = env::current_dir()?;

    // Auto-initialize with full setup if not done
    ensure_initialized(&project_root, false)?;

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

    // ── Definitions ──────────────────────────────────────────────────────────
    let def_chunks = db.find_chunks_by_symbol_name(symbol)?;

    // ── Unique source files for the definitions ───────────────────────────────
    let unique_def_files: HashSet<&str> = def_chunks.iter().map(|c| c.file_path.as_str()).collect();

    // ── Imported by ───────────────────────────────────────────────────────────
    let imported_by = db.get_edges_by_target_symbol(symbol)?;

    // ── Imports (from the definition file, only when exactly one unique file) ─
    let imports = if unique_def_files.len() == 1 {
        let file = unique_def_files.iter().next().unwrap();
        db.get_edges_by_source_file(file)?
    } else {
        Vec::new()
    };

    // ── Output ───────────────────────────────────────────────────────────────
    if json {
        let output = JsonRefsOutput {
            query: symbol.to_string(),
            definitions: def_chunks
                .iter()
                .map(|c| JsonDefinition {
                    file_path: c.file_path.clone(),
                    start_line: c.start_line,
                    end_line: c.end_line,
                    chunk_type: c.chunk_type.clone(),
                    symbol_name: c.symbol_name.clone(),
                    signature: c.signature.clone(),
                    module_path: c.module_path.clone(),
                })
                .collect(),
            imported_by: imported_by
                .iter()
                .map(|e| JsonImportedBy {
                    source_file: e.source_file.clone(),
                    target_symbol: e.target_symbol.clone(),
                    edge_type: e.edge_type.clone(),
                })
                .collect(),
            imports: imports
                .iter()
                .map(|e| JsonImport {
                    target_symbol: e.target_symbol.clone(),
                    edge_type: e.edge_type.clone(),
                    target_file: e.target_file.clone(),
                })
                .collect(),
        };
        println!("{}", serde_json::to_string_pretty(&output)?);
        return Ok(());
    }

    // Human-readable output
    println!("{} References for: {}", "".blue(), symbol.cyan());

    // Definitions section
    println!();
    println!("{}", "Definitions:".bold());
    if def_chunks.is_empty() {
        println!("  {}", "(none found)".dimmed());
    } else {
        for chunk in &def_chunks {
            let sym_label = chunk.symbol_name.as_deref().unwrap_or("").to_string();
            println!(
                "  {}:{}-{} ({}) {}",
                chunk.file_path.blue(),
                chunk.start_line,
                chunk.end_line,
                chunk.chunk_type.magenta(),
                sym_label.bold()
            );
            if let Some(ref mp) = chunk.module_path {
                if !mp.is_empty() {
                    println!("    module: {}", mp.dimmed());
                }
            }
            if let Some(ref sig) = chunk.signature {
                if !sig.is_empty() {
                    println!("    signature: {}", sig.dimmed());
                }
            }
        }
    }

    // Imported by section
    println!();
    println!("{}", "Imported by:".bold());
    if imported_by.is_empty() {
        println!("  {}", "(none found)".dimmed());
    } else {
        for edge in &imported_by {
            println!(
                "  {} {} {} ({})",
                edge.source_file.blue(),
                "".dimmed(),
                edge.target_symbol.cyan(),
                edge.edge_type.dimmed()
            );
        }
    }

    // Imports section
    println!();
    match unique_def_files.len() {
        0 => {
            println!("{}", "Imports:".bold());
            println!(
                "  {}",
                "(no definition found — cannot determine imports)".dimmed()
            );
        }
        1 => {
            let file = unique_def_files.iter().next().unwrap();
            println!("{} (from {})", "Imports:".bold(), file.blue());
            if imports.is_empty() {
                println!("  {}", "(none found)".dimmed());
            } else {
                for edge in &imports {
                    let target_info = match &edge.target_file {
                        Some(f) => format!("{}{}", edge.target_symbol.cyan(), f.blue()),
                        None => edge.target_symbol.cyan().to_string(),
                    };
                    println!("  {} ({})", target_info, edge.edge_type.dimmed());
                }
            }
        }
        n => {
            println!("{}", "Imports:".bold());
            println!(
                "  {} ({} definition files found — showing imports requires a single file)",
                "!".yellow(),
                n
            );
        }
    }

    Ok(())
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::{CodeChunk, SymbolEdge};
    use chrono::Utc;

    fn make_chunk(file_path: &str, symbol_name: &str, chunk_type: &str) -> CodeChunk {
        let ts = Utc::now().to_rfc3339();
        CodeChunk {
            id: uuid::Uuid::new_v4().to_string(),
            file_path: file_path.to_string(),
            content: String::new(),
            start_line: 1,
            end_line: 5,
            chunk_type: chunk_type.to_string(),
            language: "rust".to_string(),
            symbol_name: Some(symbol_name.to_string()),
            content_hash: "abc".to_string(),
            indexed_at: ts,
            parent_symbol: None,
            signature: Some(format!("pub struct {}", symbol_name)),
            doc_comment: None,
            module_path: Some("db".to_string()),
        }
    }

    fn make_edge(source_file: &str, target_symbol: &str) -> SymbolEdge {
        SymbolEdge {
            id: uuid::Uuid::new_v4().to_string(),
            source_file: source_file.to_string(),
            target_symbol: target_symbol.to_string(),
            edge_type: "imports".to_string(),
            target_file: None,
            indexed_at: Utc::now().to_rfc3339(),
        }
    }

    #[test]
    fn test_json_refs_output_serializes_correctly() {
        let output = JsonRefsOutput {
            query: "Database".to_string(),
            definitions: vec![JsonDefinition {
                file_path: "src/db/mod.rs".to_string(),
                start_line: 51_u32,
                end_line: 54_u32,
                chunk_type: "class".to_string(),
                symbol_name: Some("Database".to_string()),
                signature: Some("pub struct Database".to_string()),
                module_path: Some("db".to_string()),
            }],
            imported_by: vec![JsonImportedBy {
                source_file: "src/indexer/mod.rs".to_string(),
                target_symbol: "crate::db::Database".to_string(),
                edge_type: "imports".to_string(),
            }],
            imports: vec![JsonImport {
                target_symbol: "anyhow::Result".to_string(),
                edge_type: "imports".to_string(),
                target_file: None,
            }],
        };

        let json_str = serde_json::to_string_pretty(&output).unwrap();
        assert!(json_str.contains("\"query\": \"Database\""));
        assert!(json_str.contains("\"file_path\": \"src/db/mod.rs\""));
        assert!(json_str.contains("\"source_file\": \"src/indexer/mod.rs\""));
        assert!(json_str.contains("\"target_symbol\": \"anyhow::Result\""));
        // target_file: null must appear (not skipped)
        assert!(json_str.contains("\"target_file\": null"));
    }

    #[test]
    fn test_json_definition_always_includes_optional_fields() {
        // Both None values must appear as null (schema stability, Issue 4)
        let def = JsonDefinition {
            file_path: "src/main.rs".to_string(),
            start_line: 1,
            end_line: 3,
            chunk_type: "function".to_string(),
            symbol_name: Some("main".to_string()),
            signature: None,
            module_path: None,
        };
        let json_str = serde_json::to_string(&def).unwrap();
        assert!(
            json_str.contains("\"signature\":null") || json_str.contains("\"signature\": null"),
            "signature must be present as null: {}",
            json_str
        );
        assert!(
            json_str.contains("\"module_path\":null") || json_str.contains("\"module_path\": null"),
            "module_path must be present as null: {}",
            json_str
        );
    }

    #[test]
    fn test_unique_file_count_same_file_multiple_chunks() {
        // Two chunks from the same file should count as one unique file
        let chunks = vec![
            make_chunk("src/db/mod.rs", "Database", "class"),
            make_chunk("src/db/mod.rs", "Database", "function"),
        ];
        let unique: HashSet<&str> = chunks.iter().map(|c| c.file_path.as_str()).collect();
        assert_eq!(unique.len(), 1);
    }

    #[test]
    fn test_unique_file_count_multiple_files() {
        // Chunks from two different files → no imports section
        let chunks = vec![
            make_chunk("src/db/mod.rs", "Error", "class"),
            make_chunk("src/indexer/mod.rs", "Error", "class"),
        ];
        let unique: HashSet<&str> = chunks.iter().map(|c| c.file_path.as_str()).collect();
        assert_eq!(unique.len(), 2);
    }

    #[test]
    fn test_make_chunk_helper_has_expected_fields() {
        let chunk = make_chunk("src/db/mod.rs", "Database", "class");
        assert_eq!(chunk.file_path, "src/db/mod.rs");
        assert_eq!(chunk.symbol_name.as_deref(), Some("Database"));
        assert_eq!(chunk.chunk_type, "class");
    }

    #[test]
    fn test_make_edge_helper_has_expected_fields() {
        let edge = make_edge("src/indexer/mod.rs", "crate::db::Database");
        assert_eq!(edge.source_file, "src/indexer/mod.rs");
        assert_eq!(edge.target_symbol, "crate::db::Database");
        assert_eq!(edge.edge_type, "imports");
        assert!(edge.target_file.is_none());
    }
}