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

nordisk/facett

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