facet-wrapped/src/registry.rs
//! The **component registry** — one wrapper entry per facett `*-view` crate.
//!
//! An [`Entry`] is a name, a description, a namespace [`group`](Entry::group), and up
//! to two builders:
//!
//! * a **fixture** builder (`::demo()` / `::local()`) — the zero-config showcase, and
//! * a **[`DataFactory`]** — the same component built from a CALLER'S payload.
//!
//! # Why the data factory is the whole point
//!
//! A registry of fixture-only entries is a brochure, not a wrapper. A caller holding
//! real rows has no way to get them on screen, so it renders them itself — and that
//! second renderer drifts from the component immediately and invisibly. Every
//! data-bearing component here therefore carries a [`DataFactory`], and
//! [`Registry::build_with`] **refuses loudly** ([`EmbedError`]) rather than quietly
//! handing back a fixture. Canned data displayed as if it were live is worse than an
//! error, because it looks correct.
use facett_core::Facet;
/// Registry namespaces. Components that are variations on one subject share a group,
/// so a host can present them as a single section — facett has five graph surfaces
/// and three map surfaces, and a flat list of eighteen names hides that structure.
pub const GRAPH: &str = "graph";
pub const MAP: &str = "map";
/// Data-bearing views: tables, catalogs, charts, matrices.
pub const DATA: &str = "data";
/// Operational views: plans, pipelines, security reports, lineage.
pub const OPS: &str = "ops";
/// Generic chrome: tab bars, consoles, logs.
pub const UI: &str = "ui";
/// Build a component from a caller payload. `None` when the payload holds nothing
/// renderable — the registry turns that into [`EmbedError::EmptyPayload`] rather than
/// falling back to a fixture.
pub type DataFactory = fn(&serde_json::Value) -> Option<Box<dyn Facet>>;
/// Build a component from a **generator request** (`{"generate": {"source", "size",
/// "seed"}}`, lane F 2026-10-02): the graph is generated HERE, in the wasm, by
/// `facett-graphgen` — the same generators and lowerings facett-demo's graph tabs use —
/// so a host asks for `sbm:100000:1` instead of shipping 100 000 nodes as JSON.
pub type GenFactory = fn(facett_graphgen::GenRequest) -> Option<Box<dyn Facet>>;
/// An entry's generator seam: the factory, the node ceiling the component's lane takes,
/// and the sources it offers. Advertised in the manifest so a host's picker offers
/// exactly what the component will build.
#[derive(Clone, Copy)]
pub struct Generator {
pub max: usize,
pub sources: &'static [facett_graphgen::Source],
factory: GenFactory,
}
impl Generator {
/// The manifest's `generate` object: `{max, sizes, sources: [{key, label}]}`.
#[must_use]
pub fn manifest_json(&self) -> serde_json::Value {
serde_json::json!({
"max": self.max,
"sizes": facett_graphgen::SIZES.iter().copied().filter(|&s| s <= self.max).collect::<Vec<_>>(),
"sources": self.sources.iter().map(|s| serde_json::json!({ "key": s.key(), "label": s.label() })).collect::<Vec<_>>(),
})
}
}
/// Why a **live-data** build was refused.
///
/// Refusal is deliberately typed and loud. The failure this crate exists to prevent is
/// a canned fixture being displayed as though it were the caller's data; "I cannot
/// render this" must never degrade into "here is a pretty picture of something else".
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EmbedError {
/// No component is registered under this name.
UnknownComponent(String),
/// The component exists but has no [`DataFactory`] — it is a fixture-only showcase
/// entry and must not be presented as live data.
NotDataDriven(&'static str),
/// The component is data-driven but the payload held nothing renderable (no rows,
/// no geometry, no nodes). The host shows its own empty state.
EmptyPayload(&'static str),
/// A `{"generate": …}` request to a component with no generator seam.
NotGenerated(&'static str),
/// A `{"generate": …}` request the component refused, and why (unknown source, a
/// source this component does not offer, a malformed size).
BadRequest(&'static str, String),
}
impl std::fmt::Display for EmbedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownComponent(n) => write!(f, "unknown component `{n}`"),
Self::NotDataDriven(n) => write!(
f,
"component `{n}` is a demo fixture — it has no data seam, so it must \
not be shown as live data"
),
Self::EmptyPayload(n) => {
write!(f, "component `{n}` received a payload with nothing renderable")
}
Self::NotGenerated(n) => write!(
f,
"component `{n}` takes no generator request (`generate`) — hand it a data payload"
),
Self::BadRequest(n, why) => write!(f, "component `{n}` refused the generator request: {why}"),
}
}
}
impl std::error::Error for EmbedError {}
/// One wrapped component.
pub struct Entry {
pub name: &'static str,
pub description: &'static str,
/// The registry namespace (see the `GRAPH`/`MAP`/`DATA`/`OPS`/`UI` consts). The
/// `name` stays flat and stable — hosts address components by it; the group is
/// additive metadata for presentation.
pub group: &'static str,
fixture: fn() -> Box<dyn Facet>,
data: Option<DataFactory>,
generator: Option<Generator>,
}
impl Entry {
/// A fixture-only entry (no data seam).
#[must_use]
pub const fn new(
name: &'static str,
description: &'static str,
fixture: fn() -> Box<dyn Facet>,
) -> Self {
Self { name, description, group: UI, fixture, data: None, generator: None }
}
/// Builder: place this entry in a namespace.
#[must_use]
pub const fn in_group(mut self, group: &'static str) -> Self {
self.group = group;
self
}
/// Builder: attach the data seam that makes this component render caller data.
#[must_use]
pub const fn with_data(mut self, data: DataFactory) -> Self {
self.data = Some(data);
self
}
/// Build this component's **fixture** (explicitly asked for; never a fallback).
#[must_use]
pub fn build(&self) -> Box<dyn Facet> {
(self.fixture)()
}
/// Builder: attach the generator seam (`{"generate": …}`), its node ceiling and the
/// sources it offers.
#[must_use]
pub const fn with_generator(mut self, max: usize, sources: &'static [facett_graphgen::Source], factory: GenFactory) -> Self {
self.generator = Some(Generator { max, sources, factory });
self
}
/// Whether this component accepts a caller payload (data or a generator request).
#[must_use]
pub const fn accepts_data(&self) -> bool {
self.data.is_some() || self.generator.is_some()
}
/// The generator seam, when this component has one.
#[must_use]
pub const fn generator(&self) -> Option<&Generator> {
self.generator.as_ref()
}
/// Build this component **from live data**, or refuse with a typed [`EmbedError`].
/// There is deliberately no fixture fallback on this path.
///
/// A payload carrying `generate` is a generator request: it goes to the generator
/// seam (the size clamped to the component's ceiling), never to the data factory.
pub fn build_with(&self, data: &serde_json::Value) -> Result<Box<dyn Facet>, EmbedError> {
if let Some(req) = data.get("generate") {
let g = self.generator.ok_or(EmbedError::NotGenerated(self.name))?;
let req = facett_graphgen::GenRequest::parse(req).map_err(|why| EmbedError::BadRequest(self.name, why))?;
if !g.sources.contains(&req.source) {
let offered: Vec<&str> = g.sources.iter().map(|s| s.key()).collect();
return Err(EmbedError::BadRequest(
self.name,
format!("source `{}` is not offered here (offered: {})", req.source.key(), offered.join(", ")),
));
}
let req = facett_graphgen::GenRequest { size: req.size.clamp(1, g.max), ..req };
return (g.factory)(req).ok_or(EmbedError::EmptyPayload(self.name));
}
let factory = self.data.ok_or(EmbedError::NotDataDriven(self.name))?;
factory(data).ok_or(EmbedError::EmptyPayload(self.name))
}
}
/// The set of wrapped facett components.
pub struct Registry {
entries: Vec<Entry>,
}
impl Registry {
/// An empty registry.
#[must_use]
pub fn empty() -> Self {
Self { entries: Vec::new() }
}
/// Add an entry.
pub fn register(&mut self, entry: Entry) -> &mut Self {
self.entries.push(entry);
self
}
/// The **built-in registry** — every wrapped facett component.
#[must_use]
#[allow(unexpected_cfgs)] pub fn with_builtins() -> Self {
let mut r = Self::empty();
#[cfg(not(feature = "group-only"))] crate::tabular::register(&mut r);
#[cfg(not(feature = "group-only"))] crate::map::register(&mut r);
#[cfg(not(feature = "group-only"))] crate::graph::register(&mut r);
#[cfg(not(feature = "group-only"))] crate::ops::register(&mut r);
#[cfg(not(feature = "group-only"))] crate::forge::register(&mut r);
#[cfg(not(feature = "group-only"))] crate::chrome::register(&mut r);
#[cfg(not(feature = "group-only"))] crate::charts::register(&mut r); #[cfg(feature = "docs")] crate::docs::register(&mut r); #[cfg(feature = "model3d")] crate::model::register(&mut r); #[cfg(feature = "sim")] crate::sim::register(&mut r);
r
}
/// Every registered name, in registration order.
#[must_use]
pub fn names(&self) -> Vec<&'static str> {
self.entries.iter().map(|e| e.name).collect()
}
/// Names of the components that accept caller data.
#[must_use]
pub fn data_driven_names(&self) -> Vec<&'static str> {
self.entries.iter().filter(|e| e.accepts_data()).map(|e| e.name).collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Look up an entry by name.
#[must_use]
pub fn get(&self, name: &str) -> Option<&Entry> {
self.entries.iter().find(|e| e.name == name)
}
/// Build component `name`'s fixture (`None` if unknown).
#[must_use]
pub fn build(&self, name: &str) -> Option<Box<dyn Facet>> {
self.get(name).map(Entry::build)
}
/// Build component `name` **from live data**, or refuse with a typed error naming
/// exactly why (unknown / fixture-only / nothing renderable).
pub fn build_with(
&self,
name: &str,
data: &serde_json::Value,
) -> Result<Box<dyn Facet>, EmbedError> {
self.get(name)
.ok_or_else(|| EmbedError::UnknownComponent(name.to_string()))?
.build_with(data)
}
/// **Pre-flight**: can `name` render `data`? `Ok(())` when yes. A host calls this
/// before mounting so it can show the reason instead of a canvas.
pub fn check(&self, name: &str, data: &serde_json::Value) -> Result<(), EmbedError> {
self.build_with(name, data).map(|_| ())
}
/// The **manifest**: `[{ name, description, group, title, data_driven }]` — what a
/// host enumerates to build a component picker.
#[must_use]
pub fn manifest_json(&self) -> serde_json::Value {
serde_json::Value::Array(
self.entries
.iter()
.map(|e| {
serde_json::json!({
"name": e.name,
"description": e.description,
"group": e.group,
"title": e.build().title().to_string(),
"data_driven": e.accepts_data(),
"generate": e.generator.map(|g| g.manifest_json()),
})
})
.collect(),
)
}
/// The fixture `state_json` of component `name` (`None` if unknown).
#[must_use]
pub fn component_state_json(&self, name: &str) -> Option<serde_json::Value> {
self.build(name).map(|f| f.state_json())
}
}
impl Default for Registry {
fn default() -> Self {
Self::with_builtins()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// The components that MUST accept live data. This is the anti-regression guard:
/// a registry that can only build fixtures cannot show a caller's rows, which is
/// what pushes callers into writing their own renderer.
const DATA_DRIVEN: [&str; 15] = [
// data-bearing
"table", "icebergview", "stat-grid", "plot", "matrix",
// map family
"osm2d", "geomap", "map",
// graph family — all five on the ONE contract
"graphnav", "graph2d", "graph3d", "graphpan",
// the generator seam (lane F): the demo's size + generator tabs
"universe", "decograph", "lineage",
];
#[test]
#[allow(unexpected_cfgs)]
#[cfg(not(feature = "group-only"))]
fn every_component_builds_a_titled_fixture_with_structured_state() {
let r = Registry::with_builtins();
assert!(r.len() >= 18, "the registry wraps the components hosts actually use");
for name in r.names() {
let f = r.build(name).unwrap_or_else(|| panic!("{name} builds"));
assert!(!f.title().is_empty(), "{name} has a title");
let st = r.component_state_json(name).unwrap();
assert!(st.is_object() || st.is_array(), "{name} yields structured state");
}
// names are unique
let mut n = r.names();
n.sort_unstable();
n.dedup();
assert_eq!(n.len(), r.len(), "component names are unique");
}
#[test]
fn manifest_is_well_formed_and_advertises_the_data_seam() {
let r = Registry::with_builtins();
let m = r.manifest_json();
let arr = m.as_array().unwrap();
assert_eq!(arr.len(), r.len());
for e in arr {
assert!(e["name"].as_str().is_some());
assert!(e["description"].as_str().is_some());
assert!(e["title"].as_str().is_some());
assert!(e["data_driven"].is_boolean());
let g = e["group"].as_str().expect("group");
assert!([GRAPH, MAP, DATA, OPS, UI].contains(&g), "{g} is a known namespace");
}
}
#[test]
#[allow(unexpected_cfgs)]
#[cfg(not(feature = "group-only"))]
fn the_documented_data_driven_set_is_data_driven() {
let r = Registry::with_builtins();
for name in DATA_DRIVEN {
let e = r.get(name).unwrap_or_else(|| panic!("{name} is registered"));
assert!(e.accepts_data(), "{name} must accept a caller payload");
assert!(r.data_driven_names().contains(&name));
}
}
/// **A fixture is never served as live data.** Every refusal is typed and names the
/// reason; none of them quietly returns canned demo data.
#[test]
#[allow(unexpected_cfgs)]
#[cfg(not(feature = "group-only"))]
fn live_build_refuses_loudly_instead_of_faking_it() {
let r = Registry::with_builtins();
let empty = json!({});
// `Box<dyn Facet>` is neither Debug nor PartialEq, so assert on the error side.
let err = |res: Result<Box<dyn Facet>, EmbedError>| res.err().expect("refused");
// data-driven, but nothing renderable
assert_eq!(err(r.build_with("table", &empty)), EmbedError::EmptyPayload("table"));
// fixture-only entry asked for live data → explicit refusal, NOT a fixture
assert_eq!(
err(r.build_with("console", &json!({ "rows": [[1]] }))),
EmbedError::NotDataDriven("console")
);
// unknown name
assert_eq!(
err(r.build_with("nope", &empty)),
EmbedError::UnknownComponent("nope".into())
);
// the message says what a human needs to hear
assert!(EmbedError::NotDataDriven("console")
.to_string()
.contains("must not be shown as live data"));
// the pre-flight agrees with the build
assert!(r.check("console", &empty).is_err());
assert!(r.check("table", &json!({ "columns": ["a"], "rows": [["1"]] })).is_ok());
// the fixture path still exists — but only when explicitly asked for
assert!(r.build("console").is_some());
}
/// The fixture-only set may only ever **shrink**. Adding a data seam is progress;
/// a new fixture-only entry on a data-bearing surface is the regression that makes
/// callers write their own renderer.
#[test]
fn the_fixture_only_set_only_shrinks() {
/// Frozen roster of entries that are STILL fixture-only. Remove names as they
/// gain a data seam; never add one.
const FIXTURE_ONLY: [&str; 7] = [
"helix-tube", "console", "timeline", "log", "tabbar", "plandag", "security",
];
let r = Registry::with_builtins();
let actual: Vec<&str> =
r.names().into_iter().filter(|n| !r.get(n).unwrap().accepts_data()).collect();
for name in &actual {
assert!(
FIXTURE_ONLY.contains(name),
"`{name}` is fixture-only but not on the frozen roster — a data-bearing \
component MUST take live data"
);
}
assert!(
actual.len() <= FIXTURE_ONLY.len(),
"the fixture-only set grew: {actual:?} vs frozen {FIXTURE_ONLY:?}"
);
}
#[test]
#[allow(unexpected_cfgs)]
#[cfg(not(feature = "group-only"))]
fn groups_partition_the_registry_for_presentation() {
let r = Registry::with_builtins();
let graph: Vec<&str> =
r.names().into_iter().filter(|n| r.get(n).unwrap().group == GRAPH).collect();
assert!(graph.len() >= 5, "all five graph surfaces share one namespace: {graph:?}");
let map: Vec<&str> =
r.names().into_iter().filter(|n| r.get(n).unwrap().group == MAP).collect();
assert!(map.len() >= 3, "the map family shares one namespace: {map:?}");
}
}