Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/mimir.rs

//! 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::{Result, bail};
use serde_json::{Value, json};

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> {
    // An explicit `NORNIR_WORKSPACE` pin (a file, or a dir holding the
    // descriptor) always wins — same override the MCP client layer honours.
    if let Some(pin) = std::env::var_os("NORNIR_WORKSPACE") {
        let p = PathBuf::from(pin);
        if !p.as_os_str().is_empty() {
            let cand = if p.is_dir() {
                p.join("nornir-workspace.toml")
            } else {
                p
            };
            if cand.exists() {
                return Ok(cand);
            }
        }
    }
    let mut candidates = vec![
        workspace_root.join("nornir-workspace.toml"),
        workspace_root.join("workspace_holger/nornir-workspace.toml"),
    ];
    // The descriptor conventionally sits in the `workspace_*/` dir itself — i.e.
    // the GRANDPARENT of a `workspace_<name>/release/nornir.toml` config. Without
    // this candidate, standing in `workspace_nordisk` (where `discover` sets
    // `workspace_root` to the parent `git/` and `config_path` to
    // `workspace_nordisk/release/nornir.toml`) never finds
    // `workspace_nordisk/nornir-workspace.toml`, so `funnel plan-from` (and any
    // dep-graph query) failed even though the descriptor was right there. This
    // makes the `workspace_*` dir a first-class search location for ANY workspace
    // name, not just the legacy hardcoded `workspace_holger`.
    if let Some(ws_dir) = config_path.parent().and_then(Path::parent) {
        candidates.push(ws_dir.join("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)?;
    // The mirror of the config-side refusal: a `[repo.x]` CONFIG handed to the
    // descriptor loader gets an error naming both spellings, not serde's
    // `missing field workspace`.
    crate::config_spelling::check_descriptor_file(&path)?;
    let desc = WorkspaceDescriptor::load(&path)?;
    let graph = crate::warehouse::dep_graph::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`, `svg`, `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`/`svg`. Without this seed an
/// edge-less graph would have no components at all.
///
/// `git_root` is the directory holding the members' on-disk checkouts — for a
/// monitored workspace this is the synthetic `Loaded.workspace_root` (the
/// `<registry_root>/<ws>/git` dir the monitor clones each member into, as
/// `<git_root>/<member>`). Each seeded [`RepoFacts::root`] is set to
/// `<git_root>/<member>` so live-`HEAD` change-detection
/// ([`crate::change::detect`]) can read each repo's SHA via
/// [`crate::gitio::head_sha`]. (Previously `root` was left empty, which
/// dead-ended change-detection on the first member with a "read HEAD" error.)
///
/// 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],
    git_root: &Path,
) -> Result<Option<WorkspaceGraph>> {
    use crate::warehouse::dep_graph::{RepoFacts, query_dep_graph_snapshots};
    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. `root` IS populated with the
    // member's real on-disk checkout (`<git_root>/<member>`) so live-HEAD
    // change-detection can read each repo's SHA.
    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: git_root.join(m),
                produces: Default::default(),
                consumes: Default::default(),
                dev_only_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 the knowledge block.
///
/// A door that reaches no code room (this signature has only the warehouse)
/// gets the knowledge block's STATE (`no_room`), never an empty count; the
/// doors that hold a room call [`repo_overview_in`].
pub fn repo_overview(g: &WorkspaceGraph, wh: &IcebergWarehouse, repo: &str) -> Result<Value> {
    repo_overview_in(g, &crate::code_room::Reach { room: None, warehouse: Some(wh) }, repo)
}

/// [`repo_overview`] with the knowledge block read from the code room the door
/// reaches: what the room holds of `repo` (consumed offset, symbol / call /
/// test / file counts, a sample of names, the room's own stats) with its
/// evidence — or, when the room cannot answer, the refusal's state and
/// sentence (`not_consumed`, `no_room`, `lake_unavailable` …).
pub fn repo_overview_in(g: &WorkspaceGraph, reach: &crate::code_room::Reach<'_>, 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 = match crate::code_room::reader::knowledge_block(reach, repo) {
        Ok(block) => block,
        Err(refusal) => refusal.to_json(),
    };
    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 **self-contained static SVG**
/// (layered left-to-right DAG, `via`-labelled edges). No JavaScript, no diagram
/// engine, no build step (NUKE-MERMAID house style). Every known component
/// (facts keys ∪ edge endpoints) is drawn
/// as a node so a graph rebuilt from the warehouse (empty `facts`) still names
/// its nodes.
pub fn svg(g: &WorkspaceGraph) -> String {
    use std::collections::HashMap;
    use std::fmt::Write;

    let nodes = g.component_names();
    if nodes.is_empty() {
        return String::from(
            "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"160\" height=\"40\">\
             <text x=\"8\" y=\"24\" font-family=\"sans-serif\" font-size=\"12\">(empty graph)</text></svg>\n",
        );
    }
    let idx: HashMap<&str, usize> = nodes
        .iter()
        .enumerate()
        .map(|(i, s)| (s.as_str(), i))
        .collect();

    // Longest-path column assignment (sources at column 0), cycle-safe via a
    // bounded relaxation pass over the edges.
    let n = nodes.len();
    let mut col = vec![0usize; n];
    for _ in 0..n {
        let mut changed = false;
        for e in &g.edges {
            if let (Some(&f), Some(&t)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
                if f != t && col[t] <= col[f] {
                    col[t] = col[f] + 1;
                    changed = true;
                }
            }
        }
        if !changed {
            break;
        }
    }
    let cols = col.iter().copied().max().unwrap_or(0) + 1;
    let mut next_row = vec![0usize; cols];
    let mut row = vec![0usize; n];
    for i in 0..n {
        row[i] = next_row[col[i]];
        next_row[col[i]] += 1;
    }
    let rows = next_row.iter().copied().max().unwrap_or(1).max(1);

    let col_w = 200.0f64;
    let row_h = 44.0f64;
    let box_w = 150.0f64;
    let box_h = 26.0f64;
    let margin = 14.0f64;
    let width = margin * 2.0 + col_w * cols as f64;
    let height = margin * 2.0 + row_h * rows as f64;
    let pos = |i: usize| -> (f64, f64) {
        (
            margin + col[i] as f64 * col_w,
            margin + row[i] as f64 * row_h,
        )
    };

    let mut out = String::new();
    let _ = write!(
        out,
        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width:.0}\" height=\"{height:.0}\" \
         viewBox=\"0 0 {width:.0} {height:.0}\" font-family=\"sans-serif\" font-size=\"11\">\n"
    );
    out.push_str(
        "<defs><marker id=\"arrow\" markerWidth=\"8\" markerHeight=\"8\" refX=\"7\" refY=\"3\" \
         orient=\"auto\"><path d=\"M0,0 L7,3 L0,6 Z\" fill=\"#5a6876\"/></marker></defs>\n",
    );
    for e in &g.edges {
        let (Some(&fi), Some(&ti)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
            continue;
        };
        let (fx, fy) = pos(fi);
        let (tx, ty) = pos(ti);
        let x1 = fx + box_w;
        let y1 = fy + box_h / 2.0;
        let x2 = tx;
        let y2 = ty + box_h / 2.0;
        let _ = write!(
            out,
            "<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
             stroke=\"#2850a0\" stroke-width=\"1\" marker-end=\"url(#arrow)\"/>\n"
        );
        let via: Vec<&str> = e.via.iter().map(String::as_str).collect();
        if !via.is_empty() {
            let _ = write!(
                out,
                "<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\" fill=\"#5a6876\" font-size=\"9\">{}</text>\n",
                (x1 + x2) / 2.0,
                (y1 + y2) / 2.0 - 3.0,
                svg_escape(&via.join(", ")),
            );
        }
    }
    for (i, name) in nodes.iter().enumerate() {
        let (x, y) = pos(i);
        let _ = write!(
            out,
            "<rect x=\"{x:.0}\" y=\"{y:.0}\" width=\"{box_w:.0}\" height=\"{box_h:.0}\" rx=\"3\" \
             fill=\"#f5f7fc\" stroke=\"#3c3c50\" stroke-width=\"0.7\"/>\n"
        );
        let _ = write!(
            out,
            "<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\">{}</text>\n",
            x + box_w / 2.0,
            y + box_h / 2.0 + 4.0,
            svg_escape(name),
        );
    }
    out.push_str("</svg>\n");
    out
}

/// XML-escape a label for safe inclusion in SVG text.
fn svg_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::warehouse::dep_graph::CrossRepoEdge;
    use std::collections::BTreeSet;

    #[test]
    fn svg_escape_handles_xml_specials() {
        assert_eq!(svg_escape("a&b"), "a&amp;b");
        assert_eq!(svg_escape("x<y>z"), "x&lt;y&gt;z");
        assert_eq!(svg_escape("plain"), "plain");
    }

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

    /// Regression for the `funnel plan-from` autodiscovery bug: standing in a
    /// `workspace_<name>/` dir, `discover` sets `workspace_root` to the parent
    /// `git/` and `config_path` to `workspace_<name>/release/nornir.toml`. The
    /// descriptor lives at `workspace_<name>/nornir-workspace.toml` (the config's
    /// GRANDPARENT) — which the old candidate list (root, workspace_holger/,
    /// release/) never searched. Assert we now resolve it for ANY workspace name.
    #[test]
    fn resolves_descriptor_in_workspace_dir_for_any_name() {
        let tmp = tempfile::tempdir().unwrap();
        let git_root = tmp.path();
        let ws_dir = git_root.join("workspace_nordisk");
        let release = ws_dir.join("release");
        std::fs::create_dir_all(&release).unwrap();
        let config_path = release.join("nornir.toml");
        std::fs::write(&config_path, "").unwrap();
        // The descriptor sits in the workspace dir, NOT under release/.
        let descriptor = ws_dir.join("nornir-workspace.toml");
        std::fs::write(&descriptor, "[workspace]\nname = \"nordisk\"\n").unwrap();

        // Guard against a NORNIR_WORKSPACE pin leaking in from a parallel test.
        let prev = std::env::var_os("NORNIR_WORKSPACE");
        // TODO: Audit that the environment access only happens in single-threaded code.
        unsafe { std::env::remove_var("NORNIR_WORKSPACE") };
        let got = resolve_descriptor_at(git_root, &config_path);
        if let Some(p) = prev {
            unsafe { std::env::set_var("NORNIR_WORKSPACE", p) };
        }

        let got = got.expect("descriptor in the workspace dir must be discovered");
        assert_eq!(
            got.canonicalize().unwrap(),
            descriptor.canonicalize().unwrap(),
            "should resolve workspace_<name>/nornir-workspace.toml"
        );
    }

    /// 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/svg
    /// 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"
        );

        // svg: every component is drawn as a node even with empty facts.
        let svg = svg(&g);
        assert!(svg.starts_with("<svg"), "svg root: {svg}");
        assert!(!svg.contains("mermaid"), "no mermaid: {svg}");
        for repo in ["njord", "facett", "egui_kernel"] {
            assert!(
                svg.contains(&format!(">{repo}</text>")),
                "svg missing node {repo}: {svg}"
            );
        }
        assert!(svg.contains("<line "), "svg 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(),
                dev_only_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!(svg(&g).contains(">njord</text>"), "svg declares njord");
    }

    /// The two blast-radius verbs the dwarves→Mímir feed leans on, over a small
    /// synthetic `app → mid → lib` workspace (app consumes mid, mid consumes
    /// lib; edges point consumer → producer). `affected_by_change(lib)` must
    /// pull in the WHOLE transitive dependent set, and `build_order` must be
    /// topological — a producer always precedes every repo that consumes it.
    #[test]
    fn affected_and_build_order_over_a_synthetic_workspace() {
        let edges = vec![edge("app", "mid", &["mid"]), edge("mid", "lib", &["lib"])];
        let g = WorkspaceGraph::from_query_parts(Default::default(), edges);

        // Changing the leaf `lib` re-runs its transitive dependents `mid` and `app`.
        let aff = affected_by_change(&g, &["lib".to_string()]).unwrap();
        assert_eq!(aff["changed"], json!(["lib"]), "echoes the changed set");
        let affected: BTreeSet<String> = aff["affected"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v.as_str().unwrap().to_string())
            .collect();
        assert!(
            affected.contains("lib") && affected.contains("mid") && affected.contains("app"),
            "lib's blast radius must include its transitive dependents: {affected:?}"
        );

        // build_order is deps-first: lib before mid before app.
        let bo = build_order(&g).unwrap();
        let order: Vec<String> = bo["build_order"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v.as_str().unwrap().to_string())
            .collect();
        let pos = |r: &str| {
            order
                .iter()
                .position(|x| x == r)
                .unwrap_or_else(|| panic!("{r} in {order:?}"))
        };
        assert!(pos("lib") < pos("mid"), "lib builds before mid: {order:?}");
        assert!(pos("mid") < pos("app"), "mid builds before app: {order:?}");
    }
}

/// `mimir <op>` rendered as the uniform [`crate::cli_outcome::CommandOutcome`] — the
/// shared face for BOTH the fat (`run_mimir`) and thin (`run_mimir_remote`) CLI
/// paths, so they emit the SAME shape (parity by construction). mimir is JSON-native
/// (always prints the machine form), so `human` is left empty rather than carrying an
/// escaped dup of `data`. `ok ⟺ a non-empty result` (an empty list / null — e.g. no
/// dep-path, no external users — is RED). AUT9.
pub fn outcome(cmd: &str, v: Value) -> crate::cli_outcome::CommandOutcome {
    use crate::cli_outcome::CommandOutcome;
    let empty = v.is_null()
        || v.as_array().map(|a| a.is_empty()).unwrap_or(false)
        || v.as_object().map(|o| o.is_empty()).unwrap_or(false);
    if empty {
        CommandOutcome::fail(cmd, format!("{cmd}: no result"))
    } else {
        CommandOutcome::ok(cmd, v, String::new())
    }
}

#[cfg(test)]
mod outcome_tests {
    use super::*;

    #[test]
    fn mimir_outcome_is_sannr_for_data_red_for_empty() {
        // a real structured result is sannr (truth).
        let o = outcome("mimir deps-of", json!({ "dependencies": ["a", "b"] }));
        assert!(o.is_sannr());
        assert_eq!(o.command, "mimir deps-of");
        assert_eq!(o.data["dependencies"].as_array().unwrap().len(), 2);
        // top-level empty list / null / object ⇒ RED (no result).
        assert!(!outcome("mimir external-users", json!([])).is_sannr());
        assert!(!outcome("mimir dep-path", Value::Null).is_sannr());
        assert!(!outcome("mimir overview", json!({})).is_sannr());
    }
}