Skip to main content

mecha10_core/
node_registry.rs

1//! Link-time self-registration foundation for discovering framework node schema/catalog
2//! metadata, without hand-enumerating every built-in node type in Rust `match` arms.
3//!
4//! # Design
5//!
6//! Three places in this workspace hand-enumerate every built-in node type today:
7//! `mecha10_schema_registry::node_type_schema`, `mecha10_cli_core::component_catalog`'s
8//! `Component` catalog, and `packages/cli/src/handlers/add.rs`'s "known framework nodes" list.
9//! Every one of those has to be edited by hand every time a new framework node ships.
10//!
11//! The fix is for each node to register its own schema + catalog metadata at the point it's
12//! defined (via the [`inventory`] crate), so a registry/CLI consumer just iterates the
13//! collected list instead of naming each type. This module is the foundation only: the
14//! [`NodeDescriptor`] type, the `inventory` collection point, and [`register_node!`] -- wired
15//! into the `#[derive(Node)]` and [`crate::node_main!`] macros every node crate already uses, so
16//! an individual node crate only needs to fill in [`crate::node::NodeImpl::CRATE_NAME`] (and
17//! optionally `DESCRIPTION`/`DEFAULT_RUN_TARGET`) to get a registration, with no hand-written
18//! `inventory::submit!` call of its own.
19//!
20//! Nothing in this crate (or anywhere else yet) calls [`registered_nodes`] -- that's a later
21//! wave's job, once `mecha10-schema-registry`/`mecha10-cli-core`/the CLI's `add` handler are
22//! ready to iterate this instead of their current hand-written per-type dispatch. Until then,
23//! this module compiles and (for nodes that opt in via `CRATE_NAME`) registers, but changes no
24//! runtime behavior anywhere.
25//!
26//! ## Why a thunk, not a direct `inventory::submit!` of [`NodeDescriptor`]
27//!
28//! [`register_node!`] only ever sees a bare type name (`$node_type`) as its macro input --
29//! whether that type's `CRATE_NAME`/`NODE_TYPE` associated consts are set is a *value*, not
30//! something visible to `macro_rules!` token matching, and it has to work even when invoked
31//! from a generic macro (`node_main!`) that's expanded once per node crate, long before any
32//! particular crate decides whether to fill those consts in. So the "should this register at
33//! all" decision can't be made by choosing whether to emit `inventory::submit!` at macro
34//! expansion time -- it has to happen when the submitted thunk is actually evaluated. Hence
35//! every [`register_node!`] invocation submits a [`NodeDescriptorThunk`] unconditionally, and
36//! that thunk resolves to `None` (contributing no entry to [`registered_nodes`]) exactly when
37//! `CRATE_NAME` or `NODE_TYPE` is unset.
38
39use serde_json::Value;
40
41/// Everything a later consumer (schema registry, CLI catalog) needs to know about a registered
42/// node, resolved from a [`NodeDescriptorThunk`] that evaluated to `Some`. See the module docs
43/// for the overall design and why this is reached via [`registered_nodes`] rather than an
44/// `inventory::iter` of this type directly.
45pub struct NodeDescriptor {
46    /// The node's wire type identifier, e.g. `"camera"`.
47    pub node_type: &'static str,
48    /// Crate name as it appears in Cargo.toml, e.g. `"mecha10-nodes-camera"`.
49    pub crate_name: &'static str,
50    /// Human-readable description of what the node does.
51    pub description: &'static str,
52    /// Default `mecha10 add`/run target for this node.
53    pub default_run_target: &'static str,
54    /// Free-form catalog search tags, e.g. `&["camera", "video", "streaming"]`.
55    pub catalog_tags: &'static [&'static str],
56    /// Computes this node's config JSON Schema. A plain `fn` rather than an eagerly-computed
57    /// `Value` so schema generation only runs for descriptors a caller actually reads.
58    pub schema: fn() -> Value,
59}
60
61/// Registration thunk collected via `inventory` by every [`register_node!`] invocation.
62///
63/// Resolves to `Some(NodeDescriptor)` when the registered type's `CRATE_NAME` and `NODE_TYPE`
64/// are both set, `None` otherwise -- see the module docs' "Why a thunk" section for why that
65/// check has to live here instead of at macro-expansion time.
66#[doc(hidden)]
67pub struct NodeDescriptorThunk(pub fn() -> Option<NodeDescriptor>);
68
69inventory::collect!(NodeDescriptorThunk);
70
71/// Every [`NodeDescriptor`] registered so far via [`register_node!`] -- i.e. every collected
72/// [`NodeDescriptorThunk`] that resolves to `Some`.
73///
74/// Nothing calls this yet in this wave; it's the entry point a later wave's
75/// `mecha10-schema-registry`/`mecha10-cli-core`/CLI `add` handler will iterate instead of
76/// hand-enumerating node types themselves.
77pub fn registered_nodes() -> impl Iterator<Item = NodeDescriptor> {
78    inventory::iter::<NodeDescriptorThunk>
79        .into_iter()
80        .filter_map(|thunk| (thunk.0)())
81}
82
83/// Build a `serde_json::Value` JSON Schema for `T` via `schemars`.
84///
85/// Mirrors `mecha10_schema_registry::schema_for` -- duplicated here rather than depended on,
86/// since that crate itself depends on `mecha10-core` and the dependency direction can't run the
87/// other way.
88pub fn json_schema_for<T: schemars::JsonSchema>() -> Value {
89    let root_schema = schemars::SchemaGenerator::default().into_root_schema_for::<T>();
90    serde_json::to_value(root_schema).expect("schemars Schema always serializes to JSON")
91}
92
93/// Register a [`crate::node::NodeImpl`] type's [`NodeDescriptor`] for discovery by a later
94/// wave's consumer.
95///
96/// Wired into `#[derive(Node)]` (`mecha10-macros`) and [`crate::node_main!`], so a node crate
97/// gets a registration attempt "for free" the moment it uses either -- it only becomes a
98/// *populated* entry once that crate also fills in `NodeImpl::CRATE_NAME` (`NODE_TYPE` is
99/// already set by every built-in node). No-op (resolves to no [`registered_nodes`] entry) when
100/// `T::CRATE_NAME` is `None` -- the node isn't meant to be scaffolded via `mecha10 add` -- or
101/// `T::NODE_TYPE` is `None` -- it's a custom/project-local node, which doesn't need an entry in
102/// this registry at all (see `NodeImpl::NODE_TAGS`'s doc comment on how those are discovered
103/// instead).
104#[macro_export]
105macro_rules! register_node {
106    ($node_type:ty) => {
107        $crate::inventory::submit! {
108            $crate::node_registry::NodeDescriptorThunk(|| {
109                let crate_name = <$node_type as $crate::node::NodeImpl>::CRATE_NAME?;
110                let node_type = <$node_type as $crate::node::NodeImpl>::NODE_TYPE?;
111                Some($crate::node_registry::NodeDescriptor {
112                    node_type: node_type.as_str(),
113                    crate_name,
114                    description: <$node_type as $crate::node::NodeImpl>::DESCRIPTION,
115                    default_run_target: <$node_type as $crate::node::NodeImpl>::DEFAULT_RUN_TARGET,
116                    catalog_tags: <$node_type as $crate::node::NodeImpl>::CATALOG_TAGS,
117                    schema: $crate::node_registry::json_schema_for::<
118                        <$node_type as $crate::node::NodeImpl>::Config,
119                    >,
120                })
121            })
122        }
123    };
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::context::Context;
130    use crate::error::Result;
131    use crate::node::NodeImpl;
132    use async_trait::async_trait;
133
134    #[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)]
135    struct UnregisteredConfig;
136
137    #[derive(Debug)]
138    struct UnregisteredNode;
139
140    // Uses every `NodeImpl` registration-const default (`CRATE_NAME: None`) -- registering this
141    // should contribute no entry to `registered_nodes()`.
142    #[async_trait]
143    impl NodeImpl for UnregisteredNode {
144        type Config = UnregisteredConfig;
145
146        async fn init(_config: Self::Config) -> Result<Self> {
147            Ok(Self)
148        }
149
150        async fn run(&mut self, _ctx: &Context) -> Result<()> {
151            Ok(())
152        }
153    }
154
155    crate::register_node!(UnregisteredNode);
156
157    #[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)]
158    struct RegisteredConfig {
159        #[allow(dead_code)]
160        rate_hz: f32,
161    }
162
163    #[derive(Debug)]
164    struct RegisteredNode;
165
166    #[async_trait]
167    impl NodeImpl for RegisteredNode {
168        type Config = RegisteredConfig;
169
170        const NODE_TYPE: Option<crate::node::NodeType> = Some(crate::node::NodeType::Camera);
171        const CRATE_NAME: Option<&'static str> = Some("mecha10-nodes-fake-for-test");
172        const DESCRIPTION: &'static str = "A fake node used only to exercise registration.";
173        const CATALOG_TAGS: &'static [&'static str] = &["fake", "test"];
174
175        async fn init(_config: Self::Config) -> Result<Self> {
176            Ok(Self)
177        }
178
179        async fn run(&mut self, _ctx: &Context) -> Result<()> {
180            Ok(())
181        }
182    }
183
184    crate::register_node!(RegisteredNode);
185
186    #[test]
187    fn registers_node_with_crate_name_and_node_type_set() {
188        let found = registered_nodes().find(|d| d.crate_name == "mecha10-nodes-fake-for-test");
189        let descriptor = found.expect("RegisteredNode should have registered an entry");
190
191        assert_eq!(descriptor.node_type, "camera");
192        assert_eq!(
193            descriptor.description,
194            "A fake node used only to exercise registration."
195        );
196        assert_eq!(descriptor.default_run_target, "robot");
197        assert_eq!(descriptor.catalog_tags, &["fake", "test"]);
198
199        let schema = (descriptor.schema)();
200        assert!(schema.is_object(), "schema should serialize to a JSON object");
201    }
202
203    #[test]
204    fn registered_node_catalog_tags_round_trip_through_register_node_macro() {
205        let found = registered_nodes().find(|d| d.crate_name == "mecha10-nodes-fake-for-test");
206        let descriptor = found.expect("RegisteredNode should have registered an entry");
207
208        assert_eq!(
209            descriptor.catalog_tags,
210            RegisteredNode::CATALOG_TAGS,
211            "registered descriptor's catalog_tags should match the NodeImpl's CATALOG_TAGS const"
212        );
213    }
214
215    #[test]
216    fn registers_exactly_once_even_though_an_unregistered_node_also_called_the_macro() {
217        // `UnregisteredNode` above also calls `register_node!`, but leaves `CRATE_NAME` at its
218        // `None` default, so it must not add a second (or any) entry.
219        let matches = registered_nodes()
220            .filter(|d| d.crate_name == "mecha10-nodes-fake-for-test")
221            .count();
222        assert_eq!(matches, 1);
223    }
224}