Skip to main content

nros/
node_metadata.rs

1//! Node source metadata recorded without opening middleware.
2
3use crate::{
4    ParameterType, QosSettings,
5    heapless::{String, Vec},
6};
7
8#[cfg(feature = "std")]
9use crate::{QosDurabilityPolicy, QosHistoryPolicy, QosLivelinessPolicy, QosReliabilityPolicy};
10#[cfg(feature = "std")]
11use std::{format, string::String as StdString, vec::Vec as StdVec};
12
13/// Maximum nodes recorded by the built-in metadata recorder.
14pub const DEFAULT_MAX_METADATA_NODES: usize = 8;
15/// Maximum entities recorded by the built-in metadata recorder.
16pub const DEFAULT_MAX_METADATA_ENTITIES: usize = 32;
17/// Maximum callback/effect records kept by the built-in metadata recorder.
18pub const DEFAULT_MAX_METADATA_CALLBACKS: usize = 32;
19/// Maximum bytes in recorded source names and stable IDs.
20pub const METADATA_STRING_CAPACITY: usize = 128;
21
22/// Fixed-capacity string used by component metadata records.
23pub type MetadataString = String<METADATA_STRING_CAPACITY>;
24
25/// Declaration-order node slot within one extracted component.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27pub struct NodeSlot(pub usize);
28
29impl NodeSlot {
30    /// Create a node slot from its declaration-order index.
31    pub const fn new(index: usize) -> Self {
32        Self(index)
33    }
34
35    /// Declaration-order index.
36    pub const fn index(self) -> usize {
37        self.0
38    }
39}
40
41/// Declaration-order entity slot within one extracted component.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43pub struct EntitySlot(pub usize);
44
45impl EntitySlot {
46    /// Create an entity slot from its declaration-order index.
47    pub const fn new(index: usize) -> Self {
48        Self(index)
49    }
50
51    /// Declaration-order index.
52    pub const fn index(self) -> usize {
53        self.0
54    }
55}
56
57/// Declaration-order callback slot within one extracted component.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub struct CallbackSlot(pub usize);
60
61impl CallbackSlot {
62    /// Create a callback slot from its declaration-order index.
63    pub const fn new(index: usize) -> Self {
64        Self(index)
65    }
66
67    /// Declaration-order index.
68    pub const fn index(self) -> usize {
69        self.0
70    }
71}
72
73/// Source location attached to callbacks and parameters.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct SourceLocationMetadata {
76    pub artifact: MetadataString,
77    pub line: Option<u32>,
78    pub column: Option<u32>,
79}
80
81impl SourceLocationMetadata {
82    /// Empty source location used when caller data is unavailable.
83    pub const fn empty() -> Self {
84        Self {
85            artifact: MetadataString::new(),
86            line: None,
87            column: None,
88        }
89    }
90
91    /// Capture the Rust caller location.
92    #[track_caller]
93    pub fn caller() -> Result<Self, NodeMetadataError> {
94        let location = core::panic::Location::caller();
95        Ok(Self {
96            artifact: copy_str(location.file())?,
97            line: Some(location.line()),
98            column: Some(location.column()),
99        })
100    }
101}
102
103/// Parameter default value recorded for source metadata.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum ParameterDefault {
106    Bool(bool),
107    Integer(i64),
108    Double(MetadataString),
109    String(MetadataString),
110    BoolArray,
111    IntegerArray,
112    DoubleArray,
113    StringArray,
114}
115
116impl ParameterDefault {
117    /// Parameter type implied by this default.
118    pub const fn parameter_type(&self) -> ParameterType {
119        match self {
120            Self::Bool(_) => ParameterType::Bool,
121            Self::Integer(_) => ParameterType::Integer,
122            Self::Double(_) => ParameterType::Double,
123            Self::String(_) => ParameterType::String,
124            Self::BoolArray => ParameterType::BoolArray,
125            Self::IntegerArray => ParameterType::IntegerArray,
126            Self::DoubleArray => ParameterType::DoubleArray,
127            Self::StringArray => ParameterType::StringArray,
128        }
129    }
130
131    /// Default JSON-compatible value for a parameter type.
132    pub fn for_type(param_type: ParameterType) -> Result<Self, NodeMetadataError> {
133        Ok(match param_type {
134            ParameterType::Bool => Self::Bool(false),
135            ParameterType::Integer => Self::Integer(0),
136            ParameterType::Double => Self::Double(copy_str("0.0")?),
137            ParameterType::String => Self::String(copy_str("")?),
138            ParameterType::BoolArray => Self::BoolArray,
139            ParameterType::IntegerArray => Self::IntegerArray,
140            ParameterType::DoubleArray => Self::DoubleArray,
141            ParameterType::StringArray => Self::StringArray,
142            ParameterType::ByteArray | ParameterType::NotSet => Self::Integer(0),
143        })
144    }
145}
146
147/// Unresolved ROS name category as written by component source.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum SourceNameKind {
150    /// Starts with `/`.
151    Absolute,
152    /// Starts with `~`.
153    Private,
154    /// Any other non-empty source name.
155    Relative,
156}
157
158impl SourceNameKind {
159    /// Classify a source name without resolving launch remaps or namespaces.
160    pub const fn from_source_name(name: &str) -> Self {
161        let bytes = name.as_bytes();
162        if bytes.is_empty() {
163            Self::Relative
164        } else if bytes[0] == b'/' {
165            Self::Absolute
166        } else if bytes[0] == b'~' {
167            Self::Private
168        } else {
169            Self::Relative
170        }
171    }
172}
173
174/// Stable source-level identifier required for component-mode declarations.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
176pub struct EntityId<'a>(pub &'a str);
177
178impl<'a> EntityId<'a> {
179    /// Create a stable entity identifier.
180    pub const fn new(id: &'a str) -> Self {
181        Self(id)
182    }
183
184    /// Borrow the identifier string.
185    pub const fn as_str(self) -> &'a str {
186        self.0
187    }
188}
189
190/// Stable node identifier required for component-mode node declarations.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
192pub struct NodeId<'a>(pub &'a str);
193
194impl<'a> NodeId<'a> {
195    /// Create a stable node identifier.
196    pub const fn new(id: &'a str) -> Self {
197        Self(id)
198    }
199
200    /// Borrow the identifier string.
201    pub const fn as_str(self) -> &'a str {
202        self.0
203    }
204}
205
206/// Stable callback identifier required for component-mode callbacks.
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
208pub struct CallbackId<'a>(pub &'a str);
209
210impl<'a> CallbackId<'a> {
211    /// Create a stable callback identifier.
212    pub const fn new(id: &'a str) -> Self {
213        Self(id)
214    }
215
216    /// Borrow the identifier string.
217    pub const fn as_str(self) -> &'a str {
218        self.0
219    }
220}
221
222/// Entity role recorded for source metadata.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub enum EntityKind {
225    Publisher,
226    Subscription,
227    Timer,
228    ServiceServer,
229    ServiceClient,
230    ActionServer,
231    ActionClient,
232    Parameter,
233}
234
235/// Optional callback effect relation.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub enum CallbackEffectKind {
238    Reads,
239    Publishes,
240    Writes,
241}
242
243/// Metadata recorder/runtime error.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub enum NodeMetadataError {
246    /// Fixed recorder capacity exhausted.
247    Capacity,
248    /// Stable ID, node name, namespace, topic, service, action, or parameter name was too long.
249    NameTooLong,
250    /// Entity references a node ID that has not been declared.
251    UnknownNode,
252    /// Callback effect references an entity ID that has not been declared.
253    UnknownEntity,
254    /// Stable ID already exists in the same component metadata.
255    DuplicateId,
256}
257
258/// Recorded node declaration.
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct NodeMetadata {
261    pub slot: NodeSlot,
262    pub id: MetadataString,
263    pub source_default_name: MetadataString,
264    pub name: MetadataString,
265    pub namespace: MetadataString,
266    pub domain_id: u32,
267}
268
269/// Recorded entity declaration.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub struct EntityMetadata {
272    pub slot: Option<EntitySlot>,
273    pub id: MetadataString,
274    pub node_slot: Option<NodeSlot>,
275    pub node_id: MetadataString,
276    pub kind: EntityKind,
277    pub source_name: MetadataString,
278    pub source_name_kind: SourceNameKind,
279    pub type_name: &'static str,
280    pub type_hash: &'static str,
281    pub qos: QosSettings,
282    pub callback_slot: Option<CallbackSlot>,
283    pub callback_id: Option<MetadataString>,
284    pub callback_source: SourceLocationMetadata,
285    pub callback_group: Option<MetadataString>,
286    pub action_cancel_callback_slot: Option<CallbackSlot>,
287    pub action_cancel_callback_id: Option<MetadataString>,
288    pub action_cancel_source: SourceLocationMetadata,
289    pub action_accepted_callback_slot: Option<CallbackSlot>,
290    pub action_accepted_callback_id: Option<MetadataString>,
291    pub action_accepted_source: SourceLocationMetadata,
292    pub period_ms: Option<u64>,
293    pub parameter_type: Option<ParameterType>,
294    pub parameter_default: Option<ParameterDefault>,
295    pub parameter_read_only: bool,
296    /// Phase 250 (Wave 2b) — a subscription that opted into E2E message-integrity
297    /// validation (`.safety()`): the runtime registers it via
298    /// `create_generic_subscription_with_integrity` and surfaces
299    /// [`IntegrityStatus`](crate::IntegrityStatus) through `CallbackCtx::integrity()`.
300    /// Ungated (a plain flag) — only the runtime branch that reads it is gated on
301    /// `safety-e2e`, so when the capability is off the flag is simply ignored
302    /// (the subscription registers as a basic one). `false` for every other entity.
303    pub safety: bool,
304    pub source: SourceLocationMetadata,
305}
306
307/// Recorded optional callback effect.
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct CallbackEffectMetadata {
310    pub callback_id: MetadataString,
311    pub callback_slot: Option<CallbackSlot>,
312    pub kind: CallbackEffectKind,
313    pub entity_id: MetadataString,
314    pub entity_slot: Option<EntitySlot>,
315}
316
317/// Source metadata document settings used by the std JSON emitter.
318#[cfg(feature = "std")]
319#[derive(Debug, Clone)]
320pub struct SourceMetadataExport<'a> {
321    pub package: &'a str,
322    pub component: &'a str,
323    pub executable: Option<&'a str>,
324    pub exported_symbol: Option<&'a str>,
325    pub package_manifest: &'a str,
326    pub source_artifacts: &'a [&'a str],
327}
328
329#[cfg(feature = "std")]
330impl<'a> SourceMetadataExport<'a> {
331    /// Create export settings with ROS package and component names.
332    pub const fn new(package: &'a str, component: &'a str) -> Self {
333        Self {
334            package,
335            component,
336            executable: None,
337            exported_symbol: None,
338            package_manifest: "package.xml",
339            source_artifacts: &[],
340        }
341    }
342
343    /// Set executable name.
344    pub const fn executable(mut self, executable: &'a str) -> Self {
345        self.executable = Some(executable);
346        self
347    }
348
349    /// Set exported symbol name.
350    pub const fn exported_symbol(mut self, exported_symbol: &'a str) -> Self {
351        self.exported_symbol = Some(exported_symbol);
352        self
353    }
354
355    /// Set package manifest path.
356    pub const fn package_manifest(mut self, package_manifest: &'a str) -> Self {
357        self.package_manifest = package_manifest;
358        self
359    }
360
361    /// Set source artifact paths.
362    pub const fn source_artifacts(mut self, source_artifacts: &'a [&'a str]) -> Self {
363        self.source_artifacts = source_artifacts;
364        self
365    }
366}
367
368pub(crate) fn copy_str(value: &str) -> Result<MetadataString, NodeMetadataError> {
369    let mut out = MetadataString::new();
370    out.push_str(value)
371        .map_err(|_| NodeMetadataError::NameTooLong)?;
372    Ok(out)
373}
374
375/// In-memory metadata sink used by host discovery. It never opens transport.
376#[derive(Debug)]
377pub struct MetadataRecorder<
378    const MAX_NODES: usize = DEFAULT_MAX_METADATA_NODES,
379    const MAX_ENTITIES: usize = DEFAULT_MAX_METADATA_ENTITIES,
380    const MAX_CALLBACKS: usize = DEFAULT_MAX_METADATA_CALLBACKS,
381> {
382    nodes: Vec<NodeMetadata, MAX_NODES>,
383    entities: Vec<EntityMetadata, MAX_ENTITIES>,
384    callback_effects: Vec<CallbackEffectMetadata, MAX_CALLBACKS>,
385}
386
387impl<const MAX_NODES: usize, const MAX_ENTITIES: usize, const MAX_CALLBACKS: usize> Default
388    for MetadataRecorder<MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
389{
390    fn default() -> Self {
391        Self::new()
392    }
393}
394
395impl<const MAX_NODES: usize, const MAX_ENTITIES: usize, const MAX_CALLBACKS: usize>
396    MetadataRecorder<MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
397{
398    /// Create an empty metadata recorder.
399    pub const fn new() -> Self {
400        Self {
401            nodes: Vec::new(),
402            entities: Vec::new(),
403            callback_effects: Vec::new(),
404        }
405    }
406
407    /// Recorded nodes in declaration order.
408    pub fn nodes(&self) -> &[NodeMetadata] {
409        &self.nodes
410    }
411
412    /// Recorded entities in declaration order.
413    pub fn entities(&self) -> &[EntityMetadata] {
414        &self.entities
415    }
416
417    /// Recorded optional callback effects in declaration order.
418    pub fn callback_effects(&self) -> &[CallbackEffectMetadata] {
419        &self.callback_effects
420    }
421
422    /// Emit schema-version-1 source metadata JSON without opening transport.
423    #[cfg(feature = "std")]
424    pub fn to_source_metadata_json(
425        &self,
426        export: &SourceMetadataExport<'_>,
427    ) -> Result<StdString, core::fmt::Error> {
428        let mut out = StdString::new();
429        self.write_source_metadata_json(export, &mut out)?;
430        Ok(out)
431    }
432
433    /// Write schema-version-1 source metadata JSON without opening transport.
434    #[cfg(feature = "std")]
435    pub fn write_source_metadata_json(
436        &self,
437        export: &SourceMetadataExport<'_>,
438        out: &mut impl core::fmt::Write,
439    ) -> core::fmt::Result {
440        write!(out, "{{")?;
441        write!(out, "\"version\":1,")?;
442        write_json_field(out, "package", export.package)?;
443        out.write_char(',')?;
444        write_json_field(out, "component", export.component)?;
445        out.write_char(',')?;
446        write!(out, "\"language\":\"rust\",")?;
447        write_json_opt_field(out, "executable", export.executable)?;
448        out.write_char(',')?;
449        write_json_opt_field(out, "exported_symbol", export.exported_symbol)?;
450        out.write_char(',')?;
451        self.write_nodes_json(out)?;
452        out.write_char(',')?;
453        self.write_callbacks_json(out)?;
454        out.write_char(',')?;
455        self.write_parameters_json(out)?;
456        out.write_char(',')?;
457        self.write_trace_json(export, out)?;
458        write!(out, "}}")
459    }
460
461    pub(crate) fn push_node(
462        &mut self,
463        id: NodeId<'_>,
464        name: &str,
465        namespace: &str,
466        domain_id: u32,
467    ) -> Result<(), NodeMetadataError> {
468        if self.has_node(id.as_str()) {
469            return Err(NodeMetadataError::DuplicateId);
470        }
471
472        self.nodes
473            .push(NodeMetadata {
474                slot: NodeSlot::new(self.nodes.len()),
475                id: copy_str(id.as_str())?,
476                source_default_name: copy_str(name)?,
477                name: copy_str(name)?,
478                namespace: copy_str(namespace)?,
479                domain_id,
480            })
481            .map_err(|_| NodeMetadataError::Capacity)
482    }
483
484    pub(crate) fn push_entity(
485        &mut self,
486        mut entity: EntityMetadata,
487    ) -> Result<(), NodeMetadataError> {
488        if !self.has_node(&entity.node_id) {
489            return Err(NodeMetadataError::UnknownNode);
490        }
491        if self.has_entity(&entity.id) {
492            return Err(NodeMetadataError::DuplicateId);
493        }
494
495        entity.slot = Some(EntitySlot::new(self.entities.len()));
496        entity.node_slot = self.node_slot_for_id(&entity.node_id);
497        let mut current_callbacks = Vec::<MetadataString, 3>::new();
498        let mut next_callback_slot = self.callback_slot_count();
499        entity.callback_slot = entity.callback_id.as_ref().map(|callback_id| {
500            self.callback_slot_for_current_entity(
501                callback_id.as_str(),
502                &mut current_callbacks,
503                &mut next_callback_slot,
504            )
505        });
506        entity.action_cancel_callback_slot =
507            entity
508                .action_cancel_callback_id
509                .as_ref()
510                .map(|callback_id| {
511                    self.callback_slot_for_current_entity(
512                        callback_id.as_str(),
513                        &mut current_callbacks,
514                        &mut next_callback_slot,
515                    )
516                });
517        entity.action_accepted_callback_slot =
518            entity
519                .action_accepted_callback_id
520                .as_ref()
521                .map(|callback_id| {
522                    self.callback_slot_for_current_entity(
523                        callback_id.as_str(),
524                        &mut current_callbacks,
525                        &mut next_callback_slot,
526                    )
527                });
528
529        self.entities
530            .push(entity)
531            .map_err(|_| NodeMetadataError::Capacity)
532    }
533
534    pub(crate) fn push_callback_effect(
535        &mut self,
536        callback_id: CallbackId<'_>,
537        kind: CallbackEffectKind,
538        entity_id: EntityId<'_>,
539    ) -> Result<(), NodeMetadataError> {
540        if !self.has_entity(entity_id.as_str()) {
541            return Err(NodeMetadataError::UnknownEntity);
542        }
543
544        self.callback_effects
545            .push(CallbackEffectMetadata {
546                callback_id: copy_str(callback_id.as_str())?,
547                callback_slot: self.callback_slot_for_id(callback_id.as_str()),
548                kind,
549                entity_id: copy_str(entity_id.as_str())?,
550                entity_slot: self.entity_slot_for_id(entity_id.as_str()),
551            })
552            .map_err(|_| NodeMetadataError::Capacity)
553    }
554
555    pub(crate) fn has_node(&self, id: &str) -> bool {
556        self.nodes.iter().any(|node| node.id.as_str() == id)
557    }
558
559    pub(crate) fn has_entity(&self, id: &str) -> bool {
560        self.entities.iter().any(|entity| entity.id.as_str() == id)
561    }
562
563    fn node_slot_for_id(&self, id: &str) -> Option<NodeSlot> {
564        self.nodes
565            .iter()
566            .find(|node| node.id.as_str() == id)
567            .map(|node| node.slot)
568    }
569
570    fn entity_slot_for_id(&self, id: &str) -> Option<EntitySlot> {
571        self.entities
572            .iter()
573            .find(|entity| entity.id.as_str() == id)
574            .and_then(|entity| entity.slot)
575    }
576
577    fn callback_slot_for_current_entity(
578        &self,
579        id: &str,
580        current_callbacks: &mut Vec<MetadataString, 3>,
581        next_callback_slot: &mut usize,
582    ) -> CallbackSlot {
583        if let Some(slot) = self.callback_slot_for_id(id) {
584            return slot;
585        }
586        if let Some((index, _)) = current_callbacks
587            .iter()
588            .enumerate()
589            .find(|(_, callback_id)| callback_id.as_str() == id)
590        {
591            return CallbackSlot::new(self.callback_slot_count() + index);
592        }
593        let slot = CallbackSlot::new(*next_callback_slot);
594        let _ = current_callbacks
595            .push(copy_str(id).expect("callback ID already fits metadata string capacity"));
596        *next_callback_slot += 1;
597        slot
598    }
599
600    fn callback_slot_for_id(&self, id: &str) -> Option<CallbackSlot> {
601        let mut seen = Vec::<&str, MAX_CALLBACKS>::new();
602        for entity in &self.entities {
603            for callback_id in entity_callback_ids(entity) {
604                let Some(callback_id) = callback_id else {
605                    continue;
606                };
607                let callback_id = callback_id.as_str();
608                if seen.contains(&callback_id) {
609                    continue;
610                }
611                if callback_id == id {
612                    return Some(CallbackSlot::new(seen.len()));
613                }
614                let _ = seen.push(callback_id);
615            }
616        }
617        None
618    }
619
620    fn callback_slot_count(&self) -> usize {
621        let mut seen = Vec::<&str, MAX_CALLBACKS>::new();
622        for entity in &self.entities {
623            for callback_id in entity_callback_ids(entity) {
624                let Some(callback_id) = callback_id else {
625                    continue;
626                };
627                let callback_id = callback_id.as_str();
628                if !seen.contains(&callback_id) {
629                    let _ = seen.push(callback_id);
630                }
631            }
632        }
633        seen.len()
634    }
635
636    #[cfg(feature = "std")]
637    fn write_nodes_json(&self, out: &mut impl core::fmt::Write) -> core::fmt::Result {
638        write!(out, "\"nodes\":[")?;
639        for (index, node) in self.nodes.iter().enumerate() {
640            if index > 0 {
641                out.write_char(',')?;
642            }
643            write!(out, "{{")?;
644            write_json_field(out, "id", node.id.as_str())?;
645            out.write_char(',')?;
646            write!(out, "\"declaration_slot\":{},", node.slot.index())?;
647            write_json_field(
648                out,
649                "source_default_name",
650                node.source_default_name.as_str(),
651            )?;
652            out.write_char(',')?;
653            write!(out, "\"unresolved_name\":")?;
654            write_source_name(
655                out,
656                node.name.as_str(),
657                SourceNameKind::from_source_name(&node.name),
658            )?;
659            out.write_char(',')?;
660            if node.namespace.as_str() == "/" {
661                write!(out, "\"namespace\":null,")?;
662            } else {
663                write_json_field(out, "namespace", node.namespace.as_str())?;
664                out.write_char(',')?;
665            }
666            self.write_node_entities(out, node.id.as_str())?;
667            write!(out, "}}")?;
668        }
669        write!(out, "]")
670    }
671
672    #[cfg(feature = "std")]
673    fn write_node_entities(
674        &self,
675        out: &mut impl core::fmt::Write,
676        node_id: &str,
677    ) -> core::fmt::Result {
678        self.write_entity_array(out, "publishers", node_id, EntityKind::Publisher)?;
679        out.write_char(',')?;
680        self.write_entity_array(out, "subscribers", node_id, EntityKind::Subscription)?;
681        out.write_char(',')?;
682        self.write_entity_array(out, "timers", node_id, EntityKind::Timer)?;
683        out.write_char(',')?;
684        self.write_entity_array(out, "services", node_id, EntityKind::ServiceServer)?;
685        out.write_char(',')?;
686        self.write_entity_array(out, "actions", node_id, EntityKind::ActionServer)
687    }
688
689    #[cfg(feature = "std")]
690    fn write_entity_array(
691        &self,
692        out: &mut impl core::fmt::Write,
693        field: &str,
694        node_id: &str,
695        kind: EntityKind,
696    ) -> core::fmt::Result {
697        write!(out, "\"{}\":[", field)?;
698        for (index, entity) in self
699            .entities
700            .iter()
701            .filter(|entity| entity.node_id.as_str() == node_id && entity.kind == kind)
702            .enumerate()
703        {
704            if index > 0 {
705                out.write_char(',')?;
706            }
707            match kind {
708                EntityKind::Publisher => write_publisher_json(out, entity)?,
709                EntityKind::Subscription => write_subscriber_json(out, entity)?,
710                EntityKind::Timer => write_timer_json(out, entity)?,
711                EntityKind::ServiceServer => write_service_json(out, entity)?,
712                EntityKind::ActionServer => write_action_json(out, entity)?,
713                _ => {}
714            }
715        }
716        write!(out, "]")
717    }
718
719    #[cfg(feature = "std")]
720    fn write_callbacks_json(&self, out: &mut impl core::fmt::Write) -> core::fmt::Result {
721        let callbacks = self.source_callbacks();
722        write!(out, "\"callbacks\":[")?;
723        for (index, callback) in callbacks.iter().enumerate() {
724            if index > 0 {
725                out.write_char(',')?;
726            }
727            write!(out, "{{")?;
728            write_json_field(out, "id", callback.id.as_str())?;
729            out.write_char(',')?;
730            if let Some(slot) = callback.slot {
731                write!(out, "\"declaration_slot\":{},", slot.index())?;
732            }
733            write_json_field(out, "kind", callback.kind)?;
734            out.write_char(',')?;
735            if let Some(group) = callback.group.as_ref() {
736                write_json_field(out, "group", group)?;
737                out.write_char(',')?;
738            } else {
739                write!(out, "\"group\":null,")?;
740            }
741            write!(out, "\"effects\":[")?;
742            for (effect_index, effect) in self
743                .callback_effects
744                .iter()
745                .filter(|effect| effect.callback_id.as_str() == callback.id)
746                .enumerate()
747            {
748                if effect_index > 0 {
749                    out.write_char(',')?;
750                }
751                write!(out, "{{")?;
752                write_json_field(out, "kind", effect_json_kind(effect.kind))?;
753                out.write_char(',')?;
754                write_json_field(out, "entity", effect.entity_id.as_str())?;
755                if let Some(entity_slot) = effect.entity_slot {
756                    write!(out, ",\"entity_slot\":{}", entity_slot.index())?;
757                }
758                write!(out, "}}")?;
759            }
760            write!(out, "],")?;
761            write_source_location(out, &callback.source)?;
762            write!(out, "}}")?;
763        }
764        write!(out, "]")
765    }
766
767    #[cfg(feature = "std")]
768    fn write_parameters_json(&self, out: &mut impl core::fmt::Write) -> core::fmt::Result {
769        write!(out, "\"parameters\":[")?;
770        for (index, entity) in self
771            .entities
772            .iter()
773            .filter(|entity| entity.kind == EntityKind::Parameter)
774            .enumerate()
775        {
776            if index > 0 {
777                out.write_char(',')?;
778            }
779            write!(out, "{{")?;
780            write_json_field(out, "node", entity.node_id.as_str())?;
781            out.write_char(',')?;
782            if let Some(slot) = entity.slot {
783                write!(out, "\"declaration_slot\":{},", slot.index())?;
784            }
785            write_json_field(out, "name", entity.source_name.as_str())?;
786            out.write_char(',')?;
787            write!(out, "\"default\":")?;
788            write_parameter_default(out, entity.parameter_default.as_ref())?;
789            out.write_char(',')?;
790            write!(out, "\"read_only\":{},", entity.parameter_read_only)?;
791            write_source_location(out, &entity.source)?;
792            write!(out, "}}")?;
793        }
794        write!(out, "]")
795    }
796
797    #[cfg(feature = "std")]
798    fn write_trace_json(
799        &self,
800        export: &SourceMetadataExport<'_>,
801        out: &mut impl core::fmt::Write,
802    ) -> core::fmt::Result {
803        write!(out, "\"trace\":{{")?;
804        write_json_field(out, "generator", "nros-metadata-rust")?;
805        out.write_char(',')?;
806        write_json_field(out, "package_manifest", export.package_manifest)?;
807        out.write_char(',')?;
808        write!(out, "\"source_artifacts\":[")?;
809        for (index, artifact) in export.source_artifacts.iter().enumerate() {
810            if index > 0 {
811                out.write_char(',')?;
812            }
813            write_json_string(out, artifact)?;
814        }
815        write!(out, "]}}")
816    }
817
818    #[cfg(feature = "std")]
819    fn source_callbacks(&self) -> StdVec<SourceCallbackRef> {
820        let mut callbacks = StdVec::new();
821        for entity in &self.entities {
822            let Some(callback_id) = entity.callback_id.as_ref() else {
823                continue;
824            };
825            let kind = match entity.kind {
826                EntityKind::Subscription => "subscription",
827                EntityKind::Timer => "timer",
828                EntityKind::ServiceServer => "service",
829                EntityKind::ActionServer => "action_goal",
830                _ => continue,
831            };
832            if !callbacks
833                .iter()
834                .any(|callback: &SourceCallbackRef| callback.id == callback_id.as_str())
835            {
836                callbacks.push(SourceCallbackRef {
837                    id: callback_id.as_str().into(),
838                    slot: entity.callback_slot,
839                    kind,
840                    source: entity.callback_source.clone(),
841                    group: entity
842                        .callback_group
843                        .as_ref()
844                        .map(|group| group.as_str().into()),
845                });
846            }
847            if entity.kind == EntityKind::ActionServer {
848                if let Some(cancel_id) = entity.action_cancel_callback_id.as_ref()
849                    && !callbacks
850                        .iter()
851                        .any(|callback: &SourceCallbackRef| callback.id == cancel_id.as_str())
852                {
853                    callbacks.push(SourceCallbackRef {
854                        id: cancel_id.as_str().into(),
855                        slot: entity.action_cancel_callback_slot,
856                        kind: "action_cancel",
857                        source: entity.action_cancel_source.clone(),
858                        group: entity
859                            .callback_group
860                            .as_ref()
861                            .map(|group| group.as_str().into()),
862                    });
863                }
864                if let Some(accepted_id) = entity.action_accepted_callback_id.as_ref()
865                    && !callbacks
866                        .iter()
867                        .any(|callback: &SourceCallbackRef| callback.id == accepted_id.as_str())
868                {
869                    callbacks.push(SourceCallbackRef {
870                        id: accepted_id.as_str().into(),
871                        slot: entity.action_accepted_callback_slot,
872                        kind: "action_accepted",
873                        source: entity.action_accepted_source.clone(),
874                        group: entity
875                            .callback_group
876                            .as_ref()
877                            .map(|group| group.as_str().into()),
878                    });
879                }
880            }
881        }
882        callbacks
883    }
884}
885
886#[cfg(feature = "std")]
887struct SourceCallbackRef {
888    id: StdString,
889    slot: Option<CallbackSlot>,
890    kind: &'static str,
891    source: SourceLocationMetadata,
892    group: Option<StdString>,
893}
894
895pub(crate) fn entity_callback_ids(entity: &EntityMetadata) -> [Option<&MetadataString>; 3] {
896    [
897        entity.callback_id.as_ref(),
898        entity.action_cancel_callback_id.as_ref(),
899        entity.action_accepted_callback_id.as_ref(),
900    ]
901}
902
903/// Inputs for [`entity_metadata`]. Collapses the seven positional
904/// arguments — three of them adjacent `&str` (`source_name` /
905/// `type_name` / `type_hash`) that are trivially transposable at a
906/// call site — into one named-field struct.
907pub(crate) struct EntityMetadataSpec<'a> {
908    pub id: EntityId<'a>,
909    pub node_id: NodeId<'a>,
910    pub kind: EntityKind,
911    pub source_name: &'a str,
912    pub type_name: &'static str,
913    pub type_hash: &'static str,
914    pub qos: QosSettings,
915}
916
917pub(crate) fn entity_metadata(
918    spec: EntityMetadataSpec<'_>,
919) -> Result<EntityMetadata, NodeMetadataError> {
920    let EntityMetadataSpec {
921        id,
922        node_id,
923        kind,
924        source_name,
925        type_name,
926        type_hash,
927        qos,
928    } = spec;
929    Ok(EntityMetadata {
930        slot: None,
931        id: copy_str(id.as_str())?,
932        node_slot: None,
933        node_id: copy_str(node_id.as_str())?,
934        kind,
935        source_name: copy_str(source_name)?,
936        source_name_kind: SourceNameKind::from_source_name(source_name),
937        type_name,
938        type_hash,
939        qos,
940        callback_slot: None,
941        callback_id: None,
942        callback_source: SourceLocationMetadata::empty(),
943        callback_group: None,
944        action_cancel_callback_slot: None,
945        action_cancel_callback_id: None,
946        action_cancel_source: SourceLocationMetadata::empty(),
947        action_accepted_callback_slot: None,
948        action_accepted_callback_id: None,
949        action_accepted_source: SourceLocationMetadata::empty(),
950        period_ms: None,
951        parameter_type: None,
952        parameter_default: None,
953        parameter_read_only: false,
954        safety: false,
955        source: SourceLocationMetadata::empty(),
956    })
957}
958
959#[cfg(feature = "std")]
960fn write_publisher_json(
961    out: &mut impl core::fmt::Write,
962    entity: &EntityMetadata,
963) -> core::fmt::Result {
964    write!(out, "{{")?;
965    write_json_field(out, "id", entity.id.as_str())?;
966    out.write_char(',')?;
967    if let Some(slot) = entity.slot {
968        write!(out, "\"declaration_slot\":{},", slot.index())?;
969    }
970    write!(out, "\"unresolved_topic\":")?;
971    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
972    out.write_char(',')?;
973    write_interface(out, entity.type_name, "message")?;
974    out.write_char(',')?;
975    write_qos(out, entity.qos)?;
976    write!(out, "}}")
977}
978
979#[cfg(feature = "std")]
980fn write_subscriber_json(
981    out: &mut impl core::fmt::Write,
982    entity: &EntityMetadata,
983) -> core::fmt::Result {
984    write!(out, "{{")?;
985    write_json_field(out, "id", entity.id.as_str())?;
986    out.write_char(',')?;
987    if let Some(slot) = entity.slot {
988        write!(out, "\"declaration_slot\":{},", slot.index())?;
989    }
990    write!(out, "\"unresolved_topic\":")?;
991    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
992    out.write_char(',')?;
993    write_interface(out, entity.type_name, "message")?;
994    out.write_char(',')?;
995    write_qos(out, entity.qos)?;
996    out.write_char(',')?;
997    write_json_field(
998        out,
999        "callback",
1000        entity
1001            .callback_id
1002            .as_ref()
1003            .map(|id| id.as_str())
1004            .unwrap_or(""),
1005    )?;
1006    if let Some(callback_slot) = entity.callback_slot {
1007        write!(out, ",\"callback_slot\":{}", callback_slot.index())?;
1008    }
1009    write!(out, "}}")
1010}
1011
1012#[cfg(feature = "std")]
1013fn write_timer_json(out: &mut impl core::fmt::Write, entity: &EntityMetadata) -> core::fmt::Result {
1014    write!(out, "{{")?;
1015    write_json_field(out, "id", entity.id.as_str())?;
1016    out.write_char(',')?;
1017    if let Some(slot) = entity.slot {
1018        write!(out, "\"declaration_slot\":{},", slot.index())?;
1019    }
1020    write!(out, "\"period_ms\":{},", entity.period_ms.unwrap_or(0))?;
1021    write_json_field(
1022        out,
1023        "callback",
1024        entity
1025            .callback_id
1026            .as_ref()
1027            .map(|id| id.as_str())
1028            .unwrap_or(""),
1029    )?;
1030    if let Some(callback_slot) = entity.callback_slot {
1031        write!(out, ",\"callback_slot\":{}", callback_slot.index())?;
1032    }
1033    write!(out, "}}")
1034}
1035
1036#[cfg(feature = "std")]
1037fn write_service_json(
1038    out: &mut impl core::fmt::Write,
1039    entity: &EntityMetadata,
1040) -> core::fmt::Result {
1041    write!(out, "{{")?;
1042    write_json_field(out, "id", entity.id.as_str())?;
1043    out.write_char(',')?;
1044    if let Some(slot) = entity.slot {
1045        write!(out, "\"declaration_slot\":{},", slot.index())?;
1046    }
1047    write!(out, "\"unresolved_name\":")?;
1048    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
1049    out.write_char(',')?;
1050    write_interface(out, entity.type_name, "service")?;
1051    out.write_char(',')?;
1052    write_json_field(
1053        out,
1054        "callback",
1055        entity
1056            .callback_id
1057            .as_ref()
1058            .map(|id| id.as_str())
1059            .unwrap_or(""),
1060    )?;
1061    if let Some(callback_slot) = entity.callback_slot {
1062        write!(out, ",\"callback_slot\":{}", callback_slot.index())?;
1063    }
1064    write!(out, "}}")
1065}
1066
1067#[cfg(feature = "std")]
1068fn write_action_json(
1069    out: &mut impl core::fmt::Write,
1070    entity: &EntityMetadata,
1071) -> core::fmt::Result {
1072    let goal_callback = entity
1073        .callback_id
1074        .as_ref()
1075        .map(|id| id.as_str())
1076        .unwrap_or("");
1077    let cancel_callback = entity
1078        .action_cancel_callback_id
1079        .as_ref()
1080        .map(|id| id.as_str())
1081        .unwrap_or(goal_callback);
1082    let accepted_callback = entity
1083        .action_accepted_callback_id
1084        .as_ref()
1085        .map(|id| id.as_str())
1086        .unwrap_or(goal_callback);
1087    write!(out, "{{")?;
1088    write_json_field(out, "id", entity.id.as_str())?;
1089    out.write_char(',')?;
1090    if let Some(slot) = entity.slot {
1091        write!(out, "\"declaration_slot\":{},", slot.index())?;
1092    }
1093    write!(out, "\"unresolved_name\":")?;
1094    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
1095    out.write_char(',')?;
1096    write_interface(out, entity.type_name, "action")?;
1097    out.write_char(',')?;
1098    write_json_field(out, "goal_callback", goal_callback)?;
1099    if let Some(callback_slot) = entity.callback_slot {
1100        write!(out, ",\"goal_callback_slot\":{}", callback_slot.index())?;
1101    }
1102    out.write_char(',')?;
1103    write_json_field(out, "cancel_callback", cancel_callback)?;
1104    if let Some(callback_slot) = entity.action_cancel_callback_slot {
1105        write!(out, ",\"cancel_callback_slot\":{}", callback_slot.index())?;
1106    }
1107    out.write_char(',')?;
1108    write_json_field(out, "accepted_callback", accepted_callback)?;
1109    if let Some(callback_slot) = entity.action_accepted_callback_slot {
1110        write!(out, ",\"accepted_callback_slot\":{}", callback_slot.index())?;
1111    }
1112    write!(out, "}}")
1113}
1114
1115#[cfg(feature = "std")]
1116fn write_source_name(
1117    out: &mut impl core::fmt::Write,
1118    value: &str,
1119    kind: SourceNameKind,
1120) -> core::fmt::Result {
1121    write!(out, "{{")?;
1122    write_json_field(out, "value", value)?;
1123    out.write_char(',')?;
1124    write_json_field(out, "kind", source_name_kind_json(kind))?;
1125    write!(out, "}}")
1126}
1127
1128#[cfg(feature = "std")]
1129fn write_interface(
1130    out: &mut impl core::fmt::Write,
1131    type_name: &str,
1132    fallback_kind: &'static str,
1133) -> core::fmt::Result {
1134    let interface = parse_interface(type_name, fallback_kind);
1135    write!(out, "\"interface\":{{")?;
1136    write_json_field(out, "package", &interface.package)?;
1137    out.write_char(',')?;
1138    write_json_field(out, "name", &interface.name)?;
1139    out.write_char(',')?;
1140    write_json_field(out, "kind", interface.kind)?;
1141    write!(out, "}}")
1142}
1143
1144#[cfg(feature = "std")]
1145fn write_qos(out: &mut impl core::fmt::Write, qos: QosSettings) -> core::fmt::Result {
1146    write!(out, "\"qos\":{{")?;
1147    write_json_field(out, "reliability", reliability_json(qos.reliability))?;
1148    out.write_char(',')?;
1149    write_json_field(out, "durability", durability_json(qos.durability))?;
1150    out.write_char(',')?;
1151    write_json_field(out, "history", history_json(qos.history))?;
1152    out.write_char(',')?;
1153    write!(out, "\"depth\":{},", qos.depth)?;
1154    write_optional_ms(out, "deadline_ms", qos.deadline_ms)?;
1155    out.write_char(',')?;
1156    write_optional_ms(out, "lifespan_ms", qos.lifespan_ms)?;
1157    out.write_char(',')?;
1158    write_json_field(out, "liveliness", liveliness_json(qos.liveliness_kind))?;
1159    out.write_char(',')?;
1160    write_optional_ms(out, "liveliness_lease_duration_ms", qos.liveliness_lease_ms)?;
1161    write!(out, ",\"extensions\":{{}}}}")
1162}
1163
1164#[cfg(feature = "std")]
1165fn write_source_location(
1166    out: &mut impl core::fmt::Write,
1167    source: &SourceLocationMetadata,
1168) -> core::fmt::Result {
1169    write!(out, "\"source\":{{")?;
1170    write_json_field(out, "artifact", source.artifact.as_str())?;
1171    out.write_char(',')?;
1172    write!(out, "\"line\":")?;
1173    write_optional_u32(out, source.line)?;
1174    out.write_char(',')?;
1175    write!(out, "\"column\":")?;
1176    write_optional_u32(out, source.column)?;
1177    write!(out, "}}")
1178}
1179
1180#[cfg(feature = "std")]
1181fn write_parameter_default(
1182    out: &mut impl core::fmt::Write,
1183    default: Option<&ParameterDefault>,
1184) -> core::fmt::Result {
1185    match default {
1186        Some(ParameterDefault::Bool(value)) => write!(out, "{}", value),
1187        Some(ParameterDefault::Integer(value)) => write!(out, "{}", value),
1188        Some(ParameterDefault::Double(value)) => write!(out, "{}", value.as_str()),
1189        Some(ParameterDefault::String(value)) => write_json_string(out, value.as_str()),
1190        Some(ParameterDefault::BoolArray)
1191        | Some(ParameterDefault::IntegerArray)
1192        | Some(ParameterDefault::DoubleArray)
1193        | Some(ParameterDefault::StringArray)
1194        | None => write!(out, "[]"),
1195    }
1196}
1197
1198#[cfg(feature = "std")]
1199fn write_json_field(out: &mut impl core::fmt::Write, name: &str, value: &str) -> core::fmt::Result {
1200    write_json_string(out, name)?;
1201    out.write_char(':')?;
1202    write_json_string(out, value)
1203}
1204
1205#[cfg(feature = "std")]
1206fn write_json_opt_field(
1207    out: &mut impl core::fmt::Write,
1208    name: &str,
1209    value: Option<&str>,
1210) -> core::fmt::Result {
1211    write_json_string(out, name)?;
1212    out.write_char(':')?;
1213    if let Some(value) = value {
1214        write_json_string(out, value)
1215    } else {
1216        write!(out, "null")
1217    }
1218}
1219
1220#[cfg(feature = "std")]
1221fn write_json_string(out: &mut impl core::fmt::Write, value: &str) -> core::fmt::Result {
1222    out.write_char('"')?;
1223    for ch in value.chars() {
1224        match ch {
1225            '"' => write!(out, "\\\"")?,
1226            '\\' => write!(out, "\\\\")?,
1227            '\n' => write!(out, "\\n")?,
1228            '\r' => write!(out, "\\r")?,
1229            '\t' => write!(out, "\\t")?,
1230            ch if ch.is_control() => write!(out, "\\u{:04x}", ch as u32)?,
1231            ch => out.write_char(ch)?,
1232        }
1233    }
1234    out.write_char('"')
1235}
1236
1237#[cfg(feature = "std")]
1238fn write_optional_ms(out: &mut impl core::fmt::Write, name: &str, value: u32) -> core::fmt::Result {
1239    write_json_string(out, name)?;
1240    out.write_char(':')?;
1241    if value == 0 {
1242        write!(out, "null")
1243    } else {
1244        write!(out, "{}", value)
1245    }
1246}
1247
1248#[cfg(feature = "std")]
1249fn write_optional_u32(out: &mut impl core::fmt::Write, value: Option<u32>) -> core::fmt::Result {
1250    if let Some(value) = value {
1251        write!(out, "{}", value)
1252    } else {
1253        write!(out, "null")
1254    }
1255}
1256
1257#[cfg(feature = "std")]
1258fn source_name_kind_json(kind: SourceNameKind) -> &'static str {
1259    match kind {
1260        SourceNameKind::Absolute => "absolute",
1261        SourceNameKind::Relative => "relative",
1262        SourceNameKind::Private => "private",
1263    }
1264}
1265
1266#[cfg(feature = "std")]
1267fn effect_json_kind(kind: CallbackEffectKind) -> &'static str {
1268    match kind {
1269        CallbackEffectKind::Publishes => "publishes",
1270        CallbackEffectKind::Reads => "reads_parameter",
1271        CallbackEffectKind::Writes => "writes_parameter",
1272    }
1273}
1274
1275#[cfg(feature = "std")]
1276fn reliability_json(value: QosReliabilityPolicy) -> &'static str {
1277    match value {
1278        QosReliabilityPolicy::Reliable => "reliable",
1279        QosReliabilityPolicy::BestEffort => "best_effort",
1280    }
1281}
1282
1283#[cfg(feature = "std")]
1284fn durability_json(value: QosDurabilityPolicy) -> &'static str {
1285    match value {
1286        QosDurabilityPolicy::Volatile => "volatile",
1287        QosDurabilityPolicy::TransientLocal => "transient_local",
1288    }
1289}
1290
1291#[cfg(feature = "std")]
1292fn history_json(value: QosHistoryPolicy) -> &'static str {
1293    match value {
1294        QosHistoryPolicy::KeepLast => "keep_last",
1295        QosHistoryPolicy::KeepAll => "keep_all",
1296    }
1297}
1298
1299#[cfg(feature = "std")]
1300fn liveliness_json(value: QosLivelinessPolicy) -> &'static str {
1301    match value {
1302        QosLivelinessPolicy::None => "system_default",
1303        QosLivelinessPolicy::Automatic => "automatic",
1304        QosLivelinessPolicy::ManualByTopic => "manual_by_topic",
1305        QosLivelinessPolicy::ManualByNode => "manual_by_topic",
1306    }
1307}
1308
1309#[cfg(feature = "std")]
1310struct ParsedInterface {
1311    package: StdString,
1312    name: StdString,
1313    kind: &'static str,
1314}
1315
1316#[cfg(feature = "std")]
1317fn parse_interface(type_name: &str, fallback_kind: &'static str) -> ParsedInterface {
1318    let parts: StdVec<&str> = type_name.split("::").collect();
1319    if parts.len() >= 4 {
1320        let package = parts[0].into();
1321        let kind = match parts[1] {
1322            "msg" => "message",
1323            "srv" => "service",
1324            "action" => "action",
1325            _ => fallback_kind,
1326        };
1327        let mut type_leaf = parts[3].trim_end_matches('_');
1328        if type_leaf.is_empty() {
1329            type_leaf = parts.last().copied().unwrap_or("");
1330        }
1331        return ParsedInterface {
1332            package,
1333            name: format!("{}/{}", parts[1], type_leaf),
1334            kind,
1335        };
1336    }
1337
1338    ParsedInterface {
1339        package: StdString::new(),
1340        name: type_name.into(),
1341        kind: fallback_kind,
1342    }
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347    use super::*;
1348    use crate::qos;
1349
1350    #[test]
1351    fn source_name_kind_preserves_unresolved_names() {
1352        assert_eq!(
1353            SourceNameKind::from_source_name("/scan"),
1354            SourceNameKind::Absolute
1355        );
1356        assert_eq!(
1357            SourceNameKind::from_source_name("~/scan"),
1358            SourceNameKind::Private
1359        );
1360        assert_eq!(
1361            SourceNameKind::from_source_name("scan"),
1362            SourceNameKind::Relative
1363        );
1364    }
1365
1366    #[test]
1367    fn recorder_rejects_duplicate_stable_ids() {
1368        let mut recorder = MetadataRecorder::<1, 2, 1>::new();
1369        recorder
1370            .push_node(NodeId::new("node"), "talker", "/", 0)
1371            .unwrap();
1372
1373        let first = entity_metadata(EntityMetadataSpec {
1374            id: EntityId::new("pub"),
1375            node_id: NodeId::new("node"),
1376            kind: EntityKind::Publisher,
1377            source_name: "chatter",
1378            type_name: "std_msgs::msg::dds_::String_",
1379            type_hash: "hash",
1380            qos: qos::DEFAULT,
1381        })
1382        .unwrap();
1383        recorder.push_entity(first.clone()).unwrap();
1384
1385        assert_eq!(
1386            recorder.push_entity(first),
1387            Err(NodeMetadataError::DuplicateId)
1388        );
1389    }
1390
1391    #[test]
1392    fn recorder_rejects_duplicate_nodes_and_unknown_node_entities() {
1393        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
1394        recorder
1395            .push_node(NodeId::new("node"), "talker", "/", 0)
1396            .unwrap();
1397
1398        assert_eq!(
1399            recorder.push_node(NodeId::new("node"), "other", "/", 0),
1400            Err(NodeMetadataError::DuplicateId)
1401        );
1402
1403        let entity = entity_metadata(EntityMetadataSpec {
1404            id: EntityId::new("pub"),
1405            node_id: NodeId::new("missing_node"),
1406            kind: EntityKind::Publisher,
1407            source_name: "chatter",
1408            type_name: "std_msgs::msg::dds_::String_",
1409            type_hash: "hash",
1410            qos: qos::DEFAULT,
1411        })
1412        .unwrap();
1413
1414        assert_eq!(
1415            recorder.push_entity(entity),
1416            Err(NodeMetadataError::UnknownNode)
1417        );
1418    }
1419
1420    #[test]
1421    fn recorder_assigns_slots_and_source_default_names_by_declaration_order() {
1422        let mut recorder = MetadataRecorder::<2, 4, 3>::new();
1423        recorder
1424            .push_node(NodeId::new("node_alpha"), "talker", "/", 0)
1425            .unwrap();
1426        recorder
1427            .push_node(NodeId::new("node_beta"), "listener", "/demo", 42)
1428            .unwrap();
1429
1430        assert_eq!(recorder.nodes()[0].slot, NodeSlot::new(0));
1431        assert_eq!(recorder.nodes()[0].source_default_name.as_str(), "talker");
1432        assert_eq!(recorder.nodes()[1].slot, NodeSlot::new(1));
1433        assert_eq!(recorder.nodes()[1].source_default_name.as_str(), "listener");
1434
1435        recorder
1436            .push_entity(
1437                entity_metadata(EntityMetadataSpec {
1438                    id: EntityId::new("pub_chatter"),
1439                    node_id: NodeId::new("node_alpha"),
1440                    kind: EntityKind::Publisher,
1441                    source_name: "/chatter",
1442                    type_name: "std_msgs::msg::dds_::String_",
1443                    type_hash: "hash",
1444                    qos: qos::DEFAULT,
1445                })
1446                .unwrap(),
1447            )
1448            .unwrap();
1449        let mut subscription = entity_metadata(EntityMetadataSpec {
1450            id: EntityId::new("sub_chatter"),
1451            node_id: NodeId::new("node_beta"),
1452            kind: EntityKind::Subscription,
1453            source_name: "/chatter",
1454            type_name: "std_msgs::msg::dds_::String_",
1455            type_hash: "hash",
1456            qos: qos::DEFAULT,
1457        })
1458        .unwrap();
1459        subscription.callback_id = Some(copy_str("on_message").unwrap());
1460        recorder.push_entity(subscription).unwrap();
1461        let mut timer = entity_metadata(EntityMetadataSpec {
1462            id: EntityId::new("timer_tick"),
1463            node_id: NodeId::new("node_alpha"),
1464            kind: EntityKind::Timer,
1465            source_name: "",
1466            type_name: "",
1467            type_hash: "",
1468            qos: qos::DEFAULT,
1469        })
1470        .unwrap();
1471        timer.callback_id = Some(copy_str("on_tick").unwrap());
1472        recorder.push_entity(timer).unwrap();
1473
1474        assert_eq!(recorder.entities()[0].slot, Some(EntitySlot::new(0)));
1475        assert_eq!(recorder.entities()[0].node_slot, Some(NodeSlot::new(0)));
1476        assert_eq!(recorder.entities()[0].callback_slot, None);
1477        assert_eq!(recorder.entities()[1].slot, Some(EntitySlot::new(1)));
1478        assert_eq!(recorder.entities()[1].node_slot, Some(NodeSlot::new(1)));
1479        assert_eq!(
1480            recorder.entities()[1].callback_slot,
1481            Some(CallbackSlot::new(0))
1482        );
1483        assert_eq!(
1484            recorder.entities()[2].callback_slot,
1485            Some(CallbackSlot::new(1))
1486        );
1487
1488        recorder
1489            .push_callback_effect(
1490                CallbackId::new("on_tick"),
1491                CallbackEffectKind::Publishes,
1492                EntityId::new("pub_chatter"),
1493            )
1494            .unwrap();
1495        assert_eq!(
1496            recorder.callback_effects()[0].callback_slot,
1497            Some(CallbackSlot::new(1))
1498        );
1499        assert_eq!(
1500            recorder.callback_effects()[0].entity_slot,
1501            Some(EntitySlot::new(0))
1502        );
1503    }
1504
1505    #[test]
1506    fn recorder_assigns_distinct_callback_slots_within_one_action_entity() {
1507        let mut recorder = MetadataRecorder::<1, 1, 3>::new();
1508        recorder
1509            .push_node(NodeId::new("node"), "action_node", "/", 0)
1510            .unwrap();
1511        let mut action = entity_metadata(EntityMetadataSpec {
1512            id: EntityId::new("act_count"),
1513            node_id: NodeId::new("node"),
1514            kind: EntityKind::ActionServer,
1515            source_name: "/count",
1516            type_name: "example_interfaces::action::dds_::Fibonacci_",
1517            type_hash: "hash",
1518            qos: qos::DEFAULT,
1519        })
1520        .unwrap();
1521        action.callback_id = Some(copy_str("on_goal").unwrap());
1522        action.action_cancel_callback_id = Some(copy_str("on_cancel").unwrap());
1523        action.action_accepted_callback_id = Some(copy_str("on_accepted").unwrap());
1524
1525        recorder.push_entity(action).unwrap();
1526
1527        assert_eq!(
1528            recorder.entities()[0].callback_slot,
1529            Some(CallbackSlot::new(0))
1530        );
1531        assert_eq!(
1532            recorder.entities()[0].action_cancel_callback_slot,
1533            Some(CallbackSlot::new(1))
1534        );
1535        assert_eq!(
1536            recorder.entities()[0].action_accepted_callback_slot,
1537            Some(CallbackSlot::new(2))
1538        );
1539    }
1540
1541    #[cfg(feature = "std")]
1542    #[test]
1543    fn source_metadata_json_uses_agent_a_schema_shape() {
1544        let mut recorder = MetadataRecorder::<1, 5, 1>::new();
1545        recorder
1546            .push_node(NodeId::new("node_talker"), "talker", "/", 0)
1547            .unwrap();
1548        recorder
1549            .push_entity(
1550                entity_metadata(EntityMetadataSpec {
1551                    id: EntityId::new("pub_chatter"),
1552                    node_id: NodeId::new("node_talker"),
1553                    kind: EntityKind::Publisher,
1554                    source_name: "chatter",
1555                    type_name: "std_msgs::msg::dds_::String_",
1556                    type_hash: "hash",
1557                    qos: crate::qos::DEFAULT,
1558                })
1559                .unwrap(),
1560            )
1561            .unwrap();
1562        let mut timer = entity_metadata(EntityMetadataSpec {
1563            id: EntityId::new("timer_publish"),
1564            node_id: NodeId::new("node_talker"),
1565            kind: EntityKind::Timer,
1566            source_name: "",
1567            type_name: "",
1568            type_hash: "",
1569            qos: crate::qos::DEFAULT,
1570        })
1571        .unwrap();
1572        timer.callback_id = Some(copy_str("cb_timer").unwrap());
1573        timer.callback_source = SourceLocationMetadata {
1574            artifact: copy_str("src/talker.rs").unwrap(),
1575            line: Some(42),
1576            column: Some(5),
1577        };
1578        timer.period_ms = Some(100);
1579        recorder.push_entity(timer).unwrap();
1580        let mut param = entity_metadata(EntityMetadataSpec {
1581            id: EntityId::new("param_rate"),
1582            node_id: NodeId::new("node_talker"),
1583            kind: EntityKind::Parameter,
1584            source_name: "rate_hz",
1585            type_name: "",
1586            type_hash: "",
1587            qos: crate::qos::DEFAULT,
1588        })
1589        .unwrap();
1590        param.parameter_type = Some(ParameterType::Integer);
1591        param.parameter_default = Some(ParameterDefault::Integer(10));
1592        param.source = SourceLocationMetadata {
1593            artifact: copy_str("src/talker.rs").unwrap(),
1594            line: Some(25),
1595            column: Some(9),
1596        };
1597        recorder.push_entity(param).unwrap();
1598        let mut action = entity_metadata(EntityMetadataSpec {
1599            id: EntityId::new("act_count"),
1600            node_id: NodeId::new("node_talker"),
1601            kind: EntityKind::ActionServer,
1602            source_name: "~/count",
1603            type_name: "example_interfaces::action::dds_::Fibonacci_",
1604            type_hash: "hash",
1605            qos: crate::qos::DEFAULT,
1606        })
1607        .unwrap();
1608        action.callback_id = Some(copy_str("cb_count_goal").unwrap());
1609        action.callback_source = SourceLocationMetadata {
1610            artifact: copy_str("src/talker.rs").unwrap(),
1611            line: Some(90),
1612            column: Some(5),
1613        };
1614        action.action_cancel_callback_id = Some(copy_str("cb_count_cancel").unwrap());
1615        action.action_cancel_source = SourceLocationMetadata {
1616            artifact: copy_str("src/talker.rs").unwrap(),
1617            line: Some(96),
1618            column: Some(5),
1619        };
1620        action.action_accepted_callback_id = Some(copy_str("cb_count_accepted").unwrap());
1621        action.action_accepted_source = SourceLocationMetadata {
1622            artifact: copy_str("src/talker.rs").unwrap(),
1623            line: Some(104),
1624            column: Some(5),
1625        };
1626        recorder.push_entity(action).unwrap();
1627        recorder
1628            .push_callback_effect(
1629                CallbackId::new("cb_timer"),
1630                CallbackEffectKind::Publishes,
1631                EntityId::new("pub_chatter"),
1632            )
1633            .unwrap();
1634
1635        let json = recorder
1636            .to_source_metadata_json(
1637                &SourceMetadataExport::new("demo_nodes_rs", "talker")
1638                    .executable("talker")
1639                    .exported_symbol("nros_node_talker")
1640                    .source_artifacts(&["src/talker.rs"]),
1641            )
1642            .unwrap();
1643
1644        assert!(json.contains("\"version\":1"));
1645        assert!(json.contains("\"language\":\"rust\""));
1646        assert!(json.contains("\"unresolved_name\":{\"value\":\"talker\",\"kind\":\"relative\"}"));
1647        assert!(json.contains(
1648            "\"interface\":{\"package\":\"std_msgs\",\"name\":\"msg/String\",\"kind\":\"message\"}"
1649        ));
1650        assert!(json.contains("\"kind\":\"publishes\",\"entity\":\"pub_chatter\""));
1651        assert!(
1652            json.contains("\"source\":{\"artifact\":\"src/talker.rs\",\"line\":42,\"column\":5}")
1653        );
1654        assert!(json.contains("\"name\":\"rate_hz\",\"default\":10,\"read_only\":false"));
1655        assert!(json.contains("\"goal_callback\":\"cb_count_goal\""));
1656        assert!(json.contains("\"cancel_callback\":\"cb_count_cancel\""));
1657        assert!(json.contains("\"accepted_callback\":\"cb_count_accepted\""));
1658        assert!(json.contains("\"kind\":\"action_cancel\""));
1659        assert!(json.contains("\"kind\":\"action_accepted\""));
1660        assert!(json.contains("\"generator\":\"nros-metadata-rust\""));
1661    }
1662}