src/arch/mod.rs
//! EPIC ARCH — the **architecture-wiring auto-generator** (n-006).
//!
//! Coarsen the fn-level knowledge facts (symbol + call rows of the code room) and
//! the `fn → warehouse-table` access fact (the room's `TOUCHES_TABLE` edges, as
//! [`crate::warehouse::access_scan::AccessEdge`] rows) into ONE component/gRPC/core/table wiring
//! graph — the picture `.nornir/architecture-wiring.md` §2 hand-derives, but
//! produced *by code*.
//!
//! The model is a **quotient** of the fn-level call graph by an *owner-fn*
//! classifier ([`classify`]):
//!
//! | owner-fn shape | node kind |
//! |--------------------------------------------------|----------------------|
//! | `impl Facet for T` (a viz pane / UI component) | [`NodeKind::Component`] (`T`) |
//! | `impl Backend for T` (a warehouse-gen backend) | [`NodeKind::Backend`] (`T`) |
//! | `impl Codec for T` (a compression engine) | [`NodeKind::Codec`] (`T`) |
//! | `impl *Sink for T` (`TestSink`/`SagaSink`/…) | [`NodeKind::Sink`] (`T`) |
//! | a gRPC service handler (`Svc::verb`) | [`NodeKind::Grpc`] (`Svc.verb`) |
//! | the table of an access row (`fn → table`) | [`NodeKind::Table`] (the table) |
//! | anything else | [`NodeKind::CoreFn`] (`crate::module`) |
//!
//! The marker traits + kinds are the [`DEFAULT_MARKER_TRAITS`] registry, so
//! adding an architectural layer is one line there — not a classifier rewrite.
//!
//! Collapsing every fn-level call edge `caller → callee` to
//! `class(caller) → class(callee)` turns the dense fn graph into the
//! component↔gRPC↔core↔table board. The `fn → table` access fact supplies the
//! `* → Table` edges directly (a call graph never yields "this fn writes that
//! table"). Self-edges and duplicate edges are dropped, so e.g. the Test tab's
//! `fetch_test_results → test_results` collapses to `TestTab → test_results`.
//!
//! The graph renders [`to_svg`] in the **skade circuit-board** house style — a
//! direct-emit SVG (no mermaid, no typst layout engine), so it is available in
//! every feature set. It is generated/exported by `nornir arch [svg]`.
pub mod metro;
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::knowledge::symbols::{CallEdgeRow, SymbolRow};
use crate::warehouse::access_scan::{Access, AccessEdge};
/// A node kind in the coarsened architecture graph.
///
/// **Deserialization is tolerant** (custom `Deserialize`, not derived): any
/// variant string this binary doesn't recognise maps to [`NodeKind::CoreFn`]
/// (the catch-all bucket). This is load-bearing for the 🏛 Architecture viz:
/// the historized `architecture_wiring` graph JSON is read by THREE binaries
/// that deploy independently (the `nornir-server` thin RPC, the `nornir-ui`
/// viz client, the `nornir-mcp` tool). A board generated by a NEWER binary that
/// has a variant an OLDER reader lacks (e.g. `Cli`, added after the deployed
/// reader was built) must NOT make the whole member's board fail to decode and
/// silently blank the tab — it degrades that one node to CoreFn and renders the
/// rest. (Found 2026-06-18: deployed readers without `Cli` errored
/// `unknown variant 'Cli'` → `members_with_data: []`, `node_count: 0`.)
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum NodeKind {
/// An entrypoint / UI component — a `T` with `impl Facet for T` (a viz pane).
Component,
/// A gRPC service handler — `Service.verb`.
Grpc,
/// A CLI command — a clap subcommand (`nornir <verb>`). Seeded from clap
/// introspection (`Cli::command().get_subcommands()`), the authoritative,
/// anti-drift source — so CLI is a first-class marker layer alongside UI
/// (Component) and gRPC, not buried in the CoreFn bucket.
Cli,
/// A core function / module (the catch-all bucket, labelled `crate::module`).
CoreFn,
/// A warehouse table.
Table,
/// A pluggable backend — a `T` with `impl Backend for T` (e.g. a
/// warehouse-generator backend). Seeded the same syntactic way as Component,
/// but for the `Backend` architectural marker trait rather than `Facet`, so
/// the backend layer reads apart from UI instead of collapsing to CoreFn.
Backend,
/// A codec / compression engine — a `T` with `impl Codec for T`
/// (the znippy-zoomies `Codec` trait). The engine layer.
Codec,
/// A data sink — a `T` with `impl *Sink for T` (`TestSink`, `SagaSink`,
/// `ArchiveMetaSink`, …): the warehouse / emit egress seam. Matched by the
/// `*Sink` trait-name suffix so future sinks light up without a config edit.
Sink,
}
impl<'de> Deserialize<'de> for NodeKind {
/// Tolerant decode: map the externally-tagged variant name to a `NodeKind`,
/// falling back to [`NodeKind::CoreFn`] for ANY unrecognised name (forward-
/// compatible cross-binary reads — see the type doc). The on-wire form is the
/// derived `Serialize` variant identifier (`"Component"`, `"Cli"`, …); we also
/// accept the lowercase [`NodeKind::as_str`] spelling for robustness.
fn deserialize<D>(d: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(d)?;
Ok(match s.as_str() {
"Component" | "component" => NodeKind::Component,
"Grpc" | "grpc" => NodeKind::Grpc,
"Cli" | "cli" => NodeKind::Cli,
"Table" | "table" => NodeKind::Table,
"Backend" | "backend" => NodeKind::Backend,
"Codec" | "codec" => NodeKind::Codec,
"Sink" | "sink" => NodeKind::Sink,
// "CoreFn"/"corefn" AND any unknown future variant → the catch-all.
_ => NodeKind::CoreFn,
})
}
}
impl NodeKind {
pub fn as_str(self) -> &'static str {
match self {
NodeKind::Component => "component",
NodeKind::Grpc => "grpc",
NodeKind::Cli => "cli",
NodeKind::CoreFn => "corefn",
NodeKind::Table => "table",
NodeKind::Backend => "backend",
NodeKind::Codec => "codec",
NodeKind::Sink => "sink",
}
}
pub fn parse(s: &str) -> Self {
match s {
"component" => NodeKind::Component,
"grpc" => NodeKind::Grpc,
"cli" => NodeKind::Cli,
"table" => NodeKind::Table,
"backend" => NodeKind::Backend,
"codec" => NodeKind::Codec,
"sink" => NodeKind::Sink,
_ => NodeKind::CoreFn,
}
}
/// The canonical **layer** (AUT8-GAP-LAYER): `ui` · `grpc` · `cli` · `core` ·
/// `table`. Derived from the kind — the single grouping axis shared with
/// [`nornir_testmatrix::discover::SurfaceKind::layer`] (ui≙Component,
/// grpc≙Grpc, cli≙Cli, core≙CoreFn) so the matrix and the 🏛 board group by
/// the same UI/gRPC/CLI strata without a stored `layer` column.
pub fn layer(self) -> &'static str {
match self {
NodeKind::Component => "ui",
NodeKind::Grpc => "grpc",
NodeKind::Cli => "cli",
NodeKind::CoreFn => "core",
NodeKind::Table => "table",
NodeKind::Backend => "backend",
NodeKind::Codec => "codec",
NodeKind::Sink => "sink",
}
}
}
/// One coarsened node. `id` is unique + stable; `label` is the displayed text.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ArchNode {
pub id: String,
pub label: String,
pub kind: NodeKind,
}
/// A relation between coarsened nodes. `reads`/`writes` are the only kinds the
/// access fact yields (`* → Table`); plain calls are `Calls`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ArchEdgeKind {
Calls,
Reads,
Writes,
}
impl ArchEdgeKind {
pub fn as_str(self) -> &'static str {
match self {
ArchEdgeKind::Calls => "calls",
ArchEdgeKind::Reads => "reads",
ArchEdgeKind::Writes => "writes",
}
}
pub fn parse(s: &str) -> Self {
match s {
"reads" => ArchEdgeKind::Reads,
"writes" => ArchEdgeKind::Writes,
_ => ArchEdgeKind::Calls,
}
}
}
/// A coarsened edge `from → to`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ArchEdge {
pub from: String,
pub to: String,
pub kind: ArchEdgeKind,
}
/// The coarsened architecture graph: nodes + edges, deterministically ordered.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArchGraph {
pub nodes: Vec<ArchNode>,
pub edges: Vec<ArchEdge>,
}
/// The owner-fn classifier — the heart of the quotient. Maps a fn's
/// fully-qualified caller/callee path (as recorded in `symbol_facts` /
/// `call_edges`, e.g. `nornir::TestTab::fetch_test_results`) to the node it
/// collapses into.
///
/// `facet_impls` is the set of `T` types that have an `impl Facet for T`
/// (derived from `symbol_facts`); a fn whose owning type is one of them is a
/// **Component**. `grpc_handlers` maps a fully-qualified handler fn path to its
/// `Service.verb` label. Anything unmatched is a **CoreFn** bucketed by
/// `crate::module`.
pub struct Classifier<'a> {
/// Map: type name (last segment) with an `impl <MarkerTrait> for T` → the
/// [`NodeKind`] that marker confers (`Facet`→Component, `Backend`→Backend,
/// `Codec`→Codec, `*Sink`→Sink). Generalises the old single-trait `facet_impls`
/// set so every architectural marker layer — not just UI — reads apart.
pub marker_impls: &'a BTreeMap<String, NodeKind>,
/// Map: fully-qualified handler fn path → `Service.verb` label.
pub grpc_handlers: &'a BTreeMap<String, String>,
}
/// The default architectural marker traits and the [`NodeKind`] each confers.
///
/// A `(trait_pattern, kind)` pair: a plain name matches an `impl <name> for T`
/// exactly; a `*Suffix` pattern matches any trait whose bare name ends in
/// `Suffix` (so every `…Sink` lights up without a config edit). Order is
/// precedence — the first matching marker wins for a type that impls several.
///
/// Grounded in the workspace: `Facet` (facett/nornir UI panes, 100+ impls),
/// `Backend` (nornir warehouse generators, 16 impls), the `*BackendTrait` family
/// (holger's per-ecosystem repository backends — `RepositoryBackendTrait` on
/// `MavenRepoZnippy`, npm, pypi, … — otherwise all collapsed to CoreFn), `Codec`
/// (znippy-zoomies engine), and the `*Sink` family (`TestSink` / `SagaSink` /
/// `ArchiveMetaSink`). Suffix globs so new backends/sinks light up with no edit.
pub const DEFAULT_MARKER_TRAITS: &[(&str, NodeKind)] = &[
("Facet", NodeKind::Component),
("Backend", NodeKind::Backend),
("*BackendTrait", NodeKind::Backend),
("Codec", NodeKind::Codec),
("*Sink", NodeKind::Sink),
];
impl<'a> Classifier<'a> {
/// Classify one fully-qualified fn path into its coarsened node.
pub fn classify(&self, fq: &str) -> ArchNode {
// gRPC handler — exact match on the recorded handler path.
if let Some(label) = self.grpc_handlers.get(fq) {
return ArchNode {
id: format!("grpc:{label}"),
label: label.clone(),
kind: NodeKind::Grpc,
};
}
// Marker layer — the owning type (`crate::Mod::Type::method` → `Type`)
// carries an architectural marker impl (`impl Facet/Backend/Codec/*Sink
// for Type`). We look at the *penultimate* path segment (the type the
// method is defined on) and take the kind that marker confers, so
// backends/codecs/sinks read apart instead of collapsing to CoreFn.
let segs: Vec<&str> = fq.split("::").collect();
if segs.len() >= 2 {
let owner_ty = segs[segs.len() - 2];
if let Some(&kind) = self.marker_impls.get(owner_ty) {
return ArchNode {
id: format!("{}:{owner_ty}", kind.as_str()),
label: owner_ty.to_string(),
kind,
};
}
}
// CoreFn — bucket by `crate::module` (drop the fn name + any type/impl
// tail so a whole module collapses to one chip).
let bucket = core_bucket(fq);
ArchNode {
id: format!("corefn:{bucket}"),
label: bucket,
kind: NodeKind::CoreFn,
}
}
}
/// Bucket a fq fn path to `crate::module` — keep the crate + the first module
/// segment, drop deeper module/type/fn tails so a module collapses to one node.
/// `nornir::viz::live::Loader::hydrate` → `nornir::viz`. A bare `crate::fn`
/// (`nornir::main`) → `nornir`.
fn core_bucket(fq: &str) -> String {
let segs: Vec<&str> = fq.split("::").collect();
match segs.len() {
0 => "unknown".to_string(),
1 => segs[0].to_string(),
2 => segs[0].to_string(), // crate::fn → crate
_ => format!("{}::{}", segs[0], segs[1]),
}
}
/// Derive the marker map `type → NodeKind` from `symbol_facts` rows, for a set
/// of `(trait_pattern, kind)` markers (see [`DEFAULT_MARKER_TRAITS`]). A marker
/// impl is recorded as an `impl`-kind symbol with item_name `impl <Trait> for T`
/// (the [`crate::knowledge::symbols`] convention). Precedence follows `markers`
/// order — the first pattern a type matches wins (`entry().or_insert`), so a
/// type that is both a `Facet` and a `*Sink` stays a Component.
pub fn marker_impls_from_symbols(
symbols: &[SymbolRow],
markers: &[(&str, NodeKind)],
) -> BTreeMap<String, NodeKind> {
let mut out: BTreeMap<String, NodeKind> = BTreeMap::new();
for s in symbols {
if s.item_kind != "impl" {
continue;
}
for &(pat, kind) in markers {
if let Some(t) = parse_marker_impl(&s.item_name, pat) {
out.entry(t).or_insert(kind);
break; // first matching marker wins for this impl symbol
}
}
}
out
}
/// Derive the set of `T` with `impl Facet for T` — the UI-component subset of
/// [`marker_impls_from_symbols`]. Kept for callers that only want the Component
/// layer (and for API stability).
pub fn facet_impls_from_symbols(symbols: &[SymbolRow]) -> BTreeSet<String> {
marker_impls_from_symbols(symbols, &[("Facet", NodeKind::Component)])
.into_keys()
.collect()
}
/// Derive the gRPC handler map from `symbol_facts`: every method defined on a
/// `XxxSvc`-style service struct whose impl block is `impl XxxSvcTrait for
/// XxxSvc` (the tonic-generated handler trait convention used by
/// `src/bin/nornir-server.rs`) maps `fq_fn_path → "Service.Verb"`.
///
/// The service name is the `Svc` struct minus its `Svc` suffix (`VizSvc` →
/// `Viz`), and the verb is the method name in CamelCase (`timeline` →
/// `Timeline`), so `VizSvc::timeline` → `Viz.Timeline` — matching the
/// `proto/nornir.proto` service.verb naming the acceptance fixture uses.
pub fn grpc_handlers_from_symbols(symbols: &[SymbolRow]) -> BTreeMap<String, String> {
// Service struct types that have a tonic handler impl (`impl *Trait for *Svc`).
let mut svc_types: BTreeSet<String> = BTreeSet::new();
for s in symbols {
if s.item_kind == "impl" {
if let Some(ty) = parse_grpc_impl(&s.item_name) {
svc_types.insert(ty);
}
}
}
let mut out = BTreeMap::new();
for s in symbols {
if s.item_kind != "fn" {
continue;
}
// The method's owning type is the last segment of its module_path.
let owner_ty = s.module_path.rsplit("::").next().unwrap_or("");
if !svc_types.contains(owner_ty) {
continue;
}
let service = owner_ty.strip_suffix("Svc").unwrap_or(owner_ty);
let verb = to_camel(&s.item_name);
let label = format!("{service}.{verb}");
let fq = format!("{}::{}", s.module_path, s.item_name);
out.insert(fq, label);
}
out
}
/// `impl VizSvcTrait for VizSvc` → `Some("VizSvc")`. Matches a type whose name
/// ends in `Svc` and a trait that is its tonic handler trait — by either
/// spelling of the same impl:
///
/// * `impl VizSvcTrait for VizSvc`: the trait under the alias the source
/// imports it by (`use pb::viz_server::{Viz as VizSvcTrait, …}`), which is
/// what a reader of the source text sees;
/// * `impl Viz for VizSvc`: the trait under its OWN name — what the code room
/// holds, because rustc resolves the alias away. Without this spelling no
/// handler of a resolved room is a gRPC node.
fn parse_grpc_impl(label: &str) -> Option<String> {
let rest = label.strip_prefix("impl ")?;
let (trait_part, ty_part) = rest.split_once(" for ")?;
let trait_last = trait_part.rsplit("::").next().unwrap_or(trait_part).trim();
let ty = ty_part.trim();
let bare = ty.split(['<', ' ']).next().unwrap_or(ty);
let last = bare.rsplit("::").next().unwrap_or(bare).trim();
let own_trait = last.strip_suffix("Svc").is_some_and(|service| !service.is_empty() && service == trait_last);
if (trait_last.ends_with("Trait") || own_trait) && last.ends_with("Svc") && !last.is_empty() {
Some(last.to_string())
} else {
None
}
}
/// snake_case / single ident → CamelCase verb (`bakeoff_results` → `BakeoffResults`).
fn to_camel(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut upper = true;
for c in s.chars() {
if c == '_' {
upper = true;
} else if upper {
out.extend(c.to_uppercase());
upper = false;
} else {
out.push(c);
}
}
out
}
/// Build the full architecture graph from raw knowledge + access facts,
/// deriving the Facet-component set and the gRPC-handler map from `symbols`.
/// This is the one-call entrypoint `nornir arch` uses.
pub fn generate(symbols: &[SymbolRow], calls: &[CallEdgeRow], access: &[AccessEdge]) -> ArchGraph {
generate_with_cli(symbols, calls, access, &[])
}
/// Like [`generate`], plus seed one [`NodeKind::Cli`] node per clap subcommand in
/// `cli_verbs` — the binary passes `Cli::command().get_subcommands()` (the
/// authoritative, anti-drift verb list; the lib stays binary-free, so callers
/// without clap pass `&[]`). CLI verbs are entrypoints with no incoming edge, so
/// the layered layout naturally pins them to the left rail beside UI components.
/// A `cli:<verb>` node whose verb collides with an existing node id is skipped.
pub fn generate_with_cli(
symbols: &[SymbolRow],
calls: &[CallEdgeRow],
access: &[AccessEdge],
cli_verbs: &[String],
) -> ArchGraph {
let markers = marker_impls_from_symbols(symbols, DEFAULT_MARKER_TRAITS);
let handlers = grpc_handlers_from_symbols(symbols);
let cls = Classifier {
marker_impls: &markers,
grpc_handlers: &handlers,
};
let mut graph = build_graph(symbols, calls, access, &cls);
let existing: std::collections::BTreeSet<String> =
graph.nodes.iter().map(|n| n.id.clone()).collect();
for verb in cli_verbs {
let id = format!("cli:{verb}");
if existing.contains(&id) {
continue;
}
graph.nodes.push(ArchNode {
id,
label: verb.clone(),
kind: NodeKind::Cli,
});
}
graph.nodes.sort();
graph
}
/// `parse_marker_impl("impl Facet for TestTab", "Facet")` → `Some("TestTab")`;
/// a non-matching trait → `None`. The trait match is on the *bare* trait name
/// (last path segment), so `impl crate::facett::Facet for T` also matches.
///
/// `pat` is either an exact bare trait name (`"Facet"`) or a `*Suffix` glob
/// (`"*Sink"` matches `TestSink`, `SagaSink`, `ArchiveMetaSink`, …). The self
/// type is reduced to its bare ident (generics/paths stripped: `Grid<T>` → `Grid`).
fn parse_marker_impl(label: &str, pat: &str) -> Option<String> {
let rest = label.strip_prefix("impl ")?;
let (trait_part, ty_part) = rest.split_once(" for ")?;
let trait_last = trait_part.rsplit("::").next().unwrap_or(trait_part).trim();
let matches = match pat.strip_prefix('*') {
Some(suffix) => trait_last.ends_with(suffix) && !trait_last.is_empty(),
None => trait_last == pat,
};
if !matches {
return None;
}
// The self-type may carry generics (`Grid<T>`) — take the bare type ident.
let ty = ty_part.trim();
let bare = ty.split(['<', ' ']).next().unwrap_or(ty);
let last = bare.rsplit("::").next().unwrap_or(bare).trim();
if last.is_empty() {
None
} else {
Some(last.to_string())
}
}
/// Build the coarsened [`ArchGraph`] — the quotient of `calls` by `classify`,
/// plus the `* → Table` edges from `access`.
///
/// - Each `caller → callee` call edge becomes `class(caller) → class(callee)`
/// with kind [`ArchEdgeKind::Calls`]. Self-edges (a node calling itself) and
/// duplicates collapse away.
/// - Each access edge `caller_fn → (table, read|write)` becomes
/// `class(caller_fn) → Table(table)` with kind `Reads`/`Writes`. The
/// `<dynamic>` sentinel table is skipped (no concrete node).
/// - A callee that is not itself a defined symbol (an external/library call) is
/// still classified by its identifier shape; bare idents with no `::` are
/// dropped (they can't be meaningfully bucketed and would be noise).
pub fn build_graph(
symbols: &[SymbolRow],
calls: &[CallEdgeRow],
access: &[AccessEdge],
cls: &Classifier<'_>,
) -> ArchGraph {
let mut nodes: BTreeMap<String, ArchNode> = BTreeMap::new();
let mut edges: BTreeSet<ArchEdge> = BTreeSet::new();
let add_node = |nodes: &mut BTreeMap<String, ArchNode>, n: ArchNode| {
nodes.entry(n.id.clone()).or_insert(n);
};
// Seed: every defined symbol that is a fn → its classified node (so a pane
// with no outgoing call still appears as a chip). Cheap + makes the board
// reflect the surface, not just the edges.
for s in symbols {
if s.item_kind != "fn" {
continue;
}
let fq = format!("{}::{}", s.module_path, s.item_name);
add_node(&mut nodes, cls.classify(&fq));
}
// Call edges → coarsened Calls edges.
for c in calls {
let from = cls.classify(&c.caller_path);
// The callee is recorded as an identifier (`Foo::bar` or bare `bar`).
// Classify it the same way; skip bare idents (no `::`) — unbucketable
// noise (`new`, `unwrap`, `push`, …).
if !c.callee_ident.contains("::") {
// Still record the caller node so it is on the board.
add_node(&mut nodes, from);
continue;
}
let to = cls.classify(&c.callee_ident);
let (from_id, to_id) = (from.id.clone(), to.id.clone());
add_node(&mut nodes, from);
add_node(&mut nodes, to);
if from_id != to_id {
edges.insert(ArchEdge {
from: from_id,
to: to_id,
kind: ArchEdgeKind::Calls,
});
}
}
// Access edges → `* → Table` Reads/Writes edges (the edge no call graph has).
for a in access {
if a.table == crate::warehouse::access_scan::DYNAMIC_TABLE {
continue;
}
let from = cls.classify(&a.caller_fn);
let from_id = from.id.clone();
let table_id = format!("table:{}", a.table);
add_node(&mut nodes, from);
add_node(
&mut nodes,
ArchNode {
id: table_id.clone(),
label: a.table.clone(),
kind: NodeKind::Table,
},
);
let kind = match a.access {
Access::Read => ArchEdgeKind::Reads,
Access::Write => ArchEdgeKind::Writes,
};
if from_id != table_id {
edges.insert(ArchEdge {
from: from_id,
to: table_id,
kind,
});
}
}
let mut nodes: Vec<ArchNode> = nodes.into_values().collect();
nodes.sort();
let edges: Vec<ArchEdge> = edges.into_iter().collect();
ArchGraph { nodes, edges }
}
/// Trace mode (ARCH5 `nornir arch trace <entrypoint>` / AUT7 `flow <entrypoint>`):
/// the focused sub-board of everything **reachable downstream** from the
/// coarsened node an `entrypoint` ident collapses into. The entrypoint is
/// matched by node label or id substring (case-insensitive) against the full
/// [`ArchGraph`] `graph` — so `TestTab`, `Viz.Timeline`, or a `crate::module`
/// bucket all resolve. From each matched seed we BFS the `Calls`/`Reads`/`Writes`
/// edges forward, keeping only the reached nodes + the edges among them. The
/// result is itself an [`ArchGraph`], so it renders with the same circuit-board
/// [`to_svg`]. Returns an empty graph when no node matches the entrypoint.
///
/// This is the visual twin of `knowledge_call_path` lifted to the coarsened
/// board: instead of an fn-level chain it answers "open this surface — which
/// gRPC verbs / core modules / warehouse tables does it reach?".
pub fn trace_from(graph: &ArchGraph, entrypoint: &str) -> ArchGraph {
use std::collections::{HashMap, VecDeque};
let needle = entrypoint.to_lowercase();
// Seeds: any node whose label or id contains the entrypoint substring.
let seeds: BTreeSet<&str> = graph
.nodes
.iter()
.filter(|n| {
n.label.to_lowercase().contains(&needle) || n.id.to_lowercase().contains(&needle)
})
.map(|n| n.id.as_str())
.collect();
if seeds.is_empty() {
return ArchGraph::default();
}
// Forward adjacency over all edge kinds.
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for e in &graph.edges {
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
}
// BFS reachability from every seed.
let mut reached: BTreeSet<&str> = BTreeSet::new();
let mut queue: VecDeque<&str> = VecDeque::new();
for &s in &seeds {
if reached.insert(s) {
queue.push_back(s);
}
}
while let Some(cur) = queue.pop_front() {
if let Some(outs) = adj.get(cur) {
for &nxt in outs {
if reached.insert(nxt) {
queue.push_back(nxt);
}
}
}
}
let by_id: BTreeMap<&str, &ArchNode> = graph.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
let mut nodes: Vec<ArchNode> = reached
.iter()
.filter_map(|id| by_id.get(id).map(|n| (*n).clone()))
.collect();
nodes.sort();
let edges: Vec<ArchEdge> = graph
.edges
.iter()
.filter(|e| reached.contains(e.from.as_str()) && reached.contains(e.to.as_str()))
.cloned()
.collect();
ArchGraph { nodes, edges }
}
// ───────────────────────────────────────────────────────────────────────────
// Click-to-code — resolve an arch node / symbol to its source `file:line`.
//
// The 🏛 Arch tab's nodes are coarsened (a Component is a `T`, a CLI verb is a
// clap subcommand, a core-fn is a `crate::module` bucket), so a node's label is
// a *symbol-ish* string, not a path. This is the one bridge a call graph can't
// give (fn → file): we match the node label / a free-form query against the
// freshly-scanned `symbol_facts` by item name, and return the `file:line` of the
// best hit. Used by BOTH the `nornir arch open` CLI verb and the viz tab's node
// click, so the click-to-code resolution is the same code on both sides.
// ───────────────────────────────────────────────────────────────────────────
/// A resolved source location for a symbol (the click-to-code target).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SymbolLoc {
/// The matched fully-qualified-ish symbol name (`item_name` from the scan).
pub symbol: String,
/// The item kind (`fn`/`struct`/`impl`/…), for disambiguation in the UI.
pub kind: String,
/// Source file, repo-relative (as recorded in `symbol_facts`).
pub file: String,
/// 1-based line where the symbol is defined.
pub line: u32,
}
/// Resolve a free-form `query` (an arch node label, or a user-typed symbol) to
/// candidate source locations, best match first, against the scanned symbols.
///
/// Matching, in priority order (so the first hit is the most specific):
/// 1. **exact** `item_name == query`,
/// 2. exact match on the query's **last `::` segment** (`Type::method` → `method`,
/// or `crate::module` → `module`) — arch labels are often coarsened buckets,
/// 3. **substring**, case-insensitive (the loose fallback).
/// Ties break toward the shorter file path then lower line (deterministic). A
/// label like `Viz.Architecture` (a gRPC verb) is normalised to its `verb`
/// segment too, so a clicked gRPC node resolves to the handler fn.
pub fn resolve_symbol(symbols: &[SymbolRow], query: &str) -> Vec<SymbolLoc> {
let q = query.trim();
if q.is_empty() {
return Vec::new();
}
// Coarsened labels carry separators that aren't in the symbol name: a gRPC
// label is `Service.verb`, a core-fn bucket is `crate::module`. Pull the
// most specific leaf to match on, but keep the whole string for exact tries.
let leaf = q
.rsplit(['.', ':'])
.find(|s| !s.is_empty())
.unwrap_or(q)
.to_string();
let ql = q.to_lowercase();
let leafl = leaf.to_lowercase();
let mut scored: Vec<(u8, &SymbolRow)> = symbols
.iter()
.filter_map(|s| {
let name = &s.item_name;
let nl = name.to_lowercase();
let last = name.rsplit("::").next().unwrap_or(name);
let rank = if name == q {
0 // exact full-name match
} else if last == leaf {
1 // exact leaf-segment match
} else if nl == ql {
2 // case-insensitive full match
} else if last.to_lowercase() == leafl {
3 // case-insensitive leaf match
} else if nl.contains(&leafl) {
4 // substring fallback
} else {
return None;
};
Some((rank, s))
})
.collect();
scored.sort_by(|a, b| {
a.0.cmp(&b.0)
.then(a.1.file.len().cmp(&b.1.file.len()))
.then(a.1.file.cmp(&b.1.file))
.then(a.1.line.cmp(&b.1.line))
});
scored
.into_iter()
.map(|(_, s)| SymbolLoc {
symbol: s.item_name.clone(),
kind: s.item_kind.clone(),
file: s.file.clone(),
line: s.line,
})
.collect()
}
/// Best-effort open of `file:line` in the user's editor via `code --goto`. The
/// VS Code CLI is the de-facto cross-editor launcher; if `code` isn't on PATH we
/// return `Ok(false)` (logged by the caller) rather than failing — the inline
/// source preview + the printed `file:line` are the always-available fallbacks.
/// `repo_root` (when given) is prefixed so the absolute path is opened.
pub fn open_in_editor(
repo_root: Option<&std::path::Path>,
file: &str,
line: u32,
) -> std::io::Result<bool> {
let abs = match repo_root {
Some(root) => root.join(file).to_string_lossy().to_string(),
None => file.to_string(),
};
let target = format!("{abs}:{line}");
match std::process::Command::new("code")
.arg("--goto")
.arg(&target)
.status()
{
Ok(st) => Ok(st.success()),
// `code` not installed / not on PATH → not an error, just unavailable.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e),
}
}
/// Read the source lines around `line` (1-based) for the inline preview panel:
/// `context` lines on each side. Returns `(start_line, lines)` where `start_line`
/// is the 1-based number of the first returned line. Empty when the file can't be
/// read. Pure file IO so the viz preview + a test read the same window.
pub fn source_window(
repo_root: Option<&std::path::Path>,
file: &str,
line: u32,
context: u32,
) -> (u32, Vec<String>) {
let path = match repo_root {
Some(root) => root.join(file),
None => std::path::PathBuf::from(file),
};
let Ok(text) = std::fs::read_to_string(&path) else {
return (0, Vec::new());
};
let all: Vec<&str> = text.lines().collect();
if all.is_empty() {
return (0, Vec::new());
}
let idx = (line.max(1) - 1) as usize; // 0-based target
let start = idx.saturating_sub(context as usize);
let end = (idx + context as usize + 1).min(all.len());
let lines = all[start..end].iter().map(|s| s.to_string()).collect();
(start as u32 + 1, lines)
}
// ───────────────────────────────────────────────────────────────────────────
// SVG render — skade circuit-board style, direct-emit (no mermaid, no typst).
// ───────────────────────────────────────────────────────────────────────────
impl ArchGraph {
/// Render the graph as a **self-contained, static SVG** in the skade
/// circuit-board house style: a dark PCB substrate, layered chips coloured
/// by [`NodeKind`], cyan call traces and amber read/write traces. No
/// mermaid, no JavaScript, no diagram engine, no typst — the markup is
/// emitted directly so it renders verbatim in any viewer and is available
/// in every feature set.
pub fn to_svg(&self) -> String {
render_pcb_svg(self)
}
/// A plain-text adjacency dump — the no-pixels fallback (terminal output).
pub fn to_text(&self) -> String {
let mut s = String::new();
s.push_str("nodes:\n");
for n in &self.nodes {
s.push_str(&format!(" [{}] {}\n", n.kind.as_str(), n.label));
}
if self.edges.is_empty() {
s.push_str("edges: (none)\n");
} else {
s.push_str("edges:\n");
for e in &self.edges {
s.push_str(&format!(" {} → {} ({})\n", e.from, e.to, e.kind.as_str()));
}
}
s
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
/// Layered BFS columns. Layer 0 = sources (no incoming edge). Cycles broken
/// deterministically (idempotent output for a given input). Tables are pinned
/// to the **last** layer so they ground on the right (the warehouse rail).
fn layers_of(graph: &ArchGraph) -> Vec<Vec<usize>> {
use std::collections::HashMap;
let n = graph.nodes.len();
let idx: HashMap<&str, usize> = graph
.nodes
.iter()
.enumerate()
.map(|(i, nd)| (nd.id.as_str(), i))
.collect();
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
let mut indeg: Vec<usize> = vec![0; n];
for e in &graph.edges {
if let (Some(&f), Some(&t)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) {
if f != t {
adj[f].push(t);
indeg[t] += 1;
}
}
}
let mut layer_of = vec![0usize; n];
let mut remaining: BTreeSet<usize> = (0..n).collect();
let mut level = 0usize;
while !remaining.is_empty() {
let ready: Vec<usize> = remaining
.iter()
.copied()
.filter(|&i| indeg[i] == 0)
.collect();
if ready.is_empty() {
for &i in &remaining {
layer_of[i] = level;
}
break;
}
for &i in &ready {
layer_of[i] = level;
remaining.remove(&i);
}
for &i in &ready {
for &j in &adj[i] {
if indeg[j] > 0 {
indeg[j] -= 1;
}
}
}
level += 1;
}
// Pin tables to the rightmost column (the warehouse ground rail).
let mut max_level = *layer_of.iter().max().unwrap_or(&0);
let has_table = graph.nodes.iter().any(|n| n.kind == NodeKind::Table);
if has_table {
max_level = max_level.max(1);
for (i, nd) in graph.nodes.iter().enumerate() {
if nd.kind == NodeKind::Table {
layer_of[i] = max_level;
}
}
}
let mut layers: Vec<Vec<usize>> = vec![Vec::new(); max_level + 1];
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|&a, &b| graph.nodes[a].label.cmp(&graph.nodes[b].label));
for i in order {
layers[layer_of[i]].push(i);
}
layers
}
/// Chip gradient/stroke per node kind (the skade PCB palette).
fn kind_style(kind: NodeKind) -> (&'static str, &'static str) {
// (fill, stroke)
match kind {
NodeKind::Component => ("#14384f", "#3aa0d0"), // UI chip — glacier blue
NodeKind::Grpc => ("#3a2a14", "#d0962a"), // RPC chip — amber
NodeKind::Cli => ("#173f2c", "#46c07a"), // CLI verb — terminal green
NodeKind::CoreFn => ("#173d57", "#5a8fb0"), // core chip — steel
NodeKind::Table => ("#0c2438", "#2db6e0"), // warehouse rail — cyan
NodeKind::Backend => ("#2e1f4f", "#9a6cd0"), // backend chip — violet
NodeKind::Codec => ("#0f3f33", "#34c48c"), // codec engine — teal
NodeKind::Sink => ("#3f1430", "#d04a9a"), // sink egress — magenta
}
}
fn render_pcb_svg(graph: &ArchGraph) -> String {
use std::collections::HashMap;
if graph.nodes.is_empty() {
return String::from(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 320 80\" \
font-family=\"'DejaVu Sans', sans-serif\">\
<rect x=\"0\" y=\"0\" width=\"320\" height=\"80\" fill=\"#06111d\"/>\
<text x=\"16\" y=\"44\" fill=\"#5a8fb0\" font-size=\"14\">(empty architecture graph)</text>\
</svg>\n",
);
}
let layers = layers_of(graph);
let col_w = 240.0f64;
let row_h = 56.0f64;
let box_w = 188.0f64;
let box_h = 34.0f64;
let margin = 28.0f64;
let cols = layers.len().max(1);
let rows = layers.iter().map(|l| l.len()).max().unwrap_or(1).max(1);
let width = margin * 2.0 + col_w * cols as f64;
let height = margin * 2.0 + row_h * rows as f64;
let mut pos: HashMap<usize, (f64, f64)> = HashMap::new();
for (ci, layer) in layers.iter().enumerate() {
let total_h = layer.len() as f64 * row_h;
let start_y = margin + (height - margin * 2.0 - total_h) / 2.0;
for (ri, &node_i) in layer.iter().enumerate() {
let x = margin + ci as f64 * col_w;
let y = start_y + ri as f64 * row_h;
pos.insert(node_i, (x, y));
}
}
let id_to_idx: HashMap<&str, usize> = graph
.nodes
.iter()
.enumerate()
.map(|(i, nd)| (nd.id.as_str(), i))
.collect();
let mut s = String::new();
s.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width:.0} {height:.0}\" \
font-family=\"'DejaVu Sans', 'Segoe UI', sans-serif\" font-size=\"12\">\n"
));
s.push_str(
"<defs>\
<linearGradient id=\"arch-board\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\
<stop offset=\"0\" stop-color=\"#0b1c2e\"/><stop offset=\"1\" stop-color=\"#06111d\"/>\
</linearGradient>\
<marker id=\"arch-arrow\" markerWidth=\"9\" markerHeight=\"9\" refX=\"7\" refY=\"3\" \
orient=\"auto\"><path d=\"M0,0 L7,3 L0,6 Z\" fill=\"#3aa0d0\"/></marker>\
<marker id=\"arch-arrow-rw\" markerWidth=\"9\" markerHeight=\"9\" refX=\"7\" refY=\"3\" \
orient=\"auto\"><path d=\"M0,0 L7,3 L0,6 Z\" fill=\"#d0962a\"/></marker>\
</defs>\n",
);
// PCB substrate.
s.push_str(&format!(
"<rect x=\"0\" y=\"0\" width=\"{width:.0}\" height=\"{height:.0}\" fill=\"url(#arch-board)\"/>\n"
));
// Traces (edges) first, so chips sit on top.
for e in &graph.edges {
let (Some(&fi), Some(&ti)) = (id_to_idx.get(e.from.as_str()), id_to_idx.get(e.to.as_str()))
else {
continue;
};
let (Some(&(fx, fy)), Some(&(tx, ty))) = (pos.get(&fi), pos.get(&ti)) else {
continue;
};
let x1 = fx + box_w;
let y1 = fy + box_h / 2.0;
let x2 = tx;
let y2 = ty + box_h / 2.0;
// Right-angled-ish PCB trace via a cubic with a mid breakpoint.
let mid = (x1 + x2) / 2.0;
let (color, marker, dash) = match e.kind {
ArchEdgeKind::Calls => ("#3aa0d0", "url(#arch-arrow)", ""),
ArchEdgeKind::Reads => (
"#d0962a",
"url(#arch-arrow-rw)",
" stroke-dasharray=\"6 4\"",
),
ArchEdgeKind::Writes => ("#e0b050", "url(#arch-arrow-rw)", ""),
};
s.push_str(&format!(
"<path d=\"M{x1:.0},{y1:.0} C{mid:.0},{y1:.0} {mid:.0},{y2:.0} {x2:.0},{y2:.0}\" \
fill=\"none\" stroke=\"{color}\" stroke-width=\"1.6\"{dash} \
marker-end=\"{marker}\" opacity=\"0.85\"/>\n"
));
}
// Chips (nodes).
for (i, nd) in graph.nodes.iter().enumerate() {
let Some(&(x, y)) = pos.get(&i) else { continue };
let (fill, stroke) = kind_style(nd.kind);
let label = xml_escape(&nd.label);
s.push_str(&format!(
"<rect x=\"{x:.0}\" y=\"{y:.0}\" width=\"{box_w:.0}\" height=\"{box_h:.0}\" rx=\"5\" \
fill=\"{fill}\" stroke=\"{stroke}\" stroke-width=\"1.4\"/>\n"
));
// Solder-pad dot on the chip's left edge (PCB flavour).
s.push_str(&format!(
"<circle cx=\"{:.0}\" cy=\"{:.0}\" r=\"2.4\" fill=\"{stroke}\"/>\n",
x + 6.0,
y + box_h / 2.0,
));
s.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\" fill=\"#dbeaf4\">{label}</text>\n",
x + box_w / 2.0 + 4.0,
y + box_h / 2.0 + 4.0,
));
}
s.push_str("</svg>\n");
s
}
// ───────────────────────────────────────────────────────────────────────────
// AUT9 — CommandOutcome builders for the arch READ/data commands.
//
// These SHAPE already-fetched arch data into the uniform `CommandOutcome` (the
// CLI twin of a Facet's `state_json`) for BOTH the fat and thin handlers, so the
// two faces emit one shape. Each follows the contract law: `ok ⟺ non-empty`; an
// empty result (no node matched / no symbol matched / no historized snapshot)
// is RED (`CommandOutcome::fail` — RAGNARÖK), never silently green.
//
// The pure SVG/document generators (`arch generate`, `arch svg`, `arch trace`'s
// rendered SVG artifact) stay RAW — an SVG document is not "data" to wrap; only
// the structural payload (the reachable node/edge set, the resolved file:line,
// the snapshot listing) is migrated here.
// ───────────────────────────────────────────────────────────────────────────
/// `arch trace <entrypoint>` → the reachable sub-board as data: the focused
/// node + edge set BFS-reachable from `entrypoint`. `ok ⟺ at least one node
/// matched` (an empty trace — no node label/id contained the entrypoint — is
/// RED). The rendered SVG stays a separate raw artifact; this is the structural
/// twin the matrix asserts.
pub fn trace_outcome(
repo: &str,
entrypoint: &str,
sub: &ArchGraph,
) -> crate::cli_outcome::CommandOutcome {
use crate::cli_outcome::CommandOutcome;
let cmd = "arch trace";
if sub.nodes.is_empty() {
return CommandOutcome::fail(
cmd,
format!("arch trace: no node matched entrypoint `{entrypoint}` in `{repo}`"),
);
}
let nodes: Vec<_> = sub
.nodes
.iter()
.map(|n| serde_json::json!({ "id": n.id, "label": n.label, "kind": n.kind.as_str(), "layer": n.kind.layer() }))
.collect();
let edges: Vec<_> = sub
.edges
.iter()
.map(|e| serde_json::json!({ "from": e.from, "to": e.to, "kind": e.kind.as_str() }))
.collect();
let human = format!(
"arch trace `{entrypoint}` in `{repo}`: {} nodes, {} edges reachable",
sub.nodes.len(),
sub.edges.len()
);
let data = serde_json::json!({
"repo": repo,
"entrypoint": entrypoint,
"node_count": sub.nodes.len(),
"edge_count": sub.edges.len(),
"nodes": nodes,
"edges": edges,
});
CommandOutcome::ok(cmd, data, human)
}
/// `arch open <symbol>` → the click-to-code resolution as data: the resolved
/// candidate source `file:line` locations (best match first). `ok ⟺ at least
/// one hit` (no symbol matched is RED). `all` selects whether every candidate or
/// just the best is surfaced (the same slice the human print uses).
pub fn open_outcome(
repo: &str,
query: &str,
hits: &[SymbolLoc],
all: bool,
) -> crate::cli_outcome::CommandOutcome {
use crate::cli_outcome::CommandOutcome;
let cmd = "arch open";
if hits.is_empty() {
return CommandOutcome::fail(
cmd,
format!(
"arch open: no symbol matched `{query}` in `{repo}` (try `nornir symbol-lookup`)"
),
);
}
let show = if all { hits } else { &hits[..1] };
let locs: Vec<_> = show
.iter()
.map(|h| serde_json::json!({ "symbol": h.symbol, "kind": h.kind, "file": h.file, "line": h.line }))
.collect();
let human = show
.iter()
.map(|h| format!("{}:{} {} {}", h.file, h.line, h.kind, h.symbol))
.collect::<Vec<_>>()
.join("\n");
let best = &hits[0];
let data = serde_json::json!({
"repo": repo,
"query": query,
"match_count": hits.len(),
"best": { "symbol": best.symbol, "kind": best.kind, "file": best.file, "line": best.line },
"locations": locs,
});
CommandOutcome::ok(cmd, data, human)
}
#[cfg(test)]
mod tests {
use super::*;
fn sym_fn(module_path: &str, name: &str) -> SymbolRow {
SymbolRow {
crate_name: "nornir".into(),
module_path: module_path.into(),
item_kind: "fn".into(),
item_name: name.into(),
visibility: "pub".into(),
file: "src/x.rs".into(),
line: 1,
doc_lines: 0,
signature: None,
}
}
fn sym_impl(module_path: &str, label: &str) -> SymbolRow {
SymbolRow {
crate_name: "nornir".into(),
module_path: module_path.into(),
item_kind: "impl".into(),
item_name: label.into(),
visibility: "pub".into(),
file: "src/x.rs".into(),
line: 1,
doc_lines: 0,
signature: None,
}
}
fn call(caller: &str, callee: &str) -> CallEdgeRow {
CallEdgeRow {
crate_name: "nornir".into(),
caller_path: caller.into(),
callee_ident: callee.into(),
call_kind: "call".into(),
file: "src/x.rs".into(),
line: 1,
}
}
fn access(caller: &str, table: &str, a: Access) -> AccessEdge {
AccessEdge {
caller_fn: caller.into(),
crate_name: "nornir".into(),
table: table.into(),
access: a,
file: "src/x.rs".into(),
line: 1,
}
}
#[test]
fn parse_marker_impl_matches_exact_and_suffix() {
// Exact trait-name marker (`Facet`).
assert_eq!(
parse_marker_impl("impl Facet for TestTab", "Facet").as_deref(),
Some("TestTab")
);
assert_eq!(
parse_marker_impl("impl crate::facett::Facet for Grid<T>", "Facet").as_deref(),
Some("Grid")
);
assert_eq!(parse_marker_impl("impl Clone for Foo", "Facet"), None);
assert_eq!(parse_marker_impl("impl Foo", "Facet"), None);
// `*Sink` suffix pattern lights up the whole sink family.
assert_eq!(
parse_marker_impl("impl SagaSink for JsonFileSink", "*Sink").as_deref(),
Some("JsonFileSink")
);
assert_eq!(
parse_marker_impl("impl TestSink for W", "*Sink").as_deref(),
Some("W")
);
assert_eq!(parse_marker_impl("impl Facet for W", "*Sink"), None);
// Backend exact marker.
assert_eq!(
parse_marker_impl("impl Backend for DuckDb", "Backend").as_deref(),
Some("DuckDb")
);
// `*BackendTrait` suffix lights up holger's per-ecosystem repository backends.
assert_eq!(
parse_marker_impl(
"impl RepositoryBackendTrait for MavenRepoZnippy",
"*BackendTrait"
)
.as_deref(),
Some("MavenRepoZnippy")
);
}
#[test]
fn backend_codec_sink_markers_light_up_via_generate() {
// Three impl symbols carrying the new marker traits, each with a method
// fn on the marked type so the node surfaces even without a call edge.
let symbols = vec![
sym_impl("nornir::wh", "impl Backend for DuckDb"),
sym_fn("nornir::wh::DuckDb", "id"),
sym_impl("znippy::gatling", "impl Codec for Gzip"),
sym_fn("znippy::gatling::Gzip", "decode"),
sym_impl("nornir::saga", "impl SagaSink for JsonFileSink"),
sym_fn("nornir::saga::JsonFileSink", "write"),
];
let g = generate(&symbols, &[], &[]);
let kind_of = |ty: &str| g.nodes.iter().find(|n| n.label == ty).map(|n| n.kind);
assert_eq!(
kind_of("DuckDb"),
Some(NodeKind::Backend),
"Backend marker → Backend node"
);
assert_eq!(
kind_of("Gzip"),
Some(NodeKind::Codec),
"Codec marker → Codec node"
);
// `SagaSink` matched by the `*Sink` suffix pattern.
assert_eq!(
kind_of("JsonFileSink"),
Some(NodeKind::Sink),
"*Sink marker → Sink node"
);
}
#[test]
fn core_bucket_collapses_to_crate_module() {
assert_eq!(
core_bucket("nornir::viz::live::Loader::hydrate"),
"nornir::viz"
);
assert_eq!(core_bucket("nornir::viz::build_timeline"), "nornir::viz");
assert_eq!(core_bucket("nornir::main"), "nornir");
assert_eq!(core_bucket("nornir"), "nornir");
}
/// REGRESSION (2026-06-18, false-empty 🏛 Architecture tab): a historized
/// `architecture_wiring` graph JSON containing a `NodeKind` variant the
/// reader binary doesn't recognise must STILL decode (the unknown node maps
/// to `CoreFn`), so a cross-binary read never blanks the whole board. Before
/// the tolerant `Deserialize`, an older reader hit `unknown variant 'Cli'`
/// and the member's board silently dropped → `node_count: 0`,
/// `members_with_data: []`. Inject a real graph carrying BOTH a known-future
/// variant (`Cli`) and a fabricated unknown one; assert it round-trips and a
/// genuinely-unknown tag degrades to `CoreFn` rather than failing the parse.
#[test]
fn node_kind_decode_is_forward_tolerant() {
// 1. Known variants (incl. `Cli`, the one deployed readers lacked) all
// round-trip through the canonical serde form.
for (k, tag) in [
(NodeKind::Component, "Component"),
(NodeKind::Grpc, "Grpc"),
(NodeKind::Cli, "Cli"),
(NodeKind::CoreFn, "CoreFn"),
(NodeKind::Table, "Table"),
] {
let json = serde_json::to_string(&k).unwrap();
assert_eq!(json, format!("\"{tag}\""), "serialize is the variant name");
let back: NodeKind = serde_json::from_str(&json).unwrap();
assert_eq!(back, k, "{tag} round-trips");
}
// 2. A whole graph whose node carries a FABRICATED unknown variant the
// reader's enum does not contain — the exact failure shape (a future
// binary added a kind). It must decode (no Err) with that node folded
// into the catch-all CoreFn, and every other node preserved.
let wire = r#"{
"nodes": [
{"id":"cli:gen","label":"generate","kind":"Cli"},
{"id":"x:weird","label":"weird","kind":"SomeFutureKind"},
{"id":"t:tbl","label":"tbl","kind":"Table"}
],
"edges": [
{"from":"cli:gen","to":"t:tbl","kind":"Writes"}
]
}"#;
let g: ArchGraph =
serde_json::from_str(wire).expect("unknown NodeKind must NOT fail the whole decode");
assert_eq!(g.nodes.len(), 3, "all nodes decode, none dropped");
assert_eq!(g.nodes[0].kind, NodeKind::Cli, "Cli decodes (not unknown)");
assert_eq!(
g.nodes[1].kind,
NodeKind::CoreFn,
"an unrecognised variant degrades to CoreFn, never an error"
);
assert_eq!(g.nodes[2].kind, NodeKind::Table);
assert_eq!(g.edges.len(), 1);
}
/// THE acceptance fixture from the task: the Test tab's
/// `fetch_test_results → test_results` collapses to `TestTab → test_results`.
#[test]
fn test_tab_fetch_collapses_to_component_and_table() {
// TestTab is a Facet component; its fetch fn reads test_results.
let symbols = vec![
sym_impl("nornir::viz::test_tab", "impl Facet for TestTab"),
sym_fn("nornir::viz::test_tab::TestTab", "fetch_test_results"),
];
// fetch calls a per-table accessor (recorded only as a call edge here)…
let calls = vec![call(
"nornir::viz::test_tab::TestTab::fetch_test_results",
"query_test_results",
)];
// …and the access fact says that fn reads test_results.
let acc = vec![access(
"nornir::viz::test_tab::TestTab::fetch_test_results",
"test_results",
Access::Read,
)];
let markers = marker_impls_from_symbols(&symbols, DEFAULT_MARKER_TRAITS);
assert!(markers.contains_key("TestTab"));
let cls = Classifier {
marker_impls: &markers,
grpc_handlers: &BTreeMap::new(),
};
let g = build_graph(&symbols, &calls, &acc, &cls);
// The component node TestTab exists.
let comp = g
.nodes
.iter()
.find(|n| n.kind == NodeKind::Component && n.label == "TestTab")
.expect("TestTab component node");
// The table node test_results exists.
let tbl = g
.nodes
.iter()
.find(|n| n.kind == NodeKind::Table && n.label == "test_results")
.expect("test_results table node");
// The collapsed edge is exactly TestTab --reads--> test_results.
assert!(
g.edges
.iter()
.any(|e| e.from == comp.id && e.to == tbl.id && e.kind == ArchEdgeKind::Reads),
"expected TestTab -reads-> test_results; got {:?}",
g.edges
);
// The bare `query_test_results` callee (no `::`) produced no spurious
// node beyond the component + table (plus the impl-derived seed).
assert!(
g.nodes.iter().all(|n| n.label != "query_test_results"),
"accessor ident should not be a standalone node: {:?}",
g.nodes
);
}
#[test]
fn grpc_handler_classifies_and_collapses() {
let symbols = vec![sym_fn("nornir::server::Viz", "timeline")];
let mut handlers = BTreeMap::new();
handlers.insert(
"nornir::server::Viz::timeline".to_string(),
"Viz.Timeline".to_string(),
);
// The handler calls build_timeline (a core fn) which writes release_lineage.
let calls = vec![call("nornir::server::Viz::timeline", "build_timeline::foo")];
let acc = vec![access(
"nornir::server::Viz::timeline",
"release_lineage",
Access::Write,
)];
let markers: BTreeMap<String, NodeKind> = BTreeMap::new();
let cls = Classifier {
marker_impls: &markers,
grpc_handlers: &handlers,
};
let g = build_graph(&symbols, &calls, &acc, &cls);
let grpc = g
.nodes
.iter()
.find(|n| n.kind == NodeKind::Grpc && n.label == "Viz.Timeline")
.expect("Viz.Timeline gRPC node");
let tbl = g
.nodes
.iter()
.find(|n| n.kind == NodeKind::Table && n.label == "release_lineage")
.unwrap();
assert!(
g.edges
.iter()
.any(|e| e.from == grpc.id && e.to == tbl.id && e.kind == ArchEdgeKind::Writes)
);
}
#[test]
fn self_edges_and_dupes_collapse_away() {
// Two fns in the SAME module calling each other → a self-edge on the
// module's CoreFn node → must NOT appear.
let symbols = vec![sym_fn("nornir::mimir", "a"), sym_fn("nornir::mimir", "b")];
let calls = vec![
call("nornir::mimir::a", "nornir::mimir::b"),
call("nornir::mimir::b", "nornir::mimir::a"),
];
let markers: BTreeMap<String, NodeKind> = BTreeMap::new();
let cls = Classifier {
marker_impls: &markers,
grpc_handlers: &BTreeMap::new(),
};
let g = build_graph(&symbols, &calls, &[], &cls);
assert!(
g.edges.is_empty(),
"intra-module self-edge must collapse: {:?}",
g.edges
);
// One CoreFn node nornir::mimir.
assert_eq!(g.nodes.len(), 1);
assert_eq!(g.nodes[0].kind, NodeKind::CoreFn);
assert_eq!(g.nodes[0].label, "nornir::mimir");
}
#[test]
fn dynamic_table_access_is_skipped() {
let acc = vec![access(
"nornir::viz::warehouse_tab::run",
"<dynamic>",
Access::Read,
)];
let markers: BTreeMap<String, NodeKind> = BTreeMap::new();
let cls = Classifier {
marker_impls: &markers,
grpc_handlers: &BTreeMap::new(),
};
let g = build_graph(&[], &[], &acc, &cls);
assert!(
g.nodes.iter().all(|n| n.kind != NodeKind::Table),
"no <dynamic> table node"
);
}
#[test]
fn svg_is_circuit_board_with_expected_labels() {
let symbols = vec![
sym_impl("nornir::viz::test_tab", "impl Facet for TestTab"),
sym_fn("nornir::viz::test_tab::TestTab", "fetch_test_results"),
];
let acc = vec![access(
"nornir::viz::test_tab::TestTab::fetch_test_results",
"test_results",
Access::Read,
)];
let markers = marker_impls_from_symbols(&symbols, DEFAULT_MARKER_TRAITS);
let cls = Classifier {
marker_impls: &markers,
grpc_handlers: &BTreeMap::new(),
};
let g = build_graph(&symbols, &[], &acc, &cls);
let svg = g.to_svg();
assert!(svg.starts_with("<svg"), "{svg}");
assert!(svg.contains("</svg>"));
assert!(!svg.contains("mermaid"));
// Circuit-board flavour: dark substrate + the traces.
assert!(svg.contains("arch-board"), "PCB substrate gradient: {svg}");
assert!(svg.contains("<path "), "edge traces: {svg}");
// Expected node labels are present.
assert!(svg.contains(">TestTab<"), "TestTab chip label: {svg}");
assert!(svg.contains(">test_results<"), "table chip label: {svg}");
assert!(svg.len() > 400);
}
/// The code room holds the handler trait under its own name (rustc
/// resolved `Bench as BenchSvcTrait` away): `impl Bench for BenchSvc` is
/// the same impl, and its methods are handlers. Red without the second
/// spelling: the map is empty and a resolved board has no gRPC node.
#[test]
fn grpc_handlers_are_found_under_the_traits_resolved_name_too() {
let symbols = vec![
sym_impl("nornir::server_grpc", "impl Bench for BenchSvc"),
sym_fn("nornir::server_grpc::BenchSvc", "history"),
// not the service's own trait: no handler impl
sym_impl("nornir::server_grpc", "impl Clone for OtherSvc"),
sym_fn("nornir::server_grpc::OtherSvc", "clone"),
// a trait that merely shares the type's whole name is not it either
sym_impl("nornir::server_grpc", "impl Svc for Svc"),
sym_fn("nornir::server_grpc::Svc", "call"),
];
let handlers = grpc_handlers_from_symbols(&symbols);
assert_eq!(handlers.get("nornir::server_grpc::BenchSvc::history").map(String::as_str), Some("Bench.History"));
assert_eq!(handlers.len(), 1, "{handlers:?}");
}
#[test]
fn grpc_handlers_derived_from_symbols() {
let symbols = vec![
sym_impl("nornir::server", "impl VizSvcTrait for VizSvc"),
sym_fn("nornir::server::VizSvc", "timeline"),
sym_fn("nornir::server::VizSvc", "bakeoff_results"),
// a non-svc impl method must not be picked up
sym_impl("nornir::server", "impl Clone for VizSvc"),
];
let handlers = grpc_handlers_from_symbols(&symbols);
assert_eq!(
handlers
.get("nornir::server::VizSvc::timeline")
.map(String::as_str),
Some("Viz.Timeline")
);
assert_eq!(
handlers
.get("nornir::server::VizSvc::bakeoff_results")
.map(String::as_str),
Some("Viz.BakeoffResults")
);
}
#[test]
fn generate_with_cli_seeds_a_cli_marker_per_verb() {
// The clap verb list becomes first-class CLI markers (the third
// entrypoint layer), deduped, and absent from plain `generate`.
let verbs = vec!["arch".to_string(), "release".to_string()];
let g = generate_with_cli(&[], &[], &[], &verbs);
let cli: Vec<&ArchNode> = g.nodes.iter().filter(|n| n.kind == NodeKind::Cli).collect();
assert_eq!(cli.len(), 2, "one Cli node per verb");
assert!(cli.iter().any(|n| n.id == "cli:arch" && n.label == "arch"));
assert!(cli.iter().any(|n| n.id == "cli:release"));
// plain generate seeds no CLI layer (lib stays binary/clap-free)
assert!(
generate(&[], &[], &[])
.nodes
.iter()
.all(|n| n.kind != NodeKind::Cli)
);
assert_eq!(NodeKind::Cli.as_str(), "cli");
assert_eq!(NodeKind::parse("cli"), NodeKind::Cli);
}
#[test]
fn node_kind_layer_matches_surface_kind_layer() {
// AUT8-GAP-LAYER: the arch layer axis is 1:1 with the matrix surface layer.
assert_eq!(NodeKind::Component.layer(), "ui");
assert_eq!(NodeKind::Grpc.layer(), "grpc");
assert_eq!(NodeKind::Cli.layer(), "cli");
assert_eq!(NodeKind::CoreFn.layer(), "core");
assert_eq!(NodeKind::Table.layer(), "table");
// Peer correspondence with nornir_testmatrix::discover::SurfaceKind::layer.
use nornir_testmatrix::discover::SurfaceKind;
assert_eq!(NodeKind::Component.layer(), SurfaceKind::VizTab.layer());
assert_eq!(NodeKind::Grpc.layer(), SurfaceKind::Grpc.layer());
assert_eq!(NodeKind::Cli.layer(), SurfaceKind::CliCommand.layer());
assert_eq!(NodeKind::CoreFn.layer(), SurfaceKind::Function.layer());
}
#[test]
fn generate_end_to_end_derives_both_maps() {
// A pane + a server handler + a table, end-to-end through `generate`.
let symbols = vec![
sym_impl("nornir::viz::test_tab", "impl Facet for TestTab"),
sym_fn("nornir::viz::test_tab::TestTab", "fetch_test_results"),
sym_impl("nornir::server", "impl VizSvcTrait for VizSvc"),
sym_fn("nornir::server::VizSvc", "test_results"),
];
let calls = vec![];
let acc = vec![
access(
"nornir::viz::test_tab::TestTab::fetch_test_results",
"test_results",
Access::Read,
),
access(
"nornir::server::VizSvc::test_results",
"test_results",
Access::Read,
),
];
let g = generate(&symbols, &calls, &acc);
assert!(
g.nodes
.iter()
.any(|n| n.kind == NodeKind::Component && n.label == "TestTab")
);
assert!(
g.nodes
.iter()
.any(|n| n.kind == NodeKind::Grpc && n.label == "Viz.TestResults")
);
assert!(
g.nodes
.iter()
.any(|n| n.kind == NodeKind::Table && n.label == "test_results")
);
// Both the component AND the gRPC verb read test_results.
let tbl = g.nodes.iter().find(|n| n.kind == NodeKind::Table).unwrap();
let reads: Vec<&str> = g
.edges
.iter()
.filter(|e| e.to == tbl.id && e.kind == ArchEdgeKind::Reads)
.map(|e| e.from.as_str())
.collect();
assert!(reads.contains(&"component:TestTab"), "{reads:?}");
assert!(reads.contains(&"grpc:Viz.TestResults"), "{reads:?}");
}
/// ARCH5/AUT7 trace mode: from the `TestTab` entrypoint we reach exactly the
/// downstream table it reads, and an unrelated component is pruned.
#[test]
fn trace_from_entrypoint_keeps_only_reachable() {
// TestTab -reads-> test_results; an unrelated MapTab -reads-> map_tiles.
let g = ArchGraph {
nodes: vec![
ArchNode {
id: "component:TestTab".into(),
label: "TestTab".into(),
kind: NodeKind::Component,
},
ArchNode {
id: "component:MapTab".into(),
label: "MapTab".into(),
kind: NodeKind::Component,
},
ArchNode {
id: "table:test_results".into(),
label: "test_results".into(),
kind: NodeKind::Table,
},
ArchNode {
id: "table:map_tiles".into(),
label: "map_tiles".into(),
kind: NodeKind::Table,
},
],
edges: vec![
ArchEdge {
from: "component:TestTab".into(),
to: "table:test_results".into(),
kind: ArchEdgeKind::Reads,
},
ArchEdge {
from: "component:MapTab".into(),
to: "table:map_tiles".into(),
kind: ArchEdgeKind::Reads,
},
],
};
let sub = trace_from(&g, "TestTab");
// Exactly the seed + its reachable table — MapTab/map_tiles pruned.
let labels: BTreeSet<&str> = sub.nodes.iter().map(|n| n.label.as_str()).collect();
assert!(labels.contains("TestTab"), "{labels:?}");
assert!(labels.contains("test_results"), "{labels:?}");
assert!(!labels.contains("MapTab"), "unreachable pruned: {labels:?}");
assert!(
!labels.contains("map_tiles"),
"unreachable pruned: {labels:?}"
);
// The one kept edge is TestTab -reads-> test_results.
assert_eq!(sub.edges.len(), 1);
assert_eq!(sub.edges[0].from, "component:TestTab");
assert_eq!(sub.edges[0].to, "table:test_results");
assert_eq!(sub.edges[0].kind, ArchEdgeKind::Reads);
// It renders the same circuit-board SVG.
let svg = sub.to_svg();
assert!(svg.contains(">TestTab<") && svg.contains(">test_results<"));
// An entrypoint that matches nothing yields the empty graph.
assert!(trace_from(&g, "no_such_surface").nodes.is_empty());
}
#[test]
fn empty_graph_renders_placeholder_svg() {
let svg = ArchGraph::default().to_svg();
assert!(svg.starts_with("<svg"));
assert!(svg.contains("empty architecture graph"));
}
/// Click-to-code: resolve an arch node label / a typed symbol to its
/// `file:line` against scanned symbols. INJECT known rows → ASSERT the exact
/// resolved path + line + match priority (LAW 1).
#[test]
fn resolve_symbol_finds_file_line_by_name_and_leaf() {
let mut a = sym_fn("nornir::viz::arch_tab", "ArchTabState");
a.item_kind = "struct".into();
a.file = "src/viz/arch_tab.rs".into();
a.line = 99;
let mut b = sym_fn("nornir::arch", "trace_from");
b.file = "src/arch/mod.rs".into();
b.line = 460;
// A gRPC handler whose leaf segment is the verb a `Service.verb` label maps to.
let mut c = sym_fn("nornir_server::viz", "architecture");
c.file = "src/bin/nornir-server.rs".into();
c.line = 1200;
let syms = vec![a, b, c];
// Exact full-name match → the struct, at its file:line.
let hit = resolve_symbol(&syms, "ArchTabState");
assert_eq!(hit[0].file, "src/viz/arch_tab.rs");
assert_eq!(hit[0].line, 99);
assert_eq!(hit[0].kind, "struct");
// A coarsened core-fn bucket label resolves via its leaf segment.
let hit = resolve_symbol(&syms, "nornir::arch::trace_from");
assert_eq!(hit[0].symbol, "trace_from");
assert_eq!(hit[0].line, 460);
// A `Service.verb` gRPC label resolves to the handler fn via the verb leaf.
let hit = resolve_symbol(&syms, "Viz.architecture");
assert_eq!(hit[0].symbol, "architecture");
assert_eq!(hit[0].file, "src/bin/nornir-server.rs");
// No match → empty (never a false location).
assert!(resolve_symbol(&syms, "definitely_not_here").is_empty());
assert!(resolve_symbol(&syms, " ").is_empty());
}
/// The inline source-preview window reads the real lines around a target.
#[test]
fn source_window_reads_context_lines() {
let dir = std::env::temp_dir().join(format!("nornir-arch-srcwin-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = "demo.rs";
std::fs::write(dir.join(file), "l1\nl2\nl3\nl4\nl5\n").unwrap();
// Target line 3, ±1 context → lines 2..=4.
let (start, lines) = source_window(Some(&dir), file, 3, 1);
assert_eq!(start, 2);
assert_eq!(
lines,
vec!["l2".to_string(), "l3".to_string(), "l4".to_string()]
);
// Unreadable file → empty, never a panic.
let (s2, l2) = source_window(Some(&dir), "nope.rs", 1, 2);
assert_eq!(s2, 0);
assert!(l2.is_empty());
}
// ── AUT9 CommandOutcome builders — inject real data, assert sannr fields ──
fn node(id: &str, label: &str, kind: NodeKind) -> ArchNode {
ArchNode {
id: id.into(),
label: label.into(),
kind,
}
}
fn edge(from: &str, to: &str, kind: ArchEdgeKind) -> ArchEdge {
ArchEdge {
from: from.into(),
to: to.into(),
kind,
}
}
#[test]
fn trace_outcome_is_sannr_for_reachable_red_for_empty() {
// INJECT a real reachable sub-board (TestTab → core → table).
let sub = ArchGraph {
nodes: vec![
node("component:TestTab", "TestTab", NodeKind::Component),
node("corefn:nornir::test", "nornir::test", NodeKind::CoreFn),
node("table:test_results", "test_results", NodeKind::Table),
],
edges: vec![
edge(
"component:TestTab",
"corefn:nornir::test",
ArchEdgeKind::Calls,
),
edge(
"corefn:nornir::test",
"table:test_results",
ArchEdgeKind::Reads,
),
],
};
let o = trace_outcome("nornir", "TestTab", &sub);
assert!(o.is_sannr(), "populated trace is truth");
assert_eq!(o.command, "arch trace");
assert_eq!(o.data["node_count"], serde_json::json!(3));
assert_eq!(o.data["edge_count"], serde_json::json!(2));
assert_eq!(o.data["entrypoint"], serde_json::json!("TestTab"));
// real node fields surfaced (layer derived from kind).
let nodes = o.data["nodes"].as_array().unwrap();
assert!(
nodes
.iter()
.any(|n| n["label"] == "TestTab" && n["layer"] == "ui")
);
assert!(
o.data["edges"]
.as_array()
.unwrap()
.iter()
.any(|e| e["kind"] == "reads")
);
// RAGNARÖK: an empty trace (no node matched) is RED.
let empty = trace_outcome("nornir", "no_such_entry", &ArchGraph::default());
assert!(!empty.is_sannr());
assert!(empty.human.contains("no node matched"));
}
#[test]
fn open_outcome_is_sannr_for_hits_red_for_none() {
// INJECT resolved click-to-code locations (best first).
let hits = vec![
SymbolLoc {
symbol: "ArchTabState".into(),
kind: "struct".into(),
file: "src/viz/arch_tab.rs".into(),
line: 42,
},
SymbolLoc {
symbol: "ArchTabState::new".into(),
kind: "fn".into(),
file: "src/viz/arch_tab.rs".into(),
line: 60,
},
];
// all=false → only the best match is surfaced.
let best_only = open_outcome("nornir", "ArchTabState", &hits, false);
assert!(best_only.is_sannr());
assert_eq!(best_only.command, "arch open");
assert_eq!(best_only.data["match_count"], serde_json::json!(2));
assert_eq!(
best_only.data["best"]["file"],
serde_json::json!("src/viz/arch_tab.rs")
);
assert_eq!(best_only.data["best"]["line"], serde_json::json!(42));
assert_eq!(
best_only.data["locations"].as_array().unwrap().len(),
1,
"best-only slice"
);
// all=true → every candidate is surfaced.
let all = open_outcome("nornir", "ArchTabState", &hits, true);
assert!(all.is_sannr());
assert_eq!(all.data["locations"].as_array().unwrap().len(), 2);
// RAGNARÖK: no symbol matched is RED.
let none = open_outcome("nornir", "zzz_nope", &[], false);
assert!(!none.is_sannr());
assert!(none.human.contains("no symbol matched"));
}
}