Skip to main content

nros/
node_metadata.rs

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