src/viz/arch_tab.rs
//! 🏛 **Architecture** tab — the EPIC ARCH wiring board, drawn **natively in
//! egui inside the running viz**.
//!
//! The wiring graph (UI-component ↔ gRPC ↔ core-fn ↔ warehouse-table) already
//! exists as the static circuit-board SVG `nornir arch svg` emits and as the
//! `arch_graph` MCP tool's JSON — but it could not be *viewed in the app*. This
//! pane closes that gap: it reads the persisted [`crate::arch::ArchGraph`] for the
//! active workspace and paints it with egui shapes — nodes coloured by
//! [`crate::arch::NodeKind`] via the active facett palette, edges showing the
//! wiring, navigable (pan/zoom, click a node → highlight its downstream wiring,
//! the same trace `nornir arch trace` computes). It is **egui-native** (no
//! usvg/resvg rasterisation), reusing the DepGraph / CallGraph rendering approach.
//!
//! Source split (mirrors every other pane):
//! * **local / fat** — open the warehouse directly and
//! [`merge`](crate::arch::warehouse::merged_arch_from_warehouse) every
//! member's latest persisted board into one workspace-wide graph.
//! * **thin / remote** — the server owns the redb lock, so the merge runs
//! server-side and ships back over the `Viz.Architecture` RPC
//! ([`crate::viz::remote::fetch_architecture`]).
//!
//! Empty state: if no member has a persisted board yet, the pane shows a clean
//! "run `nornir arch generate`" placeholder — never a hard error, never a TODO.
use std::collections::{BTreeSet, HashMap, VecDeque};
use std::path::PathBuf;
use std::time::Duration;
use eframe::egui::{self, Color32, FontId, Pos2, RichText, ScrollArea, Sense, Stroke, Vec2};
use serde::{Deserialize, Serialize};
#[cfg(test)]
use crate::arch::ArchEdge;
use crate::arch::metro::{self, ButtonRpc, MetroLine};
use crate::arch::{ArchEdgeKind, ArchGraph, ArchNode, NodeKind, SymbolLoc};
use crate::coverage::{Boundary, classify};
use crate::viz::trace;
use super::facett_theme::{Theme, ThemeExt};
/// How often the **Live system** sub-view re-reads the warehouse (repaint-on-
/// change), mirroring `bench_live`/`coverage_live`.
const LIVE_RELOAD_EVERY: Duration = Duration::from_millis(2000);
/// The 🏛 Architecture tab is a CONTAINER: an internal sub-view selector picks
/// which architecture lens to draw. (Deps is rendered by the app — the tab only
/// owns which lens is active so the app routes the dep graph in; the other three
/// are owned + drawn here.)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchSubView {
/// 🔗 The dependency graph (cross-repo + intra-repo). Drawn by the app
/// (`draw_dep_graph`) because it needs the timeline; consolidated under Arch.
Deps,
/// 📡 The wiring board, warehouse-driven + repaint-on-change.
Live,
/// 🏛 The EPIC ARCH wiring board (arch_graph/arch_svg), static.
Wiring,
/// 📍 Hållpunkter — the ui / gRPC / emitter LANDMARK functions as nodes.
Boundaries,
/// 🚇 Metro map — per-button click → gRPC CALL PATH, drawn as subway lines
/// with every function a station + boundary fns labelled interchanges. This
/// is the call-PATH map, NOT the function call graph (that is `CallGraph`).
Metro,
/// 🕸 The function/symbol call graph (`caller → callee` from `call_edges`),
/// drilled repo → crate → fn calls. Drawn by the app (it owns the warehouse-
/// backed [`crate::viz::callgraph::CallGraphState`] renderer), the same way
/// `Deps` is — the tab leaves the central area free for it. Restored here as a
/// distinct lens (the consolidation folded it under Arch, but it had become
/// unreachable when the top-level 🕸 Call Graph button was dropped).
CallGraph,
}
impl ArchSubView {
/// Every sub-view, in selector order — the strip the tab paints + the set the
/// headless matrix enumerates.
pub const ALL: [ArchSubView; 6] = [
ArchSubView::Deps,
ArchSubView::Live,
ArchSubView::Wiring,
ArchSubView::Boundaries,
ArchSubView::CallGraph,
ArchSubView::Metro,
];
/// Stable id (the `state_json` / click contract name).
pub fn id(self) -> &'static str {
match self {
ArchSubView::Deps => "Deps",
ArchSubView::Live => "Live",
ArchSubView::Wiring => "Wiring",
ArchSubView::Boundaries => "Boundaries",
ArchSubView::CallGraph => "CallGraph",
ArchSubView::Metro => "Metro",
}
}
/// The human label the selector chip paints.
pub fn label(self) -> &'static str {
match self {
ArchSubView::Deps => "🔗 Dep Graph",
ArchSubView::Live => "📡 Live system",
ArchSubView::Wiring => "🏛 Wiring",
ArchSubView::Boundaries => "📍 Hållpunkter",
ArchSubView::CallGraph => "🕸 Call Graph",
ArchSubView::Metro => "🚇 Metro (call path)",
}
}
fn from_id(s: &str) -> Option<ArchSubView> {
ArchSubView::ALL.into_iter().find(|v| v.id() == s)
}
}
/// The decoded `Viz.Architecture` payload (the server's merged board + which
/// members contributed + any per-member load errors). Shared between the embedded
/// warehouse merge and the remote RPC decode, so a thin client renders the exact
/// same graph the fat path produces.
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct ArchPayload {
pub graph: ArchGraph,
#[serde(default)]
pub members_with_data: Vec<String>,
#[serde(default)]
pub errors: Vec<String>,
/// node id → coverage verdict (`"covered"` | `"allowlisted"` | `"missing"`),
/// cross-checked against the `surface_coverage` matrix: does a test exist for
/// this marker? Empty when no coverage was recorded. Populated on BOTH the
/// local (warehouse) and the remote (server) side so thin == fat — the
/// green/red cross-check is the same warehouse data either way.
#[serde(default)]
pub coverage: std::collections::BTreeMap<String, String>,
/// The merged `symbol_facts` of every member, read from the WAREHOUSE
/// (`KnowledgeView::load_latest`). The fuel the 🚇 Metro map needs in THIN
/// mode — where there is no local checkout to scan, so the call-PATH lines are
/// built from these warehouse-persisted facts instead. Empty in the fat path
/// (the pane scans the checkout directly); populated server-side so the thin
/// metro draws the EXACT same button→gRPC paths a local scan would.
#[serde(default)]
pub symbols: Vec<crate::knowledge::symbols::SymbolRow>,
/// The merged `call_edges` (caller→callee) of every member, read from the
/// WAREHOUSE alongside [`Self::symbols`]. Together they let the thin metro
/// build real lines (not click-handler-head stubs) without a checkout.
#[serde(default)]
pub calls: Vec<crate::knowledge::symbols::CallEdgeRow>,
/// The SCIP-provenance of the [`Self::symbols`]/[`Self::calls`] fuel, as the
/// server's `query::load_preferred` chokepoint tagged it: `"resolved/scip"`
/// (the resolved SCIP index answered for ≥1 member), `"syn"` (the name-based
/// warehouse map), or `""`/`"warehouse"` (no facts / an older server that
/// didn't ship the tag). Lets the THIN metro style + label resolved-vs-syn
/// edges the SAME way the fat path does, instead of blindly tagging
/// `"warehouse"`. `#[serde(default)]` ⇒ an older server (no field) ⇒ `""`.
#[serde(default)]
pub metro_fuel_source: String,
/// `(gRPC handler fn, table)` — the warehouse table(s) nearest to each
/// handler along CALLS + TOUCHES_TABLE, from the code room: the 🚇 lines'
/// TERMINI. `#[serde(default)]` ⇒ an older server (no field) ⇒ every line
/// ends in a placeholder terminus, as before.
#[serde(default)]
pub access: Vec<(String, String)>,
}
/// Where the graph comes from — a local warehouse (fat) or the server (thin).
enum Src {
Local(PathBuf),
Remote { endpoint: String, token: String },
}
/// A laid-out node (graph node + its on-canvas position, before pan/zoom).
struct Laid {
node: ArchNode,
pos: Pos2,
}
/// INPUT of an architecture load — which source + workspace was asked for.
#[derive(Serialize)]
struct LoadIn {
source: String,
workspace: String,
}
/// OUTPUT of an architecture render — the exact board the user sees, as readable
/// data (LAW #6): counts per kind + edges + which node is selected.
#[derive(Serialize)]
struct RenderOut {
node_count: usize,
edge_count: usize,
components: usize,
grpc: usize,
cli: usize,
backends: usize,
codecs: usize,
sinks: usize,
core_fns: usize,
tables: usize,
/// Markers with a covering test (`surface_coverage` verdict `covered`).
tested: usize,
/// Markers in the surface with no test (`missing`) — the red ones.
untested: usize,
/// Markers that are known-uncovered but allowlisted (tracked gaps).
allowlisted: usize,
/// Markers with no matching coverage row (not claimed either way).
coverage_unknown: usize,
selected: Option<String>,
}
/// The 🚇 Metro map's populated-state AGGREGATES — the verify-without-looking
/// numbers the emitter ships (and `state_json` exposes). A blank/stub metro is
/// red in this data (`metro_edges == 0` or `metro_emitter_nodes == 0` or
/// `metro_stub_lines > 0`), so correctness is asserted from the emit, never pixels.
#[derive(Serialize, Clone, Copy, Default)]
struct MetroAggregates {
line_count: usize,
reached_lines: usize,
metro_nodes: usize,
metro_edges: usize,
metro_emitter_nodes: usize,
metro_stub_lines: usize,
}
pub struct ArchTabState {
src: Src,
workspace: String,
/// `true` once a load has been attempted (so we don't reload every frame).
loaded: bool,
/// When the last board load ran — the shared `repo_pane` throttle clock so an
/// EMPTY board auto-reloads (a background `arch_generate`/populate fills it
/// without a click) instead of latching blank. `None` ⇒ never loaded.
last_fetch_at: Option<std::time::Instant>,
/// The merged board to render (empty until loaded / when no member has data).
graph: ArchGraph,
/// Members that contributed a board (for the placeholder / status line).
members_with_data: Vec<String>,
/// Configured members (for the "which members lack a board" placeholder).
members: Vec<String>,
/// Per-member load errors (surfaced; never blank the board).
errors: Vec<String>,
/// A human error string if the whole load failed (e.g. RPC down).
load_error: Option<String>,
/// Laid-out nodes + adjacency (index space), rebuilt when `graph` changes.
laid: Vec<Laid>,
/// id → index into `laid`.
idx: HashMap<String, usize>,
/// Forward adjacency over all edge kinds (for the downstream-trace highlight).
adj: HashMap<usize, Vec<usize>>,
/// Clicked node (index into `laid`) — highlights its downstream wiring.
selected: Option<usize>,
/// The downstream-reachable set from `selected` (BFS), recomputed on click.
traced: BTreeSet<usize>,
pan: Vec2,
zoom: f32,
/// Edge-trigger for the `architecture.render` trace event (once per data
/// change / selection change, not once per frame).
render_dirty: bool,
/// node id → coverage verdict from the `surface_coverage` matrix
/// (`"covered"`/`"allowlisted"`/`"missing"`). The cross-check that paints a
/// marker green (a test exists) or red (none). Empty = no coverage recorded.
coverage: std::collections::BTreeMap<String, String>,
theme: Theme,
// ── container: which sub-view (lens) is active ───────────────────────────
/// The active sub-view (Deps / Live / Wiring / Hållpunkter).
sub: ArchSubView,
// ── click-to-code (the symbol → file:line bridge) ────────────────────────
/// Local checkout root (`workspace_root/<member>`-ish) used to resolve a
/// clicked node label to source via a fresh `symbol_facts` scan, and to read
/// the inline preview window. Empty in remote mode (no checkout → no resolve).
repo_root: PathBuf,
/// The repo to scan for symbol resolution (the first configured member).
resolve_repo: String,
/// Scanned symbols, lazily loaded the first time a node is clicked (so the
/// tab doesn't scan on open). `None` until attempted; `Some(vec)` after.
symbols: Option<Vec<crate::knowledge::symbols::SymbolRow>>,
/// Scanned call edges (caller→callee), cached alongside `symbols` from the
/// same scan — the fuel for the 🚇 Metro map's button→gRPC paths.
calls: Option<Vec<crate::knowledge::symbols::CallEdgeRow>>,
/// The metro lines (one per button), built lazily on first Metro draw from
/// the cached scan. `None` until built; rebuilt when the scan is re-scoped.
metro_lines: Option<Vec<MetroLine>>,
/// **SCIP-provenance of the metro fuel** — the stable source tag the
/// `query::load_preferred` chokepoint returned for the call graph the lines
/// are built from: `"resolved/scip"` (the resolved SCIP index answered),
/// `"syn"` (the warehouse syn map), `"syn/scan"` (a live checkout scan), or
/// `"warehouse"` (thin-mode facts the server shipped). Surfaced in
/// `state_json` (LAW 6) so a reader can SEE which index resolved the edges
/// vs the syn fallback, and used to style the rail (resolved = solid accent,
/// syn-fallback = dimmed). Empty until `ensure_symbols` runs.
metro_fuel_source: String,
/// `(gRPC handler fn, table)` from the code room (`ArchPayload::access`):
/// where each 🚇 line ends. Empty = no handler reaches a table (or an
/// older server): placeholder termini.
access: Vec<(String, String)>,
/// The currently-selected metro button id (the highlighted line). Defaults to
/// the first registry button so the lens is never blank.
metro_selected: String,
/// The last click-to-code resolution: the queried symbol + its `file:line`
/// candidates (best first). Drives the inline source-preview panel + the
/// `state_json` last-clicked record + the `architecture.open` trace.
last_open: Option<OpenResult>,
/// `true` while the Live sub-view should repaint-on-change (the `live` toggle
/// parity with bench_live/coverage_live). Reuses `reload()` on the timer.
live: bool,
last_live_reload: std::time::Instant,
/// N4 — the workspace GIT root used to locate the server-cloned sources on
/// disk (`repo_dir_resolved(source_root, <repo>)` → the real checkout). Set by
/// the app in BOTH local and thin modes so the source viewer reads the actual
/// file bytes (not a stub) even when the warehouse is served remotely but the
/// clones live locally under `~/.nornir/workspaces/<ws>/git/<repo>/…`.
source_root: PathBuf,
/// N4 — a ⊙ fit was requested: on the next central-panel draw, reframe the
/// zoom so the whole graph's content extent fits the viewport (needs the live
/// viewport size, which is only known inside the panel).
fit_pending: bool,
/// N4 — the set of identifiers that are click-through NAVIGABLE in the source
/// viewer: symbol names known to the loaded wiring (graph node labels +
/// scanned `symbol_facts` + `call_edges` idents). Lazily built, dropped when
/// the graph/symbols change. A token in a source line that is in this set
/// renders as a link that jumps to its definition (Wikipedia-style walk).
navigable: Option<BTreeSet<String>>,
/// N2 — background "Deep-scan this repo" state: recomputes `call_edges` on
/// demand (thin: a server-side workspace republish that persists them; local:
/// a live syn scan of the checkout seeded straight into the caches).
deep_scan: std::sync::Arc<std::sync::Mutex<DeepScan>>,
}
/// N2 — shared state of the manual deep-scan kick. Thin mode republishes the
/// workspace server-side (persisting `symbol_facts` + `call_edges`); local mode
/// scans the checkout and returns the rows to seed on the UI thread.
#[derive(Default)]
struct DeepScan {
inflight: bool,
error: Option<String>,
/// Local-mode scan result to apply on the UI thread: (symbols, calls).
result: Option<(
Vec<crate::knowledge::symbols::SymbolRow>,
Vec<crate::knowledge::symbols::CallEdgeRow>,
)>,
/// Thin-mode: the server republish finished OK → drop the load latch so the
/// board/metro refetch the freshly-persisted `call_edges` from the warehouse.
refetch: bool,
/// A short human note for the status line after a completed scan.
note: Option<String>,
}
/// One click-to-code resolution — the queried symbol, the resolved candidates,
/// whether the editor was launched, and the inline source-preview window.
#[derive(Clone, Default)]
struct OpenResult {
query: String,
hits: Vec<SymbolLoc>,
/// `Some(true)` opened in editor, `Some(false)` `code` absent, `None` not tried.
editor_opened: Option<bool>,
/// The 1-based first line of `preview` + the source lines around the hit.
preview_start: u32,
preview: Vec<String>,
}
/// A marker's test-matrix status, derived from its `surface_coverage` verdict.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Cov {
/// A covering test exists (`verdict = covered`).
Tested,
/// Known-uncovered but allowlisted (`verdict = allowlisted`) — a tracked gap.
Allowlisted,
/// In the surface, no test (`verdict = missing`) — RED.
Untested,
/// No coverage row matched this node — we don't claim either way.
Unknown,
}
impl Cov {
fn from_verdict(v: &str) -> Cov {
match v {
"covered" => Cov::Tested,
"allowlisted" => Cov::Allowlisted,
"missing" => Cov::Untested,
_ => Cov::Unknown,
}
}
fn label(self) -> &'static str {
match self {
Cov::Tested => "tested",
Cov::Allowlisted => "allowlisted",
Cov::Untested => "untested",
Cov::Unknown => "unknown",
}
}
}
impl ArchTabState {
pub fn local(root: PathBuf) -> Self {
Self::with(Src::Local(root), String::new())
}
pub fn remote(endpoint: String, token: String, workspace: String) -> Self {
Self::with(Src::Remote { endpoint, token }, workspace)
}
fn with(src: Src, workspace: String) -> Self {
Self {
src,
workspace,
loaded: false,
last_fetch_at: None,
graph: ArchGraph::default(),
members_with_data: Vec::new(),
members: Vec::new(),
errors: Vec::new(),
load_error: None,
laid: Vec::new(),
idx: HashMap::new(),
adj: HashMap::new(),
selected: None,
traced: BTreeSet::new(),
pan: Vec2::ZERO,
zoom: 1.0,
render_dirty: false,
coverage: std::collections::BTreeMap::new(),
theme: Theme::default(),
// N3 — the Architecture tab OPENS on the 🕸 Call Graph lens: the 3-D
// rotating node cloud (the app draws `CallGraphState`, which auto-spins
// the instant it's active). The 2-D metro/wiring/dep lenses stay one
// click away in the sub-view selector strip (the toggle).
sub: ArchSubView::CallGraph,
repo_root: PathBuf::new(),
resolve_repo: String::new(),
symbols: None,
calls: None,
metro_lines: None,
metro_fuel_source: String::new(),
access: Vec::new(),
metro_selected: metro::button_registry()
.first()
.map(|b| b.id.to_string())
.unwrap_or_default(),
last_open: None,
live: false,
last_live_reload: std::time::Instant::now(),
source_root: PathBuf::new(),
fit_pending: false,
navigable: None,
deep_scan: std::sync::Arc::new(std::sync::Mutex::new(DeepScan::default())),
}
}
/// N4 — seed the workspace GIT root the source viewer resolves on-disk clones
/// against (`repo_dir_resolved(source_root, <repo>)`). Set by the app in both
/// local and thin modes; empty falls back to the registry default.
pub fn set_source_root(&mut self, root: PathBuf) {
self.source_root = root;
}
/// The active sub-view (lens). `pub` so the app routes the Deps lens to the
/// dep graph + the headless matrix reads which lens is showing.
pub fn sub_view(&self) -> ArchSubView {
self.sub
}
/// Switch the active sub-view (selector click / robot drive). Emits the
/// switch as a trace event so it's observable headlessly.
pub fn set_sub_view(&mut self, sub: ArchSubView) {
if self.sub != sub {
self.sub = sub;
trace::emit_event(
"architecture.subview",
&serde_json::json!({ "sub": sub.id() }),
);
self.render_dirty = true;
}
}
/// Seed the local checkout root + the repo to scan for click-to-code symbol
/// resolution (the inline source preview + `code --goto`). Set by the app on
/// build/switch; empty in remote mode (no checkout, no resolve).
pub fn set_resolve_source(&mut self, repo_root: PathBuf, repo: String) {
if self.repo_root != repo_root || self.resolve_repo != repo {
self.repo_root = repo_root;
self.resolve_repo = repo;
self.symbols = None; // re-scan lazily for the new source
self.calls = None;
self.metro_lines = None;
self.metro_fuel_source.clear();
self.navigable = None;
}
}
/// Set the facett palette the pane paints with (C8).
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
/// The configured workspace members (for the placeholder that names which
/// members still need `nornir arch generate`). Set by the app on build/switch.
pub fn set_members(&mut self, members: Vec<String>) {
self.members = members;
}
/// Re-scope to a different workspace (the picker switched): drop the cached
/// board so the next draw reloads from the new workspace.
pub fn set_workspace(&mut self, members: Vec<String>) {
self.members = members;
self.loaded = false;
self.last_fetch_at = None;
self.graph = ArchGraph::default();
self.members_with_data.clear();
self.errors.clear();
self.load_error = None;
self.laid.clear();
self.idx.clear();
self.adj.clear();
self.selected = None;
self.traced.clear();
self.coverage.clear();
self.pan = Vec2::ZERO;
self.zoom = 1.0;
self.symbols = None;
self.calls = None;
self.metro_lines = None;
self.metro_fuel_source.clear();
self.last_open = None;
self.navigable = None;
// Task 2: a new workspace may legitimately need its own auto-generate kick.
*self.deep_scan.lock().unwrap() = DeepScan::default();
}
/// Thin mode only: re-point the `Viz.Architecture` RPC at a new workspace.
pub fn set_workspace_name(&mut self, workspace: String) {
self.workspace = workspace;
}
/// Force a reload on the next draw (e.g. after a ⟳ Sync changed the warehouse).
pub fn reload(&mut self) {
self.loaded = false;
self.last_fetch_at = None;
}
/// Load the merged board: local opens the warehouse + merges every member's
/// latest board; remote reads the server's merge over `Viz.Architecture`.
fn ensure_loaded(&mut self) {
// AUTO-REFRESH (the sticky-`loaded` fix): the first load always runs;
// while the board is EMPTY the shared `repo_pane` throttle re-loads —
// eagerly when an `arch_generate`/populate job for this workspace is
// active, else on the slow interval — so a background generate's board
// appears WITHOUT a manual ↻. Once nodes exist it latches (no churn).
let populate_active = super::repo_pane::populate_active_for(&self.workspace, "");
if !super::repo_pane::should_refetch(
self.loaded,
self.last_fetch_at,
self.graph.nodes.is_empty(),
populate_active,
) {
return;
}
self.loaded = true;
self.last_fetch_at = Some(std::time::Instant::now());
let source = match &self.src {
Src::Local(p) => format!("local {}", p.display()),
Src::Remote { endpoint, .. } => format!("remote {endpoint} (ws={})", self.workspace),
};
trace::emit_in(
"architecture.load",
&LoadIn {
source,
workspace: self.workspace.clone(),
},
);
// The board and the metro fuel are the code room's (`Viz.Architecture`:
// the kept classifier over the room's symbol / call rows). Thin asks the
// server; fat asks the workspace's own code room in process — the same
// reader, the same JSON. A reader that cannot answer (nothing consumed,
// no lake) is `load_error` with its state — never an empty board that
// reads as "nothing wired".
let payload: Result<ArchPayload, String> = match &self.src {
Src::Remote { endpoint, token } => super::remote::fetch_architecture(endpoint, token, &self.workspace).map_err(|e| format!("architecture board: {e:#}")),
Src::Local(root) => super::remote::local_surface(root, &self.workspace, "Viz.Architecture", &serde_json::json!({ "workspace": self.workspace }))
.map_err(|e| format!("architecture board: {e:#}"))
.and_then(|v| serde_json::from_value(v).map_err(|e| format!("architecture board: the reader's answer is not a board: {e}"))),
};
match payload {
Ok(p) => {
self.load_error = None;
self.members_with_data = p.members_with_data;
self.errors = p.errors;
self.coverage = p.coverage;
// THIN-MODE METRO FUEL: in remote mode the server ships the merged
// `symbol_facts` + `call_edges` it read from the WAREHOUSE (the fat
// path scans the local checkout instead, so it leaves these empty).
// When the payload carries them, seed the scan caches so the 🚇
// Metro map builds real button→gRPC lines WITHOUT a checkout — the
// core fix: thin mode reads the warehouse, it does NOT degrade to
// click-handler-head stubs. Only overwrite when the server actually
// supplied facts, so the fat path's own checkout scan is preserved.
if !p.symbols.is_empty() {
self.symbols = Some(p.symbols);
self.calls = Some(p.calls);
// The reader's provenance tag (`resolved/scip`: rustc resolved
// every callee) styles the metro rails; an older server that
// ships "" reads as "unknown".
self.metro_fuel_source = p.metro_fuel_source;
self.metro_lines = None; // rebuild from the room's rows
}
self.access = p.access;
self.set_graph(p.graph);
}
Err(e) => {
self.load_error = Some(e);
self.coverage.clear();
self.set_graph(ArchGraph::default());
}
}
}
/// This node's test-matrix status (the green/red cross-check).
fn cov_of(&self, node_id: &str) -> Cov {
match self.coverage.get(node_id) {
Some(v) => Cov::from_verdict(v),
None => Cov::Unknown,
}
}
/// Swap in a new graph + rebuild the layout/adjacency + reset navigation.
fn set_graph(&mut self, graph: ArchGraph) {
self.graph = graph;
self.selected = None;
self.traced.clear();
self.pan = Vec2::ZERO;
self.zoom = 1.0;
self.navigable = None; // rebuild the click-through set for the new graph
self.build_layout();
self.render_dirty = true;
}
/// Layered layout (PCB-board style, reused from `arch::layers_of`'s model):
/// columns by topological layer, tables pinned right; positions centred per
/// column. Pure + deterministic, so the rendered board is stable.
fn build_layout(&mut self) {
self.laid.clear();
self.idx.clear();
self.adj.clear();
let layers = layers_of(&self.graph);
const COL_W: f32 = 230.0;
const ROW_H: f32 = 64.0;
let cols = layers.len().max(1) as f32;
let rows = layers.iter().map(|l| l.len()).max().unwrap_or(1).max(1) as f32;
let total_w = COL_W * (cols - 1.0).max(0.0);
let total_h = ROW_H * (rows - 1.0).max(0.0);
for (ci, layer) in layers.iter().enumerate() {
let layer_h = ROW_H * (layer.len().saturating_sub(1)) as f32;
let y0 = -total_h / 2.0 + (total_h - layer_h) / 2.0;
for (ri, &node_i) in layer.iter().enumerate() {
let x = -total_w / 2.0 + ci as f32 * COL_W;
let y = y0 + ri as f32 * ROW_H;
let node = self.graph.nodes[node_i].clone();
self.idx.insert(node.id.clone(), self.laid.len());
self.laid.push(Laid {
node,
pos: Pos2::new(x, y),
});
}
}
// Forward adjacency over all edge kinds, in index space.
for e in &self.graph.edges {
if let (Some(&f), Some(&t)) = (self.idx.get(&e.from), self.idx.get(&e.to)) {
self.adj.entry(f).or_default().push(t);
}
}
}
/// Build the SHARED [`super::graph_render::GraphModel`] from the laid-out
/// board: each chip's kind fill, and a stroke that folds in the coverage ring
/// (green tested / red untested / amber allowlisted) so the arch board keeps
/// its keystone cross-check WITHOUT a decoration overlay — i.e. it is the
/// "no decorations" caller of the shared `draw_graph`. Edges coloured by kind.
fn graph_model(&self) -> super::graph_render::GraphModel {
let theme = self.theme;
let nodes = self
.laid
.iter()
.map(|l| {
let (fill, kind_stroke) = kind_color(&theme, l.node.kind);
// Coverage ring rides in the node stroke (the shared renderer uses
// the node stroke when no decoration ring is supplied).
let stroke = cov_ring(self.cov_of(&l.node.id)).unwrap_or(kind_stroke);
super::graph_render::GraphNode {
id: l.node.id.clone(),
label: l.node.label.clone(),
fill,
stroke,
pos: l.pos,
}
})
.collect();
let edges = self
.graph
.edges
.iter()
.map(|e| super::graph_render::GraphEdge {
from: e.from.clone(),
to: e.to.clone(),
color: edge_color(&theme, e.kind),
dashed: false,
label: None,
})
.collect();
super::graph_render::GraphModel { nodes, edges }
}
/// BFS the downstream-reachable set from a clicked node (the visual twin of
/// `nornir arch trace` lifted to the laid-out board).
fn trace_from(&mut self, seed: usize) {
self.traced.clear();
let mut q = VecDeque::new();
self.traced.insert(seed);
q.push_back(seed);
while let Some(cur) = q.pop_front() {
if let Some(outs) = self.adj.get(&cur) {
for &nxt in outs {
if self.traced.insert(nxt) {
q.push_back(nxt);
}
}
}
}
}
/// Lazily scan the configured repo's symbols (the click-to-code source of
/// truth). Best-effort: in remote mode / with no checkout, leaves `symbols`
/// an empty vec so resolution returns nothing (the printed `file:line` + the
/// graph still work; only the inline preview is unavailable).
fn ensure_symbols(&mut self) {
if self.symbols.is_some() {
return;
}
// The fuel rides the board's payload (`ensure_loaded`). Reaching here
// means that payload carried none: the board could not be read, or the
// reader left the fuel out (too large for one message). The tag says
// which, so the metro never reads as "no lines".
self.ensure_loaded();
if self.symbols.is_some() {
return;
}
self.symbols = Some(Vec::new());
self.calls = Some(Vec::new());
self.metro_fuel_source = if self.load_error.is_some() { "unavailable" } else { "none_shipped" }.to_string();
}
/// Build the 🚇 metro map (one line per registry button) from the cached
/// scan — REUSES [`crate::arch::metro::build_metro_map`]. Lazy: built once
/// per scan, dropped on a re-scope (workspace / resolve-source switch).
fn ensure_metro(&mut self) {
if self.metro_lines.is_some() {
return;
}
self.ensure_symbols();
let syms = self.symbols.as_deref().unwrap_or(&[]);
let calls = self.calls.as_deref().unwrap_or(&[]);
let lines = metro::build_metro_map(syms, calls);
trace::emit_end(
"architecture.metro.build",
&serde_json::json!({
"lines": lines.len(),
"reached": lines.iter().filter(|l| l.reached).count(),
"symbols": syms.len(),
"calls": calls.len(),
}),
);
self.metro_lines = Some(lines);
self.render_dirty = true;
// EMITTER (verify-without-looking): the metro's populated-or-empty state as
// DATA, the instant it's built. A blank/stub metro is now a RED matrix row,
// never a silent pass — RAGNARÖK.
self.emit_metro_status();
}
/// **S5b — the facett feed.** Build a [`facett_graphview::MetroMap`] from THIS
/// tab's already-loaded `symbol_facts` + `call_edges` (the same fuel the native
/// 🚇 metro uses), overlaid with the workspace's latest `surface_coverage` (the
/// LIT signal) and the `fn → table` warehouse-access edges (the TERMINUS), via
/// the pure [`super::metro_feed::build_metro_map`] producer. The dedicated 🚇
/// Metro tab hands the result to its [`facett_graphview::MetroView`].
///
/// Lock-tolerant: any warehouse read error → empty coverage/access (every gating
/// stop unlit — HONEST "not yet proven", never a crash). Remote/thin mode ships
/// no coverage/access yet (S6b), so those lines render unlit skeletons.
pub fn facett_metro_map(&mut self) -> facett_graphview::MetroMap {
self.ensure_symbols();
let syms = self.symbols.clone().unwrap_or_default();
let calls = self.calls.clone().unwrap_or_default();
// The terminus tables are the code room's: `(gRPC handler, nearest
// table)` along CALLS + TOUCHES_TABLE, shipped with the board
// (`ArchPayload::access`). A handler that reaches no table keeps its
// placeholder terminus. The lit overlay (`surface_coverage`, written by
// the autonom gate) is not read by this pane yet: every gating stop is
// unlit — the honest "not yet proven".
let coverage: Vec<_> = Vec::new();
let access = self.access.clone();
let map = super::metro_feed::build_metro_map(&syms, &calls, &coverage, &access);
trace::emit_end(
"architecture.metro.facett_feed",
&serde_json::json!({
"lines": map.lines.len(),
"green": map.green_count(),
"coverage_rows": coverage.len(),
"access_edges": access.len(),
}),
);
map
}
/// The 🚇 metro's POPULATED-STATE AGGREGATES, as DATA — so the metro's
/// "is it real or a stub?" question is answered WITHOUT looking at pixels:
/// * `metro_edges` — total stations *between* head and terminus summed over
/// every line (the call-path edges actually drawn).
/// * `metro_nodes` — total stations summed over every line (every fn drawn).
/// * `metro_emitter_nodes` — how many stations are MARKED emitters (the
/// emit*/trace/functional_status sites the owner wants
/// flagged), summed over every line.
/// * `metro_stub_lines` — lines that are STILL click-handler-head stubs (a
/// single station, did not reach gRPC). MUST be 0 when the
/// warehouse is populated; non-zero ⇒ the read path is broken.
/// * `reached_lines` / `line_count` — the reached-vs-total tally.
fn metro_aggregates(&self) -> MetroAggregates {
let lines = self.metro_lines.as_deref().unwrap_or(&[]);
let mut nodes = 0usize;
let mut edges = 0usize;
let mut emitter_nodes = 0usize;
let mut stub_lines = 0usize;
let mut reached_lines = 0usize;
for l in lines {
nodes += l.stations.len();
// edges = the rail segments between consecutive stations.
edges += l.stations.len().saturating_sub(1);
emitter_nodes += l.emitter_stations;
if l.reached {
reached_lines += 1;
}
// A STUB = a line whose gRPC handler IS known (the rpc→handler symbol
// resolved, so the path CAN exist) yet the call graph did not connect it
// to the head → it degraded to the click-handler head only (≤1 station).
// That is precisely the "no call edges scanned" failure the warehouse
// read fixes in thin mode. A button whose handler symbol is simply absent
// from this fuel set (`grpc_terminus == None`) is NOT a stub — it has no
// resolvable target, so it cannot be expected to reach. This makes
// `metro_stub_lines == 0` the exact populated-state invariant: every
// line that COULD reach DID.
if l.grpc_terminus.is_some() && !l.reached && l.stations.len() <= 1 {
stub_lines += 1;
}
}
MetroAggregates {
line_count: lines.len(),
reached_lines,
metro_nodes: nodes,
metro_edges: edges,
metro_emitter_nodes: emitter_nodes,
metro_stub_lines: stub_lines,
}
}
/// EMIT the metro's populated-state aggregates as a `functional_status` row +
/// a trace event. EMIT-DOCTRINE: a metro draw is a pixel/render stage whose
/// correctness CANNOT be read from a return value, so an in-fn emit (gated
/// behind `testmatrix`) is the right tool — the RAGNARÖK test reads this row
/// back. ok ⇔ the metro carries REAL call-path data: at least one edge drawn,
/// at least one emitter node marked, and ZERO stub lines (empty/stub = RED).
fn emit_metro_status(&self) {
let a = self.metro_aggregates();
trace::emit_end(
"architecture.metro.status",
&serde_json::json!({
"metro_edges": a.metro_edges,
"metro_nodes": a.metro_nodes,
"metro_emitter_nodes": a.metro_emitter_nodes,
"metro_stub_lines": a.metro_stub_lines,
"reached_lines": a.reached_lines,
"line_count": a.line_count,
}),
);
#[cfg(feature = "testmatrix")]
{
let populated =
a.metro_edges > 0 && a.metro_emitter_nodes > 0 && a.metro_stub_lines == 0;
crate::selftest::emit(
"viz/architecture (metro)",
"metro_populated_not_stub",
populated,
&format!(
"metro_edges={} metro_nodes={} metro_emitter_nodes={} metro_stub_lines={} reached={}/{}",
a.metro_edges,
a.metro_nodes,
a.metro_emitter_nodes,
a.metro_stub_lines,
a.reached_lines,
a.line_count,
),
);
}
}
/// The currently-selected metro line (the highlighted button's path).
fn selected_metro_line(&self) -> Option<&MetroLine> {
self.metro_lines
.as_ref()?
.iter()
.find(|l| l.button_id == self.metro_selected)
}
/// Click-to-code: resolve a clicked node's `label` (or a typed symbol) to its
/// source `file:line` via the SHARED [`crate::arch::resolve_symbol`] (the same
/// bridge `nornir arch open` uses), then (i) open it in the editor
/// (`code --goto`, best-effort), and (ii) load the inline source-preview
/// window. The resolution is recorded in `last_open` for the preview panel +
/// `state_json` + the `architecture.open` trace. Returns whether a hit was
/// found. `pub` so the robot/CLI-parity drive + tests can fire it directly.
pub fn open_symbol(&mut self, label: &str, launch_editor: bool) -> bool {
self.ensure_symbols();
let syms = self.symbols.as_deref().unwrap_or(&[]);
let hits = crate::arch::resolve_symbol(syms, label);
trace::emit_in(
"architecture.open",
&serde_json::json!({ "query": label, "candidates": hits.len() }),
);
let mut res = OpenResult {
query: label.to_string(),
hits: hits.clone(),
..Default::default()
};
if let Some(best) = hits.first() {
// (ii) inline preview window (±6 lines) — READ from the actual on-disk
// clone. `locate_repo_root` walks the candidate git roots (the app's
// source_root + the canonical `~/.nornir/workspaces/<ws>/git`) and every
// configured member until it finds the file that really contains this
// symbol, so the viewer shows real bytes in BOTH local and thin modes.
let root = self.locate_repo_root(&best.file);
let (mut start, mut lines) =
crate::arch::source_window(root.as_deref(), &best.file, best.line, 6);
// Thin mode with no local clone of this file: the server holds the
// checkouts, so fall back to the `Mimir.SourceWindow` RPC (empty repo
// ⇒ the server tries each member and returns the first that contains
// the file). The tuple shape is identical to the local reader.
if lines.is_empty() {
if let Src::Remote { endpoint, token } = &self.src {
if let Ok((s, l)) = crate::viz::remote::fetch_source_window(
endpoint,
token,
&self.workspace,
"",
&best.file,
best.line,
6,
) {
start = s;
lines = l;
}
}
}
res.preview_start = start;
res.preview = lines;
// (i) best-effort editor launch (opens the resolved absolute path).
if launch_editor {
res.editor_opened = Some(
crate::arch::open_in_editor(root.as_deref(), &best.file, best.line)
.unwrap_or(false),
);
}
}
let found = !res.hits.is_empty();
trace::emit_end(
"architecture.open",
&serde_json::json!({
"query": label,
"resolved": res.hits.first().map(|h| format!("{}:{}", h.file, h.line)),
"editor_opened": res.editor_opened,
}),
);
self.last_open = Some(res);
self.render_dirty = true;
found
}
/// N4 — locate the on-disk repo checkout that actually contains `file` (a
/// repo-relative path from `symbol_facts`). Walks the candidate GIT roots — the
/// app-supplied [`Self::source_root`] first, then the canonical server-clone dir
/// `~/.nornir/workspaces/<ws>/git` — and, under each, every configured member via
/// [`crate::config::repo_dir_resolved`] (case-tolerant), returning the first
/// checkout whose `<root>/<file>` is a real file. This is what lets the source
/// viewer read the REAL bytes in thin mode, where the warehouse is remote but the
/// clones live locally. Returns `None` when the file isn't found on disk.
fn locate_repo_root(&self, file: &str) -> Option<PathBuf> {
if file.is_empty() {
return None;
}
// 1. The configured first-member root (set directly in local mode).
if !self.repo_root.as_os_str().is_empty() && self.repo_root.join(file).is_file() {
return Some(self.repo_root.clone());
}
// Candidate GIT roots the member checkouts live under.
let mut roots: Vec<PathBuf> = Vec::new();
if !self.source_root.as_os_str().is_empty() {
roots.push(self.source_root.clone());
}
if !self.workspace.is_empty() {
// The canonical server-clone location (FIXQUEUE N4): the workspace's
// `git/<repo>/…` under the nornir registry root.
roots.push(
crate::config::registry_root()
.join(&self.workspace)
.join("git"),
);
}
// 2. Try every configured member (else the single resolve_repo) under each
// candidate git root; also try the git root itself joined, in case the
// recorded path is already repo-qualified.
let members: Vec<&str> = if self.members.is_empty() {
if self.resolve_repo.is_empty() {
Vec::new()
} else {
vec![self.resolve_repo.as_str()]
}
} else {
self.members.iter().map(String::as_str).collect()
};
for gr in &roots {
for m in &members {
let root = crate::config::repo_dir_resolved(gr, m);
if root.join(file).is_file() {
return Some(root);
}
}
if gr.join(file).is_file() {
return Some(gr.clone());
}
}
None
}
/// N4 — the click-through NAVIGABLE identifier set: the symbol names the loaded
/// wiring knows about, so a token in the source preview can be linked to its
/// definition (Wikipedia-style walk). Built from (a) the wiring graph's node
/// labels (their last `::`/`.` leaf too), (b) the scanned `symbol_facts` item
/// names, and (c) the `call_edges` callee idents. Lazily built + cached; the set
/// is dropped when the graph or symbols change.
///
/// TODO(full-resolution): this navigates identifiers KNOWN to the loaded wiring
/// (graph nodes + scanned facts). A token that resolves to a symbol outside the
/// currently-loaded members won't link yet — a full LSP/SCIP-grade cross-repo
/// resolver would close that gap.
fn navigable_symbols(&mut self) -> &BTreeSet<String> {
if self.navigable.is_none() {
let mut set: BTreeSet<String> = BTreeSet::new();
// (a) wiring graph node labels + their leaf segment.
for n in &self.graph.nodes {
let leaf = n
.label
.rsplit(['.', ':'])
.find(|s| !s.is_empty())
.unwrap_or(&n.label);
if leaf.len() >= 2 {
set.insert(leaf.to_string());
}
}
// (b) scanned symbol item names + (c) call-edge callee idents.
if let Some(syms) = &self.symbols {
for s in syms {
let leaf = s.item_name.rsplit("::").next().unwrap_or(&s.item_name);
if leaf.len() >= 2 {
set.insert(leaf.to_string());
}
}
}
if let Some(calls) = &self.calls {
for c in calls {
if c.callee_ident.len() >= 2 {
set.insert(c.callee_ident.clone());
}
}
}
self.navigable = Some(set);
}
self.navigable.as_ref().unwrap()
}
/// N2 — trigger a real `call_edges` recomputation for the selected workspace.
/// THIN mode kicks a server-side workspace republish (`Workspaces.Fetch`
/// background) that re-scans every member and PERSISTS `symbol_facts` +
/// `call_edges` to the warehouse — the exact write path the monitor uses — then
/// drops the load latch so the board/metro refetch the fresh edges. LOCAL/fat
/// mode runs a live syn scan of the selected checkout on a background thread and
/// seeds the result straight into the metro/callgraph caches. Idempotent while a
/// scan is in flight. `pub` so a headless drive can fire it.
pub fn trigger_deep_scan(&mut self) {
{
let g = self.deep_scan.lock().unwrap();
if g.inflight {
return;
}
}
match &self.src {
Src::Remote { endpoint, token } => {
let (endpoint, token, workspace) =
(endpoint.clone(), token.clone(), self.workspace.clone());
if workspace.is_empty() {
self.deep_scan.lock().unwrap().error =
Some("no workspace selected".to_string());
return;
}
{
let mut g = self.deep_scan.lock().unwrap();
g.inflight = true;
g.error = None;
g.note = None;
}
trace::emit_in(
"architecture.deepscan",
&serde_json::json!({ "mode": "remote", "workspace": workspace }),
);
let shared = self.deep_scan.clone();
std::thread::spawn(move || {
let res = super::remote::populate_workspace(&endpoint, &token, &workspace);
let mut g = shared.lock().unwrap();
g.inflight = false;
match res {
Ok((fetched, _changed, errors, _snap)) => {
g.refetch = true;
g.note = Some(format!(
"{fetched} member(s) fetched{}; the board follows what the server's consumer has written to the code room",
if errors.is_empty() {
String::new()
} else {
format!(", {} warn", errors.len())
},
));
}
Err(e) => g.error = Some(format!("{e:#}")),
}
});
}
Src::Local(_) => {
// Fat mode has nothing to scan from this pane: the board is read
// from the workspace's code room, which the consumer writes
// (`nornir workspace populate`). Re-read it.
self.deep_scan.lock().unwrap().note =
Some("re-read the code room (it is written by the consumer: `nornir workspace populate`)".to_string());
self.reload();
}
}
}
/// N2 — apply a completed background deep-scan on the UI thread: seed local-mode
/// scan rows into the metro/callgraph caches, or drop the load latch after a
/// thin-mode republish so the board/metro refetch the persisted `call_edges`.
fn poll_deep_scan(&mut self) {
let (result, refetch) = {
let mut g = self.deep_scan.lock().unwrap();
(g.result.take(), std::mem::take(&mut g.refetch))
};
if let Some((symbols, calls)) = result {
self.symbols = Some(symbols);
self.calls = Some(calls);
self.metro_lines = None;
self.metro_fuel_source = "syn/scan".to_string();
self.navigable = None;
self.render_dirty = true;
}
if refetch {
// Fresh warehouse rows landed server-side → reload the board + metro.
self.loaded = false;
self.last_fetch_at = None;
self.symbols = None;
self.calls = None;
self.metro_lines = None;
}
}
/// Build the **Hållpunkter** (boundary landmark) graph: the ui / gRPC /
/// emitter functions as LANDMARK nodes, classified by the coverage feature's
/// [`crate::coverage::classify`] (REUSED, not reinvented), from the scanned
/// symbols. Each landmark is a node kind-mapped to the board's layers
/// (ui→Component, grpc→Grpc, emitter→CoreFn) so it paints with the same
/// legend; edges are omitted (landmarks are anchors, not a call graph).
fn boundary_landmarks(&self) -> Vec<(Boundary, ArchNode)> {
let syms = self.symbols.as_deref().unwrap_or(&[]);
let mut out: Vec<(Boundary, ArchNode)> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
for s in syms {
// Build the fully-qualified-ish name the classifier expects.
let fq = if s.module_path.is_empty() {
s.item_name.clone()
} else {
format!("{}::{}", s.module_path, s.item_name)
};
let b = classify(&fq, &s.file);
if !b.is_boundary() {
continue;
}
let kind = match b {
Boundary::Ui => NodeKind::Component,
Boundary::Grpc => NodeKind::Grpc,
Boundary::Emitter | Boundary::Core => NodeKind::CoreFn,
};
let id = format!("{}:{}", b.as_str(), fq);
if seen.insert(id.clone()) {
out.push((
b,
ArchNode {
id,
label: s.item_name.clone(),
kind,
},
));
}
}
out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.label.cmp(&b.1.label)));
out
}
/// (ui, grpc, emitter) landmark tally for the legend + `state_json`.
fn boundary_tally(&self) -> (usize, usize, usize) {
let (mut u, mut g, mut e) = (0, 0, 0);
for (b, _) in self.boundary_landmarks() {
match b {
Boundary::Ui => u += 1,
Boundary::Grpc => g += 1,
Boundary::Emitter => e += 1,
Boundary::Core => {}
}
}
(u, g, e)
}
/// The 🏛 Architecture CONTAINER: paints the sub-view selector strip, then
/// dispatches to the active lens. Deps is drawn by the app (it needs the
/// timeline) — `draw` returns early for it after the strip, so the app's
/// `draw_dep_graph` paints the central area; the other three lenses are drawn
/// here. `draw_deps_for` is never called — the app routes by `sub_view()`.
pub fn draw(&mut self, ui: &mut egui::Ui) {
let theme = self.theme;
// N2 — apply any completed background deep-scan before drawing this frame.
self.poll_deep_scan();
// The sub-view selector strip — a chip per lens (Deps / Live / Wiring /
// Hållpunkter), the active one highlighted. CONSOLIDATES deps under Arch.
egui::Panel::top("arch_subview").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new("🏛 Architecture").strong().color(theme.text));
ui.separator();
for sv in ArchSubView::ALL {
if ui.selectable_label(self.sub == sv, sv.label()).clicked() {
self.set_sub_view(sv);
}
}
});
});
match self.sub {
// The app paints the dep graph / function call graph in the remaining
// central area after this returns (those renderers live on the app —
// Deps needs the timeline, CallGraph owns the warehouse-backed
// `CallGraphState`) — we leave the space free for them.
ArchSubView::Deps | ArchSubView::CallGraph => {}
ArchSubView::Boundaries => self.draw_boundaries(ui),
ArchSubView::Metro => self.draw_metro(ui),
ArchSubView::Live | ArchSubView::Wiring => self.draw_board(ui),
}
}
/// The 🏛 Wiring / 📡 Live system lens: the EPIC ARCH wiring board, drawn
/// natively. Live additionally re-reads the warehouse on a timer + requests a
/// repaint (repaint-on-change), mirroring bench_live/coverage_live.
fn draw_board(&mut self, ui: &mut egui::Ui) {
let theme = self.theme;
// Live system: timer-driven reload + repaint (warehouse-driven).
if self.sub == ArchSubView::Live {
if self.live && self.last_live_reload.elapsed() >= LIVE_RELOAD_EVERY {
self.loaded = false;
self.last_live_reload = std::time::Instant::now();
}
}
self.ensure_loaded();
egui::Panel::top("arch_controls").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.heading(if self.sub == ArchSubView::Live {
"📡 Live system"
} else {
"🏛 Wiring"
});
ui.separator();
ui.label(format!(
"{} nodes · {} edges",
self.graph.nodes.len(),
self.graph.edges.len()
));
ui.separator();
if ui.button("↻ reload").clicked() {
self.loaded = false;
self.last_fetch_at = None;
}
// N4 — ⊙ fit: reframe the zoom so the whole graph fits the viewport.
// The actual fit runs in the central panel (it needs the live
// viewport size); here we just request it. Also recentres (pan 0).
if ui
.button("⊙ fit")
.on_hover_text("zoom to frame the whole graph")
.clicked()
{
self.fit_pending = true;
self.pan = Vec2::ZERO;
self.render_dirty = true;
}
if self.selected.is_some() && ui.button("✖ clear selection").clicked() {
self.selected = None;
self.traced.clear();
self.render_dirty = true;
}
// N2 — manual deep-scan trigger: recompute `call_edges` on demand.
ui.separator();
let scanning = {
let g = self.deep_scan.lock().unwrap();
g.inflight
};
if scanning {
ui.add(egui::Spinner::new());
ui.label("deep-scanning…");
} else if ui
.button("🔬 Deep-scan this repo")
.on_hover_text(
"recompute call_edges now — thin mode republishes the workspace \
server-side (persists symbol_facts + call_edges); local mode \
scans the checkout",
)
.clicked()
{
self.trigger_deep_scan();
}
{
let (err, note) = {
let g = self.deep_scan.lock().unwrap();
(g.error.clone(), g.note.clone())
};
if let Some(e) = err {
ui.colored_label(super::facett_theme::RED, format!("⚠ {e}"));
} else if !scanning {
if let Some(n) = note {
ui.colored_label(super::facett_theme::GREEN, format!("✓ {n}"));
}
}
}
if self.sub == ArchSubView::Live {
ui.separator();
ui.checkbox(&mut self.live, "📡 live")
.on_hover_text("re-read the warehouse + repaint on change");
}
if !self.members_with_data.is_empty() {
ui.separator();
ui.label(format!("members: {}", self.members_with_data.join(", ")));
}
});
// The kind legend (chip FILL = layer), coloured exactly as the chips paint.
ui.horizontal_wrapped(|ui| {
for (kind, name) in [
(NodeKind::Component, "UI component"),
(NodeKind::Grpc, "gRPC"),
(NodeKind::Cli, "CLI command"),
(NodeKind::Backend, "backend"),
(NodeKind::Codec, "codec"),
(NodeKind::Sink, "sink"),
(NodeKind::CoreFn, "core fn"),
(NodeKind::Table, "warehouse table"),
] {
let (fill, _stroke) = kind_color(&theme, kind);
let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), Sense::hover());
ui.painter().rect_filled(rect, 2.0, fill);
ui.label(name);
ui.add_space(8.0);
}
});
// The coverage legend + summary (chip RING = test-matrix cross-check):
// does a test exist for this marker? This is the keystone of the tab.
let (tested, untested, allowlisted, unknown) = self.cov_counts();
ui.horizontal_wrapped(|ui| {
ui.label("tests:");
for (col, name, n) in [
(super::facett_theme::GREEN, "tested", tested),
(super::facett_theme::RED, "untested", untested),
(super::facett_theme::AMBER, "allowlisted", allowlisted),
] {
let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), Sense::hover());
ui.painter().rect_stroke(
rect,
2.0,
Stroke::new(2.0_f32, col),
egui::epaint::StrokeKind::Outside,
);
ui.label(format!("{name} {n}"));
ui.add_space(8.0);
}
if unknown > 0 {
ui.label(format!("· {unknown} not in surface_coverage"));
}
if tested + untested + allowlisted + unknown == self.graph.nodes.len()
&& self.coverage.is_empty()
{
ui.label("· (no coverage recorded — run `nornir test coverage`)");
}
});
});
// Edge-triggered render trace (LAW #6): emit what the board shows once per
// data/selection change, not every frame.
if self.render_dirty {
trace::emit_end("architecture.render", &self.render_out());
self.render_dirty = false;
}
// Click-to-code: the inline source-preview panel (sits below the board,
// above the central canvas — egui needs side/bottom panels declared
// before the CentralPanel). Shows the lines around the last-clicked
// symbol's `file:line`, the editor-open result, and any extra candidates.
self.draw_source_preview(ui);
egui::CentralPanel::default().show_inside(ui, |ui| {
if let Some(err) = &self.load_error {
ui.colored_label(
super::facett_theme::RED,
format!("architecture load failed: {err}"),
);
return;
}
for e in &self.errors {
ui.colored_label(super::facett_theme::AMBER, format!("⚠ {e}"));
}
if self.graph.nodes.is_empty() {
self.draw_placeholder(ui);
return;
}
// Build the SHARED graph model (the same `draw_graph` the 🧬 release
// dashboard uses) + sync the canvas view from this tab's persisted
// pan/zoom/selection (kept index-based for the rest of the tab).
let model = self.graph_model();
// N4 — ⊙ fit: reframe so the whole graph fits. `content_extent` is the
// graph's true painted size in WORLD units (pre-zoom); pick the zoom that
// makes it fit the live viewport (with a small margin), clamped to the
// renderer's zoom range. Works for both the pan/zoom (Live) and the
// scrolled (Wiring) canvases — a smaller zoom shrinks the scrolled canvas
// until it needs no scrollbar.
if self.fit_pending {
let extent = super::graph_render::content_extent(&model);
let avail = ui.available_size();
if extent.x > 1.0 && extent.y > 1.0 {
let z = (avail.x / extent.x).min(avail.y / extent.y);
self.zoom = (z * 0.92).clamp(0.25, 4.0);
}
self.pan = Vec2::ZERO;
self.fit_pending = false;
}
let mut view = super::graph_render::GraphView {
pan: self.pan,
zoom: self.zoom,
selected: self.selected.map(|i| self.laid[i].node.id.clone()),
dot_px: None,
};
// The arch board carries NO decoration overlay — its coverage ring
// rides in the node's own stroke colour (`graph_model`). This is the
// "no decorations" caller the shared widget is designed around.
let deco = super::graph_render::Decorations::default();
// 🏛 Wiring is the STATIC board — render it inside a ScrollArea whose
// canvas is allocated at the graph's TRUE extent, so the scrollbar
// handle scales to the real overflow and glides (smooth/kinetic).
// 📡 Live stays on the pan/zoom canvas (it animates a warehouse feed;
// drag-to-move suits a moving target better than a scrollbar).
let resp = if self.sub == ArchSubView::Wiring {
super::graph_render::draw_graph_scrolled(
ui,
&model,
&deco,
&mut view,
theme.bg,
theme.text,
theme.selection(),
theme.text_dim,
)
} else {
super::graph_render::draw_graph(
ui,
&model,
&deco,
&mut view,
theme.bg,
theme.text,
theme.selection(),
theme.text_dim,
)
};
// Mirror the (possibly mutated) view back onto the tab state.
self.pan = view.pan;
self.zoom = view.zoom;
if let Some(id) = resp.clicked_node {
if let Some(&i) = self.idx.get(&id) {
self.selected = Some(i);
self.trace_from(i);
// Click-to-code: resolve the clicked node → file:line, open in
// the editor (best-effort) + load the inline source preview.
// Tables have no symbol; skip them.
let node = &self.laid[i].node;
if node.kind != NodeKind::Table {
let label = node.label.clone();
self.open_symbol(&label, true);
}
self.render_dirty = true;
}
} else if resp.clicked_empty {
self.selected = None;
self.traced.clear();
self.render_dirty = true;
}
});
// Live system: keep repainting so a warehouse change animates in.
if self.sub == ArchSubView::Live && self.live {
ui.ctx().request_repaint_after(Duration::from_millis(600));
}
}
/// The inline source-preview panel for the last click-to-code resolution: the
/// resolved `file:line`, the editor-open status, the source window, and any
/// extra candidates. Hidden when nothing has been clicked. (ii) of the
/// click-to-code contract.
fn draw_source_preview(&mut self, ui: &mut egui::Ui) {
let Some(open) = self.last_open.clone() else {
return;
};
let theme = self.theme;
// N4 — CLICK-THROUGH: pre-compute which identifiers in this preview are
// navigable (known to the loaded wiring) so each becomes a link that jumps
// to its definition. Done BEFORE the panel closure so the `navigable_symbols`
// borrow of `self` is released before the closure re-borrows `self` mutably.
let current_symbol = open.hits.first().map(|h| h.symbol.clone());
let present: std::collections::HashSet<String> = {
let nav = self.navigable_symbols();
let mut present = std::collections::HashSet::new();
for line in &open.preview {
for (is_ident, seg) in ident_runs(line) {
if is_ident && nav.contains(seg) {
present.insert(seg.to_string());
}
}
}
present
};
// A symbol the click-through can navigate to, harvested from a link click.
let mut to_open: Option<String> = None;
egui::Panel::bottom("arch_source_preview")
.resizable(true)
.default_size(200.0)
.show_inside(ui, |ui| {
ui.horizontal(|ui| {
ui.label(RichText::new("📄 source").strong().color(theme.accent));
ui.label(RichText::new(&open.query).monospace().color(theme.text));
if let Some(best) = open.hits.first() {
ui.label(
RichText::new(format!("→ {}:{}", best.file, best.line))
.monospace()
.color(theme.text_dim),
);
match open.editor_opened {
Some(true) => {
ui.colored_label(super::facett_theme::GREEN, "✓ opened in editor");
}
Some(false) => {
ui.weak("editor unavailable — inline preview below");
}
None => {}
}
} else {
ui.colored_label(super::facett_theme::AMBER, "no symbol matched");
}
if ui.button("✖").on_hover_text("close preview").clicked() {
self.last_open = None;
}
});
if open.preview.is_empty() {
if !open.hits.is_empty() {
ui.colored_label(
super::facett_theme::AMBER,
format!(
"source file not found on disk for {} (looked under the \
workspace clone) — showing metadata only",
open.hits.first().map(|h| h.file.as_str()).unwrap_or("?"),
),
);
}
return;
}
ui.horizontal(|ui| {
ui.weak("click a highlighted symbol to jump to its definition");
});
ui.separator();
ScrollArea::vertical()
.id_salt("arch_src")
.auto_shrink([false, false])
.show(ui, |ui| {
let target = open.hits.first().map(|h| h.line).unwrap_or(0);
for (off, line) in open.preview.iter().enumerate() {
let n = open.preview_start + off as u32;
let is_target = n == target;
let base_col = if is_target { theme.accent } else { theme.text };
ui.horizontal(|ui| {
// Tight monospace layout: no inter-item spacing so the
// per-token labels/links read as one contiguous line.
ui.spacing_mut().item_spacing.x = 0.0;
ui.label(
RichText::new(format!("{n:>5} "))
.monospace()
.color(theme.text_dim),
);
ui.label(
RichText::new(if is_target { "▶ " } else { " " })
.monospace()
.color(theme.accent),
);
for (is_ident, seg) in ident_runs(line) {
// A navigable identifier (other than the one already
// shown) becomes a Wikipedia-style link.
let linkable = is_ident
&& present.contains(seg)
&& current_symbol.as_deref() != Some(seg);
if linkable {
if ui
.link(RichText::new(seg).monospace().color(theme.point))
.on_hover_text("jump to definition")
.clicked()
{
to_open = Some(seg.to_string());
}
} else {
ui.label(RichText::new(seg).monospace().color(base_col));
}
}
});
}
});
});
// Fire the navigation AFTER the panel closure (it re-resolves the clicked
// symbol → new file:line + preview, the Wikipedia-style walk).
if let Some(sym) = to_open {
self.open_symbol(&sym, false);
}
}
/// The 📍 **Hållpunkter** (boundaries) lens: the ui / gRPC / emitter LANDMARK
/// functions drawn as distinct landmark nodes (chips), grouped by boundary,
/// classified with the coverage feature's [`crate::coverage::classify`]
/// (REUSED). Each landmark is clickable → click-to-code (same as the board).
fn draw_boundaries(&mut self, ui: &mut egui::Ui) {
let theme = self.theme;
self.ensure_symbols();
let landmarks = self.boundary_landmarks();
let (u, g, e) = self.boundary_tally();
egui::Panel::top("arch_boundaries_head").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.heading("📍 Hållpunkter");
ui.separator();
ui.label(
"the hard-to-cover layer boundaries — the anchors a test matrix must reach",
);
});
ui.horizontal_wrapped(|ui| {
ui.colored_label(theme.accent, format!("ui {u}"));
ui.add_space(8.0);
ui.colored_label(super::facett_theme::AMBER, format!("gRPC {g}"));
ui.add_space(8.0);
ui.colored_label(theme.point, format!("emitter {e}"));
// THIN MODE: landmarks are classified from the warehouse's
// `symbol_facts` (read over Viz.Architecture) — no checkout needed.
// Only warn when the warehouse yielded no symbols at all.
if matches!(self.src, Src::Remote { .. })
&& self.symbols.as_ref().map(|s| s.is_empty()).unwrap_or(true)
{
ui.separator();
ui.colored_label(
super::facett_theme::AMBER,
"(warehouse has no symbol_facts — run a populate/deep scan)",
);
}
});
});
// The click-to-code preview panel works here too.
self.draw_source_preview(ui);
egui::CentralPanel::default().show_inside(ui, |ui| {
if landmarks.is_empty() {
ui.add_space(12.0);
ui.weak(
"no boundary landmarks discovered yet — they are classified from the \
repo's scanned symbols (ui/draw/state_json under src/viz, gRPC handlers, \
emit*/trace points).",
);
return;
}
let mut to_open: Option<String> = None;
ScrollArea::vertical()
.id_salt("arch_landmarks")
.auto_shrink([false, false])
.show(ui, |ui| {
for (bnd, group_color, group_name) in [
(Boundary::Ui, theme.accent, "ui (impl Facet)"),
(Boundary::Grpc, super::facett_theme::AMBER, "gRPC handlers"),
(
Boundary::Emitter,
theme.point,
"emitter (state_json / emit* / $NORNIR_VIZ_TRACE)",
),
] {
let group: Vec<&ArchNode> = landmarks
.iter()
.filter(|(b, _)| *b == bnd)
.map(|(_, n)| n)
.collect();
if group.is_empty() {
continue;
}
ui.add_space(6.0);
ui.label(RichText::new(group_name).strong().color(group_color));
ui.horizontal_wrapped(|ui| {
for n in group {
let chip = egui::Button::new(
RichText::new(&n.label)
.monospace()
.size(11.0)
.color(theme.text),
)
.fill(group_color.linear_multiply(0.22))
.stroke(Stroke::new(1.2_f32, group_color));
if ui
.add(chip)
.on_hover_text("open source (click-to-code)")
.clicked()
{
to_open = Some(n.label.clone());
}
}
});
}
});
if let Some(label) = to_open {
self.open_symbol(&label, true);
}
});
}
/// The 🚇 **Metro map** lens: per-button click→gRPC CALL PATHS, octolinear.
///
/// Left strip = the line selector (one chip per registry button, the active
/// one highlighted, coloured ✓ reached / ✗ stub). Central canvas = the
/// selected button's line drawn left→right: the UI click-handler is the head
/// terminus, the gRPC handler the tail terminus, and every fn between is a
/// station. Boundary stations (ui / grpc / emitter) are larger labelled
/// interchanges, classified by [`crate::coverage::classify`] (REUSED). Click a
/// station → click-to-code (same `open_symbol` bridge as the board).
fn draw_metro(&mut self, ui: &mut egui::Ui) {
let theme = self.theme;
self.ensure_metro();
// Header: the line tally + selected line summary.
let (total, reached) = self
.metro_lines
.as_ref()
.map(|ls| (ls.len(), ls.iter().filter(|l| l.reached).count()))
.unwrap_or((0, 0));
egui::Panel::top("arch_metro_head").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.heading("🚇 Metro");
ui.separator();
ui.label("each UI button → the gRPC call path it fires · stations = functions · interchanges = layer boundaries");
});
ui.horizontal_wrapped(|ui| {
ui.colored_label(super::facett_theme::GREEN, format!("✓ {reached} reach gRPC"));
ui.add_space(8.0);
ui.colored_label(super::facett_theme::RED, format!("✗ {} stub", total.saturating_sub(reached)));
// THIN MODE: the metro is fuelled by the WAREHOUSE (call_edges +
// symbol_facts read over Viz.Architecture), NOT a local checkout —
// so "no checkout" is EXPECTED, not a degrade. Only warn when the
// warehouse genuinely yielded no call edges (an empty/unpopulated
// warehouse — the populate-diagnostic the user must act on).
let calls = self.calls.as_ref().map(|c| c.len()).unwrap_or(0);
if matches!(self.src, Src::Remote { .. }) {
ui.separator();
if calls == 0 {
ui.colored_label(
super::facett_theme::AMBER,
"(warehouse has no call_edges — run a deep scan/populate so the server persists them)",
);
} else {
ui.weak(format!("(warehouse: {calls} call edges)"));
}
}
});
// The boundary-station legend (same colours as the line stations).
ui.horizontal_wrapped(|ui| {
ui.label("stations:");
for (col, name) in [
(theme.accent, "ui (click-handler)"),
(super::facett_theme::AMBER, "gRPC (terminus)"),
(theme.point, "emitter"),
(theme.node_stroke, "core fn"),
] {
let (rect, _) = ui.allocate_exact_size(Vec2::new(12.0, 12.0), Sense::hover());
ui.painter().circle_filled(rect.center(), 5.0, col);
ui.label(name);
ui.add_space(8.0);
}
});
// ── SCIP-PROVENANCE LEGEND (the surfaced resolved-index overlay): a
// SOLID accent rail = an edge RESOLVED by the SCIP index (it spans the
// cross-crate bin→lib hop); a DASHED dim rail = the name-based SYN
// fallback (an unresolved/heuristic edge scip closes). Counts come
// from the same per-line tally `state_json` reports (LAW 6). The fuel
// tag names which index answered so it's never a mystery.
let (resolved_n, syn_n) = self.metro_provenance_counts();
ui.horizontal_wrapped(|ui| {
ui.label("provenance:");
// resolved = solid accent swatch.
let (rect, _) = ui.allocate_exact_size(Vec2::new(22.0, 12.0), Sense::hover());
ui.painter().line_segment(
[rect.left_center(), rect.right_center()],
Stroke::new(3.0_f32, theme.accent),
);
ui.label(format!("resolved (scip) {resolved_n}"));
ui.add_space(10.0);
// syn-fallback = dashed dim swatch.
let (rect, _) = ui.allocate_exact_size(Vec2::new(22.0, 12.0), Sense::hover());
let y = rect.center().y;
for k in 0..3 {
let x0 = rect.left() + k as f32 * 8.0;
ui.painter().line_segment(
[Pos2::new(x0, y), Pos2::new(x0 + 5.0, y)],
Stroke::new(3.0_f32, theme.text_dim),
);
}
ui.label(format!("syn-fallback {syn_n}"));
ui.add_space(10.0);
ui.weak(format!("· fuel: {}", self.metro_fuel()));
});
});
// Left strip: the line selector (one chip per button).
let mut pick: Option<String> = None;
egui::Panel::left("arch_metro_lines")
.resizable(true)
.default_size(220.0)
.show_inside(ui, |ui| {
ui.add_space(4.0);
ui.label(RichText::new("lines").strong().color(theme.text_dim));
ScrollArea::vertical()
.id_salt("arch_metro_sel")
.auto_shrink([false, false])
.show(ui, |ui| {
let lines = self.metro_lines.clone().unwrap_or_default();
for l in &lines {
let active = l.button_id == self.metro_selected;
let dot = if l.reached {
super::facett_theme::GREEN
} else {
super::facett_theme::RED
};
let line_col = line_color(&theme, &l.button_id);
ui.horizontal(|ui| {
let (rect, _) =
ui.allocate_exact_size(Vec2::new(10.0, 10.0), Sense::hover());
ui.painter().circle_filled(rect.center(), 5.0, line_col);
let label = format!("{} · {}", l.button_id, l.rpc);
if ui
.selectable_label(
active,
RichText::new(label).color(theme.text),
)
.clicked()
{
pick = Some(l.button_id.clone());
}
ui.colored_label(dot, if l.reached { "✓" } else { "✗" });
});
}
});
});
if let Some(id) = pick {
self.select_metro_button(&id);
}
// Click-to-code preview (shared with the board).
self.draw_source_preview(ui);
// Edge-triggered metro render trace (LAW #6).
if self.render_dirty {
trace::emit_end("architecture.metro.render", &self.metro_state());
self.render_dirty = false;
}
// Central canvas: the selected line drawn octolinearly L→R. THIN mode
// reads the warehouse, so "no checkout" is NOT a degrade — the only
// legitimate empty state is an unpopulated warehouse (zero call edges).
let is_remote = matches!(self.src, Src::Remote { .. });
let scanned_calls = self.calls.as_ref().map(|c| c.len()).unwrap_or(0);
egui::CentralPanel::default().show_inside(ui, |ui| {
let Some(line) = self.selected_metro_line().cloned() else {
// Explicit POPULATE-DIAGNOSTIC no-data state (never a blank pane):
// name WHY it's empty + WHAT populates it (RAGNARÖK corollary).
ui.add_space(24.0);
ui.vertical_centered(|ui| {
ui.heading("No call path to draw yet");
ui.add_space(8.0);
if is_remote {
ui.label(
"The 🚇 Metro map traces each UI button to the gRPC handler it \
fires, over the warehouse's `call_edges` + `symbol_facts` (read \
from the server — no local checkout needed). The warehouse for this \
workspace has none recorded yet.",
);
ui.add_space(6.0);
ui.weak("Run a populate / deep scan so the server persists call_edges, then ↻ reload.");
} else {
ui.label(
"No metro lines were built — pick a workspace member whose source \
scan yields call edges, then reload.",
);
}
});
return;
};
let line_col = line_color(&theme, &line.button_id);
// Zero call edges ⇒ every line is a head-only stub. In thin mode that
// means the warehouse isn't populated; make it explicit + actionable so
// the pane never reads as a silent failure (RAGNARÖK populate-diagnostic).
if scanned_calls == 0 {
ui.colored_label(
super::facett_theme::AMBER,
if is_remote {
"⚠ the warehouse has no call_edges for this workspace — lines show the click-handler head only (stubs). Run a populate/deep scan on the server."
} else {
"⚠ no call edges scanned for this source — lines show the click-handler head only (stubs)."
},
);
ui.add_space(4.0);
}
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(format!("🚇 {}", line.button_id)).strong().color(line_col));
ui.separator();
ui.label(RichText::new(&line.rpc).monospace().color(theme.text));
ui.separator();
if line.reached {
ui.colored_label(super::facett_theme::GREEN, format!("✓ reaches {} · {} stations · {} emitters",
line.grpc_terminus.as_deref().unwrap_or("?"), line.stations.len(), line.emitter_stations));
} else {
ui.colored_label(super::facett_theme::RED, format!("✗ does not reach the gRPC handler ({} stations)", line.stations.len()));
}
ui.separator();
// The selected line's SCIP-provenance, in words.
if self.line_is_resolved(&line) {
ui.colored_label(theme.accent, "● resolved (scip)");
} else {
ui.colored_label(theme.text_dim, "┄ syn-fallback");
}
});
ui.separator();
let n = line.stations.len().max(1);
let avail = ui.available_size();
let (resp, painter) = ui.allocate_painter(avail, Sense::click());
painter.rect_filled(resp.rect, 4.0, theme.bg);
let top = resp.rect.left_top();
// Octolinear: stations march down a single column at a fixed pitch;
// the line is a vertical rail with 90° elbows into each labelled stop.
let x_rail = top.x + 70.0;
let pitch = ((resp.rect.height() - 60.0) / n as f32).clamp(40.0, 120.0);
let mut centers: Vec<Pos2> = Vec::with_capacity(n);
for i in 0..line.stations.len() {
centers.push(Pos2::new(x_rail, top.y + 36.0 + i as f32 * pitch));
}
// The rail, STYLED BY SCIP-PROVENANCE: a RESOLVED line (the SCIP index
// spanned the chain) draws a solid accent-tinted rail; a SYN-FALLBACK
// line (name-based / unresolved bin→lib gap) draws a DASHED, dimmed
// rail — so the resolved-vs-syn split is visible on the rail itself.
let resolved = self.line_is_resolved(&line);
let rail_col = if resolved { line_col } else { line_col.linear_multiply(0.45) };
for w in centers.windows(2) {
if resolved {
painter.line_segment([w[0], w[1]], Stroke::new(5.0_f32, rail_col));
} else {
// Dashed: short dashes along the segment mark the syn fallback.
let dir = (w[1] - w[0]).normalized();
let len = (w[1] - w[0]).length();
let (dash, gap) = (9.0_f32, 6.0_f32);
let mut t = 0.0;
while t < len {
let a = w[0] + dir * t;
let b = w[0] + dir * (t + dash).min(len);
painter.line_segment([a, b], Stroke::new(4.0_f32, rail_col));
t += dash + gap;
}
}
}
// Stations + labels. Click → open source.
let mut to_open: Option<String> = None;
for (i, st) in line.stations.iter().enumerate() {
let c = centers[i];
let (fill, r) = station_style(&theme, st.boundary);
painter.circle_filled(c, r, fill);
painter.circle_stroke(c, r, Stroke::new(2.0_f32, line_col));
// Interchange (boundary) stations get a thick ring + a kind tag.
let kind_tag = match st.boundary {
Boundary::Ui => " ⟵ ui click-handler",
Boundary::Grpc => " ⟵ gRPC handler",
Boundary::Emitter => " ⟲ emitter",
Boundary::Core => "",
};
let loc = if st.line > 0 { format!(" {}:{}", st.file, st.line) } else { String::new() };
let label = format!("{}{}{}", st.fn_name, kind_tag, loc);
let txt_col = if st.is_interchange() { theme.text } else { theme.text_dim };
painter.text(
c + Vec2::new(r + 10.0, 0.0),
egui::Align2::LEFT_CENTER,
label,
FontId::proportional(if st.is_interchange() { 13.0 } else { 11.0 }),
txt_col,
);
// Hit-test the station for click-to-code.
if resp.clicked() {
if let Some(p) = resp.interact_pointer_pos() {
if (p - c).length() <= r + 6.0 {
to_open = Some(st.fn_name.clone());
}
}
}
}
if let Some(name) = to_open {
self.open_symbol(&name, true);
}
});
}
/// Select a metro line by button id (selector click / robot drive). Emits the
/// selection as a trace event so it's observable headlessly.
fn select_metro_button(&mut self, id: &str) {
if self.metro_selected != id {
self.metro_selected = id.to_string();
trace::emit_event(
"architecture.metro.select",
&serde_json::json!({ "button": id }),
);
self.render_dirty = true;
}
}
/// The empty-board placeholder: WHY there is no board — the reader's refusal
/// (nothing consumed, no lake …), or a room that holds no fn of this workspace.
fn draw_placeholder(&self, ui: &mut egui::Ui) {
ui.add_space(24.0);
ui.vertical_centered(|ui| {
ui.heading("Architecture wiring");
ui.add_space(8.0);
match &self.load_error {
Some(e) => ui.label(e),
None => ui.label("the code room holds no function of this workspace: an empty board is what it has, not a missing scan"),
};
});
}
/// Per-marker test-matrix tally: (tested, untested, allowlisted, unknown).
/// The keystone numbers — green ⟺ every marker has a covering test.
fn cov_counts(&self) -> (usize, usize, usize, usize) {
let (mut t, mut u, mut a, mut k) = (0, 0, 0, 0);
for n in &self.graph.nodes {
match self.cov_of(&n.id) {
Cov::Tested => t += 1,
Cov::Untested => u += 1,
Cov::Allowlisted => a += 1,
Cov::Unknown => k += 1,
}
}
(t, u, a, k)
}
fn render_out(&self) -> RenderOut {
let count = |k: NodeKind| self.graph.nodes.iter().filter(|n| n.kind == k).count();
let (tested, untested, allowlisted, coverage_unknown) = self.cov_counts();
RenderOut {
node_count: self.graph.nodes.len(),
edge_count: self.graph.edges.len(),
components: count(NodeKind::Component),
grpc: count(NodeKind::Grpc),
cli: count(NodeKind::Cli),
backends: count(NodeKind::Backend),
codecs: count(NodeKind::Codec),
sinks: count(NodeKind::Sink),
core_fns: count(NodeKind::CoreFn),
tables: count(NodeKind::Table),
tested,
untested,
allowlisted,
coverage_unknown,
selected: self.selected.map(|i| self.laid[i].node.label.clone()),
}
}
/// The 🚇 Metro map's slice of `state_json` (LAW #6) — the REAL structure of
/// the button→gRPC call paths, as DATA: the selected button, every line's
/// stations (fn + file:line + boundary kind), the gRPC terminus, the emitter
/// stations, and whether the line reached its handler. A blank/stub path is
/// visible here as `reached:false` + a 1-station line — the matrix FAILS on it.
fn metro_state(&self) -> serde_json::Value {
let lines = self.metro_lines.clone().unwrap_or_default();
// The overall fuel provenance the `load_preferred` chokepoint reported.
let fuel = self.metro_fuel();
// PER-LINE SCIP-PROVENANCE (the surfaced overlay): a line resolved by the
// SCIP index (it REACHED its handler over a `resolved/scip` fuel) is
// "resolved/scip"; anything else is the name-based "syn" fallback (the
// cross-crate bin→lib gap scip closes). Same source-of-truth the legend +
// rail style read (`line_is_resolved`).
let line_source = |l: &MetroLine| -> &'static str {
if self.line_is_resolved(l) {
"resolved/scip"
} else {
"syn"
}
};
let line_json = |l: &MetroLine| {
serde_json::json!({
"button_id": l.button_id,
"tab": l.tab,
"rpc": l.rpc,
"ui_handler": l.ui_handler,
"grpc_terminus": l.grpc_terminus,
"reached": l.reached,
// The per-line provenance class the overlay styles by + reports.
"source": line_source(l),
"emitter_stations": l.emitter_stations,
"station_count": l.stations.len(),
"stations": l.stations.iter().map(|s| serde_json::json!({
"fn": s.fn_name,
"symbol": s.symbol,
"file": s.file,
"line": s.line,
"boundary": s.boundary.as_str(),
"interchange": s.is_interchange(),
})).collect::<Vec<_>>(),
})
};
// The resolved-vs-syn TALLY across the lines (the legend's counts).
let resolved_lines = lines
.iter()
.filter(|l| line_source(l) == "resolved/scip")
.count();
let syn_lines = lines.len() - resolved_lines;
let selected = self.selected_metro_line().map(line_json);
// The button registry as DATA — the enumerable {tab,id,rpc} the matrix walks.
let registry: Vec<serde_json::Value> = metro::button_registry()
.iter()
.map(|b| serde_json::json!({ "tab": b.tab, "id": b.id, "rpc": b.rpc, "ui_handler": b.ui_handler }))
.collect();
// The verify-without-looking aggregates — the EMITTER's numbers, also on
// the state slice so a headless drive asserts populated-vs-stub as DATA.
let agg = self.metro_aggregates();
serde_json::json!({
"selected_button": self.metro_selected,
"line_count": lines.len(),
"reached_count": lines.iter().filter(|l| l.reached).count(),
"registry": registry,
"selected": selected,
"lines": lines.iter().map(line_json).collect::<Vec<_>>(),
// ── EMITTER aggregates (RAGNARÖK): empty/stub metro = RED here ───────
"metro_edges": agg.metro_edges,
"metro_nodes": agg.metro_nodes,
"metro_emitter_nodes": agg.metro_emitter_nodes,
"metro_stub_lines": agg.metro_stub_lines,
"reached_lines": agg.reached_lines,
// The source of the metro fuel: `warehouse` in thin mode (read over
// Viz.Architecture), `checkout` in fat mode (local scan). So a drive can
// assert thin mode is actually warehouse-backed, not stub-degraded.
"fuel_source": match self.src {
Src::Remote { .. } => "warehouse",
Src::Local(_) => "checkout",
},
// ── SCIP-PROVENANCE OVERLAY (LAW 6): which INDEX resolved the call
// graph these lines are built from — the `query::load_preferred` tag
// ("resolved/scip" the SCIP index answered, "syn"/"syn/scan" the
// name-based fallback, "warehouse" thin facts). The per-line `source`
// above is derived from this; these counts feed the legend so a reader
// SEES resolved-vs-syn-fallback without inspecting pixels.
"fuel_provenance": fuel,
"resolved_lines": resolved_lines,
"syn_lines": syn_lines,
})
}
/// The metro fuel provenance tag (the `query::load_preferred` source) — empty
/// until `ensure_symbols` runs, so default to the honest "unknown".
fn metro_fuel(&self) -> &str {
if self.metro_fuel_source.is_empty() {
"unknown"
} else {
self.metro_fuel_source.as_str()
}
}
/// Is THIS line's interior resolved by the SCIP index? A line that REACHED its
/// gRPC handler over a `resolved/scip` fuel is resolved (solid accent rail);
/// anything else — an unreached bin→lib skeleton, or a `syn`/`syn/scan`/
/// `warehouse` fuel — is the name-based SYN fallback (dashed dim rail). This is
/// the single source-of-truth the legend, the rail style, and `state_json`'s
/// per-line `source` all read.
fn line_is_resolved(&self, line: &MetroLine) -> bool {
line.reached && self.metro_fuel() == "resolved/scip"
}
/// (resolved, syn-fallback) line counts for the provenance legend.
fn metro_provenance_counts(&self) -> (usize, usize) {
let lines = self.metro_lines.as_deref().unwrap_or(&[]);
let resolved = lines.iter().filter(|l| self.line_is_resolved(l)).count();
(resolved, lines.len() - resolved)
}
/// The 🚇 Metro slice as standalone JSON. The full Metro state is also folded
/// into `state_json()["metro"]`; this accessor exposes it on its own for the
/// app/matrix. `pub` for the app + the matrix.
pub fn metro_state_json(&self) -> serde_json::Value {
self.metro_state()
}
/// The 🏛 Architecture tab's slice of `state_json` (LAW #6) — the exact board
/// it renders: counts per kind, edges, which members contributed, the selected
/// node + its downstream-traced labels, and the active palette.
pub fn state_json(&self) -> serde_json::Value {
let count = |k: NodeKind| self.graph.nodes.iter().filter(|n| n.kind == k).count();
// N2 deep-scan button state (computed here — a locked read can't sit inline
// in the `json!` macro).
let deep_scan_json = {
let g = self.deep_scan.lock().unwrap();
serde_json::json!({
"inflight": g.inflight,
"note": g.note,
"error": g.error,
})
};
let kinds: Vec<serde_json::Value> = self
.graph
.nodes
.iter()
.map(|n| {
serde_json::json!({
"id": n.id,
"label": n.label,
"kind": n.kind.as_str(),
// The canonical layer (ui/grpc/cli/core/table) — AUT8-GAP-LAYER,
// derived from kind so the matrix groups by the same strata.
"layer": n.kind.layer(),
// The per-marker cross-check: is there a test for it?
"cov": self.cov_of(&n.id).label(),
})
})
.collect();
let (tested, untested, allowlisted, coverage_unknown) = self.cov_counts();
let edges: Vec<serde_json::Value> = self
.graph
.edges
.iter()
.map(|e| serde_json::json!({ "from": e.from, "to": e.to, "kind": e.kind.as_str() }))
.collect();
let traced: Vec<String> = self
.traced
.iter()
.map(|&i| self.laid[i].node.label.clone())
.collect();
// 📍 Hållpunkter: the boundary landmark tally + the landmark labels by
// boundary, classified with the coverage feature's `classify` (reused).
let (bnd_ui, bnd_grpc, bnd_emitter) = self.boundary_tally();
let landmark_list: Vec<serde_json::Value> = self
.boundary_landmarks()
.iter()
.map(|(b, n)| serde_json::json!({ "boundary": b.as_str(), "label": n.label, "id": n.id }))
.collect();
serde_json::json!({
"source": match &self.src { Src::Local(_) => "local", Src::Remote { .. } => "remote" },
"workspace": self.workspace,
"node_count": self.graph.nodes.len(),
"edge_count": self.graph.edges.len(),
"components": count(NodeKind::Component),
"grpc": count(NodeKind::Grpc),
"cli": count(NodeKind::Cli),
"backends": count(NodeKind::Backend),
"codecs": count(NodeKind::Codec),
"sinks": count(NodeKind::Sink),
"core_fns": count(NodeKind::CoreFn),
"tables": count(NodeKind::Table),
// The keystone cross-check, as readable data: every marker tallied
// against the test matrix. green ⟺ untested == 0.
"coverage": {
"tested": tested,
"untested": untested,
"allowlisted": allowlisted,
"unknown": coverage_unknown,
"has_data": !self.coverage.is_empty(),
},
"nodes": kinds,
"edges": edges,
"members_with_data": self.members_with_data,
"members": self.members,
"errors": self.errors,
"load_error": self.load_error,
"selected": self.selected.map(|i| self.laid[i].node.label.clone()),
"traced_downstream": traced,
"empty": self.graph.nodes.is_empty(),
// LAW 2 empty-state: "populated" / "in_route" (a job is acting on this
// workspace) / "not_scanned" — the reason a blank board shows, asserted
// headlessly so an empty arch board can never silently pass.
"empty_state": super::repo_pane::classify_empty(
self.graph.nodes.is_empty(), &self.workspace, "",
).id(),
"palette": self.theme.name,
// ── CONTAINER: the active sub-view + the full sub-view list, so the
// headless matrix mechanically enumerates every lens (LAW #6).
"sub_view": self.sub.id(),
"sub_views": ArchSubView::ALL.iter().map(|v| v.id()).collect::<Vec<_>>(),
"live": self.live,
// ── 🏛 Wiring SCROLL EXTENT (validate-as-data, task #23): the TRUE
// painted bounds of the board in world units, so the ScrollArea's
// canvas — and thus the scrollbar handle — scales to the real graph,
// not to the viewport. `scrollable` is true for the static Wiring lens
// (the lens that wraps the board in a ScrollArea); Live pans instead.
// A headless drive asserts this is non-zero + grows with the graph,
// so the "wrong scale" bug can't silently return.
"board": {
"content_extent": {
"w": super::graph_render::content_extent(&self.graph_model()).x,
"h": super::graph_render::content_extent(&self.graph_model()).y,
},
"scrollable": self.sub == ArchSubView::Wiring,
"smooth_scroll": self.sub == ArchSubView::Wiring,
},
// ── 📍 Hållpunkter: the ui/grpc/emitter LANDMARK tally (the boundary
// anchors), classified by the coverage feature's `classify` (reused).
"boundaries": {
"ui": bnd_ui,
"grpc": bnd_grpc,
"emitter": bnd_emitter,
"total": bnd_ui + bnd_grpc + bnd_emitter,
"landmarks": landmark_list,
},
// ── 🚇 Metro map: the button→gRPC CALL PATHS as DATA — selected
// button, every line's stations (fn + file:line + boundary kind), the
// gRPC terminus + emitter stations + reached flag. This is the slice a
// robot drive asserts is NON-EMPTY and reaches the RPC (task proof).
"metro": self.metro_state(),
// ── click-to-code: the last-clicked symbol → file:line + candidates,
// the editor-open result, and the inline preview line range — so a
// robot test asserts the click-to-code WITHOUT pixels (task #3).
"last_open": self.last_open.as_ref().map(|o| serde_json::json!({
"query": o.query,
"resolved": o.hits.first().map(|h| format!("{}:{}", h.file, h.line)),
"file": o.hits.first().map(|h| h.file.clone()),
"line": o.hits.first().map(|h| h.line),
"symbol": o.hits.first().map(|h| h.symbol.clone()),
"candidates": o.hits.len(),
"editor_opened": o.editor_opened,
"preview_lines": o.preview.len(),
"preview_start": o.preview_start,
})),
// ── N2 deep-scan button state (LAW 6): inflight / last note / error,
// so a headless drive asserts the manual call_edges trigger fired.
"deep_scan": deep_scan_json,
})
}
// ── test-only injectors (no warehouse / no server) ───────────────────────
/// Inject a merged board directly (as the warehouse merge / RPC would deliver)
/// + the configured members, so the inject-assert harness can read
/// `state_json()` back without a warehouse or server. Returns nothing; after
/// this the board renders the injected graph.
#[doc(hidden)]
pub fn inject_for_test(&mut self, graph: ArchGraph, members_with_data: Vec<String>) {
self.loaded = true;
self.load_error = None;
self.members_with_data = members_with_data;
self.errors = Vec::new();
self.set_graph(graph);
}
/// Inject coverage verdicts (node id → `covered`/`missing`/`allowlisted`) as
/// the warehouse cross-check would deliver, so a test can assert the green/red
/// tally via `state_json()` without a warehouse.
#[doc(hidden)]
pub fn inject_coverage_for_test(
&mut self,
coverage: std::collections::BTreeMap<String, String>,
) {
self.coverage = coverage;
self.render_dirty = true;
}
/// Inject scanned symbols directly (no warehouse / no checkout) so a test can
/// drive click-to-code resolution + the boundary-landmark classifier via
/// `state_json()` / `open_symbol`. Mirrors the other inject seams.
#[doc(hidden)]
pub fn inject_symbols_for_test(&mut self, symbols: Vec<crate::knowledge::symbols::SymbolRow>) {
self.symbols = Some(symbols);
self.render_dirty = true;
}
/// Inject a full [`ArchPayload`] exactly as the thin `Viz.Architecture` RPC
/// would deliver it — including the WAREHOUSE-READ `symbols` + `calls` the
/// server ships for the 🚇 Metro map. Drives the SAME seeding `ensure_loaded`'s
/// remote branch runs, so a test proves the thin-mode metro builds REAL lines
/// from warehouse facts (not checkout stubs) without a running server. Only
/// meaningful on a `remote()` state (the thin path). Returns nothing; after it,
/// `build_metro_for_test` builds lines from the payload's call edges.
#[doc(hidden)]
pub fn inject_remote_payload_for_test(&mut self, payload: ArchPayload) {
self.loaded = true;
self.load_error = None;
self.members_with_data = payload.members_with_data;
self.errors = payload.errors;
self.coverage = payload.coverage;
if !payload.symbols.is_empty() {
self.symbols = Some(payload.symbols);
self.calls = Some(payload.calls);
self.metro_lines = None;
// Mirror production (`ensure_loaded`): adopt the server's
// `load_preferred` provenance tag, defaulting to "warehouse" only when
// the payload shipped none (older server / empty fuel).
self.metro_fuel_source = if payload.metro_fuel_source.is_empty() {
"warehouse".to_string()
} else {
payload.metro_fuel_source
};
}
self.access = payload.access;
self.set_graph(payload.graph);
}
/// Inject scanned symbols + call edges directly (no warehouse / no checkout)
/// so a test can build the 🚇 metro map + assert its `state_json` without a
/// scan. Mirrors `inject_symbols_for_test` but also seeds the calls so the
/// button→gRPC paths are real.
#[doc(hidden)]
pub fn inject_scan_for_test(
&mut self,
symbols: Vec<crate::knowledge::symbols::SymbolRow>,
calls: Vec<crate::knowledge::symbols::CallEdgeRow>,
) {
self.symbols = Some(symbols);
self.calls = Some(calls);
self.metro_lines = None;
self.render_dirty = true;
}
/// Inject scanned facts WITH an explicit SCIP-provenance fuel source — the
/// same tag `query::load_preferred` returns (`"resolved/scip"` vs `"syn"`).
/// Lets the inject-assert harness prove the metro's per-edge/per-line
/// provenance reaches `state_json` (LAW 6) + the rail styling without a
/// `--features scip` warehouse. Mirrors `inject_scan_for_test`.
#[doc(hidden)]
pub fn inject_scan_with_source_for_test(
&mut self,
symbols: Vec<crate::knowledge::symbols::SymbolRow>,
calls: Vec<crate::knowledge::symbols::CallEdgeRow>,
source: &str,
) {
self.symbols = Some(symbols);
self.calls = Some(calls);
self.metro_fuel_source = source.to_string();
self.metro_lines = None;
self.render_dirty = true;
}
/// Build the 🚇 metro map from the (injected or scanned) facts, so a test /
/// robot can read `state_json["architecture"]["metro"]` back without a draw.
/// Parity with switching to the Metro lens.
#[doc(hidden)]
pub fn build_metro_for_test(&mut self) {
self.ensure_metro();
}
/// Select a metro line by button id from a test (parity with a selector click).
#[doc(hidden)]
pub fn select_metro_button_for_test(&mut self, id: &str) {
self.select_metro_button(id);
}
/// Set the active sub-view from a test (parity with a selector click).
#[doc(hidden)]
pub fn set_sub_view_by_id_for_test(&mut self, id: &str) -> bool {
match ArchSubView::from_id(id) {
Some(sv) => {
self.set_sub_view(sv);
true
}
None => false,
}
}
/// Drive a click on the node with `label` (parity with a pointer click), so a
/// test can assert the downstream-trace highlight via `state_json()`.
#[doc(hidden)]
pub fn select_by_label_for_test(&mut self, label: &str) -> bool {
if let Some(i) = self.laid.iter().position(|l| l.node.label == label) {
self.selected = Some(i);
self.trace_from(i);
self.render_dirty = true;
true
} else {
false
}
}
}
/// N4 — split a source line into alternating (is_identifier, slice) runs so the
/// click-through preview can render identifier tokens as links while keeping the
/// exact spacing/indentation of the non-identifier runs. An "identifier" run is a
/// maximal `[A-Za-z0-9_]+` span (Rust ident chars); everything else (whitespace,
/// punctuation) is a non-identifier run. The concatenation of all slices equals
/// the input verbatim.
fn ident_runs(line: &str) -> Vec<(bool, &str)> {
let is_ident = |c: char| c.is_alphanumeric() || c == '_';
let mut out = Vec::new();
let mut start = 0usize;
let mut cur: Option<bool> = None;
for (i, c) in line.char_indices() {
let k = is_ident(c);
match cur {
Some(prev) if prev == k => {}
Some(prev) => {
out.push((prev, &line[start..i]));
start = i;
cur = Some(k);
}
None => cur = Some(k),
}
}
if let Some(prev) = cur {
out.push((prev, &line[start..]));
}
out
}
/// Chip (fill, stroke) per node kind, on the active facett palette. Each kind
/// gets a distinct semantic tint so the four layers read apart at a glance: UI
/// components on the palette accent, gRPC on amber, core-fns on the neutral
/// node-fill, warehouse tables on the palette point colour (the warehouse rail).
fn kind_color(theme: &Theme, kind: NodeKind) -> (Color32, Color32) {
match kind {
NodeKind::Component => (theme.accent.linear_multiply(0.30), theme.accent),
NodeKind::Grpc => (
super::facett_theme::AMBER.linear_multiply(0.30),
super::facett_theme::AMBER,
),
// CLI verbs on the palette edge colour — the third entrypoint marker
// layer beside UI (accent) and gRPC (amber); kept off green/red so the
// chip FILL never clashes with the coverage RING.
NodeKind::Cli => (theme.edge.linear_multiply(0.30), theme.edge),
NodeKind::CoreFn => (theme.node_fill, theme.node_stroke),
NodeKind::Table => (theme.point.linear_multiply(0.30), theme.point),
// The three marker layers beyond UI — violet (backend), cyan-teal (codec
// engine), magenta (sink egress). Fixed hues (not theme fields) so they
// read apart from accent/amber/edge/point, and kept off green/red so the
// chip FILL never clashes with the coverage RING (same rule as CLI).
NodeKind::Backend => {
let c = Color32::from_rgb(154, 108, 208);
(c.linear_multiply(0.30), c)
}
NodeKind::Codec => {
let c = Color32::from_rgb(42, 192, 196);
(c.linear_multiply(0.30), c)
}
NodeKind::Sink => {
let c = Color32::from_rgb(208, 74, 154);
(c.linear_multiply(0.30), c)
}
}
}
/// A stable per-line colour for the 🚇 metro map — a small deterministic palette
/// keyed by the button id so each line reads apart (subway-map house style).
fn line_color(theme: &Theme, button_id: &str) -> Color32 {
// A fixed metro-line palette (octolinear maps use ~8 distinct line colours).
const LINES: [Color32; 6] = [
Color32::from_rgb(0xE3, 0x2D, 0x2D), // red
Color32::from_rgb(0x2D, 0x7D, 0xE3), // blue
Color32::from_rgb(0x2D, 0xB3, 0x6B), // green
Color32::from_rgb(0xE3, 0x9A, 0x2D), // orange
Color32::from_rgb(0x9A, 0x4D, 0xE3), // purple
Color32::from_rgb(0x2D, 0xC4, 0xC4), // teal
];
let h = button_id
.bytes()
.fold(0u32, |a, b| a.wrapping_mul(31).wrapping_add(b as u32));
let _ = theme; // palette-neutral lines so they stay distinct on any theme.
LINES[(h as usize) % LINES.len()]
}
/// Station (fill, radius) per boundary: ui click-handler on the accent, gRPC
/// terminus on amber, emitter stops on the point colour, core fns small + neutral.
fn station_style(theme: &Theme, boundary: Boundary) -> (Color32, f32) {
match boundary {
Boundary::Ui => (theme.accent, 10.0),
Boundary::Grpc => (super::facett_theme::AMBER, 10.0),
Boundary::Emitter => (theme.point, 9.0),
Boundary::Core => (theme.node_stroke, 6.0),
}
}
/// Edge colour per relation kind: calls on the palette accent, reads/writes on
/// amber (the warehouse access traces).
fn edge_color(theme: &Theme, kind: ArchEdgeKind) -> Color32 {
match kind {
ArchEdgeKind::Calls => theme.edge,
ArchEdgeKind::Reads | ArchEdgeKind::Writes => super::facett_theme::AMBER,
}
}
/// The coverage ring colour for a marker's test-matrix status: green = a test
/// exists, red = none, amber = allowlisted gap, `None` = unknown (keep the kind
/// stroke). Uses the facett status palette — no ad-hoc chrome.
fn cov_ring(cov: Cov) -> Option<Color32> {
match cov {
Cov::Tested => Some(super::facett_theme::GREEN),
Cov::Untested => Some(super::facett_theme::RED),
Cov::Allowlisted => Some(super::facett_theme::AMBER),
Cov::Unknown => None,
}
}
/// Layered BFS columns (verbatim model of `arch::layers_of`, reimplemented here
/// against the laid graph so the native render columns match the SVG's): layer 0
/// = sources (no incoming edge), tables pinned to the rightmost column (the
/// warehouse ground rail). Deterministic, cycle-safe.
fn layers_of(graph: &ArchGraph) -> Vec<Vec<usize>> {
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(|nd| nd.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
}
#[cfg(test)]
mod tests {
use super::*;
fn board() -> ArchGraph {
// TestTab -reads-> test_results ; Viz.Architecture -writes-> release_lineage
// ; TestTab -calls-> nornir::viz (a core fn).
ArchGraph {
nodes: vec![
ArchNode {
id: "component:TestTab".into(),
label: "TestTab".into(),
kind: NodeKind::Component,
},
ArchNode {
id: "grpc:Viz.Architecture".into(),
label: "Viz.Architecture".into(),
kind: NodeKind::Grpc,
},
ArchNode {
id: "corefn:nornir::viz".into(),
label: "nornir::viz".into(),
kind: NodeKind::CoreFn,
},
ArchNode {
id: "table:test_results".into(),
label: "test_results".into(),
kind: NodeKind::Table,
},
ArchNode {
id: "table:release_lineage".into(),
label: "release_lineage".into(),
kind: NodeKind::Table,
},
],
edges: vec![
ArchEdge {
from: "component:TestTab".into(),
to: "table:test_results".into(),
kind: ArchEdgeKind::Reads,
},
ArchEdge {
from: "component:TestTab".into(),
to: "corefn:nornir::viz".into(),
kind: ArchEdgeKind::Calls,
},
ArchEdge {
from: "grpc:Viz.Architecture".into(),
to: "table:release_lineage".into(),
kind: ArchEdgeKind::Writes,
},
],
}
}
#[test]
fn injected_board_renders_counts_in_state_json() {
let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
st.set_members(vec!["nornir".into()]);
st.inject_for_test(board(), vec!["nornir".into()]);
let js = st.state_json();
assert_eq!(js["node_count"], 5);
assert_eq!(js["edge_count"], 3);
assert_eq!(js["components"], 1);
assert_eq!(js["grpc"], 1);
assert_eq!(js["core_fns"], 1);
assert_eq!(js["tables"], 2);
assert_eq!(js["empty"], false);
assert_eq!(js["members_with_data"][0], "nornir");
assert_eq!(js["source"], "local");
}
#[test]
fn wiring_board_reports_true_scroll_extent_as_data() {
// Task #23 (validate-as-data): the static 🏛 Wiring lens wraps the board in
// a ScrollArea whose canvas is the graph's TRUE extent — so the scrollbar
// handle scales to the real overflow. Assert the extent surfaces non-zero
// and the lens flags itself scrollable + smooth-scrolling. Live, by
// contrast, pans (no scrollbar), so it must NOT flag scrollable.
let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
st.inject_for_test(board(), vec!["nornir".into()]);
st.set_sub_view(ArchSubView::Wiring);
let js = st.state_json();
let (w, h) = (
js["board"]["content_extent"]["w"].as_f64().unwrap(),
js["board"]["content_extent"]["h"].as_f64().unwrap(),
);
// The 5-node layered board spans multiple columns/rows: both dims clear one
// chip's worth — the canvas is the graph, not the viewport (the old bug).
assert!(
w > super::super::graph_render::BOX_W as f64,
"wiring extent width > one chip: {w}"
);
assert!(
h > super::super::graph_render::BOX_H as f64,
"wiring extent height > one chip: {h}"
);
assert_eq!(
js["board"]["scrollable"], true,
"Wiring wraps the board in a ScrollArea"
);
assert_eq!(
js["board"]["smooth_scroll"], true,
"Wiring scrolls smoothly/kinetically"
);
// Live shares the renderer but pans — it is NOT scrollable.
st.set_sub_view(ArchSubView::Live);
let live = st.state_json();
assert_eq!(
live["board"]["scrollable"], false,
"Live pans, no scrollbar"
);
}
#[test]
fn empty_board_shows_placeholder_not_error() {
let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
st.set_members(vec!["nornir".into()]);
st.inject_for_test(ArchGraph::default(), vec![]);
let js = st.state_json();
assert_eq!(js["empty"], true);
assert_eq!(js["node_count"], 0);
assert!(js["load_error"].is_null(), "empty board is not an error");
}
#[test]
fn click_lights_downstream_trace() {
let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
st.inject_for_test(board(), vec!["nornir".into()]);
// Click TestTab: downstream = {TestTab, test_results, nornir::viz}.
assert!(st.select_by_label_for_test("TestTab"));
let js = st.state_json();
assert_eq!(js["selected"], "TestTab");
let traced: BTreeSet<String> = js["traced_downstream"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(traced.contains("TestTab"));
assert!(traced.contains("test_results"));
assert!(traced.contains("nornir::viz"));
// release_lineage is NOT reachable from TestTab (it's Viz.Architecture's).
assert!(
!traced.contains("release_lineage"),
"unreachable node not lit: {traced:?}"
);
}
#[test]
fn layers_pin_tables_right_and_sources_left() {
let g = board();
let layers = layers_of(&g);
// Sources (TestTab, Viz.Architecture — no incoming edge) in layer 0.
let l0: BTreeSet<&str> = layers[0]
.iter()
.map(|&i| g.nodes[i].label.as_str())
.collect();
assert!(l0.contains("TestTab"));
assert!(l0.contains("Viz.Architecture"));
// Every table is pinned to the LAST layer (the warehouse ground rail), and
// no table appears in any earlier column.
let last = layers.len() - 1;
for (li, layer) in layers.iter().enumerate() {
for &i in layer {
if g.nodes[i].kind == NodeKind::Table {
assert_eq!(
li, last,
"table `{}` not on the right rail",
g.nodes[i].label
);
}
}
}
// The last layer actually carries the tables.
assert!(
layers[last]
.iter()
.any(|&i| g.nodes[i].kind == NodeKind::Table),
"the rightmost column carries the warehouse tables"
);
}
#[test]
fn coverage_cross_check_tallies_tested_vs_untested() {
let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
st.inject_for_test(board(), vec!["nornir".into()]);
// TestTab has a test; Viz.Architecture has none; test_results is allowlisted.
let mut cov = std::collections::BTreeMap::new();
cov.insert("component:TestTab".to_string(), "covered".to_string());
cov.insert("grpc:Viz.Architecture".to_string(), "missing".to_string());
cov.insert("table:test_results".to_string(), "allowlisted".to_string());
st.inject_coverage_for_test(cov);
let js = st.state_json();
// The keystone tally: green ⟺ untested == 0.
assert_eq!(js["coverage"]["tested"], 1);
assert_eq!(js["coverage"]["untested"], 1);
assert_eq!(js["coverage"]["allowlisted"], 1);
// 5 nodes, 3 carry a verdict → 2 unmatched (never a false green).
assert_eq!(js["coverage"]["unknown"], 2);
assert_eq!(js["coverage"]["has_data"], true);
// The per-marker verdict surfaces in the nodes array.
let nodes = js["nodes"].as_array().unwrap();
let find = |id: &str| {
nodes.iter().find(|n| n["id"] == id).unwrap()["cov"]
.as_str()
.unwrap()
.to_string()
};
assert_eq!(find("component:TestTab"), "tested");
assert_eq!(find("grpc:Viz.Architecture"), "untested");
assert_eq!(find("table:test_results"), "allowlisted");
assert_eq!(find("corefn:nornir::viz"), "unknown");
}
#[test]
fn no_coverage_data_is_all_unknown_not_false_green() {
let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
st.inject_for_test(board(), vec!["nornir".into()]);
let js = st.state_json();
assert_eq!(js["coverage"]["has_data"], false);
assert_eq!(js["coverage"]["tested"], 0);
assert_eq!(
js["coverage"]["unknown"], 5,
"no data → all unknown, none green"
);
}
#[test]
fn palette_switch_reaches_pane() {
let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
st.inject_for_test(board(), vec!["nornir".into()]);
assert_eq!(st.state_json()["palette"], "default");
st.set_palette(Theme::cyberpunk_neon());
assert_eq!(st.state_json()["palette"], "cyberpunk-neon");
}
/// N3 — the Architecture tab OPENS on the 🕸 Call Graph lens (the 3-D rotating
/// node cloud), with the 2-D lenses reachable via the selector.
#[test]
fn architecture_defaults_to_3d_call_graph_lens() {
let st = ArchTabState::local(PathBuf::from("/nonexistent"));
assert_eq!(st.state_json()["sub_view"], "CallGraph");
// Every other lens (the 2-D toggle targets) is still enumerated.
let subs: Vec<String> = st.state_json()["sub_views"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(subs.contains(&"Wiring".to_string()));
assert!(subs.contains(&"Metro".to_string()));
}
fn sym(item_name: &str, file: &str, line: u32) -> crate::knowledge::symbols::SymbolRow {
crate::knowledge::symbols::SymbolRow {
crate_name: "myrepo".into(),
module_path: String::new(),
item_kind: "fn".into(),
item_name: item_name.into(),
visibility: "pub".into(),
file: file.into(),
line,
doc_lines: 0,
signature: None,
}
}
/// N4 — the source viewer READS the real file bytes from the on-disk clone
/// (resolved via `repo_dir_resolved(source_root, <member>)`) and the click-
/// through re-resolves a second symbol to its own definition line.
#[test]
fn source_viewer_reads_real_file_and_click_through_navigates() {
use std::io::Write;
let tmp =
std::env::temp_dir().join(format!("nornir-archsrc-{}-{}", std::process::id(), line!()));
let src = tmp.join("git").join("myrepo").join("src");
std::fs::create_dir_all(&src).unwrap();
let mut f = std::fs::File::create(src.join("foo.rs")).unwrap();
// 1: fn alpha 2: fn foo { 3: beta(); 4: } 5: fn beta {}
writeln!(
f,
"fn alpha() {{}}\nfn foo() {{\n beta();\n}}\nfn beta() {{}}"
)
.unwrap();
let mut st = ArchTabState::local(PathBuf::from("/nonexistent-wh"));
st.set_members(vec!["myrepo".into()]);
st.set_source_root(tmp.join("git"));
st.inject_symbols_for_test(vec![
sym("foo", "src/foo.rs", 2),
sym("beta", "src/foo.rs", 5),
]);
// Open `foo` → reads real lines around line 2 from disk.
assert!(st.open_symbol("foo", false), "resolved foo");
let js = st.state_json();
assert_eq!(js["last_open"]["file"], "src/foo.rs");
assert_eq!(js["last_open"]["line"], 2);
assert!(
js["last_open"]["preview_lines"].as_u64().unwrap() > 0,
"the viewer read REAL bytes from the on-disk clone, not a stub"
);
// `beta` is a navigable identifier (a known symbol) present in foo's body.
assert!(st.navigable_symbols().contains("beta"));
// Click-through: navigate to `beta` → its own definition line (5).
assert!(st.open_symbol("beta", false), "navigated to beta");
assert_eq!(st.state_json()["last_open"]["line"], 5);
let _ = std::fs::remove_dir_all(&tmp);
}
/// N2 — the deep-scan trigger is real: its state surfaces in `state_json`. A
/// fat pane has nothing to scan (the consumer writes the code room): the
/// trigger says so and drops the load latch, so the board is read again —
/// never a dead no-op.
#[test]
fn deep_scan_trigger_surfaces_state() {
let mut st = ArchTabState::local(PathBuf::from("/nonexistent"));
st.inject_for_test(board(), vec!["nornir".into()]);
assert_eq!(st.state_json()["deep_scan"]["inflight"], false);
assert!(st.loaded);
st.trigger_deep_scan();
let js = st.state_json();
assert_eq!(js["deep_scan"]["inflight"], false);
assert!(js["deep_scan"]["note"].as_str().is_some_and(|n| n.contains("code room")), "{}", js["deep_scan"]);
assert!(!st.loaded, "the next draw reads the code room again");
}
/// Fat mode end to end: the pane reads its workspace's code room through
/// the reader — the board, who contributed, and the metro fuel — and a
/// workspace nothing was consumed into is `load_error` by state, never an
/// empty board.
#[cfg(feature = "code-room")]
#[test]
fn a_fat_pane_reads_the_board_and_the_metro_fuel_from_the_code_room() {
use crate::code_room::reader::fixture;
let mut st = ArchTabState::local(fixture::shop().builds.clone());
st.ensure_loaded();
let js = st.state_json();
assert!(js["load_error"].is_null(), "{js}");
assert_eq!(js["node_count"], 5, "shop, shop::cart, shop::pay, stripe::Client, web");
assert_eq!(st.members_with_data, vec!["shop".to_string(), "web".to_string()]);
assert_eq!((st.symbols.as_ref().map(Vec::len), st.calls.as_ref().map(Vec::len)), (Some(16), Some(14)), "the fuel rides the payload in fat mode too");
assert_eq!(st.metro_fuel(), "resolved/scip");
// `shop` has no handler behind nornir's buttons: every line is built from the
// room's rows and none reaches — unlit skeletons, never "no fuel"
st.ensure_metro();
let lines = st.metro_lines.as_deref().unwrap();
assert!(!lines.is_empty() && lines.iter().all(|l| !l.reached), "{} line(s)", lines.len());
let mut st = ArchTabState::local(fixture::unconsumed().builds.clone());
st.ensure_loaded();
let js = st.state_json();
assert!(js["load_error"].as_str().is_some_and(|e| e.contains("[not_consumed]")), "{js}");
assert_eq!(js["node_count"], 0);
st.ensure_symbols();
assert_eq!(st.metro_fuel(), "unavailable", "no fuel because the board could not be read — not `no lines`");
}
}