mecha10-core 0.8.0

Mecha10 core runtime and foundational types
Documentation
//! Link-time self-registration foundation for discovering framework node schema/catalog
//! metadata, without hand-enumerating every built-in node type in Rust `match` arms.
//!
//! # Design
//!
//! Three places in this workspace hand-enumerate every built-in node type today:
//! `mecha10_schema_registry::node_type_schema`, `mecha10_cli_core::component_catalog`'s
//! `Component` catalog, and `packages/cli/src/handlers/add.rs`'s "known framework nodes" list.
//! Every one of those has to be edited by hand every time a new framework node ships.
//!
//! The fix is for each node to register its own schema + catalog metadata at the point it's
//! defined (via the [`inventory`] crate), so a registry/CLI consumer just iterates the
//! collected list instead of naming each type. This module is the foundation only: the
//! [`NodeDescriptor`] type, the `inventory` collection point, and [`register_node!`] -- wired
//! into the `#[derive(Node)]` and [`crate::node_main!`] macros every node crate already uses, so
//! an individual node crate only needs to fill in [`crate::node::NodeImpl::CRATE_NAME`] (and
//! optionally `DESCRIPTION`/`DEFAULT_RUN_TARGET`) to get a registration, with no hand-written
//! `inventory::submit!` call of its own.
//!
//! Nothing in this crate (or anywhere else yet) calls [`registered_nodes`] -- that's a later
//! wave's job, once `mecha10-schema-registry`/`mecha10-cli-core`/the CLI's `add` handler are
//! ready to iterate this instead of their current hand-written per-type dispatch. Until then,
//! this module compiles and (for nodes that opt in via `CRATE_NAME`) registers, but changes no
//! runtime behavior anywhere.
//!
//! ## Why a thunk, not a direct `inventory::submit!` of [`NodeDescriptor`]
//!
//! [`register_node!`] only ever sees a bare type name (`$node_type`) as its macro input --
//! whether that type's `CRATE_NAME`/`NODE_TYPE` associated consts are set is a *value*, not
//! something visible to `macro_rules!` token matching, and it has to work even when invoked
//! from a generic macro (`node_main!`) that's expanded once per node crate, long before any
//! particular crate decides whether to fill those consts in. So the "should this register at
//! all" decision can't be made by choosing whether to emit `inventory::submit!` at macro
//! expansion time -- it has to happen when the submitted thunk is actually evaluated. Hence
//! every [`register_node!`] invocation submits a [`NodeDescriptorThunk`] unconditionally, and
//! that thunk resolves to `None` (contributing no entry to [`registered_nodes`]) exactly when
//! `CRATE_NAME` or `NODE_TYPE` is unset.

use serde_json::Value;

/// Everything a later consumer (schema registry, CLI catalog) needs to know about a registered
/// node, resolved from a [`NodeDescriptorThunk`] that evaluated to `Some`. See the module docs
/// for the overall design and why this is reached via [`registered_nodes`] rather than an
/// `inventory::iter` of this type directly.
pub struct NodeDescriptor {
    /// The node's wire type identifier, e.g. `"camera"`.
    pub node_type: &'static str,
    /// Crate name as it appears in Cargo.toml, e.g. `"mecha10-nodes-camera"`.
    pub crate_name: &'static str,
    /// Human-readable description of what the node does.
    pub description: &'static str,
    /// Default `mecha10 add`/run target for this node.
    pub default_run_target: &'static str,
    /// Free-form catalog search tags, e.g. `&["camera", "video", "streaming"]`.
    pub catalog_tags: &'static [&'static str],
    /// Computes this node's config JSON Schema. A plain `fn` rather than an eagerly-computed
    /// `Value` so schema generation only runs for descriptors a caller actually reads.
    pub schema: fn() -> Value,
}

/// Registration thunk collected via `inventory` by every [`register_node!`] invocation.
///
/// Resolves to `Some(NodeDescriptor)` when the registered type's `CRATE_NAME` and `NODE_TYPE`
/// are both set, `None` otherwise -- see the module docs' "Why a thunk" section for why that
/// check has to live here instead of at macro-expansion time.
#[doc(hidden)]
pub struct NodeDescriptorThunk(pub fn() -> Option<NodeDescriptor>);

inventory::collect!(NodeDescriptorThunk);

/// Every [`NodeDescriptor`] registered so far via [`register_node!`] -- i.e. every collected
/// [`NodeDescriptorThunk`] that resolves to `Some`.
///
/// Nothing calls this yet in this wave; it's the entry point a later wave's
/// `mecha10-schema-registry`/`mecha10-cli-core`/CLI `add` handler will iterate instead of
/// hand-enumerating node types themselves.
pub fn registered_nodes() -> impl Iterator<Item = NodeDescriptor> {
    inventory::iter::<NodeDescriptorThunk>
        .into_iter()
        .filter_map(|thunk| (thunk.0)())
}

/// Build a `serde_json::Value` JSON Schema for `T` via `schemars`.
///
/// Mirrors `mecha10_schema_registry::schema_for` -- duplicated here rather than depended on,
/// since that crate itself depends on `mecha10-core` and the dependency direction can't run the
/// other way.
pub fn json_schema_for<T: schemars::JsonSchema>() -> Value {
    let root_schema = schemars::SchemaGenerator::default().into_root_schema_for::<T>();
    serde_json::to_value(root_schema).expect("schemars Schema always serializes to JSON")
}

/// Register a [`crate::node::NodeImpl`] type's [`NodeDescriptor`] for discovery by a later
/// wave's consumer.
///
/// Wired into `#[derive(Node)]` (`mecha10-macros`) and [`crate::node_main!`], so a node crate
/// gets a registration attempt "for free" the moment it uses either -- it only becomes a
/// *populated* entry once that crate also fills in `NodeImpl::CRATE_NAME` (`NODE_TYPE` is
/// already set by every built-in node). No-op (resolves to no [`registered_nodes`] entry) when
/// `T::CRATE_NAME` is `None` -- the node isn't meant to be scaffolded via `mecha10 add` -- or
/// `T::NODE_TYPE` is `None` -- it's a custom/project-local node, which doesn't need an entry in
/// this registry at all (see `NodeImpl::NODE_TAGS`'s doc comment on how those are discovered
/// instead).
#[macro_export]
macro_rules! register_node {
    ($node_type:ty) => {
        $crate::inventory::submit! {
            $crate::node_registry::NodeDescriptorThunk(|| {
                let crate_name = <$node_type as $crate::node::NodeImpl>::CRATE_NAME?;
                let node_type = <$node_type as $crate::node::NodeImpl>::NODE_TYPE?;
                Some($crate::node_registry::NodeDescriptor {
                    node_type: node_type.as_str(),
                    crate_name,
                    description: <$node_type as $crate::node::NodeImpl>::DESCRIPTION,
                    default_run_target: <$node_type as $crate::node::NodeImpl>::DEFAULT_RUN_TARGET,
                    catalog_tags: <$node_type as $crate::node::NodeImpl>::CATALOG_TAGS,
                    schema: $crate::node_registry::json_schema_for::<
                        <$node_type as $crate::node::NodeImpl>::Config,
                    >,
                })
            })
        }
    };
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::Context;
    use crate::error::Result;
    use crate::node::NodeImpl;
    use async_trait::async_trait;

    #[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)]
    struct UnregisteredConfig;

    #[derive(Debug)]
    struct UnregisteredNode;

    // Uses every `NodeImpl` registration-const default (`CRATE_NAME: None`) -- registering this
    // should contribute no entry to `registered_nodes()`.
    #[async_trait]
    impl NodeImpl for UnregisteredNode {
        type Config = UnregisteredConfig;

        async fn init(_config: Self::Config) -> Result<Self> {
            Ok(Self)
        }

        async fn run(&mut self, _ctx: &Context) -> Result<()> {
            Ok(())
        }
    }

    crate::register_node!(UnregisteredNode);

    #[derive(Debug, Default, serde::Deserialize, schemars::JsonSchema)]
    struct RegisteredConfig {
        #[allow(dead_code)]
        rate_hz: f32,
    }

    #[derive(Debug)]
    struct RegisteredNode;

    #[async_trait]
    impl NodeImpl for RegisteredNode {
        type Config = RegisteredConfig;

        const NODE_TYPE: Option<crate::node::NodeType> = Some(crate::node::NodeType::Camera);
        const CRATE_NAME: Option<&'static str> = Some("mecha10-nodes-fake-for-test");
        const DESCRIPTION: &'static str = "A fake node used only to exercise registration.";
        const CATALOG_TAGS: &'static [&'static str] = &["fake", "test"];

        async fn init(_config: Self::Config) -> Result<Self> {
            Ok(Self)
        }

        async fn run(&mut self, _ctx: &Context) -> Result<()> {
            Ok(())
        }
    }

    crate::register_node!(RegisteredNode);

    #[test]
    fn registers_node_with_crate_name_and_node_type_set() {
        let found = registered_nodes().find(|d| d.crate_name == "mecha10-nodes-fake-for-test");
        let descriptor = found.expect("RegisteredNode should have registered an entry");

        assert_eq!(descriptor.node_type, "camera");
        assert_eq!(
            descriptor.description,
            "A fake node used only to exercise registration."
        );
        assert_eq!(descriptor.default_run_target, "robot");
        assert_eq!(descriptor.catalog_tags, &["fake", "test"]);

        let schema = (descriptor.schema)();
        assert!(schema.is_object(), "schema should serialize to a JSON object");
    }

    #[test]
    fn registered_node_catalog_tags_round_trip_through_register_node_macro() {
        let found = registered_nodes().find(|d| d.crate_name == "mecha10-nodes-fake-for-test");
        let descriptor = found.expect("RegisteredNode should have registered an entry");

        assert_eq!(
            descriptor.catalog_tags,
            RegisteredNode::CATALOG_TAGS,
            "registered descriptor's catalog_tags should match the NodeImpl's CATALOG_TAGS const"
        );
    }

    #[test]
    fn registers_exactly_once_even_though_an_unregistered_node_also_called_the_macro() {
        // `UnregisteredNode` above also calls `register_node!`, but leaves `CRATE_NAME` at its
        // `None` default, so it must not add a second (or any) entry.
        let matches = registered_nodes()
            .filter(|d| d.crate_name == "mecha10-nodes-fake-for-test")
            .count();
        assert_eq!(matches, 1);
    }
}