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

nordisk/dwarves

dwarves-gui/src/warehouse_nav.rs

//! **The 🌐 Warehouse Navigator's host half — both tiers are the server's.**
//!
//! The navigator (`nornir-warehouse-nav`) is dwarves' landing view: a
//! workspace → repo → crate → **function** graph. That leaf crate is dependency-light
//! (no gRPC) and holds NO data of its own: this module feeds it, through its two
//! seams, from nornir's code room (`CodeRoom.Answer` on the forge, [`crate::room`]):
//!
//! | tier | seam | asked of | mapped onto |
//! |---|---|---|---|
//! | repos, their crates, repo → repo dependency edges | [`SkeletonSource`] | `Viz.CrateGraph` | `nornir_depgraph::RepoFacts` / `CrossRepoEdge` |
//! | fns per crate + the call edges among them | [`RealFnSource`] | `Viz.FunctionTier` | `nornir_depgraph::funcgraph::CrateFuncGraph` |
//!
//! Field for field: the room's rows are the navigator's rows. Nothing is derived here —
//! no manifest is parsed, no checkout is read, no edge is resolved.
//!
//! ## A source that cannot answer draws its state
//! Each seam's `Err` is the answer's state and evidence in one sentence
//! (`[not_consumed] code room of …`). The navigator then draws NO skeleton / NO
//! function tier and prints that sentence. There is no fixture skeleton and no
//! synthetic function behind either seam.
//!
//! ## What this replaced
//! The skeleton used to be built from the member checkouts on the machine the UI runs
//! on (`WorkspaceGraph::build_from_members` over `$DWARVES_SUITE_ROOT` / the working
//! directory's ancestors), with the glow read off `.git` mtimes — and, when no such
//! directory was found, from a compiled-in nordisk snapshot. The function tier was
//! filler nodes. A UI that reads its own disk shows the laptop's tree, not the
//! server's; both are gone. `$DWARVES_SUITE_ROOT` / `$DWARVES_SKELETON` are no longer
//! read. The recency glow has no server feed yet and is not drawn.

use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;

use facett_core::Facet;
use nornir_depgraph::funcgraph::{CrateFuncGraph, FnEdge, FnNode};
use nornir_depgraph::{CrossRepoEdge, RepoFacts};
use nornir_warehouse_nav::{RealFnSource, Skeleton, SkeletonSource, WarehouseNav};

use crate::room::{ForgeRoom, RoomAnswer, RoomSource};

/// The skeleton surface.
pub const S_SKELETON: &str = "Viz.CrateGraph";
/// The function-tier surface.
pub const S_FUNCTIONS: &str = "Viz.FunctionTier";
/// Fns per crate the navigator asks the function tier for. The tier is a SUMMARY
/// by default (each crate's counts, no fn — the whole room's fns were 37 MB for
/// `nordisk`); the world preview draws at most a few thousand nodes, so a short
/// preview of each crate is all it can use. A crate's every fn is the reader's
/// drill-in (`crate` + `page`).
pub const FN_PREVIEW_PER_CRATE: u64 = 16;

/// The sentence a seam hands the navigator for an answer that is not a measurement:
/// the state by name, then the reader's evidence.
fn refusal_line(a: &RoomAnswer) -> String {
    format!("[{}] {}", a.state, a.evidence_text())
}

/// The `name` of a `name@version` crate id.
fn crate_name(id: &str) -> &str {
    id.rsplit_once('@').map_or(id, |(n, _)| n)
}

/// **A `Viz.CrateGraph` answer as the navigator's skeleton** — the reader's `repos`
/// (each with the crates it defines and the crates those depend on) and `repo_edges`
/// (the ones that carry a dependency). `workspace` is the breadcrumb root when the
/// answer names none.
pub fn skeleton_of(a: &RoomAnswer, workspace: &str) -> Result<Skeleton, String> {
    if !a.is_measured() {
        return Err(refusal_line(a));
    }
    let names = |v: &serde_json::Value| -> BTreeSet<String> {
        v.as_array()
            .map(|x| {
                x.iter()
                    .filter_map(|c| c.as_str())
                    .map(|c| crate_name(c).to_string())
                    .collect()
            })
            .unwrap_or_default()
    };
    let mut facts: BTreeMap<String, RepoFacts> = BTreeMap::new();
    for r in a.rows("repos") {
        let Some(repo) = r["repo"].as_str() else {
            continue;
        };
        facts.insert(
            repo.to_string(),
            RepoFacts {
                name: repo.to_string(),
                produces: names(&r["crates"]),
                consumes: names(&r["consumes"]),
                ..Default::default()
            },
        );
    }
    let edges = a
        .rows("repo_edges")
        .iter()
        .filter(|e| e["depends_on"].as_u64().unwrap_or(0) > 0)
        .filter_map(|e| {
            Some(CrossRepoEdge::normal(
                e["from"].as_str()?,
                e["to"].as_str()?,
                names(&e["via"]),
            ))
        })
        .collect();
    Ok(Skeleton {
        root: a.body["room"]["workspace"]
            .as_str()
            .unwrap_or(workspace)
            .to_string(),
        facts,
        edges,
        source: format!(
            "{S_SKELETON} [{}] · {} · via {}",
            a.state,
            a.room_line(),
            a.via
        ),
    })
}

/// **A `Viz.FunctionTier` answer as the navigator's function tier** — per repo, per
/// crate, the reader's fns (its readable path is the id, its name the label) and the
/// call edges among them (indexes into that crate's fns, resolved to ids here).
pub fn functions_of(a: &RoomAnswer) -> Result<Vec<(String, Vec<CrateFuncGraph>)>, String> {
    if !a.is_measured() {
        return Err(refusal_line(a));
    }
    let mut out = Vec::new();
    for r in a.rows("repos") {
        let Some(repo) = r["repo"].as_str() else {
            continue;
        };
        let mut graphs = Vec::new();
        for c in r["crates"].as_array().map(Vec::as_slice).unwrap_or(&[]) {
            let Some(krate) = c["crate"].as_str() else {
                continue;
            };
            let nodes: Vec<FnNode> = c["fns"]
                .as_array()
                .map(Vec::as_slice)
                .unwrap_or(&[])
                .iter()
                .map(|f| FnNode {
                    id: f["id"].as_str().unwrap_or("?").to_string(),
                    name: f["name"].as_str().unwrap_or("?").to_string(),
                    crate_name: krate.to_string(),
                })
                .collect();
            let edges = c["calls"]
                .as_array()
                .map(Vec::as_slice)
                .unwrap_or(&[])
                .iter()
                .filter_map(|e| {
                    let (f, t) = (e[0].as_u64()? as usize, e[1].as_u64()? as usize);
                    Some(FnEdge {
                        from: nodes.get(f)?.id.clone(),
                        to: nodes.get(t)?.id.clone(),
                    })
                })
                .collect();
            graphs.push(CrateFuncGraph {
                crate_name: krate.to_string(),
                nodes,
                edges,
            });
        }
        out.push((repo.to_string(), graphs));
    }
    Ok(out)
}

/// **The shipped source of both tiers**: nornir's code room, through the forge. It
/// keeps the last answer of each surface so the pane can show its state, evidence and
/// the room it was read from.
pub struct RoomNavSource {
    room: Arc<dyn RoomSource>,
    workspace: String,
    last: Arc<std::sync::Mutex<BTreeMap<&'static str, RoomAnswer>>>,
}

impl RoomNavSource {
    pub fn new(room: Arc<dyn RoomSource>, workspace: impl Into<String>) -> Self {
        Self {
            room,
            workspace: workspace.into(),
            last: Arc::default(),
        }
    }

    fn ask(&self, surface: &'static str, params: serde_json::Value) -> RoomAnswer {
        let a = self.room.ask(surface, &params);
        self.last
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .insert(surface, a.clone());
        a
    }

    /// The same source for the other seam (they share the kept answers).
    fn twin(&self) -> Self {
        Self {
            room: Arc::clone(&self.room),
            workspace: self.workspace.clone(),
            last: Arc::clone(&self.last),
        }
    }
}

impl SkeletonSource for RoomNavSource {
    fn fetch(&self) -> Result<Skeleton, String> {
        // the workspace's own crates: the ring's crates are not repos of this workspace
        skeleton_of(
            &self.ask(S_SKELETON, serde_json::json!({ "universe": false })),
            &self.workspace,
        )
    }
    fn describe(&self) -> String {
        format!("{S_SKELETON} via {}", self.room.describe())
    }
}

impl RealFnSource for RoomNavSource {
    fn fetch(&self) -> Result<Vec<(String, Vec<CrateFuncGraph>)>, String> {
        functions_of(&self.ask(
            S_FUNCTIONS,
            serde_json::json!({ "per_crate": FN_PREVIEW_PER_CRATE }),
        ))
    }
    fn describe(&self) -> String {
        format!("{S_FUNCTIONS} via {}", self.room.describe())
    }
}

/// The Warehouse tab: the navigator, bound to the SELECTED workspace's room. The
/// selector's broadcast re-binds it (a new navigator over the new workspace's sources
/// — nothing of the previous workspace stays on screen).
pub struct NavTab {
    nav: WarehouseNav,
    /// The answers the sources last got, by surface (for `state_json`).
    last: Arc<std::sync::Mutex<BTreeMap<&'static str, RoomAnswer>>>,
    /// The forge endpoint + bearer a re-bind asks with (`None` for an injected source).
    forge: Option<(String, String)>,
    workspace: String,
}

impl NavTab {
    /// The shipped tab: the room of `workspace`, asked through the forge at `endpoint`.
    pub fn new(endpoint: &str, token: &str, workspace: &str) -> Self {
        let mut me = Self::with_source(ForgeRoom::shared(endpoint, token, workspace), workspace);
        me.forge = Some((endpoint.to_string(), token.to_string()));
        me
    }

    /// The tab over an injected room source (tests).
    pub fn with_source(room: Arc<dyn RoomSource>, workspace: &str) -> Self {
        let source = RoomNavSource::new(room, workspace);
        let last = Arc::clone(&source.last);
        let nav = WarehouseNav::empty(workspace)
            .with_skeleton_feed(Box::new(source.twin()))
            .with_real_source(Box::new(source));
        Self {
            nav,
            last,
            forge: None,
            workspace: workspace.to_string(),
        }
    }
}

impl Facet for NavTab {
    fn title(&self) -> &str {
        Facet::title(&self.nav)
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        // WHERE each tier came from, above the graph: the answer's state as a chip, its
        // evidence, the room it was read from (workspace · repo@seq (commit) · nodes ·
        // edges) and the door it was asked through. Not asked yet ⇒ no line.
        {
            let last = self.last.lock().unwrap_or_else(|e| e.into_inner());
            for (what, surface) in [
                ("skeleton (repos · crates)", S_SKELETON),
                ("function tier", S_FUNCTIONS),
            ] {
                if let Some(a) = last.get(surface) {
                    crate::room::callout(ui, what, a);
                }
            }
        }
        Facet::ui(&mut self.nav, ui);
    }

    fn state_json(&self) -> serde_json::Value {
        let mut v = Facet::state_json(&self.nav);
        if let Some(o) = v.as_object_mut() {
            let last = self.last.lock().unwrap_or_else(|e| e.into_inner());
            let of = |s: &str| {
                last.get(s)
                    .map(RoomAnswer::state_json)
                    .unwrap_or(serde_json::Value::Null)
            };
            o.insert("workspace".into(), serde_json::json!(self.workspace));
            // what each tier rests on: the answer's state, evidence and room (null = not asked yet)
            // …and the reader's own totals beside what the pane drew (the world preview
            // is capped; these are not): null when that tier is not a measurement.
            let total = |s: &str, key: &str| {
                last.get(s)
                    .filter(|a| a.is_measured())
                    .and_then(|a| a.body[key].as_u64())
            };
            let listed = |s: &str, key: &str| {
                last.get(s)
                    .filter(|a| a.is_measured())
                    .map(|a| a.rows(key).len())
            };
            o.insert(
                "room".into(),
                serde_json::json!({
                    "skeleton": of(S_SKELETON),
                    "functions": of(S_FUNCTIONS),
                    "repos": listed(S_SKELETON, "repos"),
                    "crates": listed(S_SKELETON, "crates"),
                    "fns_total": total(S_FUNCTIONS, "total_fns"),
                    "calls_total": total(S_FUNCTIONS, "total_calls"),
                }),
            );
        }
        v
    }

    fn update_json(&mut self, msg_json: &str) {
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
            if let Some(ws) = v.get("set_workspace").and_then(|t| t.as_str()) {
                if ws != self.workspace {
                    if let Some((endpoint, token)) = self.forge.clone() {
                        *self = Self::new(&endpoint, &token, ws);
                    }
                }
                return;
            }
        }
        Facet::update_json(&mut self.nav, msg_json);
    }
}

/// **The shipped Warehouse Navigator landing pane**, bound to the selected workspace.
/// Safe to mount when the server is down: it then draws the state of each tier.
#[must_use]
pub fn warehouse_nav_pane(
    endpoint: &str,
    token: &str,
    workspace: &str,
) -> Box<dyn facett_core::Panel> {
    Box::new(NavTab::new(endpoint, token, workspace))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::room::testing::{refusal, room, CannedRoom};
    use serde_json::json;

    /// A crate graph as the reader answers one: `shop` (two crates) and `web`, web
    /// depending on shop, shop on the dependency `serde`.
    fn graph() -> serde_json::Value {
        json!({
            "state": "complete",
            "crates": [],
            "edges": [],
            "repos": [
                { "repo": "shop", "seq": 2, "sha": "0123456789abcdef", "crates": ["shop@0.1.0", "shop-core@0.1.0"], "consumes": ["serde", "shop-core"] },
                { "repo": "web", "seq": 1, "sha": "fedcba9876543210", "crates": ["web@0.2.0"], "consumes": ["shop"] },
            ],
            "repo_edges": [
                { "from": "web", "to": "shop", "depends_on": 1, "calls": 4, "via": ["shop"] },
                // calls only, no dependency edge: not a skeleton edge
                { "from": "shop", "to": "web", "depends_on": 0, "calls": 1, "via": [] },
            ],
            "evidence": { "missing": [], "notes": [] },
            "room": room("shop-ws", "shop", 2, 31, 60),
        })
    }

    fn tier() -> serde_json::Value {
        json!({
            "state": "complete",
            "repos": [{
                "repo": "shop",
                "crates": [
                    { "crate": "shop", "version": "0.1.0", "total_fns": 3, "total_calls": 2,
                      "fns": [{ "id": "shop::checkout", "name": "checkout" }, { "id": "shop::pay::charge", "name": "charge" }, { "id": "shop::pay::audit", "name": "audit" }],
                      "calls": [[0, 1, 3], [1, 2, 2]] },
                    { "crate": "shop-core", "version": "0.1.0", "total_fns": 0, "total_calls": 0, "fns": [], "calls": [] },
                ],
            }],
            "total_fns": 3, "total_calls": 2,
            "evidence": { "missing": [], "notes": ["a lower bound"] },
            "room": room("shop-ws", "shop", 2, 31, 60),
        })
    }

    fn tab(src: &Arc<CannedRoom>) -> NavTab {
        let mut t = NavTab::with_source(src.clone(), "shop-ws");
        t.update_json(r#"{"read":true}"#);
        t
    }

    /// The skeleton drawn is the reader's: its repos, the crates it says each defines,
    /// and only the repo edges that carry a dependency.
    #[test]
    fn the_skeleton_is_the_rooms_crate_graph() {
        let src = CannedRoom::new(&[(S_SKELETON, graph()), (S_FUNCTIONS, tier())]);
        let mut t = tab(&src);
        let s = t.state_json();
        assert_eq!(
            (s["repo_count"].as_u64(), s["edge_count"].as_u64()),
            (Some(2), Some(1)),
            "{s}"
        );
        assert_eq!(
            s["edges"],
            json!([{ "from": "repo:web", "to": "repo:shop", "dashed": false }])
        );
        assert_eq!(s["breadcrumb"], json!(["shop-ws"]));
        assert!(s["skeleton_error"].is_null());
        assert_eq!(s["skeleton_source"], "Viz.CrateGraph [complete] · workspace shop-ws · shop@2 (0123456789) · 31 nodes · 60 edges · via canned room (test)");
        assert_eq!(s["room"]["skeleton"]["state"], "complete");
        assert!(
            s["room"]["functions"].is_null(),
            "the function tier is not asked on the landing"
        );
        // the workspace's own crates only — the ring was not asked for
        assert_eq!(
            src.asked.lock().unwrap()[0],
            (S_SKELETON.to_string(), json!({ "universe": false }))
        );
        // drill: the crates of `shop` are the two the reader named
        t.update_json(r#"{"drill":"repo:shop"}"#);
        let s = t.state_json();
        let crates: Vec<&str> = s["nodes"]
            .as_array()
            .unwrap()
            .iter()
            .filter(|n| n["kind"] == "crate")
            .filter_map(|n| n["label"].as_str())
            .collect();
        assert_eq!(crates, vec!["shop", "shop-core"]);
    }

    /// The function tier drawn is the reader's: its fns by name, its call edges, and
    /// nothing under a crate it named no fn of.
    #[test]
    fn the_function_tier_is_the_rooms_function_tier() {
        let src = CannedRoom::new(&[(S_SKELETON, graph()), (S_FUNCTIONS, tier())]);
        let mut t = tab(&src);
        t.update_json(r#"{"world_target":100000}"#);
        t.update_json(r#"{"world":true}"#);
        let s = t.state_json();
        assert_eq!(
            (
                s["preview_repo_nodes"].as_u64(),
                s["preview_crate_nodes"].as_u64(),
                s["preview_fn_nodes"].as_u64()
            ),
            (Some(2), Some(3), Some(3))
        );
        assert_eq!(
            (
                s["real_fn_nodes"].as_u64(),
                s["real_call_edges"].as_u64(),
                s["real_fn_crates"].as_u64(),
                s["crates_without_fns"].as_u64()
            ),
            (Some(3), Some(2), Some(1), Some(2))
        );
        assert_eq!(s["repo_dep_edges"], 1);
        assert!(s["real_error"].is_null());
        assert_eq!(s["room"]["functions"]["state"], "complete");
        assert_eq!(
            s["room"]["functions"]["room"],
            "workspace shop-ws · shop@2 (0123456789) · 31 nodes · 60 edges"
        );
        // the tier is asked as a summary with a short preview per crate — never the
        // whole room's fns (that answer was 37 MB for `nordisk`)
        assert!(
            src.asked.lock().unwrap().contains(&(
                S_FUNCTIONS.to_string(),
                json!({ "per_crate": FN_PREVIEW_PER_CRATE })
            )),
            "{:?}",
            src.asked.lock().unwrap()
        );
        // 2 repos + 3 crates + 3 fns, and edges: 3 repo→crate + 3 crate→fn + 2 calls + 1 dependency
        assert_eq!(
            (s["preview_nodes"].as_u64(), s["preview_edges"].as_u64()),
            (Some(8), Some(9))
        );
    }

    /// A room that refuses: each tier draws its state and sentence, and no node.
    #[test]
    fn a_refusing_room_draws_its_state_and_no_node() {
        let note = "code room of `shop-ws`: no repo has a consumer offset yet — nothing of this workspace was consumed";
        let src = CannedRoom::new(&[
            (S_SKELETON, refusal("not_consumed", note)),
            (S_FUNCTIONS, refusal("not_consumed", note)),
        ]);
        let mut t = tab(&src);
        let s = t.state_json();
        assert!(
            s["repo_count"].is_null()
                && s["node_count"].is_null()
                && s["edge_count"].is_null()
                && s["skeleton_read"] == false,
            "no count for a graph nobody read: {s}"
        );
        assert_eq!(s["nodes"], serde_json::json!([]));
        assert_eq!(s["skeleton_error"], format!("[not_consumed] {note}"));
        assert_eq!(s["room"]["skeleton"]["state"], "not_consumed");
        t.update_json(r#"{"world":true}"#);
        let s = t.state_json();
        assert!(
            s["preview_nodes"].is_null()
                && s["real_fn_nodes"].is_null()
                && s["real_call_edges"].is_null()
                && s["functions_read"] == false,
            "{s}"
        );
        assert_eq!(s["real_error"], format!("[not_consumed] {note}"));
    }

    /// The mapping drops nothing and invents nothing: a call edge whose index the
    /// crate's fn list does not hold is not drawn.
    #[test]
    fn a_call_edge_outside_the_fn_list_is_dropped_not_guessed() {
        let mut body = tier();
        body["repos"][0]["crates"][0]["calls"] = json!([[0, 1, 1], [0, 9, 1]]);
        let a = RoomAnswer::from_body(S_FUNCTIONS, "test", body);
        let tiers = functions_of(&a).unwrap();
        assert_eq!(
            tiers[0].1[0].edges,
            vec![FnEdge {
                from: "shop::checkout".into(),
                to: "shop::pay::charge".into()
            }]
        );
        assert_eq!(
            skeleton_of(
                &RoomAnswer::from_body(
                    S_SKELETON,
                    "test",
                    refusal("no_room", "no workspace selected")
                ),
                "w"
            )
            .unwrap_err(),
            "[no_room] no workspace selected"
        );
    }
}