nornir 0.4.27

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))
}

/// Rebuild a **query-only** [`WorkspaceGraph`] for `workspace_name` from the
/// warehouse's `dep_graph_edges` table — the cross-repo edges the monitor
/// already persisted on each republish — instead of demanding a
/// `nornir-workspace.toml` in the checkout.
///
/// This is the fix for monitored workspaces like **njord**: the server's
/// synthetic `Loaded` points `workspace_root` at the `git/` checkout, which has
/// no descriptor file, so [`build_graph_at`] fails ("no nornir-workspace.toml
/// found"). But the monitor has already recorded the dep-graph into the
/// warehouse the server owns, so we read the **latest** snapshot's edges and
/// hand them to [`WorkspaceGraph::from_query_parts`]. `facts` stays empty
/// (no `cargo metadata`), so build-order/topo is unavailable; every edge-based
/// query (`deps-of`, `dependents-of`, `mermaid`, `overview`, …) works because
/// they read `edges` + `component_names`.
///
/// `members` are the workspace's known repo names (from the served config) —
/// they seed the component set so a **single-repo** workspace (like njord, whose
/// only member is `njord` and which therefore records *zero* cross-repo edges)
/// still resolves its own repo in `deps-of`/`mermaid`. Without this seed an
/// edge-less graph would have no components at all.
///
/// Returns `Ok(None)` when the table has no snapshot for this workspace yet
/// **and** there are no members to seed — the caller can then surface the
/// descriptor error.
pub fn build_graph_from_warehouse(
    wh: &IcebergWarehouse,
    workspace_name: &str,
    members: &[String],
) -> Result<Option<WorkspaceGraph>> {
    use crate::warehouse::dep_graph::{query_dep_graph_snapshots, RepoFacts};
    use std::collections::BTreeMap;
    // Only the latest snapshot — that's the current graph.
    let snaps = wh.block_on(query_dep_graph_snapshots(wh, workspace_name, Some(1)))?;
    // Drop placeholder rows (empty endpoints recorded for a zero-edge snapshot).
    let edges: Vec<_> = snaps
        .into_iter()
        .next_back()
        .map(|s| s.edges)
        .unwrap_or_default()
        .into_iter()
        .filter(|e| !e.from.is_empty() && !e.to.is_empty())
        .collect();

    if edges.is_empty() && members.is_empty() {
        return Ok(None);
    }

    // Seed minimal facts for the known members so single-repo workspaces resolve
    // their own repo. `produces`/`consumes` stay empty (no cargo metadata here);
    // the edge-based queries don't need them.
    let mut facts: BTreeMap<String, RepoFacts> = BTreeMap::new();
    for m in members {
        if m.is_empty() {
            continue;
        }
        facts.insert(
            m.clone(),
            RepoFacts {
                name: m.clone(),
                root: Default::default(),
                produces: Default::default(),
                consumes: Default::default(),
            },
        );
    }
    let graph = WorkspaceGraph::from_query_parts(facts, edges);
    Ok(Some(graph))
}

fn ensure_repo(g: &WorkspaceGraph, repo: &str) -> Result<()> {
    // Accept any known *component* — a `facts` key OR an edge endpoint — so a
    // graph rebuilt from the warehouse's `dep_graph_edges` (empty `facts`, real
    // `edges`; the monitored-workspace fallback) resolves its repos too.
    if g.has_component(repo) {
        Ok(())
    } else {
        let known = g.component_names();
        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");
    // Declare every known component (facts keys ∪ edge endpoints) so a graph
    // rebuilt from the warehouse (empty `facts`) still names its nodes.
    for name in g.component_names() {
        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::*;
    use crate::warehouse::dep_graph::CrossRepoEdge;
    use std::collections::BTreeSet;

    #[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");
    }

    fn edge(from: &str, to: &str, via: &[&str]) -> CrossRepoEdge {
        CrossRepoEdge {
            from: from.into(),
            to: to.into(),
            via: via.iter().map(|s| s.to_string()).collect(),
        }
    }

    /// The monitored-workspace path: a graph rebuilt from the warehouse's
    /// `dep_graph_edges` has **empty `facts`** but real `edges` (exactly what
    /// `build_graph_from_warehouse` produces for njord). INJECT such a graph and
    /// ASSERT every edge-based Mímir query resolves its repos and returns the
    /// recorded structure — i.e. `nornir mimir deps-of/dependents-of/mermaid
    /// njord` works without a `nornir-workspace.toml`.
    #[test]
    fn edges_only_graph_answers_queries_without_facts() {
        // njord → facett → egui (recorded edges only; no cargo metadata).
        let edges = vec![
            edge("njord", "facett", &["facett"]),
            edge("facett", "egui_kernel", &["egui_kernel"]),
        ];
        let g = WorkspaceGraph::from_query_parts(Default::default(), edges);
        assert!(g.facts.is_empty(), "fallback graph carries no cargo facts");

        // ensure_repo must accept an edge-endpoint repo (the bug: it only
        // checked `facts`, which is empty here).
        assert!(ensure_repo(&g, "njord").is_ok(), "njord is a known component");
        assert!(ensure_repo(&g, "nope").is_err(), "unknown repo still rejected");

        // deps-of (direct): njord depends on facett via the `facett` crate.
        let direct = deps_of(&g, "njord", false).unwrap();
        let deps = direct["dependencies"].as_array().unwrap();
        assert_eq!(deps.len(), 1);
        assert_eq!(deps[0]["repo"], "facett");

        // deps-of (transitive): njord reaches egui_kernel through facett.
        let trans = deps_of(&g, "njord", true).unwrap();
        let tset: BTreeSet<String> = trans["dependencies"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v.as_str().unwrap().to_string())
            .collect();
        assert!(tset.contains("facett") && tset.contains("egui_kernel"), "closure = {tset:?}");

        // dependents-of: facett's blast radius includes njord.
        let users = dependents_of(&g, "facett", false).unwrap();
        let u = users["dependents"].as_array().unwrap();
        assert!(u.iter().any(|v| v["repo"] == "njord"), "njord depends on facett");

        // mermaid: every component is declared as a node even with empty facts.
        let mm = mermaid(&g);
        for repo in ["njord", "facett", "egui_kernel"] {
            assert!(mm.contains(&format!("[\"{repo}\"]")), "mermaid missing node {repo}: {mm}");
        }
        assert!(mm.contains("njord -->"), "mermaid has the njord→facett edge");
    }

    /// A **single-repo** workspace (njord's real shape) records zero cross-repo
    /// edges. The graph must still know its own repo so `deps-of njord` returns
    /// "no deps" instead of "unknown repo" — seeded from the member list.
    #[test]
    fn single_repo_workspace_resolves_via_member_seed() {
        // No edges, one member `njord` — exactly what build_graph_from_warehouse
        // produces for njord after the placeholder rows are dropped.
        let mut facts = std::collections::BTreeMap::new();
        facts.insert(
            "njord".to_string(),
            crate::warehouse::dep_graph::RepoFacts {
                name: "njord".into(),
                root: Default::default(),
                produces: Default::default(),
                consumes: Default::default(),
            },
        );
        let g = WorkspaceGraph::from_query_parts(facts, Vec::new());

        assert!(g.has_component("njord"), "seeded member is a known component");
        assert!(ensure_repo(&g, "njord").is_ok(), "deps-of njord must not error");
        let d = deps_of(&g, "njord", false).unwrap();
        assert_eq!(d["dependencies"].as_array().unwrap().len(), 0, "njord has no deps");
        assert!(mermaid(&g).contains("[\"njord\"]"), "mermaid declares njord");
    }
}