magellan 4.6.0

Deterministic codebase mapping tool for local development
Documentation
use anyhow::Result;
use magellan::graph::navigator::{DepthSymbol, SymbolInfo, TypedEdgeHop};
use magellan::graph::CodeGraph;
use serde::Serialize;
use std::path::PathBuf;

#[derive(Debug, Clone)]
pub enum OutputFormat {
    Human,
    Json,
}

pub struct ExploreConfig {
    pub db_path: PathBuf,
    pub symbol: Option<String>,
    pub id: Option<i64>,
    pub edges: bool,
    pub callers: bool,
    pub callees: bool,
    pub chain: Option<String>,
    pub depth: u32,
    pub format: OutputFormat,
}

#[derive(Serialize)]
struct ExploreResponse {
    node: Option<NodeResponse>,
    resolve: Option<Vec<NodeResponse>>,
    edges: Option<Vec<EdgeResponse>>,
    callers: Option<Vec<DepthResponse>>,
    callees: Option<Vec<DepthResponse>>,
    chain: Option<Vec<NodeResponse>>,
}

#[derive(Serialize)]
struct NodeResponse {
    id: i64,
    name: String,
    kind: String,
    file: Option<String>,
    line: usize,
}

#[derive(Serialize)]
struct EdgeResponse {
    edge_type: String,
    direction: String,
    target: NodeResponse,
}

#[derive(Serialize)]
struct DepthResponse {
    depth: u32,
    node: NodeResponse,
}

fn to_node(info: &SymbolInfo) -> NodeResponse {
    NodeResponse {
        id: info.id,
        name: info.name.clone(),
        kind: info
            .kind_normalized
            .clone()
            .unwrap_or_else(|| info.kind.clone()),
        file: info.file_path.clone(),
        line: info.start_line,
    }
}

fn to_depth(ds: &DepthSymbol) -> DepthResponse {
    DepthResponse {
        depth: ds.depth,
        node: to_node(&ds.info),
    }
}

fn to_edge(hop: &TypedEdgeHop) -> EdgeResponse {
    EdgeResponse {
        edge_type: hop.edge_type.clone(),
        direction: match hop.direction {
            sqlitegraph::backend::BackendDirection::Outgoing => "out".to_string(),
            sqlitegraph::backend::BackendDirection::Incoming => "in".to_string(),
        },
        target: to_node(&hop.target),
    }
}

pub fn run_explore(cfg: ExploreConfig) -> Result<()> {
    let graph = CodeGraph::open(&cfg.db_path)?;
    let nav = graph.navigator();
    let is_json = matches!(cfg.format, OutputFormat::Json);

    let mut resp = ExploreResponse {
        node: None,
        resolve: None,
        edges: None,
        callers: None,
        callees: None,
        chain: None,
    };

    let target_id = match (cfg.symbol, cfg.id) {
        (Some(name), _) => {
            let results = nav.resolve(&name)?;
            if results.is_empty() {
                if is_json {
                    println!("{{\"error\": \"no symbols found for '{}'\"}}", name);
                } else {
                    println!("No symbols found for '{}'", name);
                }
                return Ok(());
            }
            if is_json {
                resp.resolve = Some(results.iter().map(to_node).collect());
            } else {
                for info in &results {
                    print_symbol(info);
                }
            }
            results[0].id
        }
        (None, Some(id)) => id,
        (None, None) => {
            anyhow::bail!("provide --symbol <name> or --id <id>");
        }
    };

    if let Some(info) = nav.info(target_id)? {
        resp.node = Some(to_node(&info));
        if !is_json {
            print_symbol(&info);
        }
    } else if !is_json {
        println!("No entity found with id {}", target_id);
    }

    if cfg.edges {
        let edges = nav.expand(target_id)?;
        if is_json {
            resp.edges = Some(edges.iter().map(to_edge).collect());
        } else {
            print_edges_human(&edges);
        }
    }

    if cfg.callers {
        let callers = nav.k_hop_callers(target_id, cfg.depth)?;
        if is_json {
            resp.callers = Some(callers.iter().map(to_depth).collect());
        } else if callers.is_empty() {
            println!("  (no callers)");
        } else {
            println!("  callers:");
            for c in &callers {
                let path = c.info.file_path.as_deref().unwrap_or("-");
                println!(
                    "    {} id={} depth={} ({})",
                    c.info.name, c.info.id, c.depth, path
                );
            }
        }
    }

    if cfg.callees {
        let callees = nav.k_hop_callees(target_id, cfg.depth)?;
        if is_json {
            resp.callees = Some(callees.iter().map(to_depth).collect());
        } else if callees.is_empty() {
            println!("  (no callees)");
        } else {
            println!("  callees:");
            for c in &callees {
                let path = c.info.file_path.as_deref().unwrap_or("-");
                println!(
                    "    {} id={} depth={} ({})",
                    c.info.name, c.info.id, c.depth, path
                );
            }
        }
    }

    if let Some(ref chain_spec) = cfg.chain {
        let steps = parse_chain_steps(chain_spec)?;
        let results = nav.chain(target_id, &steps)?;
        if is_json {
            resp.chain = Some(results.iter().map(to_node).collect());
        } else if results.is_empty() {
            println!("  (chain returned no results)");
        } else {
            println!("  chain:");
            for r in &results {
                let path = r.file_path.as_deref().unwrap_or("-");
                println!("    {} id={} ({})", r.name, r.id, path);
            }
        }
    }

    if is_json {
        println!("{}", serde_json::to_string_pretty(&resp)?);
    }

    Ok(())
}

fn print_symbol(info: &SymbolInfo) {
    let path = info.file_path.as_deref().unwrap_or("<unknown>");
    println!(
        "id={} kind={} name=\"{}\" file={}",
        info.id,
        info.kind_normalized.as_deref().unwrap_or(&info.kind),
        info.name,
        path
    );
}

fn print_edges_human(hops: &[TypedEdgeHop]) {
    if hops.is_empty() {
        println!("  (no edges)");
        return;
    }
    for hop in hops {
        let dir = match hop.direction {
            sqlitegraph::backend::BackendDirection::Outgoing => "->",
            sqlitegraph::backend::BackendDirection::Incoming => "<-",
        };
        let path = hop.target.file_path.as_deref().unwrap_or("-");
        println!(
            "  {} [{}] {} id={} ({})",
            dir, hop.edge_type, hop.target.name, hop.target.id, path
        );
    }
}

fn parse_chain_steps(spec: &str) -> Result<Vec<sqlitegraph::multi_hop::ChainStep>> {
    use sqlitegraph::backend::BackendDirection;
    use sqlitegraph::multi_hop::ChainStep;

    let mut steps = Vec::new();
    for part in spec.split(',') {
        let part = part.trim();
        if part.is_empty() {
            continue;
        }
        let (dir, edge_type) = if let Some(rest) = part.strip_prefix('<') {
            (BackendDirection::Incoming, rest.to_string())
        } else if let Some(rest) = part.strip_prefix('>') {
            (BackendDirection::Outgoing, rest.to_string())
        } else {
            (BackendDirection::Outgoing, part.to_string())
        };
        steps.push(ChainStep {
            direction: dir,
            edge_type: Some(edge_type),
        });
    }
    if steps.is_empty() {
        anyhow::bail!("chain spec must contain at least one step (e.g. '>CALLER,>CALLS')");
    }
    Ok(steps)
}