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    /// phase-463 W1 -- the ABI entry the timer came through (see [`TimerKind`]).
472    /// Emitted as `kind` on the timer row; `Wall` for every other entity kind.
473    pub timer_kind: TimerKind,
474    pub parameter_type: Option<ParameterType>,
475    pub parameter_default: Option<ParameterDefault>,
476    pub parameter_read_only: bool,
477    /// Phase 250 (Wave 2b) — a subscription that opted into E2E message-integrity
478    /// validation (`.safety()`): the runtime registers it via
479    /// `create_generic_subscription_with_integrity` and surfaces
480    /// [`IntegrityStatus`](crate::IntegrityStatus) through `CallbackCtx::integrity()`.
481    /// Ungated (a plain flag) — only the runtime branch that reads it is gated on
482    /// `safety-e2e`, so when the capability is off the flag is simply ignored
483    /// (the subscription registers as a basic one). `false` for every other entity.
484    pub safety: bool,
485    /// phase-457 W3 — can THIS subscription's registration dispatch out of the
486    /// backend's own receive slot?
487    ///
488    /// The CALL SITE's half of a subscription's `registration_path` (RFC-0100
489    /// D1, issues 1319 / 1340), reported by
490    /// `nros_node::executor::registration_observer` from
491    /// `Executor::open_subscription` — the one site in the tree that reads it.
492    /// **Not** conjoined with the backend's own answer: a probe links the
493    /// RECORDING backend, so the conjunction would describe the probe rather
494    /// than the image, and the consumer knows the shipped backend from the
495    /// image's `rmw` name.
496    ///
497    /// `None` is **"nobody observed this"** and is a REFUSAL at every consumer,
498    /// never a `false`. It is the state of every row on a road whose probe
499    /// declares without registering — the Rust producer runs `register()`
500    /// against a recording `NodeContext` and opens no executor — and of every
501    /// non-subscription row, which claims no receive slot at all.
502    pub in_place_capable: Option<bool>,
503    /// Issue 1648 -- the BUFFERED row the registration claimed. Set by the
504    /// same observation as [`Self::in_place_capable`], so the two are present
505    /// together or not at all; `None` is "nobody observed this".
506    pub buffered_row: Option<BufferedRow>,
507    pub source: SourceLocationMetadata,
508}
509
510impl EntityMetadata {
511    /// phase-457 W5 (issue 1522) — which registration entry point this
512    /// DECLARED subscription will reach.
513    ///
514    /// **THE classifier**, in the sense
515    /// [`DeclaredSubscriptionShape`](nros_node::executor::declared_shape::DeclaredSubscriptionShape)'s
516    /// module docs give it: the declarative registrar
517    /// (`node_runtime`'s `EntityKind::Subscription` arm) dispatches on this
518    /// answer, and the metadata recorder states
519    /// [`in_place_capable`](Self::in_place_capable) from the same answer. Two
520    /// readers of one function is not a second opinion; two functions would
521    /// be, which is the drift phase-457 W3 removed from the CLI.
522    ///
523    /// The `safety-e2e` mask lives HERE and nowhere else. With the capability
524    /// off the runtime ignores [`safety`](Self::safety) and registers the
525    /// basic path (see that field's doc), so a probe built the same way must
526    /// say the same thing — and a second `#[cfg]` at either reader is the
527    /// disagreement this function exists to prevent.
528    ///
529    /// Meaningful only for [`EntityKind::Subscription`]; no other kind reaches
530    /// a subscription entry point, and no caller asks.
531    pub fn declared_subscription_shape(
532        &self,
533    ) -> nros_node::executor::declared_shape::DeclaredSubscriptionShape {
534        #[cfg(feature = "safety-e2e")]
535        let safety = self.safety;
536        #[cfg(not(feature = "safety-e2e"))]
537        let safety = false;
538        nros_node::executor::declared_shape::DeclaredSubscriptionShape::of_declaration(safety)
539    }
540}
541
542/// Recorded optional callback effect.
543#[derive(Debug, Clone, PartialEq, Eq)]
544pub struct CallbackEffectMetadata {
545    pub callback_id: MetadataString,
546    pub callback_slot: Option<CallbackSlot>,
547    pub kind: CallbackEffectKind,
548    pub entity_id: MetadataString,
549    pub entity_slot: Option<EntitySlot>,
550}
551
552/// Source metadata document settings used by the std JSON emitter.
553#[cfg(feature = "alloc")]
554#[derive(Debug, Clone)]
555pub struct SourceMetadataExport<'a> {
556    pub package: &'a str,
557    pub component: &'a str,
558    pub executable: Option<&'a str>,
559    pub exported_symbol: Option<&'a str>,
560    pub package_manifest: &'a str,
561    pub source_artifacts: &'a [&'a str],
562    /// phase-308 — the source language the sidecar describes (`"rust"`,
563    /// `"c"`, `"cpp"`). It was a hardcoded `"rust"` literal in the serializer
564    /// while Rust was the only producer; the C/C++ producer reaches this SAME
565    /// serializer, so the value has to come from the caller. The consumption
566    /// side keys on `(package, executable)` and never branches on it, but the
567    /// schema declares it and a wrong value is a lie in the artifact.
568    pub language: &'a str,
569}
570
571#[cfg(feature = "alloc")]
572impl<'a> SourceMetadataExport<'a> {
573    /// Create export settings with ROS package and component names.
574    pub const fn new(package: &'a str, component: &'a str) -> Self {
575        Self {
576            package,
577            component,
578            executable: None,
579            exported_symbol: None,
580            package_manifest: "package.xml",
581            source_artifacts: &[],
582            language: "rust",
583        }
584    }
585
586    /// Set the source language (`"c"` / `"cpp"`; defaults to `"rust"`).
587    pub const fn language(mut self, language: &'a str) -> Self {
588        self.language = language;
589        self
590    }
591
592    /// Set executable name.
593    pub const fn executable(mut self, executable: &'a str) -> Self {
594        self.executable = Some(executable);
595        self
596    }
597
598    /// Set exported symbol name.
599    pub const fn exported_symbol(mut self, exported_symbol: &'a str) -> Self {
600        self.exported_symbol = Some(exported_symbol);
601        self
602    }
603
604    /// Set package manifest path.
605    pub const fn package_manifest(mut self, package_manifest: &'a str) -> Self {
606        self.package_manifest = package_manifest;
607        self
608    }
609
610    /// Set source artifact paths.
611    pub const fn source_artifacts(mut self, source_artifacts: &'a [&'a str]) -> Self {
612        self.source_artifacts = source_artifacts;
613        self
614    }
615}
616
617/// phase-308 — `pub` for metadata-mode adapters (see
618/// [`MetadataRecorder::push_node`]); the capacity error is theirs to surface.
619pub fn metadata_string(value: &str) -> Result<MetadataString, NodeMetadataError> {
620    copy_str(value)
621}
622
623/// `copy_str`, but for a value whose TAIL is the informative end — a source
624/// path. Keeps the last whole path components that fit, marked with a leading
625/// `…/` so a reader can tell it was cut.
626///
627/// Issue 0699: the only caller is `SourceLocation::caller()`, whose input is
628/// `core::panic::Location::file()`. That is an absolute path in the metadata
629/// harness, so its length is set by where the USER put the workspace — not by
630/// anything this tree controls, and not something a fixed buffer can bound.
631pub(crate) fn copy_str_keep_tail(value: &str) -> Result<MetadataString, NodeMetadataError> {
632    if value.len() <= METADATA_STRING_CAPACITY {
633        return copy_str(value);
634    }
635    // Room for the marker, then the longest component-aligned tail that fits.
636    const MARK: &str = "…/";
637    let budget = METADATA_STRING_CAPACITY - MARK.len();
638    let tail = value
639        .char_indices()
640        .find(|(i, _)| value.len() - i <= budget)
641        .map(|(i, _)| &value[i..])
642        .unwrap_or("");
643    // Prefer a component boundary so the result reads as a path.
644    let tail = match tail.find('/') {
645        Some(cut) => &tail[cut + 1..],
646        None => tail,
647    };
648    let mut out = MetadataString::new();
649    out.push_str(MARK)
650        .map_err(|_| NodeMetadataError::NameTooLong)?;
651    out.push_str(tail)
652        .map_err(|_| NodeMetadataError::NameTooLong)?;
653    Ok(out)
654}
655
656pub(crate) fn copy_str(value: &str) -> Result<MetadataString, NodeMetadataError> {
657    let mut out = MetadataString::new();
658    out.push_str(value)
659        .map_err(|_| NodeMetadataError::NameTooLong)?;
660    Ok(out)
661}
662
663/// In-memory metadata sink used by host discovery. It never opens transport.
664#[derive(Debug)]
665pub struct MetadataRecorder<
666    const MAX_NODES: usize = DEFAULT_MAX_METADATA_NODES,
667    const MAX_ENTITIES: usize = DEFAULT_MAX_METADATA_ENTITIES,
668    const MAX_CALLBACKS: usize = DEFAULT_MAX_METADATA_CALLBACKS,
669> {
670    nodes: Vec<NodeMetadata, MAX_NODES>,
671    entities: Vec<EntityMetadata, MAX_ENTITIES>,
672    callback_effects: Vec<CallbackEffectMetadata, MAX_CALLBACKS>,
673}
674
675impl<const MAX_NODES: usize, const MAX_ENTITIES: usize, const MAX_CALLBACKS: usize> Default
676    for MetadataRecorder<MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
677{
678    fn default() -> Self {
679        Self::new()
680    }
681}
682
683impl<const MAX_NODES: usize, const MAX_ENTITIES: usize, const MAX_CALLBACKS: usize>
684    MetadataRecorder<MAX_NODES, MAX_ENTITIES, MAX_CALLBACKS>
685{
686    /// Create an empty metadata recorder.
687    pub const fn new() -> Self {
688        Self {
689            nodes: Vec::new(),
690            entities: Vec::new(),
691            callback_effects: Vec::new(),
692        }
693    }
694
695    /// Recorded nodes in declaration order.
696    pub fn nodes(&self) -> &[NodeMetadata] {
697        &self.nodes
698    }
699
700    /// Recorded entities in declaration order.
701    pub fn entities(&self) -> &[EntityMetadata] {
702        &self.entities
703    }
704
705    /// Recorded optional callback effects in declaration order.
706    pub fn callback_effects(&self) -> &[CallbackEffectMetadata] {
707        &self.callback_effects
708    }
709
710    /// phase-457 W3 — attach an OBSERVED registration fact to the subscription
711    /// row that registered it.
712    ///
713    /// A subscription is recorded when the backend creates it and observed when
714    /// the executor registers it, which are two moments in one call — the RMW
715    /// seam runs inside `Executor::open_subscription`. So the row exists and the
716    /// fact arrives afterwards; this is the join.
717    ///
718    /// Keyed on `(source_name, type_name)` and taking the LAST unset match,
719    /// which is the row that was just created: two subscriptions on one topic of
720    /// one type are indistinguishable at this seam, and taking them in order is
721    /// the only attribution available. Returns `false` when no such row is
722    /// waiting — a caller that drops that has silently lost a fact, which is why
723    /// this is `#[must_use]` like every other recorder entry point.
724    ///
725    /// **Refuses to overwrite.** A row whose fact is already set is not a match,
726    /// so a second observation lands on the next unset row rather than
727    /// re-stating the first — the difference matters exactly when two rows
728    /// disagree, which is the case this whole field exists for.
729    #[must_use]
730    pub fn observe_subscription_registration(
731        &mut self,
732        source_name: &str,
733        type_name: &str,
734        in_place_capable: bool,
735        buffered_row: BufferedRow,
736    ) -> bool {
737        for entity in self.entities.iter_mut() {
738            if entity.kind == EntityKind::Subscription
739                && entity.in_place_capable.is_none()
740                && entity.source_name.as_str() == source_name
741                && entity.type_name == type_name
742            {
743                entity.in_place_capable = Some(in_place_capable);
744                entity.buffered_row = Some(buffered_row);
745                return true;
746            }
747        }
748        false
749    }
750
751    /// Emit source metadata JSON ([`SOURCE_METADATA_SCHEMA_VERSION`]) without
752    /// opening transport.
753    #[cfg(feature = "alloc")]
754    pub fn to_source_metadata_json(
755        &self,
756        export: &SourceMetadataExport<'_>,
757    ) -> Result<StdString, core::fmt::Error> {
758        let mut out = StdString::new();
759        self.write_source_metadata_json(export, &mut out)?;
760        Ok(out)
761    }
762
763    /// Write source metadata JSON ([`SOURCE_METADATA_SCHEMA_VERSION`]) without
764    /// opening transport.
765    #[cfg(feature = "alloc")]
766    pub fn write_source_metadata_json(
767        &self,
768        export: &SourceMetadataExport<'_>,
769        out: &mut impl core::fmt::Write,
770    ) -> core::fmt::Result {
771        write!(out, "{{")?;
772        write!(out, "\"version\":{},", SOURCE_METADATA_SCHEMA_VERSION)?;
773        write_json_field(out, "package", export.package)?;
774        out.write_char(',')?;
775        write_json_field(out, "component", export.component)?;
776        out.write_char(',')?;
777        write_json_field(out, "language", export.language)?;
778        out.write_char(',')?;
779        write_json_opt_field(out, "executable", export.executable)?;
780        out.write_char(',')?;
781        write_json_opt_field(out, "exported_symbol", export.exported_symbol)?;
782        out.write_char(',')?;
783        self.write_nodes_json(out)?;
784        out.write_char(',')?;
785        self.write_callbacks_json(out)?;
786        out.write_char(',')?;
787        self.write_parameters_json(out)?;
788        out.write_char(',')?;
789        self.write_trace_json(export, out)?;
790        write!(out, "}}")
791    }
792
793    /// Record a node.
794    ///
795    /// phase-308 — `pub` because the recorder is the ONE recorder: the Rust
796    /// adapter (a `NodeContext` sink) and the C/C++ adapter (a recording RMW
797    /// backend + the timer/guard hooks in `nros-cpp`) both feed it, so there is
798    /// one definition of what a slot is and one schema emitter. Only the
799    /// adapter is per-language. Not a general-purpose API — it exists for
800    /// metadata-mode adapters, and misuse produces a sidecar that lies about
801    /// what the component declares.
802    pub fn push_node(
803        &mut self,
804        id: NodeId<'_>,
805        name: &str,
806        namespace: &str,
807        domain_id: u32,
808    ) -> Result<(), NodeMetadataError> {
809        if self.has_node(id.as_str()) {
810            return Err(NodeMetadataError::DuplicateId);
811        }
812
813        self.nodes
814            .push(NodeMetadata {
815                slot: NodeSlot::new(self.nodes.len()),
816                id: copy_str(id.as_str())?,
817                source_default_name: copy_str(name)?,
818                name: copy_str(name)?,
819                namespace: copy_str(namespace)?,
820                domain_id,
821            })
822            .map_err(|_| NodeMetadataError::Capacity)
823    }
824
825    /// Record an entity against an already-recorded node. See [`push_node`]
826    /// for why this is public.
827    ///
828    /// [`push_node`]: Self::push_node
829    pub fn push_entity(&mut self, mut entity: EntityMetadata) -> Result<(), NodeMetadataError> {
830        if !self.has_node(&entity.node_id) {
831            return Err(NodeMetadataError::UnknownNode);
832        }
833        if self.has_entity(&entity.id) {
834            return Err(NodeMetadataError::DuplicateId);
835        }
836
837        entity.slot = Some(EntitySlot::new(self.entities.len()));
838        entity.node_slot = self.node_slot_for_id(&entity.node_id);
839        let mut current_callbacks = Vec::<MetadataString, 3>::new();
840        let mut next_callback_slot = self.callback_slot_count();
841        entity.callback_slot = entity.callback_id.as_ref().map(|callback_id| {
842            self.callback_slot_for_current_entity(
843                callback_id.as_str(),
844                &mut current_callbacks,
845                &mut next_callback_slot,
846            )
847        });
848        entity.action_cancel_callback_slot =
849            entity
850                .action_cancel_callback_id
851                .as_ref()
852                .map(|callback_id| {
853                    self.callback_slot_for_current_entity(
854                        callback_id.as_str(),
855                        &mut current_callbacks,
856                        &mut next_callback_slot,
857                    )
858                });
859        entity.action_accepted_callback_slot =
860            entity
861                .action_accepted_callback_id
862                .as_ref()
863                .map(|callback_id| {
864                    self.callback_slot_for_current_entity(
865                        callback_id.as_str(),
866                        &mut current_callbacks,
867                        &mut next_callback_slot,
868                    )
869                });
870
871        self.entities
872            .push(entity)
873            .map_err(|_| NodeMetadataError::Capacity)
874    }
875
876    pub(crate) fn push_callback_effect(
877        &mut self,
878        callback_id: CallbackId<'_>,
879        kind: CallbackEffectKind,
880        entity_id: EntityId<'_>,
881    ) -> Result<(), NodeMetadataError> {
882        if !self.has_entity(entity_id.as_str()) {
883            return Err(NodeMetadataError::UnknownEntity);
884        }
885
886        self.callback_effects
887            .push(CallbackEffectMetadata {
888                callback_id: copy_str(callback_id.as_str())?,
889                callback_slot: self.callback_slot_for_id(callback_id.as_str()),
890                kind,
891                entity_id: copy_str(entity_id.as_str())?,
892                entity_slot: self.entity_slot_for_id(entity_id.as_str()),
893            })
894            .map_err(|_| NodeMetadataError::Capacity)
895    }
896
897    pub(crate) fn has_node(&self, id: &str) -> bool {
898        self.nodes.iter().any(|node| node.id.as_str() == id)
899    }
900
901    pub(crate) fn has_entity(&self, id: &str) -> bool {
902        self.entities.iter().any(|entity| entity.id.as_str() == id)
903    }
904
905    fn node_slot_for_id(&self, id: &str) -> Option<NodeSlot> {
906        self.nodes
907            .iter()
908            .find(|node| node.id.as_str() == id)
909            .map(|node| node.slot)
910    }
911
912    fn entity_slot_for_id(&self, id: &str) -> Option<EntitySlot> {
913        self.entities
914            .iter()
915            .find(|entity| entity.id.as_str() == id)
916            .and_then(|entity| entity.slot)
917    }
918
919    fn callback_slot_for_current_entity(
920        &self,
921        id: &str,
922        current_callbacks: &mut Vec<MetadataString, 3>,
923        next_callback_slot: &mut usize,
924    ) -> CallbackSlot {
925        if let Some(slot) = self.callback_slot_for_id(id) {
926            return slot;
927        }
928        if let Some((index, _)) = current_callbacks
929            .iter()
930            .enumerate()
931            .find(|(_, callback_id)| callback_id.as_str() == id)
932        {
933            return CallbackSlot::new(self.callback_slot_count() + index);
934        }
935        let slot = CallbackSlot::new(*next_callback_slot);
936        let _ = current_callbacks
937            .push(copy_str(id).expect("callback ID already fits metadata string capacity"));
938        *next_callback_slot += 1;
939        slot
940    }
941
942    fn callback_slot_for_id(&self, id: &str) -> Option<CallbackSlot> {
943        let mut seen = Vec::<&str, MAX_CALLBACKS>::new();
944        for entity in &self.entities {
945            for callback_id in entity_callback_ids(entity) {
946                let Some(callback_id) = callback_id else {
947                    continue;
948                };
949                let callback_id = callback_id.as_str();
950                if seen.contains(&callback_id) {
951                    continue;
952                }
953                if callback_id == id {
954                    return Some(CallbackSlot::new(seen.len()));
955                }
956                let _ = seen.push(callback_id);
957            }
958        }
959        None
960    }
961
962    fn callback_slot_count(&self) -> usize {
963        let mut seen = Vec::<&str, MAX_CALLBACKS>::new();
964        for entity in &self.entities {
965            for callback_id in entity_callback_ids(entity) {
966                let Some(callback_id) = callback_id else {
967                    continue;
968                };
969                let callback_id = callback_id.as_str();
970                if !seen.contains(&callback_id) {
971                    let _ = seen.push(callback_id);
972                }
973            }
974        }
975        seen.len()
976    }
977
978    #[cfg(feature = "alloc")]
979    fn write_nodes_json(&self, out: &mut impl core::fmt::Write) -> core::fmt::Result {
980        write!(out, "\"nodes\":[")?;
981        for (index, node) in self.nodes.iter().enumerate() {
982            if index > 0 {
983                out.write_char(',')?;
984            }
985            write!(out, "{{")?;
986            write_json_field(out, "id", node.id.as_str())?;
987            out.write_char(',')?;
988            write!(out, "\"declaration_slot\":{},", node.slot.index())?;
989            write_json_field(
990                out,
991                "source_default_name",
992                node.source_default_name.as_str(),
993            )?;
994            out.write_char(',')?;
995            write!(out, "\"unresolved_name\":")?;
996            write_source_name(
997                out,
998                node.name.as_str(),
999                SourceNameKind::from_source_name(&node.name),
1000            )?;
1001            out.write_char(',')?;
1002            if node.namespace.as_str() == "/" {
1003                write!(out, "\"namespace\":null,")?;
1004            } else {
1005                write_json_field(out, "namespace", node.namespace.as_str())?;
1006                out.write_char(',')?;
1007            }
1008            self.write_node_entities(out, node.id.as_str())?;
1009            write!(out, "}}")?;
1010        }
1011        write!(out, "]")
1012    }
1013
1014    #[cfg(feature = "alloc")]
1015    fn write_node_entities(
1016        &self,
1017        out: &mut impl core::fmt::Write,
1018        node_id: &str,
1019    ) -> core::fmt::Result {
1020        self.write_entity_array(out, "publishers", node_id, EntityKind::Publisher)?;
1021        out.write_char(',')?;
1022        self.write_entity_array(out, "subscribers", node_id, EntityKind::Subscription)?;
1023        out.write_char(',')?;
1024        // phase-463 W1 -- guard conditions ride in `timers[]` with their own
1025        // `kind` (see `TimerKind::GuardCondition`), so `sidecar_slots` and the
1026        // leaf entity env keep counting one slot each.
1027        self.write_entity_array(out, "timers", node_id, EntityKind::Timer)?;
1028        out.write_char(',')?;
1029        self.write_entity_array(out, "services", node_id, EntityKind::ServiceServer)?;
1030        out.write_char(',')?;
1031        self.write_entity_array(out, "actions", node_id, EntityKind::ActionServer)?;
1032        // issue 0900 — the CLIENT halves. `record_entity` has always captured
1033        // them (`EntityKind::{ActionClient,ServiceClient}`, set in node.rs) and
1034        // this writer dropped them on the way out, so a sidecar described only
1035        // what a component SERVES. The executor arena is sized from the client
1036        // count, which is why the omission had a size cost and not just a
1037        // descriptive one.
1038        out.write_char(',')?;
1039        self.write_entity_array(out, "action_clients", node_id, EntityKind::ActionClient)?;
1040        out.write_char(',')?;
1041        self.write_entity_array(out, "service_clients", node_id, EntityKind::ServiceClient)
1042    }
1043
1044    #[cfg(feature = "alloc")]
1045    fn write_entity_array(
1046        &self,
1047        out: &mut impl core::fmt::Write,
1048        field: &str,
1049        node_id: &str,
1050        kind: EntityKind,
1051    ) -> core::fmt::Result {
1052        write!(out, "\"{}\":[", field)?;
1053        for (index, entity) in self
1054            .entities
1055            .iter()
1056            .filter(|entity| entity.node_id.as_str() == node_id && entity.kind == kind)
1057            .enumerate()
1058        {
1059            if index > 0 {
1060                out.write_char(',')?;
1061            }
1062            match kind {
1063                EntityKind::Publisher => write_publisher_json(out, entity)?,
1064                EntityKind::Subscription => write_subscriber_json(out, entity)?,
1065                EntityKind::Timer => write_timer_json(out, entity)?,
1066                EntityKind::ServiceServer => write_service_json(out, entity)?,
1067                EntityKind::ActionServer => write_action_json(out, entity)?,
1068                // issue 0900 — a client registers no callbacks, so it needs
1069                // none of the server writers' callback fields. One writer
1070                // serves both client kinds.
1071                EntityKind::ActionClient | EntityKind::ServiceClient => {
1072                    write_client_json(out, entity)?
1073                }
1074                _ => {}
1075            }
1076        }
1077        write!(out, "]")
1078    }
1079
1080    #[cfg(feature = "alloc")]
1081    fn write_callbacks_json(&self, out: &mut impl core::fmt::Write) -> core::fmt::Result {
1082        let callbacks = self.source_callbacks();
1083        write!(out, "\"callbacks\":[")?;
1084        for (index, callback) in callbacks.iter().enumerate() {
1085            if index > 0 {
1086                out.write_char(',')?;
1087            }
1088            write!(out, "{{")?;
1089            write_json_field(out, "id", callback.id.as_str())?;
1090            out.write_char(',')?;
1091            if let Some(slot) = callback.slot {
1092                write!(out, "\"declaration_slot\":{},", slot.index())?;
1093            }
1094            write_json_field(out, "kind", callback.kind)?;
1095            out.write_char(',')?;
1096            if let Some(group) = callback.group.as_ref() {
1097                write_json_field(out, "group", group)?;
1098                out.write_char(',')?;
1099            } else {
1100                write!(out, "\"group\":null,")?;
1101            }
1102            write!(out, "\"effects\":[")?;
1103            for (effect_index, effect) in self
1104                .callback_effects
1105                .iter()
1106                .filter(|effect| effect.callback_id.as_str() == callback.id)
1107                .enumerate()
1108            {
1109                if effect_index > 0 {
1110                    out.write_char(',')?;
1111                }
1112                write!(out, "{{")?;
1113                write_json_field(out, "kind", effect_json_kind(effect.kind))?;
1114                out.write_char(',')?;
1115                write_json_field(out, "entity", effect.entity_id.as_str())?;
1116                if let Some(entity_slot) = effect.entity_slot {
1117                    write!(out, ",\"entity_slot\":{}", entity_slot.index())?;
1118                }
1119                write!(out, "}}")?;
1120            }
1121            write!(out, "],")?;
1122            write_source_location(out, &callback.source)?;
1123            write!(out, "}}")?;
1124        }
1125        write!(out, "]")
1126    }
1127
1128    #[cfg(feature = "alloc")]
1129    fn write_parameters_json(&self, out: &mut impl core::fmt::Write) -> core::fmt::Result {
1130        write!(out, "\"parameters\":[")?;
1131        for (index, entity) in self
1132            .entities
1133            .iter()
1134            .filter(|entity| entity.kind == EntityKind::Parameter)
1135            .enumerate()
1136        {
1137            if index > 0 {
1138                out.write_char(',')?;
1139            }
1140            write!(out, "{{")?;
1141            write_json_field(out, "node", entity.node_id.as_str())?;
1142            out.write_char(',')?;
1143            if let Some(slot) = entity.slot {
1144                write!(out, "\"declaration_slot\":{},", slot.index())?;
1145            }
1146            write_json_field(out, "name", entity.source_name.as_str())?;
1147            out.write_char(',')?;
1148            // phase-463 W1 (schema v2) -- the declared type, so a census can
1149            // compare it with the contract's without inferring it from the
1150            // default's JSON shape (an integer default and a double default of
1151            // `0` are indistinguishable there).
1152            let parameter_type = entity.parameter_type.unwrap_or_else(|| {
1153                entity
1154                    .parameter_default
1155                    .as_ref()
1156                    .map(ParameterDefault::parameter_type)
1157                    .unwrap_or(ParameterType::NotSet)
1158            });
1159            write_json_field(out, "type", parameter_type_json(parameter_type))?;
1160            out.write_char(',')?;
1161            write!(out, "\"default\":")?;
1162            write_parameter_default(out, entity.parameter_default.as_ref())?;
1163            out.write_char(',')?;
1164            write!(out, "\"read_only\":{},", entity.parameter_read_only)?;
1165            write_source_location(out, &entity.source)?;
1166            write!(out, "}}")?;
1167        }
1168        write!(out, "]")
1169    }
1170
1171    #[cfg(feature = "alloc")]
1172    fn write_trace_json(
1173        &self,
1174        export: &SourceMetadataExport<'_>,
1175        out: &mut impl core::fmt::Write,
1176    ) -> core::fmt::Result {
1177        write!(out, "\"trace\":{{")?;
1178        write_json_field(out, "generator", "nros-metadata-rust")?;
1179        out.write_char(',')?;
1180        write_json_field(out, "package_manifest", export.package_manifest)?;
1181        out.write_char(',')?;
1182        write!(out, "\"source_artifacts\":[")?;
1183        for (index, artifact) in export.source_artifacts.iter().enumerate() {
1184            if index > 0 {
1185                out.write_char(',')?;
1186            }
1187            write_json_string(out, artifact)?;
1188        }
1189        write!(out, "]}}")
1190    }
1191
1192    #[cfg(feature = "alloc")]
1193    fn source_callbacks(&self) -> StdVec<SourceCallbackRef> {
1194        let mut callbacks = StdVec::new();
1195        for entity in &self.entities {
1196            let Some(callback_id) = entity.callback_id.as_ref() else {
1197                continue;
1198            };
1199            let kind = match entity.kind {
1200                EntityKind::Subscription => "subscription",
1201                EntityKind::Timer if entity.timer_kind == TimerKind::GuardCondition => {
1202                    "guard_condition"
1203                }
1204                EntityKind::Timer => "timer",
1205                EntityKind::ServiceServer => "service",
1206                EntityKind::ActionServer => "action_goal",
1207                _ => continue,
1208            };
1209            if !callbacks
1210                .iter()
1211                .any(|callback: &SourceCallbackRef| callback.id == callback_id.as_str())
1212            {
1213                callbacks.push(SourceCallbackRef {
1214                    id: callback_id.as_str().into(),
1215                    slot: entity.callback_slot,
1216                    kind,
1217                    source: entity.callback_source.clone(),
1218                    group: entity
1219                        .callback_group
1220                        .as_ref()
1221                        .map(|group| group.as_str().into()),
1222                });
1223            }
1224            if entity.kind == EntityKind::ActionServer {
1225                if let Some(cancel_id) = entity.action_cancel_callback_id.as_ref()
1226                    && !callbacks
1227                        .iter()
1228                        .any(|callback: &SourceCallbackRef| callback.id == cancel_id.as_str())
1229                {
1230                    callbacks.push(SourceCallbackRef {
1231                        id: cancel_id.as_str().into(),
1232                        slot: entity.action_cancel_callback_slot,
1233                        kind: "action_cancel",
1234                        source: entity.action_cancel_source.clone(),
1235                        group: entity
1236                            .callback_group
1237                            .as_ref()
1238                            .map(|group| group.as_str().into()),
1239                    });
1240                }
1241                if let Some(accepted_id) = entity.action_accepted_callback_id.as_ref()
1242                    && !callbacks
1243                        .iter()
1244                        .any(|callback: &SourceCallbackRef| callback.id == accepted_id.as_str())
1245                {
1246                    callbacks.push(SourceCallbackRef {
1247                        id: accepted_id.as_str().into(),
1248                        slot: entity.action_accepted_callback_slot,
1249                        kind: "action_accepted",
1250                        source: entity.action_accepted_source.clone(),
1251                        group: entity
1252                            .callback_group
1253                            .as_ref()
1254                            .map(|group| group.as_str().into()),
1255                    });
1256                }
1257            }
1258        }
1259        callbacks
1260    }
1261}
1262
1263#[cfg(feature = "alloc")]
1264struct SourceCallbackRef {
1265    id: StdString,
1266    slot: Option<CallbackSlot>,
1267    kind: &'static str,
1268    source: SourceLocationMetadata,
1269    group: Option<StdString>,
1270}
1271
1272pub(crate) fn entity_callback_ids(entity: &EntityMetadata) -> [Option<&MetadataString>; 3] {
1273    [
1274        entity.callback_id.as_ref(),
1275        entity.action_cancel_callback_id.as_ref(),
1276        entity.action_accepted_callback_id.as_ref(),
1277    ]
1278}
1279
1280/// Inputs for [`entity_metadata`]. Collapses the seven positional
1281/// arguments — three of them adjacent `&str` (`source_name` /
1282/// `type_name` / `type_hash`) that are trivially transposable at a
1283/// call site — into one named-field struct.
1284/// phase-308 — `pub` alongside [`MetadataRecorder::push_entity`]: metadata-mode
1285/// adapters for other languages build entities through this ONE constructor so
1286/// the defaults (and therefore the recorded shape) cannot diverge per language.
1287pub struct EntityMetadataSpec<'a> {
1288    pub id: EntityId<'a>,
1289    pub node_id: NodeId<'a>,
1290    pub kind: EntityKind,
1291    pub source_name: &'a str,
1292    pub type_name: &'static str,
1293    pub type_hash: &'static str,
1294    pub qos: QoSProfile,
1295}
1296
1297/// Build an [`EntityMetadata`] from its identifying fields, defaulting the
1298/// rest. See [`EntityMetadataSpec`] for why this is public.
1299pub fn entity_metadata(spec: EntityMetadataSpec<'_>) -> Result<EntityMetadata, NodeMetadataError> {
1300    let EntityMetadataSpec {
1301        id,
1302        node_id,
1303        kind,
1304        source_name,
1305        type_name,
1306        type_hash,
1307        qos,
1308    } = spec;
1309    Ok(EntityMetadata {
1310        slot: None,
1311        id: copy_str(id.as_str())?,
1312        node_slot: None,
1313        node_id: copy_str(node_id.as_str())?,
1314        kind,
1315        source_name: copy_str(source_name)?,
1316        source_name_kind: SourceNameKind::from_source_name(source_name),
1317        type_name,
1318        type_hash,
1319        action_hashes: None,
1320        qos,
1321        callback_slot: None,
1322        callback_id: None,
1323        callback_source: SourceLocationMetadata::empty(),
1324        callback_group: None,
1325        action_cancel_callback_slot: None,
1326        action_cancel_callback_id: None,
1327        action_cancel_source: SourceLocationMetadata::empty(),
1328        action_accepted_callback_slot: None,
1329        action_accepted_callback_id: None,
1330        action_accepted_source: SourceLocationMetadata::empty(),
1331        period_ms: None,
1332        period_us: None,
1333        timer_clock: TimerClockSource::Steady,
1334        timer_kind: TimerKind::Wall,
1335        parameter_type: None,
1336        parameter_default: None,
1337        parameter_read_only: false,
1338        safety: false,
1339        // phase-457 W3 -- "nobody observed this". Set later, and only for a
1340        // subscription, by `metadata_mode::record_subscription_registration`.
1341        in_place_capable: None,
1342        buffered_row: None,
1343        source: SourceLocationMetadata::empty(),
1344    })
1345}
1346
1347#[cfg(feature = "alloc")]
1348fn write_publisher_json(
1349    out: &mut impl core::fmt::Write,
1350    entity: &EntityMetadata,
1351) -> core::fmt::Result {
1352    write!(out, "{{")?;
1353    write_json_field(out, "id", entity.id.as_str())?;
1354    out.write_char(',')?;
1355    if let Some(slot) = entity.slot {
1356        write!(out, "\"declaration_slot\":{},", slot.index())?;
1357    }
1358    write!(out, "\"unresolved_topic\":")?;
1359    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
1360    out.write_char(',')?;
1361    write_interface(out, entity.type_name, "message")?;
1362    out.write_char(',')?;
1363    write_qos(out, entity.qos)?;
1364    write!(out, "}}")
1365}
1366
1367#[cfg(feature = "alloc")]
1368fn write_subscriber_json(
1369    out: &mut impl core::fmt::Write,
1370    entity: &EntityMetadata,
1371) -> core::fmt::Result {
1372    write!(out, "{{")?;
1373    write_json_field(out, "id", entity.id.as_str())?;
1374    out.write_char(',')?;
1375    if let Some(slot) = entity.slot {
1376        write!(out, "\"declaration_slot\":{},", slot.index())?;
1377    }
1378    write!(out, "\"unresolved_topic\":")?;
1379    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
1380    out.write_char(',')?;
1381    write_interface(out, entity.type_name, "message")?;
1382    out.write_char(',')?;
1383    write_qos(out, entity.qos)?;
1384    out.write_char(',')?;
1385    write_json_field(
1386        out,
1387        "callback",
1388        entity
1389            .callback_id
1390            .as_ref()
1391            .map(|id| id.as_str())
1392            .unwrap_or(""),
1393    )?;
1394    if let Some(callback_slot) = entity.callback_slot {
1395        write!(out, ",\"callback_slot\":{}", callback_slot.index())?;
1396    }
1397    // phase-457 W3 (schema v3) -- the CALL SITE's half of this subscription's
1398    // registration path, when something observed it. Written only when known:
1399    // an absent key is "nobody observed this", which every consumer must read
1400    // as a refusal rather than as `false` (see `in_place_capable`).
1401    if let Some(in_place) = entity.in_place_capable {
1402        write!(out, ",\"in_place\":{in_place}")?;
1403    }
1404    // Issue 1648 (schema v4) -- the buffered row the same registration
1405    // claimed; absent exactly when `in_place` is.
1406    if let Some(row) = entity.buffered_row {
1407        write!(out, ",\"buffered\":\"{}\"", row.as_str())?;
1408    }
1409    write!(out, "}}")
1410}
1411
1412#[cfg(feature = "alloc")]
1413fn write_timer_json(out: &mut impl core::fmt::Write, entity: &EntityMetadata) -> core::fmt::Result {
1414    write!(out, "{{")?;
1415    write_json_field(out, "id", entity.id.as_str())?;
1416    out.write_char(',')?;
1417    if let Some(slot) = entity.slot {
1418        write!(out, "\"declaration_slot\":{},", slot.index())?;
1419    }
1420    // phase-463 W1 (schema v2) -- the row's kind. A guard condition is a row
1421    // of this array (one callback slot, like a timer) whose period is
1422    // meaningless; the kind is what tells a reader so.
1423    write_json_field(out, "kind", timer_kind_json(entity.timer_kind))?;
1424    out.write_char(',')?;
1425    write!(out, "\"period_ms\":{},", entity.period_ms.unwrap_or(0))?;
1426    write!(out, "\"period_us\":{},", entity.period_us.unwrap_or(0))?;
1427    write_json_field(
1428        out,
1429        "callback",
1430        entity
1431            .callback_id
1432            .as_ref()
1433            .map(|id| id.as_str())
1434            .unwrap_or(""),
1435    )?;
1436    if let Some(callback_slot) = entity.callback_slot {
1437        write!(out, ",\"callback_slot\":{}", callback_slot.index())?;
1438    }
1439    write!(out, "}}")
1440}
1441
1442#[cfg(feature = "alloc")]
1443fn write_service_json(
1444    out: &mut impl core::fmt::Write,
1445    entity: &EntityMetadata,
1446) -> core::fmt::Result {
1447    write!(out, "{{")?;
1448    write_json_field(out, "id", entity.id.as_str())?;
1449    out.write_char(',')?;
1450    if let Some(slot) = entity.slot {
1451        write!(out, "\"declaration_slot\":{},", slot.index())?;
1452    }
1453    write!(out, "\"unresolved_name\":")?;
1454    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
1455    out.write_char(',')?;
1456    write_interface(out, entity.type_name, "service")?;
1457    out.write_char(',')?;
1458    // phase-463 W1 (schema v2) -- every endpoint kind carries its QoS.
1459    write_qos(out, entity.qos)?;
1460    out.write_char(',')?;
1461    write_json_field(
1462        out,
1463        "callback",
1464        entity
1465            .callback_id
1466            .as_ref()
1467            .map(|id| id.as_str())
1468            .unwrap_or(""),
1469    )?;
1470    if let Some(callback_slot) = entity.callback_slot {
1471        write!(out, ",\"callback_slot\":{}", callback_slot.index())?;
1472    }
1473    write!(out, "}}")
1474}
1475
1476/// issue 0900 — a client entity: identity, name and interface, no callbacks.
1477///
1478/// Deliberately ONE writer for both `ActionClient` and `ServiceClient`. They
1479/// differ only in the interface KIND word, and the server-side pair is already
1480/// two near-identical functions; a third and fourth would be the second
1481/// spelling this repo keeps paying for.
1482#[cfg(feature = "alloc")]
1483fn write_client_json(
1484    out: &mut impl core::fmt::Write,
1485    entity: &EntityMetadata,
1486) -> core::fmt::Result {
1487    let interface_kind = if entity.kind == EntityKind::ActionClient {
1488        "action"
1489    } else {
1490        "service"
1491    };
1492    write!(out, "{{")?;
1493    write_json_field(out, "id", entity.id.as_str())?;
1494    out.write_char(',')?;
1495    if let Some(slot) = entity.slot {
1496        write!(out, "\"declaration_slot\":{},", slot.index())?;
1497    }
1498    write!(out, "\"unresolved_name\":")?;
1499    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
1500    out.write_char(',')?;
1501    write_interface(out, entity.type_name, interface_kind)?;
1502    out.write_char(',')?;
1503    // phase-463 W1 (schema v2) -- every endpoint kind carries its QoS.
1504    write_qos(out, entity.qos)?;
1505    write!(out, "}}")
1506}
1507
1508#[cfg(feature = "alloc")]
1509fn write_action_json(
1510    out: &mut impl core::fmt::Write,
1511    entity: &EntityMetadata,
1512) -> core::fmt::Result {
1513    let goal_callback = entity
1514        .callback_id
1515        .as_ref()
1516        .map(|id| id.as_str())
1517        .unwrap_or("");
1518    let cancel_callback = entity
1519        .action_cancel_callback_id
1520        .as_ref()
1521        .map(|id| id.as_str())
1522        .unwrap_or(goal_callback);
1523    let accepted_callback = entity
1524        .action_accepted_callback_id
1525        .as_ref()
1526        .map(|id| id.as_str())
1527        .unwrap_or(goal_callback);
1528    write!(out, "{{")?;
1529    write_json_field(out, "id", entity.id.as_str())?;
1530    out.write_char(',')?;
1531    if let Some(slot) = entity.slot {
1532        write!(out, "\"declaration_slot\":{},", slot.index())?;
1533    }
1534    write!(out, "\"unresolved_name\":")?;
1535    write_source_name(out, entity.source_name.as_str(), entity.source_name_kind)?;
1536    out.write_char(',')?;
1537    write_interface(out, entity.type_name, "action")?;
1538    out.write_char(',')?;
1539    // phase-463 W1 (schema v2) -- every endpoint kind carries its QoS.
1540    write_qos(out, entity.qos)?;
1541    out.write_char(',')?;
1542    write_json_field(out, "goal_callback", goal_callback)?;
1543    if let Some(callback_slot) = entity.callback_slot {
1544        write!(out, ",\"goal_callback_slot\":{}", callback_slot.index())?;
1545    }
1546    out.write_char(',')?;
1547    write_json_field(out, "cancel_callback", cancel_callback)?;
1548    if let Some(callback_slot) = entity.action_cancel_callback_slot {
1549        write!(out, ",\"cancel_callback_slot\":{}", callback_slot.index())?;
1550    }
1551    out.write_char(',')?;
1552    write_json_field(out, "accepted_callback", accepted_callback)?;
1553    if let Some(callback_slot) = entity.action_accepted_callback_slot {
1554        write!(out, ",\"accepted_callback_slot\":{}", callback_slot.index())?;
1555    }
1556    write!(out, "}}")
1557}
1558
1559#[cfg(feature = "alloc")]
1560fn write_source_name(
1561    out: &mut impl core::fmt::Write,
1562    value: &str,
1563    kind: SourceNameKind,
1564) -> core::fmt::Result {
1565    write!(out, "{{")?;
1566    write_json_field(out, "value", value)?;
1567    out.write_char(',')?;
1568    write_json_field(out, "kind", source_name_kind_json(kind))?;
1569    write!(out, "}}")
1570}
1571
1572#[cfg(feature = "alloc")]
1573fn write_interface(
1574    out: &mut impl core::fmt::Write,
1575    type_name: &str,
1576    fallback_kind: &'static str,
1577) -> core::fmt::Result {
1578    let interface = parse_interface(type_name, fallback_kind);
1579    write!(out, "\"interface\":{{")?;
1580    write_json_field(out, "package", &interface.package)?;
1581    out.write_char(',')?;
1582    write_json_field(out, "name", &interface.name)?;
1583    out.write_char(',')?;
1584    write_json_field(out, "kind", interface.kind)?;
1585    write!(out, "}}")
1586}
1587
1588#[cfg(feature = "alloc")]
1589fn write_qos(out: &mut impl core::fmt::Write, qos: QoSProfile) -> core::fmt::Result {
1590    write!(out, "\"qos\":{{")?;
1591    write_json_field(out, "reliability", reliability_json(qos.reliability))?;
1592    out.write_char(',')?;
1593    write_json_field(out, "durability", durability_json(qos.durability))?;
1594    out.write_char(',')?;
1595    write_json_field(out, "history", history_json(qos.history))?;
1596    out.write_char(',')?;
1597    write!(out, "\"depth\":{},", qos.depth)?;
1598    write_optional_ms(out, "deadline_ms", qos.deadline_ms)?;
1599    out.write_char(',')?;
1600    write_optional_ms(out, "lifespan_ms", qos.lifespan_ms)?;
1601    out.write_char(',')?;
1602    write_json_field(out, "liveliness", liveliness_json(qos.liveliness_kind))?;
1603    out.write_char(',')?;
1604    write_optional_ms(out, "liveliness_lease_duration_ms", qos.liveliness_lease_ms)?;
1605    write!(out, ",\"extensions\":{{}}}}")
1606}
1607
1608#[cfg(feature = "alloc")]
1609fn write_source_location(
1610    out: &mut impl core::fmt::Write,
1611    source: &SourceLocationMetadata,
1612) -> core::fmt::Result {
1613    write!(out, "\"source\":{{")?;
1614    write_json_field(out, "artifact", source.artifact.as_str())?;
1615    out.write_char(',')?;
1616    write!(out, "\"line\":")?;
1617    write_optional_u32(out, source.line)?;
1618    out.write_char(',')?;
1619    write!(out, "\"column\":")?;
1620    write_optional_u32(out, source.column)?;
1621    write!(out, "}}")
1622}
1623
1624#[cfg(feature = "alloc")]
1625fn write_parameter_default(
1626    out: &mut impl core::fmt::Write,
1627    default: Option<&ParameterDefault>,
1628) -> core::fmt::Result {
1629    match default {
1630        Some(ParameterDefault::Bool(value)) => write!(out, "{}", value),
1631        Some(ParameterDefault::Integer(value)) => write!(out, "{}", value),
1632        Some(ParameterDefault::Double(value)) => write!(out, "{}", value.as_str()),
1633        Some(ParameterDefault::String(value)) => write_json_string(out, value.as_str()),
1634        Some(ParameterDefault::BoolArray)
1635        | Some(ParameterDefault::IntegerArray)
1636        | Some(ParameterDefault::DoubleArray)
1637        | Some(ParameterDefault::StringArray)
1638        | None => write!(out, "[]"),
1639    }
1640}
1641
1642#[cfg(feature = "alloc")]
1643fn write_json_field(out: &mut impl core::fmt::Write, name: &str, value: &str) -> core::fmt::Result {
1644    write_json_string(out, name)?;
1645    out.write_char(':')?;
1646    write_json_string(out, value)
1647}
1648
1649#[cfg(feature = "alloc")]
1650fn write_json_opt_field(
1651    out: &mut impl core::fmt::Write,
1652    name: &str,
1653    value: Option<&str>,
1654) -> core::fmt::Result {
1655    write_json_string(out, name)?;
1656    out.write_char(':')?;
1657    if let Some(value) = value {
1658        write_json_string(out, value)
1659    } else {
1660        write!(out, "null")
1661    }
1662}
1663
1664#[cfg(feature = "alloc")]
1665fn write_json_string(out: &mut impl core::fmt::Write, value: &str) -> core::fmt::Result {
1666    out.write_char('"')?;
1667    for ch in value.chars() {
1668        match ch {
1669            '"' => write!(out, "\\\"")?,
1670            '\\' => write!(out, "\\\\")?,
1671            '\n' => write!(out, "\\n")?,
1672            '\r' => write!(out, "\\r")?,
1673            '\t' => write!(out, "\\t")?,
1674            ch if ch.is_control() => write!(out, "\\u{:04x}", ch as u32)?,
1675            ch => out.write_char(ch)?,
1676        }
1677    }
1678    out.write_char('"')
1679}
1680
1681#[cfg(feature = "alloc")]
1682fn write_optional_ms(out: &mut impl core::fmt::Write, name: &str, value: u32) -> core::fmt::Result {
1683    write_json_string(out, name)?;
1684    out.write_char(':')?;
1685    if value == 0 {
1686        write!(out, "null")
1687    } else {
1688        write!(out, "{}", value)
1689    }
1690}
1691
1692#[cfg(feature = "alloc")]
1693fn write_optional_u32(out: &mut impl core::fmt::Write, value: Option<u32>) -> core::fmt::Result {
1694    if let Some(value) = value {
1695        write!(out, "{}", value)
1696    } else {
1697        write!(out, "null")
1698    }
1699}
1700
1701#[cfg(feature = "alloc")]
1702fn source_name_kind_json(kind: SourceNameKind) -> &'static str {
1703    match kind {
1704        SourceNameKind::Absolute => "absolute",
1705        SourceNameKind::Relative => "relative",
1706        SourceNameKind::Private => "private",
1707    }
1708}
1709
1710#[cfg(feature = "alloc")]
1711fn timer_kind_json(kind: TimerKind) -> &'static str {
1712    match kind {
1713        TimerKind::Wall => "wall",
1714        TimerKind::Clock => "clock",
1715        TimerKind::Oneshot => "oneshot",
1716        TimerKind::InGroup => "in_group",
1717        TimerKind::GuardCondition => "guard_condition",
1718    }
1719}
1720
1721/// phase-463 W1 -- the `type` word on a parameter row. The spellings are the
1722/// orchestration IR's (`nros-cli-core/src/orchestration/source_metadata.rs`,
1723/// `SourceParameterType`), snake_case.
1724#[cfg(feature = "alloc")]
1725fn parameter_type_json(value: ParameterType) -> &'static str {
1726    match value {
1727        ParameterType::NotSet => "not_set",
1728        ParameterType::Bool => "bool",
1729        ParameterType::Integer => "integer",
1730        ParameterType::Double => "double",
1731        ParameterType::String => "string",
1732        ParameterType::ByteArray => "byte_array",
1733        ParameterType::BoolArray => "bool_array",
1734        ParameterType::IntegerArray => "integer_array",
1735        ParameterType::DoubleArray => "double_array",
1736        ParameterType::StringArray => "string_array",
1737    }
1738}
1739
1740#[cfg(feature = "alloc")]
1741fn effect_json_kind(kind: CallbackEffectKind) -> &'static str {
1742    match kind {
1743        CallbackEffectKind::Publishes => "publishes",
1744        CallbackEffectKind::Reads => "reads_parameter",
1745        CallbackEffectKind::Writes => "writes_parameter",
1746    }
1747}
1748
1749// issue 0829 — `"system_default"` is the spelling `liveliness_json` below
1750// already uses for the same sentinel, and the one the orchestration IR emits
1751// for an unstated policy (`nros-cli-core/src/orchestration/schema.rs:58-88`,
1752// `planner.rs:1657-1671`). The metadata reports what the node REQUESTED, so a
1753// sentinel must survive to the JSON: printing the resolved value here would
1754// claim the node asked for something it left to the backend.
1755#[cfg(feature = "alloc")]
1756fn reliability_json(value: QoSReliabilityPolicy) -> &'static str {
1757    match value {
1758        QoSReliabilityPolicy::SystemDefault => "system_default",
1759        QoSReliabilityPolicy::Reliable => "reliable",
1760        QoSReliabilityPolicy::BestEffort => "best_effort",
1761        // issue 1437 — a read-back sentinel, which this function's INPUT can
1762        // never be: the metadata reports what a node REQUESTED and
1763        // `validate_against` refuses `UNKNOWN` as a request. Spelled out
1764        // rather than `_` so a new policy value is a decision here.
1765        QoSReliabilityPolicy::Unknown => "unknown",
1766    }
1767}
1768
1769#[cfg(feature = "alloc")]
1770fn durability_json(value: QoSDurabilityPolicy) -> &'static str {
1771    match value {
1772        QoSDurabilityPolicy::SystemDefault => "system_default",
1773        QoSDurabilityPolicy::Volatile => "volatile",
1774        QoSDurabilityPolicy::TransientLocal => "transient_local",
1775        // See `reliability_json`.
1776        QoSDurabilityPolicy::Unknown => "unknown",
1777    }
1778}
1779
1780#[cfg(feature = "alloc")]
1781fn history_json(value: QoSHistoryPolicy) -> &'static str {
1782    match value {
1783        QoSHistoryPolicy::SystemDefault => "system_default",
1784        QoSHistoryPolicy::KeepLast => "keep_last",
1785        QoSHistoryPolicy::KeepAll => "keep_all",
1786        // See `reliability_json`.
1787        QoSHistoryPolicy::Unknown => "unknown",
1788    }
1789}
1790
1791#[cfg(feature = "alloc")]
1792fn liveliness_json(value: QoSLivelinessPolicy) -> &'static str {
1793    match value {
1794        QoSLivelinessPolicy::None => "system_default",
1795        QoSLivelinessPolicy::Automatic => "automatic",
1796        QoSLivelinessPolicy::ManualByTopic => "manual_by_topic",
1797        QoSLivelinessPolicy::ManualByNode => "manual_by_node",
1798        // See `reliability_json`.
1799        QoSLivelinessPolicy::Unknown => "unknown",
1800    }
1801}
1802
1803#[cfg(feature = "alloc")]
1804struct ParsedInterface {
1805    package: StdString,
1806    name: StdString,
1807    kind: &'static str,
1808}
1809
1810#[cfg(feature = "alloc")]
1811fn parse_interface(type_name: &str, fallback_kind: &'static str) -> ParsedInterface {
1812    let parts: StdVec<&str> = type_name.split("::").collect();
1813    if parts.len() >= 4 {
1814        let package = parts[0].into();
1815        let kind = match parts[1] {
1816            "msg" => "message",
1817            "srv" => "service",
1818            "action" => "action",
1819            _ => fallback_kind,
1820        };
1821        let mut type_leaf = parts[3].trim_end_matches('_');
1822        if type_leaf.is_empty() {
1823            type_leaf = parts.last().copied().unwrap_or("");
1824        }
1825        return ParsedInterface {
1826            package,
1827            name: format!("{}/{}", parts[1], type_leaf),
1828            kind,
1829        };
1830    }
1831
1832    ParsedInterface {
1833        package: StdString::new(),
1834        name: type_name.into(),
1835        kind: fallback_kind,
1836    }
1837}
1838
1839#[cfg(test)]
1840mod tests {
1841    /// Issue 0699 — a source path longer than the buffer must RECORD, not fail.
1842    ///
1843    /// `nros sync` died with `Metadata(NameTooLong)` purely because the user's
1844    /// workspace sat ~100 chars deep: rustc emits `Location::file()` absolute
1845    /// for a path dependency, so this field's length is set by where the user
1846    /// keeps their files. The tail is what the CLI keeps anyway.
1847    #[test]
1848    fn a_path_longer_than_the_buffer_keeps_its_tail() {
1849        // A literal, not `format!`: this crate is `no_std` and the test cfg has
1850        // no allocator — the same constraint the buffer itself exists under.
1851        let deep = concat!(
1852            "/deep/nested/nested/nested/nested/nested/nested/nested/nested/",
1853            "nested/nested/nested/nested/nested/nested/nested/nested/nested/",
1854            "ws/build/nros-metadata/metadata-probe/listener/src/lib.rs"
1855        );
1856        assert!(deep.len() > super::METADATA_STRING_CAPACITY);
1857
1858        let got = super::copy_str_keep_tail(deep).expect("must not error on depth");
1859        assert!(got.len() <= super::METADATA_STRING_CAPACITY);
1860        assert!(
1861            got.ends_with("src/lib.rs"),
1862            "the informative tail must survive, got {got:?}"
1863        );
1864        assert!(got.starts_with('…'), "a cut must be visible, got {got:?}");
1865
1866        // A path that fits is untouched — no marker, no loss.
1867        let short = "src/lib.rs";
1868        assert_eq!(super::copy_str_keep_tail(short).unwrap().as_str(), short);
1869    }
1870
1871    use super::*;
1872    use crate::qos;
1873
1874    #[test]
1875    fn source_name_kind_preserves_unresolved_names() {
1876        assert_eq!(
1877            SourceNameKind::from_source_name("/scan"),
1878            SourceNameKind::Absolute
1879        );
1880        assert_eq!(
1881            SourceNameKind::from_source_name("~/scan"),
1882            SourceNameKind::Private
1883        );
1884        assert_eq!(
1885            SourceNameKind::from_source_name("scan"),
1886            SourceNameKind::Relative
1887        );
1888    }
1889
1890    // Phase 305 W3 (issue 0255) — the expansion/remap seam as re-exported here.
1891    // The exhaustive rule matrix lives with the impl (`nros_node::names`);
1892    // these pin the metadata-level contract each `SourceNameKind` maps to.
1893    #[test]
1894    fn expand_name_covers_each_source_name_kind() {
1895        // Absolute: unchanged.
1896        assert_eq!(
1897            expand_name("/scan", "lidar", "/sensing").unwrap().as_str(),
1898            "/scan"
1899        );
1900        // Private: node-FQN prefixed; ns=/ collapses.
1901        assert_eq!(
1902            expand_name("~/scan", "lidar", "/sensing").unwrap().as_str(),
1903            "/sensing/lidar/scan"
1904        );
1905        assert_eq!(
1906            expand_name("~/scan", "lidar", "/").unwrap().as_str(),
1907            "/lidar/scan"
1908        );
1909        // Relative: namespace prefixed; ns=/ collapses.
1910        assert_eq!(
1911            expand_name("scan", "lidar", "/sensing").unwrap().as_str(),
1912            "/sensing/scan"
1913        );
1914        assert_eq!(expand_name("scan", "lidar", "/").unwrap().as_str(), "/scan");
1915    }
1916
1917    #[test]
1918    fn resolve_name_substitutes_first_matching_rule() {
1919        let remaps = [
1920            ("~/scan", "/points_raw"),
1921            ("/sensing/lidar/scan", "/ignored"),
1922        ];
1923        assert_eq!(
1924            resolve_name("~/scan", "lidar", "/sensing", remaps)
1925                .unwrap()
1926                .as_str(),
1927            "/points_raw"
1928        );
1929        // No match → the expansion stands.
1930        assert_eq!(
1931            resolve_name("other", "lidar", "/sensing", [("/x", "/y")])
1932                .unwrap()
1933                .as_str(),
1934            "/sensing/other"
1935        );
1936    }
1937
1938    #[test]
1939    fn recorder_rejects_duplicate_stable_ids() {
1940        let mut recorder = MetadataRecorder::<1, 2, 1>::new();
1941        recorder
1942            .push_node(NodeId::new("node"), "talker", "/", 0)
1943            .unwrap();
1944
1945        let first = entity_metadata(EntityMetadataSpec {
1946            id: EntityId::new("pub"),
1947            node_id: NodeId::new("node"),
1948            kind: EntityKind::Publisher,
1949            source_name: "chatter",
1950            type_name: "std_msgs::msg::dds_::String_",
1951            type_hash: "hash",
1952            qos: qos::DEFAULT,
1953        })
1954        .unwrap();
1955        recorder.push_entity(first.clone()).unwrap();
1956
1957        assert_eq!(
1958            recorder.push_entity(first),
1959            Err(NodeMetadataError::DuplicateId)
1960        );
1961    }
1962
1963    #[test]
1964    fn recorder_rejects_duplicate_nodes_and_unknown_node_entities() {
1965        let mut recorder = MetadataRecorder::<1, 1, 1>::new();
1966        recorder
1967            .push_node(NodeId::new("node"), "talker", "/", 0)
1968            .unwrap();
1969
1970        assert_eq!(
1971            recorder.push_node(NodeId::new("node"), "other", "/", 0),
1972            Err(NodeMetadataError::DuplicateId)
1973        );
1974
1975        let entity = entity_metadata(EntityMetadataSpec {
1976            id: EntityId::new("pub"),
1977            node_id: NodeId::new("missing_node"),
1978            kind: EntityKind::Publisher,
1979            source_name: "chatter",
1980            type_name: "std_msgs::msg::dds_::String_",
1981            type_hash: "hash",
1982            qos: qos::DEFAULT,
1983        })
1984        .unwrap();
1985
1986        assert_eq!(
1987            recorder.push_entity(entity),
1988            Err(NodeMetadataError::UnknownNode)
1989        );
1990    }
1991
1992    #[test]
1993    fn recorder_assigns_slots_and_source_default_names_by_declaration_order() {
1994        let mut recorder = MetadataRecorder::<2, 4, 3>::new();
1995        recorder
1996            .push_node(NodeId::new("node_alpha"), "talker", "/", 0)
1997            .unwrap();
1998        recorder
1999            .push_node(NodeId::new("node_beta"), "listener", "/demo", 42)
2000            .unwrap();
2001
2002        assert_eq!(recorder.nodes()[0].slot, NodeSlot::new(0));
2003        assert_eq!(recorder.nodes()[0].source_default_name.as_str(), "talker");
2004        assert_eq!(recorder.nodes()[1].slot, NodeSlot::new(1));
2005        assert_eq!(recorder.nodes()[1].source_default_name.as_str(), "listener");
2006
2007        recorder
2008            .push_entity(
2009                entity_metadata(EntityMetadataSpec {
2010                    id: EntityId::new("pub_chatter"),
2011                    node_id: NodeId::new("node_alpha"),
2012                    kind: EntityKind::Publisher,
2013                    source_name: "/chatter",
2014                    type_name: "std_msgs::msg::dds_::String_",
2015                    type_hash: "hash",
2016                    qos: qos::DEFAULT,
2017                })
2018                .unwrap(),
2019            )
2020            .unwrap();
2021        let mut subscription = entity_metadata(EntityMetadataSpec {
2022            id: EntityId::new("sub_chatter"),
2023            node_id: NodeId::new("node_beta"),
2024            kind: EntityKind::Subscription,
2025            source_name: "/chatter",
2026            type_name: "std_msgs::msg::dds_::String_",
2027            type_hash: "hash",
2028            qos: qos::DEFAULT,
2029        })
2030        .unwrap();
2031        subscription.callback_id = Some(copy_str("on_message").unwrap());
2032        recorder.push_entity(subscription).unwrap();
2033        let mut timer = entity_metadata(EntityMetadataSpec {
2034            id: EntityId::new("timer_tick"),
2035            node_id: NodeId::new("node_alpha"),
2036            kind: EntityKind::Timer,
2037            source_name: "",
2038            type_name: "",
2039            type_hash: "",
2040            qos: qos::DEFAULT,
2041        })
2042        .unwrap();
2043        timer.callback_id = Some(copy_str("on_tick").unwrap());
2044        recorder.push_entity(timer).unwrap();
2045
2046        assert_eq!(recorder.entities()[0].slot, Some(EntitySlot::new(0)));
2047        assert_eq!(recorder.entities()[0].node_slot, Some(NodeSlot::new(0)));
2048        assert_eq!(recorder.entities()[0].callback_slot, None);
2049        assert_eq!(recorder.entities()[1].slot, Some(EntitySlot::new(1)));
2050        assert_eq!(recorder.entities()[1].node_slot, Some(NodeSlot::new(1)));
2051        assert_eq!(
2052            recorder.entities()[1].callback_slot,
2053            Some(CallbackSlot::new(0))
2054        );
2055        assert_eq!(
2056            recorder.entities()[2].callback_slot,
2057            Some(CallbackSlot::new(1))
2058        );
2059
2060        recorder
2061            .push_callback_effect(
2062                CallbackId::new("on_tick"),
2063                CallbackEffectKind::Publishes,
2064                EntityId::new("pub_chatter"),
2065            )
2066            .unwrap();
2067        assert_eq!(
2068            recorder.callback_effects()[0].callback_slot,
2069            Some(CallbackSlot::new(1))
2070        );
2071        assert_eq!(
2072            recorder.callback_effects()[0].entity_slot,
2073            Some(EntitySlot::new(0))
2074        );
2075    }
2076
2077    #[test]
2078    fn recorder_assigns_distinct_callback_slots_within_one_action_entity() {
2079        let mut recorder = MetadataRecorder::<1, 1, 3>::new();
2080        recorder
2081            .push_node(NodeId::new("node"), "action_node", "/", 0)
2082            .unwrap();
2083        let mut action = entity_metadata(EntityMetadataSpec {
2084            id: EntityId::new("act_count"),
2085            node_id: NodeId::new("node"),
2086            kind: EntityKind::ActionServer,
2087            source_name: "/count",
2088            type_name: "example_interfaces::action::dds_::Fibonacci_",
2089            type_hash: "hash",
2090            qos: qos::DEFAULT,
2091        })
2092        .unwrap();
2093        action.callback_id = Some(copy_str("on_goal").unwrap());
2094        action.action_cancel_callback_id = Some(copy_str("on_cancel").unwrap());
2095        action.action_accepted_callback_id = Some(copy_str("on_accepted").unwrap());
2096
2097        recorder.push_entity(action).unwrap();
2098
2099        assert_eq!(
2100            recorder.entities()[0].callback_slot,
2101            Some(CallbackSlot::new(0))
2102        );
2103        assert_eq!(
2104            recorder.entities()[0].action_cancel_callback_slot,
2105            Some(CallbackSlot::new(1))
2106        );
2107        assert_eq!(
2108            recorder.entities()[0].action_accepted_callback_slot,
2109            Some(CallbackSlot::new(2))
2110        );
2111    }
2112
2113    #[cfg(feature = "alloc")]
2114    #[test]
2115    fn source_metadata_json_uses_agent_a_schema_shape() {
2116        // 7 entity slots, not 5: issue 0900 added the two CLIENT kinds to this
2117        // fixture. The recorder is fixed-capacity by design (`no_std`, static
2118        // storage), so a new entity here is a deliberate widening.
2119        let mut recorder = MetadataRecorder::<1, 7, 1>::new();
2120        recorder
2121            .push_node(NodeId::new("node_talker"), "talker", "/", 0)
2122            .unwrap();
2123        recorder
2124            .push_entity(
2125                entity_metadata(EntityMetadataSpec {
2126                    id: EntityId::new("pub_chatter"),
2127                    node_id: NodeId::new("node_talker"),
2128                    kind: EntityKind::Publisher,
2129                    source_name: "chatter",
2130                    type_name: "std_msgs::msg::dds_::String_",
2131                    type_hash: "hash",
2132                    qos: crate::qos::DEFAULT,
2133                })
2134                .unwrap(),
2135            )
2136            .unwrap();
2137        let mut timer = entity_metadata(EntityMetadataSpec {
2138            id: EntityId::new("timer_publish"),
2139            node_id: NodeId::new("node_talker"),
2140            kind: EntityKind::Timer,
2141            source_name: "",
2142            type_name: "",
2143            type_hash: "",
2144            qos: crate::qos::DEFAULT,
2145        })
2146        .unwrap();
2147        timer.callback_id = Some(copy_str("cb_timer").unwrap());
2148        timer.callback_source = SourceLocationMetadata {
2149            artifact: copy_str("src/talker.rs").unwrap(),
2150            line: Some(42),
2151            column: Some(5),
2152        };
2153        timer.period_ms = Some(100);
2154        recorder.push_entity(timer).unwrap();
2155        let mut param = entity_metadata(EntityMetadataSpec {
2156            id: EntityId::new("param_rate"),
2157            node_id: NodeId::new("node_talker"),
2158            kind: EntityKind::Parameter,
2159            source_name: "rate_hz",
2160            type_name: "",
2161            type_hash: "",
2162            qos: crate::qos::DEFAULT,
2163        })
2164        .unwrap();
2165        param.parameter_type = Some(ParameterType::Integer);
2166        param.parameter_default = Some(ParameterDefault::Integer(10));
2167        param.source = SourceLocationMetadata {
2168            artifact: copy_str("src/talker.rs").unwrap(),
2169            line: Some(25),
2170            column: Some(9),
2171        };
2172        recorder.push_entity(param).unwrap();
2173        let mut action = entity_metadata(EntityMetadataSpec {
2174            id: EntityId::new("act_count"),
2175            node_id: NodeId::new("node_talker"),
2176            kind: EntityKind::ActionServer,
2177            source_name: "~/count",
2178            type_name: "example_interfaces::action::dds_::Fibonacci_",
2179            type_hash: "hash",
2180            qos: crate::qos::DEFAULT,
2181        })
2182        .unwrap();
2183        action.callback_id = Some(copy_str("cb_count_goal").unwrap());
2184        action.callback_source = SourceLocationMetadata {
2185            artifact: copy_str("src/talker.rs").unwrap(),
2186            line: Some(90),
2187            column: Some(5),
2188        };
2189        action.action_cancel_callback_id = Some(copy_str("cb_count_cancel").unwrap());
2190        action.action_cancel_source = SourceLocationMetadata {
2191            artifact: copy_str("src/talker.rs").unwrap(),
2192            line: Some(96),
2193            column: Some(5),
2194        };
2195        action.action_accepted_callback_id = Some(copy_str("cb_count_accepted").unwrap());
2196        action.action_accepted_source = SourceLocationMetadata {
2197            artifact: copy_str("src/talker.rs").unwrap(),
2198            line: Some(104),
2199            column: Some(5),
2200        };
2201        recorder.push_entity(action).unwrap();
2202        recorder
2203            .push_callback_effect(
2204                CallbackId::new("cb_timer"),
2205                CallbackEffectKind::Publishes,
2206                EntityId::new("pub_chatter"),
2207            )
2208            .unwrap();
2209
2210        // Issue 0900 — one of each client kind, on the node that also SERVES
2211        // (it owns the action server above), so this would catch a writer that
2212        // filtered by node rather than by kind.
2213        recorder
2214            .push_entity(
2215                entity_metadata(EntityMetadataSpec {
2216                    id: EntityId::new("client_fib"),
2217                    node_id: NodeId::new("node_talker"),
2218                    kind: EntityKind::ActionClient,
2219                    source_name: "/fibonacci",
2220                    type_name: "example_interfaces::action::dds_::Fibonacci_",
2221                    type_hash: "hash",
2222                    qos: crate::qos::DEFAULT,
2223                })
2224                .unwrap(),
2225            )
2226            .unwrap();
2227        recorder
2228            .push_entity(
2229                entity_metadata(EntityMetadataSpec {
2230                    id: EntityId::new("client_add"),
2231                    node_id: NodeId::new("node_talker"),
2232                    kind: EntityKind::ServiceClient,
2233                    source_name: "/add_two_ints",
2234                    type_name: "example_interfaces::srv::dds_::AddTwoInts_",
2235                    type_hash: "hash",
2236                    qos: crate::qos::DEFAULT,
2237                })
2238                .unwrap(),
2239            )
2240            .unwrap();
2241
2242        let json = recorder
2243            .to_source_metadata_json(
2244                &SourceMetadataExport::new("demo_nodes_rs", "talker")
2245                    .executable("talker")
2246                    .exported_symbol("nros_node_talker")
2247                    .source_artifacts(&["src/talker.rs"]),
2248            )
2249            .unwrap();
2250
2251        // READ, not restated: the schema is ADDITIVE, so the shape this asserts
2252        // is a claim about the CURRENT version. phase-457 W3 moved it to 3 and
2253        // this literal is what noticed.
2254        assert!(
2255            json.contains(&format!("\"version\":{SOURCE_METADATA_SCHEMA_VERSION}")),
2256            "the current schema version, got {json}"
2257        );
2258        assert!(json.contains("\"language\":\"rust\""));
2259        assert!(json.contains("\"unresolved_name\":{\"value\":\"talker\",\"kind\":\"relative\"}"));
2260        assert!(json.contains(
2261            "\"interface\":{\"package\":\"std_msgs\",\"name\":\"msg/String\",\"kind\":\"message\"}"
2262        ));
2263        assert!(json.contains("\"kind\":\"publishes\",\"entity\":\"pub_chatter\""));
2264        assert!(
2265            json.contains("\"source\":{\"artifact\":\"src/talker.rs\",\"line\":42,\"column\":5}")
2266        );
2267        // v2: the declared type sits between the name and the default.
2268        assert!(
2269            json.contains(
2270                "\"name\":\"rate_hz\",\"type\":\"integer\",\"default\":10,\"read_only\":false"
2271            ),
2272            "got {json}"
2273        );
2274        assert!(json.contains("\"goal_callback\":\"cb_count_goal\""));
2275        assert!(json.contains("\"cancel_callback\":\"cb_count_cancel\""));
2276        assert!(json.contains("\"accepted_callback\":\"cb_count_accepted\""));
2277        assert!(json.contains("\"kind\":\"action_cancel\""));
2278        assert!(json.contains("\"kind\":\"action_accepted\""));
2279
2280        // Issue 0900 — the CLIENT halves must reach the JSON, not just the
2281        // recorder. `record_entity` always captured them and the writer dropped
2282        // them, so the sidecar described only what a component SERVES. The
2283        // executor arena is sized from the client count, so the omission cost
2284        // bytes on a task stack and not merely description.
2285        //
2286        // Asserted on the SERIALISED form rather than on `recorder.entities()`:
2287        // the defect was entirely in the writer, so a test that stops at the
2288        // recorder passes against the broken tree — the vacuous shape this repo
2289        // keeps finding.
2290        assert!(
2291            json.contains("\"action_clients\":[{"),
2292            "an action client must reach the sidecar, got {json}"
2293        );
2294        assert!(
2295            json.contains("\"service_clients\":[{"),
2296            "a service client must reach the sidecar, got {json}"
2297        );
2298        // Scoped to the client ARRAYS, not to the whole document. The first
2299        // version of these asserts searched all of `json` and passed against a
2300        // broken emitter: this fixture's action SERVER carries the same
2301        // Fibonacci interface, so "the string appears somewhere" was true
2302        // whatever `write_client_json` did. A test that cannot fail is the
2303        // shape `check-no-vacuous-tests` exists to catch.
2304        let action_clients = &json[json.find("\"action_clients\":").expect("array present")..];
2305        let action_clients = &action_clients[..action_clients.find(']').unwrap()];
2306        let service_clients = &json[json.find("\"service_clients\":").expect("array present")..];
2307        let service_clients = &service_clients[..service_clients.find(']').unwrap()];
2308
2309        // The interface must be PARSED, not echoed as a Rust path. `write_
2310        // client_json` shares `write_interface` with the server writers, and
2311        // `parse_interface` only splits the 4-segment DDS-mangled form — which
2312        // is what `A::ACTION_NAME` actually supplies. Pinning the parsed shape
2313        // is what would catch a client writer that bypassed that helper.
2314        assert!(
2315            action_clients.contains(
2316                "\"interface\":{\"package\":\"example_interfaces\",\"name\":\"action/Fibonacci\",\"kind\":\"action\"}"
2317            ),
2318            "action client interface must be parsed, got {action_clients}"
2319        );
2320        assert!(
2321            service_clients.contains(
2322                "\"interface\":{\"package\":\"example_interfaces\",\"name\":\"srv/AddTwoInts\",\"kind\":\"service\"}"
2323            ),
2324            "service client interface must be parsed, got {service_clients}"
2325        );
2326        // The KIND word is the only thing `write_client_json` branches on, so
2327        // the two arrays must not agree on it.
2328        assert!(
2329            !action_clients.contains("\"kind\":\"service\"")
2330                && !service_clients.contains("\"kind\":\"action\""),
2331            "the client kind word is what distinguishes the arrays"
2332        );
2333        // A client registers no callbacks, so it must carry none of the server
2334        // writers' callback fields.
2335        assert!(
2336            !action_clients.contains("callback") && !service_clients.contains("callback"),
2337            "a client registers no callback; emitting one would misdescribe it"
2338        );
2339        assert!(json.contains("\"generator\":\"nros-metadata-rust\""));
2340    }
2341}