Skip to main content

nros/
node.rs

1//! Rust component API shared by metadata discovery and generated runtimes.
2
3use core::marker::PhantomData;
4
5use crate::{
6    ActionTag, CallbackId, CancelResponse, EntityId, GoalId, GoalResponse, GoalStatus,
7    ParameterType, QosSettings, RosAction, RosMessage, RosService, ServiceTag, SubscriptionTag,
8    TimerDuration,
9    heapless::Vec,
10    node_metadata::{
11        CallbackEffectKind, CallbackEffectMetadata, CallbackSlot, EntityKind, EntityMetadata,
12        EntityMetadataSpec, EntitySlot, MetadataRecorder, MetadataString, NodeId,
13        NodeMetadataError, NodeSlot, ParameterDefault, SourceLocationMetadata, copy_str,
14        entity_callback_ids, entity_metadata,
15    },
16};
17
18// Phase 212.N.7 step-6 closing sweep — `component_register_symbol`
19// removed. It built the legacy `__nros_component_<pkg>_register`
20// symbol name for the M.5.a BSP baker to look up by literal. step-6
21// retired the macro emit + step-4 deleted the FreeRTOS BSP baker
22// crate that was the sole live consumer. The Phase 212.N Entry pkg
23// path calls `<pkg>::register(runtime)` through the path API, so this
24// helper has no live callers.
25
26/// Clear diagnostic for packages missing [`nros::node!`](crate::node!).
27pub const MISSING_NODE_EXPORT_ERROR: &str = "package has no exported nros component";
28
29/// Result type for component declarations.
30pub type NodeResult<T = ()> = Result<T, NodeDeclError>;
31
32/// Node declaration error.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum NodeDeclError {
35    /// Metadata recorder rejected the declaration.
36    Metadata(NodeMetadataError),
37    /// Host/runtime discovery could not find `nros::node!` export.
38    MissingExport,
39    /// Generated runtime rejected the declaration.
40    Runtime,
41}
42
43impl NodeDeclError {
44    /// Human-readable static message for diagnostics that cross FFI/plugin boundaries.
45    pub const fn message(self) -> &'static str {
46        match self {
47            Self::Metadata(NodeMetadataError::Capacity) => "component metadata capacity exceeded",
48            Self::Metadata(NodeMetadataError::NameTooLong) => "component metadata name too long",
49            Self::Metadata(NodeMetadataError::UnknownNode) => {
50                "component entity references an unknown node"
51            }
52            Self::Metadata(NodeMetadataError::UnknownEntity) => {
53                "component callback effect references an unknown entity"
54            }
55            Self::Metadata(NodeMetadataError::DuplicateId) => {
56                "component metadata contains a duplicate stable ID"
57            }
58            Self::MissingExport => MISSING_NODE_EXPORT_ERROR,
59            Self::Runtime => "component runtime rejected declaration",
60        }
61    }
62}
63
64impl From<NodeMetadataError> for NodeDeclError {
65    fn from(value: NodeMetadataError) -> Self {
66        Self::Metadata(value)
67    }
68}
69
70/// Rust component entry point.
71pub trait Node {
72    /// Source component name used in metadata and diagnostics.
73    const NAME: &'static str;
74
75    /// Phase 216.A.3 — declares which dispatch strategy this Node
76    /// requires from the runtime. Defaults to
77    /// [`crate::DispatchStrategy::Inline`] so every existing component
78    /// keeps compiling without source change; the substrate (Phase
79    /// 216.A.2) and `nros check` (Phase 216.D.1) consume it to
80    /// pick / validate the board-side dispatch path.
81    const DISPATCH: crate::DispatchStrategy = crate::DispatchStrategy::Inline;
82
83    /// Declare nodes, entities, callbacks, params, and optional effects.
84    fn register(context: &mut NodeContext<'_>) -> NodeResult<()>;
85}
86
87/// Runtime-neutral node construction options.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct NodeOptions<'a> {
90    /// Source node name. Launch planning may remap/namespace later.
91    pub name: &'a str,
92    /// Source namespace. Defaults to `/`.
93    pub namespace: &'a str,
94    /// ROS domain ID hint. Defaults to `0`.
95    pub domain_id: u32,
96}
97
98/// Runtime callback event delivered to an executable Node.
99///
100/// The value carries the source callback name declared by the component, but
101/// does not expose the generated/internal callback ID type to product code.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub struct Callback<'a> {
104    id: CallbackId<'a>,
105}
106
107impl<'a> Callback<'a> {
108    /// Borrow the source callback name.
109    pub const fn as_str(self) -> &'a str {
110        self.id.as_str()
111    }
112
113    /// Return true when this callback matches `name`.
114    pub fn is_named(self, name: &str) -> bool {
115        self.as_str() == name
116    }
117
118    /// Build a callback event from the internal/generated callback ID.
119    #[doc(hidden)]
120    pub const fn __from_id(id: CallbackId<'a>) -> Self {
121        Self { id }
122    }
123}
124
125impl<'a> NodeOptions<'a> {
126    /// Create node options with default namespace and domain.
127    pub const fn new(name: &'a str) -> Self {
128        Self {
129            name,
130            namespace: "/",
131            domain_id: 0,
132        }
133    }
134
135    /// Set source namespace.
136    pub const fn namespace(mut self, namespace: &'a str) -> Self {
137        self.namespace = namespace;
138        self
139    }
140
141    /// Set ROS domain ID hint.
142    pub const fn domain_id(mut self, domain_id: u32) -> Self {
143        self.domain_id = domain_id;
144        self
145    }
146}
147
148/// Declaration sink implemented by metadata recorders and generated runtimes.
149pub trait NodeRuntime {
150    /// Declare a component node.
151    fn create_node(&mut self, id: NodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()>;
152
153    /// Declare a publisher, subscription, timer, service, action, or parameter.
154    fn create_entity(&mut self, metadata: EntityMetadata) -> NodeResult<()>;
155
156    /// Add optional callback effect metadata.
157    fn record_callback_effect(
158        &mut self,
159        callback_id: CallbackId<'_>,
160        kind: CallbackEffectKind,
161        entity_id: EntityId<'_>,
162    ) -> NodeResult<()>;
163}
164
165impl<const MAX_NODES: usize, const MAX_ENTITIES: usize, const MAX_CALLBACKS: usize> NodeRuntime
166    for MetadataRecorder<MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
167{
168    fn create_node(&mut self, id: NodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()> {
169        self.push_node(id, options.name, options.namespace, options.domain_id)?;
170        Ok(())
171    }
172
173    fn create_entity(&mut self, metadata: EntityMetadata) -> NodeResult<()> {
174        self.push_entity(metadata)?;
175        Ok(())
176    }
177
178    fn record_callback_effect(
179        &mut self,
180        callback_id: CallbackId<'_>,
181        kind: CallbackEffectKind,
182        entity_id: EntityId<'_>,
183    ) -> NodeResult<()> {
184        self.push_callback_effect(callback_id, kind, entity_id)?;
185        Ok(())
186    }
187}
188
189/// Runtime node sink used by generated component executors.
190///
191/// Metadata mode records declarations only. Runtime mode maps each stable
192/// component node ID to a concrete executor-side node handle; entity callback
193/// registration is completed by generated code that owns the actual callback
194/// functions.
195pub trait DeclaredNodeRuntime {
196    /// Concrete node handle owned by the runtime executor.
197    type NodeHandle: Copy + Eq;
198
199    /// Create a runtime node from source-level component options.
200    fn build_component_node(
201        &mut self,
202        id: NodeId<'_>,
203        options: NodeOptions<'_>,
204    ) -> NodeResult<Self::NodeHandle>;
205}
206
207/// Recorded runtime node mapping.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct RuntimeNodeRecord<H: Copy + Eq> {
210    slot: NodeSlot,
211    stable_id: MetadataString,
212    source_default_name: MetadataString,
213    handle: H,
214}
215
216impl<H: Copy + Eq> RuntimeNodeRecord<H> {
217    /// Declaration-order node slot.
218    pub const fn slot(&self) -> NodeSlot {
219        self.slot
220    }
221
222    /// Stable component node ID.
223    pub fn stable_id(&self) -> &str {
224        &self.stable_id
225    }
226
227    /// Source-authored default ROS node name.
228    pub fn source_default_name(&self) -> &str {
229        &self.source_default_name
230    }
231
232    /// Runtime executor node handle.
233    pub const fn handle(&self) -> H {
234        self.handle
235    }
236}
237
238/// Runtime adapter used by generated main ownership code.
239pub struct NodeRuntimeAdapter<
240    'a,
241    R: DeclaredNodeRuntime + ?Sized,
242    const MAX_NODES: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_NODES },
243    const MAX_ENTITIES: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_ENTITIES },
244    const MAX_CALLBACKS: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_CALLBACKS },
245> {
246    node_runtime: &'a mut R,
247    nodes: Vec<RuntimeNodeRecord<R::NodeHandle>, MAX_NODES>,
248    entities: Vec<EntityMetadata, MAX_ENTITIES>,
249    callback_effects: Vec<CallbackEffectMetadata, MAX_CALLBACKS>,
250}
251
252impl<
253    'a,
254    R: DeclaredNodeRuntime + ?Sized,
255    const MAX_NODES: usize,
256    const MAX_ENTITIES: usize,
257    const MAX_CALLBACKS: usize,
258> NodeRuntimeAdapter<'a, R, MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
259{
260    /// Build a runtime adapter around a generated executor owner.
261    pub fn new(node_runtime: &'a mut R) -> Self {
262        Self {
263            node_runtime,
264            nodes: Vec::new(),
265            entities: Vec::new(),
266            callback_effects: Vec::new(),
267        }
268    }
269
270    /// Runtime node mappings in declaration order.
271    pub fn nodes(&self) -> &[RuntimeNodeRecord<R::NodeHandle>] {
272        &self.nodes
273    }
274
275    /// Entity declarations accepted for generated runtime binding.
276    pub fn entities(&self) -> &[EntityMetadata] {
277        &self.entities
278    }
279
280    /// Optional callback effects accepted for generated runtime binding.
281    pub fn callback_effects(&self) -> &[CallbackEffectMetadata] {
282        &self.callback_effects
283    }
284
285    /// Lookup an executor node handle by stable component node ID.
286    pub fn node_handle(&self, stable_id: NodeId<'_>) -> Option<R::NodeHandle> {
287        self.nodes
288            .iter()
289            .find(|node| node.stable_id() == stable_id.as_str())
290            .map(RuntimeNodeRecord::handle)
291    }
292
293    fn contains_node(&self, stable_id: &str) -> bool {
294        self.nodes.iter().any(|node| node.stable_id() == stable_id)
295    }
296
297    fn contains_entity(&self, stable_id: &str) -> bool {
298        self.entities
299            .iter()
300            .any(|entity| entity.id.as_str() == stable_id)
301    }
302
303    fn node_slot_for_id(&self, stable_id: &str) -> Option<NodeSlot> {
304        self.nodes
305            .iter()
306            .find(|node| node.stable_id() == stable_id)
307            .map(RuntimeNodeRecord::slot)
308    }
309
310    fn entity_slot_for_id(&self, stable_id: &str) -> Option<EntitySlot> {
311        self.entities
312            .iter()
313            .find(|entity| entity.id.as_str() == stable_id)
314            .and_then(|entity| entity.slot)
315    }
316
317    fn callback_slot_for_current_entity(
318        &self,
319        id: &str,
320        current_callbacks: &mut Vec<MetadataString, 3>,
321        next_callback_slot: &mut usize,
322    ) -> CallbackSlot {
323        if let Some(slot) = self.callback_slot_for_id(id) {
324            return slot;
325        }
326        if let Some((index, _)) = current_callbacks
327            .iter()
328            .enumerate()
329            .find(|(_, callback_id)| callback_id.as_str() == id)
330        {
331            return CallbackSlot::new(self.callback_slot_count() + index);
332        }
333        let slot = CallbackSlot::new(*next_callback_slot);
334        let _ = current_callbacks
335            .push(copy_str(id).expect("callback ID already fits metadata string capacity"));
336        *next_callback_slot += 1;
337        slot
338    }
339
340    fn callback_slot_for_id(&self, id: &str) -> Option<CallbackSlot> {
341        let mut seen = Vec::<&str, MAX_CALLBACKS>::new();
342        for entity in &self.entities {
343            for callback_id in entity_callback_ids(entity) {
344                let Some(callback_id) = callback_id else {
345                    continue;
346                };
347                let callback_id = callback_id.as_str();
348                if seen.contains(&callback_id) {
349                    continue;
350                }
351                if callback_id == id {
352                    return Some(CallbackSlot::new(seen.len()));
353                }
354                let _ = seen.push(callback_id);
355            }
356        }
357        None
358    }
359
360    fn callback_slot_count(&self) -> usize {
361        let mut seen = Vec::<&str, MAX_CALLBACKS>::new();
362        for entity in &self.entities {
363            for callback_id in entity_callback_ids(entity) {
364                let Some(callback_id) = callback_id else {
365                    continue;
366                };
367                let callback_id = callback_id.as_str();
368                if !seen.contains(&callback_id) {
369                    let _ = seen.push(callback_id);
370                }
371            }
372        }
373        seen.len()
374    }
375}
376
377impl<
378    R: DeclaredNodeRuntime + ?Sized,
379    const MAX_NODES: usize,
380    const MAX_ENTITIES: usize,
381    const MAX_CALLBACKS: usize,
382> NodeRuntime for NodeRuntimeAdapter<'_, R, MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
383{
384    fn create_node(&mut self, id: NodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()> {
385        if self.contains_node(id.as_str()) {
386            return Err(NodeMetadataError::DuplicateId.into());
387        }
388        let handle = self.node_runtime.build_component_node(id, options)?;
389        let slot = NodeSlot::new(self.nodes.len());
390        self.nodes
391            .push(RuntimeNodeRecord {
392                slot,
393                stable_id: copy_str(id.as_str())?,
394                source_default_name: copy_str(options.name)?,
395                handle,
396            })
397            .map_err(|_| NodeDeclError::Metadata(NodeMetadataError::Capacity))?;
398        Ok(())
399    }
400
401    fn create_entity(&mut self, mut metadata: EntityMetadata) -> NodeResult<()> {
402        if !self.contains_node(metadata.node_id.as_str()) {
403            return Err(NodeMetadataError::UnknownNode.into());
404        }
405        if self.contains_entity(metadata.id.as_str()) {
406            return Err(NodeMetadataError::DuplicateId.into());
407        }
408        metadata.slot = Some(EntitySlot::new(self.entities.len()));
409        metadata.node_slot = self.node_slot_for_id(&metadata.node_id);
410        let mut current_callbacks = Vec::<MetadataString, 3>::new();
411        let mut next_callback_slot = self.callback_slot_count();
412        metadata.callback_slot = metadata.callback_id.as_ref().map(|callback_id| {
413            self.callback_slot_for_current_entity(
414                callback_id.as_str(),
415                &mut current_callbacks,
416                &mut next_callback_slot,
417            )
418        });
419        metadata.action_cancel_callback_slot =
420            metadata
421                .action_cancel_callback_id
422                .as_ref()
423                .map(|callback_id| {
424                    self.callback_slot_for_current_entity(
425                        callback_id.as_str(),
426                        &mut current_callbacks,
427                        &mut next_callback_slot,
428                    )
429                });
430        metadata.action_accepted_callback_slot =
431            metadata
432                .action_accepted_callback_id
433                .as_ref()
434                .map(|callback_id| {
435                    self.callback_slot_for_current_entity(
436                        callback_id.as_str(),
437                        &mut current_callbacks,
438                        &mut next_callback_slot,
439                    )
440                });
441        self.entities
442            .push(metadata)
443            .map_err(|_| NodeDeclError::Metadata(NodeMetadataError::Capacity))?;
444        Ok(())
445    }
446
447    fn record_callback_effect(
448        &mut self,
449        callback_id: CallbackId<'_>,
450        kind: CallbackEffectKind,
451        entity_id: EntityId<'_>,
452    ) -> NodeResult<()> {
453        if !self.contains_entity(entity_id.as_str()) {
454            return Err(NodeMetadataError::UnknownEntity.into());
455        }
456        self.callback_effects
457            .push(CallbackEffectMetadata {
458                callback_id: copy_str(callback_id.as_str())?,
459                callback_slot: self.callback_slot_for_id(callback_id.as_str()),
460                kind,
461                entity_id: copy_str(entity_id.as_str())?,
462                entity_slot: self.entity_slot_for_id(entity_id.as_str()),
463            })
464            .map_err(|_| NodeDeclError::Metadata(NodeMetadataError::Capacity))?;
465        Ok(())
466    }
467}
468
469#[cfg(feature = "rmw-cffi")]
470impl DeclaredNodeRuntime for crate::Executor {
471    type NodeHandle = nros_node::executor::NodeId;
472
473    fn build_component_node(
474        &mut self,
475        _id: NodeId<'_>,
476        options: NodeOptions<'_>,
477    ) -> NodeResult<Self::NodeHandle> {
478        self.node_builder(options.name)
479            .namespace(options.namespace)
480            .domain_id(options.domain_id)
481            .build()
482            .map_err(|_| NodeDeclError::Runtime)
483    }
484}
485
486/// Runtime adapter backed by [`Executor`](crate::Executor).
487#[cfg(feature = "rmw-cffi")]
488pub type NodeExecutorRuntime<
489    'a,
490    const MAX_NODES: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_NODES },
491    const MAX_ENTITIES: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_ENTITIES },
492    const MAX_CALLBACKS: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_CALLBACKS },
493> = NodeRuntimeAdapter<'a, crate::Executor, MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>;
494
495/// Node declaration context. Does not own middleware transport.
496pub struct NodeContext<'a, R: NodeRuntime + ?Sized = dyn NodeRuntime + 'a> {
497    component_name: &'static str,
498    runtime: &'a mut R,
499}
500
501impl<'a, R: NodeRuntime + ?Sized> NodeContext<'a, R> {
502    /// Build a context over a metadata recorder or generated runtime.
503    pub fn new(component_name: &'static str, runtime: &'a mut R) -> Self {
504        Self {
505            component_name,
506            runtime,
507        }
508    }
509
510    /// Source component name.
511    pub const fn component_name(&self) -> &'static str {
512        self.component_name
513    }
514
515    /// Declare a node with an explicit stable node ID.
516    ///
517    /// Generated/internal form; product code should use
518    /// [`create_node`](Self::create_node).
519    #[doc(hidden)]
520    pub fn create_node_with_id<'id>(
521        &mut self,
522        id: NodeId<'id>,
523        options: NodeOptions<'_>,
524    ) -> NodeResult<DeclaredNode<'_, 'id, R>> {
525        self.runtime.create_node(id, options)?;
526        Ok(DeclaredNode {
527            runtime: self.runtime,
528            id,
529            current_group: None,
530        })
531    }
532
533    /// Declare a node using `options.name` as the stable node ID.
534    ///
535    /// This mirrors the common rclcpp/rclrs shape where a node package supplies
536    /// node options and the node name, while nano-ros keeps the generated stable
537    /// ID as internal metadata.
538    pub fn create_node<'id>(
539        &mut self,
540        options: NodeOptions<'id>,
541    ) -> NodeResult<DeclaredNode<'_, 'id, R>> {
542        self.create_node_with_id(NodeId::new(options.name), options)
543    }
544
545    /// Deprecated alias for [`create_node`](Self::create_node).
546    #[deprecated(note = "use create_node(NodeOptions)")]
547    pub fn create_node_with_options<'id>(
548        &mut self,
549        options: NodeOptions<'id>,
550    ) -> NodeResult<DeclaredNode<'_, 'id, R>> {
551        self.create_node(options)
552    }
553
554    /// Record optional effects for a callback not tied to a node wrapper.
555    #[doc(hidden)]
556    pub fn callback<'id>(&mut self, id: CallbackId<'id>) -> CallbackEffects<'_, 'id, R> {
557        CallbackEffects {
558            runtime: self.runtime,
559            id,
560        }
561    }
562}
563
564/// Declared component node.
565pub struct DeclaredNode<'ctx, 'id, R: NodeRuntime + ?Sized = dyn NodeRuntime + 'ctx> {
566    runtime: &'ctx mut R,
567    id: NodeId<'id>,
568    /// Phase 228.C sticky callback-group label. When set (via
569    /// [`callback_group`](Self::callback_group)), every subsequently
570    /// declared entity that does not carry its own group inherits it,
571    /// so the tier filter in the executor can include/exclude the
572    /// callback per the `system.toml` group→tier map. `None` →
573    /// unlabeled (wildcard-eligible).
574    current_group: Option<MetadataString>,
575}
576
577impl<'ctx, 'id, R: NodeRuntime + ?Sized> DeclaredNode<'ctx, 'id, R> {
578    /// Stable node ID.
579    #[doc(hidden)]
580    pub const fn id(&self) -> NodeId<'id> {
581        self.id
582    }
583
584    /// Set the sticky callback-group label applied to every entity
585    /// declared after this call (until changed again). The group is the
586    /// symbolic name the node author exposes; `system.toml` maps it to a
587    /// scheduling tier (RFC-0015). Entities declared while no group is set
588    /// remain unlabeled (wildcard-eligible). Reusing the Phase-216 tag
589    /// string as the group id keeps one identifier per logical callback.
590    #[track_caller]
591    pub fn callback_group(&mut self, group: &str) -> NodeResult<&mut Self> {
592        self.current_group = Some(copy_str(group)?);
593        Ok(self)
594    }
595
596    /// Phase 228.C chokepoint: stamp the sticky group onto the entity
597    /// (when the entity carries no group of its own) before forwarding to
598    /// the runtime. Every `create_*` helper routes its declaration here so
599    /// the label is applied uniformly in one place.
600    fn declare_entity(&mut self, mut metadata: EntityMetadata) -> NodeResult<()> {
601        if metadata.callback_group.is_none() {
602            metadata.callback_group = self.current_group.clone();
603        }
604        self.runtime.create_entity(metadata)
605    }
606
607    /// Declare a publisher with default QoS. Stable publisher ID is required.
608    #[track_caller]
609    #[doc(hidden)]
610    pub fn create_publisher<'entity, M: RosMessage>(
611        &mut self,
612        id: EntityId<'entity>,
613        topic: &str,
614    ) -> NodeResult<NodePublisher<'entity, M>> {
615        self.create_publisher_with_qos::<M>(id, topic, QosSettings::default())
616    }
617
618    /// Declare a publisher using `topic` as the stable entity ID.
619    ///
620    /// Use the explicit [`create_publisher`](Self::create_publisher) form when
621    /// a node declares more than one publisher on the same topic or needs a
622    /// stable metadata ID that differs from the ROS topic name.
623    #[track_caller]
624    pub fn create_publisher_for_topic<'entity, M: RosMessage>(
625        &mut self,
626        topic: &'entity str,
627    ) -> NodeResult<NodePublisher<'entity, M>> {
628        self.create_publisher_for_topic_with_qos::<M>(topic, QosSettings::default())
629    }
630
631    /// Declare a publisher with explicit QoS, using `topic` as the stable entity ID.
632    #[track_caller]
633    pub fn create_publisher_for_topic_with_qos<'entity, M: RosMessage>(
634        &mut self,
635        topic: &'entity str,
636        qos: QosSettings,
637    ) -> NodeResult<NodePublisher<'entity, M>> {
638        self.create_publisher_with_qos::<M>(EntityId::new(topic), topic, qos)
639    }
640
641    /// Declare a publisher with explicit QoS.
642    #[track_caller]
643    #[doc(hidden)]
644    pub fn create_publisher_with_qos<'entity, M: RosMessage>(
645        &mut self,
646        id: EntityId<'entity>,
647        topic: &str,
648        qos: QosSettings,
649    ) -> NodeResult<NodePublisher<'entity, M>> {
650        let mut metadata = entity_metadata(EntityMetadataSpec {
651            id,
652            node_id: self.id,
653            kind: EntityKind::Publisher,
654            source_name: topic,
655            type_name: M::TYPE_NAME,
656            type_hash: M::TYPE_HASH,
657            qos,
658        })?;
659        metadata.source = SourceLocationMetadata::caller()?;
660        self.declare_entity(metadata)?;
661        Ok(NodePublisher::new(id))
662    }
663
664    /// Declare a subscription. Stable subscription and callback IDs are required.
665    #[track_caller]
666    #[doc(hidden)]
667    pub fn create_subscription<'entity, 'callback, M: RosMessage>(
668        &mut self,
669        id: EntityId<'entity>,
670        callback_id: CallbackId<'callback>,
671        topic: &str,
672    ) -> NodeResult<NodeSubscription<'entity, M>> {
673        self.create_subscription_with_qos::<M>(id, callback_id, topic, QosSettings::default())
674    }
675
676    /// Declare a subscription using `callback_id` as the stable entity ID.
677    ///
678    /// Generated/internal form; product code should use
679    /// [`create_subscription_for_callback_name`](Self::create_subscription_for_callback_name).
680    #[track_caller]
681    #[doc(hidden)]
682    pub fn create_subscription_for_callback<'callback, M: RosMessage>(
683        &mut self,
684        callback_id: CallbackId<'callback>,
685        topic: &str,
686    ) -> NodeResult<NodeSubscription<'callback, M>> {
687        self.create_subscription_for_callback_with_qos::<M>(
688            callback_id,
689            topic,
690            QosSettings::default(),
691        )
692    }
693
694    /// Declare a subscription using `callback_name` as the source callback
695    /// name and synthesized entity ID.
696    #[track_caller]
697    pub fn create_subscription_for_callback_name<'callback, M: RosMessage>(
698        &mut self,
699        callback_name: &'callback str,
700        topic: &str,
701    ) -> NodeResult<NodeSubscription<'callback, M>> {
702        self.create_subscription_for_callback::<M>(CallbackId::new(callback_name), topic)
703    }
704
705    /// Phase 250 (Wave 2b) — declare a subscription with E2E message-integrity
706    /// validation enabled (the declarative `.safety()` opt-in). Identical to
707    /// [`create_subscription_for_callback_name`](Self::create_subscription_for_callback_name)
708    /// but flags the entity so the runtime registers it via
709    /// `create_generic_subscription_with_integrity`; the callback then reads
710    /// [`CallbackCtx::integrity`](CallbackCtx::integrity) alongside the message.
711    /// The config-driven `[safety]` axis (Wave 4 codegen) emits this call; it is
712    /// also usable by hand. Ungated — when `safety-e2e` is off the flag is simply
713    /// ignored and the subscription registers as a basic one.
714    #[track_caller]
715    pub fn create_subscription_for_callback_name_with_safety<'callback, M: RosMessage>(
716        &mut self,
717        callback_name: &'callback str,
718        topic: &str,
719    ) -> NodeResult<NodeSubscription<'callback, M>> {
720        let callback_id = CallbackId::new(callback_name);
721        let id = EntityId::new(callback_id.as_str());
722        let mut metadata = entity_metadata(EntityMetadataSpec {
723            id,
724            node_id: self.id,
725            kind: EntityKind::Subscription,
726            source_name: topic,
727            type_name: M::TYPE_NAME,
728            type_hash: M::TYPE_HASH,
729            qos: QosSettings::default(),
730        })?;
731        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
732        metadata.callback_source = SourceLocationMetadata::caller()?;
733        metadata.source = metadata.callback_source.clone();
734        metadata.safety = true;
735        self.declare_entity(metadata)?;
736        Ok(NodeSubscription::new(id))
737    }
738
739    /// Declare a subscription with explicit QoS, using `callback_id` as the stable entity ID.
740    #[track_caller]
741    #[doc(hidden)]
742    pub fn create_subscription_for_callback_with_qos<'callback, M: RosMessage>(
743        &mut self,
744        callback_id: CallbackId<'callback>,
745        topic: &str,
746        qos: QosSettings,
747    ) -> NodeResult<NodeSubscription<'callback, M>> {
748        self.create_subscription_with_qos::<M>(
749            EntityId::new(callback_id.as_str()),
750            callback_id,
751            topic,
752            qos,
753        )
754    }
755
756    /// Declare a subscription using `topic` as both the stable entity ID and callback ID.
757    #[track_caller]
758    pub fn create_subscription_for_topic<'entity, M: RosMessage>(
759        &mut self,
760        topic: &'entity str,
761    ) -> NodeResult<NodeSubscription<'entity, M>> {
762        self.create_subscription_for_topic_with_qos::<M>(topic, QosSettings::default())
763    }
764
765    /// Declare a subscription with explicit QoS, using `topic` as both IDs.
766    #[track_caller]
767    pub fn create_subscription_for_topic_with_qos<'entity, M: RosMessage>(
768        &mut self,
769        topic: &'entity str,
770        qos: QosSettings,
771    ) -> NodeResult<NodeSubscription<'entity, M>> {
772        self.create_subscription_with_qos::<M>(
773            EntityId::new(topic),
774            CallbackId::new(topic),
775            topic,
776            qos,
777        )
778    }
779
780    /// Declare a subscription with explicit QoS.
781    #[track_caller]
782    #[doc(hidden)]
783    pub fn create_subscription_with_qos<'entity, 'callback, M: RosMessage>(
784        &mut self,
785        id: EntityId<'entity>,
786        callback_id: CallbackId<'callback>,
787        topic: &str,
788        qos: QosSettings,
789    ) -> NodeResult<NodeSubscription<'entity, M>> {
790        let mut metadata = entity_metadata(EntityMetadataSpec {
791            id,
792            node_id: self.id,
793            kind: EntityKind::Subscription,
794            source_name: topic,
795            type_name: M::TYPE_NAME,
796            type_hash: M::TYPE_HASH,
797            qos,
798        })?;
799        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
800        metadata.callback_source = SourceLocationMetadata::caller()?;
801        metadata.source = metadata.callback_source.clone();
802        self.declare_entity(metadata)?;
803        Ok(NodeSubscription::new(id))
804    }
805
806    /// Declare a subscription whose stable entity and callback IDs are
807    /// both synthesized from the topic literal, returning a
808    /// [`SubscriptionTag`] the Node author stores on `Self::State` and
809    /// matches against the `Callback<'_>` delivered to
810    /// [`ExecutableNode::on_callback`].
811    ///
812    /// Use this on the Phase 216.A Deferred Node path where the Node
813    /// author does not need to invent a separate stable entity ID — the
814    /// topic literal becomes both the entity ID and the callback ID,
815    /// and the returned tag preserves that identifier for compile-time
816    /// `state.sub_chatter == cb` matches in `on_callback`.
817    #[track_caller]
818    pub fn create_subscription_static<M: RosMessage>(
819        &mut self,
820        topic: &'static str,
821    ) -> NodeResult<SubscriptionTag> {
822        let id = EntityId::new(topic);
823        let callback_id = CallbackId::new(topic);
824        let mut metadata = entity_metadata(EntityMetadataSpec {
825            id,
826            node_id: self.id,
827            kind: EntityKind::Subscription,
828            source_name: topic,
829            type_name: M::TYPE_NAME,
830            type_hash: M::TYPE_HASH,
831            qos: QosSettings::default(),
832        })?;
833        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
834        metadata.callback_source = SourceLocationMetadata::caller()?;
835        metadata.source = metadata.callback_source.clone();
836        self.declare_entity(metadata)?;
837        Ok(SubscriptionTag::new(topic))
838    }
839
840    /// Declare a timer. Stable timer and callback IDs are required.
841    #[track_caller]
842    #[doc(hidden)]
843    pub fn create_timer<'entity, 'callback>(
844        &mut self,
845        id: EntityId<'entity>,
846        callback_id: CallbackId<'callback>,
847        period: TimerDuration,
848    ) -> NodeResult<NodeTimer<'entity>> {
849        let mut metadata = entity_metadata(EntityMetadataSpec {
850            id,
851            node_id: self.id,
852            kind: EntityKind::Timer,
853            source_name: "",
854            type_name: "",
855            type_hash: "",
856            qos: QosSettings::default(),
857        })?;
858        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
859        metadata.callback_source = SourceLocationMetadata::caller()?;
860        metadata.source = metadata.callback_source.clone();
861        metadata.period_ms = Some(period.as_millis());
862        self.declare_entity(metadata)?;
863        Ok(NodeTimer::new(id))
864    }
865
866    /// Declare a timer using `callback_id` as the stable timer entity ID.
867    #[track_caller]
868    #[doc(hidden)]
869    pub fn create_timer_for_callback<'callback>(
870        &mut self,
871        callback_id: CallbackId<'callback>,
872        period: TimerDuration,
873    ) -> NodeResult<NodeTimer<'callback>> {
874        self.create_timer(EntityId::new(callback_id.as_str()), callback_id, period)
875    }
876
877    /// Declare a timer using `callback_name` as the source callback name and
878    /// synthesized entity ID.
879    #[track_caller]
880    pub fn create_timer_for_callback_name<'callback>(
881        &mut self,
882        callback_name: &'callback str,
883        period: TimerDuration,
884    ) -> NodeResult<NodeTimer<'callback>> {
885        self.create_timer_for_callback(CallbackId::new(callback_name), period)
886    }
887
888    /// Declare a service server. Stable service and callback IDs are required.
889    #[track_caller]
890    #[doc(hidden)]
891    pub fn create_service_server<'entity, 'callback, S: RosService>(
892        &mut self,
893        id: EntityId<'entity>,
894        callback_id: CallbackId<'callback>,
895        service_name: &str,
896    ) -> NodeResult<NodeServiceServer<'entity, S>> {
897        let mut metadata = entity_metadata(EntityMetadataSpec {
898            id,
899            node_id: self.id,
900            kind: EntityKind::ServiceServer,
901            source_name: service_name,
902            type_name: S::SERVICE_NAME,
903            type_hash: S::SERVICE_HASH,
904            qos: QosSettings::default(),
905        })?;
906        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
907        metadata.callback_source = SourceLocationMetadata::caller()?;
908        metadata.source = metadata.callback_source.clone();
909        self.declare_entity(metadata)?;
910        Ok(NodeServiceServer::new(id))
911    }
912
913    /// Declare a service server using `name` as both the stable entity ID
914    /// and callback ID.
915    #[track_caller]
916    pub fn create_service_server_for_name<'entity, S: RosService>(
917        &mut self,
918        name: &'entity str,
919    ) -> NodeResult<NodeServiceServer<'entity, S>> {
920        self.create_service_server::<S>(EntityId::new(name), CallbackId::new(name), name)
921    }
922
923    /// Declare a service server using `name` as the stable entity ID and
924    /// `callback_name` as the source callback name.
925    #[track_caller]
926    pub fn create_service_server_for_name_with_callback<'entity, S: RosService>(
927        &mut self,
928        name: &'entity str,
929        callback_name: &str,
930    ) -> NodeResult<NodeServiceServer<'entity, S>> {
931        self.create_service_server::<S>(EntityId::new(name), CallbackId::new(callback_name), name)
932    }
933
934    /// Declare a service server whose stable entity and callback IDs are
935    /// both synthesized from the service-name literal, returning a
936    /// [`ServiceTag`] the Node author stores on `Self::State` and matches
937    /// against the `Callback<'_>` delivered to
938    /// [`ExecutableNode::on_callback`].
939    ///
940    /// Tag-only registration is restricted to the SERVER side: clients
941    /// need a USABLE handle (`NodeServiceClient`) to issue requests, so
942    /// use the existing
943    /// [`create_service_client_for_name`](Self::create_service_client_for_name) builder
944    /// for the client side.
945    #[track_caller]
946    pub fn create_service_static<S: RosService>(
947        &mut self,
948        name: &'static str,
949    ) -> NodeResult<ServiceTag> {
950        self.create_service_server_for_name::<S>(name)?;
951        Ok(ServiceTag::new(name))
952    }
953
954    /// Declare a service client. Stable service client ID is required.
955    #[track_caller]
956    #[doc(hidden)]
957    pub fn create_service_client<'entity, S: RosService>(
958        &mut self,
959        id: EntityId<'entity>,
960        service_name: &str,
961    ) -> NodeResult<NodeServiceClient<'entity, S>> {
962        let mut metadata = entity_metadata(EntityMetadataSpec {
963            id,
964            node_id: self.id,
965            kind: EntityKind::ServiceClient,
966            source_name: service_name,
967            type_name: S::SERVICE_NAME,
968            type_hash: S::SERVICE_HASH,
969            qos: QosSettings::default(),
970        })?;
971        metadata.source = SourceLocationMetadata::caller()?;
972        self.declare_entity(metadata)?;
973        Ok(NodeServiceClient::new(id))
974    }
975
976    /// Declare a service client using `name` as the stable entity ID.
977    #[track_caller]
978    pub fn create_service_client_for_name<'entity, S: RosService>(
979        &mut self,
980        name: &'entity str,
981    ) -> NodeResult<NodeServiceClient<'entity, S>> {
982        self.create_service_client::<S>(EntityId::new(name), name)
983    }
984
985    /// Declare an action server. Stable action and callback IDs are required.
986    #[track_caller]
987    #[doc(hidden)]
988    pub fn create_action_server<'entity, 'callback, A: RosAction>(
989        &mut self,
990        id: EntityId<'entity>,
991        callback_id: CallbackId<'callback>,
992        action_name: &str,
993    ) -> NodeResult<NodeActionServer<'entity, A>> {
994        self.create_action_server_with_callbacks::<A>(
995            id,
996            callback_id,
997            callback_id,
998            callback_id,
999            action_name,
1000        )
1001    }
1002
1003    /// Declare an action server with distinct goal/cancel/accepted callbacks.
1004    #[track_caller]
1005    #[doc(hidden)]
1006    pub fn create_action_server_with_callbacks<'entity, 'goal, 'cancel, 'accepted, A: RosAction>(
1007        &mut self,
1008        id: EntityId<'entity>,
1009        goal_callback_id: CallbackId<'goal>,
1010        cancel_callback_id: CallbackId<'cancel>,
1011        accepted_callback_id: CallbackId<'accepted>,
1012        action_name: &str,
1013    ) -> NodeResult<NodeActionServer<'entity, A>> {
1014        let mut metadata = entity_metadata(EntityMetadataSpec {
1015            id,
1016            node_id: self.id,
1017            kind: EntityKind::ActionServer,
1018            source_name: action_name,
1019            type_name: A::ACTION_NAME,
1020            type_hash: A::ACTION_HASH,
1021            qos: QosSettings::default(),
1022        })?;
1023        metadata.callback_id = Some(copy_str(goal_callback_id.as_str())?);
1024        metadata.callback_source = SourceLocationMetadata::caller()?;
1025        metadata.action_cancel_callback_id = Some(copy_str(cancel_callback_id.as_str())?);
1026        metadata.action_cancel_source = metadata.callback_source.clone();
1027        metadata.action_accepted_callback_id = Some(copy_str(accepted_callback_id.as_str())?);
1028        metadata.action_accepted_source = metadata.callback_source.clone();
1029        metadata.source = metadata.callback_source.clone();
1030        self.declare_entity(metadata)?;
1031        Ok(NodeActionServer::new(id))
1032    }
1033
1034    /// Declare an action server using `name` as the stable entity ID and
1035    /// default goal/cancel/accepted callback ID.
1036    #[track_caller]
1037    pub fn create_action_server_for_name<'entity, A: RosAction>(
1038        &mut self,
1039        name: &'entity str,
1040    ) -> NodeResult<NodeActionServer<'entity, A>> {
1041        self.create_action_server::<A>(EntityId::new(name), CallbackId::new(name), name)
1042    }
1043
1044    /// Declare an action server using `name` as the stable entity ID and
1045    /// explicit source callback names for goal, cancel, and accepted events.
1046    #[track_caller]
1047    pub fn create_action_server_for_name_with_callbacks<'entity, A: RosAction>(
1048        &mut self,
1049        name: &'entity str,
1050        goal_callback_name: &str,
1051        cancel_callback_name: &str,
1052        accepted_callback_name: &str,
1053    ) -> NodeResult<NodeActionServer<'entity, A>> {
1054        self.create_action_server_with_callbacks::<A>(
1055            EntityId::new(name),
1056            CallbackId::new(goal_callback_name),
1057            CallbackId::new(cancel_callback_name),
1058            CallbackId::new(accepted_callback_name),
1059            name,
1060        )
1061    }
1062
1063    /// Declare an action server whose stable entity and callback IDs are
1064    /// both synthesized from the action-name literal, returning an
1065    /// [`ActionTag`] the Node author stores on `Self::State` and matches
1066    /// against the `Callback<'_>` delivered to
1067    /// [`ExecutableNode::on_callback`].
1068    ///
1069    /// The synthesized callback ID is shared by the goal / cancel /
1070    /// accepted callbacks (matching the default behavior of
1071    /// [`create_action_server`](Self::create_action_server)).
1072    ///
1073    /// Tag-only registration is restricted to the SERVER side: clients
1074    /// need a USABLE handle (`NodeActionClient`) to dispatch goals, so
1075    /// use the existing
1076    /// [`create_action_client_for_name`](Self::create_action_client_for_name) builder
1077    /// for the client side.
1078    #[track_caller]
1079    pub fn create_action_static<A: RosAction>(
1080        &mut self,
1081        name: &'static str,
1082    ) -> NodeResult<ActionTag> {
1083        self.create_action_server_for_name::<A>(name)?;
1084        Ok(ActionTag::new(name))
1085    }
1086
1087    /// Declare an action client. Stable action client ID is required.
1088    #[track_caller]
1089    #[doc(hidden)]
1090    pub fn create_action_client<'entity, A: RosAction>(
1091        &mut self,
1092        id: EntityId<'entity>,
1093        action_name: &str,
1094    ) -> NodeResult<NodeActionClient<'entity, A>> {
1095        let mut metadata = entity_metadata(EntityMetadataSpec {
1096            id,
1097            node_id: self.id,
1098            kind: EntityKind::ActionClient,
1099            source_name: action_name,
1100            type_name: A::ACTION_NAME,
1101            type_hash: A::ACTION_HASH,
1102            qos: QosSettings::default(),
1103        })?;
1104        metadata.source = SourceLocationMetadata::caller()?;
1105        self.declare_entity(metadata)?;
1106        Ok(NodeActionClient::new(id))
1107    }
1108
1109    /// Declare an action client using `name` as the stable entity ID.
1110    #[track_caller]
1111    pub fn create_action_client_for_name<'entity, A: RosAction>(
1112        &mut self,
1113        name: &'entity str,
1114    ) -> NodeResult<NodeActionClient<'entity, A>> {
1115        self.create_action_client::<A>(EntityId::new(name), name)
1116    }
1117
1118    /// Declare an action client that delivers the goal RESULT + FEEDBACK to
1119    /// named callbacks (Phase 212.M-F.23). `name` is the stable entity ID. The
1120    /// executor auto-drives accept → feedback stream → result during spin and
1121    /// dispatches `ExecutableNode::on_callback` with `result_callback_name`
1122    /// (payload = result CDR) on completion, and with `feedback_callback_name`
1123    /// (payload = feedback CDR) per feedback message. Read either with
1124    /// `CallbackCtx::message::<A::Result>()` / `::<A::Feedback>()`. Without
1125    /// these the client can only `send_goal`; result + feedback are dropped.
1126    ///
1127    /// (Layout note: the action-client variant reuses the server-side
1128    /// `action_accepted_callback_id` metadata slot for the feedback callback —
1129    /// that field is unused on a client, so no new schema field is needed.)
1130    #[track_caller]
1131    pub fn create_action_client_with_callbacks_for_name<'entity, A: RosAction>(
1132        &mut self,
1133        name: &'entity str,
1134        result_callback_name: &str,
1135        feedback_callback_name: &str,
1136    ) -> NodeResult<NodeActionClient<'entity, A>> {
1137        let mut metadata = entity_metadata(EntityMetadataSpec {
1138            id: EntityId::new(name),
1139            node_id: self.id,
1140            kind: EntityKind::ActionClient,
1141            source_name: name,
1142            type_name: A::ACTION_NAME,
1143            type_hash: A::ACTION_HASH,
1144            qos: QosSettings::default(),
1145        })?;
1146        metadata.callback_id = Some(copy_str(result_callback_name)?);
1147        metadata.action_accepted_callback_id = Some(copy_str(feedback_callback_name)?);
1148        metadata.callback_source = SourceLocationMetadata::caller()?;
1149        metadata.source = metadata.callback_source.clone();
1150        self.declare_entity(metadata)?;
1151        Ok(NodeActionClient::new(EntityId::new(name)))
1152    }
1153
1154    /// Declare a parameter. Stable parameter ID is required.
1155    #[track_caller]
1156    #[doc(hidden)]
1157    pub fn declare_parameter<'entity>(
1158        &mut self,
1159        id: EntityId<'entity>,
1160        name: &str,
1161        parameter_type: ParameterType,
1162    ) -> NodeResult<NodeParameter<'entity>> {
1163        self.declare_parameter_with_default(id, name, ParameterDefault::for_type(parameter_type)?)
1164    }
1165
1166    /// Declare a parameter with a concrete source default.
1167    #[track_caller]
1168    #[doc(hidden)]
1169    pub fn declare_parameter_with_default<'entity>(
1170        &mut self,
1171        id: EntityId<'entity>,
1172        name: &str,
1173        default: ParameterDefault,
1174    ) -> NodeResult<NodeParameter<'entity>> {
1175        let mut metadata = entity_metadata(EntityMetadataSpec {
1176            id,
1177            node_id: self.id,
1178            kind: EntityKind::Parameter,
1179            source_name: name,
1180            type_name: "",
1181            type_hash: "",
1182            qos: QosSettings::default(),
1183        })?;
1184        metadata.parameter_type = Some(default.parameter_type());
1185        metadata.parameter_default = Some(default);
1186        metadata.source = SourceLocationMetadata::caller()?;
1187        self.declare_entity(metadata)?;
1188        Ok(NodeParameter::new(id))
1189    }
1190
1191    /// Declare a parameter using `name` as the generated stable entity ID.
1192    #[track_caller]
1193    pub fn declare_parameter_for_name<'entity>(
1194        &mut self,
1195        name: &'entity str,
1196        parameter_type: ParameterType,
1197    ) -> NodeResult<NodeParameter<'entity>> {
1198        self.declare_parameter(EntityId::new(name), name, parameter_type)
1199    }
1200
1201    /// Declare a parameter with a concrete source default, using `name` as
1202    /// the generated stable entity ID.
1203    #[track_caller]
1204    pub fn declare_parameter_for_name_with_default<'entity>(
1205        &mut self,
1206        name: &'entity str,
1207        default: ParameterDefault,
1208    ) -> NodeResult<NodeParameter<'entity>> {
1209        self.declare_parameter_with_default(EntityId::new(name), name, default)
1210    }
1211
1212    /// Record optional effects for a callback.
1213    #[doc(hidden)]
1214    pub fn callback<'callback>(
1215        &mut self,
1216        id: CallbackId<'callback>,
1217    ) -> CallbackEffects<'_, 'callback, R> {
1218        CallbackEffects {
1219            runtime: self.runtime,
1220            id,
1221        }
1222    }
1223
1224    /// Record optional effects for a named callback without exposing
1225    /// `CallbackId` at the declaration site.
1226    pub fn callback_for_name<'callback>(
1227        &mut self,
1228        name: &'callback str,
1229    ) -> CallbackEffects<'_, 'callback, R> {
1230        self.callback(CallbackId::new(name))
1231    }
1232}
1233
1234/// Builder for optional callback effect metadata.
1235pub struct CallbackEffects<'ctx, 'id, R: NodeRuntime + ?Sized = dyn NodeRuntime + 'ctx> {
1236    runtime: &'ctx mut R,
1237    id: CallbackId<'id>,
1238}
1239
1240impl<'ctx, 'id, R: NodeRuntime + ?Sized> CallbackEffects<'ctx, 'id, R> {
1241    /// Record that callback reads from an entity.
1242    #[doc(hidden)]
1243    pub fn reads(self, entity_id: EntityId<'_>) -> NodeResult<Self> {
1244        self.runtime
1245            .record_callback_effect(self.id, CallbackEffectKind::Reads, entity_id)?;
1246        Ok(self)
1247    }
1248
1249    /// Record that callback reads from a declared entity handle.
1250    pub fn reads_entity(self, entity: &impl DeclaredEntity) -> NodeResult<Self> {
1251        self.reads(entity.entity_id())
1252    }
1253
1254    /// Record that callback publishes via an entity.
1255    #[doc(hidden)]
1256    pub fn publishes(self, entity_id: EntityId<'_>) -> NodeResult<Self> {
1257        self.runtime
1258            .record_callback_effect(self.id, CallbackEffectKind::Publishes, entity_id)?;
1259        Ok(self)
1260    }
1261
1262    /// Record that callback publishes via a declared entity handle.
1263    pub fn publishes_entity(self, entity: &impl DeclaredEntity) -> NodeResult<Self> {
1264        self.publishes(entity.entity_id())
1265    }
1266
1267    /// Record that callback writes to an entity or parameter.
1268    #[doc(hidden)]
1269    pub fn writes(self, entity_id: EntityId<'_>) -> NodeResult<Self> {
1270        self.runtime
1271            .record_callback_effect(self.id, CallbackEffectKind::Writes, entity_id)?;
1272        Ok(self)
1273    }
1274
1275    /// Record that callback writes to a declared entity handle.
1276    pub fn writes_entity(self, entity: &impl DeclaredEntity) -> NodeResult<Self> {
1277        self.writes(entity.entity_id())
1278    }
1279}
1280
1281/// A declared source-level entity handle that can be referenced by callback effects.
1282#[doc(hidden)]
1283pub trait DeclaredEntity {
1284    /// Stable entity ID for metadata and generated runtime lookup.
1285    fn entity_id(&self) -> EntityId<'_>;
1286}
1287
1288macro_rules! component_handle {
1289    ($name:ident $(, $type_param:ident)?) => {
1290        /// Source-level component entity handle.
1291        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1292        pub struct $name<'id $(, $type_param)?> {
1293            id: EntityId<'id>,
1294            _marker: PhantomData<($($type_param,)?)>,
1295        }
1296
1297        impl<'id $(, $type_param)?> $name<'id $(, $type_param)?> {
1298            const fn new(id: EntityId<'id>) -> Self {
1299                Self {
1300                    id,
1301                    _marker: PhantomData,
1302                }
1303            }
1304
1305            /// Stable entity ID.
1306            #[doc(hidden)]
1307            pub const fn id(&self) -> EntityId<'id> {
1308                self.id
1309            }
1310        }
1311
1312        impl<'id $(, $type_param)?> DeclaredEntity for $name<'id $(, $type_param)?> {
1313            fn entity_id(&self) -> EntityId<'_> {
1314                self.id
1315            }
1316        }
1317    };
1318}
1319
1320component_handle!(NodePublisher, M);
1321component_handle!(NodeSubscription, M);
1322component_handle!(NodeServiceServer, S);
1323component_handle!(NodeServiceClient, S);
1324component_handle!(NodeActionServer, A);
1325component_handle!(NodeActionClient, A);
1326component_handle!(NodeTimer);
1327component_handle!(NodeParameter);
1328
1329// ============================================================================
1330// Phase 172 W.5.1 — executable component layer (callback bodies)
1331// ============================================================================
1332//
1333// The declarative `Node::register` above stays the planning/metadata SSOT.
1334// This layer binds *runnable* bodies: the generated runtime builds the
1335// component `State` once, then routes each fired callback to `on_callback` with
1336// a `CallbackCtx` that exposes the triggering payload + an immediate publish
1337// path. Publishers are self-contained transport handles
1338// (`EmbeddedRawPublisher::publish_raw(&self)`), so a body publishes immediately
1339// mid-spin with no executor re-entrancy and no deferred queue (causality
1340// preserved). Shared state across a component's callbacks is `&mut State`
1341// behind the generated runtime's `'static` storage — `no_std`, no `alloc`.
1342
1343/// Resolves a component publisher by its stable [`EntityId`] for the
1344/// callback-body publish path (W.5.1).
1345///
1346/// The generated runtime implements this over its owned `'static` publishers;
1347/// metadata/discovery mode never constructs a [`CallbackCtx`], so it need not
1348/// implement this.
1349pub trait PublisherResolver {
1350    /// Publish raw CDR bytes through the publisher with this stable entity id.
1351    /// `Err(NodeDeclError::Runtime)` if no such publisher is registered or the
1352    /// transport rejects the write.
1353    fn publish_raw(&self, entity_id: &str, data: &[u8]) -> NodeResult<()>;
1354}
1355
1356/// Where a service / action-result callback body writes its reply (W.5.3): the
1357/// generated trampoline lends a `buf`; the body fills it via
1358/// [`CallbackCtx::reply`] and the trampoline reads `*written` back out.
1359struct ReplySink<'a> {
1360    buf: &'a mut [u8],
1361    written: &'a mut usize,
1362}
1363
1364/// Where an action goal / cancel-decision callback writes its accept/reject
1365/// (W.5.3): the generated trampoline lends the out-slot, the body fills it via
1366/// [`CallbackCtx::set_goal_response`] / [`set_cancel_response`](CallbackCtx::set_cancel_response),
1367/// and the trampoline returns it. Decisions need no executor — unlike feedback /
1368/// result, which do (see the action-execution note in Phase 172 W.5.3).
1369enum DecisionSink<'a> {
1370    Goal(&'a mut GoalResponse),
1371    Cancel(&'a mut CancelResponse),
1372}
1373
1374/// Context handed to an executable component callback body (W.5.1).
1375///
1376/// Carries the triggering payload (raw CDR — empty for timers) plus the
1377/// publisher resolver, so a body can read its message and publish immediately.
1378/// Service / action-result callbacks additionally carry a `ReplySink` the body
1379/// fills via [`reply`](Self::reply); action goal / cancel callbacks carry a
1380/// `DecisionSink` the body fills via
1381/// [`set_goal_response`](Self::set_goal_response) /
1382/// [`set_cancel_response`](Self::set_cancel_response) (W.5.3).
1383pub struct CallbackCtx<'a> {
1384    payload: &'a [u8],
1385    publishers: &'a dyn PublisherResolver,
1386    reply: Option<ReplySink<'a>>,
1387    decision: Option<DecisionSink<'a>>,
1388    /// Phase 250 (Wave 2) — E2E message-integrity status for a subscription that
1389    /// opted in via `.safety()`; `None` for every other callback (timers,
1390    /// services, non-safety subscriptions). Read with
1391    /// [`integrity`](Self::integrity). Gated with the capability so it is
1392    /// zero-cost when `safety-e2e` is off.
1393    #[cfg(feature = "safety-e2e")]
1394    integrity: Option<&'a crate::IntegrityStatus>,
1395}
1396
1397impl<'a> CallbackCtx<'a> {
1398    /// Build a callback context with no reply sink (timer / subscription).
1399    /// `payload` is the entity's raw CDR (empty slice for timers).
1400    pub fn new(payload: &'a [u8], publishers: &'a dyn PublisherResolver) -> Self {
1401        Self {
1402            payload,
1403            publishers,
1404            reply: None,
1405            decision: None,
1406            #[cfg(feature = "safety-e2e")]
1407            integrity: None,
1408        }
1409    }
1410
1411    /// Phase 250 (Wave 2) — build a subscription context carrying E2E
1412    /// [`IntegrityStatus`](crate::IntegrityStatus) (the declarative `.safety()`
1413    /// path). The body reads both the message ([`message`](Self::message)) and
1414    /// the status ([`integrity`](Self::integrity)) in one callback, mirroring the
1415    /// imperative `FnMut(&M, &IntegrityStatus)` shape.
1416    #[cfg(feature = "safety-e2e")]
1417    pub fn new_with_integrity(
1418        payload: &'a [u8],
1419        publishers: &'a dyn PublisherResolver,
1420        integrity: &'a crate::IntegrityStatus,
1421    ) -> Self {
1422        Self {
1423            payload,
1424            publishers,
1425            reply: None,
1426            decision: None,
1427            integrity: Some(integrity),
1428        }
1429    }
1430
1431    /// Build a callback context with a reply sink (service / action-result;
1432    /// W.5.3). The body fills `reply_buf` via [`reply`](Self::reply); the
1433    /// generated trampoline reads `*reply_written` back as the response length.
1434    pub fn with_reply(
1435        payload: &'a [u8],
1436        publishers: &'a dyn PublisherResolver,
1437        reply_buf: &'a mut [u8],
1438        reply_written: &'a mut usize,
1439    ) -> Self {
1440        *reply_written = 0;
1441        Self {
1442            payload,
1443            publishers,
1444            reply: Some(ReplySink {
1445                buf: reply_buf,
1446                written: reply_written,
1447            }),
1448            decision: None,
1449            #[cfg(feature = "safety-e2e")]
1450            integrity: None,
1451        }
1452    }
1453
1454    /// Build a context for an action **goal** callback (W.5.3): the body decides
1455    /// accept/reject via [`set_goal_response`](Self::set_goal_response); the
1456    /// generated trampoline returns `*out`. `payload` is the goal CDR.
1457    pub fn with_goal_decision(
1458        payload: &'a [u8],
1459        publishers: &'a dyn PublisherResolver,
1460        out: &'a mut GoalResponse,
1461    ) -> Self {
1462        Self {
1463            payload,
1464            publishers,
1465            reply: None,
1466            decision: Some(DecisionSink::Goal(out)),
1467            #[cfg(feature = "safety-e2e")]
1468            integrity: None,
1469        }
1470    }
1471
1472    /// Build a context for an action **cancel** callback (W.5.3): the body decides
1473    /// accept/reject via [`set_cancel_response`](Self::set_cancel_response).
1474    pub fn with_cancel_decision(
1475        payload: &'a [u8],
1476        publishers: &'a dyn PublisherResolver,
1477        out: &'a mut CancelResponse,
1478    ) -> Self {
1479        Self {
1480            payload,
1481            publishers,
1482            reply: None,
1483            decision: Some(DecisionSink::Cancel(out)),
1484            #[cfg(feature = "safety-e2e")]
1485            integrity: None,
1486        }
1487    }
1488
1489    /// Set the action goal-callback's accept/reject decision (W.5.3). `Err` when
1490    /// the callback is not a goal decision.
1491    pub fn set_goal_response(&mut self, response: GoalResponse) -> NodeResult<()> {
1492        match &mut self.decision {
1493            Some(DecisionSink::Goal(slot)) => {
1494                **slot = response;
1495                Ok(())
1496            }
1497            _ => Err(NodeDeclError::Runtime),
1498        }
1499    }
1500
1501    /// Set the action cancel-callback's accept/reject decision (W.5.3). `Err` when
1502    /// the callback is not a cancel decision.
1503    pub fn set_cancel_response(&mut self, response: CancelResponse) -> NodeResult<()> {
1504        match &mut self.decision {
1505            Some(DecisionSink::Cancel(slot)) => {
1506                **slot = response;
1507                Ok(())
1508            }
1509            _ => Err(NodeDeclError::Runtime),
1510        }
1511    }
1512
1513    /// Write the service / action reply as raw CDR bytes (W.5.3). `Err` when the
1514    /// callback has no reply sink (timer / subscription) or the reply exceeds the
1515    /// lent buffer.
1516    pub fn reply_raw(&mut self, data: &[u8]) -> NodeResult<()> {
1517        let sink = self.reply.as_mut().ok_or(NodeDeclError::Runtime)?;
1518        if data.len() > sink.buf.len() {
1519            return Err(NodeDeclError::Runtime);
1520        }
1521        sink.buf[..data.len()].copy_from_slice(data);
1522        *sink.written = data.len();
1523        Ok(())
1524    }
1525
1526    /// Serialize `msg` and write it as the service / action reply (W.5.3).
1527    pub fn reply<M: RosMessage, const N: usize>(&mut self, msg: &M) -> NodeResult<()> {
1528        let mut buf = [0u8; N];
1529        let mut writer =
1530            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
1531        msg.serialize(&mut writer)
1532            .map_err(|_| NodeDeclError::Runtime)?;
1533        let len = writer.position();
1534        self.reply_raw(&buf[..len])
1535    }
1536
1537    /// Raw CDR payload of the triggering message / request. Empty for timers.
1538    pub fn payload(&self) -> &[u8] {
1539        self.payload
1540    }
1541
1542    /// Phase 250 (Wave 2) — E2E message-integrity status (CRC + sequence gap/dup)
1543    /// for this dispatch. `Some` only when the firing subscription opted in via
1544    /// `.safety()`; `None` for timers, services, and non-safety subscriptions.
1545    /// Read it alongside [`message`](Self::message) — the status describes the
1546    /// message you just received.
1547    #[cfg(feature = "safety-e2e")]
1548    pub fn integrity(&self) -> Option<&crate::IntegrityStatus> {
1549        self.integrity
1550    }
1551
1552    /// Deserialize the triggering payload as `M` (subscription / service-request
1553    /// bodies). `Err` if the payload is malformed for `M`.
1554    pub fn message<M: RosMessage>(&self) -> NodeResult<M> {
1555        let mut reader =
1556            crate::CdrReader::new_with_header(self.payload).map_err(|_| NodeDeclError::Runtime)?;
1557        M::deserialize(&mut reader).map_err(|_| NodeDeclError::Runtime)
1558    }
1559
1560    /// Publish raw CDR bytes through the named publisher entity (immediate).
1561    #[doc(hidden)]
1562    pub fn publish_raw(&self, publisher: EntityId<'_>, data: &[u8]) -> NodeResult<()> {
1563        self.publishers.publish_raw(publisher.as_str(), data)
1564    }
1565
1566    /// Serialize `msg` into an `N`-byte stack buffer and publish it (immediate).
1567    /// `N` must be ≥ the CDR-encoded size of `msg`; the generated runtime picks
1568    /// it from the message type.
1569    #[doc(hidden)]
1570    pub fn publish<M: RosMessage, const N: usize>(
1571        &self,
1572        publisher: EntityId<'_>,
1573        msg: &M,
1574    ) -> NodeResult<()> {
1575        let mut buf = [0u8; N];
1576        let mut writer =
1577            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
1578        msg.serialize(&mut writer)
1579            .map_err(|_| NodeDeclError::Runtime)?;
1580        let len = writer.position();
1581        self.publish_raw(publisher, &buf[..len])
1582    }
1583
1584    /// Serialize `msg` and publish through the entity synthesized from `topic`.
1585    ///
1586    /// This pairs with
1587    /// [`DeclaredNode::create_publisher_for_topic`], allowing simple callback
1588    /// bodies to use the ROS topic literal instead of restating an unrelated
1589    /// stable entity ID.
1590    pub fn publish_to_topic<M: RosMessage, const N: usize>(
1591        &self,
1592        topic: &str,
1593        msg: &M,
1594    ) -> NodeResult<()> {
1595        self.publish::<M, N>(EntityId::new(topic), msg)
1596    }
1597}
1598
1599/// The executable counterpart of [`Node`] (W.5.1).
1600///
1601/// `register` (declarative) stays the planning SSOT; this binds runnable
1602/// bodies. The generated runtime builds [`State`](ExecutableNode::State) once via
1603/// [`init`](ExecutableNode::init), then routes every fired callback to
1604/// [`on_callback`](ExecutableNode::on_callback). Trait-dispatch (no boxed `dyn`, no
1605/// `alloc`) keeps it `no_std`.
1606/// Executor-backed action operations a [`TickCtx`] drives (W.5.6).
1607///
1608/// Action result/feedback need `&mut Executor` (`complete_goal_raw` /
1609/// `publish_feedback_raw`), which a mid-spin *callback* can't hold (the executor
1610/// is borrowed) — so they run from [`ExecutableNode::tick`], between spins.
1611/// The generated runtime implements this over the real executor + the action
1612/// servers' handles (resolved by stable action entity id); the component never
1613/// sees the executor directly. Kept as a trait so [`TickCtx`] stays `no_std` +
1614/// free of the `rmw-cffi`-gated `Executor` type.
1615pub trait ActionExecutor {
1616    /// Complete the goal `goal_id` on action `action_entity` with raw CDR result.
1617    fn complete_goal_raw(
1618        &mut self,
1619        action_entity: &str,
1620        goal_id: &GoalId,
1621        status: GoalStatus,
1622        result: &[u8],
1623    ) -> NodeResult<()>;
1624
1625    /// Publish raw CDR feedback for `goal_id` on action `action_entity`.
1626    fn publish_feedback_raw(
1627        &mut self,
1628        action_entity: &str,
1629        goal_id: &GoalId,
1630        feedback: &[u8],
1631    ) -> NodeResult<()>;
1632
1633    /// Visit every goal on `action_entity` that has been accepted but not yet
1634    /// completed, with its id + current status. The execution seam: a `tick` body
1635    /// has no other way to learn an accepted goal's id (the goal-decision callback
1636    /// doesn't surface it), so it iterates here to drive feedback / completion.
1637    fn for_each_active_goal(&self, action_entity: &str, visit: &mut dyn FnMut(&GoalId, GoalStatus));
1638}
1639
1640/// Executor-backed CLIENT operations a [`TickCtx`] drives (Phase 212.M-F.4).
1641///
1642/// Service-client `call` + action-client `send_goal` need `&mut Executor`
1643/// (the W.5.6 client handles live on the executor), which a mid-spin
1644/// callback can't hold. They run from [`ExecutableNode::tick`], between
1645/// spins. The generated runtime impls this over the real executor + the
1646/// service/action client handles (resolved by stable client entity id); the
1647/// component never sees the executor directly. Kept as a trait so [`TickCtx`]
1648/// stays `no_std` + free of the `rmw-cffi`-gated `Executor` type.
1649///
1650/// Mirrors the sibling [`ActionExecutor`] (server-side ops). Splitting
1651/// client vs server keeps each trait small + lets the codegen-side
1652/// `GenClientDispatch` impl resolve client handles independently from
1653/// server handles.
1654pub trait ClientDispatch {
1655    /// Issue a service-client request on `service_entity` carrying CDR
1656    /// `request_cdr`; block on the reply, write the response CDR into
1657    /// `response_buf`, return the response length in bytes.
1658    ///
1659    /// The synchronous block model matches the existing
1660    /// `ServiceClientTrait::call_raw` surface in nros-node — the tick
1661    /// hook drives the executor between callback dispatch, so a blocked
1662    /// `call_raw` does not starve other callbacks (each tick yields back
1663    /// to the runtime after returning).
1664    fn call_raw(
1665        &mut self,
1666        service_entity: &str,
1667        request_cdr: &[u8],
1668        response_buf: &mut [u8],
1669    ) -> NodeResult<usize>;
1670
1671    /// Send an action-client goal request on `action_entity` carrying
1672    /// CDR `goal_cdr`; return the assigned [`GoalId`] (server-stamped on
1673    /// the goal-accept response). Result + feedback streams arrive via
1674    /// callback dispatch — not this method.
1675    fn send_goal_raw(&mut self, action_entity: &str, goal_cdr: &[u8]) -> NodeResult<GoalId>;
1676}
1677
1678/// Context handed to [`ExecutableNode::tick`] (W.5.6 + M-F.4): the per-spin
1679/// hook that runs *between* callback dispatch, where the executor is free.
1680/// Exposes the immediate publish path (like `CallbackCtx`) plus executor-backed
1681/// action-server ops (complete goal / publish feedback) AND executor-backed
1682/// client-side ops (service `call` / action-client `send_goal`). Callbacks
1683/// can't perform any of these since they don't hold the executor.
1684pub struct TickCtx<'a> {
1685    publishers: &'a dyn PublisherResolver,
1686    actions: &'a mut dyn ActionExecutor,
1687    clients: &'a mut dyn ClientDispatch,
1688}
1689
1690impl<'a> TickCtx<'a> {
1691    /// Build a tick context (called by the generated runtime each spin).
1692    pub fn new(
1693        publishers: &'a dyn PublisherResolver,
1694        actions: &'a mut dyn ActionExecutor,
1695        clients: &'a mut dyn ClientDispatch,
1696    ) -> Self {
1697        Self {
1698            publishers,
1699            actions,
1700            clients,
1701        }
1702    }
1703
1704    /// Publish raw CDR bytes through the named publisher entity (immediate).
1705    #[doc(hidden)]
1706    pub fn publish_raw(&self, publisher: EntityId<'_>, data: &[u8]) -> NodeResult<()> {
1707        self.publishers.publish_raw(publisher.as_str(), data)
1708    }
1709
1710    /// Serialize `msg` into an `N`-byte stack buffer and publish it (immediate).
1711    #[doc(hidden)]
1712    pub fn publish<M: RosMessage, const N: usize>(
1713        &self,
1714        publisher: EntityId<'_>,
1715        msg: &M,
1716    ) -> NodeResult<()> {
1717        let mut buf = [0u8; N];
1718        let mut writer =
1719            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
1720        msg.serialize(&mut writer)
1721            .map_err(|_| NodeDeclError::Runtime)?;
1722        let len = writer.position();
1723        self.publish_raw(publisher, &buf[..len])
1724    }
1725
1726    /// Serialize `msg` and publish through the entity synthesized from `topic`.
1727    ///
1728    /// This pairs with [`DeclaredNode::create_publisher_for_topic`] for
1729    /// executable tick hooks.
1730    pub fn publish_to_topic<M: RosMessage, const N: usize>(
1731        &self,
1732        topic: &str,
1733        msg: &M,
1734    ) -> NodeResult<()> {
1735        self.publish::<M, N>(EntityId::new(topic), msg)
1736    }
1737
1738    /// Complete an action goal with a typed result (W.5.6 — needs the executor,
1739    /// hence tick-only).
1740    #[doc(hidden)]
1741    pub fn complete_goal<R: RosMessage, const N: usize>(
1742        &mut self,
1743        action: EntityId<'_>,
1744        goal_id: &GoalId,
1745        status: GoalStatus,
1746        result: &R,
1747    ) -> NodeResult<()> {
1748        // CDR-LE encapsulation header included: the executor's `complete_goal_raw`
1749        // frames only the outer envelope ([header][goal_id]) + this payload
1750        // verbatim — it does NOT add an inner header — and the consumer reads the
1751        // result via `CallbackCtx::message` (`CdrReader::new_with_header`), as do
1752        // the C++/ffi clients. Without the header the reader eats the first data
1753        // word (e.g. a sequence length) → empty/garbage payload (issue #35 M-F.23
1754        // follow-up: action result `sequence` deserialized to len 0).
1755        let mut buf = [0u8; N];
1756        let mut writer =
1757            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
1758        result
1759            .serialize(&mut writer)
1760            .map_err(|_| NodeDeclError::Runtime)?;
1761        let len = writer.position();
1762        self.actions
1763            .complete_goal_raw(action.as_str(), goal_id, status, &buf[..len])
1764    }
1765
1766    /// Complete an action goal on the action entity synthesized from `name`.
1767    ///
1768    /// This pairs with
1769    /// [`DeclaredNode::create_action_server_for_name`] and
1770    /// [`DeclaredNode::create_action_server_for_name_with_callbacks`].
1771    pub fn complete_goal_for_name<R: RosMessage, const N: usize>(
1772        &mut self,
1773        name: &str,
1774        goal_id: &GoalId,
1775        status: GoalStatus,
1776        result: &R,
1777    ) -> NodeResult<()> {
1778        self.complete_goal::<R, N>(EntityId::new(name), goal_id, status, result)
1779    }
1780
1781    /// Visit each active (accepted, not yet completed) goal on `action` with its
1782    /// id + status — how a `tick` body discovers goals to feed / complete. Collect
1783    /// the ids you want to act on, then call [`Self::publish_feedback`] /
1784    /// [`Self::complete_goal`] after the visit returns (those borrow `self`
1785    /// mutably, so they can't run inside `visit`).
1786    #[doc(hidden)]
1787    pub fn for_each_active_goal(
1788        &self,
1789        action: EntityId<'_>,
1790        visit: &mut dyn FnMut(&GoalId, GoalStatus),
1791    ) {
1792        self.actions.for_each_active_goal(action.as_str(), visit);
1793    }
1794
1795    /// Visit active goals on the action entity synthesized from `name`.
1796    pub fn for_each_active_goal_for_name(
1797        &self,
1798        name: &str,
1799        visit: &mut dyn FnMut(&GoalId, GoalStatus),
1800    ) {
1801        self.for_each_active_goal(EntityId::new(name), visit);
1802    }
1803
1804    /// Publish typed feedback for an active action goal (W.5.6 — tick-only).
1805    #[doc(hidden)]
1806    pub fn publish_feedback<F: RosMessage, const N: usize>(
1807        &mut self,
1808        action: EntityId<'_>,
1809        goal_id: &GoalId,
1810        feedback: &F,
1811    ) -> NodeResult<()> {
1812        // CDR-LE encapsulation header included — see `complete_goal` above. The
1813        // executor frames the outer [header][goal_id] envelope only; the consumer
1814        // reads feedback via `CallbackCtx::message` (`new_with_header`), so the
1815        // payload itself must carry the header (issue #35 M-F.23 follow-up).
1816        let mut buf = [0u8; N];
1817        let mut writer =
1818            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
1819        feedback
1820            .serialize(&mut writer)
1821            .map_err(|_| NodeDeclError::Runtime)?;
1822        let len = writer.position();
1823        self.actions
1824            .publish_feedback_raw(action.as_str(), goal_id, &buf[..len])
1825    }
1826
1827    /// Publish feedback on the action entity synthesized from `name`.
1828    pub fn publish_feedback_for_name<F: RosMessage, const N: usize>(
1829        &mut self,
1830        name: &str,
1831        goal_id: &GoalId,
1832        feedback: &F,
1833    ) -> NodeResult<()> {
1834        self.publish_feedback::<F, N>(EntityId::new(name), goal_id, feedback)
1835    }
1836
1837    /// Issue a service-client raw-CDR request and block on the reply
1838    /// (M-F.4 — tick-only). Writes the response CDR into `response_buf`
1839    /// and returns the response length in bytes.
1840    #[doc(hidden)]
1841    pub fn call_raw(
1842        &mut self,
1843        service: EntityId<'_>,
1844        request_cdr: &[u8],
1845        response_buf: &mut [u8],
1846    ) -> NodeResult<usize> {
1847        self.clients
1848            .call_raw(service.as_str(), request_cdr, response_buf)
1849    }
1850
1851    /// Issue a raw service-client request through the entity synthesized
1852    /// from `name`.
1853    pub fn call_raw_for_name(
1854        &mut self,
1855        name: &str,
1856        request_cdr: &[u8],
1857        response_buf: &mut [u8],
1858    ) -> NodeResult<usize> {
1859        self.call_raw(EntityId::new(name), request_cdr, response_buf)
1860    }
1861
1862    /// Issue a typed service-client request and decode the reply
1863    /// (M-F.4 — tick-only). `REQ_N` / `RESP_N` stack-size the request /
1864    /// response CDR buffers; size them via
1865    /// `<<Req as RosMessage>::SerializedSize as nros::SerializedSize>::SIZE`.
1866    #[doc(hidden)]
1867    pub fn call<Req: RosMessage, Resp: RosMessage, const REQ_N: usize, const RESP_N: usize>(
1868        &mut self,
1869        service: EntityId<'_>,
1870        request: &Req,
1871    ) -> NodeResult<Resp> {
1872        let mut req_buf = [0u8; REQ_N];
1873        let mut writer =
1874            crate::CdrWriter::new_with_header(&mut req_buf).map_err(|_| NodeDeclError::Runtime)?;
1875        request
1876            .serialize(&mut writer)
1877            .map_err(|_| NodeDeclError::Runtime)?;
1878        let req_len = writer.position();
1879
1880        let mut resp_buf = [0u8; RESP_N];
1881        let resp_len =
1882            self.clients
1883                .call_raw(service.as_str(), &req_buf[..req_len], &mut resp_buf)?;
1884
1885        let mut reader = crate::CdrReader::new_with_header(&resp_buf[..resp_len])
1886            .map_err(|_| NodeDeclError::Runtime)?;
1887        Resp::deserialize(&mut reader).map_err(|_| NodeDeclError::Runtime)
1888    }
1889
1890    /// Issue a typed service-client request through the entity synthesized
1891    /// from `name`.
1892    pub fn call_for_name<
1893        Req: RosMessage,
1894        Resp: RosMessage,
1895        const REQ_N: usize,
1896        const RESP_N: usize,
1897    >(
1898        &mut self,
1899        name: &str,
1900        request: &Req,
1901    ) -> NodeResult<Resp> {
1902        self.call::<Req, Resp, REQ_N, RESP_N>(EntityId::new(name), request)
1903    }
1904
1905    /// Send a raw-CDR action-client goal and return the assigned
1906    /// [`GoalId`] (M-F.4 — tick-only). Result + feedback streams arrive
1907    /// via callback dispatch; this method only kicks off the request.
1908    #[doc(hidden)]
1909    pub fn send_goal_raw(&mut self, action: EntityId<'_>, goal_cdr: &[u8]) -> NodeResult<GoalId> {
1910        self.clients.send_goal_raw(action.as_str(), goal_cdr)
1911    }
1912
1913    /// Send a raw-CDR action-client goal through the entity synthesized
1914    /// from `name`.
1915    pub fn send_goal_raw_for_name(&mut self, name: &str, goal_cdr: &[u8]) -> NodeResult<GoalId> {
1916        self.send_goal_raw(EntityId::new(name), goal_cdr)
1917    }
1918
1919    /// Send a typed action-client goal and return the assigned
1920    /// [`GoalId`] (M-F.4 — tick-only). `N` stack-sizes the goal CDR
1921    /// buffer.
1922    #[doc(hidden)]
1923    pub fn send_goal<G: RosMessage, const N: usize>(
1924        &mut self,
1925        action: EntityId<'_>,
1926        goal: &G,
1927    ) -> NodeResult<GoalId> {
1928        let mut buf = [0u8; N];
1929        let mut writer =
1930            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
1931        goal.serialize(&mut writer)
1932            .map_err(|_| NodeDeclError::Runtime)?;
1933        let len = writer.position();
1934        self.clients.send_goal_raw(action.as_str(), &buf[..len])
1935    }
1936
1937    /// Send a typed action-client goal through the entity synthesized
1938    /// from `name`.
1939    pub fn send_goal_for_name<G: RosMessage, const N: usize>(
1940        &mut self,
1941        name: &str,
1942        goal: &G,
1943    ) -> NodeResult<GoalId> {
1944        self.send_goal::<G, N>(EntityId::new(name), goal)
1945    }
1946}
1947
1948pub trait ExecutableNode: Node {
1949    /// Per-instance mutable state shared across the component's callbacks.
1950    type State;
1951
1952    /// Build the initial state (called once by the generated runtime).
1953    fn init() -> Self::State;
1954
1955    /// Run the body for `callback`. `ctx` exposes the triggering payload + the
1956    /// immediate publish path. Bodies match on the source callback name declared
1957    /// by `create_*_for_callback_name` and related helpers.
1958    fn on_callback(state: &mut Self::State, callback: Callback<'_>, ctx: &mut CallbackCtx<'_>);
1959
1960    /// Per-spin execution hook (W.5.6), run *between* callback dispatch by the
1961    /// generated runtime — where the executor is free, so this is the only place
1962    /// a component can complete action goals / publish feedback (via `ctx`) or do
1963    /// periodic work. Default: no-op (timer/sub/service-only components).
1964    fn tick(_state: &mut Self::State, _ctx: &mut TickCtx<'_>) {}
1965}
1966
1967/// Emit a no-op [`ExecutableNode`] impl for a declarative-only component
1968/// (W.5.1). The generated runtime calls `on_callback` unconditionally, so a
1969/// component instantiated into a generated binary must impl `ExecutableNode`;
1970/// components without callback bodies use this to satisfy that contract:
1971///
1972/// ```ignore
1973/// pub struct Node;
1974/// impl nros::Node for Node { /* register(...) */ }
1975/// nros::declarative_component!(Node);
1976/// ```
1977#[macro_export]
1978macro_rules! declarative_component {
1979    ($ty:ty) => {
1980        impl $crate::ExecutableNode for $ty {
1981            type State = ();
1982            fn init() -> Self::State {}
1983            fn on_callback(
1984                _state: &mut Self::State,
1985                _callback: $crate::Callback<'_>,
1986                _ctx: &mut $crate::CallbackCtx<'_>,
1987            ) {
1988            }
1989        }
1990    };
1991}
1992
1993/// Run component registration against any component runtime.
1994pub fn register_node<C: Node>(runtime: &mut dyn NodeRuntime) -> NodeResult<()> {
1995    let mut context = NodeContext::new(C::NAME, runtime);
1996    C::register(&mut context)
1997}
1998
1999/// Phase 212.M.5.a.4 internal — `Box`-erase a freshly built component
2000/// `State` to the type-erased `*mut ()` ABI the BSP path uses. Called
2001/// only from the `nros::node!()` macro emit; not public API.
2002///
2003/// The returned pointer is a leaked `Box`; the BSP runtime keeps it
2004/// alive for the firmware lifetime (embedded slots never deallocate).
2005#[cfg(feature = "alloc")]
2006#[doc(hidden)]
2007pub fn __private_node_state_into_raw<C: ExecutableNode>(state: C::State) -> *mut () {
2008    extern crate alloc;
2009    alloc::boxed::Box::into_raw(alloc::boxed::Box::new(state)) as *mut ()
2010}
2011
2012/// Run component registration against an in-memory metadata recorder.
2013pub fn record_node_metadata<C: Node>(recorder: &mut dyn NodeRuntime) -> NodeResult<()> {
2014    register_node::<C>(recorder)
2015}
2016
2017#[cfg(test)]
2018mod tests {
2019    use super::*;
2020    use crate::{CdrReader, CdrWriter, DeserError, SerError, SourceNameKind};
2021
2022    #[derive(Default)]
2023    struct FakeNodeRuntime {
2024        next: u8,
2025        created: Vec<MetadataString, 4>,
2026    }
2027
2028    impl DeclaredNodeRuntime for FakeNodeRuntime {
2029        type NodeHandle = u8;
2030
2031        fn build_component_node(
2032            &mut self,
2033            _id: NodeId<'_>,
2034            options: NodeOptions<'_>,
2035        ) -> NodeResult<Self::NodeHandle> {
2036            self.created
2037                .push(copy_str(options.name)?)
2038                .map_err(|_| NodeDeclError::Metadata(NodeMetadataError::Capacity))?;
2039            let handle = self.next;
2040            self.next += 1;
2041            Ok(handle)
2042        }
2043    }
2044
2045    #[derive(Debug, Clone, Copy, Default)]
2046    struct TestMsg;
2047
2048    impl crate::Serialize for TestMsg {
2049        fn serialize(&self, _writer: &mut CdrWriter) -> Result<(), SerError> {
2050            Ok(())
2051        }
2052    }
2053
2054    impl crate::Deserialize for TestMsg {
2055        fn deserialize(_reader: &mut CdrReader) -> Result<Self, DeserError> {
2056            Ok(Self)
2057        }
2058    }
2059
2060    impl RosMessage for TestMsg {
2061        const TYPE_NAME: &'static str = "test_msgs::msg::dds_::Test_";
2062        const TYPE_HASH: &'static str = "test_hash";
2063    }
2064
2065    struct TestService;
2066
2067    impl RosService for TestService {
2068        type Request = TestMsg;
2069        type Reply = TestMsg;
2070
2071        const SERVICE_NAME: &'static str = "test_msgs::srv::dds_::Test_";
2072        const SERVICE_HASH: &'static str = "test_service_hash";
2073    }
2074
2075    struct TestAction;
2076
2077    impl RosAction for TestAction {
2078        type Goal = TestMsg;
2079        type Result = TestMsg;
2080        type Feedback = TestMsg;
2081        type SendGoalRequest = TestMsg;
2082        type SendGoalResponse = TestMsg;
2083        type GetResultRequest = TestMsg;
2084        type GetResultResponse = TestMsg;
2085        type FeedbackMessage = TestMsg;
2086
2087        const ACTION_NAME: &'static str = "test_msgs::action::dds_::Test_";
2088        const ACTION_HASH: &'static str = "test_action_hash";
2089    }
2090
2091    struct TalkerComponent;
2092
2093    impl Node for TalkerComponent {
2094        const NAME: &'static str = "talker_component";
2095
2096        fn register(context: &mut NodeContext<'_>) -> NodeResult<()> {
2097            let mut node =
2098                context.create_node_with_id(NodeId::new("node"), NodeOptions::new("talker"))?;
2099            let _publisher =
2100                node.create_publisher::<TestMsg>(EntityId::new("pub_chatter"), "chatter")?;
2101            let _subscription = node.create_subscription::<TestMsg>(
2102                EntityId::new("sub_cmd"),
2103                CallbackId::new("on_cmd"),
2104                "~/cmd",
2105            )?;
2106            let _timer = node.create_timer(
2107                EntityId::new("timer_tick"),
2108                CallbackId::new("on_tick"),
2109                TimerDuration::from_millis(10),
2110            )?;
2111            let _parameter =
2112                node.declare_parameter(EntityId::new("param_gain"), "gain", ParameterType::Double)?;
2113            node.callback(CallbackId::new("on_tick"))
2114                .publishes(EntityId::new("pub_chatter"))?
2115                .writes(EntityId::new("param_gain"))?;
2116            Ok(())
2117        }
2118    }
2119
2120    #[test]
2121    fn component_records_metadata_without_transport() {
2122        let mut recorder = MetadataRecorder::<2, 8, 4>::new();
2123        record_node_metadata::<TalkerComponent>(&mut recorder).unwrap();
2124
2125        assert_eq!(recorder.nodes().len(), 1);
2126        assert_eq!(recorder.nodes()[0].name.as_str(), "talker");
2127        assert_eq!(recorder.entities().len(), 4);
2128        assert_eq!(recorder.entities()[0].kind, EntityKind::Publisher);
2129        assert_eq!(recorder.entities()[1].source_name.as_str(), "~/cmd");
2130        assert_eq!(
2131            recorder.entities()[1]
2132                .callback_id
2133                .as_ref()
2134                .map(|id| id.as_str()),
2135            Some("on_cmd")
2136        );
2137        assert_eq!(recorder.callback_effects().len(), 2);
2138    }
2139
2140    // Phase 250 Wave 2b — the declarative `.safety()` opt-in records the
2141    // `EntityMetadata.safety` flag so the runtime registers the integrity-aware
2142    // subscription. A plain subscription stays `safety == false`.
2143    struct SafetyComponent;
2144    impl Node for SafetyComponent {
2145        const NAME: &'static str = "safety_component";
2146        fn register(context: &mut NodeContext<'_>) -> NodeResult<()> {
2147            let mut node =
2148                context.create_node_with_id(NodeId::new("node"), NodeOptions::new("listener"))?;
2149            let _plain = node.create_subscription_for_callback_name::<TestMsg>("on_plain", "/a")?;
2150            let _safe =
2151                node.create_subscription_for_callback_name_with_safety::<TestMsg>("on_safe", "/b")?;
2152            Ok(())
2153        }
2154    }
2155
2156    #[test]
2157    fn safety_opt_in_records_metadata_flag() {
2158        let mut recorder = MetadataRecorder::<2, 8, 4>::new();
2159        record_node_metadata::<SafetyComponent>(&mut recorder).unwrap();
2160        let ents = recorder.entities();
2161        assert_eq!(ents.len(), 2);
2162        // Plain subscription on /a — no safety.
2163        assert_eq!(ents[0].source_name.as_str(), "/a");
2164        assert!(!ents[0].safety, "plain sub must not be flagged");
2165        // `.safety()` subscription on /b — flagged.
2166        assert_eq!(ents[1].source_name.as_str(), "/b");
2167        assert!(ents[1].safety, "safety sub must be flagged");
2168    }
2169
2170    struct GroupedComponent;
2171
2172    impl Node for GroupedComponent {
2173        const NAME: &'static str = "grouped_component";
2174
2175        fn register(context: &mut NodeContext<'_>) -> NodeResult<()> {
2176            let mut node =
2177                context.create_node_with_id(NodeId::new("node"), NodeOptions::new("grouped"))?;
2178            // Unlabeled entity declared before any group is set.
2179            let _pub = node.create_publisher::<TestMsg>(EntityId::new("pub_plain"), "plain")?;
2180            // Sticky "control" group covers the next two entities.
2181            node.callback_group("control")?;
2182            let _sub = node.create_subscription::<TestMsg>(
2183                EntityId::new("sub_cmd"),
2184                CallbackId::new("on_cmd"),
2185                "~/cmd",
2186            )?;
2187            let _timer = node.create_timer(
2188                EntityId::new("timer_tick"),
2189                CallbackId::new("on_tick"),
2190                TimerDuration::from_millis(10),
2191            )?;
2192            // Switch to "telemetry" for the last entity.
2193            node.callback_group("telemetry")?;
2194            let _sub2 = node.create_subscription::<TestMsg>(
2195                EntityId::new("sub_diag"),
2196                CallbackId::new("on_diag"),
2197                "~/diag",
2198            )?;
2199            Ok(())
2200        }
2201    }
2202
2203    #[test]
2204    fn sticky_callback_group_stamps_subsequent_entities() {
2205        let mut recorder = MetadataRecorder::<2, 8, 4>::new();
2206        record_node_metadata::<GroupedComponent>(&mut recorder).unwrap();
2207
2208        let group_of = |idx: usize| {
2209            recorder.entities()[idx]
2210                .callback_group
2211                .as_ref()
2212                .map(|g| g.as_str())
2213        };
2214        // pub_plain — declared before any group → unlabeled.
2215        assert_eq!(group_of(0), None);
2216        // sub_cmd + timer_tick — under "control".
2217        assert_eq!(group_of(1), Some("control"));
2218        assert_eq!(group_of(2), Some("control"));
2219        // sub_diag — under "telemetry".
2220        assert_eq!(group_of(3), Some("telemetry"));
2221    }
2222
2223    #[test]
2224    fn runtime_adapter_maps_stable_nodes_to_runtime_handles() {
2225        let mut node_runtime = FakeNodeRuntime::default();
2226        let mut runtime = NodeRuntimeAdapter::<_, 2, 8, 4>::new(&mut node_runtime);
2227
2228        register_node::<TalkerComponent>(&mut runtime).unwrap();
2229
2230        assert_eq!(runtime.nodes().len(), 1);
2231        assert_eq!(runtime.nodes()[0].slot(), NodeSlot::new(0));
2232        assert_eq!(runtime.nodes()[0].stable_id(), "node");
2233        assert_eq!(runtime.nodes()[0].source_default_name(), "talker");
2234        assert_eq!(runtime.node_handle(NodeId::new("node")), Some(0));
2235        assert_eq!(runtime.entities().len(), 4);
2236        assert_eq!(runtime.entities()[0].slot, Some(EntitySlot::new(0)));
2237        assert_eq!(runtime.entities()[0].node_slot, Some(NodeSlot::new(0)));
2238        assert_eq!(
2239            runtime.entities()[1].callback_slot,
2240            Some(CallbackSlot::new(0))
2241        );
2242        assert_eq!(
2243            runtime.entities()[2].callback_slot,
2244            Some(CallbackSlot::new(1))
2245        );
2246        assert_eq!(runtime.callback_effects().len(), 2);
2247        assert_eq!(
2248            runtime.callback_effects()[0].callback_slot,
2249            Some(CallbackSlot::new(1))
2250        );
2251        assert_eq!(
2252            runtime.callback_effects()[0].entity_slot,
2253            Some(EntitySlot::new(0))
2254        );
2255    }
2256
2257    #[test]
2258    fn context_can_synthesize_stable_node_id_from_options_name() {
2259        let mut recorder = MetadataRecorder::<1, 0, 0>::new();
2260        let mut context = NodeContext::new("test", &mut recorder);
2261        let node = context
2262            .create_node(NodeOptions::new("talker").namespace("/demo").domain_id(42))
2263            .unwrap();
2264
2265        assert_eq!(node.id(), NodeId::new("talker"));
2266        // End the `node`/`context` borrows before re-borrowing `recorder`.
2267        let _ = node;
2268        let _ = context;
2269        assert_eq!(recorder.nodes().len(), 1);
2270        assert_eq!(recorder.nodes()[0].id.as_str(), "talker");
2271        assert_eq!(recorder.nodes()[0].name.as_str(), "talker");
2272        assert_eq!(recorder.nodes()[0].namespace.as_str(), "/demo");
2273        assert_eq!(recorder.nodes()[0].domain_id, 42);
2274    }
2275
2276    #[test]
2277    fn synthesized_node_ids_reject_duplicate_names() {
2278        let mut node_runtime = FakeNodeRuntime::default();
2279        let mut runtime = NodeRuntimeAdapter::<_, 2, 0, 0>::new(&mut node_runtime);
2280        {
2281            let mut context = NodeContext::new("test", &mut runtime);
2282            context.create_node(NodeOptions::new("talker")).unwrap();
2283        }
2284        let mut context = NodeContext::new("test", &mut runtime);
2285        let result = context.create_node(NodeOptions::new("talker"));
2286
2287        assert!(matches!(
2288            result,
2289            Err(NodeDeclError::Metadata(NodeMetadataError::DuplicateId))
2290        ));
2291    }
2292
2293    #[test]
2294    fn synthesized_entity_helpers_record_topic_and_callback_ids() {
2295        let mut recorder = MetadataRecorder::<1, 3, 2>::new();
2296        let mut context = NodeContext::new("test", &mut recorder);
2297        let mut node = context.create_node(NodeOptions::new("talker")).unwrap();
2298
2299        let publisher = node
2300            .create_publisher_for_topic::<TestMsg>("/chatter")
2301            .unwrap();
2302        let subscription = node
2303            .create_subscription_for_callback::<TestMsg>(CallbackId::new("on_message"), "/cmd")
2304            .unwrap();
2305        let _timer = node
2306            .create_timer_for_callback(CallbackId::new("on_tick"), TimerDuration::from_millis(10))
2307            .unwrap();
2308
2309        node.callback(CallbackId::new("on_tick"))
2310            .publishes_entity(&publisher)
2311            .unwrap();
2312        node.callback(CallbackId::new("on_message"))
2313            .reads_entity(&subscription)
2314            .unwrap();
2315
2316        assert_eq!(publisher.id(), EntityId::new("/chatter"));
2317        assert_eq!(subscription.id(), EntityId::new("on_message"));
2318        assert_eq!(recorder.entities().len(), 3);
2319
2320        let publisher = &recorder.entities()[0];
2321        assert_eq!(publisher.id.as_str(), "/chatter");
2322        assert_eq!(publisher.kind, EntityKind::Publisher);
2323        assert_eq!(publisher.source_name.as_str(), "/chatter");
2324
2325        let subscription = &recorder.entities()[1];
2326        assert_eq!(subscription.id.as_str(), "on_message");
2327        assert_eq!(subscription.kind, EntityKind::Subscription);
2328        assert_eq!(subscription.source_name.as_str(), "/cmd");
2329        assert_eq!(
2330            subscription.callback_id.as_ref().map(|id| id.as_str()),
2331            Some("on_message")
2332        );
2333
2334        let timer = &recorder.entities()[2];
2335        assert_eq!(timer.id.as_str(), "on_tick");
2336        assert_eq!(timer.kind, EntityKind::Timer);
2337        assert_eq!(
2338            timer.callback_id.as_ref().map(|id| id.as_str()),
2339            Some("on_tick")
2340        );
2341
2342        assert_eq!(recorder.callback_effects().len(), 2);
2343        assert_eq!(
2344            recorder.callback_effects()[0].entity_id.as_str(),
2345            "/chatter"
2346        );
2347        assert_eq!(
2348            recorder.callback_effects()[1].entity_id.as_str(),
2349            "on_message"
2350        );
2351    }
2352
2353    #[test]
2354    fn named_callback_helpers_avoid_manual_callback_ids() {
2355        let mut recorder = MetadataRecorder::<1, 3, 2>::new();
2356        let mut context = NodeContext::new("test", &mut recorder);
2357        let mut node = context.create_node(NodeOptions::new("listener")).unwrap();
2358
2359        let publisher = node
2360            .create_publisher_for_topic::<TestMsg>("/chatter")
2361            .unwrap();
2362        let subscription = node
2363            .create_subscription_for_callback_name::<TestMsg>("on_message", "/chatter")
2364            .unwrap();
2365        let timer = node
2366            .create_timer_for_callback_name("on_tick", TimerDuration::from_millis(10))
2367            .unwrap();
2368
2369        node.callback_for_name("on_message")
2370            .reads_entity(&subscription)
2371            .unwrap();
2372        node.callback_for_name("on_tick")
2373            .publishes_entity(&publisher)
2374            .unwrap();
2375
2376        assert_eq!(subscription.id().as_str(), "on_message");
2377        assert_eq!(timer.id().as_str(), "on_tick");
2378        assert_eq!(
2379            recorder.entities()[1]
2380                .callback_id
2381                .as_ref()
2382                .map(|id| id.as_str()),
2383            Some("on_message")
2384        );
2385        assert_eq!(
2386            recorder.entities()[2]
2387                .callback_id
2388                .as_ref()
2389                .map(|id| id.as_str()),
2390            Some("on_tick")
2391        );
2392        assert_eq!(
2393            recorder.callback_effects()[0].callback_id.as_str(),
2394            "on_message"
2395        );
2396        assert_eq!(
2397            recorder.callback_effects()[0].entity_id.as_str(),
2398            "on_message"
2399        );
2400        assert_eq!(
2401            recorder.callback_effects()[1].callback_id.as_str(),
2402            "on_tick"
2403        );
2404        assert_eq!(
2405            recorder.callback_effects()[1].entity_id.as_str(),
2406            "/chatter"
2407        );
2408    }
2409
2410    #[test]
2411    fn synthesized_entity_ids_reject_collisions() {
2412        let mut recorder = MetadataRecorder::<1, 2, 0>::new();
2413        let mut context = NodeContext::new("test", &mut recorder);
2414        let mut node = context.create_node(NodeOptions::new("talker")).unwrap();
2415
2416        node.create_publisher_for_topic::<TestMsg>("/chatter")
2417            .unwrap();
2418        let result = node.create_publisher_for_topic::<TestMsg>("/chatter");
2419
2420        assert!(matches!(
2421            result,
2422            Err(NodeDeclError::Metadata(NodeMetadataError::DuplicateId))
2423        ));
2424    }
2425
2426    /// Verifies the runtime adapter rejects duplicate nodes and unknown effect entities.
2427    #[test]
2428    fn runtime_adapter_rejects_unknown_entities() {
2429        let mut node_runtime = FakeNodeRuntime::default();
2430        let mut runtime = NodeRuntimeAdapter::<_, 1, 1, 1>::new(&mut node_runtime);
2431        runtime
2432            .create_node(NodeId::new("node"), NodeOptions::new("talker"))
2433            .unwrap();
2434
2435        assert_eq!(
2436            runtime.create_node(NodeId::new("node"), NodeOptions::new("other")),
2437            Err(NodeDeclError::Metadata(NodeMetadataError::DuplicateId))
2438        );
2439        assert_eq!(
2440            runtime.record_callback_effect(
2441                CallbackId::new("cb"),
2442                CallbackEffectKind::Reads,
2443                EntityId::new("missing")
2444            ),
2445            Err(NodeDeclError::Metadata(NodeMetadataError::UnknownEntity))
2446        );
2447    }
2448
2449    #[test]
2450    fn component_rejects_effect_for_unknown_entity() {
2451        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
2452        let mut context = NodeContext::new("test", &mut recorder);
2453        let result = context
2454            .callback(CallbackId::new("cb"))
2455            .reads(EntityId::new("missing"));
2456        assert!(matches!(
2457            result,
2458            Err(NodeDeclError::Metadata(NodeMetadataError::UnknownEntity))
2459        ));
2460    }
2461
2462    #[test]
2463    fn component_missing_export_error_message_is_clear() {
2464        assert_eq!(
2465            NodeDeclError::MissingExport.message(),
2466            MISSING_NODE_EXPORT_ERROR
2467        );
2468        assert_eq!(
2469            NodeDeclError::MissingExport.message(),
2470            "package has no exported nros component"
2471        );
2472    }
2473
2474    struct RobotComponent;
2475
2476    impl Node for RobotComponent {
2477        const NAME: &'static str = "robot_component";
2478
2479        fn register(context: &mut NodeContext<'_>) -> NodeResult<()> {
2480            {
2481                let mut sensors = context.create_node_with_id(
2482                    NodeId::new("node_sensors"),
2483                    NodeOptions::new("sensors"),
2484                )?;
2485                let _status =
2486                    sensors.create_publisher::<TestMsg>(EntityId::new("pub_status"), "~/status")?;
2487            }
2488
2489            let mut control = context
2490                .create_node_with_id(NodeId::new("node_control"), NodeOptions::new("control"))?;
2491            let _cmd = control.create_subscription::<TestMsg>(
2492                EntityId::new("sub_cmd"),
2493                CallbackId::new("cb_cmd"),
2494                "~/cmd",
2495            )?;
2496            let _reset = control.create_service_server::<TestService>(
2497                EntityId::new("srv_reset"),
2498                CallbackId::new("cb_reset"),
2499                "reset",
2500            )?;
2501            let _navigate = control.create_action_server_with_callbacks::<TestAction>(
2502                EntityId::new("act_navigate"),
2503                CallbackId::new("cb_nav_goal"),
2504                CallbackId::new("cb_nav_cancel"),
2505                CallbackId::new("cb_nav_accepted"),
2506                "~/navigate",
2507            )?;
2508            let _gain = control.declare_parameter_with_default(
2509                EntityId::new("param_gain"),
2510                "gain",
2511                ParameterDefault::Double(copy_str("1.5")?),
2512            )?;
2513
2514            control
2515                .callback(CallbackId::new("cb_cmd"))
2516                .publishes(EntityId::new("pub_status"))?
2517                .reads(EntityId::new("param_gain"))?;
2518            control
2519                .callback(CallbackId::new("cb_nav_accepted"))
2520                .writes(EntityId::new("param_gain"))?;
2521
2522            Ok(())
2523        }
2524    }
2525
2526    /// Verifies the component API records multi-node services, actions, and defaults.
2527    #[test]
2528    fn component_api_records_multi_node_services() {
2529        let mut recorder = MetadataRecorder::<4, 12, 4>::new();
2530        record_node_metadata::<RobotComponent>(&mut recorder).unwrap();
2531
2532        assert_eq!(recorder.nodes().len(), 2);
2533        assert_eq!(recorder.nodes()[0].id.as_str(), "node_sensors");
2534        assert_eq!(recorder.nodes()[1].id.as_str(), "node_control");
2535
2536        let status = recorder
2537            .entities()
2538            .iter()
2539            .find(|entity| entity.id.as_str() == "pub_status")
2540            .unwrap();
2541        assert_eq!(status.kind, EntityKind::Publisher);
2542        assert_eq!(status.source_name.as_str(), "~/status");
2543        assert_eq!(status.source_name_kind, SourceNameKind::Private);
2544
2545        let reset = recorder
2546            .entities()
2547            .iter()
2548            .find(|entity| entity.id.as_str() == "srv_reset")
2549            .unwrap();
2550        assert_eq!(reset.kind, EntityKind::ServiceServer);
2551        assert_eq!(
2552            reset.callback_id.as_ref().map(|id| id.as_str()),
2553            Some("cb_reset")
2554        );
2555
2556        let navigate = recorder
2557            .entities()
2558            .iter()
2559            .find(|entity| entity.id.as_str() == "act_navigate")
2560            .unwrap();
2561        assert_eq!(navigate.kind, EntityKind::ActionServer);
2562        assert_eq!(
2563            navigate.callback_id.as_ref().map(|id| id.as_str()),
2564            Some("cb_nav_goal")
2565        );
2566        assert_eq!(
2567            navigate
2568                .action_cancel_callback_id
2569                .as_ref()
2570                .map(|id| id.as_str()),
2571            Some("cb_nav_cancel")
2572        );
2573        assert_eq!(
2574            navigate
2575                .action_accepted_callback_id
2576                .as_ref()
2577                .map(|id| id.as_str()),
2578            Some("cb_nav_accepted")
2579        );
2580
2581        let gain = recorder
2582            .entities()
2583            .iter()
2584            .find(|entity| entity.id.as_str() == "param_gain")
2585            .unwrap();
2586        assert_eq!(gain.kind, EntityKind::Parameter);
2587        assert!(matches!(
2588            gain.parameter_default.as_ref(),
2589            Some(ParameterDefault::Double(value)) if value.as_str() == "1.5"
2590        ));
2591
2592        assert_eq!(recorder.callback_effects().len(), 3);
2593        assert!(recorder.callback_effects().iter().any(|effect| {
2594            effect.callback_id.as_str() == "cb_cmd"
2595                && effect.kind == CallbackEffectKind::Publishes
2596                && effect.entity_id.as_str() == "pub_status"
2597        }));
2598        assert!(recorder.callback_effects().iter().any(|effect| {
2599            effect.callback_id.as_str() == "cb_nav_accepted"
2600                && effect.kind == CallbackEffectKind::Writes
2601                && effect.entity_id.as_str() == "param_gain"
2602        }));
2603    }
2604
2605    #[cfg(feature = "std")]
2606    #[test]
2607    fn component_api_json_contains_planner_callback_links() {
2608        let mut recorder = MetadataRecorder::<4, 12, 4>::new();
2609        record_node_metadata::<RobotComponent>(&mut recorder).unwrap();
2610
2611        let json = recorder
2612            .to_source_metadata_json(&crate::SourceMetadataExport::new(
2613                "demo_robot",
2614                RobotComponent::NAME,
2615            ))
2616            .unwrap();
2617
2618        assert!(json.contains("\"callbacks\":["));
2619        assert!(json.contains("\"id\":\"cb_cmd\",\"declaration_slot\":0"));
2620        assert!(json.contains("\"kind\":\"subscription\""));
2621        assert!(json.contains("\"id\":\"cb_reset\",\"declaration_slot\":1"));
2622        assert!(json.contains("\"kind\":\"service\""));
2623        assert!(json.contains("\"id\":\"cb_nav_goal\",\"declaration_slot\":2"));
2624        assert!(json.contains("\"kind\":\"action_goal\""));
2625        assert!(json.contains("\"id\":\"cb_nav_cancel\",\"declaration_slot\":3"));
2626        assert!(json.contains("\"kind\":\"action_cancel\""));
2627        assert!(json.contains("\"id\":\"cb_nav_accepted\",\"declaration_slot\":4"));
2628        assert!(json.contains("\"kind\":\"action_accepted\""));
2629        assert!(json.contains("\"kind\":\"publishes\",\"entity\":\"pub_status\""));
2630        assert!(json.contains("\"kind\":\"reads_parameter\",\"entity\":\"param_gain\""));
2631        assert!(json.contains("\"kind\":\"writes_parameter\",\"entity\":\"param_gain\""));
2632        assert!(json.contains("\"goal_callback\":\"cb_nav_goal\""));
2633        assert!(json.contains("\"cancel_callback\":\"cb_nav_cancel\""));
2634        assert!(json.contains("\"accepted_callback\":\"cb_nav_accepted\""));
2635    }
2636
2637    // W.5.1 — an executable component callback runs its body: mutates state +
2638    // publishes immediately through the resolver (the substrate the generator
2639    // will wire). `TalkerComponent` already impls `Node` (declarative);
2640    // here it also impls `ExecutableNode`.
2641    impl ExecutableNode for TalkerComponent {
2642        type State = u32;
2643
2644        fn init() -> u32 {
2645            0
2646        }
2647
2648        fn on_callback(state: &mut u32, callback: Callback<'_>, ctx: &mut CallbackCtx<'_>) {
2649            if callback.as_str() == "on_tick" {
2650                *state += 1;
2651                // Publish through the declared publisher entity.
2652                let _ = ctx.publish::<TestMsg, 64>(EntityId::new("pub_chatter"), &TestMsg);
2653            }
2654        }
2655    }
2656
2657    #[test]
2658    fn executable_component_callback_publishes_and_mutates_state() {
2659        use core::cell::RefCell;
2660
2661        struct RecordingResolver {
2662            last: RefCell<Option<(MetadataString, usize)>>,
2663        }
2664        impl PublisherResolver for RecordingResolver {
2665            fn publish_raw(&self, entity_id: &str, data: &[u8]) -> NodeResult<()> {
2666                *self.last.borrow_mut() = Some((copy_str(entity_id)?, data.len()));
2667                Ok(())
2668            }
2669        }
2670
2671        let resolver = RecordingResolver {
2672            last: RefCell::new(None),
2673        };
2674        let mut state = TalkerComponent::init();
2675        let mut ctx = CallbackCtx::new(&[], &resolver);
2676
2677        // An unrelated callback id does nothing.
2678        TalkerComponent::on_callback(
2679            &mut state,
2680            Callback::__from_id(CallbackId::new("other")),
2681            &mut ctx,
2682        );
2683        assert_eq!(state, 0);
2684        assert!(resolver.last.borrow().is_none());
2685
2686        // The bound callback bumps state + publishes through "pub_chatter".
2687        TalkerComponent::on_callback(
2688            &mut state,
2689            Callback::__from_id(CallbackId::new("on_tick")),
2690            &mut ctx,
2691        );
2692        assert_eq!(state, 1);
2693        let last = resolver.last.borrow();
2694        let (entity, len) = last.as_ref().expect("a publish was recorded");
2695        assert_eq!(entity.as_str(), "pub_chatter");
2696        // Empty TestMsg ⇒ just the 4-byte CDR header.
2697        assert_eq!(*len, 4);
2698    }
2699
2700    // W.5.3 — a service-style body writes its reply through the CallbackCtx
2701    // reply sink; the trampoline reads `*written` back. A timer/sub ctx (no
2702    // sink) rejects a reply.
2703    #[test]
2704    fn callback_ctx_reply_sink_roundtrips() {
2705        struct NoopResolver;
2706        impl PublisherResolver for NoopResolver {
2707            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
2708                Ok(())
2709            }
2710        }
2711        let resolver = NoopResolver;
2712        let mut reply_buf = [0u8; 64];
2713        let mut written = 0usize;
2714        {
2715            let mut ctx = CallbackCtx::with_reply(&[], &resolver, &mut reply_buf, &mut written);
2716            ctx.reply::<TestMsg, 64>(&TestMsg).unwrap();
2717        }
2718        // Empty TestMsg ⇒ just the 4-byte CDR header.
2719        assert_eq!(written, 4);
2720
2721        // A reply-less ctx (timer / subscription) rejects a reply.
2722        let mut ctx2 = CallbackCtx::new(&[], &resolver);
2723        assert!(ctx2.reply_raw(&[1, 2, 3]).is_err());
2724    }
2725
2726    // Phase 250 Wave 2 — the declarative `.safety()` surface: a normal ctx has
2727    // no integrity status; one built with `new_with_integrity` exposes it, read
2728    // alongside the message in the same callback (Shape A).
2729    #[cfg(feature = "safety-e2e")]
2730    #[test]
2731    fn callback_ctx_integrity_surface() {
2732        struct NoopResolver;
2733        impl PublisherResolver for NoopResolver {
2734            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
2735                Ok(())
2736            }
2737        }
2738        let resolver = NoopResolver;
2739
2740        // Non-safety dispatch (timer / plain sub) → None.
2741        let ctx = CallbackCtx::new(&[], &resolver);
2742        assert!(ctx.integrity().is_none());
2743
2744        // Safety dispatch → the status rides alongside the payload.
2745        let status = crate::IntegrityStatus {
2746            gap: 2,
2747            duplicate: false,
2748            crc_valid: Some(true),
2749        };
2750        let ctx = CallbackCtx::new_with_integrity(&[], &resolver, &status);
2751        let got = ctx.integrity().expect("safety ctx carries status");
2752        assert_eq!(got.gap, 2);
2753        assert!(!got.duplicate);
2754        assert_eq!(got.crc_valid, Some(true));
2755    }
2756
2757    // W.5.3 — an action goal / cancel body sets its accept/reject decision
2758    // through the CallbackCtx decision sink; the trampoline returns `*out`. A
2759    // wrong-kind setter (or a sink-less ctx) errors.
2760    #[test]
2761    fn callback_ctx_decision_sink() {
2762        struct NoopResolver;
2763        impl PublisherResolver for NoopResolver {
2764            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
2765                Ok(())
2766            }
2767        }
2768        let resolver = NoopResolver;
2769
2770        let mut gr = GoalResponse::Reject;
2771        {
2772            let mut ctx = CallbackCtx::with_goal_decision(&[], &resolver, &mut gr);
2773            ctx.set_goal_response(GoalResponse::AcceptAndExecute)
2774                .unwrap();
2775            // Wrong-kind setter on a goal ctx errors.
2776            assert!(ctx.set_cancel_response(CancelResponse::Ok).is_err());
2777        }
2778        assert!(matches!(gr, GoalResponse::AcceptAndExecute));
2779
2780        let mut cr = CancelResponse::Rejected;
2781        {
2782            let mut ctx = CallbackCtx::with_cancel_decision(&[], &resolver, &mut cr);
2783            ctx.set_cancel_response(CancelResponse::Ok).unwrap();
2784        }
2785        assert!(matches!(cr, CancelResponse::Ok));
2786
2787        // A timer/sub ctx (no decision sink) rejects both.
2788        let mut ctx3 = CallbackCtx::new(&[], &resolver);
2789        assert!(ctx3.set_goal_response(GoalResponse::Reject).is_err());
2790        assert!(ctx3.set_cancel_response(CancelResponse::Ok).is_err());
2791    }
2792
2793    // W.5.6 — the tick hook publishes (immediate) + drives executor-backed action
2794    // ops (complete goal / publish feedback) through the ActionExecutor seam.
2795    #[test]
2796    fn tick_ctx_publish_and_action_ops() {
2797        use core::cell::Cell;
2798        struct RecPub {
2799            published: Cell<bool>,
2800        }
2801        impl PublisherResolver for RecPub {
2802            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
2803                self.published.set(true);
2804                Ok(())
2805            }
2806        }
2807        struct RecAct {
2808            completed: bool,
2809            fed: bool,
2810            visited: usize,
2811        }
2812        impl ActionExecutor for RecAct {
2813            fn complete_goal_raw(
2814                &mut self,
2815                _action_entity: &str,
2816                _goal_id: &GoalId,
2817                _status: GoalStatus,
2818                _result: &[u8],
2819            ) -> NodeResult<()> {
2820                self.completed = true;
2821                Ok(())
2822            }
2823            fn publish_feedback_raw(
2824                &mut self,
2825                _action_entity: &str,
2826                _goal_id: &GoalId,
2827                _feedback: &[u8],
2828            ) -> NodeResult<()> {
2829                self.fed = true;
2830                Ok(())
2831            }
2832            fn for_each_active_goal(
2833                &self,
2834                _action_entity: &str,
2835                visit: &mut dyn FnMut(&GoalId, GoalStatus),
2836            ) {
2837                // One pretend-active goal, so the tick body has something to drive.
2838                visit(&GoalId::zero(), GoalStatus::Executing);
2839            }
2840        }
2841
2842        struct RecClients;
2843        impl ClientDispatch for RecClients {
2844            fn call_raw(
2845                &mut self,
2846                _service: &str,
2847                _req: &[u8],
2848                _resp: &mut [u8],
2849            ) -> NodeResult<usize> {
2850                Err(NodeDeclError::Runtime)
2851            }
2852            fn send_goal_raw(&mut self, _action: &str, _goal: &[u8]) -> NodeResult<GoalId> {
2853                Err(NodeDeclError::Runtime)
2854            }
2855        }
2856
2857        let pubs = RecPub {
2858            published: Cell::new(false),
2859        };
2860        let mut acts = RecAct {
2861            completed: false,
2862            fed: false,
2863            visited: 0,
2864        };
2865        let mut clients = RecClients;
2866        let goal = GoalId::zero();
2867        let mut seen = 0usize;
2868        {
2869            let mut ctx = TickCtx::new(&pubs, &mut acts, &mut clients);
2870            ctx.publish::<TestMsg, 64>(EntityId::new("pub_x"), &TestMsg)
2871                .unwrap();
2872            // Discover the active goal the way a real tick body does, then act on it.
2873            ctx.for_each_active_goal(EntityId::new("act"), &mut |_id, _status| seen += 1);
2874            ctx.publish_feedback::<TestMsg, 64>(EntityId::new("act"), &goal, &TestMsg)
2875                .unwrap();
2876            ctx.complete_goal::<TestMsg, 64>(
2877                EntityId::new("act"),
2878                &goal,
2879                GoalStatus::Succeeded,
2880                &TestMsg,
2881            )
2882            .unwrap();
2883        }
2884        acts.visited = seen;
2885        assert!(pubs.published.get());
2886        assert!(acts.completed);
2887        assert!(acts.fed);
2888        assert_eq!(acts.visited, 1);
2889    }
2890
2891    /// Phase 216.A.3 — `Node::DISPATCH` defaults to
2892    /// `DispatchStrategy::Inline` so every pre-216 `impl Node`
2893    /// keeps compiling unchanged.
2894    #[test]
2895    fn node_dispatch_default_is_inline() {
2896        struct Dummy;
2897        impl Node for Dummy {
2898            const NAME: &'static str = "dummy";
2899            fn register(_: &mut NodeContext<'_>) -> NodeResult<()> {
2900                Ok(())
2901            }
2902        }
2903        assert_eq!(Dummy::DISPATCH, crate::DispatchStrategy::Inline);
2904    }
2905
2906    // Phase 216.A.5 — `nros::node!()` emits the
2907    // `__nros_node_<pkg>_dispatch_strategy()` ABI export. We invoke the
2908    // macro on a dummy Node + ExecutableNode pair in a private sub-module
2909    // here so the macro expansion lives inside the `nros` crate itself;
2910    // the emitted `#[unsafe(no_mangle)] extern "C"` symbol is global, so
2911    // the test below re-declares + calls it. If the macro stopped
2912    // emitting the symbol (or renamed it) this would link-fail.
2913    //
2914    // `<pkg>` resolves to `CARGO_PKG_NAME` after
2915    // `sanitize_pkg_name_for_symbol`. The `nros` crate's pkg name is
2916    // literal `nros`, so the expected symbol is
2917    // `__nros_node_nros_dispatch_strategy`.
2918    // Phase 216 final wave — the macro emit now references
2919    // `::nros::Executor` (rmw-cffi-gated) in addition to the existing
2920    // alloc-gated `__private_node_state_into_raw`. Gate the test on
2921    // both features so the macro invocation only attempts to expand
2922    // when every referenced symbol is present.
2923    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2924    mod dispatch_probe_macro_test {
2925        // `extern crate self as nros;` at the crate root (in `lib.rs`,
2926        // `cfg(test)`-gated) lets the `::nros::*` paths the macro emits
2927        // resolve in-crate.
2928        use super::*;
2929
2930        pub struct DispatchProbe;
2931
2932        impl Node for DispatchProbe {
2933            const NAME: &'static str = "dispatch_probe";
2934            // Default `DISPATCH = Inline` ⇒ discriminant 0.
2935            fn register(_: &mut NodeContext<'_>) -> NodeResult<()> {
2936                Ok(())
2937            }
2938        }
2939
2940        impl ExecutableNode for DispatchProbe {
2941            type State = ();
2942            fn init() -> Self::State {}
2943            fn on_callback(
2944                _state: &mut Self::State,
2945                _callback: Callback<'_>,
2946                _ctx: &mut CallbackCtx<'_>,
2947            ) {
2948            }
2949        }
2950
2951        // Emits both the per-pkg `register` wrapper AND the new
2952        // `__nros_node_nros_dispatch_strategy` ABI symbol.
2953        nros_macros::node!(DispatchProbe);
2954    }
2955
2956    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2957    #[test]
2958    fn node_macro_emits_dispatch_strategy_symbol() {
2959        // Re-declare the ABI export the macro just emitted. If the macro
2960        // elides the symbol (or renames it) this fails to link — exactly
2961        // the regression the test is meant to catch.
2962        unsafe extern "C" {
2963            fn __nros_node_nros_dispatch_strategy() -> u8;
2964        }
2965        let strategy = unsafe { __nros_node_nros_dispatch_strategy() };
2966        // The probe Node uses the default `DISPATCH = Inline`
2967        // (discriminant 0) — confirms the macro is splicing
2968        // `<Type as Node>::DISPATCH as u8`, not a hard-coded zero.
2969        assert_eq!(strategy, crate::DispatchStrategy::Inline as u8);
2970        assert_eq!(strategy, 0);
2971    }
2972
2973    // The `nros::node!()` macro also emits
2974    // `__nros_node_<pkg>_on_callback`, the extern "C" trampoline the
2975    // RTIC / Embassy dispatch tasks call after dequeuing a
2976    // `SignaledCallback<'static>` (see `nros-platform::SignaledCallback`).
2977    // The expansion lives in the same `dispatch_probe_macro_test`
2978    // sub-module as the dispatch-strategy probe, so a single
2979    // `nros_macros::node!(DispatchProbe);` invocation covers both
2980    // symbols. Symbol name resolves to
2981    // `__nros_node_nros_on_callback` (CARGO_PKG_NAME = "nros").
2982    //
2983    // The test only confirms the symbol is linkable — actually
2984    // invoking the trampoline would need a live State + CallbackCtx
2985    // pointer pair, which is the dispatch-task author's contract
2986    // (documented in the macro emit). A link-only probe is enough to
2987    // catch the macro silently eliding the export — the exact
2988    // regression class this test is for.
2989    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
2990    #[test]
2991    fn node_macro_emits_on_callback_symbol() {
2992        unsafe extern "C" {
2993            fn __nros_node_nros_on_callback(
2994                state: *mut core::ffi::c_void,
2995                cb_id_ptr: *const u8,
2996                cb_id_len: usize,
2997                ctx: *mut core::ffi::c_void,
2998            );
2999        }
3000        // Take the address of the symbol and feed it through
3001        // `core::hint::black_box` — forces the linker to resolve the
3002        // symbol and prevents the optimiser from folding the unused
3003        // reference away. If the macro stopped emitting the export
3004        // this line fails at link time, which is the exact regression
3005        // class this test catches. (`fn`-pointer values are never
3006        // null per Rust's type system, so a direct null check would
3007        // be a tautology — `-D useless-ptr-null-checks` would reject
3008        // it.)
3009        let fn_ptr: unsafe extern "C" fn(
3010            *mut core::ffi::c_void,
3011            *const u8,
3012            usize,
3013            *mut core::ffi::c_void,
3014        ) = __nros_node_nros_on_callback;
3015        core::hint::black_box(fn_ptr);
3016    }
3017
3018    #[test]
3019    fn create_subscription_static_returns_tag_matching_topic() {
3020        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
3021        let mut context = NodeContext::new("test", &mut recorder);
3022        let mut node = context.create_node(NodeOptions::new("listener")).unwrap();
3023        let tag = node
3024            .create_subscription_static::<TestMsg>("/chatter")
3025            .unwrap();
3026
3027        assert_eq!(tag.as_str(), "/chatter");
3028        assert!(tag == CallbackId::new("/chatter"));
3029        assert_eq!(recorder.entities().len(), 1);
3030        let entity = &recorder.entities()[0];
3031        assert_eq!(entity.kind, EntityKind::Subscription);
3032        assert_eq!(entity.source_name.as_str(), "/chatter");
3033        assert_eq!(
3034            entity.callback_id.as_ref().map(|id| id.as_str()),
3035            Some("/chatter")
3036        );
3037    }
3038
3039    #[test]
3040    fn create_service_static_returns_tag() {
3041        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
3042        let mut context = NodeContext::new("test", &mut recorder);
3043        let mut node = context.create_node(NodeOptions::new("server")).unwrap();
3044        let tag = node
3045            .create_service_static::<TestService>("/add_two_ints")
3046            .unwrap();
3047
3048        assert_eq!(tag.as_str(), "/add_two_ints");
3049        assert!(tag == CallbackId::new("/add_two_ints"));
3050        assert_eq!(recorder.entities().len(), 1);
3051        let entity = &recorder.entities()[0];
3052        assert_eq!(entity.kind, EntityKind::ServiceServer);
3053        assert_eq!(entity.source_name.as_str(), "/add_two_ints");
3054        assert_eq!(
3055            entity.callback_id.as_ref().map(|id| id.as_str()),
3056            Some("/add_two_ints")
3057        );
3058    }
3059
3060    #[test]
3061    fn create_service_helpers_use_name_as_entity_and_callback_id() {
3062        let mut recorder = MetadataRecorder::<1, 2, 1>::new();
3063        let mut context = NodeContext::new("test", &mut recorder);
3064        let mut node = context.create_node(NodeOptions::new("services")).unwrap();
3065        let server = node
3066            .create_service_server_for_name::<TestService>("/add_two_ints")
3067            .unwrap();
3068        let client = node
3069            .create_service_client_for_name::<TestService>("/reset")
3070            .unwrap();
3071
3072        assert_eq!(server.id(), EntityId::new("/add_two_ints"));
3073        assert_eq!(client.id(), EntityId::new("/reset"));
3074        assert_eq!(recorder.entities().len(), 2);
3075
3076        let server = &recorder.entities()[0];
3077        assert_eq!(server.kind, EntityKind::ServiceServer);
3078        assert_eq!(server.id.as_str(), "/add_two_ints");
3079        assert_eq!(server.source_name.as_str(), "/add_two_ints");
3080        assert_eq!(
3081            server.callback_id.as_ref().map(|id| id.as_str()),
3082            Some("/add_two_ints")
3083        );
3084
3085        let client = &recorder.entities()[1];
3086        assert_eq!(client.kind, EntityKind::ServiceClient);
3087        assert_eq!(client.id.as_str(), "/reset");
3088        assert_eq!(client.source_name.as_str(), "/reset");
3089        assert!(client.callback_id.is_none());
3090    }
3091
3092    #[test]
3093    fn create_action_static_returns_tag() {
3094        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
3095        let mut context = NodeContext::new("test", &mut recorder);
3096        let mut node = context.create_node(NodeOptions::new("server")).unwrap();
3097        let tag = node
3098            .create_action_static::<TestAction>("/fibonacci")
3099            .unwrap();
3100
3101        assert_eq!(tag.as_str(), "/fibonacci");
3102        assert!(tag == CallbackId::new("/fibonacci"));
3103        assert_eq!(recorder.entities().len(), 1);
3104        let entity = &recorder.entities()[0];
3105        assert_eq!(entity.kind, EntityKind::ActionServer);
3106        assert_eq!(entity.source_name.as_str(), "/fibonacci");
3107        assert_eq!(
3108            entity.callback_id.as_ref().map(|id| id.as_str()),
3109            Some("/fibonacci")
3110        );
3111        assert_eq!(
3112            entity
3113                .action_cancel_callback_id
3114                .as_ref()
3115                .map(|id| id.as_str()),
3116            Some("/fibonacci")
3117        );
3118        assert_eq!(
3119            entity
3120                .action_accepted_callback_id
3121                .as_ref()
3122                .map(|id| id.as_str()),
3123            Some("/fibonacci")
3124        );
3125    }
3126
3127    #[test]
3128    fn create_action_helpers_use_name_as_entity_and_default_callback_id() {
3129        let mut recorder = MetadataRecorder::<1, 2, 3>::new();
3130        let mut context = NodeContext::new("test", &mut recorder);
3131        let mut node = context.create_node(NodeOptions::new("actions")).unwrap();
3132        let server = node
3133            .create_action_server_for_name::<TestAction>("/fibonacci")
3134            .unwrap();
3135        let client = node
3136            .create_action_client_for_name::<TestAction>("/navigate")
3137            .unwrap();
3138
3139        assert_eq!(server.id(), EntityId::new("/fibonacci"));
3140        assert_eq!(client.id(), EntityId::new("/navigate"));
3141        assert_eq!(recorder.entities().len(), 2);
3142
3143        let server = &recorder.entities()[0];
3144        assert_eq!(server.kind, EntityKind::ActionServer);
3145        assert_eq!(server.id.as_str(), "/fibonacci");
3146        assert_eq!(server.source_name.as_str(), "/fibonacci");
3147        assert_eq!(
3148            server.callback_id.as_ref().map(|id| id.as_str()),
3149            Some("/fibonacci")
3150        );
3151        assert_eq!(
3152            server
3153                .action_cancel_callback_id
3154                .as_ref()
3155                .map(|id| id.as_str()),
3156            Some("/fibonacci")
3157        );
3158        assert_eq!(
3159            server
3160                .action_accepted_callback_id
3161                .as_ref()
3162                .map(|id| id.as_str()),
3163            Some("/fibonacci")
3164        );
3165
3166        let client = &recorder.entities()[1];
3167        assert_eq!(client.kind, EntityKind::ActionClient);
3168        assert_eq!(client.id.as_str(), "/navigate");
3169        assert_eq!(client.source_name.as_str(), "/navigate");
3170        assert!(client.callback_id.is_none());
3171    }
3172}