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}