sinter-io 0.43.0

sinter command-line interface
//! Symbol-argument resolution shared by every query-side command: exact
//! name, qualified suffix, node id, or trigram suggestions.

use std::collections::BTreeSet;
use std::path::Path;

use anyhow::{Context, Result, bail};
use sinter_core::{Confidence, Evidence, Node};
use sinter_resolve::qualified_of;
use sinter_store::{EdgeFilter, Store};

use crate::pipeline;

/// Valid query, no results. Read commands exit 1 (grep-style) when the
/// error chain carries this; every other error is exit 2.
#[derive(Debug)]
pub struct NoMatch(pub String);

impl std::fmt::Display for NoMatch {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for NoMatch {}

pub fn open_store(repo: &Path) -> Result<Store> {
    let repo = pipeline::discover_root(repo);
    let repo = repo
        .canonicalize()
        .with_context(|| format!("repo path {}", repo.display()))?;
    let path = pipeline::db_path(&repo);
    if !path.exists() {
        bail!("no graph at {} — run `sinter build` first", path.display());
    }
    // Freshness lives at the one-shot query boundary. The MCP server owns
    // an event-driven generation and calls open_current after synchronizing.
    pipeline::build(&repo, None)?;
    open_current(&repo)
}

/// Open an already-synchronized graph. This is deliberately crate-private:
/// only the MCP freshness owner may bypass the one-shot query scan.
pub(crate) fn open_current(repo: &Path) -> Result<Store> {
    let repo = pipeline::discover_root(repo);
    let repo = repo
        .canonicalize()
        .with_context(|| format!("repo path {}", repo.display()))?;
    let path = pipeline::db_path(&repo);
    if !path.exists() {
        bail!("no graph at {} — run `sinter build` first", path.display());
    }
    let store = Store::open(&path)?;
    // A 0-node graph answers every query with "no match" — say what is
    // actually wrong instead.
    if store.node_count()? == 0 {
        bail!(
            "graph at {} is empty — was `sinter build` run in the right directory?",
            path.display()
        );
    }
    Ok(store)
}

/// Nodes matching a symbol argument: full node id, exact name, or qualified
/// suffix (`Config::new`), optionally narrowed by a file-path suffix
/// (`run@init.rs`, `new@cli/src/config.rs`) — the disambiguator an agent
/// can derive from the candidate list without copying a byte-offset id.
/// Empty result falls back to fuzzy suggestions.
pub enum Found {
    Exact(Vec<Node>),
    Suggestions(Vec<Node>),
}

pub fn find_symbol(store: &Store, symbol: &str) -> Result<Found> {
    if symbol.contains('#') {
        if let Some(node) = store.node(&sinter_core::NodeId::new(symbol))? {
            return Ok(Found::Exact(vec![node]));
        }
        return Ok(Found::Suggestions(Vec::new()));
    }
    let (symbol, file) = match symbol.rsplit_once('@') {
        Some((s, f)) if !s.is_empty() && !f.is_empty() => (s, Some(f)),
        _ => (symbol, None),
    };
    let name = symbol.rsplit("::").next().unwrap_or(symbol);
    let mut matches: Vec<Node> = store
        .nodes_named(name)?
        .into_iter()
        .filter(|n| {
            let q = qualified_of(n.id.as_str());
            (q == symbol || q.ends_with(&format!("::{symbol}")))
                && file.is_none_or(|f| n.file == f || n.file.ends_with(&format!("/{f}")))
        })
        .collect();
    matches.sort_by(|a, b| a.id.cmp(&b.id));
    if matches.is_empty() {
        Ok(Found::Suggestions(store.search(symbol, 10)?))
    } else {
        Ok(Found::Exact(matches))
    }
}

/// Exactly one node or a listed-candidates error.
pub fn unique_symbol(store: &Store, symbol: &str) -> Result<Node> {
    match find_symbol(store, symbol)? {
        Found::Exact(mut nodes) if nodes.len() == 1 => Ok(nodes.remove(0)),
        Found::Exact(nodes) => {
            let list: Vec<String> = nodes
                .iter()
                .map(|n| {
                    format!(
                        "  {}@{}  ({}, id {})",
                        qualified_of(n.id.as_str()),
                        n.file,
                        n.kind.as_str(),
                        n.id.as_str()
                    )
                })
                .collect();
            bail!(
                "`{symbol}` is ambiguous — rerun with one of these (`name@file`; a \
                 file-path suffix is enough) or the node id:\n{}",
                list.join("\n")
            )
        }
        Found::Suggestions(nodes) if nodes.is_empty() => Err(NoMatch(format!(
            "no symbol matches `{symbol}` — try `sinter ask \"{symbol}\"` for concept search"
        ))
        .into()),
        Found::Suggestions(nodes) => {
            let list: Vec<String> = nodes
                .iter()
                .map(|n| format!("  {}", qualified_of(n.id.as_str())))
                .collect();
            Err(NoMatch(format!(
                "no exact match for `{symbol}`; close names:\n{}",
                list.join("\n")
            ))
            .into())
        }
    }
}

/// One place a symbol not defined in this repo is referenced: the
/// enclosing definition (or file) and how many refs it holds.
pub struct ExternalSite {
    pub file: String,
    pub enclosing: Option<String>,
    pub refs: usize,
}

/// Reference sites for a symbol the corpus does not define — dependency
/// blast radius at the repo boundary ("what here touches tokio::spawn").
/// Qualified queries must match the written path's tail; bare names match
/// the final segment.
pub fn external_sites(store: &Store, symbol: &str) -> Result<Vec<ExternalSite>> {
    let tail = symbol.rsplit([':', '/', '.']).next().unwrap_or(symbol);
    if tail.is_empty() {
        return Ok(Vec::new());
    }
    let matches = |written: &str| {
        written == symbol
            || (written.ends_with(symbol)
                && written[..written.len() - symbol.len()]
                    .chars()
                    .next_back()
                    .is_some_and(|c| !c.is_alphanumeric() && c != '_'))
    };
    let files = store.ref_files(&BTreeSet::from([tail.to_string()]))?;
    let mut sites: std::collections::BTreeMap<(String, Option<String>), usize> =
        std::collections::BTreeMap::new();
    for file in files {
        for r in store.references_in(&file)? {
            let written = r.path.as_deref().unwrap_or(&r.name);
            if matches(written) || matches(&r.name) {
                let enclosing = r.enclosing.map(|id| qualified_of(id.as_str()).to_string());
                *sites.entry((r.file, enclosing)).or_default() += 1;
            }
        }
    }
    Ok(sites
        .into_iter()
        .map(|((file, enclosing), refs)| ExternalSite {
            file,
            enclosing,
            refs,
        })
        .collect())
}

/// --evidence / --certain flags to an EdgeFilter.
pub fn edge_filter(evidence: &[String], certain: bool) -> Result<EdgeFilter> {
    let evidence = if evidence.is_empty() {
        None
    } else {
        let mut set = BTreeSet::new();
        for e in evidence {
            set.insert(match e.as_str() {
                "structural" => Evidence::Structural,
                "scope" => Evidence::Scope,
                "import" => Evidence::Import,
                "scip" => Evidence::Scip,
                "dynamic" => Evidence::Dynamic,
                other => bail!("unknown evidence kind `{other}`"),
            });
        }
        Some(set)
    };
    Ok(EdgeFilter {
        evidence,
        min_confidence: certain.then_some(Confidence::Certain),
        relations: None,
    })
}

/// --relations names to the traversal's relation set; empty = all.
pub fn relation_set(relations: &[String]) -> Result<Option<BTreeSet<sinter_core::Relation>>> {
    if relations.is_empty() {
        return Ok(None);
    }
    let mut set = BTreeSet::new();
    for r in relations {
        set.insert(match r.as_str() {
            "calls" => sinter_core::Relation::Calls,
            "uses" => sinter_core::Relation::Uses,
            "imports" => sinter_core::Relation::Imports,
            "implements" => sinter_core::Relation::Implements,
            "extends" => sinter_core::Relation::Extends,
            other => {
                bail!("unknown relation `{other}` (calls, uses, imports, implements, extends)")
            }
        });
    }
    Ok(Some(set))
}

/// Content-bearing one-node listing (R3): the reader should not need to
/// open the file.
pub fn print_node(node: &Node) {
    println!(
        "{} {}  {}:{}..{}",
        node.kind.as_str(),
        qualified_of(node.id.as_str()),
        node.file,
        node.span.start,
        node.span.end
    );
    if !node.signature.is_empty() {
        println!("    {}", node.signature);
    }
    if let Some(doc) = &node.doc {
        for line in doc.lines().take(3) {
            println!("    /// {line}");
        }
    }
}