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    /// Issue 2051 (RFC-0109 D9) — drop the baked locator when the image has
527    /// session properties.
528    ///
529    /// With an RMW file the file is the wiring, and every other road bakes no
530    /// locator beside it (`nros::main!`, the C/C++ boot blob). A Zephyr image
531    /// still bakes its Kconfig locator (`CONFIG_NROS_ZENOH_LOCATOR`, default
532    /// `tcp/127.0.0.1:7447`), and the zenoh shim dials a supplied locator
533    /// BEFORE the file's `connect` list: a router on that port would win, and
534    /// the file's would be a fallback. Called before
535    /// [`with_locator_override`](Self::with_locator_override), so a run-time
536    /// locator still wins. Public for the entry macro; not API.
537    #[doc(hidden)]
538    pub fn yield_locator_to_session_properties(
539        mut self,
540        properties: &[nros_node::SessionProperty],
541    ) -> Self {
542        if !properties.is_empty() {
543            self.locator.clear();
544        }
545        self
546    }
547
548    /// phase-427 W10 — the config the executor's SESSION opens with: this
549    /// context's locator, domain and mode, and no node name.
550    ///
551    /// The counterpart of [`config`](Self::config), which is for the older
552    /// shape where the executor IS the node it was configured with. Here the
553    /// session carries no ROS identity of its own — `Executor::create_node`
554    /// supplies that, once per node, and one executor holds several
555    /// (RFC-0047). What lands in `ExecutorConfig::node_name` is therefore
556    /// whatever [`ExecutorConfig::new`] defaults to; it names the transport
557    /// session for diagnostics, not a participant on the graph.
558    #[cfg(feature = "rmw-cffi")]
559    fn session_config(&self) -> ExecutorConfig<'_> {
560        ExecutorConfig::new(self.locator.as_str())
561            .domain_id(self.domain_id)
562            .mode(self.mode)
563    }
564}
565
566/// phase-427 W10 — `rclrs::Context::create_executor`, and the ours-only
567/// no-leak twin beside it.
568///
569/// Both are gated on `rmw-cffi`, because an executor without the vtable
570/// runtime has no session to open; `create_executor` is additionally gated on
571/// `alloc`, because it is the one that leaks a default-sized backing.
572#[cfg(all(feature = "alloc", feature = "rmw-cffi"))]
573impl Context {
574    /// `rclrs::Context::create_executor()` — open the session this context
575    /// resolved and hand back the [`Executor`] that owns it.
576    ///
577    /// This is the entry a ported rclrs `main` lands on: upstream writes
578    /// `context.create_basic_executor()` (or `create_executor(runtime)`) and
579    /// gets an `Executor` back; ours returns a `Result`, so the port is a `?`.
580    /// The runtime argument has no counterpart here and is not accepted — one
581    /// executor per RTOS task is the model (RFC-0002), and there is no runtime
582    /// to choose between.
583    ///
584    /// The node name is NOT taken here. Name nodes with
585    /// [`Executor::create_node`], which is where several named nodes on one
586    /// session already live (RFC-0047).
587    ///
588    /// # Available on `alloc` only
589    ///
590    /// It leaks an executor-lifetime backing at
591    /// [`ExecutorSizing::DEFAULT`] — the reason it is `Executor<'static>` and
592    /// the reason it needs an allocator. A target without one calls
593    /// [`create_executor_in`](Self::create_executor_in) with its own
594    /// `static`, which is the same open with the storage supplied rather than
595    /// leaked. (Today [`Context`] itself has an `alloc` floor — its `locator`
596    /// and `rmw` are owned `String`s — so the pair moves together; if that
597    /// floor ever drops, this is the half that stays behind.)
598    ///
599    /// ```ignore
600    /// let context = nros::Context::default_from_env()?;
601    /// let mut executor = context.create_executor()?;
602    /// let mut node = executor.create_node("talker")?;
603    /// ```
604    ///
605    /// The `-r` rules `init_with_args` / `Context::new` parsed are
606    /// installed in the new executor as its fallback remap tier.
607    pub fn create_executor(&self) -> Result<Executor<'static>, InitError> {
608        let mut executor =
609            Executor::open(&self.session_config()).map_err(InitError::ExecutorOpenFailed)?;
610        self.install_ros_args(&mut executor);
611        Ok(executor)
612    }
613
614    /// Put this context's `--ros-args` remaps into `executor`'s fallback tier.
615    /// A rule that does not fit is REFUSED rather than dropped.
616    fn install_ros_args(&self, executor: &mut Executor<'_>) {
617        #[cfg(feature = "env")]
618        {
619            let rules = self.ros_args.iter().map(|r| nros_node::ros_args::RemapArg {
620                node: r.node.as_deref(),
621                from: r.from.as_str(),
622                to: r.to.as_str(),
623            });
624            if let Err(rule) = nros_node::ros_args::install_argv_remaps(executor, rules) {
625                refuse_ros_args(&RosArgsRefusal::DoesNotFit {
626                    node: rule.node,
627                    from: rule.from,
628                    to: rule.to,
629                });
630            }
631            #[cfg(feature = "param-store")]
632            if !self.ros_params.is_empty() {
633                let params = self
634                    .ros_params
635                    .iter()
636                    .map(|p| nros_node::ros_args::ParamArg {
637                        node: p.node.as_deref(),
638                        name: p.name.as_str(),
639                        value: p.value.as_str(),
640                    });
641                if executor.install_argv_params(params).is_err() {
642                    refuse_ros_args(&RosArgsRefusal::NoParamStore);
643                }
644            }
645        }
646        #[cfg(not(feature = "env"))]
647        let _ = executor;
648    }
649
650    /// OURS — [`create_executor`](Self::create_executor) with the storage
651    /// supplied by the caller instead of leaked.
652    ///
653    /// The freestanding form. An RTOS or bare-metal entry owns a
654    /// `static mut [MaybeUninit<u64>; N]` (or the array the `nros::main!`
655    /// macro sizes for it) and hands it in; nothing is allocated, and the
656    /// returned executor borrows the backing for `'b` rather than living
657    /// forever. rclrs has no counterpart — it allocates.
658    ///
659    /// `backing` must hold at least `ExecutorSizing::DEFAULT.u64_len()` words.
660    /// A shorter slice is [`InitError::BackingTooSmall`] naming both numbers,
661    /// never undefined behaviour: this is the safe wrapper around the `unsafe`
662    /// `Executor::open_in`, and the length check is what makes it safe. Size
663    /// the array with `ExecutorSizing::DEFAULT.u64_len()` rather than a
664    /// literal, since the default moves with `NROS_EXECUTOR_MAX_CBS` and its
665    /// siblings.
666    ///
667    /// ```ignore
668    /// static mut BACKING: [MaybeUninit<u64>; N] = [MaybeUninit::uninit(); N];
669    /// let context = nros::Context::default_from_env()?;
670    /// // SAFETY: single-threaded entry, taken once.
671    /// let mut executor = context.create_executor_in(unsafe { &mut *&raw mut BACKING })?;
672    /// let mut node = executor.create_node("talker")?;
673    /// ```
674    pub fn create_executor_in<'b>(
675        &self,
676        backing: &'b mut [MaybeUninit<u64>],
677    ) -> Result<Executor<'b>, InitError> {
678        let sizing = ExecutorSizing::DEFAULT;
679        let needed = sizing.u64_len();
680        let given = backing.len();
681        if given < needed {
682            return Err(InitError::BackingTooSmall { needed, given });
683        }
684        // SAFETY: `backing` is at least `sizing.u64_len()` words (checked
685        // directly above), is `u64`-aligned by its element type, is uniquely
686        // borrowed for `'b`, and the returned `Executor<'b>` is the only thing
687        // that can reach it for that lifetime.
688        let mut executor = unsafe { Executor::open_in(&self.session_config(), backing, sizing) }
689            .map_err(InitError::ExecutorOpenFailed)?;
690        self.install_ros_args(&mut executor);
691        Ok(executor)
692    }
693}
694
695#[cfg(feature = "env")]
696fn read_env_context(source: ContextSource) -> Result<Context, InitError> {
697    // issue 0687 — through the ONE resolver, not a third parse of the same four
698    // variables. This function had its own copy, and the copies had drifted:
699    // it did not see the legacy `ZENOH_LOCATOR`/`ZENOH_MODE` spellings (now
700    // retired and refused by the shared reader), and it range-checked nothing, so
701    // `ROS_DOMAIN_ID=300` reached a backend that could only fail later.
702    //
703    // Issue 0330 — no backend default for the locator: `nros` is RMW-agnostic,
704    // so unset env leaves it EMPTY (= absent) and the linked backend
705    // substitutes its own (zenoh: `nros_rmw_zenoh::DEFAULT_LOCATOR`; xrce: its
706    // agent default; cyclonedds ignores the locator entirely). That is what
707    // `resolve_hosted` does with an empty `BootConfig` too.
708    let resolved =
709        crate::env::try_resolve_hosted(nros_node::BootConfig::default()).map_err(|e| match e {
710            nros_node::BootConfigError::DomainIdRange => InitError::DomainIdOutOfRange,
711            nros_node::BootConfigError::DomainIdParse => InitError::EnvParseFailed,
712            nros_node::BootConfigError::RetiredEnvVar { name, replacement } => {
713                InitError::RetiredEnvVar { name, replacement }
714            }
715        })?;
716    let locator = alloc::string::String::from(resolved.locator);
717    let domain_id = resolved.domain_id;
718    let mode = resolved.mode;
719    // issue 0687 — the `$NROS_RMW` half comes from the shared selector; the
720    // `RMW_IMPLEMENTATION` fallback stays HERE and only here. `Context.rmw` is
721    // a ROS-vocabulary HINT (`rmw_cyclonedds_cpp`), not the cffi registry
722    // selector (`cyclonedds`) — folding the two together would hand a ROS name
723    // to `resolve_backend`, which answers `Unknown` and fails the open.
724    let rmw = crate::rmw_selector()
725        .map(|s| alloc::string::String::from(s.as_str()))
726        .or_else(|| crate::env::var("RMW_IMPLEMENTATION").map(alloc::string::String::from))
727        .unwrap_or_default();
728    Ok(Context {
729        domain_id,
730        locator,
731        mode,
732        rmw,
733        source,
734        ros_args: alloc::vec::Vec::new(),
735        ros_params: alloc::vec::Vec::new(),
736    })
737}
738
739/// Pattern 3 — raw init, launch file ignored. The C++-symmetric anchor:
740/// `rclcpp::init()` in the Rust spelling, and EQUAL to
741/// [`Context::default_from_env`] on a hosted build (RFC-0089 "Context and
742/// `init`, settled" — each language follows its own upstream; rclrs spells
743/// this one on the type, rclcpp as a free function, and both are here).
744///
745/// Reads env vars (`ROS_DOMAIN_ID`, `NROS_LOCATOR`, `NROS_SESSION_MODE`,
746/// `NROS_RMW` / `RMW_IMPLEMENTATION`) and returns a [`Context`]. The
747/// caller owns the spin loop — typically `Executor::open(&ctx.config(name))`
748/// followed by `spin` or a hand-rolled `spin_once` loop.
749#[cfg(feature = "env")]
750pub fn init() -> Result<Context, InitError> {
751    read_env_context(ContextSource::Env)
752}
753
754/// The refusal [`init_with_args`] and [`Context::new`] emit for a ROS
755/// argument nano-ros does not honour. Panics carry this text followed by the
756/// refused argument. `NROS_RCLCPP_REFUSE_INIT_ARGV` is the C++ twin
757/// (`packages/api/nros-cpp/include/rclcpp/logging.hpp`), and since phase-467 Row 11's
758/// C++ half it states the same honoured and refused sets, from the same parser.
759#[cfg(feature = "env")]
760pub const REFUSE_INIT_ARGS: &str = "nros::init_with_args / nros::Context::new was given a --ros-args \
761argument nano-ros cannot honour (RFC-0089, phase-417 W3.b). Proceeding would DISCARD it -- the \
762'compiles and differs' the rule forbids. HONOURED inside --ros-args ... --: -r / --remap \
763[node:]from:=to, applied as the FALLBACK beneath any remap the launch file projected for the same \
764name (RFC-0046; rcl's local-before-global), through Context::create_executor / \
765create_executor_in; and, in an image with a parameter store, -p / --param [node:]name:=value and \
766--params-file <path>, applied when a node declares the parameter (RFC-0015 section 9). REFUSED: \
767node-identity remaps (__node, __name, __ns), -e / --enclave, the log flags, any token that is not a \
768ROS flag, and -p / --params-file in an image without a parameter store. A nros sync image does not read argv at all: its launch \
769remaps and parameters are projected into the GENERATED ENTRY at BUILD time.";
770
771/// Does `args` open a `--ros-args` scope at all?
772///
773/// No longer the refusal's predicate — since the `-r` parse landed, a vector
774/// with `--ros-args` is parsed, and only what it cannot honour is refused
775/// (`nros_node::ros_args::parse_ros_args`). Kept because "will this argv be
776/// read?" is still a question a caller can ask before handing it over.
777///
778/// Exact match only. `--ros-args-extra` is not the flag, and a prefix match
779/// would claim an argument nano-ros never reads — pinned by a unit test.
780#[cfg(feature = "env")]
781pub fn args_have_ros_args<I, S>(args: I) -> bool
782where
783    I: IntoIterator<Item = S>,
784    S: AsRef<str>,
785{
786    args.into_iter().any(|a| a.as_ref() == "--ros-args")
787}
788
789/// Why a `--ros-args` vector, or a context carrying one, was refused.
790#[cfg(feature = "env")]
791enum RosArgsRefusal<'a> {
792    /// The parse itself refused an argument.
793    Parse(nros_node::ros_args::RosArgsError<'a>),
794    /// A parsed rule did not fit the executor's remap table.
795    #[cfg(feature = "rmw-cffi")]
796    DoesNotFit {
797        node: Option<&'a str>,
798        from: &'a str,
799        to: &'a str,
800    },
801    /// [`Context::config`] was asked for a config that cannot carry the rules.
802    UnreachableExecutor,
803    /// phase-482 W5 — `--params-file <path>` could not be read or parsed.
804    #[cfg(feature = "param-store")]
805    ParamsFile {
806        path: alloc::string::String,
807        why: alloc::string::String,
808    },
809    /// phase-482 W5 — the parameter store could not be allocated (issue 1706).
810    #[cfg(all(feature = "param-store", feature = "rmw-cffi"))]
811    NoParamStore,
812}
813
814#[cfg(feature = "env")]
815impl core::fmt::Display for RosArgsRefusal<'_> {
816    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
817        match self {
818            Self::Parse(e) => e.fmt(f),
819            #[cfg(feature = "rmw-cffi")]
820            Self::DoesNotFit { node, from, to } => {
821                let node = node.map(|n| alloc::format!("{n}:")).unwrap_or_default();
822                write!(
823                    f,
824                    "`-r {node}{from}:={to}` does not fit the executor's remap table (MAX_REMAPS rules, \
825                     shared with launch remaps; names up to {} bytes)",
826                    nros_node::names::MAX_RESOLVED_NAME_LEN
827                )
828            }
829            Self::UnreachableExecutor => f.write_str(
830                "Context::config() cannot carry this context's --ros-args remaps or parameter \
831                 overrides into the executor Executor::open builds from it; use \
832                 Context::create_executor() or create_executor_in(), which install them",
833            ),
834            #[cfg(feature = "param-store")]
835            Self::ParamsFile { path, why } => write!(f, "`--params-file {path}`: {why}"),
836            #[cfg(all(feature = "param-store", feature = "rmw-cffi"))]
837            Self::NoParamStore => f.write_str(
838                "the parameter store could not be allocated, so the parameter overrides have \
839                 nowhere to go",
840            ),
841        }
842    }
843}
844
845/// The one refusal site: logged through `nros_log` (never `std::println!`,
846/// issue 0589), then a panic carrying [`REFUSE_INIT_ARGS`] and the reason —
847/// the shape `rclcpp::init(argc, argv)` has in `nros.hpp`, and the shape
848/// rclcpp's own `UnknownROSArgsError` has.
849#[cfg(feature = "env")]
850fn refuse_ros_args(why: &RosArgsRefusal<'_>) -> ! {
851    nros_log::log_error!(
852        nros_log::get_logger("nros"),
853        "{}\n  refused: {}",
854        REFUSE_INIT_ARGS,
855        why
856    );
857    panic!("{REFUSE_INIT_ARGS}\n  refused: {why}");
858}
859
860/// Parse `args` (rcl's grammar — `nros_node::ros_args`) into owned rules, or
861/// refuse. Shared by [`init_with_args`] and [`Context::new`], so the two stay
862/// one behaviour.
863#[cfg(feature = "env")]
864fn parse_ros_args_or_refuse<I, S>(
865    args: I,
866) -> (alloc::vec::Vec<ArgvRemap>, alloc::vec::Vec<ArgvParam>)
867where
868    I: IntoIterator<Item = S>,
869    S: AsRef<str>,
870{
871    use alloc::string::String;
872    let owned: alloc::vec::Vec<String> =
873        args.into_iter().map(|a| String::from(a.as_ref())).collect();
874    let mut rules = alloc::vec::Vec::new();
875    #[cfg_attr(not(feature = "param-store"), allow(unused_mut))]
876    let mut params: alloc::vec::Vec<ArgvParam> = alloc::vec::Vec::new();
877    let remap = |r: nros_node::ros_args::RemapArg<'_>| ArgvRemap {
878        node: r.node.map(String::from),
879        from: String::from(r.from),
880        to: String::from(r.to),
881    };
882    // phase-482 W5 — with a parameter store, `-p` and `--params-file` are
883    // honoured, each file's entries landing where its flag stood (so a later
884    // `-p` overrides it and a later file overrides an earlier `-p`). Without
885    // one, the remap-only parse refuses them by name.
886    #[cfg(feature = "param-store")]
887    let parsed = {
888        let mut events = alloc::vec::Vec::new();
889        let r = nros_node::ros_args::parse_ros_args_events(owned.iter().map(|a| a.as_str()), |a| {
890            events.push(a)
891        });
892        if r.is_ok() {
893            for a in events {
894                match a {
895                    nros_node::ros_args::RosArg::Remap(r) => rules.push(remap(r)),
896                    nros_node::ros_args::RosArg::Param(p) => params.push(ArgvParam {
897                        node: p.node.map(String::from),
898                        name: String::from(p.name),
899                        value: String::from(p.value),
900                    }),
901                    nros_node::ros_args::RosArg::ParamsFile(path) => {
902                        // phase-359 W10 stage B — the C library's read, beside
903                        // `getenv`, so the hosted entry needs no `std`.
904                        let text = match crate::env::read_file(path) {
905                            Ok(t) => t,
906                            Err(why) => refuse_ros_args(&RosArgsRefusal::ParamsFile {
907                                path: String::from(path),
908                                why: String::from(why),
909                            }),
910                        };
911                        if let Err(e) = nros_node::ros_args::parse_params_yaml(&text, |p| {
912                            params.push(ArgvParam {
913                                node: p.node.map(String::from),
914                                name: String::from(p.name),
915                                value: String::from(p.value),
916                            })
917                        }) {
918                            refuse_ros_args(&RosArgsRefusal::ParamsFile {
919                                path: String::from(path),
920                                why: alloc::format!("{e}"),
921                            });
922                        }
923                    }
924                }
925            }
926        }
927        r
928    };
929    #[cfg(not(feature = "param-store"))]
930    let parsed = nros_node::ros_args::parse_ros_args(owned.iter().map(|a| a.as_str()), |r| {
931        rules.push(remap(r))
932    });
933    if let Err(e) = parsed {
934        refuse_ros_args(&RosArgsRefusal::Parse(e));
935    }
936    (rules, params)
937}
938
939/// Pattern 3 — like [`init()`] but takes the process arguments, the way
940/// `rclcpp::init(argc, argv)` / rclrs's `Context::new(args, ..)` do. The
941/// free-function twin of [`Context::new`] with default options.
942///
943/// # What it does with the arguments
944///
945/// It parses `--ros-args` with rcl's grammar (Humble `rcl/arguments.h`): ROS
946/// arguments live between `--ros-args` and `--` (or the end), several scopes
947/// may appear, and everything outside them is the program's own and is left
948/// alone.
949///
950/// * **`-r` / `--remap [node:]from:=to` — honoured.** The rules ride on the
951///   returned [`Context`] and are installed in every executor it creates
952///   (`Context::create_executor` / `Context::create_executor_in`) as the
953///   FALLBACK remap tier: a launch-projected rule for the same name wins
954///   (RFC-0046), exactly as rcl checks a node's local arguments before the
955///   process's global ones. They reach entities on every road — the handle
956///   `Executor::create_node` returns, `nros::node!` components, and the C and
957///   C++ APIs.
958/// * **`-p` / `--param [node:]name:=value` and `--params-file <path>` —
959///   honoured when `nros` has `param-services`** (phase-482 W5, RFC-0015 §9's
960///   POSIX row). They ride on the [`Context`] in argv order and are applied
961///   when a node DECLARES the parameter: the last matching override wins, its
962///   text typed by the declared default, and a `node:` override reaches only
963///   the node of that name. The envelope, stated here because it is the API:
964///   a parameter file is read with `std::fs` and holds scalars only (arrays,
965///   anchors and block scalars are refused, with their line), a node key
966///   matches by NAME not namespace, and an override that does not parse as
967///   the declared type is logged and the default kept.
968/// * **Everything else inside a scope — refused, loudly**: node-identity
969///   remaps, enclaves, log flags, unknown tokens, and `-p` / `--params-file`
970///   in an image with no parameter store. Logged through `nros_log`, then a panic
971///   carrying [`REFUSE_INIT_ARGS`] and the argument. rclcpp refuses unknown
972///   ROS arguments too (`UnknownROSArgsError`).
973///
974/// # Where the rules do NOT reach
975///
976/// [`Context::config`] refuses a context that carries rules, because an
977/// `ExecutorConfig` cannot carry them. Entities created through the
978/// executor's own `add_*` registration methods or a `node_mut` context are
979/// not remapped either: those are also where the C ABI and the component
980/// sink hand their ALREADY-RESOLVED names, so remapping there would apply a
981/// rule twice.
982#[cfg(feature = "env")]
983pub fn init_with_args<I, S>(args: I) -> Result<Context, InitError>
984where
985    I: IntoIterator<Item = S>,
986    S: AsRef<str>,
987{
988    let (ros_args, ros_params) = parse_ros_args_or_refuse(args);
989    let mut context = init()?;
990    context.ros_args = ros_args;
991    context.ros_params = ros_params;
992    Ok(context)
993}
994
995#[cfg(all(test, feature = "env"))]
996mod ros_args_refusal_tests {
997    use super::*;
998
999    #[test]
1000    fn predicate_matches_the_flag_exactly() {
1001        let _env = crate::env::test_env_lock();
1002        assert!(args_have_ros_args(["node", "--ros-args", "-p", "x:=1"]));
1003        assert!(args_have_ros_args(["--ros-args"]));
1004        // A prefix match would refuse an argument nano-ros never drops.
1005        assert!(!args_have_ros_args(["--ros-args-extra"]));
1006        assert!(!args_have_ros_args(["--ros-arg"]));
1007        assert!(!args_have_ros_args(["node", "--verbose"]));
1008        assert!(!args_have_ros_args(core::iter::empty::<&str>()));
1009    }
1010
1011    fn remap(node: Option<&str>, from: &str, to: &str) -> ArgvRemap {
1012        ArgvRemap {
1013            node: node.map(alloc::string::String::from),
1014            from: alloc::string::String::from(from),
1015            to: alloc::string::String::from(to),
1016        }
1017    }
1018
1019    #[test]
1020    fn remap_rules_ride_on_the_context_in_argv_order() {
1021        let _env = crate::env::test_env_lock();
1022        let ctx = init_with_args([
1023            "/usr/bin/talker",
1024            "--ros-args",
1025            "-r",
1026            "chatter:=/other",
1027            "--remap",
1028            "talker:~/out:=/wire",
1029            "--",
1030            "positional",
1031        ])
1032        .expect("init");
1033        assert_eq!(
1034            ctx.ros_args,
1035            [
1036                remap(None, "chatter", "/other"),
1037                remap(Some("talker"), "~/out", "/wire")
1038            ]
1039        );
1040    }
1041
1042    #[test]
1043    fn an_empty_scope_is_not_a_refusal() {
1044        let _env = crate::env::test_env_lock();
1045        assert!(
1046            init_with_args(["--ros-args"])
1047                .expect("init")
1048                .ros_args
1049                .is_empty()
1050        );
1051    }
1052
1053    /// Without a parameter store there is nowhere to put an override, so the
1054    /// parse refuses it by name rather than dropping it.
1055    #[cfg(not(feature = "param-store"))]
1056    #[test]
1057    #[should_panic(expected = "refused: `-p`")]
1058    fn parameter_overrides_are_still_refused_by_name() {
1059        let _env = crate::env::test_env_lock();
1060        let _ = init_with_args(["/usr/bin/talker", "--ros-args", "-r", "a:=b", "-p", "x:=1"]);
1061    }
1062
1063    #[cfg(feature = "param-store")]
1064    fn param(node: Option<&str>, name: &str, value: &str) -> ArgvParam {
1065        ArgvParam {
1066            node: node.map(alloc::string::String::from),
1067            name: alloc::string::String::from(name),
1068            value: alloc::string::String::from(value),
1069        }
1070    }
1071
1072    /// phase-482 W5 — `-p` and a `--params-file` ride on the context in argv
1073    /// order, the file's entries where its flag stood, beside the remaps.
1074    #[cfg(feature = "param-store")]
1075    #[test]
1076    fn parameter_overrides_ride_on_the_context_in_argv_order() {
1077        let _env = crate::env::test_env_lock();
1078        let path =
1079            std::env::temp_dir().join(alloc::format!("nros-w5-params-{}.yaml", std::process::id()));
1080        std::fs::write(
1081            &path,
1082            "/**:\n  ros__parameters:\n    rate: 30\n/talker:\n  ros__parameters:\n    gains:\n      kp: 1.5\n",
1083        )
1084        .unwrap();
1085        let path_s = path.to_str().unwrap();
1086        let ctx = init_with_args([
1087            "/usr/bin/talker",
1088            "--ros-args",
1089            "-p",
1090            "rate:=10",
1091            "--params-file",
1092            path_s,
1093            "-r",
1094            "chatter:=/other",
1095            "-p",
1096            "talker:label:=hi",
1097        ])
1098        .expect("init");
1099        let _ = std::fs::remove_file(&path);
1100        assert_eq!(
1101            ctx.ros_params,
1102            [
1103                param(None, "rate", "10"),
1104                param(None, "rate", "30"),
1105                param(Some("talker"), "gains.kp", "1.5"),
1106                param(Some("talker"), "label", "hi"),
1107            ]
1108        );
1109        assert_eq!(ctx.ros_args, [remap(None, "chatter", "/other")]);
1110    }
1111
1112    /// An unreadable parameter file is refused, naming the path — never a
1113    /// silently empty set of overrides.
1114    #[cfg(feature = "param-store")]
1115    #[test]
1116    #[should_panic(expected = "--params-file /nonexistent/nros-w5.yaml")]
1117    fn an_unreadable_params_file_is_refused_by_path() {
1118        let _env = crate::env::test_env_lock();
1119        let _ = init_with_args(["--ros-args", "--params-file", "/nonexistent/nros-w5.yaml"]);
1120    }
1121
1122    #[test]
1123    #[should_panic(expected = "remaps a node's identity")]
1124    fn identity_remaps_are_refused() {
1125        let _env = crate::env::test_env_lock();
1126        let _ = init_with_args(["--ros-args", "-r", "__ns:=/robot"]);
1127    }
1128
1129    #[test]
1130    #[should_panic(expected = "Context::config() cannot carry")]
1131    fn config_refuses_a_context_that_carries_remaps() {
1132        let _env = crate::env::test_env_lock();
1133        let ctx = init_with_args(["--ros-args", "-r", "chatter:=/other"]).expect("init");
1134        let _ = ctx.config("talker");
1135    }
1136
1137    #[test]
1138    fn config_is_unaffected_without_remaps() {
1139        let _env = crate::env::test_env_lock();
1140        let ctx = init_with_args(["/usr/bin/talker", "--verbose"]).expect("init");
1141        assert_eq!(ctx.config("talker").node_name, "talker");
1142    }
1143
1144    #[test]
1145    fn plain_args_pass_through_to_init() {
1146        let _env = crate::env::test_env_lock();
1147        // Same answer as `init()` — the arguments are not consulted, and no
1148        // refusal fires. Both read the same environment, so compare the
1149        // resolved knobs rather than asserting a particular value.
1150        let via_args = init_with_args(["/usr/bin/talker", "--verbose", "positional"]);
1151        let plain = init();
1152        match (via_args, plain) {
1153            (Ok(a), Ok(b)) => {
1154                assert_eq!(a.domain_id, b.domain_id);
1155                assert_eq!(a.locator, b.locator);
1156                assert_eq!(a.rmw, b.rmw);
1157            }
1158            (Err(a), Err(b)) => assert_eq!(a, b),
1159            (a, b) => panic!("init_with_args and init disagree: {a:?} vs {b:?}"),
1160        }
1161    }
1162
1163    // phase-427 W9 — the rclrs family on a hosted build.
1164
1165    fn same_identity(a: &Context, b: &Context) {
1166        assert_eq!(a.domain_id, b.domain_id);
1167        assert_eq!(a.locator, b.locator);
1168        assert_eq!(a.mode, b.mode);
1169        assert_eq!(a.rmw, b.rmw);
1170        assert_eq!(a.source, b.source);
1171    }
1172
1173    #[test]
1174    fn default_from_env_is_init() {
1175        let _env = crate::env::test_env_lock();
1176        match (Context::default_from_env(), init()) {
1177            (Ok(a), Ok(b)) => {
1178                same_identity(&a, &b);
1179                assert_eq!(a.source, ContextSource::Env);
1180            }
1181            (Err(a), Err(b)) => assert_eq!(a, b),
1182            (a, b) => panic!("default_from_env and init disagree: {a:?} vs {b:?}"),
1183        }
1184    }
1185
1186    #[test]
1187    fn from_env_honours_the_domain_override() {
1188        let _env = crate::env::test_env_lock();
1189        let env = match init() {
1190            Ok(c) => c,
1191            Err(e) => panic!("init() failed on the test host: {e:?}"),
1192        };
1193        // A value the environment is not carrying, so the override is visible
1194        // whatever `ROS_DOMAIN_ID` says.
1195        let wanted = if env.domain_id == 7 { 9 } else { 7 };
1196        let ctx = Context::from_env(InitOptions::new().with_domain_id(Some(wanted as usize)))
1197            .expect("from_env with a valid override");
1198        assert_eq!(ctx.domain_id, wanted);
1199        // Everything else is untouched.
1200        assert_eq!(ctx.locator, env.locator);
1201        assert_eq!(ctx.rmw, env.rmw);
1202        assert_eq!(ctx.source, ContextSource::Env);
1203    }
1204
1205    #[test]
1206    fn from_env_with_no_override_keeps_the_env_value() {
1207        let _env = crate::env::test_env_lock();
1208        match (
1209            Context::from_env(InitOptions::new().with_domain_id(None)),
1210            init(),
1211        ) {
1212            (Ok(a), Ok(b)) => same_identity(&a, &b),
1213            (Err(a), Err(b)) => assert_eq!(a, b),
1214            (a, b) => panic!("from_env(None) and init disagree: {a:?} vs {b:?}"),
1215        }
1216    }
1217
1218    #[test]
1219    fn from_env_rejects_an_out_of_range_override() {
1220        let _env = crate::env::test_env_lock();
1221        if init().is_err() {
1222            // The environment itself is invalid; the override is never reached.
1223            return;
1224        }
1225        assert_eq!(
1226            Context::from_env(InitOptions::new().with_domain_id(Some(DOMAIN_ID_MAX as usize + 1)))
1227                .map(|c| c.domain_id),
1228            Err(InitError::DomainIdOutOfRange)
1229        );
1230        // A value no `u32` can hold saturates in the builder and is refused
1231        // here, not truncated into a plausible small number.
1232        assert_eq!(
1233            Context::from_env(InitOptions::new().with_domain_id(Some(usize::MAX)))
1234                .map(|c| c.domain_id),
1235            Err(InitError::DomainIdOutOfRange)
1236        );
1237    }
1238
1239    #[test]
1240    #[should_panic(expected = "refused: `--enclave`")]
1241    fn context_new_refuses_what_it_cannot_honour() {
1242        let _env = crate::env::test_env_lock();
1243        let _ = Context::new(
1244            [
1245                "/usr/bin/talker",
1246                "--ros-args",
1247                "-r",
1248                "chatter:=/other",
1249                "--enclave",
1250                "/e",
1251            ],
1252            InitOptions::new(),
1253        );
1254    }
1255
1256    #[test]
1257    fn context_new_parses_exactly_like_init_with_args() {
1258        let _env = crate::env::test_env_lock();
1259        let argv = ["/usr/bin/talker", "--ros-args", "-r", "chatter:=/other"];
1260        let a = Context::new(argv, InitOptions::new()).expect("Context::new");
1261        let b = init_with_args(argv).expect("init_with_args");
1262        assert_eq!(a.ros_args, b.ros_args);
1263        assert_eq!(a.ros_args, [remap(None, "chatter", "/other")]);
1264    }
1265
1266    #[test]
1267    fn context_new_passes_plain_args_through() {
1268        let _env = crate::env::test_env_lock();
1269        let opts = InitOptions::new().with_domain_id(Some(3));
1270        match (
1271            Context::new(["/usr/bin/talker", "--verbose", "positional"], opts),
1272            Context::from_env(opts),
1273        ) {
1274            (Ok(a), Ok(b)) => {
1275                same_identity(&a, &b);
1276                assert_eq!(a.domain_id, 3);
1277            }
1278            (Err(a), Err(b)) => assert_eq!(a, b),
1279            (a, b) => panic!("Context::new and from_env disagree: {a:?} vs {b:?}"),
1280        }
1281    }
1282
1283    #[test]
1284    fn context_new_accepts_rclrs_argv_shape() {
1285        let _env = crate::env::test_env_lock();
1286        // `impl IntoIterator<Item = String>` is what rclrs takes; ours must
1287        // accept the same call unchanged.
1288        let args: alloc::vec::Vec<alloc::string::String> =
1289            alloc::vec![alloc::string::String::from("talker")];
1290        let _ = Context::new(args, InitOptions::default());
1291    }
1292}
1293
1294/// phase-427 W9 — the freestanding constructor's parse, with the constants
1295/// injected. Runs on the `alloc` floor (`cargo test -p nros --features alloc
1296/// --lib`); nothing here reads the environment.
1297#[cfg(all(test, feature = "alloc"))]
1298mod baked_tests {
1299    use super::*;
1300
1301    #[test]
1302    fn nothing_baked_is_domain_zero_and_an_absent_locator() {
1303        let ctx = Context::from_baked(None, None).expect("nothing baked is valid");
1304        assert_eq!(ctx.domain_id, 0);
1305        assert_eq!(ctx.locator, "");
1306        assert_eq!(ctx.mode, SessionMode::Client);
1307        assert_eq!(ctx.rmw, "");
1308        assert_eq!(ctx.source, ContextSource::Baked);
1309    }
1310
1311    #[test]
1312    fn baked_values_are_carried() {
1313        let ctx = Context::from_baked(Some("tcp/127.0.0.1:7456"), Some("42")).expect("valid bake");
1314        assert_eq!(ctx.locator, "tcp/127.0.0.1:7456");
1315        assert_eq!(ctx.domain_id, 42);
1316        assert_eq!(ctx.source, ContextSource::Baked);
1317        // The config it materialises is the one the macros used to assemble
1318        // by hand: locator, domain, node name, client mode.
1319        let cfg = ctx.config("talker");
1320        assert_eq!(cfg.locator, "tcp/127.0.0.1:7456");
1321        assert_eq!(cfg.domain_id, 42);
1322        assert_eq!(cfg.node_name, "talker");
1323        assert_eq!(cfg.mode, SessionMode::Client);
1324    }
1325
1326    #[test]
1327    fn an_empty_baked_locator_is_absent() {
1328        let ctx = Context::from_baked(Some(""), None).expect("empty is absent, not an error");
1329        assert_eq!(ctx.locator, "");
1330    }
1331
1332    #[test]
1333    fn a_non_numeric_baked_domain_is_refused() {
1334        assert_eq!(
1335            Context::from_baked(None, Some("seven")).map(|c| c.domain_id),
1336            Err(InitError::EnvParseFailed)
1337        );
1338        assert_eq!(
1339            Context::from_baked(None, Some("-1")).map(|c| c.domain_id),
1340            Err(InitError::EnvParseFailed)
1341        );
1342    }
1343
1344    #[test]
1345    fn an_out_of_range_baked_domain_is_refused() {
1346        // The bound `try_resolve_hosted` applies, so an image cannot be built
1347        // onto a domain a hosted process would refuse to boot on.
1348        assert_eq!(
1349            Context::from_baked(None, Some("300")).map(|c| c.domain_id),
1350            Err(InitError::DomainIdOutOfRange)
1351        );
1352        assert_eq!(
1353            Context::from_baked(None, Some("232")).map(|c| c.domain_id),
1354            Ok(DOMAIN_ID_MAX)
1355        );
1356    }
1357
1358    #[test]
1359    fn locator_override_applies_only_when_present_and_non_empty() {
1360        let baked = || Context::from_baked(Some("tcp/10.0.2.2:7447"), Some("1")).unwrap();
1361        assert_eq!(
1362            baked()
1363                .with_locator_override(Some("tcp/127.0.0.1:7456"))
1364                .locator,
1365            "tcp/127.0.0.1:7456"
1366        );
1367        assert_eq!(
1368            baked().with_locator_override(None).locator,
1369            "tcp/10.0.2.2:7447"
1370        );
1371        assert_eq!(
1372            baked().with_locator_override(Some("")).locator,
1373            "tcp/10.0.2.2:7447"
1374        );
1375        // The override touches nothing else.
1376        let ctx = baked().with_locator_override(Some("tcp/127.0.0.1:7456"));
1377        assert_eq!(ctx.domain_id, 1);
1378        assert_eq!(ctx.source, ContextSource::Baked);
1379    }
1380
1381    #[test]
1382    fn baked_session_properties_take_the_locators_place() {
1383        let baked = || Context::from_baked(Some("tcp/10.0.2.2:7447"), Some("1")).unwrap();
1384        // Issue 2051 — a run-time locator, applied after, still wins.
1385        let connect = [nros_node::SessionProperty::new(
1386            c"connect",
1387            c"tcp/10.0.0.9:7499",
1388        )];
1389        assert_eq!(
1390            baked()
1391                .yield_locator_to_session_properties(&connect)
1392                .locator,
1393            ""
1394        );
1395        assert_eq!(
1396            baked().yield_locator_to_session_properties(&[]).locator,
1397            baked().locator
1398        );
1399        assert_eq!(
1400            baked()
1401                .yield_locator_to_session_properties(&connect)
1402                .with_locator_override(Some("tcp/127.0.0.1:7456"))
1403                .locator,
1404            "tcp/127.0.0.1:7456"
1405        );
1406    }
1407
1408    /// phase-467 row `rust:Context::domain_id`. Two claims, and the second is
1409    /// the one worth a test: the accessor answers the field, and it answers it
1410    /// as a `usize` — rclrs's type — so a ported `let d: usize =
1411    /// ctx.domain_id();` compiles. The annotation is the assertion; without it
1412    /// an integer literal would infer whatever the accessor returns and the
1413    /// test would pass against a `u32` too.
1414    #[test]
1415    fn context_domain_id_reads_the_field_as_rclrs_types_it() {
1416        let ctx = Context::from_baked(Some("tcp/10.0.2.2:7447"), Some("7")).unwrap();
1417        let d: usize = ctx.domain_id();
1418        assert_eq!(d, 7);
1419        assert_eq!(ctx.domain_id(), ctx.domain_id as usize);
1420        // The field did not move: `Context` is a plain struct and both
1421        // spellings reach the same byte.
1422        assert_eq!(ctx.domain_id, 7u32);
1423    }
1424
1425    #[test]
1426    fn baked_reads_this_crates_build_environment() {
1427        // The values depend on the build; the SHAPE does not. Whatever was
1428        // baked, the source is `Baked`, and a parse failure would have come
1429        // from a bake this test cannot control — report it rather than hide it.
1430        match Context::baked() {
1431            Ok(ctx) => assert_eq!(ctx.source, ContextSource::Baked),
1432            Err(e) => panic!(
1433                "NROS_DOMAIN_ID was baked into this test build with a value the constructor refuses: {e:?}"
1434            ),
1435        }
1436    }
1437
1438    #[test]
1439    fn init_options_default_overrides_nothing() {
1440        assert_eq!(InitOptions::new(), InitOptions::default());
1441        assert_eq!(InitOptions::new().domain_id(), None);
1442        assert_eq!(
1443            InitOptions::new().with_domain_id(Some(5)).domain_id(),
1444            Some(5)
1445        );
1446        assert_eq!(
1447            InitOptions::new()
1448                .with_domain_id(Some(5))
1449                .with_domain_id(None)
1450                .domain_id(),
1451            None
1452        );
1453        // Saturates rather than truncates: `u32::MAX + 1` must not read as 0.
1454        // Reported back as a `usize`, which is the getter's type since
1455        // phase-467 — the saturation is in the STORED `u32` and the getter
1456        // only widens it, so this is the one place the two widths are both
1457        // visible in one line.
1458        assert_eq!(
1459            InitOptions::new()
1460                .with_domain_id(Some(u32::MAX as usize + 1))
1461                .domain_id(),
1462            Some(u32::MAX as usize)
1463        );
1464        // The in-place spelling is the same operation.
1465        let mut opts = InitOptions::new();
1466        opts.set_domain_id(Some(9));
1467        assert_eq!(opts, InitOptions::new().with_domain_id(Some(9)));
1468        opts.set_domain_id(None);
1469        assert_eq!(opts, InitOptions::new());
1470    }
1471
1472    /// phase-427 W10 — the two variants `create_executor*` can produce say
1473    /// WHICH thing went wrong, in words. `alloc` only: the errors are values
1474    /// and need no RMW to construct, which is the point of testing them here
1475    /// rather than beside a session.
1476    #[test]
1477    fn the_executor_errors_say_what_happened() {
1478        use alloc::string::ToString as _;
1479
1480        let too_small = InitError::BackingTooSmall {
1481            needed: 4096,
1482            given: 8,
1483        }
1484        .to_string();
1485        // Both numbers, because "too small" without them tells a caller
1486        // nothing they can act on.
1487        assert!(too_small.contains("4096"), "{too_small}");
1488        assert!(too_small.contains('8'), "{too_small}");
1489
1490        // The backend's own error survives the wrapping — issue 0465's rule:
1491        // an exhausted table and a missing router must not read alike.
1492        let open_failed = InitError::ExecutorOpenFailed(NodeError::NodeTableFull).to_string();
1493        assert!(open_failed.contains("NodeTableFull"), "{open_failed}");
1494        assert_ne!(
1495            open_failed,
1496            InitError::ExecutorOpenFailed(NodeError::NameTooLong).to_string()
1497        );
1498    }
1499
1500    #[cfg(not(feature = "env"))]
1501    #[test]
1502    fn freestanding_default_from_env_is_the_bake() {
1503        // Without `env` there is no process environment to read; the family
1504        // resolves to the bake, and the override still lands on top of it.
1505        let ctx = Context::default_from_env().expect("bake");
1506        assert_eq!(ctx.source, ContextSource::Baked);
1507        let ctx = Context::from_env(InitOptions::new().with_domain_id(Some(11))).expect("bake");
1508        assert_eq!(ctx.domain_id, 11);
1509        assert_eq!(ctx.source, ContextSource::Baked);
1510    }
1511}