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");
}
}