Skip to main content

nros/
init.rs

1//! Phase 212.L.5 — top-level init API; phase-427 W9 — on every target.
2//!
3//! [`Context`] is WHERE this image is connected — locator, domain, RMW,
4//! session mode, and the source those came from (RFC-0089 "Context and
5//! `init`, settled"). One per process or image; a resolved value, not an
6//! entity on the graph. A `Node` is a named participant with its own
7//! entities, and one image holds several of those on one `Context`
8//! (RFC-0047).
9//!
10//! ## Where the values come from
11//!
12//! * **Hosted** (feature `env`): the process environment (`ROS_DOMAIN_ID`,
13//!   `NROS_LOCATOR`, `NROS_SESSION_MODE`, `NROS_RMW` / `RMW_IMPLEMENTATION`)
14//!   through the one hosted resolver, `crate::env::try_resolve_hosted`. A
15//!   launcher projects per-node values into that environment before exec.
16//! * **Freestanding** (no `env`): there is no process and no environment; the
17//!   same values are BAKED at compile time — `option_env!("NROS_LOCATOR")` and
18//!   `option_env!("NROS_DOMAIN_ID")`, exported into this crate's build by
19//!   `nros_zephyr_build::bake_nros_config()` from Kconfig (`build.rs`), or
20//!   present in the process environment of the build for the other RTOS lanes
21//!   (the board crates read the same two names the same way). The semantics
22//!   survive with the source moved from run time to build time: "the
23//!   environment this image was built for or launched in".
24//!
25//! The `env` feature gates ONLY the process-environment reader and the
26//! argv/launch entry points; `Context`, [`ContextSource`], [`InitError`],
27//! [`InitOptions`] and [`Context::config`] compile on every target. The floor
28//! for `Context` itself is `alloc`: `locator` and `rmw` are owned `String`s
29//! so the value outlives whatever transient produced it (an env cache, a
30//! parsed launch file, a harness-supplied override).
31//!
32//! ## The rclrs family
33//!
34//! [`Context::default_from_env`], [`Context::from_env`] and (hosted only)
35//! [`Context::new`] are the `rclrs::Context` constructors under the same
36//! names; [`InitOptions`] is rclrs's, with the one option it has. [`init()`] is
37//! the C++-symmetric anchor (`rclcpp::init`) and equals `default_from_env()`.
38//!
39//! Two patterns are supported:
40//!
41//! 1. **Node pkg** — register via the [`nros::node!`](crate::node!)
42//!    macro (Phase 172 W.3); the generated runtime owns the spin loop and
43//!    builds its `Context` through [`Context::default_from_env`].
44//! 2. **Application pkg** — call [`init()`] / [`Context::default_from_env`]
45//!    (or [`init_with_args`] / [`Context::new`] for argv-style entry) and
46//!    drive your own spin. The returned [`Context`] carries the domain id,
47//!    locator and RMW choice from the environment — see "Where launch content
48//!    comes from" below.
49//!
50//! To actually open a session, call [`Context::create_executor`] (`alloc`) or
51//! [`Context::create_executor_in`] (caller-supplied backing, nothing leaked),
52//! then name each node with `Executor::create_node`. The older spelling —
53//! materialise an [`crate::ExecutorConfig`] via [`Context::config`] and pass it
54//! to `Executor::open` — still works and is what a single-node entry wants;
55//! phase-427 W10 added the pair because it cannot express the second node.
56//!
57//! ## Where launch content comes from
58//!
59//! Nothing here reads a launch file at run time. A launch tree reaches an
60//! image at BUILD time: `nros sync` resolves it into the image's SystemModel,
61//! and the generated entry (`nros::main!(launch = …)`,
62//! `nano_ros_entry(… LAUNCH …)`) installs its node identities, remaps and
63//! parameters. What is left for init to read is the environment
64//! (`ROS_DOMAIN_ID`, `NROS_LOCATOR`, `NROS_SESSION_MODE`,
65//! `RMW_IMPLEMENTATION`) and, through [`init_with_args`] / [`Context::new`],
66//! per-process `--ros-args`. (Issue 1917 retired the runtime-overlay
67//! placeholder and, with issue 1931, the `init_with_launch*` entry points
68//! named after it.)
69
70#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
71use core::mem::MaybeUninit;
72
73#[cfg(feature = "alloc")]
74use nros_node::DOMAIN_ID_MAX;
75#[cfg(feature = "alloc")]
76use nros_node::ExecutorConfig;
77// phase-427 W10 — the open failure a `create_executor*` call can carry back.
78// Ungated: `NodeError` lives in `nros_node::executor::types`, which is compiled
79// on every target, and [`InitError`] is on every target too (W9).
80use nros_node::NodeError;
81#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
82use nros_node::{Executor, ExecutorSizing};
83#[cfg(feature = "alloc")]
84use nros_rmw::SessionMode;
85
86/// Errors returned by the init API.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum InitError {
89    /// A launch-derived env var (`ROS_DOMAIN_ID`, etc.) failed to parse —
90    /// or, freestanding, the baked `NROS_DOMAIN_ID` is not a decimal integer.
91    EnvParseFailed,
92    /// A domain id — from the environment, the bake, or an
93    /// [`InitOptions::with_domain_id`] override — exceeds
94    /// [`crate::DOMAIN_ID_MAX`]. Rejected here rather than
95    /// handed to a backend that can only fail later (the #206 rule).
96    DomainIdOutOfRange,
97    /// phase-484 W8c — a RETIRED environment variable is set (`ZENOH_LOCATOR`,
98    /// `ZENOH_MODE`). Refused rather than ignored: an ignored locator dials
99    /// the default router, which reads as "no delivery".
100    RetiredEnvVar {
101        /// The retired name the environment still sets.
102        name: &'static str,
103        /// The canonical name to set instead.
104        replacement: &'static str,
105    },
106    /// phase-427 W10 — [`Context::create_executor`] /
107    /// [`Context::create_executor_in`] resolved the identity and the RMW
108    /// session open then failed.
109    ///
110    /// The backend's own [`NodeError`] is CARRIED, not flattened into a flag:
111    /// `Transport(InvalidConfig)` (no backend registered, or more than one and
112    /// no selector) and `Transport(ConnectionFailed)` (the router is not there)
113    /// are different problems with different fixes, and issue 0465 is what
114    /// collapsing them cost the last time.
115    ExecutorOpenFailed(NodeError),
116    /// phase-427 W10 — [`Context::create_executor_in`] was handed a backing
117    /// smaller than the executor's tables need.
118    ///
119    /// Refused rather than accepted: `Executor::open_in` is `unsafe` precisely
120    /// because a short backing is undefined behaviour, so the safe wrapper
121    /// checks the length and names both numbers. `needed` is
122    /// `ExecutorSizing::DEFAULT.u64_len()` for this build — it moves with
123    /// `NROS_EXECUTOR_MAX_CBS` / `_MAX_SC` / `_ARENA_SIZE` / `_MAX_NODES`, so a
124    /// caller sizing a `static` from a literal will hear about it here rather
125    /// than corrupting memory later.
126    BackingTooSmall {
127        /// `u64` words the executor's tables need.
128        needed: usize,
129        /// `u64` words the caller supplied.
130        given: usize,
131    },
132}
133
134impl core::fmt::Display for InitError {
135    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
136        match self {
137            InitError::EnvParseFailed => f.write_str("env var parse failed"),
138            InitError::DomainIdOutOfRange => f.write_str("domain id out of range"),
139            InitError::RetiredEnvVar { name, replacement } => write!(
140                f,
141                "{name} is retired and no longer read; set {replacement} instead (unset {name})"
142            ),
143            InitError::ExecutorOpenFailed(e) => {
144                write!(f, "RMW session open failed: {e:?}")
145            }
146            InitError::BackingTooSmall { needed, given } => write!(
147                f,
148                "executor backing too small: {given} u64 words supplied, {needed} needed"
149            ),
150        }
151    }
152}
153
154// `core::error::Error` — `std::error::Error` is a re-export of it since Rust
155// 1.81, so this is the same trait, and it needs no feature: phase-427 W9 took
156// this module out from behind `env`.
157impl core::error::Error for InitError {}
158
159/// Phase 212.L.5 — resolved init context.
160///
161/// Returned by every `init*` entry point and every `Context::*` constructor.
162/// Carries the fields the user needs to construct an [`ExecutorConfig`] and
163/// open a session.
164///
165/// Fields are owned (`String`) so the `Context` can outlive transient parents
166/// (env caches, parsed launch files, a harness-supplied locator). That is why
167/// the type's feature floor is `alloc`.
168///
169/// ADOPT-BOUNDED against `rclrs::Context`, which OWNS the rcl context. This is
170/// the resolved identity as a plain value, so:
171/// - There is no `ok()`: the value has no shutdown state; liveness is the
172///   executor's.
173/// - `domain_id` is a public field, not a `domain_id()` method.
174/// - Executors are opened FROM it: [`create_executor`](Self::create_executor)
175///   leaks an executor-lifetime backing (needs `alloc`), and
176///   [`create_executor_in`](Self::create_executor_in) takes caller storage.
177#[cfg(feature = "alloc")]
178#[derive(Debug, Clone)]
179pub struct Context {
180    /// ROS 2 domain ID (`ROS_DOMAIN_ID`, default 0; baked `NROS_DOMAIN_ID`
181    /// freestanding).
182    pub domain_id: u32,
183    /// Middleware locator (`NROS_LOCATOR`; baked
184    /// `NROS_LOCATOR` freestanding). Empty means ABSENT: `nros` is
185    /// RMW-agnostic, so the linked backend substitutes its own default
186    /// (issue 0330).
187    pub locator: alloc::string::String,
188    /// Session mode (`NROS_SESSION_MODE`, default `Client`).
189    pub mode: SessionMode,
190    /// RMW implementation hint (`RMW_IMPLEMENTATION` /  `NROS_RMW`).
191    ///
192    /// Empty when neither var is set, and always empty freestanding (an image
193    /// links exactly one backend). The runtime uses this to pick a primary
194    /// backend when multiple are linked; see `crate::internals::open_session`.
195    pub rmw: alloc::string::String,
196    /// Source of this context — useful for diagnostics + tests.
197    pub source: ContextSource,
198    /// The `-r`/`--remap` rules `init_with_args` / `Context::new` parsed
199    /// out of `--ros-args`, in argv order. Installed as the FALLBACK remap
200    /// tier of every executor this context creates
201    /// (`Context::create_executor`, `Context::create_executor_in`).
202    ///
203    /// rcl keeps its global arguments on the context for the same reason: the
204    /// context is what the process was started with, and every node created
205    /// from it inherits them. Private, because nothing outside the parse
206    /// should write a rule here — and a rule that bypassed the parse would
207    /// bypass its refusals too.
208    #[cfg_attr(not(feature = "env"), allow(dead_code))]
209    ros_args: alloc::vec::Vec<ArgvRemap>,
210    /// phase-482 W5 — the `-p` / `--params-file` overrides, in argv order,
211    /// installed in every executor this context creates and applied when a
212    /// node declares the parameter (`Executor::install_argv_params`). Empty
213    /// unless `nros` has `param-services`; without it the parse refuses them.
214    #[cfg_attr(not(feature = "env"), allow(dead_code))]
215    ros_params: alloc::vec::Vec<ArgvParam>,
216}
217
218/// One parameter override, owned — from `-p [node:]name:=value` or a
219/// `--params-file` entry.
220#[cfg(feature = "alloc")]
221#[derive(Debug, Clone, PartialEq, Eq)]
222#[cfg_attr(not(feature = "env"), allow(dead_code))]
223struct ArgvParam {
224    node: Option<alloc::string::String>,
225    name: alloc::string::String,
226    value: alloc::string::String,
227}
228
229/// One `-r [node:]from:=to` rule, owned so a [`Context`] can outlive the
230/// argument vector it came from.
231#[cfg(feature = "alloc")]
232#[derive(Debug, Clone, PartialEq, Eq)]
233#[cfg_attr(not(feature = "env"), allow(dead_code))]
234struct ArgvRemap {
235    node: Option<alloc::string::String>,
236    from: alloc::string::String,
237    to: alloc::string::String,
238}
239
240/// Where the [`Context`] came from. Diagnostics only.
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub enum ContextSource {
243    /// Built from env vars by [`init()`] / [`init_with_args`] /
244    /// [`Context::new`], or by [`Context::default_from_env`] /
245    /// [`Context::from_env`] on a hosted build.
246    Env,
247    /// phase-427 W9 — built from the constants baked into this crate at
248    /// compile time (`NROS_LOCATOR`, `NROS_DOMAIN_ID`) by [`Context::baked`],
249    /// which is what [`Context::default_from_env`] / [`Context::from_env`]
250    /// resolve to on a freestanding build. The entry macros
251    /// (`nros::main!`'s Zephyr arm, `nros::zephyr_component_main!`) produce
252    /// this one.
253    Baked,
254}
255
256/// phase-427 W9 — `rclrs::InitOptions`, with the one option it has.
257///
258/// Consumed by [`Context::from_env`] and [`Context::new`]; `None` for the
259/// domain keeps whatever the environment (hosted) or the bake (freestanding)
260/// resolved.
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
262pub struct InitOptions {
263    domain_id: Option<u32>,
264}
265
266impl InitOptions {
267    /// Default options: nothing overridden.
268    pub const fn new() -> Self {
269        Self { domain_id: None }
270    }
271
272    /// Override the domain id (`Some`) or keep the resolved one (`None`).
273    ///
274    /// `usize` because that is rclrs's signature. A value above `u32::MAX`
275    /// cannot be a domain id at all; it saturates here and the constructor
276    /// that consumes the options then rejects it as
277    /// [`InitError::DomainIdOutOfRange`], which is the loud place — a builder
278    /// method has no `Result` to carry the refusal in.
279    pub const fn with_domain_id(mut self, domain_id: Option<usize>) -> Self {
280        self.domain_id = match domain_id {
281            Some(d) if d > u32::MAX as usize => Some(u32::MAX),
282            Some(d) => Some(d as u32),
283            None => None,
284        };
285        self
286    }
287
288    /// The in-place twin of [`with_domain_id`](Self::with_domain_id) — rclrs
289    /// has both, and a ported `opts.set_domain_id(Some(7))` compiles as
290    /// written. Same saturation.
291    pub fn set_domain_id(&mut self, domain_id: Option<usize>) {
292        *self = self.with_domain_id(domain_id);
293    }
294
295    /// The domain override, if any.
296    ///
297    /// `Option<usize>`, which is rclrs's signature and the same reason
298    /// [`with_domain_id`](Self::with_domain_id) takes one. phase-467: this
299    /// returned `Option<u32>` — the STORED width — so the two setters spoke
300    /// rclrs and the getter spoke the field, and a ported
301    /// `let d: Option<usize> = opts.domain_id();` failed on a type nobody
302    /// chose. The value is still stored as a `u32` and `with_domain_id`'s
303    /// saturation still applies, so a round trip through `u32::MAX + 1`
304    /// reports `u32::MAX`; the refusal stays on the consuming constructor
305    /// ([`InitError::DomainIdOutOfRange`]), which is where a `Result` exists
306    /// to carry it.
307    pub const fn domain_id(&self) -> Option<usize> {
308        match self.domain_id {
309            Some(d) => Some(d as usize),
310            None => None,
311        }
312    }
313}
314
315#[cfg(feature = "alloc")]
316/// The range check every constructor applies to a domain id, whatever its
317/// source (env, bake, override): the same bound `try_resolve_hosted` enforces
318/// ("INCLUDING a baked one"), so a freestanding image cannot boot on a domain
319/// a hosted process would have refused.
320fn check_domain_id(domain_id: u32) -> Result<u32, InitError> {
321    if domain_id > DOMAIN_ID_MAX {
322        Err(InitError::DomainIdOutOfRange)
323    } else {
324        Ok(domain_id)
325    }
326}
327
328#[cfg(feature = "alloc")]
329impl Context {
330    /// `rclrs::Context::domain_id()` — the resolved ROS domain.
331    ///
332    /// `usize`, because that is rclrs's return type (0.7.0,
333    /// `rclrs/src/context.rs`) and this name exists so a ported
334    /// `ctx.domain_id()` compiles. The field stays `pub` and stays `u32`:
335    /// `Context` is a plain value with nothing to hide, `u32` is the width
336    /// every producer and consumer of a domain in this tree speaks
337    /// (`BoardConfig::domain_id`, `ExecutorConfig::domain_id`,
338    /// `NodeHandle::domain_id`, the `NROS_DOMAIN_ID` bake), and fields and
339    /// methods are in different namespaces in Rust, so the two coexist. The
340    /// widening is lossless on every target here — `usize` is at least 32
341    /// bits on all of them, down to `thumbv7m-none-eabi`.
342    ///
343    /// phase-467 row `rust:Context::domain_id`. The row proposed `-> u32`
344    /// once and that would have been a second, quieter port break; it then
345    /// argued `usize` from consistency with [`InitOptions`], which was only
346    /// two thirds true — the setters took `usize`, the getter returned
347    /// `Option<u32>`. Both getters speak `usize` now, so the argument is the
348    /// one this crate can actually make: it is upstream's type.
349    #[must_use]
350    pub const fn domain_id(&self) -> usize {
351        self.domain_id as usize
352    }
353
354    /// Materialise an [`ExecutorConfig`] for a node with the given name.
355    ///
356    /// The returned config borrows from `self`, so callers usually do:
357    ///
358    /// ```ignore
359    /// let ctx = nros::Context::default_from_env()?;
360    /// let cfg = ctx.config("talker");
361    /// let mut executor = nros::Executor::open(&cfg)?;
362    /// ```
363    ///
364    /// phase-427 W10 — this is the shape where the executor IS the node it was
365    /// configured with, and it stays: an entry that opens exactly one node
366    /// wants exactly this. What it cannot express is the second node, which is
367    /// why `Context::create_executor()` + `Executor::create_node(name)` now sit
368    /// beside it — the session takes no name, and each node takes its own.
369    // phase-427 W10 — the one legitimate remaining use of the deprecated
370    // builder spelling: this method IS the executor-is-the-node shape, so it
371    // asks for it deliberately rather than by not having heard.
372    ///
373    /// # Refused when the context carries `--ros-args` remaps
374    ///
375    /// An [`ExecutorConfig`] has nowhere to put them, so the executor
376    /// `Executor::open` builds from it would never see them — the silent drop
377    /// `init_with_args` exists to prevent. Such a context PANICS here,
378    /// naming `Context::create_executor`, which installs them.
379    #[allow(deprecated)] // the deliberate use named above (phase-427 W10)
380    pub fn config<'a>(&'a self, node_name: &'a str) -> ExecutorConfig<'a> {
381        #[cfg(feature = "env")]
382        if !self.ros_args.is_empty() || !self.ros_params.is_empty() {
383            refuse_ros_args(&RosArgsRefusal::UnreachableExecutor);
384        }
385        ExecutorConfig::new(self.locator.as_str())
386            .node_name(node_name)
387            .domain_id(self.domain_id)
388            .mode(self.mode)
389    }
390
391    /// `rclrs::Context::default_from_env()` — the environment, default
392    /// options.
393    ///
394    /// Hosted (feature `env`) this reads the process environment and is
395    /// exactly [`init()`]; freestanding it is [`Context::baked`], the build
396    /// environment this image was compiled for. RFC-0089 "Context and `init`,
397    /// settled": the semantics survive with the source moved from run time to
398    /// build time, which is the bounded half of this adoption.
399    pub fn default_from_env() -> Result<Context, InitError> {
400        #[cfg(feature = "env")]
401        {
402            init()
403        }
404        #[cfg(not(feature = "env"))]
405        {
406            Self::baked()
407        }
408    }
409
410    /// `rclrs::Context::from_env(options)` — [`default_from_env`](Self::default_from_env)
411    /// with the [`InitOptions`] applied on top: a
412    /// `Some` domain replaces the resolved one, `None` keeps it.
413    ///
414    /// Bounded: [`InitOptions`] carries rclrs's one option, `domain_id`, and no
415    /// other, so an option rclrs adds later is a compile error here. A domain above
416    /// `DOMAIN_ID_MAX` is [`InitError::DomainIdOutOfRange`]. Freestanding, the base
417    /// the options override is the BAKED context ([`Context::baked`]), not a
418    /// process environment.
419    pub fn from_env(options: InitOptions) -> Result<Context, InitError> {
420        let mut ctx = Self::default_from_env()?;
421        if let Some(domain_id) = options.domain_id {
422            ctx.domain_id = check_domain_id(domain_id)?;
423        }
424        Ok(ctx)
425    }
426
427    /// `rclrs::Context::new(args, options)` — the argv constructor. HOSTED
428    /// ONLY: a freestanding image has no argv, so the name is absent there and
429    /// the compiler says so.
430    ///
431    /// **What it does with the arguments:** the same as [`init_with_args`] —
432    /// `-r`/`--remap` rules inside `--ros-args` are parsed onto the returned
433    /// context and every other ROS argument is REFUSED LOUDLY at the call
434    /// ([`REFUSE_INIT_ARGS`]). Arguments outside a `--ros-args` scope are the
435    /// program's own and are left alone.
436    ///
437    /// `S: AsRef<str>` is a superset of rclrs's `Item = String`, so
438    /// `Context::new(std::env::args(), InitOptions::new())` compiles as
439    /// written.
440    #[cfg(feature = "env")]
441    pub fn new<I, S>(args: I, options: InitOptions) -> Result<Context, InitError>
442    where
443        I: IntoIterator<Item = S>,
444        S: AsRef<str>,
445    {
446        let (ros_args, ros_params) = parse_ros_args_or_refuse(args);
447        let mut context = Self::from_env(options)?;
448        context.ros_args = ros_args;
449        context.ros_params = ros_params;
450        Ok(context)
451    }
452
453    /// phase-427 W9 — the freestanding constructor: the constants baked into
454    /// this crate at compile time.
455    ///
456    /// * `NROS_LOCATOR` — the middleware locator. Unset or empty ⇒ empty ⇒
457    ///   the linked backend's own default (issue 0330; for zenoh-pico that is
458    ///   multicast scouting, which native_sim NSOS cannot satisfy — hence the
459    ///   bake).
460    /// * `NROS_DOMAIN_ID` — the domain. Unset ⇒ 0; non-numeric ⇒
461    ///   [`InitError::EnvParseFailed`]; above `DOMAIN_ID_MAX` ⇒
462    ///   [`InitError::DomainIdOutOfRange`].
463    ///
464    /// Both names reach this crate's build the same way they reach the board
465    /// crates: on Zephyr, `build.rs` re-exports `CONFIG_NROS_ZENOH_LOCATOR` /
466    /// `CONFIG_NROS_DOMAIN_ID` from `$DOTCONFIG` (`nros_zephyr_build::
467    /// bake_nros_config`, the issue-0460 channel); on the other RTOS lanes the
468    /// fixture build exports them into the process environment. This is the
469    /// ONE `option_env!` site for the pair — the entry macros used to carry
470    /// their own copies (and `nros::main!`'s Zephyr arm had lost the domain
471    /// half, issue 0161's class), which is what "one source of the baked
472    /// shape" replaces.
473    ///
474    /// Mode is `Client` and `rmw` is empty: an image links exactly one
475    /// backend and nothing bakes a session mode today.
476    ///
477    /// Hosted builds have this too — it answers "what was baked", which a
478    /// hosted process rarely wants; [`Context::default_from_env`] is the
479    /// constructor that picks the right source for the build.
480    pub fn baked() -> Result<Context, InitError> {
481        Self::from_baked(option_env!("NROS_LOCATOR"), option_env!("NROS_DOMAIN_ID"))
482    }
483
484    /// The parse behind [`Context::baked`], with the two constants injected
485    /// so a unit test can exercise every arm without a build environment.
486    /// Public because the macros in `nros-macros` expand in another crate;
487    /// not API.
488    #[doc(hidden)]
489    pub fn from_baked(
490        baked_locator: Option<&str>,
491        baked_domain_id: Option<&str>,
492    ) -> Result<Context, InitError> {
493        let domain_id = match baked_domain_id {
494            Some(d) => check_domain_id(d.trim().parse().map_err(|_| InitError::EnvParseFailed)?)?,
495            None => 0,
496        };
497        Ok(Context {
498            domain_id,
499            locator: alloc::string::String::from(baked_locator.unwrap_or("")),
500            mode: SessionMode::Client,
501            rmw: alloc::string::String::new(),
502            source: ContextSource::Baked,
503            ros_args: alloc::vec::Vec::new(),
504            ros_params: alloc::vec::Vec::new(),
505        })
506    }
507
508    /// Replace the locator with one handed in at run time, when there is one.
509    ///
510    /// #166 / phase-286 W1 — native_sim test parallelism: the harness launches
511    /// an image with `-testargs --nros-locator=<loc>` and a per-test router on
512    /// that ephemeral port, and `nros_runtime_locator_override()`
513    /// (`nros-platform-zephyr`, argv-backed) hands it back here. `None` or an
514    /// empty string keeps the baked value, so on real hardware — where the hook
515    /// returns NULL — the bake stands. The entry macros call this after
516    /// [`Context::default_from_env`]; a hand-written entry may too.
517    pub fn with_locator_override(mut self, locator: Option<&str>) -> Self {
518        if let Some(loc) = locator {
519            if !loc.is_empty() {
520                self.locator = alloc::string::String::from(loc);
521            }
522        }
523        self
524    }
525
526    /// phase-427 W10 — the config the executor's SESSION opens with: this
527    /// context's locator, domain and mode, and no node name.
528    ///
529    /// The counterpart of [`config`](Self::config), which is for the older
530    /// shape where the executor IS the node it was configured with. Here the
531    /// session carries no ROS identity of its own — `Executor::create_node`
532    /// supplies that, once per node, and one executor holds several
533    /// (RFC-0047). What lands in `ExecutorConfig::node_name` is therefore
534    /// whatever [`ExecutorConfig::new`] defaults to; it names the transport
535    /// session for diagnostics, not a participant on the graph.
536    #[cfg(feature = "rmw-cffi")]
537    fn session_config(&self) -> ExecutorConfig<'_> {
538        ExecutorConfig::new(self.locator.as_str())
539            .domain_id(self.domain_id)
540            .mode(self.mode)
541    }
542}
543
544/// phase-427 W10 — `rclrs::Context::create_executor`, and the ours-only
545/// no-leak twin beside it.
546///
547/// Both are gated on `rmw-cffi`, because an executor without the vtable
548/// runtime has no session to open; `create_executor` is additionally gated on
549/// `alloc`, because it is the one that leaks a default-sized backing.
550#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
551impl Context {
552    /// `rclrs::Context::create_executor()` — open the session this context
553    /// resolved and hand back the [`Executor`] that owns it.
554    ///
555    /// This is the entry a ported rclrs `main` lands on: upstream writes
556    /// `context.create_basic_executor()` (or `create_executor(runtime)`) and
557    /// gets an `Executor` back; ours returns a `Result`, so the port is a `?`.
558    /// The runtime argument has no counterpart here and is not accepted — one
559    /// executor per RTOS task is the model (RFC-0002), and there is no runtime
560    /// to choose between.
561    ///
562    /// The node name is NOT taken here. Name nodes with
563    /// [`Executor::create_node`], which is where several named nodes on one
564    /// session already live (RFC-0047).
565    ///
566    /// # Available on `alloc` only
567    ///
568    /// It leaks an executor-lifetime backing at
569    /// [`ExecutorSizing::DEFAULT`] — the reason it is `Executor<'static>` and
570    /// the reason it needs an allocator. A target without one calls
571    /// [`create_executor_in`](Self::create_executor_in) with its own
572    /// `static`, which is the same open with the storage supplied rather than
573    /// leaked. (Today [`Context`] itself has an `alloc` floor — its `locator`
574    /// and `rmw` are owned `String`s — so the pair moves together; if that
575    /// floor ever drops, this is the half that stays behind.)
576    ///
577    /// ```ignore
578    /// let context = nros::Context::default_from_env()?;
579    /// let mut executor = context.create_executor()?;
580    /// let mut node = executor.create_node("talker")?;
581    /// ```
582    ///
583    /// The `-r` rules `init_with_args` / `Context::new` parsed are
584    /// installed in the new executor as its fallback remap tier.
585    pub fn create_executor(&self) -> Result<Executor<'static>, InitError> {
586        let mut executor =
587            Executor::open(&self.session_config()).map_err(InitError::ExecutorOpenFailed)?;
588        self.install_ros_args(&mut executor);
589        Ok(executor)
590    }
591
592    /// Put this context's `--ros-args` remaps into `executor`'s fallback tier.
593    /// A rule that does not fit is REFUSED rather than dropped.
594    fn install_ros_args(&self, executor: &mut Executor<'_>) {
595        #[cfg(feature = "env")]
596        {
597            let rules = self.ros_args.iter().map(|r| nros_node::ros_args::RemapArg {
598                node: r.node.as_deref(),
599                from: r.from.as_str(),
600                to: r.to.as_str(),
601            });
602            if let Err(rule) = nros_node::ros_args::install_argv_remaps(executor, rules) {
603                refuse_ros_args(&RosArgsRefusal::DoesNotFit {
604                    node: rule.node,
605                    from: rule.from,
606                    to: rule.to,
607                });
608            }
609            #[cfg(feature = "param-store")]
610            if !self.ros_params.is_empty() {
611                let params = self
612                    .ros_params
613                    .iter()
614                    .map(|p| nros_node::ros_args::ParamArg {
615                        node: p.node.as_deref(),
616                        name: p.name.as_str(),
617                        value: p.value.as_str(),
618                    });
619                if executor.install_argv_params(params).is_err() {
620                    refuse_ros_args(&RosArgsRefusal::NoParamStore);
621                }
622            }
623        }
624        #[cfg(not(feature = "env"))]
625        let _ = executor;
626    }
627
628    /// OURS — [`create_executor`](Self::create_executor) with the storage
629    /// supplied by the caller instead of leaked.
630    ///
631    /// The freestanding form. An RTOS or bare-metal entry owns a
632    /// `static mut [MaybeUninit<u64>; N]` (or the array the `nros::main!`
633    /// macro sizes for it) and hands it in; nothing is allocated, and the
634    /// returned executor borrows the backing for `'b` rather than living
635    /// forever. rclrs has no counterpart — it allocates.
636    ///
637    /// `backing` must hold at least `ExecutorSizing::DEFAULT.u64_len()` words.
638    /// A shorter slice is [`InitError::BackingTooSmall`] naming both numbers,
639    /// never undefined behaviour: this is the safe wrapper around the `unsafe`
640    /// `Executor::open_in`, and the length check is what makes it safe. Size
641    /// the array with `ExecutorSizing::DEFAULT.u64_len()` rather than a
642    /// literal, since the default moves with `NROS_EXECUTOR_MAX_CBS` and its
643    /// siblings.
644    ///
645    /// ```ignore
646    /// static mut BACKING: [MaybeUninit<u64>; N] = [MaybeUninit::uninit(); N];
647    /// let context = nros::Context::default_from_env()?;
648    /// // SAFETY: single-threaded entry, taken once.
649    /// let mut executor = context.create_executor_in(unsafe { &mut *&raw mut BACKING })?;
650    /// let mut node = executor.create_node("talker")?;
651    /// ```
652    pub fn create_executor_in<'b>(
653        &self,
654        backing: &'b mut [MaybeUninit<u64>],
655    ) -> Result<Executor<'b>, InitError> {
656        let sizing = ExecutorSizing::DEFAULT;
657        let needed = sizing.u64_len();
658        let given = backing.len();
659        if given < needed {
660            return Err(InitError::BackingTooSmall { needed, given });
661        }
662        // SAFETY: `backing` is at least `sizing.u64_len()` words (checked
663        // directly above), is `u64`-aligned by its element type, is uniquely
664        // borrowed for `'b`, and the returned `Executor<'b>` is the only thing
665        // that can reach it for that lifetime.
666        let mut executor = unsafe { Executor::open_in(&self.session_config(), backing, sizing) }
667            .map_err(InitError::ExecutorOpenFailed)?;
668        self.install_ros_args(&mut executor);
669        Ok(executor)
670    }
671}
672
673#[cfg(feature = "env")]
674fn read_env_context(source: ContextSource) -> Result<Context, InitError> {
675    // issue 0687 — through the ONE resolver, not a third parse of the same four
676    // variables. This function had its own copy, and the copies had drifted:
677    // it did not see the legacy `ZENOH_LOCATOR`/`ZENOH_MODE` spellings (now
678    // retired and refused by the shared reader), and it range-checked nothing, so
679    // `ROS_DOMAIN_ID=300` reached a backend that could only fail later.
680    //
681    // Issue 0330 — no backend default for the locator: `nros` is RMW-agnostic,
682    // so unset env leaves it EMPTY (= absent) and the linked backend
683    // substitutes its own (zenoh: `nros_rmw_zenoh::DEFAULT_LOCATOR`; xrce: its
684    // agent default; cyclonedds ignores the locator entirely). That is what
685    // `resolve_hosted` does with an empty `BootConfig` too.
686    let resolved =
687        crate::env::try_resolve_hosted(nros_node::BootConfig::default()).map_err(|e| match e {
688            nros_node::BootConfigError::DomainIdRange => InitError::DomainIdOutOfRange,
689            nros_node::BootConfigError::DomainIdParse => InitError::EnvParseFailed,
690            nros_node::BootConfigError::RetiredEnvVar { name, replacement } => {
691                InitError::RetiredEnvVar { name, replacement }
692            }
693        })?;
694    let locator = alloc::string::String::from(resolved.locator);
695    let domain_id = resolved.domain_id;
696    let mode = resolved.mode;
697    // issue 0687 — the `$NROS_RMW` half comes from the shared selector; the
698    // `RMW_IMPLEMENTATION` fallback stays HERE and only here. `Context.rmw` is
699    // a ROS-vocabulary HINT (`rmw_cyclonedds_cpp`), not the cffi registry
700    // selector (`cyclonedds`) — folding the two together would hand a ROS name
701    // to `resolve_backend`, which answers `Unknown` and fails the open.
702    let rmw = crate::rmw_selector()
703        .map(|s| alloc::string::String::from(s.as_str()))
704        .or_else(|| std::env::var("RMW_IMPLEMENTATION").ok())
705        .unwrap_or_default();
706    Ok(Context {
707        domain_id,
708        locator,
709        mode,
710        rmw,
711        source,
712        ros_args: alloc::vec::Vec::new(),
713        ros_params: alloc::vec::Vec::new(),
714    })
715}
716
717/// Pattern 3 — raw init, launch file ignored. The C++-symmetric anchor:
718/// `rclcpp::init()` in the Rust spelling, and EQUAL to
719/// [`Context::default_from_env`] on a hosted build (RFC-0089 "Context and
720/// `init`, settled" — each language follows its own upstream; rclrs spells
721/// this one on the type, rclcpp as a free function, and both are here).
722///
723/// Reads env vars (`ROS_DOMAIN_ID`, `NROS_LOCATOR`, `NROS_SESSION_MODE`,
724/// `NROS_RMW` / `RMW_IMPLEMENTATION`) and returns a [`Context`]. The
725/// caller owns the spin loop — typically `Executor::open(&ctx.config(name))`
726/// followed by `spin` or a hand-rolled `spin_once` loop.
727#[cfg(feature = "env")]
728pub fn init() -> Result<Context, InitError> {
729    read_env_context(ContextSource::Env)
730}
731
732/// The refusal [`init_with_args`] and [`Context::new`] emit for a ROS
733/// argument nano-ros does not honour. Panics carry this text followed by the
734/// refused argument. `NROS_RCLCPP_REFUSE_INIT_ARGV` is the C++ twin
735/// (`packages/api/nros-cpp/include/rclcpp/logging.hpp`), and since phase-467 Row 11's
736/// C++ half it states the same honoured and refused sets, from the same parser.
737#[cfg(feature = "env")]
738pub const REFUSE_INIT_ARGS: &str = "nros::init_with_args / nros::Context::new was given a --ros-args \
739argument nano-ros cannot honour (RFC-0089, phase-417 W3.b). Proceeding would DISCARD it -- the \
740'compiles and differs' the rule forbids. HONOURED inside --ros-args ... --: -r / --remap \
741[node:]from:=to, applied as the FALLBACK beneath any remap the launch file projected for the same \
742name (RFC-0046; rcl's local-before-global), through Context::create_executor / \
743create_executor_in; and, in an image with a parameter store, -p / --param [node:]name:=value and \
744--params-file <path>, applied when a node declares the parameter (RFC-0015 section 9). REFUSED: \
745node-identity remaps (__node, __name, __ns), -e / --enclave, the log flags, any token that is not a \
746ROS flag, and -p / --params-file in an image without a parameter store. A nros sync image does not read argv at all: its launch \
747remaps and parameters are projected into the GENERATED ENTRY at BUILD time.";
748
749/// Does `args` open a `--ros-args` scope at all?
750///
751/// No longer the refusal's predicate — since the `-r` parse landed, a vector
752/// with `--ros-args` is parsed, and only what it cannot honour is refused
753/// (`nros_node::ros_args::parse_ros_args`). Kept because "will this argv be
754/// read?" is still a question a caller can ask before handing it over.
755///
756/// Exact match only. `--ros-args-extra` is not the flag, and a prefix match
757/// would claim an argument nano-ros never reads — pinned by a unit test.
758#[cfg(feature = "env")]
759pub fn args_have_ros_args<I, S>(args: I) -> bool
760where
761    I: IntoIterator<Item = S>,
762    S: AsRef<str>,
763{
764    args.into_iter().any(|a| a.as_ref() == "--ros-args")
765}
766
767/// Why a `--ros-args` vector, or a context carrying one, was refused.
768#[cfg(feature = "env")]
769enum RosArgsRefusal<'a> {
770    /// The parse itself refused an argument.
771    Parse(nros_node::ros_args::RosArgsError<'a>),
772    /// A parsed rule did not fit the executor's remap table.
773    #[cfg(feature = "rmw-cffi")]
774    DoesNotFit {
775        node: Option<&'a str>,
776        from: &'a str,
777        to: &'a str,
778    },
779    /// [`Context::config`] was asked for a config that cannot carry the rules.
780    UnreachableExecutor,
781    /// phase-482 W5 — `--params-file <path>` could not be read or parsed.
782    #[cfg(feature = "param-store")]
783    ParamsFile {
784        path: alloc::string::String,
785        why: alloc::string::String,
786    },
787    /// phase-482 W5 — the parameter store could not be allocated (issue 1706).
788    #[cfg(all(feature = "param-store", feature = "rmw-cffi"))]
789    NoParamStore,
790}
791
792#[cfg(feature = "env")]
793impl core::fmt::Display for RosArgsRefusal<'_> {
794    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
795        match self {
796            Self::Parse(e) => e.fmt(f),
797            #[cfg(feature = "rmw-cffi")]
798            Self::DoesNotFit { node, from, to } => {
799                let node = node.map(|n| alloc::format!("{n}:")).unwrap_or_default();
800                write!(
801                    f,
802                    "`-r {node}{from}:={to}` does not fit the executor's remap table (MAX_REMAPS rules, \
803                     shared with launch remaps; names up to {} bytes)",
804                    nros_node::names::MAX_RESOLVED_NAME_LEN
805                )
806            }
807            Self::UnreachableExecutor => f.write_str(
808                "Context::config() cannot carry this context's --ros-args remaps or parameter \
809                 overrides into the executor Executor::open builds from it; use \
810                 Context::create_executor() or create_executor_in(), which install them",
811            ),
812            #[cfg(feature = "param-store")]
813            Self::ParamsFile { path, why } => write!(f, "`--params-file {path}`: {why}"),
814            #[cfg(all(feature = "param-store", feature = "rmw-cffi"))]
815            Self::NoParamStore => f.write_str(
816                "the parameter store could not be allocated, so the parameter overrides have \
817                 nowhere to go",
818            ),
819        }
820    }
821}
822
823/// The one refusal site: logged through `nros_log` (never `std::println!`,
824/// issue 0589), then a panic carrying [`REFUSE_INIT_ARGS`] and the reason —
825/// the shape `rclcpp::init(argc, argv)` has in `nros.hpp`, and the shape
826/// rclcpp's own `UnknownROSArgsError` has.
827#[cfg(feature = "env")]
828fn refuse_ros_args(why: &RosArgsRefusal<'_>) -> ! {
829    nros_log::log_error!(
830        nros_log::get_logger("nros"),
831        "{}\n  refused: {}",
832        REFUSE_INIT_ARGS,
833        why
834    );
835    panic!("{REFUSE_INIT_ARGS}\n  refused: {why}");
836}
837
838/// Parse `args` (rcl's grammar — `nros_node::ros_args`) into owned rules, or
839/// refuse. Shared by [`init_with_args`] and [`Context::new`], so the two stay
840/// one behaviour.
841#[cfg(feature = "env")]
842fn parse_ros_args_or_refuse<I, S>(
843    args: I,
844) -> (alloc::vec::Vec<ArgvRemap>, alloc::vec::Vec<ArgvParam>)
845where
846    I: IntoIterator<Item = S>,
847    S: AsRef<str>,
848{
849    use alloc::string::String;
850    let owned: alloc::vec::Vec<String> =
851        args.into_iter().map(|a| String::from(a.as_ref())).collect();
852    let mut rules = alloc::vec::Vec::new();
853    #[cfg_attr(not(feature = "param-store"), allow(unused_mut))]
854    let mut params: alloc::vec::Vec<ArgvParam> = alloc::vec::Vec::new();
855    let remap = |r: nros_node::ros_args::RemapArg<'_>| ArgvRemap {
856        node: r.node.map(String::from),
857        from: String::from(r.from),
858        to: String::from(r.to),
859    };
860    // phase-482 W5 — with a parameter store, `-p` and `--params-file` are
861    // honoured, each file's entries landing where its flag stood (so a later
862    // `-p` overrides it and a later file overrides an earlier `-p`). Without
863    // one, the remap-only parse refuses them by name.
864    #[cfg(feature = "param-store")]
865    let parsed = {
866        let mut events = alloc::vec::Vec::new();
867        let r = nros_node::ros_args::parse_ros_args_events(owned.iter().map(|a| a.as_str()), |a| {
868            events.push(a)
869        });
870        if r.is_ok() {
871            for a in events {
872                match a {
873                    nros_node::ros_args::RosArg::Remap(r) => rules.push(remap(r)),
874                    nros_node::ros_args::RosArg::Param(p) => params.push(ArgvParam {
875                        node: p.node.map(String::from),
876                        name: String::from(p.name),
877                        value: String::from(p.value),
878                    }),
879                    nros_node::ros_args::RosArg::ParamsFile(path) => {
880                        let text = match std::fs::read_to_string(path) {
881                            Ok(t) => t,
882                            Err(e) => refuse_ros_args(&RosArgsRefusal::ParamsFile {
883                                path: String::from(path),
884                                why: alloc::format!("cannot be read: {e}"),
885                            }),
886                        };
887                        if let Err(e) = nros_node::ros_args::parse_params_yaml(&text, |p| {
888                            params.push(ArgvParam {
889                                node: p.node.map(String::from),
890                                name: String::from(p.name),
891                                value: String::from(p.value),
892                            })
893                        }) {
894                            refuse_ros_args(&RosArgsRefusal::ParamsFile {
895                                path: String::from(path),
896                                why: alloc::format!("{e}"),
897                            });
898                        }
899                    }
900                }
901            }
902        }
903        r
904    };
905    #[cfg(not(feature = "param-store"))]
906    let parsed = nros_node::ros_args::parse_ros_args(owned.iter().map(|a| a.as_str()), |r| {
907        rules.push(remap(r))
908    });
909    if let Err(e) = parsed {
910        refuse_ros_args(&RosArgsRefusal::Parse(e));
911    }
912    (rules, params)
913}
914
915/// Pattern 3 — like [`init()`] but takes the process arguments, the way
916/// `rclcpp::init(argc, argv)` / rclrs's `Context::new(args, ..)` do. The
917/// free-function twin of [`Context::new`] with default options.
918///
919/// # What it does with the arguments
920///
921/// It parses `--ros-args` with rcl's grammar (Humble `rcl/arguments.h`): ROS
922/// arguments live between `--ros-args` and `--` (or the end), several scopes
923/// may appear, and everything outside them is the program's own and is left
924/// alone.
925///
926/// * **`-r` / `--remap [node:]from:=to` — honoured.** The rules ride on the
927///   returned [`Context`] and are installed in every executor it creates
928///   (`Context::create_executor` / `Context::create_executor_in`) as the
929///   FALLBACK remap tier: a launch-projected rule for the same name wins
930///   (RFC-0046), exactly as rcl checks a node's local arguments before the
931///   process's global ones. They reach entities on every road — the handle
932///   `Executor::create_node` returns, `nros::node!` components, and the C and
933///   C++ APIs.
934/// * **`-p` / `--param [node:]name:=value` and `--params-file <path>` —
935///   honoured when `nros` has `param-services`** (phase-482 W5, RFC-0015 §9's
936///   POSIX row). They ride on the [`Context`] in argv order and are applied
937///   when a node DECLARES the parameter: the last matching override wins, its
938///   text typed by the declared default, and a `node:` override reaches only
939///   the node of that name. The envelope, stated here because it is the API:
940///   a parameter file is read with `std::fs` and holds scalars only (arrays,
941///   anchors and block scalars are refused, with their line), a node key
942///   matches by NAME not namespace, and an override that does not parse as
943///   the declared type is logged and the default kept.
944/// * **Everything else inside a scope — refused, loudly**: node-identity
945///   remaps, enclaves, log flags, unknown tokens, and `-p` / `--params-file`
946///   in an image with no parameter store. Logged through `nros_log`, then a panic
947///   carrying [`REFUSE_INIT_ARGS`] and the argument. rclcpp refuses unknown
948///   ROS arguments too (`UnknownROSArgsError`).
949///
950/// # Where the rules do NOT reach
951///
952/// [`Context::config`] refuses a context that carries rules, because an
953/// `ExecutorConfig` cannot carry them. Entities created through the
954/// executor's own `add_*` registration methods or a `node_mut` context are
955/// not remapped either: those are also where the C ABI and the component
956/// sink hand their ALREADY-RESOLVED names, so remapping there would apply a
957/// rule twice.
958#[cfg(feature = "env")]
959pub fn init_with_args<I, S>(args: I) -> Result<Context, InitError>
960where
961    I: IntoIterator<Item = S>,
962    S: AsRef<str>,
963{
964    let (ros_args, ros_params) = parse_ros_args_or_refuse(args);
965    let mut context = init()?;
966    context.ros_args = ros_args;
967    context.ros_params = ros_params;
968    Ok(context)
969}
970
971#[cfg(all(test, feature = "env"))]
972mod ros_args_refusal_tests {
973    use super::*;
974
975    #[test]
976    fn predicate_matches_the_flag_exactly() {
977        let _env = crate::env::test_env_lock();
978        assert!(args_have_ros_args(["node", "--ros-args", "-p", "x:=1"]));
979        assert!(args_have_ros_args(["--ros-args"]));
980        // A prefix match would refuse an argument nano-ros never drops.
981        assert!(!args_have_ros_args(["--ros-args-extra"]));
982        assert!(!args_have_ros_args(["--ros-arg"]));
983        assert!(!args_have_ros_args(["node", "--verbose"]));
984        assert!(!args_have_ros_args(core::iter::empty::<&str>()));
985    }
986
987    fn remap(node: Option<&str>, from: &str, to: &str) -> ArgvRemap {
988        ArgvRemap {
989            node: node.map(alloc::string::String::from),
990            from: alloc::string::String::from(from),
991            to: alloc::string::String::from(to),
992        }
993    }
994
995    #[test]
996    fn remap_rules_ride_on_the_context_in_argv_order() {
997        let _env = crate::env::test_env_lock();
998        let ctx = init_with_args([
999            "/usr/bin/talker",
1000            "--ros-args",
1001            "-r",
1002            "chatter:=/other",
1003            "--remap",
1004            "talker:~/out:=/wire",
1005            "--",
1006            "positional",
1007        ])
1008        .expect("init");
1009        assert_eq!(
1010            ctx.ros_args,
1011            [
1012                remap(None, "chatter", "/other"),
1013                remap(Some("talker"), "~/out", "/wire")
1014            ]
1015        );
1016    }
1017
1018    #[test]
1019    fn an_empty_scope_is_not_a_refusal() {
1020        let _env = crate::env::test_env_lock();
1021        assert!(
1022            init_with_args(["--ros-args"])
1023                .expect("init")
1024                .ros_args
1025                .is_empty()
1026        );
1027    }
1028
1029    /// Without a parameter store there is nowhere to put an override, so the
1030    /// parse refuses it by name rather than dropping it.
1031    #[cfg(not(feature = "param-store"))]
1032    #[test]
1033    #[should_panic(expected = "refused: `-p`")]
1034    fn parameter_overrides_are_still_refused_by_name() {
1035        let _env = crate::env::test_env_lock();
1036        let _ = init_with_args(["/usr/bin/talker", "--ros-args", "-r", "a:=b", "-p", "x:=1"]);
1037    }
1038
1039    #[cfg(feature = "param-store")]
1040    fn param(node: Option<&str>, name: &str, value: &str) -> ArgvParam {
1041        ArgvParam {
1042            node: node.map(alloc::string::String::from),
1043            name: alloc::string::String::from(name),
1044            value: alloc::string::String::from(value),
1045        }
1046    }
1047
1048    /// phase-482 W5 — `-p` and a `--params-file` ride on the context in argv
1049    /// order, the file's entries where its flag stood, beside the remaps.
1050    #[cfg(feature = "param-store")]
1051    #[test]
1052    fn parameter_overrides_ride_on_the_context_in_argv_order() {
1053        let _env = crate::env::test_env_lock();
1054        let path =
1055            std::env::temp_dir().join(alloc::format!("nros-w5-params-{}.yaml", std::process::id()));
1056        std::fs::write(
1057            &path,
1058            "/**:\n  ros__parameters:\n    rate: 30\n/talker:\n  ros__parameters:\n    gains:\n      kp: 1.5\n",
1059        )
1060        .unwrap();
1061        let path_s = path.to_str().unwrap();
1062        let ctx = init_with_args([
1063            "/usr/bin/talker",
1064            "--ros-args",
1065            "-p",
1066            "rate:=10",
1067            "--params-file",
1068            path_s,
1069            "-r",
1070            "chatter:=/other",
1071            "-p",
1072            "talker:label:=hi",
1073        ])
1074        .expect("init");
1075        let _ = std::fs::remove_file(&path);
1076        assert_eq!(
1077            ctx.ros_params,
1078            [
1079                param(None, "rate", "10"),
1080                param(None, "rate", "30"),
1081                param(Some("talker"), "gains.kp", "1.5"),
1082                param(Some("talker"), "label", "hi"),
1083            ]
1084        );
1085        assert_eq!(ctx.ros_args, [remap(None, "chatter", "/other")]);
1086    }
1087
1088    /// An unreadable parameter file is refused, naming the path — never a
1089    /// silently empty set of overrides.
1090    #[cfg(feature = "param-store")]
1091    #[test]
1092    #[should_panic(expected = "--params-file /nonexistent/nros-w5.yaml")]
1093    fn an_unreadable_params_file_is_refused_by_path() {
1094        let _env = crate::env::test_env_lock();
1095        let _ = init_with_args(["--ros-args", "--params-file", "/nonexistent/nros-w5.yaml"]);
1096    }
1097
1098    #[test]
1099    #[should_panic(expected = "remaps a node's identity")]
1100    fn identity_remaps_are_refused() {
1101        let _env = crate::env::test_env_lock();
1102        let _ = init_with_args(["--ros-args", "-r", "__ns:=/robot"]);
1103    }
1104
1105    #[test]
1106    #[should_panic(expected = "Context::config() cannot carry")]
1107    fn config_refuses_a_context_that_carries_remaps() {
1108        let _env = crate::env::test_env_lock();
1109        let ctx = init_with_args(["--ros-args", "-r", "chatter:=/other"]).expect("init");
1110        let _ = ctx.config("talker");
1111    }
1112
1113    #[test]
1114    fn config_is_unaffected_without_remaps() {
1115        let _env = crate::env::test_env_lock();
1116        let ctx = init_with_args(["/usr/bin/talker", "--verbose"]).expect("init");
1117        assert_eq!(ctx.config("talker").node_name, "talker");
1118    }
1119
1120    #[test]
1121    fn plain_args_pass_through_to_init() {
1122        let _env = crate::env::test_env_lock();
1123        // Same answer as `init()` — the arguments are not consulted, and no
1124        // refusal fires. Both read the same environment, so compare the
1125        // resolved knobs rather than asserting a particular value.
1126        let via_args = init_with_args(["/usr/bin/talker", "--verbose", "positional"]);
1127        let plain = init();
1128        match (via_args, plain) {
1129            (Ok(a), Ok(b)) => {
1130                assert_eq!(a.domain_id, b.domain_id);
1131                assert_eq!(a.locator, b.locator);
1132                assert_eq!(a.rmw, b.rmw);
1133            }
1134            (Err(a), Err(b)) => assert_eq!(a, b),
1135            (a, b) => panic!("init_with_args and init disagree: {a:?} vs {b:?}"),
1136        }
1137    }
1138
1139    // phase-427 W9 — the rclrs family on a hosted build.
1140
1141    fn same_identity(a: &Context, b: &Context) {
1142        assert_eq!(a.domain_id, b.domain_id);
1143        assert_eq!(a.locator, b.locator);
1144        assert_eq!(a.mode, b.mode);
1145        assert_eq!(a.rmw, b.rmw);
1146        assert_eq!(a.source, b.source);
1147    }
1148
1149    #[test]
1150    fn default_from_env_is_init() {
1151        let _env = crate::env::test_env_lock();
1152        match (Context::default_from_env(), init()) {
1153            (Ok(a), Ok(b)) => {
1154                same_identity(&a, &b);
1155                assert_eq!(a.source, ContextSource::Env);
1156            }
1157            (Err(a), Err(b)) => assert_eq!(a, b),
1158            (a, b) => panic!("default_from_env and init disagree: {a:?} vs {b:?}"),
1159        }
1160    }
1161
1162    #[test]
1163    fn from_env_honours_the_domain_override() {
1164        let _env = crate::env::test_env_lock();
1165        let env = match init() {
1166            Ok(c) => c,
1167            Err(e) => panic!("init() failed on the test host: {e:?}"),
1168        };
1169        // A value the environment is not carrying, so the override is visible
1170        // whatever `ROS_DOMAIN_ID` says.
1171        let wanted = if env.domain_id == 7 { 9 } else { 7 };
1172        let ctx = Context::from_env(InitOptions::new().with_domain_id(Some(wanted as usize)))
1173            .expect("from_env with a valid override");
1174        assert_eq!(ctx.domain_id, wanted);
1175        // Everything else is untouched.
1176        assert_eq!(ctx.locator, env.locator);
1177        assert_eq!(ctx.rmw, env.rmw);
1178        assert_eq!(ctx.source, ContextSource::Env);
1179    }
1180
1181    #[test]
1182    fn from_env_with_no_override_keeps_the_env_value() {
1183        let _env = crate::env::test_env_lock();
1184        match (
1185            Context::from_env(InitOptions::new().with_domain_id(None)),
1186            init(),
1187        ) {
1188            (Ok(a), Ok(b)) => same_identity(&a, &b),
1189            (Err(a), Err(b)) => assert_eq!(a, b),
1190            (a, b) => panic!("from_env(None) and init disagree: {a:?} vs {b:?}"),
1191        }
1192    }
1193
1194    #[test]
1195    fn from_env_rejects_an_out_of_range_override() {
1196        let _env = crate::env::test_env_lock();
1197        if init().is_err() {
1198            // The environment itself is invalid; the override is never reached.
1199            return;
1200        }
1201        assert_eq!(
1202            Context::from_env(InitOptions::new().with_domain_id(Some(DOMAIN_ID_MAX as usize + 1)))
1203                .map(|c| c.domain_id),
1204            Err(InitError::DomainIdOutOfRange)
1205        );
1206        // A value no `u32` can hold saturates in the builder and is refused
1207        // here, not truncated into a plausible small number.
1208        assert_eq!(
1209            Context::from_env(InitOptions::new().with_domain_id(Some(usize::MAX)))
1210                .map(|c| c.domain_id),
1211            Err(InitError::DomainIdOutOfRange)
1212        );
1213    }
1214
1215    #[test]
1216    #[should_panic(expected = "refused: `--enclave`")]
1217    fn context_new_refuses_what_it_cannot_honour() {
1218        let _env = crate::env::test_env_lock();
1219        let _ = Context::new(
1220            [
1221                "/usr/bin/talker",
1222                "--ros-args",
1223                "-r",
1224                "chatter:=/other",
1225                "--enclave",
1226                "/e",
1227            ],
1228            InitOptions::new(),
1229        );
1230    }
1231
1232    #[test]
1233    fn context_new_parses_exactly_like_init_with_args() {
1234        let _env = crate::env::test_env_lock();
1235        let argv = ["/usr/bin/talker", "--ros-args", "-r", "chatter:=/other"];
1236        let a = Context::new(argv, InitOptions::new()).expect("Context::new");
1237        let b = init_with_args(argv).expect("init_with_args");
1238        assert_eq!(a.ros_args, b.ros_args);
1239        assert_eq!(a.ros_args, [remap(None, "chatter", "/other")]);
1240    }
1241
1242    #[test]
1243    fn context_new_passes_plain_args_through() {
1244        let _env = crate::env::test_env_lock();
1245        let opts = InitOptions::new().with_domain_id(Some(3));
1246        match (
1247            Context::new(["/usr/bin/talker", "--verbose", "positional"], opts),
1248            Context::from_env(opts),
1249        ) {
1250            (Ok(a), Ok(b)) => {
1251                same_identity(&a, &b);
1252                assert_eq!(a.domain_id, 3);
1253            }
1254            (Err(a), Err(b)) => assert_eq!(a, b),
1255            (a, b) => panic!("Context::new and from_env disagree: {a:?} vs {b:?}"),
1256        }
1257    }
1258
1259    #[test]
1260    fn context_new_accepts_rclrs_argv_shape() {
1261        let _env = crate::env::test_env_lock();
1262        // `impl IntoIterator<Item = String>` is what rclrs takes; ours must
1263        // accept the same call unchanged.
1264        let args: alloc::vec::Vec<alloc::string::String> =
1265            alloc::vec![alloc::string::String::from("talker")];
1266        let _ = Context::new(args, InitOptions::default());
1267    }
1268}
1269
1270/// phase-427 W9 — the freestanding constructor's parse, with the constants
1271/// injected. Runs on the `alloc` floor (`cargo test -p nros --features alloc
1272/// --lib`); nothing here reads the environment.
1273#[cfg(all(test, feature = "alloc"))]
1274mod baked_tests {
1275    use super::*;
1276
1277    #[test]
1278    fn nothing_baked_is_domain_zero_and_an_absent_locator() {
1279        let ctx = Context::from_baked(None, None).expect("nothing baked is valid");
1280        assert_eq!(ctx.domain_id, 0);
1281        assert_eq!(ctx.locator, "");
1282        assert_eq!(ctx.mode, SessionMode::Client);
1283        assert_eq!(ctx.rmw, "");
1284        assert_eq!(ctx.source, ContextSource::Baked);
1285    }
1286
1287    #[test]
1288    fn baked_values_are_carried() {
1289        let ctx = Context::from_baked(Some("tcp/127.0.0.1:7456"), Some("42")).expect("valid bake");
1290        assert_eq!(ctx.locator, "tcp/127.0.0.1:7456");
1291        assert_eq!(ctx.domain_id, 42);
1292        assert_eq!(ctx.source, ContextSource::Baked);
1293        // The config it materialises is the one the macros used to assemble
1294        // by hand: locator, domain, node name, client mode.
1295        let cfg = ctx.config("talker");
1296        assert_eq!(cfg.locator, "tcp/127.0.0.1:7456");
1297        assert_eq!(cfg.domain_id, 42);
1298        assert_eq!(cfg.node_name, "talker");
1299        assert_eq!(cfg.mode, SessionMode::Client);
1300    }
1301
1302    #[test]
1303    fn an_empty_baked_locator_is_absent() {
1304        let ctx = Context::from_baked(Some(""), None).expect("empty is absent, not an error");
1305        assert_eq!(ctx.locator, "");
1306    }
1307
1308    #[test]
1309    fn a_non_numeric_baked_domain_is_refused() {
1310        assert_eq!(
1311            Context::from_baked(None, Some("seven")).map(|c| c.domain_id),
1312            Err(InitError::EnvParseFailed)
1313        );
1314        assert_eq!(
1315            Context::from_baked(None, Some("-1")).map(|c| c.domain_id),
1316            Err(InitError::EnvParseFailed)
1317        );
1318    }
1319
1320    #[test]
1321    fn an_out_of_range_baked_domain_is_refused() {
1322        // The bound `try_resolve_hosted` applies, so an image cannot be built
1323        // onto a domain a hosted process would refuse to boot on.
1324        assert_eq!(
1325            Context::from_baked(None, Some("300")).map(|c| c.domain_id),
1326            Err(InitError::DomainIdOutOfRange)
1327        );
1328        assert_eq!(
1329            Context::from_baked(None, Some("232")).map(|c| c.domain_id),
1330            Ok(DOMAIN_ID_MAX)
1331        );
1332    }
1333
1334    #[test]
1335    fn locator_override_applies_only_when_present_and_non_empty() {
1336        let baked = || Context::from_baked(Some("tcp/10.0.2.2:7447"), Some("1")).unwrap();
1337        assert_eq!(
1338            baked()
1339                .with_locator_override(Some("tcp/127.0.0.1:7456"))
1340                .locator,
1341            "tcp/127.0.0.1:7456"
1342        );
1343        assert_eq!(
1344            baked().with_locator_override(None).locator,
1345            "tcp/10.0.2.2:7447"
1346        );
1347        assert_eq!(
1348            baked().with_locator_override(Some("")).locator,
1349            "tcp/10.0.2.2:7447"
1350        );
1351        // The override touches nothing else.
1352        let ctx = baked().with_locator_override(Some("tcp/127.0.0.1:7456"));
1353        assert_eq!(ctx.domain_id, 1);
1354        assert_eq!(ctx.source, ContextSource::Baked);
1355    }
1356
1357    /// phase-467 row `rust:Context::domain_id`. Two claims, and the second is
1358    /// the one worth a test: the accessor answers the field, and it answers it
1359    /// as a `usize` — rclrs's type — so a ported `let d: usize =
1360    /// ctx.domain_id();` compiles. The annotation is the assertion; without it
1361    /// an integer literal would infer whatever the accessor returns and the
1362    /// test would pass against a `u32` too.
1363    #[test]
1364    fn context_domain_id_reads_the_field_as_rclrs_types_it() {
1365        let ctx = Context::from_baked(Some("tcp/10.0.2.2:7447"), Some("7")).unwrap();
1366        let d: usize = ctx.domain_id();
1367        assert_eq!(d, 7);
1368        assert_eq!(ctx.domain_id(), ctx.domain_id as usize);
1369        // The field did not move: `Context` is a plain struct and both
1370        // spellings reach the same byte.
1371        assert_eq!(ctx.domain_id, 7u32);
1372    }
1373
1374    #[test]
1375    fn baked_reads_this_crates_build_environment() {
1376        // The values depend on the build; the SHAPE does not. Whatever was
1377        // baked, the source is `Baked`, and a parse failure would have come
1378        // from a bake this test cannot control — report it rather than hide it.
1379        match Context::baked() {
1380            Ok(ctx) => assert_eq!(ctx.source, ContextSource::Baked),
1381            Err(e) => panic!(
1382                "NROS_DOMAIN_ID was baked into this test build with a value the constructor refuses: {e:?}"
1383            ),
1384        }
1385    }
1386
1387    #[test]
1388    fn init_options_default_overrides_nothing() {
1389        assert_eq!(InitOptions::new(), InitOptions::default());
1390        assert_eq!(InitOptions::new().domain_id(), None);
1391        assert_eq!(
1392            InitOptions::new().with_domain_id(Some(5)).domain_id(),
1393            Some(5)
1394        );
1395        assert_eq!(
1396            InitOptions::new()
1397                .with_domain_id(Some(5))
1398                .with_domain_id(None)
1399                .domain_id(),
1400            None
1401        );
1402        // Saturates rather than truncates: `u32::MAX + 1` must not read as 0.
1403        // Reported back as a `usize`, which is the getter's type since
1404        // phase-467 — the saturation is in the STORED `u32` and the getter
1405        // only widens it, so this is the one place the two widths are both
1406        // visible in one line.
1407        assert_eq!(
1408            InitOptions::new()
1409                .with_domain_id(Some(u32::MAX as usize + 1))
1410                .domain_id(),
1411            Some(u32::MAX as usize)
1412        );
1413        // The in-place spelling is the same operation.
1414        let mut opts = InitOptions::new();
1415        opts.set_domain_id(Some(9));
1416        assert_eq!(opts, InitOptions::new().with_domain_id(Some(9)));
1417        opts.set_domain_id(None);
1418        assert_eq!(opts, InitOptions::new());
1419    }
1420
1421    /// phase-427 W10 — the two variants `create_executor*` can produce say
1422    /// WHICH thing went wrong, in words. `alloc` only: the errors are values
1423    /// and need no RMW to construct, which is the point of testing them here
1424    /// rather than beside a session.
1425    #[test]
1426    fn the_executor_errors_say_what_happened() {
1427        use alloc::string::ToString as _;
1428
1429        let too_small = InitError::BackingTooSmall {
1430            needed: 4096,
1431            given: 8,
1432        }
1433        .to_string();
1434        // Both numbers, because "too small" without them tells a caller
1435        // nothing they can act on.
1436        assert!(too_small.contains("4096"), "{too_small}");
1437        assert!(too_small.contains('8'), "{too_small}");
1438
1439        // The backend's own error survives the wrapping — issue 0465's rule:
1440        // an exhausted table and a missing router must not read alike.
1441        let open_failed = InitError::ExecutorOpenFailed(NodeError::NodeTableFull).to_string();
1442        assert!(open_failed.contains("NodeTableFull"), "{open_failed}");
1443        assert_ne!(
1444            open_failed,
1445            InitError::ExecutorOpenFailed(NodeError::NameTooLong).to_string()
1446        );
1447    }
1448
1449    #[cfg(not(feature = "env"))]
1450    #[test]
1451    fn freestanding_default_from_env_is_the_bake() {
1452        // Without `env` there is no process environment to read; the family
1453        // resolves to the bake, and the override still lands on top of it.
1454        let ctx = Context::default_from_env().expect("bake");
1455        assert_eq!(ctx.source, ContextSource::Baked);
1456        let ctx = Context::from_env(InitOptions::new().with_domain_id(Some(11))).expect("bake");
1457        assert_eq!(ctx.domain_id, 11);
1458        assert_eq!(ctx.source, ContextSource::Baked);
1459    }
1460}