infigraph-cli 1.0.0

CLI for infigraph — AST-powered code analysis and impact review
use std::path::Path;

use anyhow::{Context, Result};
use infigraph_core::Infigraph;
use infigraph_languages::bundled_registry;

pub(crate) fn cmd_stats(root: &Path) -> Result<()> {
    let registry = bundled_registry()?;
    let mut prism = Infigraph::open(root, registry)?;
    prism.init()?;
    let stats = prism.stats()?;
    println!("{}", stats);
    Ok(())
}

pub(crate) fn cmd_languages(project_root: Option<&Path>) -> Result<()> {
    let registry = crate::full_registry(project_root)?;
    println!("Available languages:");
    for pack in registry.languages() {
        let backend = match &pack.backend {
            infigraph_core::lang::ParserBackend::TreeSitter { .. } => "tree-sitter",
            infigraph_core::lang::ParserBackend::Custom(_) => "grammar-plugin",
        };
        println!(
            "  {} ({}) [{}]",
            pack.name,
            pack.extensions.join(", "),
            backend
        );
    }
    Ok(())
}

pub(crate) fn cmd_symbols(root: &Path, file: &str) -> Result<()> {
    let registry = bundled_registry()?;
    let mut prism = Infigraph::open(root, registry)?;
    prism.init()?;

    let store = prism.store().context("graph not initialized")?;
    let conn = store.connection()?;
    let gq = infigraph_core::graph::GraphQuery::new(&conn);

    let symbols = gq.symbols_in_file(file)?;
    if symbols.is_empty() {
        println!(
            "No symbols found for '{}'. Run 'infigraph index' first.",
            file
        );
        return Ok(());
    }

    println!("Symbols in {}:", file);
    for s in &symbols {
        println!(
            "  {:>8} {:30} L{}-{}",
            s.kind, s.name, s.start_line, s.end_line
        );
    }
    Ok(())
}

pub(crate) fn cmd_index_manifests(root: &Path) -> Result<()> {
    let registry = bundled_registry()?;
    let mut prism = Infigraph::open(root, registry)?;
    prism.init()?;
    let store = prism.store().context("graph not initialized")?;
    let results = infigraph_core::manifest::index_manifests(root, store)?;
    if results.is_empty() {
        println!("No manifests found.");
        return Ok(());
    }
    let total: usize = results.iter().map(|r| r.deps.len()).sum();
    println!(
        "Indexed {} manifests, {} dependencies:\n",
        results.len(),
        total
    );
    for r in &results {
        println!(
            "  {} [{}]: {} deps",
            r.manifest_file,
            r.ecosystem,
            r.deps.len()
        );
    }
    Ok(())
}

pub(crate) fn cmd_dependencies(root: &Path, ecosystem: Option<&str>) -> Result<()> {
    let registry = bundled_registry()?;
    let mut prism = Infigraph::open(root, registry)?;
    prism.init()?;
    let store = prism.store().context("graph not initialized")?;
    let mut deps = infigraph_core::manifest::query_deps(store)?;
    if let Some(eco) = ecosystem {
        deps.retain(|d| d.ecosystem == eco);
    }
    if deps.is_empty() {
        println!("No dependencies found. Run 'infigraph index-manifests' first.");
        return Ok(());
    }
    println!("Dependencies ({}):\n", deps.len());
    let mut cur_eco = String::new();
    for d in &deps {
        if d.ecosystem != cur_eco {
            println!("  [{}]", d.ecosystem);
            cur_eco = d.ecosystem.clone();
        }
        let dev_tag = if d.is_dev { " (dev)" } else { "" };
        println!("    {}@{}{}", d.name, d.version, dev_tag);
    }
    Ok(())
}

pub(crate) fn cmd_api_surface(root: &Path, file_filter: Option<&str>) -> Result<()> {
    let registry = bundled_registry()?;
    let mut prism = Infigraph::open(root, registry)?;
    prism.init()?;
    let store = prism.store().context("graph not initialized")?;
    let conn = store.connection()?;
    let gq = infigraph_core::graph::GraphQuery::new(&conn);

    let mut syms = gq.get_api_surface()?;
    if let Some(f) = file_filter {
        syms.retain(|s| s.file.contains(f));
    }

    println!("API Surface ({} symbols):\n", syms.len());
    let mut cur_file = String::new();
    for s in &syms {
        if s.file != cur_file {
            println!("  {}", s.file);
            cur_file = s.file.clone();
        }
        println!("    [{:<10}] L{:<5} {}", s.kind, s.line, s.name);
    }
    Ok(())
}

pub(crate) fn cmd_file_deps(root: &Path, file: &str) -> Result<()> {
    let registry = bundled_registry()?;
    let mut prism = Infigraph::open(root, registry)?;
    prism.init()?;
    let store = prism.store().context("graph not initialized")?;
    let conn = store.connection()?;
    let gq = infigraph_core::graph::GraphQuery::new(&conn);

    let deps = gq.get_file_deps(file)?;
    println!("File dependencies for '{}':\n", file);
    println!("  Imports ({}):", deps.imports.len());
    for f in &deps.imports {
        println!("{}", f);
    }
    if deps.imports.is_empty() {
        println!("    (none)");
    }
    println!("\n  Imported by ({}):", deps.imported_by.len());
    for f in &deps.imported_by {
        println!("{}", f);
    }
    if deps.imported_by.is_empty() {
        println!("    (none)");
    }
    Ok(())
}

pub(crate) fn cmd_type_hierarchy(root: &Path, symbol: &str, depth: u32) -> Result<()> {
    let registry = bundled_registry()?;
    let mut prism = Infigraph::open(root, registry)?;
    prism.init()?;
    let store = prism.store().context("graph not initialized")?;
    let conn = store.connection()?;
    let gq = infigraph_core::graph::GraphQuery::new(&conn);

    let hier = gq.get_type_hierarchy(symbol, depth)?;
    println!("Type hierarchy for '{}':\n", hier.root_name);
    println!("  Ancestors ({}):", hier.ancestors.len());
    for a in &hier.ancestors {
        println!("{} [{}]  ({})", a.name, a.kind, a.file);
    }
    if hier.ancestors.is_empty() {
        println!("    (none — root type)");
    }
    println!("\n  Descendants ({}):", hier.descendants.len());
    for d in &hier.descendants {
        println!("{} [{}]  ({})", d.name, d.kind, d.file);
    }
    if hier.descendants.is_empty() {
        println!("    (none — leaf type)");
    }
    Ok(())
}

pub(crate) fn cmd_test_coverage(root: &Path, file_filter: Option<&str>) -> Result<()> {
    let registry = bundled_registry()?;
    let mut prism = Infigraph::open(root, registry)?;
    prism.init()?;
    let store = prism.store().context("graph not initialized")?;
    let conn = store.connection()?;
    let gq = infigraph_core::graph::GraphQuery::new(&conn);

    let mut cov = gq.get_test_coverage()?;
    if let Some(f) = file_filter {
        cov.covered.retain(|s| s.file.contains(f));
        cov.uncovered.retain(|s| s.file.contains(f));
        let total = cov.covered.len() + cov.uncovered.len();
        cov.coverage_pct = (cov.covered.len() * 100).checked_div(total).unwrap_or(0);
        cov.covered_count = cov.covered.len();
        cov.uncovered_count = cov.uncovered.len();
    }

    println!(
        "Test Coverage: {}%  ({} covered / {} uncovered)\n",
        cov.coverage_pct, cov.covered_count, cov.uncovered_count
    );

    if !cov.uncovered.is_empty() {
        println!("Uncovered ({}):", cov.uncovered.len());
        for s in cov.uncovered.iter().take(50) {
            println!("  ✗  {:<40} [{}]  {}", s.symbol_name, s.kind, s.file);
        }
        if cov.uncovered.len() > 50 {
            println!("  ... and {} more", cov.uncovered.len() - 50);
        }
    }
    Ok(())
}

pub(crate) fn cmd_watch(root: &Path, debounce: u64) -> Result<()> {
    let registry = bundled_registry()?;
    let mut prism = Infigraph::open(root, registry)?;
    prism.init()?;

    println!(
        "Watching {} (debounce {}ms) — Ctrl-C to stop",
        root.display(),
        debounce
    );

    let (stop_tx, stop_rx) = std::sync::mpsc::channel();

    ctrlc::set_handler(move || {
        let _ = stop_tx.send(());
    })
    .ok();

    infigraph_core::watch::watch_project(&prism, debounce, stop_rx, |evt| {
        println!("[watch] {evt}");
    })?;

    println!("Watch stopped.");
    Ok(())
}

pub(crate) fn cmd_scip_import(root: &Path, index_path: &Path) -> Result<()> {
    let registry = bundled_registry()?;
    let mut prism = Infigraph::open(root, registry)?;
    prism.init()?;

    let store = prism.store().context("graph not initialized")?;
    let abs_index = if index_path.is_absolute() {
        index_path.to_path_buf()
    } else {
        root.join(index_path)
    };

    println!("Importing SCIP index from {}", abs_index.display());
    let stats = infigraph_core::scip::import_scip_index(&abs_index, store, Some(root))?;
    println!(
        "SCIP import complete:\n  files processed: {}\n  symbols added: {}\n  symbols enriched: {}\n  relations added: {}\n  references added: {}\n  corrections learned: {}",
        stats.files_processed,
        stats.symbols_added,
        stats.symbols_enriched,
        stats.relations_added,
        stats.references_added,
        stats.corrections_learned,
    );
    Ok(())
}

pub(crate) fn cmd_index_docs(root: &Path) -> Result<()> {
    let start = std::time::Instant::now();
    let mut idx = infigraph_docs::DocIndex::open(root)?;
    idx.init()?;
    let result = idx.index()?;
    let elapsed = start.elapsed();
    println!(
        "Document indexing complete in {:.1}s\n  Files scanned: {}\n  Files indexed: {}\n  Chunks created: {}",
        elapsed.as_secs_f64(), result.total_files, result.indexed_files, result.total_chunks
    );
    if let Some(store) = idx.store() {
        let stats = store.stats()?;
        println!(
            "  Total documents in store: {}\n  Total chunks in store: {}",
            stats.document_count, stats.chunk_count
        );
    }
    Ok(())
}

pub(crate) fn cmd_reindex_docs(root: &Path) -> Result<()> {
    let start = std::time::Instant::now();
    let mut idx = infigraph_docs::DocIndex::open(root)?;
    let result = idx.reindex()?;
    let elapsed = start.elapsed();
    println!(
        "Document full reindex complete in {:.1}s\n  Files scanned: {}\n  Files indexed: {}\n  Chunks created: {}",
        elapsed.as_secs_f64(), result.total_files, result.indexed_files, result.total_chunks
    );
    Ok(())
}

pub(crate) fn cmd_clean_docs(root: &Path) -> Result<()> {
    let mut idx = infigraph_docs::DocIndex::open(root)?;
    idx.clean()?;
    println!("Document index cleaned.");
    Ok(())
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_index_confluence(
    root: &Path,
    base_url: &str,
    space: &str,
    page_ids: Option<Vec<String>>,
    pat: Option<String>,
    email: Option<String>,
    api_token: Option<String>,
    follow_links: bool,
    follow_depth: usize,
    max_pages: usize,
) -> Result<()> {
    let client = if let Some(pat) = pat {
        infigraph_confluence::ConfluenceClient::new(base_url, &pat)
    } else if let (Some(email), Some(token)) = (email, api_token) {
        infigraph_confluence::ConfluenceClient::new_basic(base_url, &email, &token)
    } else {
        anyhow::bail!("Provide either --pat or both --email and --api-token for authentication");
    };

    let crawl = if follow_links {
        infigraph_confluence::CrawlOptions {
            follow_links: true,
            follow_depth,
            max_pages,
            same_space_only: true,
        }
    } else {
        infigraph_confluence::CrawlOptions::no_follow()
    };

    let start = std::time::Instant::now();
    let sync = infigraph_confluence::ConfluenceSync::new(client, space);

    let mut idx = infigraph_docs::DocIndex::open(root)?;
    idx.init()?;
    let store = idx.store().context("DocStore not initialized")?;

    let ids = page_ids.as_deref();
    let result = sync.sync_with_options(store, root, ids, &crawl)?;
    let elapsed = start.elapsed();

    println!(
        "Confluence sync complete in {:.1}s\n  Pages fetched: {}\n  Pages indexed: {}\n  Pages deleted: {}\n  Chunks created: {}\n  Links created: {}",
        elapsed.as_secs_f64(),
        result.pages_fetched,
        result.pages_indexed,
        result.pages_deleted,
        result.chunks_created,
        result.links_created,
    );

    let stats = store.stats()?;
    println!(
        "  Total documents in store: {}\n  Total chunks in store: {}",
        stats.document_count, stats.chunk_count
    );
    Ok(())
}