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

nordisk/facett

facett-holger/src/lib.rs

//! **facett-holger** — the holger *registry & artifact browser* panel as a
//! reusable [`facett_core::Facet`] component.
//!
//! This is the standalone, backend-free component behind holger-ui's Repos +
//! Browse tabs: a **repository roster** (`name` / `type` / `writable` / `archive`)
//! over the **selected repo's artifact listing** (`name` / `version` / `size` /
//! `type`), both rendered with [`facett_table::Table`] — the same component
//! holger-ui uses. It is **backend-agnostic**: the host feeds it a serde
//! [`RegistryModel`] (built from holger's in-process functional read or a thin
//! gRPC read) and the panel never touches holger, the network, or the filesystem.
//!
//! Two hosts share this ONE component (the constellation "wire-once" rule):
//!
//! * **holger's own app / demo** — mounts [`RegistryBrowser::from_fixture`] (a
//!   sample registry) or `from_model` (real data) natively.
//! * **a peer app** (nornir's Holger tab) — hosts it through the `holger-embed`
//!   adapter (backend (2), native, no wasm), fed real data over the wire.
//!
//! The component mirrors the `Facet` shape (`title` / `ui` / `state_json`) plus a
//! message path ([`RegistryBrowser::apply`] / [`RegistryBrowser::update_json`])
//! so the headless robot harness and the design's `Panel` trait drive it the same
//! way. See `.nornir` consolidation design (§2, native panels).

use facett_core::Facet;
use facett_table::Table;
use serde::{Deserialize, Serialize};
use serde_json::json;

/// One repository in the roster (the Repos-tab columns).
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct RepoEntry {
    /// Repository name (`crates-mirror`, `pypi-cache`, …).
    pub name: String,
    /// Ecosystem / repo type (`rust`, `python`, `maven`, `npm`, `docker`, …).
    pub kind: String,
    /// `true` for a writable backend (a rehearsal/dev registry or a write-through
    /// cache); `false` for a read-only upstream mirror.
    pub writable: bool,
    /// Whether the backend is a znippy archive (airgap-carriable) vs a bare mirror.
    pub has_archive: bool,
    /// Distinct artifacts the backend holds (0 for an upstream with no local
    /// archive to list). Advisory — surfaced in the roster and `state_json`.
    #[serde(default)]
    pub artifact_count: u64,
}

/// One artifact row for the selected repo (the Browse-tab columns).
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ArtifactEntry {
    /// Artifact / package name.
    pub name: String,
    /// Version coordinate.
    pub version: String,
    /// Size of the stored artifact in bytes.
    pub size_bytes: u64,
    /// MIME / content type (`application/x-rust-crate`, …).
    pub content_type: String,
}

/// The **backend-agnostic** model a host feeds the browser. Serde so a host builds
/// it from holger's read API (in-process or gRPC) and the headless harness
/// round-trips it. `artifacts` are the listing for `selected_repo`; a host that
/// wants a different repo's listing rebuilds the model (or drives
/// [`RegistryBrowser::apply`] and re-supplies artifacts).
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct RegistryModel {
    /// The repository roster.
    pub repos: Vec<RepoEntry>,
    /// The artifact listing for [`selected_repo`](Self::selected_repo).
    pub artifacts: Vec<ArtifactEntry>,
    /// Which repo's artifacts `artifacts` holds (matches a `repos[i].name`).
    pub selected_repo: String,
}

impl RegistryModel {
    /// A sample holger registry + artifact listing — the no-backend fixture the
    /// demo mounts (a cross-section of the ecosystems holger serves). Identical in
    /// spirit to holger-ui's Repos/Browse tabs over a synthetic registry.
    pub fn fixture() -> Self {
        let repos = vec![
            RepoEntry { name: "crates-mirror".into(), kind: "rust".into(), writable: false, has_archive: true, artifact_count: 8 },
            RepoEntry { name: "pypi-cache".into(), kind: "python".into(), writable: false, has_archive: true, artifact_count: 0 },
            RepoEntry { name: "maven-central".into(), kind: "maven".into(), writable: false, has_archive: true, artifact_count: 0 },
            RepoEntry { name: "npm-proxy".into(), kind: "npm".into(), writable: true, has_archive: false, artifact_count: 0 },
            RepoEntry { name: "docker-hub".into(), kind: "docker".into(), writable: false, has_archive: true, artifact_count: 0 },
            RepoEntry { name: "go-proxy".into(), kind: "go".into(), writable: true, has_archive: false, artifact_count: 0 },
            RepoEntry { name: "nuget-gallery".into(), kind: "nuget".into(), writable: false, has_archive: true, artifact_count: 0 },
            RepoEntry { name: "helm-charts".into(), kind: "helm".into(), writable: true, has_archive: true, artifact_count: 0 },
        ];
        let artifacts = vec![
            ArtifactEntry { name: "serde".into(), version: "1.0.203".into(), size_bytes: 78_234, content_type: "application/x-rust-crate".into() },
            ArtifactEntry { name: "tokio".into(), version: "1.38.0".into(), size_bytes: 812_345, content_type: "application/x-rust-crate".into() },
            ArtifactEntry { name: "anyhow".into(), version: "1.0.86".into(), size_bytes: 45_123, content_type: "application/x-rust-crate".into() },
            ArtifactEntry { name: "clap".into(), version: "4.5.7".into(), size_bytes: 356_780, content_type: "application/x-rust-crate".into() },
            ArtifactEntry { name: "znippy-common".into(), version: "0.9.6".into(), size_bytes: 129_900, content_type: "application/x-rust-crate".into() },
            ArtifactEntry { name: "serde_json".into(), version: "1.0.117".into(), size_bytes: 98_211, content_type: "application/x-rust-crate".into() },
            ArtifactEntry { name: "rayon".into(), version: "1.10.0".into(), size_bytes: 178_432, content_type: "application/x-rust-crate".into() },
            ArtifactEntry { name: "blake3".into(), version: "1.5.1".into(), size_bytes: 267_900, content_type: "application/x-rust-crate".into() },
        ];
        Self { selected_repo: "crates-mirror".into(), repos, artifacts }
    }
}

/// A message driving the browser — the ONLY mutation path (mirrors the facett Elm
/// `update` seam + the design's `Panel::update_json`).
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
    /// Select a repository by name (updates the roster selection + records which
    /// repo the artifact listing is *for*).
    SelectRepo(String),
}

/// The holger registry / artifact browser panel.
pub struct RegistryBrowser {
    model: RegistryModel,
    /// The repository roster table (rebuilt from `model.repos`).
    repos: Table,
    /// The artifact listing table for `model.selected_repo`.
    artifacts: Table,
    /// `true` when built from the built-in [`RegistryModel::fixture`] (no backend);
    /// surfaced in `state_json` so a viewer can tell demo data from live data.
    fixture: bool,
}

impl Default for RegistryBrowser {
    fn default() -> Self {
        Self::from_fixture()
    }
}

impl RegistryBrowser {
    /// Build the browser over a host-supplied [`RegistryModel`] (live data).
    pub fn from_model(model: RegistryModel) -> Self {
        let (repos, artifacts) = build_tables(&model);
        Self { model, repos, artifacts, fixture: false }
    }

    /// Build the browser over the built-in sample registry — no backend, no
    /// network, no filesystem (browser-safe; what the demo mounts).
    pub fn from_fixture() -> Self {
        let model = RegistryModel::fixture();
        let (repos, artifacts) = build_tables(&model);
        Self { model, repos, artifacts, fixture: true }
    }

    /// Replace the whole model (e.g. a fresh read from holger) and rebuild the
    /// tables. Keeps the panel a pure projection of the supplied data.
    pub fn set_model(&mut self, model: RegistryModel) {
        let (repos, artifacts) = build_tables(&model);
        self.model = model;
        self.repos = repos;
        self.artifacts = artifacts;
        self.fixture = false;
    }

    /// The repo whose artifacts the browser is showing.
    pub fn selected_repo(&self) -> &str {
        &self.model.selected_repo
    }

    /// Apply one [`Msg`] — the mutation path. Returns `true` if it changed state.
    pub fn apply(&mut self, msg: Msg) -> bool {
        match msg {
            Msg::SelectRepo(name) => self.select_repo(&name),
        }
    }

    /// JSON message path (mirrors `Panel::update_json`): `{"select_repo": "name"}`.
    /// Unknown messages are ignored (returns `false`).
    pub fn update_json(&mut self, msg_json: &str) -> bool {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
            return false;
        };
        if let Some(name) = v.get("select_repo").and_then(|n| n.as_str()) {
            return self.select_repo(name);
        }
        false
    }

    /// Select `name` in the roster: mark the row selected and record it as the
    /// repo the artifact listing is for. Returns `false` if `name` isn't in the
    /// roster (selection unchanged).
    pub fn select_repo(&mut self, name: &str) -> bool {
        let Some(idx) = self.model.repos.iter().position(|r| r.name == name) else {
            return false;
        };
        self.model.selected_repo = name.to_string();
        self.repos.select_row(idx);
        true
    }
}

/// Build the two `Table`s from a model — the single projection both constructors
/// and [`RegistryBrowser::set_model`] share.
fn build_tables(model: &RegistryModel) -> (Table, Table) {
    let mut repos = Table::new(
        "repositories",
        vec!["name".into(), "type".into(), "writable".into(), "archive".into()],
    );
    for (i, r) in model.repos.iter().enumerate() {
        repos.push_row(vec![
            r.name.clone(),
            r.kind.clone(),
            r.writable.to_string(),
            r.has_archive.to_string(),
        ]);
        if r.name == model.selected_repo {
            repos.select_row(i);
        }
    }

    let mut artifacts = Table::new(
        "browse",
        vec!["name".into(), "version".into(), "size".into(), "type".into()],
    );
    for a in &model.artifacts {
        artifacts.push_row(vec![
            a.name.clone(),
            a.version.clone(),
            a.size_bytes.to_string(),
            a.content_type.clone(),
        ]);
    }
    (repos, artifacts)
}

impl Facet for RegistryBrowser {
    fn title(&self) -> &str {
        "holger — registry & artifact browser"
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        ui.heading("holger — registry & artifact browser");
        if self.fixture {
            ui.label(
                egui::RichText::new(
                    "sample registry (no backend) — a host supplies live data via `set_model`.",
                )
                .italics()
                .weak(),
            );
        }
        ui.separator();

        // Clickable repo selectors — a button per repo (the robot clicks these by
        // label; a user clicks to switch the artifact listing's target repo). The
        // "▸ " prefix keeps the selector's a11y label DISTINCT from the same repo
        // name rendered as a roster-table cell, so a click targets one widget.
        ui.horizontal_wrapped(|ui| {
            ui.label(egui::RichText::new("repositories").strong());
            for r in &self.model.repos {
                let selected = r.name == self.model.selected_repo;
                if ui.selectable_label(selected, format!("▸ {}", r.name)).clicked() {
                    // Defer the mutation: record the pick, apply after the loop to
                    // avoid borrowing `self` mutably while iterating `self.model`.
                    ui.data_mut(|d| d.insert_temp(egui::Id::new("facett_holger_pick"), r.name.clone()));
                }
            }
        });
        if let Some(pick) =
            ui.data_mut(|d| d.remove_temp::<String>(egui::Id::new("facett_holger_pick")))
        {
            self.select_repo(&pick);
        }

        self.repos.ui(ui);

        ui.add_space(8.0);
        ui.separator();
        ui.label(
            egui::RichText::new(format!("artifacts in `{}`", self.model.selected_repo)).strong(),
        );
        self.artifacts.ui(ui);
    }

    fn state_json(&self) -> serde_json::Value {
        json!({
            "panel": "holger-registry-browser",
            "backend": if self.fixture { "fixture" } else { "live" },
            "selected_repo": self.model.selected_repo,
            "repo_count": self.model.repos.len(),
            "artifact_count": self.model.artifacts.len(),
            "repositories": self.repos.state_json(),
            "artifacts": self.artifacts.state_json(),
        })
    }

    fn kind(&self) -> &'static str {
        "holger-registry"
    }

    /// The **robot drive seam**. This override was MISSING: the inherent
    /// `RegistryBrowser::update_json` existed and worked, but the `Facet` impl
    /// inherited the trait's no-op default — so any host or robot driving the pane
    /// **through `dyn Facet`** (which is how `FacetDeck` and
    /// `facett_core::harness::probe_facet` reach it) silently dropped every
    /// message. Delegating here makes the trait-level seam live; `tests/matrix.rs`
    /// pins it (it goes RED if this override is removed again).
    fn update_json(&mut self, msg_json: &str) {
        let _applied = RegistryBrowser::update_json(self, msg_json);
    }

    fn portable_state(&self) -> Option<serde_json::Value> {
        serde_json::to_value(&self.model).ok()
    }

    fn load_state(&mut self, state: &serde_json::Value) -> bool {
        match serde_json::from_value::<RegistryModel>(state.clone()) {
            Ok(model) => {
                self.set_model(model);
                true
            }
            Err(_) => false,
        }
    }
}

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

    #[test]
    fn fixture_reports_holger_shaped_state() {
        let b = RegistryBrowser::from_fixture();
        let s = b.state_json();
        assert_eq!(s["panel"], "holger-registry-browser");
        assert_eq!(s["backend"], "fixture");
        assert_eq!(s["selected_repo"], "crates-mirror");
        assert_eq!(s["repo_count"], 8);
        assert_eq!(s["artifact_count"], 8);
        assert_eq!(s["repositories"]["rows"], 8);
        assert_eq!(s["artifacts"]["rows"], 8);
        assert_eq!(s["repositories"]["columns"][0], "name");
        assert_eq!(s["repositories"]["columns"][2], "writable");
    }

    #[test]
    fn from_model_marks_backend_live() {
        let model = RegistryModel {
            selected_repo: "sparring".into(),
            repos: vec![RepoEntry {
                name: "sparring".into(),
                kind: "rust".into(),
                writable: true,
                has_archive: true,
                artifact_count: 3,
            }],
            artifacts: vec![ArtifactEntry {
                name: "nornir".into(),
                version: "0.5.2".into(),
                size_bytes: 1_000,
                content_type: "application/x-rust-crate".into(),
            }],
        };
        let b = RegistryBrowser::from_model(model);
        let s = b.state_json();
        assert_eq!(s["backend"], "live");
        assert_eq!(s["selected_repo"], "sparring");
        assert_eq!(s["repo_count"], 1);
        assert_eq!(s["artifact_count"], 1);
    }

    #[test]
    fn select_repo_changes_selection_and_rejects_unknown() {
        let mut b = RegistryBrowser::from_fixture();
        assert!(b.apply(Msg::SelectRepo("npm-proxy".into())));
        assert_eq!(b.selected_repo(), "npm-proxy");
        assert_eq!(b.state_json()["selected_repo"], "npm-proxy");
        // Unknown repo → no change.
        assert!(!b.select_repo("does-not-exist"));
        assert_eq!(b.selected_repo(), "npm-proxy");
    }

    #[test]
    fn update_json_drives_selection() {
        let mut b = RegistryBrowser::from_fixture();
        assert!(b.update_json(r#"{"select_repo":"helm-charts"}"#));
        assert_eq!(b.selected_repo(), "helm-charts");
        // Malformed / unknown message ignored.
        assert!(!b.update_json("not json"));
        assert!(!b.update_json(r#"{"nope":1}"#));
    }

    #[test]
    fn portable_state_round_trips_between_instances() {
        let mut a = RegistryBrowser::from_fixture();
        a.select_repo("go-proxy");
        let portable = a.portable_state().expect("portable");
        let mut b = RegistryBrowser::from_fixture();
        assert!(b.load_state(&portable));
        assert_eq!(b.selected_repo(), "go-proxy");
        assert_eq!(b.state_json()["backend"], "live", "adopted state is live data");
    }
}