nornir 0.4.13

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Dependency-Mímir query helpers over a built [`WorkspaceGraph`].
//!
//! Shared by the embedded CLI/MCP paths and the `nornir-server` `Mimir` gRPC
//! service so both compute **identical** JSON for a dep-graph question. Each
//! query takes a `&WorkspaceGraph` and returns a `serde_json::Value` (the same
//! shape the MCP tools have always emitted); the server wraps the value as a
//! JSON string in its `JsonResponse`, the embedded path emits it directly.

use std::path::{Path, PathBuf};

use anyhow::{bail, Result};
use serde_json::{json, Value};

use crate::config::Loaded;
use crate::warehouse::dep_graph::WorkspaceGraph;
use crate::warehouse::iceberg::IcebergWarehouse;
use crate::workspace::descriptor::WorkspaceDescriptor;

/// Resolve the `nornir-workspace.toml` describing the repos to graph, searching
/// conventional locations relative to the loaded `nornir.toml`. (The MCP layers
/// a `NORNIR_WORKSPACE`-env override on top of this for the *client* side.)
pub fn resolve_descriptor(loaded: &Loaded) -> Result<PathBuf> {
    resolve_descriptor_at(&loaded.workspace_root, &loaded.config_path)
}

/// Path-based form of [`resolve_descriptor`] — lets the server resolve without
/// holding the `Loaded` lock across the (blocking) graph build.
pub fn resolve_descriptor_at(workspace_root: &Path, config_path: &Path) -> Result<PathBuf> {
    let mut candidates = vec![
        workspace_root.join("nornir-workspace.toml"),
        workspace_root.join("workspace_holger/nornir-workspace.toml"),
    ];
    if let Some(dir) = config_path.parent() {
        candidates.push(dir.join("nornir-workspace.toml"));
    }
    for c in &candidates {
        if c.exists() {
            return Ok(c.clone());
        }
    }
    bail!(
        "no nornir-workspace.toml found (set NORNIR_WORKSPACE or create one); searched: {}",
        candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
    )
}

/// Build the cross-repo dependency graph for `loaded`, returning it alongside
/// the workspace name. Heavy (drives `cargo metadata`); callers should cache.
pub fn build_graph(loaded: &Loaded) -> Result<(WorkspaceGraph, String)> {
    build_graph_at(&loaded.workspace_root, &loaded.config_path)
}

/// Path-based form of [`build_graph`] for the server (avoids cloning `Loaded`).
pub fn build_graph_at(workspace_root: &Path, config_path: &Path) -> Result<(WorkspaceGraph, String)> {
    let path = resolve_descriptor_at(workspace_root, config_path)?;
    let desc = WorkspaceDescriptor::load(&path)?;
    let graph = WorkspaceGraph::build(&desc)?;
    let name = desc.workspace.name.clone();
    Ok((graph, name))
}

fn ensure_repo(g: &WorkspaceGraph, repo: &str) -> Result<()> {
    if g.facts.contains_key(repo) {
        Ok(())
    } else {
        let known: Vec<&str> = g.facts.keys().map(String::as_str).collect();
        bail!("unknown repo `{repo}`; known repos: {}", known.join(", "))
    }
}

/// `deps_of`: repos `repo` depends on — direct edges with justifying crates, or
/// the full forward closure when `transitive`.
pub fn deps_of(g: &WorkspaceGraph, repo: &str, transitive: bool) -> Result<Value> {
    ensure_repo(g, repo)?;
    Ok(if transitive {
        json!({
            "repo": repo, "transitive": true,
            "dependencies": g.deps_transitive(repo).into_iter().collect::<Vec<_>>(),
        })
    } else {
        let direct: Vec<_> = g
            .dependencies_of(repo)
            .into_iter()
            .map(|e| json!({ "repo": e.to, "via": e.via.iter().cloned().collect::<Vec<_>>() }))
            .collect();
        json!({ "repo": repo, "transitive": false, "dependencies": direct })
    })
}

/// `dependents_of`: repos that depend ON `repo` (the blast radius).
pub fn dependents_of(g: &WorkspaceGraph, repo: &str, transitive: bool) -> Result<Value> {
    ensure_repo(g, repo)?;
    Ok(if transitive {
        json!({
            "repo": repo, "transitive": true,
            "dependents": g.dependents_transitive(repo).into_iter().collect::<Vec<_>>(),
        })
    } else {
        let direct: Vec<_> = g
            .dependents_of(repo)
            .into_iter()
            .map(|e| json!({ "repo": e.from, "via": e.via.iter().cloned().collect::<Vec<_>>() }))
            .collect();
        json!({ "repo": repo, "transitive": false, "dependents": direct })
    })
}

/// `affected_by_change`: changed repos + everything that transitively depends on
/// them, in build order.
pub fn affected_by_change(g: &WorkspaceGraph, repos: &[String]) -> Result<Value> {
    for r in repos {
        ensure_repo(g, r)?;
    }
    Ok(json!({ "changed": repos, "affected": g.affected_by_change(repos) }))
}

/// `build_order`: full workspace build order (deps before dependents).
pub fn build_order(g: &WorkspaceGraph) -> Result<Value> {
    Ok(json!({ "build_order": g.build_order()? }))
}

/// `dep_path`: shortest dependency path `from`→`to`, annotated with `via` crates.
pub fn dep_path(g: &WorkspaceGraph, from: &str, to: &str) -> Result<Value> {
    ensure_repo(g, from)?;
    ensure_repo(g, to)?;
    Ok(match g.dep_path(from, to) {
        Some(path) => {
            let hops: Vec<_> = path
                .windows(2)
                .map(|w| {
                    let via: Vec<String> = g
                        .dependencies_of(&w[0])
                        .into_iter()
                        .find(|e| e.to == w[1])
                        .map(|e| e.via.iter().cloned().collect())
                        .unwrap_or_default();
                    json!({ "from": w[0], "to": w[1], "via": via })
                })
                .collect();
            json!({ "from": from, "to": to, "path": path, "hops": hops })
        }
        None => json!({ "from": from, "to": to, "path": null, "hops": [] }),
    })
}

/// `external_dep_users`: workspace repos consuming external crate `krate`.
pub fn external_dep_users(g: &WorkspaceGraph, krate: &str) -> Value {
    json!({ "crate": krate, "users": g.external_dep_users(krate) })
}

/// `repo_overview`: one-shot orientation — `repo`'s internal deps + dependents
/// (with justifying crates), its build-order index, and a knowledge digest
/// (symbol/call counts + a sample of symbol names) read from the warehouse.
/// The knowledge digest is best-effort (`null` when no syn scan is persisted).
/// `wh` may block (warehouse open/scan); callers should run on a blocking thread.
pub fn repo_overview(g: &WorkspaceGraph, wh: &IcebergWarehouse, repo: &str) -> Result<Value> {
    ensure_repo(g, repo)?;
    let depends_on: Vec<_> = g
        .dependencies_of(repo)
        .into_iter()
        .map(|e| json!({ "repo": e.to, "via": e.via.iter().cloned().collect::<Vec<_>>() }))
        .collect();
    let dependents: Vec<_> = g
        .dependents_of(repo)
        .into_iter()
        .map(|e| json!({ "repo": e.from, "via": e.via.iter().cloned().collect::<Vec<_>>() }))
        .collect();
    let build_order_index = g
        .build_order()
        .ok()
        .and_then(|order| order.iter().position(|r| r == repo));
    let knowledge = crate::knowledge::query::load_latest(wh, repo).ok().map(|view| {
        let sample: Vec<String> = view.symbols.iter().take(15).map(|s| s.item_name.clone()).collect();
        json!({
            "symbols": view.symbols.len(),
            "call_edges": view.calls.len(),
            "sample_symbols": sample,
        })
    });
    Ok(json!({
        "repo": repo,
        "depends_on": depends_on,
        "dependents": dependents,
        "build_order_index": build_order_index,
        "knowledge": knowledge,
    }))
}

/// Render the cross-repo dependency graph as a Mermaid flowchart.
pub fn mermaid(g: &WorkspaceGraph) -> String {
    use std::fmt::Write;
    let mut out = String::from("graph LR\n");
    for name in g.facts.keys() {
        let _ = writeln!(out, "  {}[\"{}\"]", mermaid_id(name), name);
    }
    for e in &g.edges {
        let via: Vec<&str> = e.via.iter().map(String::as_str).collect();
        let _ = writeln!(
            out,
            "  {} -->|\"{}\"| {}",
            mermaid_id(&e.from),
            via.join(", "),
            mermaid_id(&e.to),
        );
    }
    out
}

fn mermaid_id(name: &str) -> String {
    name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect()
}

#[cfg(test)]
mod tests {
    use super::mermaid_id;

    #[test]
    fn mermaid_id_sanitizes_non_alnum() {
        assert_eq!(mermaid_id("snippy-core"), "snippy_core");
        assert_eq!(mermaid_id("a.b/c"), "a_b_c");
        assert_eq!(mermaid_id("plain"), "plain");
    }
}