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('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[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&b");
assert_eq!(svg_escape("x<y>z"), "x<y>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());
}
}