Skip to main content

nros/
node.rs

1//! Rust component API shared by metadata discovery and generated runtimes.
2
3use core::marker::PhantomData;
4
5// issue 0413 — the descriptor-registration bound. `MessageForRmw` is
6// `RosMessage` alone unless a descriptor-needing backend is linked, in which
7// case it also requires `nros_serdes::schema::Message` (the schema the Cyclone
8// descriptor builder walks). Generated message crates implement both.
9use nros_node::rmw_type_registry::MessageForRmw;
10
11use crate::{
12    ActionTag, CallbackId, CancelResponse, EntityId, GoalId, GoalResponse, GoalStatus,
13    ParameterType, QoSProfile, RosAction, RosMessage, RosService, ServiceTag, SubscriptionTag,
14    TimerDuration,
15    heapless::Vec,
16    node_metadata::{
17        CallbackEffectKind, CallbackEffectMetadata, CallbackSlot, EntityKind, EntityMetadata,
18        EntityMetadataSpec, EntitySlot, MetadataRecorder, MetadataString, NodeId,
19        NodeMetadataError, NodeSlot, ParameterDefault, SourceLocationMetadata, copy_str,
20        entity_callback_ids, entity_metadata,
21    },
22};
23
24// Phase 212.N.7 step-6 closing sweep — `component_register_symbol`
25// removed. It built the legacy `__nros_component_<pkg>_register`
26// symbol name for the M.5.a BSP baker to look up by literal. step-6
27// retired the macro emit + step-4 deleted the FreeRTOS BSP baker
28// crate that was the sole live consumer. The Phase 212.N Entry pkg
29// path calls `<pkg>::register(runtime)` through the path API, so this
30// helper has no live callers.
31
32/// Clear diagnostic for packages missing [`nros::node!`](macro@crate::node).
33pub const MISSING_NODE_EXPORT_ERROR: &str = "package has no exported nros component";
34
35/// Result type for component declarations.
36pub type NodeResult<T = ()> = Result<T, NodeDeclError>;
37
38/// Register `M`'s runtime type descriptor with a descriptor-needing backend
39/// (Cyclone DDS), from the DECLARATIVE path — issue 0413.
40///
41/// The imperative API's typed creators (`Node::create_publisher_with_qos::<M>`
42/// in `nros-node`) already call `register_type::<M>()` before asking the cffi
43/// vtable for the entity, because Cyclone resolves topic types through a
44/// RUNTIME registry and `dds_create_topic` fails without it.
45///
46/// The declarative Node API does not reach those creators. `NodeContext`
47/// records `EntityMetadata` and the sink calls the type-ERASED
48/// `create_generic_publisher_with_qos(topic, type_name, type_hash, qos)` —
49/// which has a type NAME and no `M`, so it cannot register anything. The
50/// descriptor was therefore never built, `find_descriptor` returned null in
51/// `publisher.cpp`, and the entity failed with `NROS_RMW_RET_UNSUPPORTED` ->
52/// `TransportError::PublisherCreationFailed` -> `NodeDeclError::Runtime` ->
53/// `RuntimeError::NodeRegister("<pkg>")`, four collapses away from the cause.
54///
55/// So it is registered HERE, at the last point that still knows `M`. A no-op
56/// unless a descriptor-needing backend installed a registrar
57/// (`nros_rmw::register_type_descriptor` returns `Ok` when the slot is empty),
58/// so zenoh / XRCE builds are unaffected.
59///
60/// Why this only surfaced now: every native Rust example was
61/// `[package.metadata.nros.application]` (imperative, typed creators) until
62/// phase-338 W3 made them Node-class. C and C++ were never affected — they use
63/// the static `descriptors.cpp` table, which is why `c/talker` published
64/// normally against the same backend while `rust/talker` could not.
65#[inline]
66fn register_declared_type<M: nros_node::rmw_type_registry::MessageForRmw>() -> NodeResult<()> {
67    nros_node::rmw_type_registry::register_type::<M>().map_err(|_| NodeDeclError::Runtime)
68}
69
70/// issue 0413, service half — register both payload types of `S`.
71///
72/// Services reach the same type-erased sink path as publishers, so the
73/// descriptor has to be built here too. The bound is a WHERE-CLAUSE on the
74/// declarative methods rather than on `RosService` itself: `RosService` lives in
75/// `nros-core`, which cannot depend on `nros-node` where `MessageForRmw` is
76/// defined. Generated service crates satisfy it (their payloads implement
77/// `schema::Message`); a hand-rolled service used only with zenoh/XRCE is
78/// unaffected, because `MessageForRmw` collapses to `RosMessage` when no
79/// descriptor-needing backend is linked.
80#[inline]
81fn register_declared_service<S: RosService>() -> NodeResult<()>
82where
83    S::Request: nros_node::rmw_type_registry::MessageForRmw,
84    S::Reply: nros_node::rmw_type_registry::MessageForRmw,
85{
86    register_declared_type::<S::Request>()?;
87    register_declared_type::<S::Reply>()
88}
89
90/// Node declaration error.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum NodeDeclError {
93    /// Metadata recorder rejected the declaration.
94    Metadata(NodeMetadataError),
95    /// Host/runtime discovery could not find `nros::node!` export.
96    MissingExport,
97    /// Generated runtime rejected the declaration.
98    Runtime,
99    /// The executor's fixed callback-entry table is full — a timer /
100    /// subscription / service / action could not claim a slot (issue 0095).
101    /// Carries the capacity cause through the `NodeError → NodeDeclError`
102    /// collapse so the register seam can name `NROS_EXECUTOR_MAX_CBS`.
103    ExecutorFull,
104    /// A publish named an entity this component has no publisher for — the
105    /// LOOKUP failed, nothing was ever handed to the transport.
106    ///
107    /// issue 0736 — split out of [`Self::Runtime`], which the publish path
108    /// returned for BOTH "the transport rejected the sample" and "there is no
109    /// such publisher": `lookup_publisher(...).unwrap_or(Err(Runtime))`. Those
110    /// are different bugs in different layers with different fixes, and from a
111    /// serial console they were the same line. Exactly the conflation #572
112    /// removed one level out, where discarding the result made "the timer never
113    /// fired" and "every publish failed" the same observation.
114    UnknownPublisher,
115}
116
117impl NodeDeclError {
118    /// Human-readable static message for diagnostics that cross FFI/plugin boundaries.
119    pub const fn message(self) -> &'static str {
120        match self {
121            Self::Metadata(NodeMetadataError::Capacity) => "component metadata capacity exceeded",
122            Self::Metadata(NodeMetadataError::NameTooLong) => "component metadata name too long",
123            Self::Metadata(NodeMetadataError::UnknownNode) => {
124                "component entity references an unknown node"
125            }
126            Self::Metadata(NodeMetadataError::UnknownEntity) => {
127                "component callback effect references an unknown entity"
128            }
129            Self::Metadata(NodeMetadataError::DuplicateId) => {
130                "component metadata contains a duplicate stable ID"
131            }
132            Self::MissingExport => MISSING_NODE_EXPORT_ERROR,
133            Self::Runtime => "component runtime rejected declaration",
134            Self::UnknownPublisher => "no publisher declared for that entity",
135            Self::ExecutorFull => {
136                "executor callback table full — raise NROS_EXECUTOR_MAX_CBS \
137                 (build-time, default 4)"
138            }
139        }
140    }
141}
142
143impl From<NodeMetadataError> for NodeDeclError {
144    fn from(value: NodeMetadataError) -> Self {
145        Self::Metadata(value)
146    }
147}
148
149/// issue 0413, action half — register every wire payload type of `A`.
150///
151/// Mirrors the eight `register_type::<A::…>()` calls the IMPERATIVE action
152/// creator makes (`nros-node/src/executor/action.rs`); the declarative path
153/// reaches the same type-erased sink and would otherwise register none of them.
154/// `A::register_protocol_types()` covers the fixed `action_msgs` types the
155/// cancel/status plumbing serializes, exactly as the imperative path does.
156#[inline]
157fn register_declared_action<A: RosAction>() -> NodeResult<()>
158where
159    A::Goal: nros_node::rmw_type_registry::MessageForRmw,
160    A::Result: nros_node::rmw_type_registry::MessageForRmw,
161    A::Feedback: nros_node::rmw_type_registry::MessageForRmw,
162    A::SendGoalRequest: nros_node::rmw_type_registry::MessageForRmw,
163    A::SendGoalResponse: nros_node::rmw_type_registry::MessageForRmw,
164    A::GetResultRequest: nros_node::rmw_type_registry::MessageForRmw,
165    A::GetResultResponse: nros_node::rmw_type_registry::MessageForRmw,
166    A::FeedbackMessage: nros_node::rmw_type_registry::MessageForRmw,
167{
168    register_declared_type::<A::Goal>()?;
169    register_declared_type::<A::Result>()?;
170    register_declared_type::<A::Feedback>()?;
171    register_declared_type::<A::SendGoalRequest>()?;
172    register_declared_type::<A::SendGoalResponse>()?;
173    register_declared_type::<A::GetResultRequest>()?;
174    register_declared_type::<A::GetResultResponse>()?;
175    register_declared_type::<A::FeedbackMessage>()?;
176    A::register_protocol_types().map_err(|()| NodeDeclError::Runtime)
177}
178
179/// phase-391 W5-endgame step 2c (issue 0857) — a component class's declared
180/// upper bounds, PER ENTITY KIND, for sizing its cell registries at compile
181/// time.
182///
183/// The runtime's per-class cell storage is static, so its registries pay
184/// their CAPACITY whether or not entities fill it — and one publisher slot
185/// costs ~1.35 KiB (the loan arena rides inside). The default is the
186/// `NROS_RUNTIME_MAX_CELL_ENTITIES` knob per kind, which always works;
187/// a class that declares its real bounds pays exactly what it uses.
188/// Declaring FEWER than `register()` creates is a loud registration error
189/// (registry full), never a silent drop.
190///
191/// Public and non-generic on purpose (the `ExecutorSizing` rule): the const
192/// generics this feeds stay behind the `nros::node!` macro emission, so no
193/// other language ever sees them.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub struct EntityBounds {
196    /// Publishers this class creates (each slot ~1.35 KiB — the big one).
197    pub publishers: usize,
198    /// Service servers (sizes the trampoline-context slab, not a registry).
199    pub service_servers: usize,
200    /// Service clients.
201    pub service_clients: usize,
202    /// Action clients.
203    pub action_clients: usize,
204    /// Action servers.
205    pub action_servers: usize,
206}
207
208impl EntityBounds {
209    /// The knob-capped default (`NROS_RUNTIME_MAX_CELL_ENTITIES` per kind).
210    pub const fn knob_caps() -> Self {
211        Self {
212            publishers: crate::config::MAX_CELL_ENTITIES,
213            service_servers: crate::config::MAX_CELL_ENTITIES,
214            service_clients: crate::config::MAX_CELL_ENTITIES,
215            action_clients: crate::config::MAX_CELL_ENTITIES,
216            action_servers: crate::config::MAX_CELL_ENTITIES,
217        }
218    }
219
220    /// Exact bounds, spelled positionally:
221    /// `(publishers, service_servers, service_clients, action_clients, action_servers)`.
222    pub const fn exact(
223        publishers: usize,
224        service_servers: usize,
225        service_clients: usize,
226        action_clients: usize,
227        action_servers: usize,
228    ) -> Self {
229        Self {
230            publishers,
231            service_servers,
232            service_clients,
233            action_clients,
234            action_servers,
235        }
236    }
237}
238
239/// Rust component entry point.
240pub trait Node {
241    /// Source component name used in metadata and diagnostics.
242    const NAME: &'static str;
243
244    /// Phase 216.A.3 — declares which dispatch strategy this Node
245    /// requires from the runtime. Defaults to
246    /// [`crate::DispatchStrategy::Inline`] so every existing component
247    /// keeps compiling without source change; the substrate (Phase
248    /// 216.A.2) and `nros check` (Phase 216.D.1) consume it to
249    /// pick / validate the board-side dispatch path.
250    const DISPATCH: crate::DispatchStrategy = crate::DispatchStrategy::Inline;
251
252    /// phase-391 W5-endgame step 2c — this class's per-kind entity bounds,
253    /// sizing its static cell registries. Defaults to the knob caps so every
254    /// existing component keeps compiling; declare [`EntityBounds::exact`]
255    /// to stop paying for capacity `register()` never fills.
256    const ENTITY_BOUNDS: EntityBounds = EntityBounds::knob_caps();
257
258    /// Declare nodes, entities, callbacks, params, and optional effects.
259    fn register(context: &mut NodeContext<'_>) -> NodeResult<()>;
260}
261
262/// Runtime-neutral node construction options.
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub struct NodeOptions<'a> {
265    /// Source node name. Launch planning may remap/namespace later.
266    pub name: &'a str,
267    /// Source namespace. Defaults to `/`.
268    pub namespace: &'a str,
269    /// ROS domain ID hint. Defaults to `0`.
270    pub domain_id: u32,
271}
272
273/// Runtime callback event delivered to an executable Node.
274///
275/// The value carries the source callback name declared by the component, but
276/// does not expose the generated/internal callback ID type to product code.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
278pub struct Callback<'a> {
279    id: CallbackId<'a>,
280}
281
282impl<'a> Callback<'a> {
283    /// Borrow the source callback name.
284    pub const fn as_str(self) -> &'a str {
285        self.id.as_str()
286    }
287
288    /// Return true when this callback matches `name`.
289    pub fn is_named(self, name: &str) -> bool {
290        self.as_str() == name
291    }
292
293    /// Build a callback event from the internal/generated callback ID.
294    #[doc(hidden)]
295    pub const fn __from_id(id: CallbackId<'a>) -> Self {
296        Self { id }
297    }
298}
299
300impl<'a> NodeOptions<'a> {
301    /// Create node options with default namespace and domain.
302    pub const fn new(name: &'a str) -> Self {
303        Self {
304            name,
305            namespace: "/",
306            domain_id: 0,
307        }
308    }
309
310    /// Set source namespace.
311    pub const fn namespace(mut self, namespace: &'a str) -> Self {
312        self.namespace = namespace;
313        self
314    }
315
316    /// Set ROS domain ID hint.
317    pub const fn domain_id(mut self, domain_id: u32) -> Self {
318        self.domain_id = domain_id;
319        self
320    }
321}
322
323/// Declaration sink implemented by metadata recorders and generated runtimes.
324pub trait NodeRuntime {
325    /// Declare a component node.
326    fn create_node(&mut self, id: NodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()>;
327
328    /// Declare a publisher, subscription, timer, service, action, or parameter.
329    fn create_entity(&mut self, metadata: EntityMetadata) -> NodeResult<()>;
330
331    /// Add optional callback effect metadata.
332    fn record_callback_effect(
333        &mut self,
334        callback_id: CallbackId<'_>,
335        kind: CallbackEffectKind,
336        entity_id: EntityId<'_>,
337    ) -> NodeResult<()>;
338}
339
340impl<const MAX_NODES: usize, const MAX_ENTITIES: usize, const MAX_CALLBACKS: usize> NodeRuntime
341    for MetadataRecorder<MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
342{
343    fn create_node(&mut self, id: NodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()> {
344        self.push_node(id, options.name, options.namespace, options.domain_id)?;
345        Ok(())
346    }
347
348    fn create_entity(&mut self, metadata: EntityMetadata) -> NodeResult<()> {
349        self.push_entity(metadata)?;
350        Ok(())
351    }
352
353    fn record_callback_effect(
354        &mut self,
355        callback_id: CallbackId<'_>,
356        kind: CallbackEffectKind,
357        entity_id: EntityId<'_>,
358    ) -> NodeResult<()> {
359        self.push_callback_effect(callback_id, kind, entity_id)?;
360        Ok(())
361    }
362}
363
364/// Runtime node sink used by generated component executors.
365///
366/// Metadata mode records declarations only. Runtime mode maps each stable
367/// component node ID to a concrete executor-side node handle; entity callback
368/// registration is completed by generated code that owns the actual callback
369/// functions.
370pub trait DeclaredNodeRuntime {
371    /// Concrete node handle owned by the runtime executor.
372    type NodeHandle: Copy + Eq;
373
374    /// Create a runtime node from source-level component options.
375    fn build_component_node(
376        &mut self,
377        id: NodeId<'_>,
378        options: NodeOptions<'_>,
379    ) -> NodeResult<Self::NodeHandle>;
380}
381
382/// Recorded runtime node mapping.
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub struct RuntimeNodeRecord<H: Copy + Eq> {
385    slot: NodeSlot,
386    stable_id: MetadataString,
387    source_default_name: MetadataString,
388    handle: H,
389}
390
391impl<H: Copy + Eq> RuntimeNodeRecord<H> {
392    /// Declaration-order node slot.
393    pub const fn slot(&self) -> NodeSlot {
394        self.slot
395    }
396
397    /// Stable component node ID.
398    pub fn stable_id(&self) -> &str {
399        &self.stable_id
400    }
401
402    /// Source-authored default ROS node name.
403    pub fn source_default_name(&self) -> &str {
404        &self.source_default_name
405    }
406
407    /// Runtime executor node handle.
408    pub const fn handle(&self) -> H {
409        self.handle
410    }
411}
412
413/// Runtime adapter used by generated main ownership code.
414pub struct NodeRuntimeAdapter<
415    'a,
416    R: DeclaredNodeRuntime + ?Sized,
417    const MAX_NODES: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_NODES },
418    const MAX_ENTITIES: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_ENTITIES },
419    const MAX_CALLBACKS: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_CALLBACKS },
420> {
421    node_runtime: &'a mut R,
422    nodes: Vec<RuntimeNodeRecord<R::NodeHandle>, MAX_NODES>,
423    entities: Vec<EntityMetadata, MAX_ENTITIES>,
424    callback_effects: Vec<CallbackEffectMetadata, MAX_CALLBACKS>,
425}
426
427impl<
428    'a,
429    R: DeclaredNodeRuntime + ?Sized,
430    const MAX_NODES: usize,
431    const MAX_ENTITIES: usize,
432    const MAX_CALLBACKS: usize,
433> NodeRuntimeAdapter<'a, R, MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
434{
435    /// Build a runtime adapter around a generated executor owner.
436    pub fn new(node_runtime: &'a mut R) -> Self {
437        Self {
438            node_runtime,
439            nodes: Vec::new(),
440            entities: Vec::new(),
441            callback_effects: Vec::new(),
442        }
443    }
444
445    /// Runtime node mappings in declaration order.
446    pub fn nodes(&self) -> &[RuntimeNodeRecord<R::NodeHandle>] {
447        &self.nodes
448    }
449
450    /// Entity declarations accepted for generated runtime binding.
451    pub fn entities(&self) -> &[EntityMetadata] {
452        &self.entities
453    }
454
455    /// Optional callback effects accepted for generated runtime binding.
456    pub fn callback_effects(&self) -> &[CallbackEffectMetadata] {
457        &self.callback_effects
458    }
459
460    /// Lookup an executor node handle by stable component node ID.
461    pub fn node_handle(&self, stable_id: NodeId<'_>) -> Option<R::NodeHandle> {
462        self.nodes
463            .iter()
464            .find(|node| node.stable_id() == stable_id.as_str())
465            .map(RuntimeNodeRecord::handle)
466    }
467
468    fn contains_node(&self, stable_id: &str) -> bool {
469        self.nodes.iter().any(|node| node.stable_id() == stable_id)
470    }
471
472    fn contains_entity(&self, stable_id: &str) -> bool {
473        self.entities
474            .iter()
475            .any(|entity| entity.id.as_str() == stable_id)
476    }
477
478    fn node_slot_for_id(&self, stable_id: &str) -> Option<NodeSlot> {
479        self.nodes
480            .iter()
481            .find(|node| node.stable_id() == stable_id)
482            .map(RuntimeNodeRecord::slot)
483    }
484
485    fn entity_slot_for_id(&self, stable_id: &str) -> Option<EntitySlot> {
486        self.entities
487            .iter()
488            .find(|entity| entity.id.as_str() == stable_id)
489            .and_then(|entity| entity.slot)
490    }
491
492    fn callback_slot_for_current_entity(
493        &self,
494        id: &str,
495        current_callbacks: &mut Vec<MetadataString, 3>,
496        next_callback_slot: &mut usize,
497    ) -> CallbackSlot {
498        if let Some(slot) = self.callback_slot_for_id(id) {
499            return slot;
500        }
501        if let Some((index, _)) = current_callbacks
502            .iter()
503            .enumerate()
504            .find(|(_, callback_id)| callback_id.as_str() == id)
505        {
506            return CallbackSlot::new(self.callback_slot_count() + index);
507        }
508        let slot = CallbackSlot::new(*next_callback_slot);
509        let _ = current_callbacks
510            .push(copy_str(id).expect("callback ID already fits metadata string capacity"));
511        *next_callback_slot += 1;
512        slot
513    }
514
515    fn callback_slot_for_id(&self, id: &str) -> Option<CallbackSlot> {
516        let mut seen = Vec::<&str, MAX_CALLBACKS>::new();
517        for entity in &self.entities {
518            for callback_id in entity_callback_ids(entity) {
519                let Some(callback_id) = callback_id else {
520                    continue;
521                };
522                let callback_id = callback_id.as_str();
523                if seen.contains(&callback_id) {
524                    continue;
525                }
526                if callback_id == id {
527                    return Some(CallbackSlot::new(seen.len()));
528                }
529                let _ = seen.push(callback_id);
530            }
531        }
532        None
533    }
534
535    fn callback_slot_count(&self) -> usize {
536        let mut seen = Vec::<&str, MAX_CALLBACKS>::new();
537        for entity in &self.entities {
538            for callback_id in entity_callback_ids(entity) {
539                let Some(callback_id) = callback_id else {
540                    continue;
541                };
542                let callback_id = callback_id.as_str();
543                if !seen.contains(&callback_id) {
544                    let _ = seen.push(callback_id);
545                }
546            }
547        }
548        seen.len()
549    }
550}
551
552impl<
553    R: DeclaredNodeRuntime + ?Sized,
554    const MAX_NODES: usize,
555    const MAX_ENTITIES: usize,
556    const MAX_CALLBACKS: usize,
557> NodeRuntime for NodeRuntimeAdapter<'_, R, MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
558{
559    fn create_node(&mut self, id: NodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()> {
560        if self.contains_node(id.as_str()) {
561            return Err(NodeMetadataError::DuplicateId.into());
562        }
563        let handle = self.node_runtime.build_component_node(id, options)?;
564        let slot = NodeSlot::new(self.nodes.len());
565        self.nodes
566            .push(RuntimeNodeRecord {
567                slot,
568                stable_id: copy_str(id.as_str())?,
569                source_default_name: copy_str(options.name)?,
570                handle,
571            })
572            .map_err(|_| NodeDeclError::Metadata(NodeMetadataError::Capacity))?;
573        Ok(())
574    }
575
576    fn create_entity(&mut self, mut metadata: EntityMetadata) -> NodeResult<()> {
577        if !self.contains_node(metadata.node_id.as_str()) {
578            return Err(NodeMetadataError::UnknownNode.into());
579        }
580        if self.contains_entity(metadata.id.as_str()) {
581            return Err(NodeMetadataError::DuplicateId.into());
582        }
583        metadata.slot = Some(EntitySlot::new(self.entities.len()));
584        metadata.node_slot = self.node_slot_for_id(&metadata.node_id);
585        let mut current_callbacks = Vec::<MetadataString, 3>::new();
586        let mut next_callback_slot = self.callback_slot_count();
587        metadata.callback_slot = metadata.callback_id.as_ref().map(|callback_id| {
588            self.callback_slot_for_current_entity(
589                callback_id.as_str(),
590                &mut current_callbacks,
591                &mut next_callback_slot,
592            )
593        });
594        metadata.action_cancel_callback_slot =
595            metadata
596                .action_cancel_callback_id
597                .as_ref()
598                .map(|callback_id| {
599                    self.callback_slot_for_current_entity(
600                        callback_id.as_str(),
601                        &mut current_callbacks,
602                        &mut next_callback_slot,
603                    )
604                });
605        metadata.action_accepted_callback_slot =
606            metadata
607                .action_accepted_callback_id
608                .as_ref()
609                .map(|callback_id| {
610                    self.callback_slot_for_current_entity(
611                        callback_id.as_str(),
612                        &mut current_callbacks,
613                        &mut next_callback_slot,
614                    )
615                });
616        self.entities
617            .push(metadata)
618            .map_err(|_| NodeDeclError::Metadata(NodeMetadataError::Capacity))?;
619        Ok(())
620    }
621
622    fn record_callback_effect(
623        &mut self,
624        callback_id: CallbackId<'_>,
625        kind: CallbackEffectKind,
626        entity_id: EntityId<'_>,
627    ) -> NodeResult<()> {
628        if !self.contains_entity(entity_id.as_str()) {
629            return Err(NodeMetadataError::UnknownEntity.into());
630        }
631        self.callback_effects
632            .push(CallbackEffectMetadata {
633                callback_id: copy_str(callback_id.as_str())?,
634                callback_slot: self.callback_slot_for_id(callback_id.as_str()),
635                kind,
636                entity_id: copy_str(entity_id.as_str())?,
637                entity_slot: self.entity_slot_for_id(entity_id.as_str()),
638            })
639            .map_err(|_| NodeDeclError::Metadata(NodeMetadataError::Capacity))?;
640        Ok(())
641    }
642}
643
644#[cfg(feature = "rmw-cffi")]
645impl DeclaredNodeRuntime for crate::Executor<'static> {
646    type NodeHandle = nros_node::executor::NodeId;
647
648    fn build_component_node(
649        &mut self,
650        _id: NodeId<'_>,
651        options: NodeOptions<'_>,
652    ) -> NodeResult<Self::NodeHandle> {
653        self.node_builder(options.name)
654            .namespace(options.namespace)
655            .domain_id(options.domain_id)
656            .build()
657            .map_err(|_| NodeDeclError::Runtime)
658    }
659}
660
661/// Runtime adapter backed by [`Executor`](crate::Executor).
662#[cfg(feature = "rmw-cffi")]
663pub type NodeExecutorRuntime<
664    'a,
665    const MAX_NODES: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_NODES },
666    const MAX_ENTITIES: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_ENTITIES },
667    const MAX_CALLBACKS: usize = { crate::node_metadata::DEFAULT_MAX_METADATA_CALLBACKS },
668> = NodeRuntimeAdapter<'a, crate::Executor<'static>, MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>;
669
670/// Node declaration context. Does not own middleware transport.
671pub struct NodeContext<'a, R: NodeRuntime + ?Sized = dyn NodeRuntime + 'a> {
672    component_name: &'static str,
673    runtime: &'a mut R,
674    /// Phase 264 W4a — this node instance's parameters, the COMPILE-BAKED initial
675    /// values from the launch `<param name=… value=…/>` entries (`nros::main!`
676    /// bakes them + threads them through `install_node_typed_with_params`). Empty
677    /// for the metadata-recorder / no-launch paths. A node reads them in
678    /// `register()` via [`param`](Self::param) and stashes the typed value on its
679    /// `State` (RFC-0004 §10 — baked initials; runtime reconfig is W4b).
680    params: &'a [(&'a str, &'a str)],
681}
682
683impl<'a, R: NodeRuntime + ?Sized> NodeContext<'a, R> {
684    /// Build a context over a metadata recorder or generated runtime.
685    pub fn new(component_name: &'static str, runtime: &'a mut R) -> Self {
686        Self {
687            component_name,
688            runtime,
689            params: &[],
690        }
691    }
692
693    /// Phase 264 W4a — seed this node instance's baked launch parameters (called by
694    /// `install_node_typed_with_params` before `Node::register`).
695    pub fn set_params(&mut self, params: &'a [(&'a str, &'a str)]) {
696        self.params = params;
697    }
698
699    /// Phase 264 W4a — the baked initial value of launch parameter `name`, or
700    /// `None` if the launch declared no `<param name="…"/>` for this node
701    /// instance. Read in `register()` and parse/stash on `State` (RFC-0004 §10).
702    pub fn param(&self, name: &str) -> Option<&'a str> {
703        self.params
704            .iter()
705            .find(|(k, _)| *k == name)
706            .map(|(_, v)| *v)
707    }
708
709    /// Source component name.
710    pub const fn component_name(&self) -> &'static str {
711        self.component_name
712    }
713
714    /// Declare a node with an explicit stable node ID.
715    ///
716    /// Generated/internal form; product code should use
717    /// [`create_node`](Self::create_node).
718    #[doc(hidden)]
719    pub fn create_node_with_id<'id>(
720        &mut self,
721        id: NodeId<'id>,
722        options: NodeOptions<'_>,
723    ) -> NodeResult<DeclaredNode<'_, 'id, R>> {
724        self.runtime.create_node(id, options)?;
725        Ok(DeclaredNode {
726            runtime: self.runtime,
727            id,
728            current_group: None,
729        })
730    }
731
732    /// Declare a node using `options.name` as the stable node ID.
733    ///
734    /// This mirrors the common rclcpp/rclrs shape where a node package supplies
735    /// node options and the node name, while nano-ros keeps the generated stable
736    /// ID as internal metadata.
737    pub fn create_node<'id>(
738        &mut self,
739        options: NodeOptions<'id>,
740    ) -> NodeResult<DeclaredNode<'_, 'id, R>> {
741        self.create_node_with_id(NodeId::new(options.name), options)
742    }
743
744    /// Deprecated alias for [`create_node`](Self::create_node).
745    #[deprecated(note = "use create_node(NodeOptions)")]
746    pub fn create_node_with_options<'id>(
747        &mut self,
748        options: NodeOptions<'id>,
749    ) -> NodeResult<DeclaredNode<'_, 'id, R>> {
750        self.create_node(options)
751    }
752
753    /// Record optional effects for a callback not tied to a node wrapper.
754    #[doc(hidden)]
755    pub fn callback<'id>(&mut self, id: CallbackId<'id>) -> CallbackEffects<'_, 'id, R> {
756        CallbackEffects {
757            runtime: self.runtime,
758            id,
759        }
760    }
761}
762
763/// Declared component node.
764pub struct DeclaredNode<'ctx, 'id, R: NodeRuntime + ?Sized = dyn NodeRuntime + 'ctx> {
765    runtime: &'ctx mut R,
766    id: NodeId<'id>,
767    /// Phase 228.C sticky callback-group label. When set (via
768    /// [`callback_group`](Self::callback_group)), every subsequently
769    /// declared entity that does not carry its own group inherits it,
770    /// so the tier filter in the executor can include/exclude the
771    /// callback per the `system.toml` group→tier map. `None` →
772    /// unlabeled (wildcard-eligible).
773    current_group: Option<MetadataString>,
774}
775
776impl<'ctx, 'id, R: NodeRuntime + ?Sized> DeclaredNode<'ctx, 'id, R> {
777    /// Stable node ID.
778    #[doc(hidden)]
779    pub const fn id(&self) -> NodeId<'id> {
780        self.id
781    }
782
783    /// Set the sticky callback-group label applied to every entity
784    /// declared after this call (until changed again). The group is the
785    /// symbolic name the node author exposes; `system.toml` maps it to a
786    /// scheduling tier (RFC-0015). Entities declared while no group is set
787    /// remain unlabeled (wildcard-eligible). Reusing the Phase-216 tag
788    /// string as the group id keeps one identifier per logical callback.
789    #[track_caller]
790    pub fn callback_group(&mut self, group: &str) -> NodeResult<&mut Self> {
791        self.current_group = Some(copy_str(group)?);
792        Ok(self)
793    }
794
795    /// Phase 228.C chokepoint: stamp the sticky group onto the entity
796    /// (when the entity carries no group of its own) before forwarding to
797    /// the runtime. Every `create_*` helper routes its declaration here so
798    /// the label is applied uniformly in one place.
799    fn declare_entity(&mut self, mut metadata: EntityMetadata) -> NodeResult<()> {
800        if metadata.callback_group.is_none() {
801            metadata.callback_group = self.current_group.clone();
802        }
803        self.runtime.create_entity(metadata)
804    }
805
806    /// Declare a publisher with default QoS. Stable publisher ID is required.
807    #[track_caller]
808    #[doc(hidden)]
809    pub fn create_publisher<'entity, M: MessageForRmw>(
810        &mut self,
811        id: EntityId<'entity>,
812        topic: &str,
813    ) -> NodeResult<NodePublisher<'entity, M>> {
814        self.create_publisher_with_qos::<M>(id, topic, QoSProfile::default())
815    }
816
817    /// Declare a publisher using `topic` as the stable entity ID.
818    ///
819    /// Use the explicit [`create_publisher`](Self::create_publisher) form when
820    /// a node declares more than one publisher on the same topic or needs a
821    /// stable metadata ID that differs from the ROS topic name.
822    #[track_caller]
823    pub fn create_publisher_for_topic<'entity, M: MessageForRmw>(
824        &mut self,
825        topic: &'entity str,
826    ) -> NodeResult<NodePublisher<'entity, M>> {
827        self.create_publisher_for_topic_with_qos::<M>(topic, QoSProfile::default())
828    }
829
830    /// Declare a publisher with explicit QoS, using `topic` as the stable entity ID.
831    #[track_caller]
832    pub fn create_publisher_for_topic_with_qos<'entity, M: MessageForRmw>(
833        &mut self,
834        topic: &'entity str,
835        qos: QoSProfile,
836    ) -> NodeResult<NodePublisher<'entity, M>> {
837        self.create_publisher_with_qos::<M>(EntityId::new(topic), topic, qos)
838    }
839
840    /// Declare a publisher with explicit QoS.
841    #[track_caller]
842    #[doc(hidden)]
843    pub fn create_publisher_with_qos<'entity, M: MessageForRmw>(
844        &mut self,
845        id: EntityId<'entity>,
846        topic: &str,
847        qos: QoSProfile,
848    ) -> NodeResult<NodePublisher<'entity, M>> {
849        register_declared_type::<M>()?;
850        let mut metadata = entity_metadata(EntityMetadataSpec {
851            id,
852            node_id: self.id,
853            kind: EntityKind::Publisher,
854            source_name: topic,
855            // issue 0413 — `MessageForRmw` can pull `schema::Message` into scope,
856            // which also has a `TYPE_NAME`; disambiguate to the ROS-facing one.
857            type_name: <M as RosMessage>::TYPE_NAME,
858            type_hash: <M as RosMessage>::TYPE_HASH,
859            qos,
860        })?;
861        metadata.source = SourceLocationMetadata::caller()?;
862        self.declare_entity(metadata)?;
863        Ok(NodePublisher::new(id))
864    }
865
866    /// Declare a subscription. Stable subscription and callback IDs are required.
867    #[track_caller]
868    #[doc(hidden)]
869    pub fn create_subscription<'entity, 'callback, M: MessageForRmw>(
870        &mut self,
871        id: EntityId<'entity>,
872        callback_id: CallbackId<'callback>,
873        topic: &str,
874    ) -> NodeResult<NodeSubscription<'entity, M>> {
875        self.create_subscription_with_qos::<M>(id, callback_id, topic, QoSProfile::default())
876    }
877
878    /// Declare a subscription using `callback_id` as the stable entity ID.
879    ///
880    /// Generated/internal form; product code should use
881    /// [`create_subscription_for_callback_name`](Self::create_subscription_for_callback_name).
882    #[track_caller]
883    #[doc(hidden)]
884    pub fn create_subscription_for_callback<'callback, M: MessageForRmw>(
885        &mut self,
886        callback_id: CallbackId<'callback>,
887        topic: &str,
888    ) -> NodeResult<NodeSubscription<'callback, M>> {
889        self.create_subscription_for_callback_with_qos::<M>(
890            callback_id,
891            topic,
892            QoSProfile::default(),
893        )
894    }
895
896    /// Declare a subscription using `callback_name` as the source callback
897    /// name and synthesized entity ID.
898    #[track_caller]
899    pub fn create_subscription_for_callback_name<'callback, M: MessageForRmw>(
900        &mut self,
901        callback_name: &'callback str,
902        topic: &str,
903    ) -> NodeResult<NodeSubscription<'callback, M>> {
904        self.create_subscription_for_callback::<M>(CallbackId::new(callback_name), topic)
905    }
906
907    /// Phase 250 (Wave 2b) — declare a subscription with E2E message-integrity
908    /// validation enabled (the declarative `.safety()` opt-in). Identical to
909    /// [`create_subscription_for_callback_name`](Self::create_subscription_for_callback_name)
910    /// but flags the entity so the runtime registers it via
911    /// `create_generic_subscription_with_integrity`; the callback then reads
912    /// [`CallbackCtx::integrity`](CallbackCtx::integrity) alongside the message.
913    /// The config-driven `[safety]` axis (Wave 4 codegen) emits this call; it is
914    /// also usable by hand. Ungated — when `safety-e2e` is off the flag is simply
915    /// ignored and the subscription registers as a basic one.
916    #[track_caller]
917    pub fn create_subscription_for_callback_name_with_safety<'callback, M: MessageForRmw>(
918        &mut self,
919        callback_name: &'callback str,
920        topic: &str,
921    ) -> NodeResult<NodeSubscription<'callback, M>> {
922        let callback_id = CallbackId::new(callback_name);
923        let id = EntityId::new(callback_id.as_str());
924        register_declared_type::<M>()?;
925        let mut metadata = entity_metadata(EntityMetadataSpec {
926            id,
927            node_id: self.id,
928            kind: EntityKind::Subscription,
929            source_name: topic,
930            // issue 0413 — `MessageForRmw` can pull `schema::Message` into scope,
931            // which also has a `TYPE_NAME`; disambiguate to the ROS-facing one.
932            type_name: <M as RosMessage>::TYPE_NAME,
933            type_hash: <M as RosMessage>::TYPE_HASH,
934            qos: QoSProfile::default(),
935        })?;
936        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
937        metadata.callback_source = SourceLocationMetadata::caller()?;
938        metadata.source = metadata.callback_source.clone();
939        metadata.safety = true;
940        self.declare_entity(metadata)?;
941        Ok(NodeSubscription::new(id))
942    }
943
944    /// Declare a subscription with explicit QoS, using `callback_id` as the stable entity ID.
945    #[track_caller]
946    #[doc(hidden)]
947    pub fn create_subscription_for_callback_with_qos<'callback, M: MessageForRmw>(
948        &mut self,
949        callback_id: CallbackId<'callback>,
950        topic: &str,
951        qos: QoSProfile,
952    ) -> NodeResult<NodeSubscription<'callback, M>> {
953        self.create_subscription_with_qos::<M>(
954            EntityId::new(callback_id.as_str()),
955            callback_id,
956            topic,
957            qos,
958        )
959    }
960
961    /// Declare a subscription using `topic` as both the stable entity ID and callback ID.
962    #[track_caller]
963    pub fn create_subscription_for_topic<'entity, M: MessageForRmw>(
964        &mut self,
965        topic: &'entity str,
966    ) -> NodeResult<NodeSubscription<'entity, M>> {
967        self.create_subscription_for_topic_with_qos::<M>(topic, QoSProfile::default())
968    }
969
970    /// Declare a subscription with explicit QoS, using `topic` as both IDs.
971    #[track_caller]
972    pub fn create_subscription_for_topic_with_qos<'entity, M: MessageForRmw>(
973        &mut self,
974        topic: &'entity str,
975        qos: QoSProfile,
976    ) -> NodeResult<NodeSubscription<'entity, M>> {
977        self.create_subscription_with_qos::<M>(
978            EntityId::new(topic),
979            CallbackId::new(topic),
980            topic,
981            qos,
982        )
983    }
984
985    /// Declare a subscription with explicit QoS.
986    #[track_caller]
987    #[doc(hidden)]
988    pub fn create_subscription_with_qos<'entity, 'callback, M: MessageForRmw>(
989        &mut self,
990        id: EntityId<'entity>,
991        callback_id: CallbackId<'callback>,
992        topic: &str,
993        qos: QoSProfile,
994    ) -> NodeResult<NodeSubscription<'entity, M>> {
995        // Phase 380 W4 — a subscription whose receive buffer provably cannot
996        // hold its own message type fails the BUILD, not the field.
997        //
998        // Without this the sample is received, ACKed, and then dropped, and
999        // `report_dropped_take` can only say "raise the knob" because nothing
1000        // knows what value would have worked (issues 0757, 0776). The number is
1001        // known at compile time for any BOUNDED type, so the check costs
1002        // nothing at runtime and cannot be forgotten at a call site.
1003        //
1004        // `bound_fits`, not `buffer_fits`: an UNBOUNDED type passes, because
1005        // there is nothing to prove and no finite buffer fits a `String` — the
1006        // other predicate would refuse the most common message in ROS. Both
1007        // encodings are checked and the larger taken; the peer chooses the
1008        // encoding at runtime, so sizing from XCDR1 alone is a trap.
1009        // Only where `MessageForRmw` guarantees a schema. The other arm accepts a
1010        // hand-written `RosMessage` with none (see `rmw_type_registry`), and
1011        // requiring one there would make codegen mandatory for a user's own
1012        // message type — too large a price for a build assertion. Backends that
1013        // register type descriptors already demand the schema, so this is free
1014        // there and absent elsewhere; `size::bound_fits` stays public so a
1015        // caller can assert it explicitly.
1016        const {
1017            assert!(
1018                nros_node::rmw_type_registry::subscription_buffer_ok::<M>(),
1019                "this message type's maximum serialized size exceeds \
1020                 NROS_SUBSCRIPTION_BUFFER_SIZE — every sample would be received, \
1021                 ACKed and then DROPPED. Raise the knob to at least the type's \
1022                 bound (`<M as nros_serdes::schema::Message>::MAX_SERIALIZED_SIZE_XCDR2`)."
1023            )
1024        }
1025        register_declared_type::<M>()?;
1026        let mut metadata = entity_metadata(EntityMetadataSpec {
1027            id,
1028            node_id: self.id,
1029            kind: EntityKind::Subscription,
1030            source_name: topic,
1031            // issue 0413 — `MessageForRmw` can pull `schema::Message` into scope,
1032            // which also has a `TYPE_NAME`; disambiguate to the ROS-facing one.
1033            type_name: <M as RosMessage>::TYPE_NAME,
1034            type_hash: <M as RosMessage>::TYPE_HASH,
1035            qos,
1036        })?;
1037        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
1038        metadata.callback_source = SourceLocationMetadata::caller()?;
1039        metadata.source = metadata.callback_source.clone();
1040        self.declare_entity(metadata)?;
1041        Ok(NodeSubscription::new(id))
1042    }
1043
1044    /// Declare a subscription whose stable entity and callback IDs are
1045    /// both synthesized from the topic literal, returning a
1046    /// [`SubscriptionTag`] the Node author stores on `Self::State` and
1047    /// matches against the `Callback<'_>` delivered to
1048    /// [`ExecutableNode::on_callback`].
1049    ///
1050    /// Use this on the Phase 216.A Deferred Node path where the Node
1051    /// author does not need to invent a separate stable entity ID — the
1052    /// topic literal becomes both the entity ID and the callback ID,
1053    /// and the returned tag preserves that identifier for compile-time
1054    /// `state.sub_chatter == cb` matches in `on_callback`.
1055    #[track_caller]
1056    pub fn create_subscription_static<M: MessageForRmw>(
1057        &mut self,
1058        topic: &'static str,
1059    ) -> NodeResult<SubscriptionTag> {
1060        let id = EntityId::new(topic);
1061        let callback_id = CallbackId::new(topic);
1062        register_declared_type::<M>()?;
1063        let mut metadata = entity_metadata(EntityMetadataSpec {
1064            id,
1065            node_id: self.id,
1066            kind: EntityKind::Subscription,
1067            source_name: topic,
1068            // issue 0413 — `MessageForRmw` can pull `schema::Message` into scope,
1069            // which also has a `TYPE_NAME`; disambiguate to the ROS-facing one.
1070            type_name: <M as RosMessage>::TYPE_NAME,
1071            type_hash: <M as RosMessage>::TYPE_HASH,
1072            qos: QoSProfile::default(),
1073        })?;
1074        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
1075        metadata.callback_source = SourceLocationMetadata::caller()?;
1076        metadata.source = metadata.callback_source.clone();
1077        self.declare_entity(metadata)?;
1078        Ok(SubscriptionTag::new(topic))
1079    }
1080
1081    /// Declare a timer. Stable timer and callback IDs are required.
1082    #[track_caller]
1083    #[doc(hidden)]
1084    pub fn create_timer<'entity, 'callback>(
1085        &mut self,
1086        id: EntityId<'entity>,
1087        callback_id: CallbackId<'callback>,
1088        period: TimerDuration,
1089    ) -> NodeResult<NodeTimer<'entity>> {
1090        let mut metadata = entity_metadata(EntityMetadataSpec {
1091            id,
1092            node_id: self.id,
1093            kind: EntityKind::Timer,
1094            source_name: "",
1095            type_name: "",
1096            type_hash: "",
1097            qos: QoSProfile::default(),
1098        })?;
1099        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
1100        metadata.callback_source = SourceLocationMetadata::caller()?;
1101        metadata.source = metadata.callback_source.clone();
1102        metadata.period_ms = Some(period.as_millis());
1103        metadata.period_us = Some(period.as_micros());
1104        self.declare_entity(metadata)?;
1105        Ok(NodeTimer::new(id))
1106    }
1107
1108    /// Declare a timer using `callback_id` as the stable timer entity ID.
1109    #[track_caller]
1110    #[doc(hidden)]
1111    pub fn create_timer_for_callback<'callback>(
1112        &mut self,
1113        callback_id: CallbackId<'callback>,
1114        period: TimerDuration,
1115    ) -> NodeResult<NodeTimer<'callback>> {
1116        self.create_timer(EntityId::new(callback_id.as_str()), callback_id, period)
1117    }
1118
1119    /// Declare a timer using `callback_name` as the source callback name and
1120    /// synthesized entity ID.
1121    #[track_caller]
1122    pub fn create_timer_for_callback_name<'callback>(
1123        &mut self,
1124        callback_name: &'callback str,
1125        period: TimerDuration,
1126    ) -> NodeResult<NodeTimer<'callback>> {
1127        self.create_timer_for_callback(CallbackId::new(callback_name), period)
1128    }
1129
1130    /// Declare a service server. Stable service and callback IDs are required.
1131    #[track_caller]
1132    #[doc(hidden)]
1133    pub fn create_service_server<
1134        'entity,
1135        'callback,
1136        S: RosService<
1137                Request: nros_node::rmw_type_registry::MessageForRmw,
1138                Reply: nros_node::rmw_type_registry::MessageForRmw,
1139            >,
1140    >(
1141        &mut self,
1142        id: EntityId<'entity>,
1143        callback_id: CallbackId<'callback>,
1144        service_name: &str,
1145    ) -> NodeResult<NodeServiceServer<'entity, S>> {
1146        register_declared_service::<S>()?;
1147        let mut metadata = entity_metadata(EntityMetadataSpec {
1148            id,
1149            node_id: self.id,
1150            kind: EntityKind::ServiceServer,
1151            source_name: service_name,
1152            type_name: S::SERVICE_NAME,
1153            type_hash: S::SERVICE_HASH,
1154            qos: QoSProfile::default(),
1155        })?;
1156        metadata.callback_id = Some(copy_str(callback_id.as_str())?);
1157        metadata.callback_source = SourceLocationMetadata::caller()?;
1158        metadata.source = metadata.callback_source.clone();
1159        self.declare_entity(metadata)?;
1160        Ok(NodeServiceServer::new(id))
1161    }
1162
1163    /// Declare a service server using `name` as both the stable entity ID
1164    /// and callback ID.
1165    #[track_caller]
1166    pub fn create_service_server_for_name<
1167        'entity,
1168        S: RosService<
1169                Request: nros_node::rmw_type_registry::MessageForRmw,
1170                Reply: nros_node::rmw_type_registry::MessageForRmw,
1171            >,
1172    >(
1173        &mut self,
1174        name: &'entity str,
1175    ) -> NodeResult<NodeServiceServer<'entity, S>> {
1176        self.create_service_server::<S>(EntityId::new(name), CallbackId::new(name), name)
1177    }
1178
1179    /// Declare a service server using `name` as the stable entity ID and
1180    /// `callback_name` as the source callback name.
1181    #[track_caller]
1182    pub fn create_service_server_for_name_with_callback<
1183        'entity,
1184        S: RosService<
1185                Request: nros_node::rmw_type_registry::MessageForRmw,
1186                Reply: nros_node::rmw_type_registry::MessageForRmw,
1187            >,
1188    >(
1189        &mut self,
1190        name: &'entity str,
1191        callback_name: &str,
1192    ) -> NodeResult<NodeServiceServer<'entity, S>> {
1193        self.create_service_server::<S>(EntityId::new(name), CallbackId::new(callback_name), name)
1194    }
1195
1196    /// Declare a service server whose stable entity and callback IDs are
1197    /// both synthesized from the service-name literal, returning a
1198    /// [`ServiceTag`] the Node author stores on `Self::State` and matches
1199    /// against the `Callback<'_>` delivered to
1200    /// [`ExecutableNode::on_callback`].
1201    ///
1202    /// Tag-only registration is restricted to the SERVER side: clients
1203    /// need a USABLE handle (`NodeServiceClient`) to issue requests, so
1204    /// use the existing
1205    /// [`create_service_client_for_name`](Self::create_service_client_for_name) builder
1206    /// for the client side.
1207    #[track_caller]
1208    pub fn create_service_static<
1209        S: RosService<
1210                Request: nros_node::rmw_type_registry::MessageForRmw,
1211                Reply: nros_node::rmw_type_registry::MessageForRmw,
1212            >,
1213    >(
1214        &mut self,
1215        name: &'static str,
1216    ) -> NodeResult<ServiceTag> {
1217        self.create_service_server_for_name::<S>(name)?;
1218        Ok(ServiceTag::new(name))
1219    }
1220
1221    /// Declare a service client. Stable service client ID is required.
1222    #[track_caller]
1223    #[doc(hidden)]
1224    pub fn create_service_client<
1225        'entity,
1226        S: RosService<
1227                Request: nros_node::rmw_type_registry::MessageForRmw,
1228                Reply: nros_node::rmw_type_registry::MessageForRmw,
1229            >,
1230    >(
1231        &mut self,
1232        id: EntityId<'entity>,
1233        service_name: &str,
1234    ) -> NodeResult<NodeServiceClient<'entity, S>> {
1235        register_declared_service::<S>()?;
1236        let mut metadata = entity_metadata(EntityMetadataSpec {
1237            id,
1238            node_id: self.id,
1239            kind: EntityKind::ServiceClient,
1240            source_name: service_name,
1241            type_name: S::SERVICE_NAME,
1242            type_hash: S::SERVICE_HASH,
1243            qos: QoSProfile::default(),
1244        })?;
1245        metadata.source = SourceLocationMetadata::caller()?;
1246        self.declare_entity(metadata)?;
1247        Ok(NodeServiceClient::new(id))
1248    }
1249
1250    /// Declare a service client using `name` as the stable entity ID.
1251    #[track_caller]
1252    pub fn create_service_client_for_name<
1253        'entity,
1254        S: RosService<
1255                Request: nros_node::rmw_type_registry::MessageForRmw,
1256                Reply: nros_node::rmw_type_registry::MessageForRmw,
1257            >,
1258    >(
1259        &mut self,
1260        name: &'entity str,
1261    ) -> NodeResult<NodeServiceClient<'entity, S>> {
1262        self.create_service_client::<S>(EntityId::new(name), name)
1263    }
1264
1265    /// Declare an action server. Stable action and callback IDs are required.
1266    #[track_caller]
1267    #[doc(hidden)]
1268    pub fn create_action_server<
1269        'entity,
1270        'callback,
1271        A: RosAction<
1272                Goal: nros_node::rmw_type_registry::MessageForRmw,
1273                Result: nros_node::rmw_type_registry::MessageForRmw,
1274                Feedback: nros_node::rmw_type_registry::MessageForRmw,
1275                SendGoalRequest: nros_node::rmw_type_registry::MessageForRmw,
1276                SendGoalResponse: nros_node::rmw_type_registry::MessageForRmw,
1277                GetResultRequest: nros_node::rmw_type_registry::MessageForRmw,
1278                GetResultResponse: nros_node::rmw_type_registry::MessageForRmw,
1279                FeedbackMessage: nros_node::rmw_type_registry::MessageForRmw,
1280            >,
1281    >(
1282        &mut self,
1283        id: EntityId<'entity>,
1284        callback_id: CallbackId<'callback>,
1285        action_name: &str,
1286    ) -> NodeResult<NodeActionServer<'entity, A>> {
1287        self.create_action_server_with_callbacks::<A>(
1288            id,
1289            callback_id,
1290            callback_id,
1291            callback_id,
1292            action_name,
1293        )
1294    }
1295
1296    /// Declare an action server with distinct goal/cancel/accepted callbacks.
1297    #[track_caller]
1298    #[doc(hidden)]
1299    pub fn create_action_server_with_callbacks<
1300        'entity,
1301        'goal,
1302        'cancel,
1303        'accepted,
1304        A: RosAction<
1305                Goal: nros_node::rmw_type_registry::MessageForRmw,
1306                Result: nros_node::rmw_type_registry::MessageForRmw,
1307                Feedback: nros_node::rmw_type_registry::MessageForRmw,
1308                SendGoalRequest: nros_node::rmw_type_registry::MessageForRmw,
1309                SendGoalResponse: nros_node::rmw_type_registry::MessageForRmw,
1310                GetResultRequest: nros_node::rmw_type_registry::MessageForRmw,
1311                GetResultResponse: nros_node::rmw_type_registry::MessageForRmw,
1312                FeedbackMessage: nros_node::rmw_type_registry::MessageForRmw,
1313            >,
1314    >(
1315        &mut self,
1316        id: EntityId<'entity>,
1317        goal_callback_id: CallbackId<'goal>,
1318        cancel_callback_id: CallbackId<'cancel>,
1319        accepted_callback_id: CallbackId<'accepted>,
1320        action_name: &str,
1321    ) -> NodeResult<NodeActionServer<'entity, A>> {
1322        register_declared_action::<A>()?;
1323        let mut metadata = entity_metadata(EntityMetadataSpec {
1324            id,
1325            node_id: self.id,
1326            kind: EntityKind::ActionServer,
1327            source_name: action_name,
1328            type_name: A::ACTION_NAME,
1329            type_hash: A::ACTION_HASH,
1330            qos: QoSProfile::default(),
1331        })?;
1332        metadata.callback_id = Some(copy_str(goal_callback_id.as_str())?);
1333        metadata.callback_source = SourceLocationMetadata::caller()?;
1334        metadata.action_cancel_callback_id = Some(copy_str(cancel_callback_id.as_str())?);
1335        metadata.action_cancel_source = metadata.callback_source.clone();
1336        metadata.action_accepted_callback_id = Some(copy_str(accepted_callback_id.as_str())?);
1337        metadata.action_accepted_source = metadata.callback_source.clone();
1338        metadata.source = metadata.callback_source.clone();
1339        self.declare_entity(metadata)?;
1340        Ok(NodeActionServer::new(id))
1341    }
1342
1343    /// Declare an action server using `name` as the stable entity ID and
1344    /// default goal/cancel/accepted callback ID.
1345    #[track_caller]
1346    pub fn create_action_server_for_name<
1347        'entity,
1348        A: RosAction<
1349                Goal: nros_node::rmw_type_registry::MessageForRmw,
1350                Result: nros_node::rmw_type_registry::MessageForRmw,
1351                Feedback: nros_node::rmw_type_registry::MessageForRmw,
1352                SendGoalRequest: nros_node::rmw_type_registry::MessageForRmw,
1353                SendGoalResponse: nros_node::rmw_type_registry::MessageForRmw,
1354                GetResultRequest: nros_node::rmw_type_registry::MessageForRmw,
1355                GetResultResponse: nros_node::rmw_type_registry::MessageForRmw,
1356                FeedbackMessage: nros_node::rmw_type_registry::MessageForRmw,
1357            >,
1358    >(
1359        &mut self,
1360        name: &'entity str,
1361    ) -> NodeResult<NodeActionServer<'entity, A>> {
1362        self.create_action_server::<A>(EntityId::new(name), CallbackId::new(name), name)
1363    }
1364
1365    /// Declare an action server using `name` as the stable entity ID and
1366    /// explicit source callback names for goal, cancel, and accepted events.
1367    #[track_caller]
1368    pub fn create_action_server_for_name_with_callbacks<
1369        'entity,
1370        A: RosAction<
1371                Goal: nros_node::rmw_type_registry::MessageForRmw,
1372                Result: nros_node::rmw_type_registry::MessageForRmw,
1373                Feedback: nros_node::rmw_type_registry::MessageForRmw,
1374                SendGoalRequest: nros_node::rmw_type_registry::MessageForRmw,
1375                SendGoalResponse: nros_node::rmw_type_registry::MessageForRmw,
1376                GetResultRequest: nros_node::rmw_type_registry::MessageForRmw,
1377                GetResultResponse: nros_node::rmw_type_registry::MessageForRmw,
1378                FeedbackMessage: nros_node::rmw_type_registry::MessageForRmw,
1379            >,
1380    >(
1381        &mut self,
1382        name: &'entity str,
1383        goal_callback_name: &str,
1384        cancel_callback_name: &str,
1385        accepted_callback_name: &str,
1386    ) -> NodeResult<NodeActionServer<'entity, A>> {
1387        self.create_action_server_with_callbacks::<A>(
1388            EntityId::new(name),
1389            CallbackId::new(goal_callback_name),
1390            CallbackId::new(cancel_callback_name),
1391            CallbackId::new(accepted_callback_name),
1392            name,
1393        )
1394    }
1395
1396    /// Declare an action server whose stable entity and callback IDs are
1397    /// both synthesized from the action-name literal, returning an
1398    /// [`ActionTag`] the Node author stores on `Self::State` and matches
1399    /// against the `Callback<'_>` delivered to
1400    /// [`ExecutableNode::on_callback`].
1401    ///
1402    /// The synthesized callback ID is shared by the goal / cancel /
1403    /// accepted callbacks (matching the default behavior of
1404    /// [`create_action_server`](Self::create_action_server)).
1405    ///
1406    /// Tag-only registration is restricted to the SERVER side: clients
1407    /// need a USABLE handle (`NodeActionClient`) to dispatch goals, so
1408    /// use the existing
1409    /// [`create_action_client_for_name`](Self::create_action_client_for_name) builder
1410    /// for the client side.
1411    #[track_caller]
1412    pub fn create_action_static<
1413        A: RosAction<
1414                Goal: nros_node::rmw_type_registry::MessageForRmw,
1415                Result: nros_node::rmw_type_registry::MessageForRmw,
1416                Feedback: nros_node::rmw_type_registry::MessageForRmw,
1417                SendGoalRequest: nros_node::rmw_type_registry::MessageForRmw,
1418                SendGoalResponse: nros_node::rmw_type_registry::MessageForRmw,
1419                GetResultRequest: nros_node::rmw_type_registry::MessageForRmw,
1420                GetResultResponse: nros_node::rmw_type_registry::MessageForRmw,
1421                FeedbackMessage: nros_node::rmw_type_registry::MessageForRmw,
1422            >,
1423    >(
1424        &mut self,
1425        name: &'static str,
1426    ) -> NodeResult<ActionTag> {
1427        self.create_action_server_for_name::<A>(name)?;
1428        Ok(ActionTag::new(name))
1429    }
1430
1431    /// Declare an action client. Stable action client ID is required.
1432    #[track_caller]
1433    #[doc(hidden)]
1434    pub fn create_action_client<
1435        'entity,
1436        A: RosAction<
1437                Goal: nros_node::rmw_type_registry::MessageForRmw,
1438                Result: nros_node::rmw_type_registry::MessageForRmw,
1439                Feedback: nros_node::rmw_type_registry::MessageForRmw,
1440                SendGoalRequest: nros_node::rmw_type_registry::MessageForRmw,
1441                SendGoalResponse: nros_node::rmw_type_registry::MessageForRmw,
1442                GetResultRequest: nros_node::rmw_type_registry::MessageForRmw,
1443                GetResultResponse: nros_node::rmw_type_registry::MessageForRmw,
1444                FeedbackMessage: nros_node::rmw_type_registry::MessageForRmw,
1445            >,
1446    >(
1447        &mut self,
1448        id: EntityId<'entity>,
1449        action_name: &str,
1450    ) -> NodeResult<NodeActionClient<'entity, A>> {
1451        register_declared_action::<A>()?;
1452        let mut metadata = entity_metadata(EntityMetadataSpec {
1453            id,
1454            node_id: self.id,
1455            kind: EntityKind::ActionClient,
1456            source_name: action_name,
1457            type_name: A::ACTION_NAME,
1458            type_hash: A::ACTION_HASH,
1459            qos: QoSProfile::default(),
1460        })?;
1461        metadata.source = SourceLocationMetadata::caller()?;
1462        self.declare_entity(metadata)?;
1463        Ok(NodeActionClient::new(id))
1464    }
1465
1466    /// Declare an action client using `name` as the stable entity ID.
1467    #[track_caller]
1468    pub fn create_action_client_for_name<
1469        'entity,
1470        A: RosAction<
1471                Goal: nros_node::rmw_type_registry::MessageForRmw,
1472                Result: nros_node::rmw_type_registry::MessageForRmw,
1473                Feedback: nros_node::rmw_type_registry::MessageForRmw,
1474                SendGoalRequest: nros_node::rmw_type_registry::MessageForRmw,
1475                SendGoalResponse: nros_node::rmw_type_registry::MessageForRmw,
1476                GetResultRequest: nros_node::rmw_type_registry::MessageForRmw,
1477                GetResultResponse: nros_node::rmw_type_registry::MessageForRmw,
1478                FeedbackMessage: nros_node::rmw_type_registry::MessageForRmw,
1479            >,
1480    >(
1481        &mut self,
1482        name: &'entity str,
1483    ) -> NodeResult<NodeActionClient<'entity, A>> {
1484        self.create_action_client::<A>(EntityId::new(name), name)
1485    }
1486
1487    /// Declare an action client that delivers the goal RESULT + FEEDBACK to
1488    /// named callbacks (Phase 212.M-F.23). `name` is the stable entity ID. The
1489    /// executor auto-drives accept → feedback stream → result during spin and
1490    /// dispatches `ExecutableNode::on_callback` with `result_callback_name`
1491    /// (payload = result CDR) on completion, and with `feedback_callback_name`
1492    /// (payload = feedback CDR) per feedback message. Read either with
1493    /// `CallbackCtx::message::<A::Result>()` / `::<A::Feedback>()`. Without
1494    /// these the client can only `send_goal`; result + feedback are dropped.
1495    ///
1496    /// (Layout note: the action-client variant reuses the server-side
1497    /// `action_accepted_callback_id` metadata slot for the feedback callback —
1498    /// that field is unused on a client, so no new schema field is needed.)
1499    #[track_caller]
1500    pub fn create_action_client_with_callbacks_for_name<
1501        'entity,
1502        A: RosAction<
1503                Goal: nros_node::rmw_type_registry::MessageForRmw,
1504                Result: nros_node::rmw_type_registry::MessageForRmw,
1505                Feedback: nros_node::rmw_type_registry::MessageForRmw,
1506                SendGoalRequest: nros_node::rmw_type_registry::MessageForRmw,
1507                SendGoalResponse: nros_node::rmw_type_registry::MessageForRmw,
1508                GetResultRequest: nros_node::rmw_type_registry::MessageForRmw,
1509                GetResultResponse: nros_node::rmw_type_registry::MessageForRmw,
1510                FeedbackMessage: nros_node::rmw_type_registry::MessageForRmw,
1511            >,
1512    >(
1513        &mut self,
1514        name: &'entity str,
1515        result_callback_name: &str,
1516        feedback_callback_name: &str,
1517    ) -> NodeResult<NodeActionClient<'entity, A>> {
1518        register_declared_action::<A>()?;
1519        let mut metadata = entity_metadata(EntityMetadataSpec {
1520            id: EntityId::new(name),
1521            node_id: self.id,
1522            kind: EntityKind::ActionClient,
1523            source_name: name,
1524            type_name: A::ACTION_NAME,
1525            type_hash: A::ACTION_HASH,
1526            qos: QoSProfile::default(),
1527        })?;
1528        metadata.callback_id = Some(copy_str(result_callback_name)?);
1529        metadata.action_accepted_callback_id = Some(copy_str(feedback_callback_name)?);
1530        metadata.callback_source = SourceLocationMetadata::caller()?;
1531        metadata.source = metadata.callback_source.clone();
1532        self.declare_entity(metadata)?;
1533        Ok(NodeActionClient::new(EntityId::new(name)))
1534    }
1535
1536    /// Declare a parameter. Stable parameter ID is required.
1537    #[track_caller]
1538    #[doc(hidden)]
1539    pub fn declare_parameter<'entity>(
1540        &mut self,
1541        id: EntityId<'entity>,
1542        name: &str,
1543        parameter_type: ParameterType,
1544    ) -> NodeResult<NodeParameter<'entity>> {
1545        self.declare_parameter_with_default(id, name, ParameterDefault::for_type(parameter_type)?)
1546    }
1547
1548    /// Declare a parameter with a concrete source default.
1549    #[track_caller]
1550    #[doc(hidden)]
1551    pub fn declare_parameter_with_default<'entity>(
1552        &mut self,
1553        id: EntityId<'entity>,
1554        name: &str,
1555        default: ParameterDefault,
1556    ) -> NodeResult<NodeParameter<'entity>> {
1557        let mut metadata = entity_metadata(EntityMetadataSpec {
1558            id,
1559            node_id: self.id,
1560            kind: EntityKind::Parameter,
1561            source_name: name,
1562            type_name: "",
1563            type_hash: "",
1564            qos: QoSProfile::default(),
1565        })?;
1566        metadata.parameter_type = Some(default.parameter_type());
1567        metadata.parameter_default = Some(default);
1568        metadata.source = SourceLocationMetadata::caller()?;
1569        self.declare_entity(metadata)?;
1570        Ok(NodeParameter::new(id))
1571    }
1572
1573    /// Declare a parameter using `name` as the generated stable entity ID.
1574    #[track_caller]
1575    pub fn declare_parameter_for_name<'entity>(
1576        &mut self,
1577        name: &'entity str,
1578        parameter_type: ParameterType,
1579    ) -> NodeResult<NodeParameter<'entity>> {
1580        self.declare_parameter(EntityId::new(name), name, parameter_type)
1581    }
1582
1583    /// Declare a parameter with a concrete source default, using `name` as
1584    /// the generated stable entity ID.
1585    #[track_caller]
1586    pub fn declare_parameter_for_name_with_default<'entity>(
1587        &mut self,
1588        name: &'entity str,
1589        default: ParameterDefault,
1590    ) -> NodeResult<NodeParameter<'entity>> {
1591        self.declare_parameter_with_default(EntityId::new(name), name, default)
1592    }
1593
1594    /// Record optional effects for a callback.
1595    #[doc(hidden)]
1596    pub fn callback<'callback>(
1597        &mut self,
1598        id: CallbackId<'callback>,
1599    ) -> CallbackEffects<'_, 'callback, R> {
1600        CallbackEffects {
1601            runtime: self.runtime,
1602            id,
1603        }
1604    }
1605
1606    /// Record optional effects for a named callback without exposing
1607    /// `CallbackId` at the declaration site.
1608    pub fn callback_for_name<'callback>(
1609        &mut self,
1610        name: &'callback str,
1611    ) -> CallbackEffects<'_, 'callback, R> {
1612        self.callback(CallbackId::new(name))
1613    }
1614}
1615
1616/// Builder for optional callback effect metadata.
1617pub struct CallbackEffects<'ctx, 'id, R: NodeRuntime + ?Sized = dyn NodeRuntime + 'ctx> {
1618    runtime: &'ctx mut R,
1619    id: CallbackId<'id>,
1620}
1621
1622impl<'ctx, 'id, R: NodeRuntime + ?Sized> CallbackEffects<'ctx, 'id, R> {
1623    /// Record that callback reads from an entity.
1624    #[doc(hidden)]
1625    pub fn reads(self, entity_id: EntityId<'_>) -> NodeResult<Self> {
1626        self.runtime
1627            .record_callback_effect(self.id, CallbackEffectKind::Reads, entity_id)?;
1628        Ok(self)
1629    }
1630
1631    /// Record that callback reads from a declared entity handle.
1632    pub fn reads_entity(self, entity: &impl DeclaredEntity) -> NodeResult<Self> {
1633        self.reads(entity.entity_id())
1634    }
1635
1636    /// Record that callback publishes via an entity.
1637    #[doc(hidden)]
1638    pub fn publishes(self, entity_id: EntityId<'_>) -> NodeResult<Self> {
1639        self.runtime
1640            .record_callback_effect(self.id, CallbackEffectKind::Publishes, entity_id)?;
1641        Ok(self)
1642    }
1643
1644    /// Record that callback publishes via a declared entity handle.
1645    pub fn publishes_entity(self, entity: &impl DeclaredEntity) -> NodeResult<Self> {
1646        self.publishes(entity.entity_id())
1647    }
1648
1649    /// Record that callback writes to an entity or parameter.
1650    #[doc(hidden)]
1651    pub fn writes(self, entity_id: EntityId<'_>) -> NodeResult<Self> {
1652        self.runtime
1653            .record_callback_effect(self.id, CallbackEffectKind::Writes, entity_id)?;
1654        Ok(self)
1655    }
1656
1657    /// Record that callback writes to a declared entity handle.
1658    pub fn writes_entity(self, entity: &impl DeclaredEntity) -> NodeResult<Self> {
1659        self.writes(entity.entity_id())
1660    }
1661}
1662
1663/// A declared source-level entity handle that can be referenced by callback effects.
1664#[doc(hidden)]
1665pub trait DeclaredEntity {
1666    /// Stable entity ID for metadata and generated runtime lookup.
1667    fn entity_id(&self) -> EntityId<'_>;
1668}
1669
1670macro_rules! component_handle {
1671    ($name:ident $(, $type_param:ident)?) => {
1672        /// Source-level component entity handle.
1673        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1674        pub struct $name<'id $(, $type_param)?> {
1675            id: EntityId<'id>,
1676            _marker: PhantomData<($($type_param,)?)>,
1677        }
1678
1679        impl<'id $(, $type_param)?> $name<'id $(, $type_param)?> {
1680            const fn new(id: EntityId<'id>) -> Self {
1681                Self {
1682                    id,
1683                    _marker: PhantomData,
1684                }
1685            }
1686
1687            /// Stable entity ID.
1688            #[doc(hidden)]
1689            pub const fn id(&self) -> EntityId<'id> {
1690                self.id
1691            }
1692        }
1693
1694        impl<'id $(, $type_param)?> DeclaredEntity for $name<'id $(, $type_param)?> {
1695            fn entity_id(&self) -> EntityId<'_> {
1696                self.id
1697            }
1698        }
1699    };
1700}
1701
1702component_handle!(NodePublisher, M);
1703component_handle!(NodeSubscription, M);
1704component_handle!(NodeServiceServer, S);
1705component_handle!(NodeServiceClient, S);
1706component_handle!(NodeActionServer, A);
1707component_handle!(NodeActionClient, A);
1708component_handle!(NodeTimer);
1709component_handle!(NodeParameter);
1710
1711// ============================================================================
1712// Phase 172 W.5.1 — executable component layer (callback bodies)
1713// ============================================================================
1714//
1715// The declarative `Node::register` above stays the planning/metadata SSOT.
1716// This layer binds *runnable* bodies: the generated runtime builds the
1717// component `State` once, then routes each fired callback to `on_callback` with
1718// a `CallbackCtx` that exposes the triggering payload + an immediate publish
1719// path. Publishers are self-contained transport handles
1720// (`EmbeddedRawPublisher::publish_raw(&self)`), so a body publishes immediately
1721// mid-spin with no executor re-entrancy and no deferred queue (causality
1722// preserved). Shared state across a component's callbacks is `&mut State`
1723// behind the generated runtime's `'static` storage — `no_std`, no `alloc`.
1724
1725/// Resolves a component publisher by its stable [`EntityId`] for the
1726/// callback-body publish path (W.5.1).
1727///
1728/// The generated runtime implements this over its owned `'static` publishers;
1729/// metadata/discovery mode never constructs a [`CallbackCtx`], so it need not
1730/// implement this.
1731pub trait PublisherResolver {
1732    /// Publish raw CDR bytes through the publisher with this stable entity id.
1733    /// `Err(NodeDeclError::Runtime)` if no such publisher is registered or the
1734    /// transport rejects the write.
1735    fn publish_raw(&self, entity_id: &str, data: &[u8]) -> NodeResult<()>;
1736}
1737
1738/// Where a service / action-result callback body writes its reply (W.5.3): the
1739/// generated trampoline lends a `buf`; the body fills it via
1740/// [`CallbackCtx::reply`] and the trampoline reads `*written` back out.
1741struct ReplySink<'a> {
1742    buf: &'a mut [u8],
1743    written: &'a mut usize,
1744}
1745
1746/// Where an action goal / cancel-decision callback writes its accept/reject
1747/// (W.5.3): the generated trampoline lends the out-slot, the body fills it via
1748/// [`CallbackCtx::set_goal_response`] / [`set_cancel_response`](CallbackCtx::set_cancel_response),
1749/// and the trampoline returns it. Decisions need no executor — unlike feedback /
1750/// result, which do (see the action-execution note in Phase 172 W.5.3).
1751enum DecisionSink<'a> {
1752    Goal(&'a mut GoalResponse),
1753    Cancel(&'a mut CancelResponse),
1754}
1755
1756/// Context handed to an executable component callback body (W.5.1).
1757///
1758/// Carries the triggering payload (raw CDR — empty for timers) plus the
1759/// publisher resolver, so a body can read its message and publish immediately.
1760/// Service / action-result callbacks additionally carry a `ReplySink` the body
1761/// fills via [`reply`](Self::reply); action goal / cancel callbacks carry a
1762/// `DecisionSink` the body fills via
1763/// [`set_goal_response`](Self::set_goal_response) /
1764/// [`set_cancel_response`](Self::set_cancel_response) (W.5.3).
1765/// issue 0461 — bytes of `unique_identifier_msgs/UUID` that precede the goal
1766/// fields in a SendGoal request. A fixed `uint8[16]` array: no length prefix.
1767const GOAL_UUID_LEN: usize = 16;
1768
1769pub struct CallbackCtx<'a> {
1770    payload: &'a [u8],
1771    publishers: &'a dyn PublisherResolver,
1772    reply: Option<ReplySink<'a>>,
1773    decision: Option<DecisionSink<'a>>,
1774    /// Phase 250 (Wave 2) — E2E message-integrity status for a subscription that
1775    /// opted in via `.safety()`; `None` for every other callback (timers,
1776    /// services, non-safety subscriptions). Read with
1777    /// [`integrity`](Self::integrity). Gated with the capability so it is
1778    /// zero-cost when `safety-e2e` is off.
1779    #[cfg(feature = "safety-e2e")]
1780    integrity: Option<&'a crate::IntegrityStatus>,
1781    /// Phase 264 W4c — the executor's volatile parameter store, threaded by the dispatch
1782    /// site from the component cell (`None` until `[param_services]` registers the store).
1783    /// Read with [`parameter`](Self::parameter). Gated so it is zero-cost when
1784    /// `param-services` is off.
1785    #[cfg(feature = "param-services")]
1786    params: Option<&'a crate::ParameterServer<'a>>,
1787}
1788
1789impl<'a> CallbackCtx<'a> {
1790    /// Build a callback context with no reply sink (timer / subscription).
1791    /// `payload` is the entity's raw CDR (empty slice for timers).
1792    pub fn new(payload: &'a [u8], publishers: &'a dyn PublisherResolver) -> Self {
1793        Self {
1794            payload,
1795            publishers,
1796            reply: None,
1797            decision: None,
1798            #[cfg(feature = "safety-e2e")]
1799            integrity: None,
1800            #[cfg(feature = "param-services")]
1801            params: None,
1802        }
1803    }
1804
1805    /// Phase 250 (Wave 2) — build a subscription context carrying E2E
1806    /// [`IntegrityStatus`](crate::IntegrityStatus) (the declarative `.safety()`
1807    /// path). The body reads both the message ([`message`](Self::message)) and
1808    /// the status ([`integrity`](Self::integrity)) in one callback, mirroring the
1809    /// imperative `FnMut(&M, &IntegrityStatus)` shape.
1810    #[cfg(feature = "safety-e2e")]
1811    pub fn new_with_integrity(
1812        payload: &'a [u8],
1813        publishers: &'a dyn PublisherResolver,
1814        integrity: &'a crate::IntegrityStatus,
1815    ) -> Self {
1816        Self {
1817            payload,
1818            publishers,
1819            reply: None,
1820            decision: None,
1821            integrity: Some(integrity),
1822            #[cfg(feature = "param-services")]
1823            params: None,
1824        }
1825    }
1826
1827    /// Build a callback context with a reply sink (service / action-result;
1828    /// W.5.3). The body fills `reply_buf` via [`reply`](Self::reply); the
1829    /// generated trampoline reads `*reply_written` back as the response length.
1830    pub fn with_reply(
1831        payload: &'a [u8],
1832        publishers: &'a dyn PublisherResolver,
1833        reply_buf: &'a mut [u8],
1834        reply_written: &'a mut usize,
1835    ) -> Self {
1836        *reply_written = 0;
1837        Self {
1838            payload,
1839            publishers,
1840            reply: Some(ReplySink {
1841                buf: reply_buf,
1842                written: reply_written,
1843            }),
1844            decision: None,
1845            #[cfg(feature = "safety-e2e")]
1846            integrity: None,
1847            #[cfg(feature = "param-services")]
1848            params: None,
1849        }
1850    }
1851
1852    /// Build a context for an action **goal** callback (W.5.3): the body decides
1853    /// accept/reject via [`set_goal_response`](Self::set_goal_response); the
1854    /// generated trampoline returns `*out`. `payload` is the goal CDR.
1855    pub fn with_goal_decision(
1856        payload: &'a [u8],
1857        publishers: &'a dyn PublisherResolver,
1858        out: &'a mut GoalResponse,
1859    ) -> Self {
1860        Self {
1861            payload,
1862            publishers,
1863            reply: None,
1864            decision: Some(DecisionSink::Goal(out)),
1865            #[cfg(feature = "safety-e2e")]
1866            integrity: None,
1867            #[cfg(feature = "param-services")]
1868            params: None,
1869        }
1870    }
1871
1872    /// Build a context for an action **cancel** callback (W.5.3): the body decides
1873    /// accept/reject via [`set_cancel_response`](Self::set_cancel_response).
1874    pub fn with_cancel_decision(
1875        payload: &'a [u8],
1876        publishers: &'a dyn PublisherResolver,
1877        out: &'a mut CancelResponse,
1878    ) -> Self {
1879        Self {
1880            payload,
1881            publishers,
1882            reply: None,
1883            decision: Some(DecisionSink::Cancel(out)),
1884            #[cfg(feature = "safety-e2e")]
1885            integrity: None,
1886            #[cfg(feature = "param-services")]
1887            params: None,
1888        }
1889    }
1890
1891    /// Phase 264 W4c — thread the executor's volatile parameter store into this context.
1892    /// The dispatch site calls this after construction (the store reaches the callback via
1893    /// the component cell, not the constructor args). No-op-equivalent when `None`.
1894    #[cfg(feature = "param-services")]
1895    pub fn set_param_server(&mut self, params: Option<&'a crate::ParameterServer<'a>>) {
1896        self.params = params;
1897    }
1898
1899    /// Phase 264 W4c — read this node's parameter `name` as `T`, or `None` if the
1900    /// parameter is undeclared, the wrong type, or `[param_services]` is not enabled.
1901    /// Returns the **live** value — the launch-baked initial, or whatever a `ros2 param
1902    /// set` last wrote (RFC-0004 §10; values are volatile, lost at the next boot).
1903    #[cfg(feature = "param-services")]
1904    pub fn parameter<T: crate::ParameterVariant>(&self, name: &str) -> Option<T> {
1905        self.params
1906            .and_then(|server| server.get(name))
1907            .and_then(T::from_parameter_value)
1908    }
1909
1910    /// Set the action goal-callback's accept/reject decision (W.5.3). `Err` when
1911    /// the callback is not a goal decision.
1912    pub fn set_goal_response(&mut self, response: GoalResponse) -> NodeResult<()> {
1913        match &mut self.decision {
1914            Some(DecisionSink::Goal(slot)) => {
1915                **slot = response;
1916                Ok(())
1917            }
1918            _ => Err(NodeDeclError::Runtime),
1919        }
1920    }
1921
1922    /// Set the action cancel-callback's accept/reject decision (W.5.3). `Err` when
1923    /// the callback is not a cancel decision.
1924    ///
1925    /// Issue 0796 — `CancelResponse` here is the PER-GOAL decision
1926    /// (`Reject` / `Accept`), the twin of [`GoalResponse`] and the same two
1927    /// values C's `nros_cancel_response_t` and C++'s `nros::CancelResponse`
1928    /// carry. It used to be the `action_msgs/srv/CancelGoal` RPC return code
1929    /// (now `nros_core::CancelReturnCode`), so answering "cancel this goal"
1930    /// was spelled `CancelResponse::Ok` — a whole-request status code used to
1931    /// decide one goal.
1932    pub fn set_cancel_response(&mut self, response: CancelResponse) -> NodeResult<()> {
1933        match &mut self.decision {
1934            Some(DecisionSink::Cancel(slot)) => {
1935                **slot = response;
1936                Ok(())
1937            }
1938            _ => Err(NodeDeclError::Runtime),
1939        }
1940    }
1941
1942    /// Write the service / action reply as raw CDR bytes (W.5.3). `Err` when the
1943    /// callback has no reply sink (timer / subscription) or the reply exceeds the
1944    /// lent buffer.
1945    pub fn reply_raw(&mut self, data: &[u8]) -> NodeResult<()> {
1946        let sink = self.reply.as_mut().ok_or(NodeDeclError::Runtime)?;
1947        if data.len() > sink.buf.len() {
1948            return Err(NodeDeclError::Runtime);
1949        }
1950        sink.buf[..data.len()].copy_from_slice(data);
1951        *sink.written = data.len();
1952        Ok(())
1953    }
1954
1955    /// Serialize `msg` and write it as the service / action reply (W.5.3).
1956    pub fn reply<M: RosMessage, const N: usize>(&mut self, msg: &M) -> NodeResult<()> {
1957        let mut buf = [0u8; N];
1958        let mut writer =
1959            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
1960        msg.serialize(&mut writer)
1961            .map_err(|_| NodeDeclError::Runtime)?;
1962        let len = writer.position();
1963        self.reply_raw(&buf[..len])
1964    }
1965
1966    /// Raw CDR payload of the triggering message / request. Empty for timers.
1967    pub fn payload(&self) -> &[u8] {
1968        self.payload
1969    }
1970
1971    /// Phase 250 (Wave 2) — E2E message-integrity status (CRC + sequence gap/dup)
1972    /// for this dispatch. `Some` only when the firing subscription opted in via
1973    /// `.safety()`; `None` for timers, services, and non-safety subscriptions.
1974    /// Read it alongside [`message`](Self::message) — the status describes the
1975    /// message you just received.
1976    #[cfg(feature = "safety-e2e")]
1977    pub fn integrity(&self) -> Option<&crate::IntegrityStatus> {
1978        self.integrity
1979    }
1980
1981    /// Deserialize the triggering payload as `M` (subscription / service-request
1982    /// bodies). `Err` if the payload is malformed for `M`.
1983    pub fn message<M: RosMessage>(&self) -> NodeResult<M> {
1984        let mut reader =
1985            crate::CdrReader::new_with_header(self.payload).map_err(|_| NodeDeclError::Runtime)?;
1986        // issue 0461 — an action GOAL callback's payload is the whole SendGoal
1987        // request, `[CDR header][goal_id uuid][goal fields]`. Without this skip
1988        // the reader is sitting on the uuid and a goal type decodes its first
1989        // four bytes — the goal counter, so every goal looked like `order = 1`.
1990        //
1991        // The goal_id reaches the callback by other means (the server's
1992        // `for_each_active_goal_for_name`), so it is framing here, not data.
1993        // Same shape as the typed `try_accept_goal` path, which has always
1994        // skipped it and has always decoded correctly.
1995        if matches!(self.decision, Some(DecisionSink::Goal(_))) {
1996            for _ in 0..GOAL_UUID_LEN {
1997                let _ = reader.read_u8();
1998            }
1999        }
2000        M::deserialize(&mut reader).map_err(|_| NodeDeclError::Runtime)
2001    }
2002
2003    /// Publish raw CDR bytes through the named publisher entity (immediate).
2004    #[doc(hidden)]
2005    pub fn publish_raw(&self, publisher: EntityId<'_>, data: &[u8]) -> NodeResult<()> {
2006        self.publishers.publish_raw(publisher.as_str(), data)
2007    }
2008
2009    /// Serialize `msg` into an `N`-byte stack buffer and publish it (immediate).
2010    /// `N` must be ≥ the CDR-encoded size of `msg`; the generated runtime picks
2011    /// it from the message type.
2012    #[doc(hidden)]
2013    pub fn publish<M: RosMessage, const N: usize>(
2014        &self,
2015        publisher: EntityId<'_>,
2016        msg: &M,
2017    ) -> NodeResult<()> {
2018        let mut buf = [0u8; N];
2019        let mut writer =
2020            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
2021        msg.serialize(&mut writer)
2022            .map_err(|_| NodeDeclError::Runtime)?;
2023        let len = writer.position();
2024        self.publish_raw(publisher, &buf[..len])
2025    }
2026
2027    /// Serialize `msg` and publish through the entity synthesized from `topic`.
2028    ///
2029    /// This pairs with
2030    /// [`DeclaredNode::create_publisher_for_topic`], allowing simple callback
2031    /// bodies to use the ROS topic literal instead of restating an unrelated
2032    /// stable entity ID.
2033    pub fn publish_to_topic<M: RosMessage, const N: usize>(
2034        &self,
2035        topic: &str,
2036        msg: &M,
2037    ) -> NodeResult<()> {
2038        self.publish::<M, N>(EntityId::new(topic), msg)
2039    }
2040}
2041
2042/// The executable counterpart of [`Node`] (W.5.1).
2043///
2044/// `register` (declarative) stays the planning SSOT; this binds runnable
2045/// bodies. The generated runtime builds [`State`](ExecutableNode::State) once via
2046/// [`init`](ExecutableNode::init), then routes every fired callback to
2047/// [`on_callback`](ExecutableNode::on_callback). Trait-dispatch (no boxed `dyn`, no
2048/// `alloc`) keeps it `no_std`.
2049/// Executor-backed action operations a [`TickCtx`] drives (W.5.6).
2050///
2051/// Action result/feedback need `&mut Executor` (`complete_goal_raw` /
2052/// `publish_feedback_raw`), which a mid-spin *callback* can't hold (the executor
2053/// is borrowed) — so they run from [`ExecutableNode::tick`], between spins.
2054/// The generated runtime implements this over the real executor + the action
2055/// servers' handles (resolved by stable action entity id); the component never
2056/// sees the executor directly. Kept as a trait so [`TickCtx`] stays `no_std` +
2057/// free of the `rmw-cffi`-gated `Executor` type.
2058pub trait ActionExecutor {
2059    /// Complete the goal `goal_id` on action `action_entity` with raw CDR result.
2060    fn complete_goal_raw(
2061        &mut self,
2062        action_entity: &str,
2063        goal_id: &GoalId,
2064        status: GoalStatus,
2065        result: &[u8],
2066    ) -> NodeResult<()>;
2067
2068    /// Publish raw CDR feedback for `goal_id` on action `action_entity`.
2069    fn publish_feedback_raw(
2070        &mut self,
2071        action_entity: &str,
2072        goal_id: &GoalId,
2073        feedback: &[u8],
2074    ) -> NodeResult<()>;
2075
2076    /// Visit every goal on `action_entity` that has been accepted but not yet
2077    /// completed, with its id + current status. The execution seam: a `tick` body
2078    /// has no other way to learn an accepted goal's id (the goal-decision callback
2079    /// doesn't surface it), so it iterates here to drive feedback / completion.
2080    fn for_each_active_goal(&self, action_entity: &str, visit: &mut dyn FnMut(&GoalId, GoalStatus));
2081}
2082
2083/// Executor-backed CLIENT operations a [`TickCtx`] drives (Phase 212.M-F.4).
2084///
2085/// Service-client `call` + action-client `send_goal` need `&mut Executor`
2086/// (the W.5.6 client handles live on the executor), which a mid-spin
2087/// callback can't hold. They run from [`ExecutableNode::tick`], between
2088/// spins. The generated runtime impls this over the real executor + the
2089/// service/action client handles (resolved by stable client entity id); the
2090/// component never sees the executor directly. Kept as a trait so [`TickCtx`]
2091/// stays `no_std` + free of the `rmw-cffi`-gated `Executor` type.
2092///
2093/// Mirrors the sibling [`ActionExecutor`] (server-side ops). Splitting
2094/// client vs server keeps each trait small + lets the codegen-side
2095/// `GenClientDispatch` impl resolve client handles independently from
2096/// server handles.
2097pub trait ClientDispatch {
2098    /// Issue a service-client request on `service_entity` carrying CDR
2099    /// `request_cdr`; block on the reply, write the response CDR into
2100    /// `response_buf`, return the response length in bytes.
2101    ///
2102    /// The synchronous block is built on the executor-driven
2103    /// `send_request_raw` + `take_response_raw` pair (phase-301: the
2104    /// RMW layer has no blocking call) — the tick hook drives the
2105    /// executor between callback dispatch, so a blocked `call_raw`
2106    /// does not starve other callbacks (each tick yields back to the
2107    /// runtime after returning).
2108    fn call_raw(
2109        &mut self,
2110        service_entity: &str,
2111        request_cdr: &[u8],
2112        response_buf: &mut [u8],
2113    ) -> NodeResult<usize>;
2114
2115    /// Send an action-client goal request on `action_entity` carrying
2116    /// CDR `goal_cdr`; return the assigned [`GoalId`] (server-stamped on
2117    /// the goal-accept response). Result + feedback streams arrive via
2118    /// callback dispatch — not this method.
2119    fn send_goal_raw(&mut self, action_entity: &str, goal_cdr: &[u8]) -> NodeResult<GoalId>;
2120}
2121
2122/// Context handed to [`ExecutableNode::tick`] (W.5.6 + M-F.4): the per-spin
2123/// hook that runs *between* callback dispatch, where the executor is free.
2124/// Exposes the immediate publish path (like `CallbackCtx`) plus executor-backed
2125/// action-server ops (complete goal / publish feedback) AND executor-backed
2126/// client-side ops (service `call` / action-client `send_goal`). Callbacks
2127/// can't perform any of these since they don't hold the executor.
2128pub struct TickCtx<'a> {
2129    publishers: &'a dyn PublisherResolver,
2130    actions: &'a mut dyn ActionExecutor,
2131    clients: &'a mut dyn ClientDispatch,
2132    /// Phase 264 W4c — the executor's volatile parameter store, threaded by the tick
2133    /// driver (`tick_one_cell` already holds the executor). `None` until
2134    /// `[param_services]` registers the store. Read with [`parameter`](Self::parameter).
2135    #[cfg(feature = "param-services")]
2136    params: Option<&'a crate::ParameterServer<'a>>,
2137}
2138
2139impl<'a> TickCtx<'a> {
2140    /// Build a tick context (called by the generated runtime each spin).
2141    pub fn new(
2142        publishers: &'a dyn PublisherResolver,
2143        actions: &'a mut dyn ActionExecutor,
2144        clients: &'a mut dyn ClientDispatch,
2145    ) -> Self {
2146        Self {
2147            publishers,
2148            actions,
2149            clients,
2150            #[cfg(feature = "param-services")]
2151            params: None,
2152        }
2153    }
2154
2155    /// Phase 264 W4c — thread the executor's volatile parameter store in (the tick
2156    /// driver holds the executor directly). No-op-equivalent when `None`.
2157    #[cfg(feature = "param-services")]
2158    pub fn set_param_server(&mut self, params: Option<&'a crate::ParameterServer<'a>>) {
2159        self.params = params;
2160    }
2161
2162    /// Phase 264 W4c — read this node's parameter `name` as `T` during `tick`, or `None`
2163    /// if undeclared, the wrong type, or `[param_services]` is off. Returns the live
2164    /// value (baked initial or last `ros2 param set`; volatile — RFC-0004 §10).
2165    #[cfg(feature = "param-services")]
2166    pub fn parameter<T: crate::ParameterVariant>(&self, name: &str) -> Option<T> {
2167        self.params
2168            .and_then(|server| server.get(name))
2169            .and_then(T::from_parameter_value)
2170    }
2171
2172    /// Publish raw CDR bytes through the named publisher entity (immediate).
2173    #[doc(hidden)]
2174    pub fn publish_raw(&self, publisher: EntityId<'_>, data: &[u8]) -> NodeResult<()> {
2175        self.publishers.publish_raw(publisher.as_str(), data)
2176    }
2177
2178    /// Serialize `msg` into an `N`-byte stack buffer and publish it (immediate).
2179    #[doc(hidden)]
2180    pub fn publish<M: RosMessage, const N: usize>(
2181        &self,
2182        publisher: EntityId<'_>,
2183        msg: &M,
2184    ) -> NodeResult<()> {
2185        let mut buf = [0u8; N];
2186        let mut writer =
2187            crate::CdrWriter::new_with_header(&mut buf).map_err(|_| NodeDeclError::Runtime)?;
2188        msg.serialize(&mut writer)
2189            .map_err(|_| NodeDeclError::Runtime)?;
2190        let len = writer.position();
2191        self.publish_raw(publisher, &buf[..len])
2192    }
2193
2194    /// Serialize `msg` and publish through the entity synthesized from `topic`.
2195    ///
2196    /// This pairs with [`DeclaredNode::create_publisher_for_topic`] for
2197    /// executable tick hooks.
2198    pub fn publish_to_topic<M: RosMessage, const N: usize>(
2199        &self,
2200        topic: &str,
2201        msg: &M,
2202    ) -> NodeResult<()> {
2203        self.publish::<M, N>(EntityId::new(topic), msg)
2204    }
2205
2206    /// Complete an action goal with a typed result (W.5.6 — needs the executor,
2207    /// hence tick-only).
2208    #[doc(hidden)]
2209    pub fn complete_goal<R: RosMessage, const N: usize>(
2210        &mut self,
2211        action: EntityId<'_>,
2212        goal_id: &GoalId,
2213        status: GoalStatus,
2214        result: &R,
2215    ) -> NodeResult<()> {
2216        // RFC-0069 / issue 0418 — NO inner encapsulation header. ROS 2's
2217        // `<Action>_GetResult_Response` is ONE CDR message: `[header][status][result
2218        // fields]`. This used to write a second header inside the envelope, which
2219        // made the payload `[outer][status][pad][INNER][fields]` — self-consistent
2220        // with nano-ros's own raw consumer and undecodable by any `rcl_action` peer
2221        // or by nano-ros's TYPED path.
2222        //
2223        // The issue-#35 corruption that motivated the old header ("the reader eats
2224        // the first data word … `sequence` deserialized to len 0") is now prevented
2225        // on the READ side instead: the executor splices the envelope's encap onto
2226        // the body before the callback sees it, so `CallbackCtx::message` still
2227        // receives a well-formed CDR message. Producer and consumer changed
2228        // together — either alone reproduces #35.
2229        let mut buf = [0u8; N];
2230        let mut writer = crate::CdrWriter::new(&mut buf);
2231        result
2232            .serialize(&mut writer)
2233            .map_err(|_| NodeDeclError::Runtime)?;
2234        let len = writer.position();
2235        self.actions
2236            .complete_goal_raw(action.as_str(), goal_id, status, &buf[..len])
2237    }
2238
2239    /// Complete an action goal on the action entity synthesized from `name`.
2240    ///
2241    /// This pairs with
2242    /// [`DeclaredNode::create_action_server_for_name`] and
2243    /// [`DeclaredNode::create_action_server_for_name_with_callbacks`].
2244    pub fn complete_goal_for_name<R: RosMessage, const N: usize>(
2245        &mut self,
2246        name: &str,
2247        goal_id: &GoalId,
2248        status: GoalStatus,
2249        result: &R,
2250    ) -> NodeResult<()> {
2251        self.complete_goal::<R, N>(EntityId::new(name), goal_id, status, result)
2252    }
2253
2254    /// Visit each active (accepted, not yet completed) goal on `action` with its
2255    /// id + status — how a `tick` body discovers goals to feed / complete. Collect
2256    /// the ids you want to act on, then call [`Self::publish_feedback`] /
2257    /// [`Self::complete_goal`] after the visit returns (those borrow `self`
2258    /// mutably, so they can't run inside `visit`).
2259    #[doc(hidden)]
2260    pub fn for_each_active_goal(
2261        &self,
2262        action: EntityId<'_>,
2263        visit: &mut dyn FnMut(&GoalId, GoalStatus),
2264    ) {
2265        self.actions.for_each_active_goal(action.as_str(), visit);
2266    }
2267
2268    /// Visit active goals on the action entity synthesized from `name`.
2269    pub fn for_each_active_goal_for_name(
2270        &self,
2271        name: &str,
2272        visit: &mut dyn FnMut(&GoalId, GoalStatus),
2273    ) {
2274        self.for_each_active_goal(EntityId::new(name), visit);
2275    }
2276
2277    /// Publish typed feedback for an active action goal (W.5.6 — tick-only).
2278    #[doc(hidden)]
2279    pub fn publish_feedback<F: RosMessage, const N: usize>(
2280        &mut self,
2281        action: EntityId<'_>,
2282        goal_id: &GoalId,
2283        feedback: &F,
2284    ) -> NodeResult<()> {
2285        // RFC-0069 / issue 0418 — NO inner encapsulation header; see
2286        // `complete_goal` above. ROS 2's `<Action>_FeedbackMessage` is ONE CDR
2287        // message: `[header][goal_id][feedback fields]`. The executor frames
2288        // `[header][goal_id]` and this payload is the fields alone.
2289        let mut buf = [0u8; N];
2290        let mut writer = crate::CdrWriter::new(&mut buf);
2291        feedback
2292            .serialize(&mut writer)
2293            .map_err(|_| NodeDeclError::Runtime)?;
2294        let len = writer.position();
2295        self.actions
2296            .publish_feedback_raw(action.as_str(), goal_id, &buf[..len])
2297    }
2298
2299    /// Publish feedback on the action entity synthesized from `name`.
2300    pub fn publish_feedback_for_name<F: RosMessage, const N: usize>(
2301        &mut self,
2302        name: &str,
2303        goal_id: &GoalId,
2304        feedback: &F,
2305    ) -> NodeResult<()> {
2306        self.publish_feedback::<F, N>(EntityId::new(name), goal_id, feedback)
2307    }
2308
2309    /// Issue a service-client raw-CDR request and block on the reply
2310    /// (M-F.4 — tick-only). Writes the response CDR into `response_buf`
2311    /// and returns the response length in bytes.
2312    #[doc(hidden)]
2313    pub fn call_raw(
2314        &mut self,
2315        service: EntityId<'_>,
2316        request_cdr: &[u8],
2317        response_buf: &mut [u8],
2318    ) -> NodeResult<usize> {
2319        self.clients
2320            .call_raw(service.as_str(), request_cdr, response_buf)
2321    }
2322
2323    /// Issue a raw service-client request through the entity synthesized
2324    /// from `name`.
2325    pub fn call_raw_for_name(
2326        &mut self,
2327        name: &str,
2328        request_cdr: &[u8],
2329        response_buf: &mut [u8],
2330    ) -> NodeResult<usize> {
2331        self.call_raw(EntityId::new(name), request_cdr, response_buf)
2332    }
2333
2334    /// Issue a typed service-client request and decode the reply
2335    /// (M-F.4 — tick-only). `REQ_N` / `RESP_N` stack-size the request /
2336    /// response CDR buffers; size them via
2337    /// `<<Req as RosMessage>::SerializedSize as nros::SerializedSize>::SIZE`.
2338    #[doc(hidden)]
2339    pub fn call<Req: RosMessage, Resp: RosMessage, const REQ_N: usize, const RESP_N: usize>(
2340        &mut self,
2341        service: EntityId<'_>,
2342        request: &Req,
2343    ) -> NodeResult<Resp> {
2344        let mut req_buf = [0u8; REQ_N];
2345        let mut writer =
2346            crate::CdrWriter::new_with_header(&mut req_buf).map_err(|_| NodeDeclError::Runtime)?;
2347        request
2348            .serialize(&mut writer)
2349            .map_err(|_| NodeDeclError::Runtime)?;
2350        let req_len = writer.position();
2351
2352        let mut resp_buf = [0u8; RESP_N];
2353        let resp_len =
2354            self.clients
2355                .call_raw(service.as_str(), &req_buf[..req_len], &mut resp_buf)?;
2356
2357        let mut reader = crate::CdrReader::new_with_header(&resp_buf[..resp_len])
2358            .map_err(|_| NodeDeclError::Runtime)?;
2359        Resp::deserialize(&mut reader).map_err(|_| NodeDeclError::Runtime)
2360    }
2361
2362    /// Issue a typed service-client request through the entity synthesized
2363    /// from `name`.
2364    pub fn call_for_name<
2365        Req: RosMessage,
2366        Resp: RosMessage,
2367        const REQ_N: usize,
2368        const RESP_N: usize,
2369    >(
2370        &mut self,
2371        name: &str,
2372        request: &Req,
2373    ) -> NodeResult<Resp> {
2374        self.call::<Req, Resp, REQ_N, RESP_N>(EntityId::new(name), request)
2375    }
2376
2377    /// Send a raw-CDR action-client goal and return the assigned
2378    /// [`GoalId`] (M-F.4 — tick-only). Result + feedback streams arrive
2379    /// via callback dispatch; this method only kicks off the request.
2380    #[doc(hidden)]
2381    pub fn send_goal_raw(&mut self, action: EntityId<'_>, goal_cdr: &[u8]) -> NodeResult<GoalId> {
2382        self.clients.send_goal_raw(action.as_str(), goal_cdr)
2383    }
2384
2385    /// Send a raw-CDR action-client goal through the entity synthesized
2386    /// from `name`.
2387    pub fn send_goal_raw_for_name(&mut self, name: &str, goal_cdr: &[u8]) -> NodeResult<GoalId> {
2388        self.send_goal_raw(EntityId::new(name), goal_cdr)
2389    }
2390
2391    /// Send a typed action-client goal and return the assigned
2392    /// [`GoalId`] (M-F.4 — tick-only). `N` stack-sizes the goal CDR
2393    /// buffer.
2394    #[doc(hidden)]
2395    pub fn send_goal<G: RosMessage, const N: usize>(
2396        &mut self,
2397        action: EntityId<'_>,
2398        goal: &G,
2399    ) -> NodeResult<GoalId> {
2400        // RFC-0069 / issue 0418 — NO inner encapsulation header, same rule as
2401        // `publish_feedback` / `complete_goal`. ROS 2's `<Action>_SendGoal_Request`
2402        // is ONE CDR message: `[header][goal_id][goal fields]`. `send_goal_raw`
2403        // frames `[header][goal_id]`, so this payload is the fields alone.
2404        //
2405        // Issue 0448: this path used `new_with_header` and shipped a SECOND
2406        // encapsulation (`header|uuid|header|fields`) — 4 bytes over the ROS 2
2407        // layout. Fast-DDS sizes its reader history from the type and drops the
2408        // sample outright ("Change payload size of '28' bytes is larger than the
2409        // history payload size of '27'"), so the goal never reached the server
2410        // and the client saw a zeroed default result. nros-c / nros-cpp already
2411        // stripped the header; only this Rust path was missed.
2412        //
2413        // Confirmed independently while root-causing issue 0461: the extra
2414        // header also made every SERVER decode the wrong offset over zenoh
2415        // (C/C++ read the inner header as the first field, so an order of 10
2416        // arrived as 256). Same defect, a second symptom.
2417        let mut buf = [0u8; N];
2418        let mut writer = crate::CdrWriter::new(&mut buf);
2419        goal.serialize(&mut writer)
2420            .map_err(|_| NodeDeclError::Runtime)?;
2421        let len = writer.position();
2422        self.clients.send_goal_raw(action.as_str(), &buf[..len])
2423    }
2424
2425    /// Send a typed action-client goal through the entity synthesized
2426    /// from `name`.
2427    pub fn send_goal_for_name<G: RosMessage, const N: usize>(
2428        &mut self,
2429        name: &str,
2430        goal: &G,
2431    ) -> NodeResult<GoalId> {
2432        self.send_goal::<G, N>(EntityId::new(name), goal)
2433    }
2434}
2435
2436pub trait ExecutableNode: Node {
2437    /// Per-instance mutable state shared across the component's callbacks.
2438    type State;
2439
2440    /// Build the initial state (called once by the generated runtime).
2441    fn init() -> Self::State;
2442
2443    /// Run the body for `callback`. `ctx` exposes the triggering payload + the
2444    /// immediate publish path. Bodies match on the source callback name declared
2445    /// by `create_*_for_callback_name` and related helpers.
2446    fn on_callback(state: &mut Self::State, callback: Callback<'_>, ctx: &mut CallbackCtx<'_>);
2447
2448    /// Per-spin execution hook (W.5.6), run *between* callback dispatch by the
2449    /// generated runtime — where the executor is free, so this is the only place
2450    /// a component can complete action goals / publish feedback (via `ctx`) or do
2451    /// periodic work. Default: no-op (timer/sub/service-only components).
2452    fn tick(_state: &mut Self::State, _ctx: &mut TickCtx<'_>) {}
2453}
2454
2455/// Emit a no-op [`ExecutableNode`] impl for a declarative-only component
2456/// (W.5.1). The generated runtime calls `on_callback` unconditionally, so a
2457/// component instantiated into a generated binary must impl `ExecutableNode`;
2458/// components without callback bodies use this to satisfy that contract:
2459///
2460/// ```ignore
2461/// pub struct Node;
2462/// impl nros::Node for Node { /* register(...) */ }
2463/// nros::declarative_component!(Node);
2464/// ```
2465#[macro_export]
2466macro_rules! declarative_component {
2467    ($ty:ty) => {
2468        impl $crate::ExecutableNode for $ty {
2469            type State = ();
2470            fn init() -> Self::State {}
2471            fn on_callback(
2472                _state: &mut Self::State,
2473                _callback: $crate::Callback<'_>,
2474                _ctx: &mut $crate::CallbackCtx<'_>,
2475            ) {
2476            }
2477        }
2478    };
2479}
2480
2481/// Run component registration against any component runtime.
2482pub fn register_node<C: Node>(runtime: &mut dyn NodeRuntime) -> NodeResult<()> {
2483    let mut context = NodeContext::new(C::NAME, runtime);
2484    C::register(&mut context)
2485}
2486
2487/// Phase 212.M.5.a.4 internal — `Box`-erase a freshly built component
2488/// `State` to the type-erased `*mut ()` ABI the BSP path uses. Called
2489/// only from the `nros::node!()` macro emit; not public API.
2490///
2491/// The returned pointer is a leaked `Box`; the BSP runtime keeps it
2492/// alive for the firmware lifetime (embedded slots never deallocate).
2493#[cfg(feature = "alloc")]
2494#[doc(hidden)]
2495pub fn __private_node_state_into_raw<C: ExecutableNode>(state: C::State) -> *mut () {
2496    extern crate alloc;
2497    alloc::boxed::Box::into_raw(alloc::boxed::Box::new(state)) as *mut ()
2498}
2499
2500/// Run component registration against an in-memory metadata recorder.
2501pub fn record_node_metadata<C: Node>(recorder: &mut dyn NodeRuntime) -> NodeResult<()> {
2502    register_node::<C>(recorder)
2503}
2504
2505#[cfg(test)]
2506mod tests {
2507    use super::*;
2508    use crate::{CdrReader, CdrWriter, DeserError, SerError, SourceNameKind};
2509
2510    #[derive(Default)]
2511    struct FakeNodeRuntime {
2512        next: u8,
2513        created: Vec<MetadataString, 4>,
2514    }
2515
2516    impl DeclaredNodeRuntime for FakeNodeRuntime {
2517        type NodeHandle = u8;
2518
2519        fn build_component_node(
2520            &mut self,
2521            _id: NodeId<'_>,
2522            options: NodeOptions<'_>,
2523        ) -> NodeResult<Self::NodeHandle> {
2524            self.created
2525                .push(copy_str(options.name)?)
2526                .map_err(|_| NodeDeclError::Metadata(NodeMetadataError::Capacity))?;
2527            let handle = self.next;
2528            self.next += 1;
2529            Ok(handle)
2530        }
2531    }
2532
2533    #[derive(Debug, Clone, Copy, Default)]
2534    struct TestMsg;
2535
2536    impl crate::Serialize for TestMsg {
2537        fn serialize(&self, _writer: &mut CdrWriter) -> Result<(), SerError> {
2538            Ok(())
2539        }
2540    }
2541
2542    impl crate::Deserialize for TestMsg {
2543        fn deserialize(_reader: &mut CdrReader) -> Result<Self, DeserError> {
2544            Ok(Self)
2545        }
2546    }
2547
2548    impl RosMessage for TestMsg {
2549        const TYPE_NAME: &'static str = "test_msgs::msg::dds_::Test_";
2550        const TYPE_HASH: &'static str = "test_hash";
2551    }
2552
2553    // Phase 380 W4 — `MessageForRmw` now also requires a field schema, because
2554    // that is where a subscription's size bound comes from. `TestMsg` is an
2555    // empty struct, so its schema is the empty slice and its bound is just the
2556    // encapsulation header — which is exactly what the build assertion should
2557    // see for it.
2558    impl nros_serdes::schema::Message for TestMsg {
2559        const TYPE_NAME: &'static str = "test_msgs/msg/Test";
2560        const FIELDS: &'static [nros_serdes::schema::Field] = &[];
2561    }
2562
2563    struct TestService;
2564
2565    impl RosService for TestService {
2566        type Request = TestMsg;
2567        type Reply = TestMsg;
2568
2569        const SERVICE_NAME: &'static str = "test_msgs::srv::dds_::Test_";
2570        const SERVICE_HASH: &'static str = "test_service_hash";
2571    }
2572
2573    struct TestAction;
2574
2575    impl RosAction for TestAction {
2576        type Goal = TestMsg;
2577        type Result = TestMsg;
2578        type Feedback = TestMsg;
2579        type SendGoalRequest = TestMsg;
2580        type SendGoalResponse = TestMsg;
2581        type GetResultRequest = TestMsg;
2582        type GetResultResponse = TestMsg;
2583        type FeedbackMessage = TestMsg;
2584
2585        const ACTION_NAME: &'static str = "test_msgs::action::dds_::Test_";
2586        const ACTION_HASH: &'static str = "test_action_hash";
2587    }
2588
2589    struct TalkerComponent;
2590
2591    impl Node for TalkerComponent {
2592        const NAME: &'static str = "talker_component";
2593
2594        fn register(context: &mut NodeContext<'_>) -> NodeResult<()> {
2595            let mut node =
2596                context.create_node_with_id(NodeId::new("node"), NodeOptions::new("talker"))?;
2597            let _publisher =
2598                node.create_publisher::<TestMsg>(EntityId::new("pub_chatter"), "chatter")?;
2599            let _subscription = node.create_subscription::<TestMsg>(
2600                EntityId::new("sub_cmd"),
2601                CallbackId::new("on_cmd"),
2602                "~/cmd",
2603            )?;
2604            let _timer = node.create_timer(
2605                EntityId::new("timer_tick"),
2606                CallbackId::new("on_tick"),
2607                TimerDuration::from_millis(10),
2608            )?;
2609            let _parameter =
2610                node.declare_parameter(EntityId::new("param_gain"), "gain", ParameterType::Double)?;
2611            node.callback(CallbackId::new("on_tick"))
2612                .publishes(EntityId::new("pub_chatter"))?
2613                .writes(EntityId::new("param_gain"))?;
2614            Ok(())
2615        }
2616    }
2617
2618    #[test]
2619    fn component_records_metadata_without_transport() {
2620        let mut recorder = MetadataRecorder::<2, 8, 4>::new();
2621        record_node_metadata::<TalkerComponent>(&mut recorder).unwrap();
2622
2623        assert_eq!(recorder.nodes().len(), 1);
2624        assert_eq!(recorder.nodes()[0].name.as_str(), "talker");
2625        assert_eq!(recorder.entities().len(), 4);
2626        assert_eq!(recorder.entities()[0].kind, EntityKind::Publisher);
2627        assert_eq!(recorder.entities()[1].source_name.as_str(), "~/cmd");
2628        assert_eq!(
2629            recorder.entities()[1]
2630                .callback_id
2631                .as_ref()
2632                .map(|id| id.as_str()),
2633            Some("on_cmd")
2634        );
2635        assert_eq!(recorder.callback_effects().len(), 2);
2636    }
2637
2638    // Phase 250 Wave 2b — the declarative `.safety()` opt-in records the
2639    // `EntityMetadata.safety` flag so the runtime registers the integrity-aware
2640    // subscription. A plain subscription stays `safety == false`.
2641    struct SafetyComponent;
2642    impl Node for SafetyComponent {
2643        const NAME: &'static str = "safety_component";
2644        fn register(context: &mut NodeContext<'_>) -> NodeResult<()> {
2645            let mut node =
2646                context.create_node_with_id(NodeId::new("node"), NodeOptions::new("listener"))?;
2647            let _plain = node.create_subscription_for_callback_name::<TestMsg>("on_plain", "/a")?;
2648            let _safe =
2649                node.create_subscription_for_callback_name_with_safety::<TestMsg>("on_safe", "/b")?;
2650            Ok(())
2651        }
2652    }
2653
2654    #[test]
2655    fn safety_opt_in_records_metadata_flag() {
2656        let mut recorder = MetadataRecorder::<2, 8, 4>::new();
2657        record_node_metadata::<SafetyComponent>(&mut recorder).unwrap();
2658        let ents = recorder.entities();
2659        assert_eq!(ents.len(), 2);
2660        // Plain subscription on /a — no safety.
2661        assert_eq!(ents[0].source_name.as_str(), "/a");
2662        assert!(!ents[0].safety, "plain sub must not be flagged");
2663        // `.safety()` subscription on /b — flagged.
2664        assert_eq!(ents[1].source_name.as_str(), "/b");
2665        assert!(ents[1].safety, "safety sub must be flagged");
2666    }
2667
2668    struct GroupedComponent;
2669
2670    impl Node for GroupedComponent {
2671        const NAME: &'static str = "grouped_component";
2672
2673        fn register(context: &mut NodeContext<'_>) -> NodeResult<()> {
2674            let mut node =
2675                context.create_node_with_id(NodeId::new("node"), NodeOptions::new("grouped"))?;
2676            // Unlabeled entity declared before any group is set.
2677            let _pub = node.create_publisher::<TestMsg>(EntityId::new("pub_plain"), "plain")?;
2678            // Sticky "control" group covers the next two entities.
2679            node.callback_group("control")?;
2680            let _sub = node.create_subscription::<TestMsg>(
2681                EntityId::new("sub_cmd"),
2682                CallbackId::new("on_cmd"),
2683                "~/cmd",
2684            )?;
2685            let _timer = node.create_timer(
2686                EntityId::new("timer_tick"),
2687                CallbackId::new("on_tick"),
2688                TimerDuration::from_millis(10),
2689            )?;
2690            // Switch to "telemetry" for the last entity.
2691            node.callback_group("telemetry")?;
2692            let _sub2 = node.create_subscription::<TestMsg>(
2693                EntityId::new("sub_diag"),
2694                CallbackId::new("on_diag"),
2695                "~/diag",
2696            )?;
2697            Ok(())
2698        }
2699    }
2700
2701    #[test]
2702    fn sticky_callback_group_stamps_subsequent_entities() {
2703        let mut recorder = MetadataRecorder::<2, 8, 4>::new();
2704        record_node_metadata::<GroupedComponent>(&mut recorder).unwrap();
2705
2706        let group_of = |idx: usize| {
2707            recorder.entities()[idx]
2708                .callback_group
2709                .as_ref()
2710                .map(|g| g.as_str())
2711        };
2712        // pub_plain — declared before any group → unlabeled.
2713        assert_eq!(group_of(0), None);
2714        // sub_cmd + timer_tick — under "control".
2715        assert_eq!(group_of(1), Some("control"));
2716        assert_eq!(group_of(2), Some("control"));
2717        // sub_diag — under "telemetry".
2718        assert_eq!(group_of(3), Some("telemetry"));
2719    }
2720
2721    #[test]
2722    fn runtime_adapter_maps_stable_nodes_to_runtime_handles() {
2723        let mut node_runtime = FakeNodeRuntime::default();
2724        let mut runtime = NodeRuntimeAdapter::<_, 2, 8, 4>::new(&mut node_runtime);
2725
2726        register_node::<TalkerComponent>(&mut runtime).unwrap();
2727
2728        assert_eq!(runtime.nodes().len(), 1);
2729        assert_eq!(runtime.nodes()[0].slot(), NodeSlot::new(0));
2730        assert_eq!(runtime.nodes()[0].stable_id(), "node");
2731        assert_eq!(runtime.nodes()[0].source_default_name(), "talker");
2732        assert_eq!(runtime.node_handle(NodeId::new("node")), Some(0));
2733        assert_eq!(runtime.entities().len(), 4);
2734        assert_eq!(runtime.entities()[0].slot, Some(EntitySlot::new(0)));
2735        assert_eq!(runtime.entities()[0].node_slot, Some(NodeSlot::new(0)));
2736        assert_eq!(
2737            runtime.entities()[1].callback_slot,
2738            Some(CallbackSlot::new(0))
2739        );
2740        assert_eq!(
2741            runtime.entities()[2].callback_slot,
2742            Some(CallbackSlot::new(1))
2743        );
2744        assert_eq!(runtime.callback_effects().len(), 2);
2745        assert_eq!(
2746            runtime.callback_effects()[0].callback_slot,
2747            Some(CallbackSlot::new(1))
2748        );
2749        assert_eq!(
2750            runtime.callback_effects()[0].entity_slot,
2751            Some(EntitySlot::new(0))
2752        );
2753    }
2754
2755    #[test]
2756    fn context_can_synthesize_stable_node_id_from_options_name() {
2757        let mut recorder = MetadataRecorder::<1, 0, 0>::new();
2758        let mut context = NodeContext::new("test", &mut recorder);
2759        let node = context
2760            .create_node(NodeOptions::new("talker").namespace("/demo").domain_id(42))
2761            .unwrap();
2762
2763        assert_eq!(node.id(), NodeId::new("talker"));
2764        // End the `node`/`context` borrows before re-borrowing `recorder`.
2765        let _ = node;
2766        let _ = context;
2767        assert_eq!(recorder.nodes().len(), 1);
2768        assert_eq!(recorder.nodes()[0].id.as_str(), "talker");
2769        assert_eq!(recorder.nodes()[0].name.as_str(), "talker");
2770        assert_eq!(recorder.nodes()[0].namespace.as_str(), "/demo");
2771        assert_eq!(recorder.nodes()[0].domain_id, 42);
2772    }
2773
2774    #[test]
2775    fn synthesized_node_ids_reject_duplicate_names() {
2776        let mut node_runtime = FakeNodeRuntime::default();
2777        let mut runtime = NodeRuntimeAdapter::<_, 2, 0, 0>::new(&mut node_runtime);
2778        {
2779            let mut context = NodeContext::new("test", &mut runtime);
2780            context.create_node(NodeOptions::new("talker")).unwrap();
2781        }
2782        let mut context = NodeContext::new("test", &mut runtime);
2783        let result = context.create_node(NodeOptions::new("talker"));
2784
2785        assert!(matches!(
2786            result,
2787            Err(NodeDeclError::Metadata(NodeMetadataError::DuplicateId))
2788        ));
2789    }
2790
2791    #[test]
2792    fn synthesized_entity_helpers_record_topic_and_callback_ids() {
2793        let mut recorder = MetadataRecorder::<1, 3, 2>::new();
2794        let mut context = NodeContext::new("test", &mut recorder);
2795        let mut node = context.create_node(NodeOptions::new("talker")).unwrap();
2796
2797        let publisher = node
2798            .create_publisher_for_topic::<TestMsg>("/chatter")
2799            .unwrap();
2800        let subscription = node
2801            .create_subscription_for_callback::<TestMsg>(CallbackId::new("on_message"), "/cmd")
2802            .unwrap();
2803        let _timer = node
2804            .create_timer_for_callback(CallbackId::new("on_tick"), TimerDuration::from_millis(10))
2805            .unwrap();
2806
2807        node.callback(CallbackId::new("on_tick"))
2808            .publishes_entity(&publisher)
2809            .unwrap();
2810        node.callback(CallbackId::new("on_message"))
2811            .reads_entity(&subscription)
2812            .unwrap();
2813
2814        assert_eq!(publisher.id(), EntityId::new("/chatter"));
2815        assert_eq!(subscription.id(), EntityId::new("on_message"));
2816        assert_eq!(recorder.entities().len(), 3);
2817
2818        let publisher = &recorder.entities()[0];
2819        assert_eq!(publisher.id.as_str(), "/chatter");
2820        assert_eq!(publisher.kind, EntityKind::Publisher);
2821        assert_eq!(publisher.source_name.as_str(), "/chatter");
2822
2823        let subscription = &recorder.entities()[1];
2824        assert_eq!(subscription.id.as_str(), "on_message");
2825        assert_eq!(subscription.kind, EntityKind::Subscription);
2826        assert_eq!(subscription.source_name.as_str(), "/cmd");
2827        assert_eq!(
2828            subscription.callback_id.as_ref().map(|id| id.as_str()),
2829            Some("on_message")
2830        );
2831
2832        let timer = &recorder.entities()[2];
2833        assert_eq!(timer.id.as_str(), "on_tick");
2834        assert_eq!(timer.kind, EntityKind::Timer);
2835        assert_eq!(
2836            timer.callback_id.as_ref().map(|id| id.as_str()),
2837            Some("on_tick")
2838        );
2839
2840        assert_eq!(recorder.callback_effects().len(), 2);
2841        assert_eq!(
2842            recorder.callback_effects()[0].entity_id.as_str(),
2843            "/chatter"
2844        );
2845        assert_eq!(
2846            recorder.callback_effects()[1].entity_id.as_str(),
2847            "on_message"
2848        );
2849    }
2850
2851    #[test]
2852    fn named_callback_helpers_avoid_manual_callback_ids() {
2853        let mut recorder = MetadataRecorder::<1, 3, 2>::new();
2854        let mut context = NodeContext::new("test", &mut recorder);
2855        let mut node = context.create_node(NodeOptions::new("listener")).unwrap();
2856
2857        let publisher = node
2858            .create_publisher_for_topic::<TestMsg>("/chatter")
2859            .unwrap();
2860        let subscription = node
2861            .create_subscription_for_callback_name::<TestMsg>("on_message", "/chatter")
2862            .unwrap();
2863        let timer = node
2864            .create_timer_for_callback_name("on_tick", TimerDuration::from_millis(10))
2865            .unwrap();
2866
2867        node.callback_for_name("on_message")
2868            .reads_entity(&subscription)
2869            .unwrap();
2870        node.callback_for_name("on_tick")
2871            .publishes_entity(&publisher)
2872            .unwrap();
2873
2874        assert_eq!(subscription.id().as_str(), "on_message");
2875        assert_eq!(timer.id().as_str(), "on_tick");
2876        assert_eq!(
2877            recorder.entities()[1]
2878                .callback_id
2879                .as_ref()
2880                .map(|id| id.as_str()),
2881            Some("on_message")
2882        );
2883        assert_eq!(
2884            recorder.entities()[2]
2885                .callback_id
2886                .as_ref()
2887                .map(|id| id.as_str()),
2888            Some("on_tick")
2889        );
2890        assert_eq!(
2891            recorder.callback_effects()[0].callback_id.as_str(),
2892            "on_message"
2893        );
2894        assert_eq!(
2895            recorder.callback_effects()[0].entity_id.as_str(),
2896            "on_message"
2897        );
2898        assert_eq!(
2899            recorder.callback_effects()[1].callback_id.as_str(),
2900            "on_tick"
2901        );
2902        assert_eq!(
2903            recorder.callback_effects()[1].entity_id.as_str(),
2904            "/chatter"
2905        );
2906    }
2907
2908    #[test]
2909    fn synthesized_entity_ids_reject_collisions() {
2910        let mut recorder = MetadataRecorder::<1, 2, 0>::new();
2911        let mut context = NodeContext::new("test", &mut recorder);
2912        let mut node = context.create_node(NodeOptions::new("talker")).unwrap();
2913
2914        node.create_publisher_for_topic::<TestMsg>("/chatter")
2915            .unwrap();
2916        let result = node.create_publisher_for_topic::<TestMsg>("/chatter");
2917
2918        assert!(matches!(
2919            result,
2920            Err(NodeDeclError::Metadata(NodeMetadataError::DuplicateId))
2921        ));
2922    }
2923
2924    /// Verifies the runtime adapter rejects duplicate nodes and unknown effect entities.
2925    #[test]
2926    fn runtime_adapter_rejects_unknown_entities() {
2927        let mut node_runtime = FakeNodeRuntime::default();
2928        let mut runtime = NodeRuntimeAdapter::<_, 1, 1, 1>::new(&mut node_runtime);
2929        runtime
2930            .create_node(NodeId::new("node"), NodeOptions::new("talker"))
2931            .unwrap();
2932
2933        assert_eq!(
2934            runtime.create_node(NodeId::new("node"), NodeOptions::new("other")),
2935            Err(NodeDeclError::Metadata(NodeMetadataError::DuplicateId))
2936        );
2937        assert_eq!(
2938            runtime.record_callback_effect(
2939                CallbackId::new("cb"),
2940                CallbackEffectKind::Reads,
2941                EntityId::new("missing")
2942            ),
2943            Err(NodeDeclError::Metadata(NodeMetadataError::UnknownEntity))
2944        );
2945    }
2946
2947    #[test]
2948    fn component_rejects_effect_for_unknown_entity() {
2949        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
2950        let mut context = NodeContext::new("test", &mut recorder);
2951        let result = context
2952            .callback(CallbackId::new("cb"))
2953            .reads(EntityId::new("missing"));
2954        assert!(matches!(
2955            result,
2956            Err(NodeDeclError::Metadata(NodeMetadataError::UnknownEntity))
2957        ));
2958    }
2959
2960    #[test]
2961    fn component_missing_export_error_message_is_clear() {
2962        assert_eq!(
2963            NodeDeclError::MissingExport.message(),
2964            MISSING_NODE_EXPORT_ERROR
2965        );
2966        assert_eq!(
2967            NodeDeclError::MissingExport.message(),
2968            "package has no exported nros component"
2969        );
2970    }
2971
2972    struct RobotComponent;
2973
2974    impl Node for RobotComponent {
2975        const NAME: &'static str = "robot_component";
2976
2977        fn register(context: &mut NodeContext<'_>) -> NodeResult<()> {
2978            {
2979                let mut sensors = context.create_node_with_id(
2980                    NodeId::new("node_sensors"),
2981                    NodeOptions::new("sensors"),
2982                )?;
2983                let _status =
2984                    sensors.create_publisher::<TestMsg>(EntityId::new("pub_status"), "~/status")?;
2985            }
2986
2987            let mut control = context
2988                .create_node_with_id(NodeId::new("node_control"), NodeOptions::new("control"))?;
2989            let _cmd = control.create_subscription::<TestMsg>(
2990                EntityId::new("sub_cmd"),
2991                CallbackId::new("cb_cmd"),
2992                "~/cmd",
2993            )?;
2994            let _reset = control.create_service_server::<TestService>(
2995                EntityId::new("srv_reset"),
2996                CallbackId::new("cb_reset"),
2997                "reset",
2998            )?;
2999            let _navigate = control.create_action_server_with_callbacks::<TestAction>(
3000                EntityId::new("act_navigate"),
3001                CallbackId::new("cb_nav_goal"),
3002                CallbackId::new("cb_nav_cancel"),
3003                CallbackId::new("cb_nav_accepted"),
3004                "~/navigate",
3005            )?;
3006            let _gain = control.declare_parameter_with_default(
3007                EntityId::new("param_gain"),
3008                "gain",
3009                ParameterDefault::Double(copy_str("1.5")?),
3010            )?;
3011
3012            control
3013                .callback(CallbackId::new("cb_cmd"))
3014                .publishes(EntityId::new("pub_status"))?
3015                .reads(EntityId::new("param_gain"))?;
3016            control
3017                .callback(CallbackId::new("cb_nav_accepted"))
3018                .writes(EntityId::new("param_gain"))?;
3019
3020            Ok(())
3021        }
3022    }
3023
3024    /// Verifies the component API records multi-node services, actions, and defaults.
3025    #[test]
3026    fn component_api_records_multi_node_services() {
3027        let mut recorder = MetadataRecorder::<4, 12, 4>::new();
3028        record_node_metadata::<RobotComponent>(&mut recorder).unwrap();
3029
3030        assert_eq!(recorder.nodes().len(), 2);
3031        assert_eq!(recorder.nodes()[0].id.as_str(), "node_sensors");
3032        assert_eq!(recorder.nodes()[1].id.as_str(), "node_control");
3033
3034        let status = recorder
3035            .entities()
3036            .iter()
3037            .find(|entity| entity.id.as_str() == "pub_status")
3038            .unwrap();
3039        assert_eq!(status.kind, EntityKind::Publisher);
3040        assert_eq!(status.source_name.as_str(), "~/status");
3041        assert_eq!(status.source_name_kind, SourceNameKind::Private);
3042
3043        let reset = recorder
3044            .entities()
3045            .iter()
3046            .find(|entity| entity.id.as_str() == "srv_reset")
3047            .unwrap();
3048        assert_eq!(reset.kind, EntityKind::ServiceServer);
3049        assert_eq!(
3050            reset.callback_id.as_ref().map(|id| id.as_str()),
3051            Some("cb_reset")
3052        );
3053
3054        let navigate = recorder
3055            .entities()
3056            .iter()
3057            .find(|entity| entity.id.as_str() == "act_navigate")
3058            .unwrap();
3059        assert_eq!(navigate.kind, EntityKind::ActionServer);
3060        assert_eq!(
3061            navigate.callback_id.as_ref().map(|id| id.as_str()),
3062            Some("cb_nav_goal")
3063        );
3064        assert_eq!(
3065            navigate
3066                .action_cancel_callback_id
3067                .as_ref()
3068                .map(|id| id.as_str()),
3069            Some("cb_nav_cancel")
3070        );
3071        assert_eq!(
3072            navigate
3073                .action_accepted_callback_id
3074                .as_ref()
3075                .map(|id| id.as_str()),
3076            Some("cb_nav_accepted")
3077        );
3078
3079        let gain = recorder
3080            .entities()
3081            .iter()
3082            .find(|entity| entity.id.as_str() == "param_gain")
3083            .unwrap();
3084        assert_eq!(gain.kind, EntityKind::Parameter);
3085        assert!(matches!(
3086            gain.parameter_default.as_ref(),
3087            Some(ParameterDefault::Double(value)) if value.as_str() == "1.5"
3088        ));
3089
3090        assert_eq!(recorder.callback_effects().len(), 3);
3091        assert!(recorder.callback_effects().iter().any(|effect| {
3092            effect.callback_id.as_str() == "cb_cmd"
3093                && effect.kind == CallbackEffectKind::Publishes
3094                && effect.entity_id.as_str() == "pub_status"
3095        }));
3096        assert!(recorder.callback_effects().iter().any(|effect| {
3097            effect.callback_id.as_str() == "cb_nav_accepted"
3098                && effect.kind == CallbackEffectKind::Writes
3099                && effect.entity_id.as_str() == "param_gain"
3100        }));
3101    }
3102
3103    #[cfg(feature = "std")]
3104    #[test]
3105    fn component_api_json_contains_planner_callback_links() {
3106        let mut recorder = MetadataRecorder::<4, 12, 4>::new();
3107        record_node_metadata::<RobotComponent>(&mut recorder).unwrap();
3108
3109        let json = recorder
3110            .to_source_metadata_json(&crate::SourceMetadataExport::new(
3111                "demo_robot",
3112                RobotComponent::NAME,
3113            ))
3114            .unwrap();
3115
3116        assert!(json.contains("\"callbacks\":["));
3117        assert!(json.contains("\"id\":\"cb_cmd\",\"declaration_slot\":0"));
3118        assert!(json.contains("\"kind\":\"subscription\""));
3119        assert!(json.contains("\"id\":\"cb_reset\",\"declaration_slot\":1"));
3120        assert!(json.contains("\"kind\":\"service\""));
3121        assert!(json.contains("\"id\":\"cb_nav_goal\",\"declaration_slot\":2"));
3122        assert!(json.contains("\"kind\":\"action_goal\""));
3123        assert!(json.contains("\"id\":\"cb_nav_cancel\",\"declaration_slot\":3"));
3124        assert!(json.contains("\"kind\":\"action_cancel\""));
3125        assert!(json.contains("\"id\":\"cb_nav_accepted\",\"declaration_slot\":4"));
3126        assert!(json.contains("\"kind\":\"action_accepted\""));
3127        assert!(json.contains("\"kind\":\"publishes\",\"entity\":\"pub_status\""));
3128        assert!(json.contains("\"kind\":\"reads_parameter\",\"entity\":\"param_gain\""));
3129        assert!(json.contains("\"kind\":\"writes_parameter\",\"entity\":\"param_gain\""));
3130        assert!(json.contains("\"goal_callback\":\"cb_nav_goal\""));
3131        assert!(json.contains("\"cancel_callback\":\"cb_nav_cancel\""));
3132        assert!(json.contains("\"accepted_callback\":\"cb_nav_accepted\""));
3133    }
3134
3135    // W.5.1 — an executable component callback runs its body: mutates state +
3136    // publishes immediately through the resolver (the substrate the generator
3137    // will wire). `TalkerComponent` already impls `Node` (declarative);
3138    // here it also impls `ExecutableNode`.
3139    impl ExecutableNode for TalkerComponent {
3140        type State = u32;
3141
3142        fn init() -> u32 {
3143            0
3144        }
3145
3146        fn on_callback(state: &mut u32, callback: Callback<'_>, ctx: &mut CallbackCtx<'_>) {
3147            if callback.as_str() == "on_tick" {
3148                *state += 1;
3149                // Publish through the declared publisher entity.
3150                let _ = ctx.publish::<TestMsg, 64>(EntityId::new("pub_chatter"), &TestMsg);
3151            }
3152        }
3153    }
3154
3155    #[test]
3156    fn executable_component_callback_publishes_and_mutates_state() {
3157        use core::cell::RefCell;
3158
3159        struct RecordingResolver {
3160            last: RefCell<Option<(MetadataString, usize)>>,
3161        }
3162        impl PublisherResolver for RecordingResolver {
3163            fn publish_raw(&self, entity_id: &str, data: &[u8]) -> NodeResult<()> {
3164                *self.last.borrow_mut() = Some((copy_str(entity_id)?, data.len()));
3165                Ok(())
3166            }
3167        }
3168
3169        let resolver = RecordingResolver {
3170            last: RefCell::new(None),
3171        };
3172        let mut state = TalkerComponent::init();
3173        let mut ctx = CallbackCtx::new(&[], &resolver);
3174
3175        // An unrelated callback id does nothing.
3176        TalkerComponent::on_callback(
3177            &mut state,
3178            Callback::__from_id(CallbackId::new("other")),
3179            &mut ctx,
3180        );
3181        assert_eq!(state, 0);
3182        assert!(resolver.last.borrow().is_none());
3183
3184        // The bound callback bumps state + publishes through "pub_chatter".
3185        TalkerComponent::on_callback(
3186            &mut state,
3187            Callback::__from_id(CallbackId::new("on_tick")),
3188            &mut ctx,
3189        );
3190        assert_eq!(state, 1);
3191        let last = resolver.last.borrow();
3192        let (entity, len) = last.as_ref().expect("a publish was recorded");
3193        assert_eq!(entity.as_str(), "pub_chatter");
3194        // Empty TestMsg ⇒ just the 4-byte CDR header.
3195        assert_eq!(*len, 4);
3196    }
3197
3198    // W.5.3 — a service-style body writes its reply through the CallbackCtx
3199    // reply sink; the trampoline reads `*written` back. A timer/sub ctx (no
3200    // sink) rejects a reply.
3201    #[test]
3202    fn callback_ctx_reply_sink_roundtrips() {
3203        struct NoopResolver;
3204        impl PublisherResolver for NoopResolver {
3205            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
3206                Ok(())
3207            }
3208        }
3209        let resolver = NoopResolver;
3210        let mut reply_buf = [0u8; 64];
3211        let mut written = 0usize;
3212        {
3213            let mut ctx = CallbackCtx::with_reply(&[], &resolver, &mut reply_buf, &mut written);
3214            ctx.reply::<TestMsg, 64>(&TestMsg).unwrap();
3215        }
3216        // Empty TestMsg ⇒ just the 4-byte CDR header.
3217        assert_eq!(written, 4);
3218
3219        // A reply-less ctx (timer / subscription) rejects a reply.
3220        let mut ctx2 = CallbackCtx::new(&[], &resolver);
3221        assert!(ctx2.reply_raw(&[1, 2, 3]).is_err());
3222    }
3223
3224    // Phase 264 W4c — a callback reads the live parameter value through
3225    // `CallbackCtx::parameter::<T>` once the dispatch site threads the store in.
3226    #[cfg(feature = "param-services")]
3227    #[test]
3228    fn callback_ctx_reads_param() {
3229        struct NoopResolver;
3230        impl PublisherResolver for NoopResolver {
3231            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
3232                Ok(())
3233            }
3234        }
3235        let resolver = NoopResolver;
3236
3237        // No store threaded ⇒ every read is None (param-services off / not registered).
3238        let ctx_none = CallbackCtx::new(&[], &resolver);
3239        assert_eq!(ctx_none.parameter::<i64>("speed"), None);
3240
3241        // Seed a store with the typed value a `ros2 param set speed 7` would land on.
3242        // phase-382 W2' — the slots are the caller's; a local `ParameterStorage`
3243        // is the shape a test wants, sized for what it actually declares rather
3244        // than the build-time `MAX_PARAMETERS` default.
3245        let mut storage = crate::ParameterStorage::<4>::new();
3246        let mut server = crate::ParameterServer::new_in(storage.as_table());
3247        assert!(server.declare("speed", crate::ParameterValue::Integer(7)));
3248
3249        let mut ctx = CallbackCtx::new(&[], &resolver);
3250        ctx.set_param_server(Some(&server));
3251        assert_eq!(ctx.parameter::<i64>("speed"), Some(7));
3252        // Wrong type ⇒ None, not a panic.
3253        assert_eq!(ctx.parameter::<bool>("speed"), None);
3254        // Undeclared ⇒ None.
3255        assert_eq!(ctx.parameter::<i64>("missing"), None);
3256    }
3257
3258    // Phase 250 Wave 2 — the declarative `.safety()` surface: a normal ctx has
3259    // no integrity status; one built with `new_with_integrity` exposes it, read
3260    // alongside the message in the same callback (Shape A).
3261    #[cfg(feature = "safety-e2e")]
3262    #[test]
3263    fn callback_ctx_integrity_surface() {
3264        struct NoopResolver;
3265        impl PublisherResolver for NoopResolver {
3266            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
3267                Ok(())
3268            }
3269        }
3270        let resolver = NoopResolver;
3271
3272        // Non-safety dispatch (timer / plain sub) → None.
3273        let ctx = CallbackCtx::new(&[], &resolver);
3274        assert!(ctx.integrity().is_none());
3275
3276        // Safety dispatch → the status rides alongside the payload.
3277        let status = crate::IntegrityStatus {
3278            gap: 2,
3279            duplicate: false,
3280            crc_valid: Some(true),
3281        };
3282        let ctx = CallbackCtx::new_with_integrity(&[], &resolver, &status);
3283        let got = ctx.integrity().expect("safety ctx carries status");
3284        assert_eq!(got.gap, 2);
3285        assert!(!got.duplicate);
3286        assert_eq!(got.crc_valid, Some(true));
3287    }
3288
3289    // W.5.3 — an action goal / cancel body sets its accept/reject decision
3290    // through the CallbackCtx decision sink; the trampoline returns `*out`. A
3291    // wrong-kind setter (or a sink-less ctx) errors.
3292    #[test]
3293    fn callback_ctx_decision_sink() {
3294        struct NoopResolver;
3295        impl PublisherResolver for NoopResolver {
3296            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
3297                Ok(())
3298            }
3299        }
3300        let resolver = NoopResolver;
3301
3302        let mut gr = GoalResponse::Reject;
3303        {
3304            let mut ctx = CallbackCtx::with_goal_decision(&[], &resolver, &mut gr);
3305            ctx.set_goal_response(GoalResponse::AcceptAndExecute)
3306                .unwrap();
3307            // Wrong-kind setter on a goal ctx errors.
3308            assert!(ctx.set_cancel_response(CancelResponse::Accept).is_err());
3309        }
3310        assert!(matches!(gr, GoalResponse::AcceptAndExecute));
3311
3312        let mut cr = CancelResponse::Reject;
3313        {
3314            let mut ctx = CallbackCtx::with_cancel_decision(&[], &resolver, &mut cr);
3315            ctx.set_cancel_response(CancelResponse::Accept).unwrap();
3316        }
3317        assert!(matches!(cr, CancelResponse::Accept));
3318
3319        // A timer/sub ctx (no decision sink) rejects both.
3320        let mut ctx3 = CallbackCtx::new(&[], &resolver);
3321        assert!(ctx3.set_goal_response(GoalResponse::Reject).is_err());
3322        assert!(ctx3.set_cancel_response(CancelResponse::Accept).is_err());
3323    }
3324
3325    // W.5.6 — the tick hook publishes (immediate) + drives executor-backed action
3326    // ops (complete goal / publish feedback) through the ActionExecutor seam.
3327    #[test]
3328    fn tick_ctx_publish_and_action_ops() {
3329        use core::cell::Cell;
3330        struct RecPub {
3331            published: Cell<bool>,
3332        }
3333        impl PublisherResolver for RecPub {
3334            fn publish_raw(&self, _entity_id: &str, _data: &[u8]) -> NodeResult<()> {
3335                self.published.set(true);
3336                Ok(())
3337            }
3338        }
3339        struct RecAct {
3340            completed: bool,
3341            fed: bool,
3342            visited: usize,
3343        }
3344        impl ActionExecutor for RecAct {
3345            fn complete_goal_raw(
3346                &mut self,
3347                _action_entity: &str,
3348                _goal_id: &GoalId,
3349                _status: GoalStatus,
3350                _result: &[u8],
3351            ) -> NodeResult<()> {
3352                self.completed = true;
3353                Ok(())
3354            }
3355            fn publish_feedback_raw(
3356                &mut self,
3357                _action_entity: &str,
3358                _goal_id: &GoalId,
3359                _feedback: &[u8],
3360            ) -> NodeResult<()> {
3361                self.fed = true;
3362                Ok(())
3363            }
3364            fn for_each_active_goal(
3365                &self,
3366                _action_entity: &str,
3367                visit: &mut dyn FnMut(&GoalId, GoalStatus),
3368            ) {
3369                // One pretend-active goal, so the tick body has something to drive.
3370                visit(&GoalId::zero(), GoalStatus::Executing);
3371            }
3372        }
3373
3374        struct RecClients;
3375        impl ClientDispatch for RecClients {
3376            fn call_raw(
3377                &mut self,
3378                _service: &str,
3379                _req: &[u8],
3380                _resp: &mut [u8],
3381            ) -> NodeResult<usize> {
3382                Err(NodeDeclError::Runtime)
3383            }
3384            fn send_goal_raw(&mut self, _action: &str, _goal: &[u8]) -> NodeResult<GoalId> {
3385                Err(NodeDeclError::Runtime)
3386            }
3387        }
3388
3389        let pubs = RecPub {
3390            published: Cell::new(false),
3391        };
3392        let mut acts = RecAct {
3393            completed: false,
3394            fed: false,
3395            visited: 0,
3396        };
3397        let mut clients = RecClients;
3398        let goal = GoalId::zero();
3399        let mut seen = 0usize;
3400        {
3401            let mut ctx = TickCtx::new(&pubs, &mut acts, &mut clients);
3402            ctx.publish::<TestMsg, 64>(EntityId::new("pub_x"), &TestMsg)
3403                .unwrap();
3404            // Discover the active goal the way a real tick body does, then act on it.
3405            ctx.for_each_active_goal(EntityId::new("act"), &mut |_id, _status| seen += 1);
3406            ctx.publish_feedback::<TestMsg, 64>(EntityId::new("act"), &goal, &TestMsg)
3407                .unwrap();
3408            ctx.complete_goal::<TestMsg, 64>(
3409                EntityId::new("act"),
3410                &goal,
3411                GoalStatus::Succeeded,
3412                &TestMsg,
3413            )
3414            .unwrap();
3415        }
3416        acts.visited = seen;
3417        assert!(pubs.published.get());
3418        assert!(acts.completed);
3419        assert!(acts.fed);
3420        assert_eq!(acts.visited, 1);
3421    }
3422
3423    /// Phase 216.A.3 — `Node::DISPATCH` defaults to
3424    /// `DispatchStrategy::Inline` so every pre-216 `impl Node`
3425    /// keeps compiling unchanged.
3426    #[test]
3427    fn node_dispatch_default_is_inline() {
3428        struct Dummy;
3429        impl Node for Dummy {
3430            const NAME: &'static str = "dummy";
3431            fn register(_: &mut NodeContext<'_>) -> NodeResult<()> {
3432                Ok(())
3433            }
3434        }
3435        assert_eq!(Dummy::DISPATCH, crate::DispatchStrategy::Inline);
3436    }
3437
3438    // Phase 216.A.5 — `nros::node!()` emits the
3439    // `__nros_node_<pkg>_dispatch_strategy()` ABI export. We invoke the
3440    // macro on a dummy Node + ExecutableNode pair in a private sub-module
3441    // here so the macro expansion lives inside the `nros` crate itself;
3442    // the emitted `#[unsafe(no_mangle)] extern "C"` symbol is global, so
3443    // the test below re-declares + calls it. If the macro stopped
3444    // emitting the symbol (or renamed it) this would link-fail.
3445    //
3446    // `<pkg>` resolves to `CARGO_PKG_NAME` after
3447    // `sanitize_pkg_name_for_symbol`. The `nros` crate's pkg name is
3448    // literal `nros`, so the expected symbol is
3449    // `__nros_node_nros_dispatch_strategy`.
3450    // Phase 216 final wave — the macro emit now references
3451    // `::nros::Executor` (rmw-cffi-gated) in addition to the existing
3452    // alloc-gated `__private_node_state_into_raw`. Gate the test on
3453    // both features so the macro invocation only attempts to expand
3454    // when every referenced symbol is present.
3455    #[cfg(all(feature = "alloc", feature = "rmw-cffi", feature = "macros"))]
3456    mod dispatch_probe_macro_test {
3457        // `extern crate self as nros;` at the crate root (in `lib.rs`,
3458        // `cfg(test)`-gated) lets the `::nros::*` paths the macro emits
3459        // resolve in-crate.
3460        use super::*;
3461
3462        pub struct DispatchProbe;
3463
3464        impl Node for DispatchProbe {
3465            const NAME: &'static str = "dispatch_probe";
3466            // Default `DISPATCH = Inline` ⇒ discriminant 0.
3467            fn register(_: &mut NodeContext<'_>) -> NodeResult<()> {
3468                Ok(())
3469            }
3470        }
3471
3472        impl ExecutableNode for DispatchProbe {
3473            type State = ();
3474            fn init() -> Self::State {}
3475            fn on_callback(
3476                _state: &mut Self::State,
3477                _callback: Callback<'_>,
3478                _ctx: &mut CallbackCtx<'_>,
3479            ) {
3480            }
3481        }
3482
3483        // Emits both the per-pkg `register` wrapper AND the new
3484        // `__nros_node_nros_dispatch_strategy` ABI symbol.
3485        nros_macros::node!(DispatchProbe);
3486    }
3487
3488    // Also `macros`: this asserts the ABI symbol the `node!` invocation above
3489    // emits, so without the macro there is nothing to assert and the extern
3490    // would not resolve.
3491    #[cfg(all(feature = "alloc", feature = "rmw-cffi", feature = "macros"))]
3492    #[test]
3493    fn node_macro_emits_dispatch_strategy_symbol() {
3494        // Re-declare the ABI export the macro just emitted. If the macro
3495        // elides the symbol (or renames it) this fails to link — exactly
3496        // the regression the test is meant to catch.
3497        unsafe extern "C" {
3498            fn __nros_node_nros_dispatch_strategy() -> u8;
3499        }
3500        let strategy = unsafe { __nros_node_nros_dispatch_strategy() };
3501        // The probe Node uses the default `DISPATCH = Inline`
3502        // (discriminant 0) — confirms the macro is splicing
3503        // `<Type as Node>::DISPATCH as u8`, not a hard-coded zero.
3504        assert_eq!(strategy, crate::DispatchStrategy::Inline as u8);
3505        assert_eq!(strategy, 0);
3506    }
3507
3508    // The `nros::node!()` macro also emits
3509    // `__nros_node_<pkg>_on_callback`, the extern "C" trampoline the
3510    // RTIC / Embassy dispatch tasks call after dequeuing a
3511    // `SignaledCallback<'static>` (see `nros-platform::SignaledCallback`).
3512    // The expansion lives in the same `dispatch_probe_macro_test`
3513    // sub-module as the dispatch-strategy probe, so a single
3514    // `nros_macros::node!(DispatchProbe);` invocation covers both
3515    // symbols. Symbol name resolves to
3516    // `__nros_node_nros_on_callback` (CARGO_PKG_NAME = "nros").
3517    //
3518    // The test only confirms the symbol is linkable — actually
3519    // invoking the trampoline would need a live State + CallbackCtx
3520    // pointer pair, which is the dispatch-task author's contract
3521    // (documented in the macro emit). A link-only probe is enough to
3522    // catch the macro silently eliding the export — the exact
3523    // regression class this test is for.
3524    #[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
3525    #[test]
3526    fn node_macro_emits_on_callback_symbol() {
3527        unsafe extern "C" {
3528            fn __nros_node_nros_on_callback(
3529                state: *mut core::ffi::c_void,
3530                cb_id_ptr: *const u8,
3531                cb_id_len: usize,
3532                ctx: *mut core::ffi::c_void,
3533            );
3534        }
3535        // Take the address of the symbol and feed it through
3536        // `core::hint::black_box` — forces the linker to resolve the
3537        // symbol and prevents the optimiser from folding the unused
3538        // reference away. If the macro stopped emitting the export
3539        // this line fails at link time, which is the exact regression
3540        // class this test catches. (`fn`-pointer values are never
3541        // null per Rust's type system, so a direct null check would
3542        // be a tautology — `-D useless-ptr-null-checks` would reject
3543        // it.)
3544        let fn_ptr: unsafe extern "C" fn(
3545            *mut core::ffi::c_void,
3546            *const u8,
3547            usize,
3548            *mut core::ffi::c_void,
3549        ) = __nros_node_nros_on_callback;
3550        core::hint::black_box(fn_ptr);
3551    }
3552
3553    #[test]
3554    fn create_subscription_static_returns_tag_matching_topic() {
3555        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
3556        let mut context = NodeContext::new("test", &mut recorder);
3557        let mut node = context.create_node(NodeOptions::new("listener")).unwrap();
3558        let tag = node
3559            .create_subscription_static::<TestMsg>("/chatter")
3560            .unwrap();
3561
3562        assert_eq!(tag.as_str(), "/chatter");
3563        assert!(tag == CallbackId::new("/chatter"));
3564        assert_eq!(recorder.entities().len(), 1);
3565        let entity = &recorder.entities()[0];
3566        assert_eq!(entity.kind, EntityKind::Subscription);
3567        assert_eq!(entity.source_name.as_str(), "/chatter");
3568        assert_eq!(
3569            entity.callback_id.as_ref().map(|id| id.as_str()),
3570            Some("/chatter")
3571        );
3572    }
3573
3574    #[test]
3575    fn create_service_static_returns_tag() {
3576        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
3577        let mut context = NodeContext::new("test", &mut recorder);
3578        let mut node = context.create_node(NodeOptions::new("server")).unwrap();
3579        let tag = node
3580            .create_service_static::<TestService>("/add_two_ints")
3581            .unwrap();
3582
3583        assert_eq!(tag.as_str(), "/add_two_ints");
3584        assert!(tag == CallbackId::new("/add_two_ints"));
3585        assert_eq!(recorder.entities().len(), 1);
3586        let entity = &recorder.entities()[0];
3587        assert_eq!(entity.kind, EntityKind::ServiceServer);
3588        assert_eq!(entity.source_name.as_str(), "/add_two_ints");
3589        assert_eq!(
3590            entity.callback_id.as_ref().map(|id| id.as_str()),
3591            Some("/add_two_ints")
3592        );
3593    }
3594
3595    #[test]
3596    fn create_service_helpers_use_name_as_entity_and_callback_id() {
3597        let mut recorder = MetadataRecorder::<1, 2, 1>::new();
3598        let mut context = NodeContext::new("test", &mut recorder);
3599        let mut node = context.create_node(NodeOptions::new("services")).unwrap();
3600        let server = node
3601            .create_service_server_for_name::<TestService>("/add_two_ints")
3602            .unwrap();
3603        let client = node
3604            .create_service_client_for_name::<TestService>("/reset")
3605            .unwrap();
3606
3607        assert_eq!(server.id(), EntityId::new("/add_two_ints"));
3608        assert_eq!(client.id(), EntityId::new("/reset"));
3609        assert_eq!(recorder.entities().len(), 2);
3610
3611        let server = &recorder.entities()[0];
3612        assert_eq!(server.kind, EntityKind::ServiceServer);
3613        assert_eq!(server.id.as_str(), "/add_two_ints");
3614        assert_eq!(server.source_name.as_str(), "/add_two_ints");
3615        assert_eq!(
3616            server.callback_id.as_ref().map(|id| id.as_str()),
3617            Some("/add_two_ints")
3618        );
3619
3620        let client = &recorder.entities()[1];
3621        assert_eq!(client.kind, EntityKind::ServiceClient);
3622        assert_eq!(client.id.as_str(), "/reset");
3623        assert_eq!(client.source_name.as_str(), "/reset");
3624        assert!(client.callback_id.is_none());
3625    }
3626
3627    #[test]
3628    fn create_action_static_returns_tag() {
3629        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
3630        let mut context = NodeContext::new("test", &mut recorder);
3631        let mut node = context.create_node(NodeOptions::new("server")).unwrap();
3632        let tag = node
3633            .create_action_static::<TestAction>("/fibonacci")
3634            .unwrap();
3635
3636        assert_eq!(tag.as_str(), "/fibonacci");
3637        assert!(tag == CallbackId::new("/fibonacci"));
3638        assert_eq!(recorder.entities().len(), 1);
3639        let entity = &recorder.entities()[0];
3640        assert_eq!(entity.kind, EntityKind::ActionServer);
3641        assert_eq!(entity.source_name.as_str(), "/fibonacci");
3642        assert_eq!(
3643            entity.callback_id.as_ref().map(|id| id.as_str()),
3644            Some("/fibonacci")
3645        );
3646        assert_eq!(
3647            entity
3648                .action_cancel_callback_id
3649                .as_ref()
3650                .map(|id| id.as_str()),
3651            Some("/fibonacci")
3652        );
3653        assert_eq!(
3654            entity
3655                .action_accepted_callback_id
3656                .as_ref()
3657                .map(|id| id.as_str()),
3658            Some("/fibonacci")
3659        );
3660    }
3661
3662    #[test]
3663    fn create_action_helpers_use_name_as_entity_and_default_callback_id() {
3664        let mut recorder = MetadataRecorder::<1, 2, 3>::new();
3665        let mut context = NodeContext::new("test", &mut recorder);
3666        let mut node = context.create_node(NodeOptions::new("actions")).unwrap();
3667        let server = node
3668            .create_action_server_for_name::<TestAction>("/fibonacci")
3669            .unwrap();
3670        let client = node
3671            .create_action_client_for_name::<TestAction>("/navigate")
3672            .unwrap();
3673
3674        assert_eq!(server.id(), EntityId::new("/fibonacci"));
3675        assert_eq!(client.id(), EntityId::new("/navigate"));
3676        assert_eq!(recorder.entities().len(), 2);
3677
3678        let server = &recorder.entities()[0];
3679        assert_eq!(server.kind, EntityKind::ActionServer);
3680        assert_eq!(server.id.as_str(), "/fibonacci");
3681        assert_eq!(server.source_name.as_str(), "/fibonacci");
3682        assert_eq!(
3683            server.callback_id.as_ref().map(|id| id.as_str()),
3684            Some("/fibonacci")
3685        );
3686        assert_eq!(
3687            server
3688                .action_cancel_callback_id
3689                .as_ref()
3690                .map(|id| id.as_str()),
3691            Some("/fibonacci")
3692        );
3693        assert_eq!(
3694            server
3695                .action_accepted_callback_id
3696                .as_ref()
3697                .map(|id| id.as_str()),
3698            Some("/fibonacci")
3699        );
3700
3701        let client = &recorder.entities()[1];
3702        assert_eq!(client.kind, EntityKind::ActionClient);
3703        assert_eq!(client.id.as_str(), "/navigate");
3704        assert_eq!(client.source_name.as_str(), "/navigate");
3705        assert!(client.callback_id.is_none());
3706    }
3707
3708    // Phase 268 W1 — unit test: launch node_identity injection overrides NodeOptions
3709    // default (RFC-0046). Uses a `CapturingRuntime` that applies the same
3710    // `match self.node_identity` logic as `ExecutorSink::create_node` and records the
3711    // resolved (name, namespace). No executor needed — tests the design contract.
3712    // Uses `MetadataString` (heapless::String re-export) to stay no_std-compatible.
3713    #[test]
3714    fn node_identity_injected_wins_over_node_options() {
3715        /// Minimal NodeRuntime that applies the Phase 268 W1 identity override
3716        /// (same logic as `ExecutorSink::create_node`) and records the resolved values.
3717        struct CapturingRuntime {
3718            node_identity: Option<(&'static str, &'static str)>,
3719            resolved_name: MetadataString,
3720            resolved_ns: MetadataString,
3721        }
3722        impl NodeRuntime for CapturingRuntime {
3723            fn create_node(&mut self, _id: NodeId<'_>, options: NodeOptions<'_>) -> NodeResult<()> {
3724                // Mirror ExecutorSink::create_node override logic (RFC-0046).
3725                let (name, ns) = match self.node_identity {
3726                    Some((n, s)) => (n, s),
3727                    None => (options.name, options.namespace),
3728                };
3729                self.resolved_name.clear();
3730                let _ = self.resolved_name.push_str(name);
3731                self.resolved_ns.clear();
3732                let _ = self.resolved_ns.push_str(ns);
3733                Ok(())
3734            }
3735            fn create_entity(&mut self, _m: EntityMetadata) -> NodeResult<()> {
3736                Ok(())
3737            }
3738            fn record_callback_effect(
3739                &mut self,
3740                _id: CallbackId<'_>,
3741                _kind: crate::node_metadata::CallbackEffectKind,
3742                _entity: EntityId<'_>,
3743            ) -> NodeResult<()> {
3744                Ok(())
3745            }
3746        }
3747
3748        // (a) Injected identity wins over NodeOptions default.
3749        let mut rt_a = CapturingRuntime {
3750            node_identity: Some(("launched", "/ns")),
3751            resolved_name: MetadataString::new(),
3752            resolved_ns: MetadataString::new(),
3753        };
3754        {
3755            let mut ctx = NodeContext::new("test_node", &mut rt_a);
3756            ctx.create_node(NodeOptions::new("default").namespace("/d"))
3757                .unwrap();
3758        }
3759        assert_eq!(rt_a.resolved_name.as_str(), "launched");
3760        assert_eq!(rt_a.resolved_ns.as_str(), "/ns");
3761
3762        // (b) None → NodeOptions default stands (backward-compatible).
3763        let mut rt_b = CapturingRuntime {
3764            node_identity: None,
3765            resolved_name: MetadataString::new(),
3766            resolved_ns: MetadataString::new(),
3767        };
3768        {
3769            let mut ctx = NodeContext::new("test_node", &mut rt_b);
3770            ctx.create_node(NodeOptions::new("default").namespace("/d"))
3771                .unwrap();
3772        }
3773        assert_eq!(rt_b.resolved_name.as_str(), "default");
3774        assert_eq!(rt_b.resolved_ns.as_str(), "/d");
3775    }
3776
3777    // Phase 305 W3 (issue 0255) — unit test: entity source names are resolved
3778    // through the launch remap seam against the node identity `create_node`
3779    // stored. Applies the same `resolve_name` call shape as
3780    // `ExecutorSink::create_entity` (Timer/Parameter exempt) and records the
3781    // resolved wire name. No executor needed — tests the design contract.
3782    #[test]
3783    fn entity_names_resolved_through_launch_remaps() {
3784        struct CapturingRuntime {
3785            node_identity: (&'static str, &'static str),
3786            remaps: &'static [(&'static str, &'static str)],
3787            resolved: MetadataString,
3788        }
3789        impl NodeRuntime for CapturingRuntime {
3790            fn create_node(&mut self, _id: NodeId<'_>, _o: NodeOptions<'_>) -> NodeResult<()> {
3791                Ok(())
3792            }
3793            fn create_entity(&mut self, m: EntityMetadata) -> NodeResult<()> {
3794                // Mirror ExecutorSink::create_entity kind gating + resolution.
3795                let name = match m.kind {
3796                    EntityKind::Timer | EntityKind::Parameter => m.source_name.clone(),
3797                    _ => crate::node_metadata::resolve_name(
3798                        m.source_name.as_str(),
3799                        self.node_identity.0,
3800                        self.node_identity.1,
3801                        self.remaps.iter().copied(),
3802                    )
3803                    .map_err(|_| NodeDeclError::Runtime)?,
3804                };
3805                self.resolved.clear();
3806                let _ = self.resolved.push_str(name.as_str());
3807                Ok(())
3808            }
3809            fn record_callback_effect(
3810                &mut self,
3811                _id: CallbackId<'_>,
3812                _kind: crate::node_metadata::CallbackEffectKind,
3813                _entity: EntityId<'_>,
3814            ) -> NodeResult<()> {
3815                Ok(())
3816            }
3817        }
3818
3819        let mut rt = CapturingRuntime {
3820            node_identity: ("filter", "/sensing"),
3821            remaps: &[("~/input/points", "/points_raw")],
3822            resolved: MetadataString::new(),
3823        };
3824        {
3825            let mut ctx = NodeContext::new("test_node", &mut rt);
3826            let mut node = ctx
3827                .create_node(NodeOptions::new("filter").namespace("/sensing"))
3828                .unwrap();
3829            // Remapped private name → the rule's target.
3830            node.create_subscription_for_callback_name::<TestMsg>("cb", "~/input/points")
3831                .unwrap();
3832        }
3833        assert_eq!(rt.resolved.as_str(), "/points_raw");
3834
3835        // Un-remapped relative name → plain expansion.
3836        {
3837            let mut ctx = NodeContext::new("test_node", &mut rt);
3838            let mut node = ctx
3839                .create_node(NodeOptions::new("filter").namespace("/sensing"))
3840                .unwrap();
3841            node.create_publisher_for_topic::<TestMsg>("status")
3842                .unwrap();
3843        }
3844        assert_eq!(rt.resolved.as_str(), "/sensing/status");
3845
3846        // Parameter names bypass the remap seam.
3847        {
3848            let mut ctx = NodeContext::new("test_node", &mut rt);
3849            let mut node = ctx
3850                .create_node(NodeOptions::new("filter").namespace("/sensing"))
3851                .unwrap();
3852            node.declare_parameter_for_name("~/input/points", crate::ParameterType::Bool)
3853                .unwrap();
3854        }
3855        assert_eq!(rt.resolved.as_str(), "~/input/points");
3856    }
3857}