Skip to main content

nros/
node_metadata.rs

1//! Node source metadata recorded without opening middleware.
2
3use crate::{
4    ParameterType, QoSProfile,
5    heapless::{String, Vec},
6};
7
8#[cfg(feature = "alloc")]
9use crate::{QoSDurabilityPolicy, QoSHistoryPolicy, QoSLivelinessPolicy, QoSReliabilityPolicy};
10#[cfg(feature = "alloc")]
11// phase-359 W8 — `alloc`, not `std`. These three are the file's ONLY reason for
12// a gate, and `alloc::{format, string::String, vec::Vec}` are the same types.
13// Gating them as `std` did not just mislabel them, it WITHHELD the source
14// metadata JSON API from `no_std + alloc` targets that can run it.
15use alloc::{format, string::String as StdString, vec::Vec as StdVec};
16
17/// Maximum nodes recorded by the built-in metadata recorder.
18pub const DEFAULT_MAX_METADATA_NODES: usize = 8;
19/// Maximum entities recorded by the built-in metadata recorder.
20pub const DEFAULT_MAX_METADATA_ENTITIES: usize = 32;
21/// Maximum callback/effect records kept by the built-in metadata recorder.
22pub const DEFAULT_MAX_METADATA_CALLBACKS: usize = 32;
23/// Maximum bytes in recorded source names and stable IDs.
24pub const METADATA_STRING_CAPACITY: usize = 128;
25
26/// Fixed-capacity string used by component metadata records.
27pub type MetadataString = String<METADATA_STRING_CAPACITY>;
28
29/// Declaration-order node slot within one extracted component.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub struct NodeSlot(pub usize);
32
33impl NodeSlot {
34    /// Create a node slot from its declaration-order index.
35    pub const fn new(index: usize) -> Self {
36        Self(index)
37    }
38
39    /// Declaration-order index.
40    pub const fn index(self) -> usize {
41        self.0
42    }
43}
44
45/// Declaration-order entity slot within one extracted component.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47pub struct EntitySlot(pub usize);
48
49impl EntitySlot {
50    /// Create an entity slot from its declaration-order index.
51    pub const fn new(index: usize) -> Self {
52        Self(index)
53    }
54
55    /// Declaration-order index.
56    pub const fn index(self) -> usize {
57        self.0
58    }
59}
60
61/// Declaration-order callback slot within one extracted component.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub struct CallbackSlot(pub usize);
64
65impl CallbackSlot {
66    /// Create a callback slot from its declaration-order index.
67    pub const fn new(index: usize) -> Self {
68        Self(index)
69    }
70
71    /// Declaration-order index.
72    pub const fn index(self) -> usize {
73        self.0
74    }
75}
76
77/// Source location attached to callbacks and parameters.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct SourceLocationMetadata {
80    pub artifact: MetadataString,
81    pub line: Option<u32>,
82    pub column: Option<u32>,
83}
84
85impl SourceLocationMetadata {
86    /// Empty source location used when caller data is unavailable.
87    pub const fn empty() -> Self {
88        Self {
89            artifact: MetadataString::new(),
90            line: None,
91            column: None,
92        }
93    }
94
95    /// Capture the Rust caller location.
96    #[track_caller]
97    pub fn caller() -> Result<Self, NodeMetadataError> {
98        let location = core::panic::Location::caller();
99        Ok(Self {
100            // issue 0699 — keep the TAIL, never fail on depth.
101            //
102            // rustc emits `Location::file()` ABSOLUTE whenever the recording
103            // crate is compiled as a path dependency from another directory,
104            // which is exactly how the metadata harness builds it. So this
105            // field carries the user's home directory, and a workspace nested
106            // ~100 chars deep overflowed the 128-byte buffer — `nros sync`
107            // failed with `Metadata(NameTooLong)` for no reason but where the
108            // user keeps their files.
109            //
110            // Truncating the FRONT is safe precisely here: the CLI's
111            // `metadata_build.rs` already rewrites this to be relative to the
112            // component package afterwards, so the prefix is discarded either
113            // way and the tail is the part that survives. A path is also
114            // diagnostic metadata — losing its head degrades a message,
115            // whereas erroring loses the whole register.
116            artifact: copy_str_keep_tail(location.file())?,
117            line: Some(location.line()),
118            column: Some(location.column()),
119        })
120    }
121}
122
123/// Parameter default value recorded for source metadata.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum ParameterDefault {
126    Bool(bool),
127    Integer(i64),
128    Double(MetadataString),
129    String(MetadataString),
130    BoolArray,
131    IntegerArray,
132    DoubleArray,
133    StringArray,
134}
135
136impl ParameterDefault {
137    /// Parameter type implied by this default.
138    pub const fn parameter_type(&self) -> ParameterType {
139        match self {
140            Self::Bool(_) => ParameterType::Bool,
141            Self::Integer(_) => ParameterType::Integer,
142            Self::Double(_) => ParameterType::Double,
143            Self::String(_) => ParameterType::String,
144            Self::BoolArray => ParameterType::BoolArray,
145            Self::IntegerArray => ParameterType::IntegerArray,
146            Self::DoubleArray => ParameterType::DoubleArray,
147            Self::StringArray => ParameterType::StringArray,
148        }
149    }
150
151    /// Default JSON-compatible value for a parameter type.
152    pub fn for_type(param_type: ParameterType) -> Result<Self, NodeMetadataError> {
153        Ok(match param_type {
154            ParameterType::Bool => Self::Bool(false),
155            ParameterType::Integer => Self::Integer(0),
156            ParameterType::Double => Self::Double(copy_str("0.0")?),
157            ParameterType::String => Self::String(copy_str("")?),
158            ParameterType::BoolArray => Self::BoolArray,
159            ParameterType::IntegerArray => Self::IntegerArray,
160            ParameterType::DoubleArray => Self::DoubleArray,
161            ParameterType::StringArray => Self::StringArray,
162            ParameterType::ByteArray | ParameterType::NotSet => Self::Integer(0),
163        })
164    }
165}
166
167/// Unresolved ROS name category as written by component source.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum SourceNameKind {
170    /// Starts with `/`.
171    Absolute,
172    /// Starts with `~`.
173    Private,
174    /// Any other non-empty source name.
175    Relative,
176}
177
178impl SourceNameKind {
179    /// Classify a source name without resolving launch remaps or namespaces.
180    pub const fn from_source_name(name: &str) -> Self {
181        let bytes = name.as_bytes();
182        if bytes.is_empty() {
183            Self::Relative
184        } else if bytes[0] == b'/' {
185            Self::Absolute
186        } else if bytes[0] == b'~' {
187            Self::Private
188        } else {
189            Self::Relative
190        }
191    }
192}
193
194/// Phase 305 W3 (issue 0255) — the ONE name-resolution seam: ROS 2 source-name
195/// expansion (`~`/relative → FQN, ns=`/` collapse) + launch remap substitution
196/// (exact-FQN match, first rule wins; no wildcards). Implemented in
197/// `nros_node::names` so both the Rust `ExecutorSink` and the C-ABI
198/// executor-side remap table share one lowering; re-exported here next to
199/// [`SourceNameKind`] as the source-metadata-level entry point.
200pub use nros_node::names::{MAX_RESOLVED_NAME_LEN, ResolvedName, expand_name, resolve_name};
201
202/// Stable source-level identifier required for component-mode declarations.
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
204pub struct EntityId<'a>(pub &'a str);
205
206impl<'a> EntityId<'a> {
207    /// Create a stable entity identifier.
208    pub const fn new(id: &'a str) -> Self {
209        Self(id)
210    }
211
212    /// Borrow the identifier string.
213    pub const fn as_str(self) -> &'a str {
214        self.0
215    }
216}
217
218/// Stable node identifier required for component-mode node declarations.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
220pub struct NodeId<'a>(pub &'a str);
221
222impl<'a> NodeId<'a> {
223    /// Create a stable node identifier.
224    pub const fn new(id: &'a str) -> Self {
225        Self(id)
226    }
227
228    /// Borrow the identifier string.
229    pub const fn as_str(self) -> &'a str {
230        self.0
231    }
232}
233
234/// Stable callback identifier required for component-mode callbacks.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
236pub struct CallbackId<'a>(pub &'a str);
237
238impl<'a> CallbackId<'a> {
239    /// Create a stable callback identifier.
240    pub const fn new(id: &'a str) -> Self {
241        Self(id)
242    }
243
244    /// Borrow the identifier string.
245    pub const fn as_str(self) -> &'a str {
246        self.0
247    }
248}
249
250/// Entity role recorded for source metadata.
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum EntityKind {
253    Publisher,
254    Subscription,
255    Timer,
256    ServiceServer,
257    ServiceClient,
258    ActionServer,
259    ActionClient,
260    Parameter,
261}
262
263/// Optional callback effect relation.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum CallbackEffectKind {
266    Reads,
267    Publishes,
268    Writes,
269}
270
271/// Metadata recorder/runtime error.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum NodeMetadataError {
274    /// Fixed recorder capacity exhausted.
275    Capacity,
276    /// Stable ID, node name, namespace, topic, service, action, or parameter name was too long.
277    NameTooLong,
278    /// Entity references a node ID that has not been declared.
279    UnknownNode,
280    /// Callback effect references an entity ID that has not been declared.
281    UnknownEntity,
282    /// Stable ID already exists in the same component metadata.
283    DuplicateId,
284}
285
286/// Recorded node declaration.
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct NodeMetadata {
289    pub slot: NodeSlot,
290    pub id: MetadataString,
291    pub source_default_name: MetadataString,
292    pub name: MetadataString,
293    pub namespace: MetadataString,
294    pub domain_id: u32,
295}
296
297/// Recorded entity declaration.
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct EntityMetadata {
300    pub slot: Option<EntitySlot>,
301    pub id: MetadataString,
302    pub node_slot: Option<NodeSlot>,
303    pub node_id: MetadataString,
304    pub kind: EntityKind,
305    pub source_name: MetadataString,
306    pub source_name_kind: SourceNameKind,
307    pub type_name: &'static str,
308    pub type_hash: &'static str,
309    pub qos: QoSProfile,
310    pub callback_slot: Option<CallbackSlot>,
311    pub callback_id: Option<MetadataString>,
312    pub callback_source: SourceLocationMetadata,
313    pub callback_group: Option<MetadataString>,
314    pub action_cancel_callback_slot: Option<CallbackSlot>,
315    pub action_cancel_callback_id: Option<MetadataString>,
316    pub action_cancel_source: SourceLocationMetadata,
317    pub action_accepted_callback_slot: Option<CallbackSlot>,
318    pub action_accepted_callback_id: Option<MetadataString>,
319    pub action_accepted_source: SourceLocationMetadata,
320    pub period_ms: Option<u64>,
321    /// Issue #505 — the timer period at the executor's own resolution.
322    /// `period_ms` is kept (and still emitted) for consumers of the
323    /// metadata JSON, but it truncates: a declared 33.333 ms timer used
324    /// to reach the runtime as 33 ms, a 1% permanent rate error, and a
325    /// sub-millisecond period reached it as zero.
326    pub period_us: Option<u64>,
327    pub parameter_type: Option<ParameterType>,
328    pub parameter_default: Option<ParameterDefault>,
329    pub parameter_read_only: bool,
330    /// Phase 250 (Wave 2b) — a subscription that opted into E2E message-integrity
331    /// validation (`.safety()`): the runtime registers it via
332    /// `create_generic_subscription_with_integrity` and surfaces
333    /// [`IntegrityStatus`](crate::IntegrityStatus) through `CallbackCtx::integrity()`.
334    /// Ungated (a plain flag) — only the runtime branch that reads it is gated on
335    /// `safety-e2e`, so when the capability is off the flag is simply ignored
336    /// (the subscription registers as a basic one). `false` for every other entity.
337    pub safety: bool,
338    pub source: SourceLocationMetadata,
339}
340
341/// Recorded optional callback effect.
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct CallbackEffectMetadata {
344    pub callback_id: MetadataString,
345    pub callback_slot: Option<CallbackSlot>,
346    pub kind: CallbackEffectKind,
347    pub entity_id: MetadataString,
348    pub entity_slot: Option<EntitySlot>,
349}
350
351/// Source metadata document settings used by the std JSON emitter.
352#[cfg(feature = "alloc")]
353#[derive(Debug, Clone)]
354pub struct SourceMetadataExport<'a> {
355    pub package: &'a str,
356    pub component: &'a str,
357    pub executable: Option<&'a str>,
358    pub exported_symbol: Option<&'a str>,
359    pub package_manifest: &'a str,
360    pub source_artifacts: &'a [&'a str],
361    /// phase-308 — the source language the sidecar describes (`"rust"`,
362    /// `"c"`, `"cpp"`). It was a hardcoded `"rust"` literal in the serializer
363    /// while Rust was the only producer; the C/C++ producer reaches this SAME
364    /// serializer, so the value has to come from the caller. The consumption
365    /// side keys on `(package, executable)` and never branches on it, but the
366    /// schema declares it and a wrong value is a lie in the artifact.
367    pub language: &'a str,
368}
369
370#[cfg(feature = "alloc")]
371impl<'a> SourceMetadataExport<'a> {
372    /// Create export settings with ROS package and component names.
373    pub const fn new(package: &'a str, component: &'a str) -> Self {
374        Self {
375            package,
376            component,
377            executable: None,
378            exported_symbol: None,
379            package_manifest: "package.xml",
380            source_artifacts: &[],
381            language: "rust",
382        }
383    }
384
385    /// Set the source language (`"c"` / `"cpp"`; defaults to `"rust"`).
386    pub const fn language(mut self, language: &'a str) -> Self {
387        self.language = language;
388        self
389    }
390
391    /// Set executable name.
392    pub const fn executable(mut self, executable: &'a str) -> Self {
393        self.executable = Some(executable);
394        self
395    }
396
397    /// Set exported symbol name.
398    pub const fn exported_symbol(mut self, exported_symbol: &'a str) -> Self {
399        self.exported_symbol = Some(exported_symbol);
400        self
401    }
402
403    /// Set package manifest path.
404    pub const fn package_manifest(mut self, package_manifest: &'a str) -> Self {
405        self.package_manifest = package_manifest;
406        self
407    }
408
409    /// Set source artifact paths.
410    pub const fn source_artifacts(mut self, source_artifacts: &'a [&'a str]) -> Self {
411        self.source_artifacts = source_artifacts;
412        self
413    }
414}
415
416/// phase-308 — `pub` for metadata-mode adapters (see
417/// [`MetadataRecorder::push_node`]); the capacity error is theirs to surface.
418pub fn metadata_string(value: &str) -> Result<MetadataString, NodeMetadataError> {
419    copy_str(value)
420}
421
422/// `copy_str`, but for a value whose TAIL is the informative end — a source
423/// path. Keeps the last whole path components that fit, marked with a leading
424/// `…/` so a reader can tell it was cut.
425///
426/// Issue 0699: the only caller is `SourceLocation::caller()`, whose input is
427/// `core::panic::Location::file()`. That is an absolute path in the metadata
428/// harness, so its length is set by where the USER put the workspace — not by
429/// anything this tree controls, and not something a fixed buffer can bound.
430pub(crate) fn copy_str_keep_tail(value: &str) -> Result<MetadataString, NodeMetadataError> {
431    if value.len() <= METADATA_STRING_CAPACITY {
432        return copy_str(value);
433    }
434    // Room for the marker, then the longest component-aligned tail that fits.
435    const MARK: &str = "…/";
436    let budget = METADATA_STRING_CAPACITY - MARK.len();
437    let tail = value
438        .char_indices()
439        .find(|(i, _)| value.len() - i <= budget)
440        .map(|(i, _)| &value[i..])
441        .unwrap_or("");
442    // Prefer a component boundary so the result reads as a path.
443    let tail = match tail.find('/') {
444        Some(cut) => &tail[cut + 1..],
445        None => tail,
446    };
447    let mut out = MetadataString::new();
448    out.push_str(MARK)
449        .map_err(|_| NodeMetadataError::NameTooLong)?;
450    out.push_str(tail)
451        .map_err(|_| NodeMetadataError::NameTooLong)?;
452    Ok(out)
453}
454
455pub(crate) fn copy_str(value: &str) -> Result<MetadataString, NodeMetadataError> {
456    let mut out = MetadataString::new();
457    out.push_str(value)
458        .map_err(|_| NodeMetadataError::NameTooLong)?;
459    Ok(out)
460}
461
462/// In-memory metadata sink used by host discovery. It never opens transport.
463#[derive(Debug)]
464pub struct MetadataRecorder<
465    const MAX_NODES: usize = DEFAULT_MAX_METADATA_NODES,
466    const MAX_ENTITIES: usize = DEFAULT_MAX_METADATA_ENTITIES,
467    const MAX_CALLBACKS: usize = DEFAULT_MAX_METADATA_CALLBACKS,
468> {
469    nodes: Vec<NodeMetadata, MAX_NODES>,
470    entities: Vec<EntityMetadata, MAX_ENTITIES>,
471    callback_effects: Vec<CallbackEffectMetadata, MAX_CALLBACKS>,
472}
473
474impl<const MAX_NODES: usize, const MAX_ENTITIES: usize, const MAX_CALLBACKS: usize> Default
475    for MetadataRecorder<MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
476{
477    fn default() -> Self {
478        Self::new()
479    }
480}
481
482impl<const MAX_NODES: usize, const MAX_ENTITIES: usize, const MAX_CALLBACKS: usize>
483    MetadataRecorder<MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
484{
485    /// Create an empty metadata recorder.
486    pub const fn new() -> Self {
487        Self {
488            nodes: Vec::new(),
489            entities: Vec::new(),
490            callback_effects: Vec::new(),
491        }
492    }
493
494    /// Recorded nodes in declaration order.
495    pub fn nodes(&self) -> &[NodeMetadata] {
496        &self.nodes
497    }
498
499    /// Recorded entities in declaration order.
500    pub fn entities(&self) -> &[EntityMetadata] {
501        &self.entities
502    }
503
504    /// Recorded optional callback effects in declaration order.
505    pub fn callback_effects(&self) -> &[CallbackEffectMetadata] {
506        &self.callback_effects
507    }
508
509    /// Emit schema-version-1 source metadata JSON without opening transport.
510    #[cfg(feature = "alloc")]
511    pub fn to_source_metadata_json(
512        &self,
513        export: &SourceMetadataExport<'_>,
514    ) -> Result<StdString, core::fmt::Error> {
515        let mut out = StdString::new();
516        self.write_source_metadata_json(export, &mut out)?;
517        Ok(out)
518    }
519
520    /// Write schema-version-1 source metadata JSON without opening transport.
521    #[cfg(feature = "alloc")]
522    pub fn write_source_metadata_json(
523        &self,
524        export: &SourceMetadataExport<'_>,
525        out: &mut impl core::fmt::Write,
526    ) -> core::fmt::Result {
527        write!(out, "{{")?;
528        write!(out, "\"version\":1,")?;
529        write_json_field(out, "package", export.package)?;
530        out.write_char(',')?;
531        write_json_field(out, "component", export.component)?;
532        out.write_char(',')?;
533        write_json_field(out, "language", export.language)?;
534        out.write_char(',')?;
535        write_json_opt_field(out, "executable", export.executable)?;
536        out.write_char(',')?;
537        write_json_opt_field(out, "exported_symbol", export.exported_symbol)?;
538        out.write_char(',')?;
539        self.write_nodes_json(out)?;
540        out.write_char(',')?;
541        self.write_callbacks_json(out)?;
542        out.write_char(',')?;
543        self.write_parameters_json(out)?;
544        out.write_char(',')?;
545        self.write_trace_json(export, out)?;
546        write!(out, "}}")
547    }
548
549    /// Record a node.
550    ///
551    /// phase-308 — `pub` because the recorder is the ONE recorder: the Rust
552    /// adapter (a `NodeContext` sink) and the C/C++ adapter (a recording RMW
553    /// backend + the timer/guard hooks in `nros-cpp`) both feed it, so there is
554    /// one definition of what a slot is and one schema emitter. Only the
555    /// adapter is per-language. Not a general-purpose API — it exists for
556    /// metadata-mode adapters, and misuse produces a sidecar that lies about
557    /// what the component declares.
558    pub fn push_node(
559        &mut self,
560        id: NodeId<'_>,
561        name: &str,
562        namespace: &str,
563        domain_id: u32,
564    ) -> Result<(), NodeMetadataError> {
565        if self.has_node(id.as_str()) {
566            return Err(NodeMetadataError::DuplicateId);
567        }
568
569        self.nodes
570            .push(NodeMetadata {
571                slot: NodeSlot::new(self.nodes.len()),
572                id: copy_str(id.as_str())?,
573                source_default_name: copy_str(name)?,
574                name: copy_str(name)?,
575                namespace: copy_str(namespace)?,
576                domain_id,
577            })
578            .map_err(|_| NodeMetadataError::Capacity)
579    }
580
581    /// Record an entity against an already-recorded node. See [`push_node`]
582    /// for why this is public.
583    ///
584    /// [`push_node`]: Self::push_node
585    pub fn push_entity(&mut self, mut entity: EntityMetadata) -> Result<(), NodeMetadataError> {
586        if !self.has_node(&entity.node_id) {
587            return Err(NodeMetadataError::UnknownNode);
588        }
589        if self.has_entity(&entity.id) {
590            return Err(NodeMetadataError::DuplicateId);
591        }
592
593        entity.slot = Some(EntitySlot::new(self.entities.len()));
594        entity.node_slot = self.node_slot_for_id(&entity.node_id);
595        let mut current_callbacks = Vec::<MetadataString, 3>::new();
596        let mut next_callback_slot = self.callback_slot_count();
597        entity.callback_slot = entity.callback_id.as_ref().map(|callback_id| {
598            self.callback_slot_for_current_entity(
599                callback_id.as_str(),
600                &mut current_callbacks,
601                &mut next_callback_slot,
602            )
603        });
604        entity.action_cancel_callback_slot =
605            entity
606                .action_cancel_callback_id
607                .as_ref()
608                .map(|callback_id| {
609                    self.callback_slot_for_current_entity(
610                        callback_id.as_str(),
611                        &mut current_callbacks,
612                        &mut next_callback_slot,
613                    )
614                });
615        entity.action_accepted_callback_slot =
616            entity
617                .action_accepted_callback_id
618                .as_ref()
619                .map(|callback_id| {
620                    self.callback_slot_for_current_entity(
621                        callback_id.as_str(),
622                        &mut current_callbacks,
623                        &mut next_callback_slot,
624                    )
625                });
626
627        self.entities
628            .push(entity)
629            .map_err(|_| NodeMetadataError::Capacity)
630    }
631
632    pub(crate) fn push_callback_effect(
633        &mut self,
634        callback_id: CallbackId<'_>,
635        kind: CallbackEffectKind,
636        entity_id: EntityId<'_>,
637    ) -> Result<(), NodeMetadataError> {
638        if !self.has_entity(entity_id.as_str()) {
639            return Err(NodeMetadataError::UnknownEntity);
640        }
641
642        self.callback_effects
643            .push(CallbackEffectMetadata {
644                callback_id: copy_str(callback_id.as_str())?,
645                callback_slot: self.callback_slot_for_id(callback_id.as_str()),
646                kind,
647                entity_id: copy_str(entity_id.as_str())?,
648                entity_slot: self.entity_slot_for_id(entity_id.as_str()),
649            })
650            .map_err(|_| NodeMetadataError::Capacity)
651    }
652
653    pub(crate) fn has_node(&self, id: &str) -> bool {
654        self.nodes.iter().any(|node| node.id.as_str() == id)
655    }
656
657    pub(crate) fn has_entity(&self, id: &str) -> bool {
658        self.entities.iter().any(|entity| entity.id.as_str() == id)
659    }
660
661    fn node_slot_for_id(&self, id: &str) -> Option<NodeSlot> {
662        self.nodes
663            .iter()
664            .find(|node| node.id.as_str() == id)
665            .map(|node| node.slot)
666    }
667
668    fn entity_slot_for_id(&self, id: &str) -> Option<EntitySlot> {
669        self.entities
670            .iter()
671            .find(|entity| entity.id.as_str() == id)
672            .and_then(|entity| entity.slot)
673    }
674
675    fn callback_slot_for_current_entity(
676        &self,
677        id: &str,
678        current_callbacks: &mut Vec<MetadataString, 3>,
679        next_callback_slot: &mut usize,
680    ) -> CallbackSlot {
681        if let Some(slot) = self.callback_slot_for_id(id) {
682            return slot;
683        }
684        if let Some((index, _)) = current_callbacks
685            .iter()
686            .enumerate()
687            .find(|(_, callback_id)| callback_id.as_str() == id)
688        {
689            return CallbackSlot::new(self.callback_slot_count() + index);
690        }
691        let slot = CallbackSlot::new(*next_callback_slot);
692        let _ = current_callbacks
693            .push(copy_str(id).expect("callback ID already fits metadata string capacity"));
694        *next_callback_slot += 1;
695        slot
696    }
697
698    fn callback_slot_for_id(&self, id: &str) -> Option<CallbackSlot> {
699        let mut seen = Vec::<&str, MAX_CALLBACKS>::new();
700        for entity in &self.entities {
701            for callback_id in entity_callback_ids(entity) {
702                let Some(callback_id) = callback_id else {
703                    continue;
704                };
705                let callback_id = callback_id.as_str();
706                if seen.contains(&callback_id) {
707                    continue;
708                }
709                if callback_id == id {
710                    return Some(CallbackSlot::new(seen.len()));
711                }
712                let _ = seen.push(callback_id);
713            }
714        }
715        None
716    }
717
718    fn callback_slot_count(&self) -> usize {
719        let mut seen = Vec::<&str, MAX_CALLBACKS>::new();
720        for entity in &self.entities {
721            for callback_id in entity_callback_ids(entity) {
722                let Some(callback_id) = callback_id else {
723                    continue;
724                };
725                let callback_id = callback_id.as_str();
726                if !seen.contains(&callback_id) {
727                    let _ = seen.push(callback_id);
728                }
729            }
730        }
731        seen.len()
732    }
733
734    #[cfg(feature = "alloc")]
735    fn write_nodes_json(&self, out: &mut impl core::fmt::Write) -> core::fmt::Result {
736        write!(out, "\"nodes\":[")?;
737        for (index, node) in self.nodes.iter().enumerate() {
738            if index > 0 {
739                out.write_char(',')?;
740            }
741            write!(out, "{{")?;
742            write_json_field(out, "id", node.id.as_str())?;
743            out.write_char(',')?;
744            write!(out, "\"declaration_slot\":{},", node.slot.index())?;
745            write_json_field(
746                out,
747                "source_default_name",
748                node.source_default_name.as_str(),
749            )?;
750            out.write_char(',')?;
751            write!(out, "\"unresolved_name\":")?;
752            write_source_name(
753                out,
754                node.name.as_str(),
755                SourceNameKind::from_source_name(&node.name),
756            )?;
757            out.write_char(',')?;
758            if node.namespace.as_str() == "/" {
759                write!(out, "\"namespace\":null,")?;
760            } else {
761                write_json_field(out, "namespace", node.namespace.as_str())?;
762                out.write_char(',')?;
763            }
764            self.write_node_entities(out, node.id.as_str())?;
765            write!(out, "}}")?;
766        }
767        write!(out, "]")
768    }
769
770    #[cfg(feature = "alloc")]
771    fn write_node_entities(
772        &self,
773        out: &mut impl core::fmt::Write,
774        node_id: &str,
775    ) -> core::fmt::Result {
776        self.write_entity_array(out, "publishers", node_id, EntityKind::Publisher)?;
777        out.write_char(',')?;
778        self.write_entity_array(out, "subscribers", node_id, EntityKind::Subscription)?;
779        out.write_char(',')?;
780        self.write_entity_array(out, "timers", node_id, EntityKind::Timer)?;
781        out.write_char(',')?;
782        self.write_entity_array(out, "services", node_id, EntityKind::ServiceServer)?;
783        out.write_char(',')?;
784        self.write_entity_array(out, "actions", node_id, EntityKind::ActionServer)?;
785        // issue 0900 — the CLIENT halves. `record_entity` has always captured
786        // them (`EntityKind::{ActionClient,ServiceClient}`, set in node.rs) and
787        // this writer dropped them on the way out, so a sidecar described only
788        // what a component SERVES. The executor arena is sized from the client
789        // count, which is why the omission had a size cost and not just a
790        // descriptive one.
791        out.write_char(',')?;
792        self.write_entity_array(out, "action_clients", node_id, EntityKind::ActionClient)?;
793        out.write_char(',')?;
794        self.write_entity_array(out, "service_clients", node_id, EntityKind::ServiceClient)
795    }
796
797    #[cfg(feature = "alloc")]
798    fn write_entity_array(
799        &self,
800        out: &mut impl core::fmt::Write,
801        field: &str,
802        node_id: &str,
803        kind: EntityKind,
804    ) -> core::fmt::Result {
805        write!(out, "\"{}\":[", field)?;
806        for (index, entity) in self
807            .entities
808            .iter()
809            .filter(|entity| entity.node_id.as_str() == node_id && entity.kind == kind)
810            .enumerate()
811        {
812            if index > 0 {
813                out.write_char(',')?;
814            }
815            match kind {
816                EntityKind::Publisher => write_publisher_json(out, entity)?,
817                EntityKind::Subscription => write_subscriber_json(out, entity)?,
818                EntityKind::Timer => write_timer_json(out, entity)?,
819                EntityKind::ServiceServer => write_service_json(out, entity)?,
820                EntityKind::ActionServer => write_action_json(out, entity)?,
821                // issue 0900 — a client registers no callbacks, so it needs
822                // none of the server writers' callback fields. One writer
823                // serves both client kinds.
824                EntityKind::ActionClient | EntityKind::ServiceClient => {
825                    write_client_json(out, entity)?
826                }
827                _ => {}
828            }
829        }
830        write!(out, "]")
831    }
832
833    #[cfg(feature = "alloc")]
834    fn write_callbacks_json(&self, out: &mut impl core::fmt::Write) -> core::fmt::Result {
835        let callbacks = self.source_callbacks();
836        write!(out, "\"callbacks\":[")?;
837        for (index, callback) in callbacks.iter().enumerate() {
838            if index > 0 {
839                out.write_char(',')?;
840            }
841            write!(out, "{{")?;
842            write_json_field(out, "id", callback.id.as_str())?;
843            out.write_char(',')?;
844            if let Some(slot) = callback.slot {
845                write!(out, "\"declaration_slot\":{},", slot.index())?;
846            }
847            write_json_field(out, "kind", callback.kind)?;
848            out.write_char(',')?;
849            if let Some(group) = callback.group.as_ref() {
850                write_json_field(out, "group", group)?;
851                out.write_char(',')?;
852            } else {
853                write!(out, "\"group\":null,")?;
854            }
855            write!(out, "\"effects\":[")?;
856            for (effect_index, effect) in self
857                .callback_effects
858                .iter()
859                .filter(|effect| effect.callback_id.as_str() == callback.id)
860                .enumerate()
861            {
862                if effect_index > 0 {
863                    out.write_char(',')?;
864                }
865                write!(out, "{{")?;
866                write_json_field(out, "kind", effect_json_kind(effect.kind))?;
867                out.write_char(',')?;
868                write_json_field(out, "entity", effect.entity_id.as_str())?;
869                if let Some(entity_slot) = effect.entity_slot {
870                    write!(out, ",\"entity_slot\":{}", entity_slot.index())?;
871                }
872                write!(out, "}}")?;
873            }
874            write!(out, "],")?;
875            write_source_location(out, &callback.source)?;
876            write!(out, "}}")?;
877        }
878        write!(out, "]")
879    }
880
881    #[cfg(feature = "alloc")]
882    fn write_parameters_json(&self, out: &mut impl core::fmt::Write) -> core::fmt::Result {
883        write!(out, "\"parameters\":[")?;
884        for (index, entity) in self
885            .entities
886            .iter()
887            .filter(|entity| entity.kind == EntityKind::Parameter)
888            .enumerate()
889        {
890            if index > 0 {
891                out.write_char(',')?;
892            }
893            write!(out, "{{")?;
894            write_json_field(out, "node", entity.node_id.as_str())?;
895            out.write_char(',')?;
896            if let Some(slot) = entity.slot {
897                write!(out, "\"declaration_slot\":{},", slot.index())?;
898            }
899            write_json_field(out, "name", entity.source_name.as_str())?;
900            out.write_char(',')?;
901            write!(out, "\"default\":")?;
902            write_parameter_default(out, entity.parameter_default.as_ref())?;
903            out.write_char(',')?;
904            write!(out, "\"read_only\":{},", entity.parameter_read_only)?;
905            write_source_location(out, &entity.source)?;
906            write!(out, "}}")?;
907        }
908        write!(out, "]")
909    }
910
911    #[cfg(feature = "alloc")]
912    fn write_trace_json(
913        &self,
914        export: &SourceMetadataExport<'_>,
915        out: &mut impl core::fmt::Write,
916    ) -> core::fmt::Result {
917        write!(out, "\"trace\":{{")?;
918        write_json_field(out, "generator", "nros-metadata-rust")?;
919        out.write_char(',')?;
920        write_json_field(out, "package_manifest", export.package_manifest)?;
921        out.write_char(',')?;
922        write!(out, "\"source_artifacts\":[")?;
923        for (index, artifact) in export.source_artifacts.iter().enumerate() {
924            if index > 0 {
925                out.write_char(',')?;
926            }
927            write_json_string(out, artifact)?;
928        }
929        write!(out, "]}}")
930    }
931
932    #[cfg(feature = "alloc")]
933    fn source_callbacks(&self) -> StdVec<SourceCallbackRef> {
934        let mut callbacks = StdVec::new();
935        for entity in &self.entities {
936            let Some(callback_id) = entity.callback_id.as_ref() else {
937                continue;
938            };
939            let kind = match entity.kind {
940                EntityKind::Subscription => "subscription",
941                EntityKind::Timer => "timer",
942                EntityKind::ServiceServer => "service",
943                EntityKind::ActionServer => "action_goal",
944                _ => continue,
945            };
946            if !callbacks
947                .iter()
948                .any(|callback: &SourceCallbackRef| callback.id == callback_id.as_str())
949            {
950                callbacks.push(SourceCallbackRef {
951                    id: callback_id.as_str().into(),
952                    slot: entity.callback_slot,
953                    kind,
954                    source: entity.callback_source.clone(),
955                    group: entity
956                        .callback_group
957                        .as_ref()
958                        .map(|group| group.as_str().into()),
959                });
960            }
961            if entity.kind == EntityKind::ActionServer {
962                if let Some(cancel_id) = entity.action_cancel_callback_id.as_ref()
963                    && !callbacks
964                        .iter()
965                        .any(|callback: &SourceCallbackRef| callback.id == cancel_id.as_str())
966                {
967                    callbacks.push(SourceCallbackRef {
968                        id: cancel_id.as_str().into(),
969                        slot: entity.action_cancel_callback_slot,
970                        kind: "action_cancel",
971                        source: entity.action_cancel_source.clone(),
972                        group: entity
973                            .callback_group
974                            .as_ref()
975                            .map(|group| group.as_str().into()),
976                    });
977                }
978                if let Some(accepted_id) = entity.action_accepted_callback_id.as_ref()
979                    && !callbacks
980                        .iter()
981                        .any(|callback: &SourceCallbackRef| callback.id == accepted_id.as_str())
982                {
983                    callbacks.push(SourceCallbackRef {
984                        id: accepted_id.as_str().into(),
985                        slot: entity.action_accepted_callback_slot,
986                        kind: "action_accepted",
987                        source: entity.action_accepted_source.clone(),
988                        group: entity
989                            .callback_group
990                            .as_ref()
991                            .map(|group| group.as_str().into()),
992                    });
993                }
994            }
995        }
996        callbacks
997    }
998}
999
1000#[cfg(feature = "alloc")]
1001struct SourceCallbackRef {
1002    id: StdString,
1003    slot: Option<CallbackSlot>,
1004    kind: &'static str,
1005    source: SourceLocationMetadata,
1006    group: Option<StdString>,
1007}
1008
1009pub(crate) fn entity_callback_ids(entity: &EntityMetadata) -> [Option<&MetadataString>; 3] {
1010    [
1011        entity.callback_id.as_ref(),
1012        entity.action_cancel_callback_id.as_ref(),
1013        entity.action_accepted_callback_id.as_ref(),
1014    ]
1015}
1016
1017/// Inputs for [`entity_metadata`]. Collapses the seven positional
1018/// arguments — three of them adjacent `&str` (`source_name` /
1019/// `type_name` / `type_hash`) that are trivially transposable at a
1020/// call site — into one named-field struct.
1021/// phase-308 — `pub` alongside [`MetadataRecorder::push_entity`]: metadata-mode
1022/// adapters for other languages build entities through this ONE constructor so
1023/// the defaults (and therefore the recorded shape) cannot diverge per language.
1024pub struct EntityMetadataSpec<'a> {
1025    pub id: EntityId<'a>,
1026    pub node_id: NodeId<'a>,
1027    pub kind: EntityKind,
1028    pub source_name: &'a str,
1029    pub type_name: &'static str,
1030    pub type_hash: &'static str,
1031    pub qos: QoSProfile,
1032}
1033
1034/// Build an [`EntityMetadata`] from its identifying fields, defaulting the
1035/// rest. See [`EntityMetadataSpec`] for why this is public.
1036pub fn entity_metadata(spec: EntityMetadataSpec<'_>) -> Result<EntityMetadata, NodeMetadataError> {
1037    let EntityMetadataSpec {
1038        id,
1039        node_id,
1040        kind,
1041        source_name,
1042        type_name,
1043        type_hash,
1044        qos,
1045    } = spec;
1046    Ok(EntityMetadata {
1047        slot: None,
1048        id: copy_str(id.as_str())?,
1049        node_slot: None,
1050        node_id: copy_str(node_id.as_str())?,
1051        kind,
1052        source_name: copy_str(source_name)?,
1053        source_name_kind: SourceNameKind::from_source_name(source_name),
1054        type_name,
1055        type_hash,
1056        qos,
1057        callback_slot: None,
1058        callback_id: None,
1059        callback_source: SourceLocationMetadata::empty(),
1060        callback_group: None,
1061        action_cancel_callback_slot: None,
1062        action_cancel_callback_id: None,
1063        action_cancel_source: SourceLocationMetadata::empty(),
1064        action_accepted_callback_slot: None,
1065        action_accepted_callback_id: None,
1066        action_accepted_source: SourceLocationMetadata::empty(),
1067        period_ms: None,
1068        period_us: None,
1069        parameter_type: None,
1070        parameter_default: None,
1071        parameter_read_only: false,
1072        safety: false,
1073        source: SourceLocationMetadata::empty(),
1074    })
1075}
1076
1077#[cfg(feature = "alloc")]
1078fn write_publisher_json(
1079    out: &mut impl core::fmt::Write,
1080    entity: &EntityMetadata,
1081) -> core::fmt::Result {
1082    write!(out, "{{")?;
1083    write_json_field(out, "id", entity.id.as_str())?;
1084    out.write_char(',')?;
1085    if let Some(slot) = entity.slot {
1086        write!(out, "\"declaration_slot\":{},", slot.index())?;
1087    }
1088    write!(out, "\"unresolved_topic\":")?;
1089    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
1090    out.write_char(',')?;
1091    write_interface(out, entity.type_name, "message")?;
1092    out.write_char(',')?;
1093    write_qos(out, entity.qos)?;
1094    write!(out, "}}")
1095}
1096
1097#[cfg(feature = "alloc")]
1098fn write_subscriber_json(
1099    out: &mut impl core::fmt::Write,
1100    entity: &EntityMetadata,
1101) -> core::fmt::Result {
1102    write!(out, "{{")?;
1103    write_json_field(out, "id", entity.id.as_str())?;
1104    out.write_char(',')?;
1105    if let Some(slot) = entity.slot {
1106        write!(out, "\"declaration_slot\":{},", slot.index())?;
1107    }
1108    write!(out, "\"unresolved_topic\":")?;
1109    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
1110    out.write_char(',')?;
1111    write_interface(out, entity.type_name, "message")?;
1112    out.write_char(',')?;
1113    write_qos(out, entity.qos)?;
1114    out.write_char(',')?;
1115    write_json_field(
1116        out,
1117        "callback",
1118        entity
1119            .callback_id
1120            .as_ref()
1121            .map(|id| id.as_str())
1122            .unwrap_or(""),
1123    )?;
1124    if let Some(callback_slot) = entity.callback_slot {
1125        write!(out, ",\"callback_slot\":{}", callback_slot.index())?;
1126    }
1127    write!(out, "}}")
1128}
1129
1130#[cfg(feature = "alloc")]
1131fn write_timer_json(out: &mut impl core::fmt::Write, entity: &EntityMetadata) -> core::fmt::Result {
1132    write!(out, "{{")?;
1133    write_json_field(out, "id", entity.id.as_str())?;
1134    out.write_char(',')?;
1135    if let Some(slot) = entity.slot {
1136        write!(out, "\"declaration_slot\":{},", slot.index())?;
1137    }
1138    write!(out, "\"period_ms\":{},", entity.period_ms.unwrap_or(0))?;
1139    write!(out, "\"period_us\":{},", entity.period_us.unwrap_or(0))?;
1140    write_json_field(
1141        out,
1142        "callback",
1143        entity
1144            .callback_id
1145            .as_ref()
1146            .map(|id| id.as_str())
1147            .unwrap_or(""),
1148    )?;
1149    if let Some(callback_slot) = entity.callback_slot {
1150        write!(out, ",\"callback_slot\":{}", callback_slot.index())?;
1151    }
1152    write!(out, "}}")
1153}
1154
1155#[cfg(feature = "alloc")]
1156fn write_service_json(
1157    out: &mut impl core::fmt::Write,
1158    entity: &EntityMetadata,
1159) -> core::fmt::Result {
1160    write!(out, "{{")?;
1161    write_json_field(out, "id", entity.id.as_str())?;
1162    out.write_char(',')?;
1163    if let Some(slot) = entity.slot {
1164        write!(out, "\"declaration_slot\":{},", slot.index())?;
1165    }
1166    write!(out, "\"unresolved_name\":")?;
1167    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
1168    out.write_char(',')?;
1169    write_interface(out, entity.type_name, "service")?;
1170    out.write_char(',')?;
1171    write_json_field(
1172        out,
1173        "callback",
1174        entity
1175            .callback_id
1176            .as_ref()
1177            .map(|id| id.as_str())
1178            .unwrap_or(""),
1179    )?;
1180    if let Some(callback_slot) = entity.callback_slot {
1181        write!(out, ",\"callback_slot\":{}", callback_slot.index())?;
1182    }
1183    write!(out, "}}")
1184}
1185
1186/// issue 0900 — a client entity: identity, name and interface, no callbacks.
1187///
1188/// Deliberately ONE writer for both `ActionClient` and `ServiceClient`. They
1189/// differ only in the interface KIND word, and the server-side pair is already
1190/// two near-identical functions; a third and fourth would be the second
1191/// spelling this repo keeps paying for.
1192#[cfg(feature = "alloc")]
1193fn write_client_json(
1194    out: &mut impl core::fmt::Write,
1195    entity: &EntityMetadata,
1196) -> core::fmt::Result {
1197    let interface_kind = if entity.kind == EntityKind::ActionClient {
1198        "action"
1199    } else {
1200        "service"
1201    };
1202    write!(out, "{{")?;
1203    write_json_field(out, "id", entity.id.as_str())?;
1204    out.write_char(',')?;
1205    if let Some(slot) = entity.slot {
1206        write!(out, "\"declaration_slot\":{},", slot.index())?;
1207    }
1208    write!(out, "\"unresolved_name\":")?;
1209    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
1210    out.write_char(',')?;
1211    write_interface(out, entity.type_name, interface_kind)?;
1212    write!(out, "}}")
1213}
1214
1215#[cfg(feature = "alloc")]
1216fn write_action_json(
1217    out: &mut impl core::fmt::Write,
1218    entity: &EntityMetadata,
1219) -> core::fmt::Result {
1220    let goal_callback = entity
1221        .callback_id
1222        .as_ref()
1223        .map(|id| id.as_str())
1224        .unwrap_or("");
1225    let cancel_callback = entity
1226        .action_cancel_callback_id
1227        .as_ref()
1228        .map(|id| id.as_str())
1229        .unwrap_or(goal_callback);
1230    let accepted_callback = entity
1231        .action_accepted_callback_id
1232        .as_ref()
1233        .map(|id| id.as_str())
1234        .unwrap_or(goal_callback);
1235    write!(out, "{{")?;
1236    write_json_field(out, "id", entity.id.as_str())?;
1237    out.write_char(',')?;
1238    if let Some(slot) = entity.slot {
1239        write!(out, "\"declaration_slot\":{},", slot.index())?;
1240    }
1241    write!(out, "\"unresolved_name\":")?;
1242    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
1243    out.write_char(',')?;
1244    write_interface(out, entity.type_name, "action")?;
1245    out.write_char(',')?;
1246    write_json_field(out, "goal_callback", goal_callback)?;
1247    if let Some(callback_slot) = entity.callback_slot {
1248        write!(out, ",\"goal_callback_slot\":{}", callback_slot.index())?;
1249    }
1250    out.write_char(',')?;
1251    write_json_field(out, "cancel_callback", cancel_callback)?;
1252    if let Some(callback_slot) = entity.action_cancel_callback_slot {
1253        write!(out, ",\"cancel_callback_slot\":{}", callback_slot.index())?;
1254    }
1255    out.write_char(',')?;
1256    write_json_field(out, "accepted_callback", accepted_callback)?;
1257    if let Some(callback_slot) = entity.action_accepted_callback_slot {
1258        write!(out, ",\"accepted_callback_slot\":{}", callback_slot.index())?;
1259    }
1260    write!(out, "}}")
1261}
1262
1263#[cfg(feature = "alloc")]
1264fn write_source_name(
1265    out: &mut impl core::fmt::Write,
1266    value: &str,
1267    kind: SourceNameKind,
1268) -> core::fmt::Result {
1269    write!(out, "{{")?;
1270    write_json_field(out, "value", value)?;
1271    out.write_char(',')?;
1272    write_json_field(out, "kind", source_name_kind_json(kind))?;
1273    write!(out, "}}")
1274}
1275
1276#[cfg(feature = "alloc")]
1277fn write_interface(
1278    out: &mut impl core::fmt::Write,
1279    type_name: &str,
1280    fallback_kind: &'static str,
1281) -> core::fmt::Result {
1282    let interface = parse_interface(type_name, fallback_kind);
1283    write!(out, "\"interface\":{{")?;
1284    write_json_field(out, "package", &interface.package)?;
1285    out.write_char(',')?;
1286    write_json_field(out, "name", &interface.name)?;
1287    out.write_char(',')?;
1288    write_json_field(out, "kind", interface.kind)?;
1289    write!(out, "}}")
1290}
1291
1292#[cfg(feature = "alloc")]
1293fn write_qos(out: &mut impl core::fmt::Write, qos: QoSProfile) -> core::fmt::Result {
1294    write!(out, "\"qos\":{{")?;
1295    write_json_field(out, "reliability", reliability_json(qos.reliability))?;
1296    out.write_char(',')?;
1297    write_json_field(out, "durability", durability_json(qos.durability))?;
1298    out.write_char(',')?;
1299    write_json_field(out, "history", history_json(qos.history))?;
1300    out.write_char(',')?;
1301    write!(out, "\"depth\":{},", qos.depth)?;
1302    write_optional_ms(out, "deadline_ms", qos.deadline_ms)?;
1303    out.write_char(',')?;
1304    write_optional_ms(out, "lifespan_ms", qos.lifespan_ms)?;
1305    out.write_char(',')?;
1306    write_json_field(out, "liveliness", liveliness_json(qos.liveliness_kind))?;
1307    out.write_char(',')?;
1308    write_optional_ms(out, "liveliness_lease_duration_ms", qos.liveliness_lease_ms)?;
1309    write!(out, ",\"extensions\":{{}}}}")
1310}
1311
1312#[cfg(feature = "alloc")]
1313fn write_source_location(
1314    out: &mut impl core::fmt::Write,
1315    source: &SourceLocationMetadata,
1316) -> core::fmt::Result {
1317    write!(out, "\"source\":{{")?;
1318    write_json_field(out, "artifact", source.artifact.as_str())?;
1319    out.write_char(',')?;
1320    write!(out, "\"line\":")?;
1321    write_optional_u32(out, source.line)?;
1322    out.write_char(',')?;
1323    write!(out, "\"column\":")?;
1324    write_optional_u32(out, source.column)?;
1325    write!(out, "}}")
1326}
1327
1328#[cfg(feature = "alloc")]
1329fn write_parameter_default(
1330    out: &mut impl core::fmt::Write,
1331    default: Option<&ParameterDefault>,
1332) -> core::fmt::Result {
1333    match default {
1334        Some(ParameterDefault::Bool(value)) => write!(out, "{}", value),
1335        Some(ParameterDefault::Integer(value)) => write!(out, "{}", value),
1336        Some(ParameterDefault::Double(value)) => write!(out, "{}", value.as_str()),
1337        Some(ParameterDefault::String(value)) => write_json_string(out, value.as_str()),
1338        Some(ParameterDefault::BoolArray)
1339        | Some(ParameterDefault::IntegerArray)
1340        | Some(ParameterDefault::DoubleArray)
1341        | Some(ParameterDefault::StringArray)
1342        | None => write!(out, "[]"),
1343    }
1344}
1345
1346#[cfg(feature = "alloc")]
1347fn write_json_field(out: &mut impl core::fmt::Write, name: &str, value: &str) -> core::fmt::Result {
1348    write_json_string(out, name)?;
1349    out.write_char(':')?;
1350    write_json_string(out, value)
1351}
1352
1353#[cfg(feature = "alloc")]
1354fn write_json_opt_field(
1355    out: &mut impl core::fmt::Write,
1356    name: &str,
1357    value: Option<&str>,
1358) -> core::fmt::Result {
1359    write_json_string(out, name)?;
1360    out.write_char(':')?;
1361    if let Some(value) = value {
1362        write_json_string(out, value)
1363    } else {
1364        write!(out, "null")
1365    }
1366}
1367
1368#[cfg(feature = "alloc")]
1369fn write_json_string(out: &mut impl core::fmt::Write, value: &str) -> core::fmt::Result {
1370    out.write_char('"')?;
1371    for ch in value.chars() {
1372        match ch {
1373            '"' => write!(out, "\\\"")?,
1374            '\\' => write!(out, "\\\\")?,
1375            '\n' => write!(out, "\\n")?,
1376            '\r' => write!(out, "\\r")?,
1377            '\t' => write!(out, "\\t")?,
1378            ch if ch.is_control() => write!(out, "\\u{:04x}", ch as u32)?,
1379            ch => out.write_char(ch)?,
1380        }
1381    }
1382    out.write_char('"')
1383}
1384
1385#[cfg(feature = "alloc")]
1386fn write_optional_ms(out: &mut impl core::fmt::Write, name: &str, value: u32) -> core::fmt::Result {
1387    write_json_string(out, name)?;
1388    out.write_char(':')?;
1389    if value == 0 {
1390        write!(out, "null")
1391    } else {
1392        write!(out, "{}", value)
1393    }
1394}
1395
1396#[cfg(feature = "alloc")]
1397fn write_optional_u32(out: &mut impl core::fmt::Write, value: Option<u32>) -> core::fmt::Result {
1398    if let Some(value) = value {
1399        write!(out, "{}", value)
1400    } else {
1401        write!(out, "null")
1402    }
1403}
1404
1405#[cfg(feature = "alloc")]
1406fn source_name_kind_json(kind: SourceNameKind) -> &'static str {
1407    match kind {
1408        SourceNameKind::Absolute => "absolute",
1409        SourceNameKind::Relative => "relative",
1410        SourceNameKind::Private => "private",
1411    }
1412}
1413
1414#[cfg(feature = "alloc")]
1415fn effect_json_kind(kind: CallbackEffectKind) -> &'static str {
1416    match kind {
1417        CallbackEffectKind::Publishes => "publishes",
1418        CallbackEffectKind::Reads => "reads_parameter",
1419        CallbackEffectKind::Writes => "writes_parameter",
1420    }
1421}
1422
1423// issue 0829 — `"system_default"` is the spelling `liveliness_json` below
1424// already uses for the same sentinel, and the one the orchestration IR emits
1425// for an unstated policy (`nros-cli-core/src/orchestration/schema.rs:58-88`,
1426// `planner.rs:1657-1671`). The metadata reports what the node REQUESTED, so a
1427// sentinel must survive to the JSON: printing the resolved value here would
1428// claim the node asked for something it left to the backend.
1429#[cfg(feature = "alloc")]
1430fn reliability_json(value: QoSReliabilityPolicy) -> &'static str {
1431    match value {
1432        QoSReliabilityPolicy::SystemDefault => "system_default",
1433        QoSReliabilityPolicy::Reliable => "reliable",
1434        QoSReliabilityPolicy::BestEffort => "best_effort",
1435    }
1436}
1437
1438#[cfg(feature = "alloc")]
1439fn durability_json(value: QoSDurabilityPolicy) -> &'static str {
1440    match value {
1441        QoSDurabilityPolicy::SystemDefault => "system_default",
1442        QoSDurabilityPolicy::Volatile => "volatile",
1443        QoSDurabilityPolicy::TransientLocal => "transient_local",
1444    }
1445}
1446
1447#[cfg(feature = "alloc")]
1448fn history_json(value: QoSHistoryPolicy) -> &'static str {
1449    match value {
1450        QoSHistoryPolicy::SystemDefault => "system_default",
1451        QoSHistoryPolicy::KeepLast => "keep_last",
1452        QoSHistoryPolicy::KeepAll => "keep_all",
1453    }
1454}
1455
1456#[cfg(feature = "alloc")]
1457fn liveliness_json(value: QoSLivelinessPolicy) -> &'static str {
1458    match value {
1459        QoSLivelinessPolicy::None => "system_default",
1460        QoSLivelinessPolicy::Automatic => "automatic",
1461        QoSLivelinessPolicy::ManualByTopic => "manual_by_topic",
1462        QoSLivelinessPolicy::ManualByNode => "manual_by_node",
1463    }
1464}
1465
1466#[cfg(feature = "alloc")]
1467struct ParsedInterface {
1468    package: StdString,
1469    name: StdString,
1470    kind: &'static str,
1471}
1472
1473#[cfg(feature = "alloc")]
1474fn parse_interface(type_name: &str, fallback_kind: &'static str) -> ParsedInterface {
1475    let parts: StdVec<&str> = type_name.split("::").collect();
1476    if parts.len() >= 4 {
1477        let package = parts[0].into();
1478        let kind = match parts[1] {
1479            "msg" => "message",
1480            "srv" => "service",
1481            "action" => "action",
1482            _ => fallback_kind,
1483        };
1484        let mut type_leaf = parts[3].trim_end_matches('_');
1485        if type_leaf.is_empty() {
1486            type_leaf = parts.last().copied().unwrap_or("");
1487        }
1488        return ParsedInterface {
1489            package,
1490            name: format!("{}/{}", parts[1], type_leaf),
1491            kind,
1492        };
1493    }
1494
1495    ParsedInterface {
1496        package: StdString::new(),
1497        name: type_name.into(),
1498        kind: fallback_kind,
1499    }
1500}
1501
1502#[cfg(test)]
1503mod tests {
1504    /// Issue 0699 — a source path longer than the buffer must RECORD, not fail.
1505    ///
1506    /// `nros sync` died with `Metadata(NameTooLong)` purely because the user's
1507    /// workspace sat ~100 chars deep: rustc emits `Location::file()` absolute
1508    /// for a path dependency, so this field's length is set by where the user
1509    /// keeps their files. The tail is what the CLI keeps anyway.
1510    #[test]
1511    fn a_path_longer_than_the_buffer_keeps_its_tail() {
1512        // A literal, not `format!`: this crate is `no_std` and the test cfg has
1513        // no allocator — the same constraint the buffer itself exists under.
1514        let deep = concat!(
1515            "/deep/nested/nested/nested/nested/nested/nested/nested/nested/",
1516            "nested/nested/nested/nested/nested/nested/nested/nested/nested/",
1517            "ws/build/nros-metadata/metadata-probe/listener/src/lib.rs"
1518        );
1519        assert!(deep.len() > super::METADATA_STRING_CAPACITY);
1520
1521        let got = super::copy_str_keep_tail(deep).expect("must not error on depth");
1522        assert!(got.len() <= super::METADATA_STRING_CAPACITY);
1523        assert!(
1524            got.ends_with("src/lib.rs"),
1525            "the informative tail must survive, got {got:?}"
1526        );
1527        assert!(got.starts_with('…'), "a cut must be visible, got {got:?}");
1528
1529        // A path that fits is untouched — no marker, no loss.
1530        let short = "src/lib.rs";
1531        assert_eq!(super::copy_str_keep_tail(short).unwrap().as_str(), short);
1532    }
1533
1534    use super::*;
1535    use crate::qos;
1536
1537    #[test]
1538    fn source_name_kind_preserves_unresolved_names() {
1539        assert_eq!(
1540            SourceNameKind::from_source_name("/scan"),
1541            SourceNameKind::Absolute
1542        );
1543        assert_eq!(
1544            SourceNameKind::from_source_name("~/scan"),
1545            SourceNameKind::Private
1546        );
1547        assert_eq!(
1548            SourceNameKind::from_source_name("scan"),
1549            SourceNameKind::Relative
1550        );
1551    }
1552
1553    // Phase 305 W3 (issue 0255) — the expansion/remap seam as re-exported here.
1554    // The exhaustive rule matrix lives with the impl (`nros_node::names`);
1555    // these pin the metadata-level contract each `SourceNameKind` maps to.
1556    #[test]
1557    fn expand_name_covers_each_source_name_kind() {
1558        // Absolute: unchanged.
1559        assert_eq!(
1560            expand_name("/scan", "lidar", "/sensing").unwrap().as_str(),
1561            "/scan"
1562        );
1563        // Private: node-FQN prefixed; ns=/ collapses.
1564        assert_eq!(
1565            expand_name("~/scan", "lidar", "/sensing").unwrap().as_str(),
1566            "/sensing/lidar/scan"
1567        );
1568        assert_eq!(
1569            expand_name("~/scan", "lidar", "/").unwrap().as_str(),
1570            "/lidar/scan"
1571        );
1572        // Relative: namespace prefixed; ns=/ collapses.
1573        assert_eq!(
1574            expand_name("scan", "lidar", "/sensing").unwrap().as_str(),
1575            "/sensing/scan"
1576        );
1577        assert_eq!(expand_name("scan", "lidar", "/").unwrap().as_str(), "/scan");
1578    }
1579
1580    #[test]
1581    fn resolve_name_substitutes_first_matching_rule() {
1582        let remaps = [
1583            ("~/scan", "/points_raw"),
1584            ("/sensing/lidar/scan", "/ignored"),
1585        ];
1586        assert_eq!(
1587            resolve_name("~/scan", "lidar", "/sensing", remaps)
1588                .unwrap()
1589                .as_str(),
1590            "/points_raw"
1591        );
1592        // No match → the expansion stands.
1593        assert_eq!(
1594            resolve_name("other", "lidar", "/sensing", [("/x", "/y")])
1595                .unwrap()
1596                .as_str(),
1597            "/sensing/other"
1598        );
1599    }
1600
1601    #[test]
1602    fn recorder_rejects_duplicate_stable_ids() {
1603        let mut recorder = MetadataRecorder::<1, 2, 1>::new();
1604        recorder
1605            .push_node(NodeId::new("node"), "talker", "/", 0)
1606            .unwrap();
1607
1608        let first = entity_metadata(EntityMetadataSpec {
1609            id: EntityId::new("pub"),
1610            node_id: NodeId::new("node"),
1611            kind: EntityKind::Publisher,
1612            source_name: "chatter",
1613            type_name: "std_msgs::msg::dds_::String_",
1614            type_hash: "hash",
1615            qos: qos::DEFAULT,
1616        })
1617        .unwrap();
1618        recorder.push_entity(first.clone()).unwrap();
1619
1620        assert_eq!(
1621            recorder.push_entity(first),
1622            Err(NodeMetadataError::DuplicateId)
1623        );
1624    }
1625
1626    #[test]
1627    fn recorder_rejects_duplicate_nodes_and_unknown_node_entities() {
1628        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
1629        recorder
1630            .push_node(NodeId::new("node"), "talker", "/", 0)
1631            .unwrap();
1632
1633        assert_eq!(
1634            recorder.push_node(NodeId::new("node"), "other", "/", 0),
1635            Err(NodeMetadataError::DuplicateId)
1636        );
1637
1638        let entity = entity_metadata(EntityMetadataSpec {
1639            id: EntityId::new("pub"),
1640            node_id: NodeId::new("missing_node"),
1641            kind: EntityKind::Publisher,
1642            source_name: "chatter",
1643            type_name: "std_msgs::msg::dds_::String_",
1644            type_hash: "hash",
1645            qos: qos::DEFAULT,
1646        })
1647        .unwrap();
1648
1649        assert_eq!(
1650            recorder.push_entity(entity),
1651            Err(NodeMetadataError::UnknownNode)
1652        );
1653    }
1654
1655    #[test]
1656    fn recorder_assigns_slots_and_source_default_names_by_declaration_order() {
1657        let mut recorder = MetadataRecorder::<2, 4, 3>::new();
1658        recorder
1659            .push_node(NodeId::new("node_alpha"), "talker", "/", 0)
1660            .unwrap();
1661        recorder
1662            .push_node(NodeId::new("node_beta"), "listener", "/demo", 42)
1663            .unwrap();
1664
1665        assert_eq!(recorder.nodes()[0].slot, NodeSlot::new(0));
1666        assert_eq!(recorder.nodes()[0].source_default_name.as_str(), "talker");
1667        assert_eq!(recorder.nodes()[1].slot, NodeSlot::new(1));
1668        assert_eq!(recorder.nodes()[1].source_default_name.as_str(), "listener");
1669
1670        recorder
1671            .push_entity(
1672                entity_metadata(EntityMetadataSpec {
1673                    id: EntityId::new("pub_chatter"),
1674                    node_id: NodeId::new("node_alpha"),
1675                    kind: EntityKind::Publisher,
1676                    source_name: "/chatter",
1677                    type_name: "std_msgs::msg::dds_::String_",
1678                    type_hash: "hash",
1679                    qos: qos::DEFAULT,
1680                })
1681                .unwrap(),
1682            )
1683            .unwrap();
1684        let mut subscription = entity_metadata(EntityMetadataSpec {
1685            id: EntityId::new("sub_chatter"),
1686            node_id: NodeId::new("node_beta"),
1687            kind: EntityKind::Subscription,
1688            source_name: "/chatter",
1689            type_name: "std_msgs::msg::dds_::String_",
1690            type_hash: "hash",
1691            qos: qos::DEFAULT,
1692        })
1693        .unwrap();
1694        subscription.callback_id = Some(copy_str("on_message").unwrap());
1695        recorder.push_entity(subscription).unwrap();
1696        let mut timer = entity_metadata(EntityMetadataSpec {
1697            id: EntityId::new("timer_tick"),
1698            node_id: NodeId::new("node_alpha"),
1699            kind: EntityKind::Timer,
1700            source_name: "",
1701            type_name: "",
1702            type_hash: "",
1703            qos: qos::DEFAULT,
1704        })
1705        .unwrap();
1706        timer.callback_id = Some(copy_str("on_tick").unwrap());
1707        recorder.push_entity(timer).unwrap();
1708
1709        assert_eq!(recorder.entities()[0].slot, Some(EntitySlot::new(0)));
1710        assert_eq!(recorder.entities()[0].node_slot, Some(NodeSlot::new(0)));
1711        assert_eq!(recorder.entities()[0].callback_slot, None);
1712        assert_eq!(recorder.entities()[1].slot, Some(EntitySlot::new(1)));
1713        assert_eq!(recorder.entities()[1].node_slot, Some(NodeSlot::new(1)));
1714        assert_eq!(
1715            recorder.entities()[1].callback_slot,
1716            Some(CallbackSlot::new(0))
1717        );
1718        assert_eq!(
1719            recorder.entities()[2].callback_slot,
1720            Some(CallbackSlot::new(1))
1721        );
1722
1723        recorder
1724            .push_callback_effect(
1725                CallbackId::new("on_tick"),
1726                CallbackEffectKind::Publishes,
1727                EntityId::new("pub_chatter"),
1728            )
1729            .unwrap();
1730        assert_eq!(
1731            recorder.callback_effects()[0].callback_slot,
1732            Some(CallbackSlot::new(1))
1733        );
1734        assert_eq!(
1735            recorder.callback_effects()[0].entity_slot,
1736            Some(EntitySlot::new(0))
1737        );
1738    }
1739
1740    #[test]
1741    fn recorder_assigns_distinct_callback_slots_within_one_action_entity() {
1742        let mut recorder = MetadataRecorder::<1, 1, 3>::new();
1743        recorder
1744            .push_node(NodeId::new("node"), "action_node", "/", 0)
1745            .unwrap();
1746        let mut action = entity_metadata(EntityMetadataSpec {
1747            id: EntityId::new("act_count"),
1748            node_id: NodeId::new("node"),
1749            kind: EntityKind::ActionServer,
1750            source_name: "/count",
1751            type_name: "example_interfaces::action::dds_::Fibonacci_",
1752            type_hash: "hash",
1753            qos: qos::DEFAULT,
1754        })
1755        .unwrap();
1756        action.callback_id = Some(copy_str("on_goal").unwrap());
1757        action.action_cancel_callback_id = Some(copy_str("on_cancel").unwrap());
1758        action.action_accepted_callback_id = Some(copy_str("on_accepted").unwrap());
1759
1760        recorder.push_entity(action).unwrap();
1761
1762        assert_eq!(
1763            recorder.entities()[0].callback_slot,
1764            Some(CallbackSlot::new(0))
1765        );
1766        assert_eq!(
1767            recorder.entities()[0].action_cancel_callback_slot,
1768            Some(CallbackSlot::new(1))
1769        );
1770        assert_eq!(
1771            recorder.entities()[0].action_accepted_callback_slot,
1772            Some(CallbackSlot::new(2))
1773        );
1774    }
1775
1776    #[cfg(feature = "alloc")]
1777    #[test]
1778    fn source_metadata_json_uses_agent_a_schema_shape() {
1779        // 7 entity slots, not 5: issue 0900 added the two CLIENT kinds to this
1780        // fixture. The recorder is fixed-capacity by design (`no_std`, static
1781        // storage), so a new entity here is a deliberate widening.
1782        let mut recorder = MetadataRecorder::<1, 7, 1>::new();
1783        recorder
1784            .push_node(NodeId::new("node_talker"), "talker", "/", 0)
1785            .unwrap();
1786        recorder
1787            .push_entity(
1788                entity_metadata(EntityMetadataSpec {
1789                    id: EntityId::new("pub_chatter"),
1790                    node_id: NodeId::new("node_talker"),
1791                    kind: EntityKind::Publisher,
1792                    source_name: "chatter",
1793                    type_name: "std_msgs::msg::dds_::String_",
1794                    type_hash: "hash",
1795                    qos: crate::qos::DEFAULT,
1796                })
1797                .unwrap(),
1798            )
1799            .unwrap();
1800        let mut timer = entity_metadata(EntityMetadataSpec {
1801            id: EntityId::new("timer_publish"),
1802            node_id: NodeId::new("node_talker"),
1803            kind: EntityKind::Timer,
1804            source_name: "",
1805            type_name: "",
1806            type_hash: "",
1807            qos: crate::qos::DEFAULT,
1808        })
1809        .unwrap();
1810        timer.callback_id = Some(copy_str("cb_timer").unwrap());
1811        timer.callback_source = SourceLocationMetadata {
1812            artifact: copy_str("src/talker.rs").unwrap(),
1813            line: Some(42),
1814            column: Some(5),
1815        };
1816        timer.period_ms = Some(100);
1817        recorder.push_entity(timer).unwrap();
1818        let mut param = entity_metadata(EntityMetadataSpec {
1819            id: EntityId::new("param_rate"),
1820            node_id: NodeId::new("node_talker"),
1821            kind: EntityKind::Parameter,
1822            source_name: "rate_hz",
1823            type_name: "",
1824            type_hash: "",
1825            qos: crate::qos::DEFAULT,
1826        })
1827        .unwrap();
1828        param.parameter_type = Some(ParameterType::Integer);
1829        param.parameter_default = Some(ParameterDefault::Integer(10));
1830        param.source = SourceLocationMetadata {
1831            artifact: copy_str("src/talker.rs").unwrap(),
1832            line: Some(25),
1833            column: Some(9),
1834        };
1835        recorder.push_entity(param).unwrap();
1836        let mut action = entity_metadata(EntityMetadataSpec {
1837            id: EntityId::new("act_count"),
1838            node_id: NodeId::new("node_talker"),
1839            kind: EntityKind::ActionServer,
1840            source_name: "~/count",
1841            type_name: "example_interfaces::action::dds_::Fibonacci_",
1842            type_hash: "hash",
1843            qos: crate::qos::DEFAULT,
1844        })
1845        .unwrap();
1846        action.callback_id = Some(copy_str("cb_count_goal").unwrap());
1847        action.callback_source = SourceLocationMetadata {
1848            artifact: copy_str("src/talker.rs").unwrap(),
1849            line: Some(90),
1850            column: Some(5),
1851        };
1852        action.action_cancel_callback_id = Some(copy_str("cb_count_cancel").unwrap());
1853        action.action_cancel_source = SourceLocationMetadata {
1854            artifact: copy_str("src/talker.rs").unwrap(),
1855            line: Some(96),
1856            column: Some(5),
1857        };
1858        action.action_accepted_callback_id = Some(copy_str("cb_count_accepted").unwrap());
1859        action.action_accepted_source = SourceLocationMetadata {
1860            artifact: copy_str("src/talker.rs").unwrap(),
1861            line: Some(104),
1862            column: Some(5),
1863        };
1864        recorder.push_entity(action).unwrap();
1865        recorder
1866            .push_callback_effect(
1867                CallbackId::new("cb_timer"),
1868                CallbackEffectKind::Publishes,
1869                EntityId::new("pub_chatter"),
1870            )
1871            .unwrap();
1872
1873        // Issue 0900 — one of each client kind, on the node that also SERVES
1874        // (it owns the action server above), so this would catch a writer that
1875        // filtered by node rather than by kind.
1876        recorder
1877            .push_entity(
1878                entity_metadata(EntityMetadataSpec {
1879                    id: EntityId::new("client_fib"),
1880                    node_id: NodeId::new("node_talker"),
1881                    kind: EntityKind::ActionClient,
1882                    source_name: "/fibonacci",
1883                    type_name: "example_interfaces::action::dds_::Fibonacci_",
1884                    type_hash: "hash",
1885                    qos: crate::qos::DEFAULT,
1886                })
1887                .unwrap(),
1888            )
1889            .unwrap();
1890        recorder
1891            .push_entity(
1892                entity_metadata(EntityMetadataSpec {
1893                    id: EntityId::new("client_add"),
1894                    node_id: NodeId::new("node_talker"),
1895                    kind: EntityKind::ServiceClient,
1896                    source_name: "/add_two_ints",
1897                    type_name: "example_interfaces::srv::dds_::AddTwoInts_",
1898                    type_hash: "hash",
1899                    qos: crate::qos::DEFAULT,
1900                })
1901                .unwrap(),
1902            )
1903            .unwrap();
1904
1905        let json = recorder
1906            .to_source_metadata_json(
1907                &SourceMetadataExport::new("demo_nodes_rs", "talker")
1908                    .executable("talker")
1909                    .exported_symbol("nros_node_talker")
1910                    .source_artifacts(&["src/talker.rs"]),
1911            )
1912            .unwrap();
1913
1914        assert!(json.contains("\"version\":1"));
1915        assert!(json.contains("\"language\":\"rust\""));
1916        assert!(json.contains("\"unresolved_name\":{\"value\":\"talker\",\"kind\":\"relative\"}"));
1917        assert!(json.contains(
1918            "\"interface\":{\"package\":\"std_msgs\",\"name\":\"msg/String\",\"kind\":\"message\"}"
1919        ));
1920        assert!(json.contains("\"kind\":\"publishes\",\"entity\":\"pub_chatter\""));
1921        assert!(
1922            json.contains("\"source\":{\"artifact\":\"src/talker.rs\",\"line\":42,\"column\":5}")
1923        );
1924        assert!(json.contains("\"name\":\"rate_hz\",\"default\":10,\"read_only\":false"));
1925        assert!(json.contains("\"goal_callback\":\"cb_count_goal\""));
1926        assert!(json.contains("\"cancel_callback\":\"cb_count_cancel\""));
1927        assert!(json.contains("\"accepted_callback\":\"cb_count_accepted\""));
1928        assert!(json.contains("\"kind\":\"action_cancel\""));
1929        assert!(json.contains("\"kind\":\"action_accepted\""));
1930
1931        // Issue 0900 — the CLIENT halves must reach the JSON, not just the
1932        // recorder. `record_entity` always captured them and the writer dropped
1933        // them, so the sidecar described only what a component SERVES. The
1934        // executor arena is sized from the client count, so the omission cost
1935        // bytes on a task stack and not merely description.
1936        //
1937        // Asserted on the SERIALISED form rather than on `recorder.entities()`:
1938        // the defect was entirely in the writer, so a test that stops at the
1939        // recorder passes against the broken tree — the vacuous shape this repo
1940        // keeps finding.
1941        assert!(
1942            json.contains("\"action_clients\":[{"),
1943            "an action client must reach the sidecar, got {json}"
1944        );
1945        assert!(
1946            json.contains("\"service_clients\":[{"),
1947            "a service client must reach the sidecar, got {json}"
1948        );
1949        // Scoped to the client ARRAYS, not to the whole document. The first
1950        // version of these asserts searched all of `json` and passed against a
1951        // broken emitter: this fixture's action SERVER carries the same
1952        // Fibonacci interface, so "the string appears somewhere" was true
1953        // whatever `write_client_json` did. A test that cannot fail is the
1954        // shape `check-no-vacuous-tests` exists to catch.
1955        let action_clients = &json[json.find("\"action_clients\":").expect("array present")..];
1956        let action_clients = &action_clients[..action_clients.find(']').unwrap()];
1957        let service_clients = &json[json.find("\"service_clients\":").expect("array present")..];
1958        let service_clients = &service_clients[..service_clients.find(']').unwrap()];
1959
1960        // The interface must be PARSED, not echoed as a Rust path. `write_
1961        // client_json` shares `write_interface` with the server writers, and
1962        // `parse_interface` only splits the 4-segment DDS-mangled form — which
1963        // is what `A::ACTION_NAME` actually supplies. Pinning the parsed shape
1964        // is what would catch a client writer that bypassed that helper.
1965        assert!(
1966            action_clients.contains(
1967                "\"interface\":{\"package\":\"example_interfaces\",\"name\":\"action/Fibonacci\",\"kind\":\"action\"}"
1968            ),
1969            "action client interface must be parsed, got {action_clients}"
1970        );
1971        assert!(
1972            service_clients.contains(
1973                "\"interface\":{\"package\":\"example_interfaces\",\"name\":\"srv/AddTwoInts\",\"kind\":\"service\"}"
1974            ),
1975            "service client interface must be parsed, got {service_clients}"
1976        );
1977        // The KIND word is the only thing `write_client_json` branches on, so
1978        // the two arrays must not agree on it.
1979        assert!(
1980            !action_clients.contains("\"kind\":\"service\"")
1981                && !service_clients.contains("\"kind\":\"action\""),
1982            "the client kind word is what distinguishes the arrays"
1983        );
1984        // A client registers no callbacks, so it must carry none of the server
1985        // writers' callback fields.
1986        assert!(
1987            !action_clients.contains("callback") && !service_clients.contains("callback"),
1988            "a client registers no callback; emitting one would misdescribe it"
1989        );
1990        assert!(json.contains("\"generator\":\"nros-metadata-rust\""));
1991    }
1992}