Skip to main content

nros/
env.rs

1//! The hosted edge of configuration: the process environment.
2//!
3//! issue 0687 / phase-359 W10. Every env var nano-ros honours is read HERE and
4//! nowhere else. The core (`nros-node`) takes values — `ExecutorConfig::
5//! resolve_with` accepts an [`EnvRung`](nros_node::EnvRung) of already-resolved fields — so it
6//! needs no `std`, no `env` capability, and no stub on the five ports that have
7//! no environment to read.
8//!
9//! That is the difference between this and the ABI ports W10 made for the
10//! clock, sleep, tasks and the log sink: those name capabilities every RTOS
11//! HAS. A process environment is not one. Modelling it as an ABI entry would
12//! have put `return 0` in five ports and permanent surface in the header, for a
13//! facility that exists on one platform family.
14//!
15//! What lives here:
16//!
17//! * [`ExecutorConfigEnvExt::from_env`] — the constructor that used to be an
18//!   inherent method on `ExecutorConfig`. Bring the trait into scope (the
19//!   prelude has it) and the call site spelling is unchanged.
20//! * [`resolve_hosted`](crate::env::resolve_hosted) / [`try_resolve_hosted`](crate::env::try_resolve_hosted) — RFC-0045 precedence model A
21//!   with the environment rung on top. These replace the old
22//!   `ExecutorConfig::resolve(baked, hosted_env: bool)`, whose flag was a
23//!   compile-time constant at every call site in the tree: `true` at exactly
24//!   one board plus the two FFI entries, `false` everywhere else.
25//! * [`rmw_selector`] — the one `$NROS_RMW` reader (issue 0687's first half).
26
27use crate::retired_env::RETIRED_ENV_VARS;
28use alloc::{
29    boxed::Box,
30    string::{String, ToString},
31};
32use nros_node::{BootConfig, BootConfigError, EnvRung, ExecutorConfig, RMW_SELECTOR_CAP};
33use nros_rmw::SessionMode;
34
35/// A frozen snapshot of the environment, and the backing store for the
36/// `&'static str` fields a resolved [`ExecutorConfig`] hands out.
37struct EnvCache {
38    locator: String,
39    mode: SessionMode,
40    /// RFC-0045 model A — `NROS_NODE_NAME` env rung (issue #206 parity).
41    node_name: String,
42    /// RFC-0045 model A — `NROS_NODE_NAMESPACE` env rung (issue 0794). The
43    /// ladder was uneven: every other identity field had three rungs and the
44    /// namespace had two.
45    namespace: String,
46    /// issue 0687 — `$NROS_RMW`, through [`rmw_selector`] so the snapshot and
47    /// the live reader cannot disagree about what "unset" means.
48    rmw: Option<String>,
49}
50
51// `std::sync::OnceLock`, and it stays that way deliberately. The portable
52// `nros_rmw::sync::Mutex` would cost a `spin` edge to remove a `std::` path
53// from a module whose next line calls `std::env::var` — this whole file is
54// compiled only under `env`, which REQUIRES `std` because a process
55// environment is a `std` facility. Porting it would move the census number
56// without changing what the build links, which is the definition of gaming the
57// ratchet. (`metadata_mode`'s mutex was the opposite case and did move: there
58// the lock was the ONLY std thing, so porting it made the capability
59// `alloc`-only.)
60static ENV_CACHE: std::sync::OnceLock<EnvCache> = std::sync::OnceLock::new();
61
62/// The process-global env cache — and why tests do not share it.
63///
64/// Issue 0607 — resolution reads env PRESENCE live but env VALUES from here,
65/// and a `OnceLock` freezes at whichever caller touched it first. Under
66/// `cargo test` every unit test shares one process, so a later test's
67/// `EnvGuard` moves what `std::env::var` reports while the cached VALUE stays
68/// behind. The two then disagree:
69///
70/// ```text
71/// assertion `left == right` failed
72///   left: ""
73///  right: "tcp/env:7447"
74/// ```
75///
76/// It failed ~1 run in 5, never single-threaded, and a mutex cannot help: a
77/// mutex serialises MUTATION, and this is a stale READ of a cache already
78/// populated. Two tests had been weakened to tolerate it.
79///
80/// So tests rebuild per call — live env is the only coherent answer when the
81/// env is what they are varying. Production keeps the `OnceLock`: nothing there
82/// mutates the environment, so freezing it once is both correct and the point.
83fn env_cache() -> &'static EnvCache {
84    fn build() -> EnvCache {
85        // phase-484 W8c — the legacy `ZENOH_*` aliases are RETIRED; `env_rung`
86        // refuses them before this cache is consulted, so only the canonical
87        // names are read here.
88        let locator = std::env::var("NROS_LOCATOR")
89            // Issue 0330 — unset env leaves the locator EMPTY (= absent); the
90            // active backend substitutes its own default.
91            .unwrap_or_default();
92        let mode_str = std::env::var("NROS_SESSION_MODE").ok();
93        let mode = match mode_str.as_deref() {
94            Some("peer") => SessionMode::Peer,
95            _ => SessionMode::Client,
96        };
97        let node_name = std::env::var("NROS_NODE_NAME").unwrap_or_default();
98        let namespace = std::env::var("NROS_NODE_NAMESPACE").unwrap_or_default();
99        EnvCache {
100            locator,
101            mode,
102            node_name,
103            namespace,
104            rmw: rmw_selector().map(|s| s.as_str().to_string()),
105        }
106    }
107
108    if cfg!(test) {
109        Box::leak(Box::new(build()))
110    } else {
111        ENV_CACHE.get_or_init(build)
112    }
113}
114
115/// **The** answer to "which RMW backend did the user select" — one reader, one
116/// semantic, for every consumer.
117///
118/// phase-359 W10 / issue 0687. This variable had FOUR readers with THREE
119/// semantics: `Executor::open` read it as raw OS bytes, `nros`'s
120/// `open_session` as a UTF-8 string filtered for empty, `nros-c`'s entry as a
121/// string passed through EVEN WHEN EMPTY, and `nros::init` as a string with an
122/// `RMW_IMPLEMENTATION` fallback the other three did not have. "Which backend
123/// did the user ask for" had four answers in one process.
124///
125/// Two decisions are baked in, and both are deliberate:
126///
127/// * **`$NROS_RMW` only.** `$RMW_IMPLEMENTATION` is NOT folded in, though it
128///   was tempting and one reader did it. The two carry different vocabularies:
129///   this selector is matched against the cffi registry's canonical names
130///   (`zenoh`, `dds`, `cyclonedds`), while `RMW_IMPLEMENTATION` holds ROS names
131///   (`rmw_cyclonedds_cpp`). Feeding a ROS name to `resolve_backend` yields
132///   `Unknown` — an ERROR — where today it is ignored and the unique-backend
133///   path runs. Unifying them without a mapping would convert "ignored" into
134///   "fails to start". [`crate::init`](fn@crate::init) keeps its fallback for the `Context.rmw`
135///   HINT, which is a different quantity.
136/// * **Empty or non-UTF-8 means unset.** A name that is not UTF-8 cannot match
137///   a registry entry, so treating it as absent is what the caller wants; the
138///   old raw-bytes reader would have reported `Unknown` instead.
139///
140/// The return is `heapless::String` rather than `String` because
141/// `RMW_SELECTOR_CAP` is a real bound, not a guess: it is the capacity of
142/// `Executor::primary_rmw_name`, so a longer value cannot name a registry slot
143/// and is reported as unset rather than truncated into a different backend's
144/// name.
145pub fn rmw_selector() -> Option<nros_core::heapless::String<RMW_SELECTOR_CAP>> {
146    let raw = std::env::var_os("NROS_RMW")?;
147    let s = raw.to_str()?;
148    if s.is_empty() {
149        return None;
150    }
151    nros_core::heapless::String::try_from(s).ok()
152}
153
154/// The environment as an [`EnvRung`], for [`ExecutorConfig::resolve_with`].
155///
156/// Presence is read LIVE (so a test that sets a var sees its effect) while
157/// values come from the frozen [`env_cache`] — that split is what gives the
158/// resolved config `&'static str` fields without leaking per call.
159fn env_rung() -> Result<EnvRung<'static>, BootConfigError> {
160    // phase-484 W8c — a retired alias is REFUSED, never ignored: an ignored
161    // `ZENOH_LOCATOR` dials the default router and reads as "no delivery".
162    // Presence alone refuses (an empty value is still somebody's intent), and
163    // the refusal is logged here so the C / C++ shims, which only get a
164    // return code, still name the replacement.
165    for &(name, replacement) in RETIRED_ENV_VARS {
166        if std::env::var_os(name).is_some() {
167            let err = BootConfigError::RetiredEnvVar { name, replacement };
168            nros_log::log_error!(nros_log::get_logger("nros"), "{}", err);
169            return Err(err);
170        }
171    }
172    let cache = env_cache();
173
174    let locator_present = std::env::var("NROS_LOCATOR").is_ok();
175    let domain_id = match std::env::var("ROS_DOMAIN_ID") {
176        Ok(s) if !s.is_empty() => {
177            // #206 — malformed is an ERROR, not a silent skip. The parse lives
178            // here rather than in the core because only this side has the text.
179            Some(
180                s.trim()
181                    .parse::<u32>()
182                    .map_err(|_| BootConfigError::DomainIdParse)?,
183            )
184        }
185        _ => None,
186    };
187    let node_name_present = std::env::var("NROS_NODE_NAME")
188        .map(|s| !s.is_empty())
189        .unwrap_or(false);
190    // Issue 0794 — an EMPTY value is not a declaration here, same as the node
191    // name: `""` is what an unset namespace already resolves to, so honouring
192    // it would make `NROS_NODE_NAMESPACE=` indistinguishable from "override the
193    // baked namespace back to root", which is a different request.
194    let namespace_present = std::env::var("NROS_NODE_NAMESPACE")
195        .map(|s| !s.is_empty())
196        .unwrap_or(false);
197
198    Ok(EnvRung {
199        locator: locator_present.then_some(cache.locator.as_str()),
200        domain_id,
201        // Session mode has no baked rung to fall through to, so the cache's
202        // default (`Client`) is always the answer — it is stated rather than
203        // conditional for exactly that reason.
204        mode: Some(cache.mode),
205        node_name: node_name_present.then_some(cache.node_name.as_str()),
206        namespace: namespace_present.then_some(cache.namespace.as_str()),
207        rmw: cache.rmw.as_deref(),
208    })
209}
210
211/// RFC-0045 precedence model A with the environment on top:
212/// `env (var set) > baked > compiled default`.
213///
214/// Fails loud on invalid identity input (repo rule: a bad domain id at boot is
215/// a configuration error, never a silent domain-0 node). FFI shims that need a
216/// return code call [`try_resolve_hosted`].
217pub fn resolve_hosted<'a>(baked: BootConfig<'a>) -> ExecutorConfig<'a> {
218    match try_resolve_hosted(baked) {
219        Ok(cfg) => cfg,
220        Err(e) => panic!("nros boot-config resolution failed: {e}"),
221    }
222}
223
224/// Fallible [`resolve_hosted`] — returns [`BootConfigError`] instead of
225/// panicking, so the C / C++ FFI shims can surface a return code.
226///
227/// - `ROS_DOMAIN_ID` set but non-numeric → `DomainIdParse` (the pre-#206 C++
228///   header silently collapsed this to domain 0; the resolver silently ignored
229///   it — both were wrong).
230/// - any resolved domain id > `DOMAIN_ID_MAX` → `DomainIdRange`, INCLUDING a
231///   baked one (the DDS backend would only fail later).
232pub fn try_resolve_hosted<'a>(
233    baked: BootConfig<'a>,
234) -> Result<ExecutorConfig<'a>, BootConfigError> {
235    ExecutorConfig::try_resolve_with(baked, Some(env_rung()?))
236}
237
238/// `ExecutorConfig::from_env()`, as an extension trait.
239///
240/// issue 0687 — this was an inherent method on `ExecutorConfig`, which is
241/// defined in `nros-node`; an inherent method cannot be moved to another crate
242/// and keep its spelling, and the spelling is what ~26 call sites (most of them
243/// user-facing native examples) are written against. A trait keeps
244/// `ExecutorConfig::from_env()` working wherever it is in scope — the
245/// [`prelude`](crate::prelude) carries it, so a consumer that already writes
246/// `use nros::prelude::*` changes nothing at all.
247pub trait ExecutorConfigEnvExt {
248    /// Create a configuration from environment variables.
249    ///
250    /// Reads:
251    /// - `NROS_LOCATOR` — Middleware locator. Unset ⇒ empty (issue 0330: the
252    ///   active RMW backend applies its own default; e.g. zenoh dials
253    ///   `nros_rmw_zenoh::DEFAULT_LOCATOR`). The retired name `ZENOH_LOCATOR`
254    ///   is refused (phase-484 W8c) — see [`BootConfigError::RetiredEnvVar`].
255    /// - `ROS_DOMAIN_ID` — ROS 2 domain ID (default: `0`).
256    /// - `NROS_SESSION_MODE` — `"client"` or `"peer"` (default: `"client"`).
257    ///   The retired name `ZENOH_MODE` is refused (phase-484 W8c).
258    /// - `NROS_RMW` — the backend selector, through [`rmw_selector`]. issue
259    ///   0687: `Executor::open` used to read this itself; it now takes
260    ///   `ExecutorConfig::rmw`, so a config built here still selects the
261    ///   backend the user named.
262    ///
263    /// String values are cached in a process-global `OnceLock` on the first
264    /// call and reused for the process lifetime — repeated calls do NOT
265    /// accrete memory, and the returned `&'static str` fields point into that
266    /// cache. Presence and the domain id are read live, so a caller that
267    /// changes the environment between calls sees the change.
268    ///
269    /// **Panics** on a malformed or out-of-range `$ROS_DOMAIN_ID` or a set
270    /// retired alias (`ZENOH_LOCATOR` / `ZENOH_MODE`), like
271    /// [`resolve_hosted`] and the C / C++ entries. A boot identity that cannot
272    /// be resolved is a configuration error; the alternative is a node silently
273    /// running on domain 0, which is what issue #206 removed everywhere else.
274    fn from_env() -> ExecutorConfig<'static>;
275}
276
277impl ExecutorConfigEnvExt for ExecutorConfig<'static> {
278    fn from_env() -> ExecutorConfig<'static> {
279        // issue 0687 — `from_env` IS `resolve_hosted` with nothing baked, and
280        // saying so in code rather than in a test is the point. The two used to
281        // be parallel implementations pinned together by an assertion
282        // (`noop_resolve_matches_from_env`), and they had already drifted where
283        // the assertion did not look: this one read `$ROS_DOMAIN_ID` through a
284        // second, silent parse that turned a malformed or out-of-range value
285        // into domain 0. Now a bad domain fails loud here exactly as it does
286        // for `resolve`, `nros-c` and `nros-cpp` — the #206 rule, finally
287        // uniform across all four.
288        //
289        // The one field that does NOT come from the rung is `node_name`:
290        // `from_env` has never honoured `$NROS_NODE_NAME` (its doc lists what
291        // it reads), and callers chain `.node_name(..)` immediately. Changing
292        // that is a decision, not a cleanup.
293        ExecutorConfig {
294            node_name: "node",
295            ..resolve_hosted(BootConfig::default())
296        }
297    }
298}
299
300/// Process-wide mutex that serialises every test that touches the process
301/// environment — the ones that MUTATE it (`EnvGuard` below) and the ones that
302/// only READ it through `init()` / `Context::from_env()` (`init.rs`).
303///
304/// `cargo test` runs `#[test]`s in parallel within a single binary by default,
305/// and under test the env cache is rebuilt from the live environment on every
306/// call (see [`env_cache`]). So a reader that does not hold this lock can
307/// observe a sibling's `ROS_DOMAIN_ID=abc` halfway through: `init.rs`'s
308/// `plain_args_pass_through_to_init` compared two reads and got `Ok(..)` vs
309/// `Err(EnvParseFailed)` under `just ci gate`. It lived in `env.rs`'s private
310/// test module, so the readers in `init.rs` could not take it. (`cargo
311/// nextest` runs each test in its own process, so the lock is uncontended
312/// there, but taking it is still correct.)
313///
314/// Poison-TOLERANT, deliberately: three tests assert that a bad
315/// `$ROS_DOMAIN_ID` panics, and a panic while this guard is alive poisons the
316/// mutex — which would then fail every LATER test for a reason that has
317/// nothing to do with what it asserts (measured: 3 intended failures became
318/// 8). The data behind the lock is `()`; there is no invariant a panic could
319/// have broken.
320#[cfg(test)]
321pub(crate) fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
322    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
323    LOCK.get_or_init(|| std::sync::Mutex::new(()))
324        .lock()
325        .unwrap_or_else(|e| e.into_inner())
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
333        super::test_env_lock()
334    }
335
336    /// RAII guard that saves and restores a single env var.
337    struct EnvGuard {
338        key: &'static str,
339        prev: Option<std::ffi::OsString>,
340    }
341
342    impl EnvGuard {
343        fn set(key: &'static str, value: &str) -> Self {
344            let prev = std::env::var_os(key);
345            // SAFETY: serialised via env_lock().
346            unsafe { std::env::set_var(key, value) };
347            Self { key, prev }
348        }
349
350        fn unset(key: &'static str) -> Self {
351            let prev = std::env::var_os(key);
352            // SAFETY: serialised via env_lock().
353            unsafe { std::env::remove_var(key) };
354            Self { key, prev }
355        }
356    }
357
358    impl Drop for EnvGuard {
359        fn drop(&mut self) {
360            // SAFETY: serialised via env_lock().
361            unsafe {
362                match &self.prev {
363                    Some(v) => std::env::set_var(self.key, v),
364                    None => std::env::remove_var(self.key),
365                }
366            }
367        }
368    }
369
370    /// `resolve_hosted(BootConfig::default())` must be field-for-field
371    /// identical to `from_env()`, regardless of what env vars are set.
372    #[test]
373    fn noop_resolve_matches_from_env() {
374        let _g = env_lock();
375
376        let resolved = resolve_hosted(BootConfig::default());
377        let env_cfg = ExecutorConfig::from_env();
378
379        assert_eq!(resolved.locator, env_cfg.locator);
380        assert_eq!(resolved.mode, env_cfg.mode);
381        assert_eq!(resolved.domain_id, env_cfg.domain_id);
382        assert_eq!(resolved.node_name, env_cfg.node_name);
383        assert_eq!(resolved.namespace, env_cfg.namespace);
384        assert_eq!(resolved.rmw, env_cfg.rmw);
385    }
386
387    #[test]
388    fn env_overrides_baked_on_hosted() {
389        let _g = env_lock();
390        let _e = EnvGuard::set("NROS_LOCATOR", "tcp/env:7447");
391
392        let baked = BootConfig {
393            locator: Some("tcp/baked:9999"),
394            ..Default::default()
395        };
396        let resolved = resolve_hosted(baked);
397
398        // Issue 0607 — the exact env string is observable: tests read live env
399        // rather than a process-global cache that whichever test ran first had
400        // already frozen. This was an `assert_ne!` against the baked value for
401        // exactly that reason.
402        assert_eq!(
403            resolved.locator, "tcp/env:7447",
404            "env locator must win over baked, with its own value"
405        );
406    }
407
408    #[test]
409    fn baked_used_when_env_unset_on_hosted() {
410        let _g = env_lock();
411        let _e1 = EnvGuard::unset("NROS_LOCATOR");
412
413        let baked = BootConfig {
414            locator: Some("tcp/baked-only:8888"),
415            ..Default::default()
416        };
417        let resolved = resolve_hosted(baked);
418
419        assert_eq!(
420            resolved.locator, "tcp/baked-only:8888",
421            "baked locator must be used when env var is absent"
422        );
423    }
424
425    #[test]
426    fn per_field_independence_baked_name_env_locator() {
427        let _g = env_lock();
428        let _e = EnvGuard::set("NROS_LOCATOR", "tcp/env:7447");
429        let _n = EnvGuard::unset("NROS_NODE_NAME");
430
431        let baked = BootConfig {
432            node_name: Some("my_talker"),
433            ..Default::default()
434        };
435        let resolved = resolve_hosted(baked);
436
437        assert_eq!(resolved.locator, "tcp/env:7447");
438        assert_eq!(
439            resolved.node_name, "my_talker",
440            "baked node_name must apply even when locator comes from env"
441        );
442    }
443
444    #[test]
445    fn try_resolve_malformed_domain_env_errors() {
446        let _l = env_lock();
447        let _g = EnvGuard::set("ROS_DOMAIN_ID", "not-a-number");
448        let err = match try_resolve_hosted(BootConfig::default()) {
449            Err(e) => e,
450            Ok(_) => panic!("expected DomainIdParse error"),
451        };
452        assert_eq!(err, BootConfigError::DomainIdParse);
453    }
454
455    /// phase-484 W8c — a retired alias REFUSES, naming its replacement, and
456    /// does so even beside a canonical value (half-migrated env is still a
457    /// mistake somebody should hear about).
458    #[test]
459    fn retired_zenoh_locator_is_refused_naming_nros_locator() {
460        let _l = env_lock();
461        let (locator_alias, _) = RETIRED_ENV_VARS[0];
462        let (mode_alias, _) = RETIRED_ENV_VARS[1];
463        let _a = EnvGuard::set(locator_alias, "tcp/legacy:7447");
464        let _b = EnvGuard::unset("NROS_LOCATOR");
465        let _c = EnvGuard::unset(mode_alias);
466        let err = match try_resolve_hosted(BootConfig::default()) {
467            Err(e) => e,
468            Ok(cfg) => panic!("expected RetiredEnvVar, resolved {:?}", cfg.locator),
469        };
470        assert_eq!(
471            err,
472            BootConfigError::RetiredEnvVar {
473                name: locator_alias,
474                replacement: "NROS_LOCATOR"
475            }
476        );
477        let msg = alloc::format!("{err}");
478        assert!(msg.contains("NROS_LOCATOR"), "{msg}");
479
480        let _d = EnvGuard::set("NROS_LOCATOR", "tcp/canonical:7447");
481        assert!(try_resolve_hosted(BootConfig::default()).is_err());
482    }
483
484    #[test]
485    fn retired_zenoh_mode_is_refused_naming_nros_session_mode() {
486        let _l = env_lock();
487        let (locator_alias, _) = RETIRED_ENV_VARS[0];
488        let (mode_alias, _) = RETIRED_ENV_VARS[1];
489        let _a = EnvGuard::unset(locator_alias);
490        let _b = EnvGuard::set(mode_alias, "peer");
491        let err = match try_resolve_hosted(BootConfig::default()) {
492            Err(e) => e,
493            Ok(_) => panic!("expected RetiredEnvVar"),
494        };
495        assert_eq!(
496            err,
497            BootConfigError::RetiredEnvVar {
498                name: mode_alias,
499                replacement: "NROS_SESSION_MODE"
500            }
501        );
502    }
503
504    /// Positive control for the two refusals above: the canonical name alone
505    /// resolves, with its own value.
506    #[test]
507    fn canonical_nros_locator_resolves_when_no_retired_alias_set() {
508        let _l = env_lock();
509        let _a = EnvGuard::unset(RETIRED_ENV_VARS[0].0);
510        let _b = EnvGuard::unset(RETIRED_ENV_VARS[1].0);
511        let _c = EnvGuard::set("NROS_LOCATOR", "tcp/canonical:7447");
512        let cfg = try_resolve_hosted(BootConfig::default()).expect("canonical resolves");
513        assert_eq!(cfg.locator, "tcp/canonical:7447");
514    }
515
516    #[test]
517    fn try_resolve_domain_env_over_max_errors() {
518        let _l = env_lock();
519        let _g = EnvGuard::set("ROS_DOMAIN_ID", "233");
520        let err = match try_resolve_hosted(BootConfig::default()) {
521            Err(e) => e,
522            Ok(_) => panic!("expected DomainIdRange error"),
523        };
524        assert_eq!(err, BootConfigError::DomainIdRange);
525    }
526
527    #[test]
528    fn try_resolve_node_name_env_rung() {
529        let _l = env_lock();
530        let _g = EnvGuard::set("NROS_NODE_NAME", "env_node");
531        let baked = BootConfig {
532            node_name: Some("baked_node"),
533            ..BootConfig::default()
534        };
535        let cfg = match try_resolve_hosted(baked) {
536            Ok(c) => c,
537            Err(e) => panic!("resolve failed: {e}"),
538        };
539        assert_eq!(
540            cfg.node_name, "env_node",
541            "env rung must override baked with its own value"
542        );
543    }
544
545    /// issue 0794 — the hosted edge fills the namespace rung.
546    ///
547    /// Before, `EnvRung` had no namespace field, so this variable could not
548    /// exist: a hosted node's namespace came from the baked rung or from a
549    /// hand-written `ExecutorConfig::namespace(..)` — the per-board lore
550    /// RFC-0045 exists to abolish.
551    #[test]
552    fn try_resolve_namespace_env_rung() {
553        let _l = env_lock();
554        let baked = BootConfig {
555            namespace: Some("/baked"),
556            ..BootConfig::default()
557        };
558
559        let g = EnvGuard::set("NROS_NODE_NAMESPACE", "/robot1");
560        let cfg = try_resolve_hosted(baked).expect("resolve ok");
561        assert_eq!(
562            cfg.namespace, "/robot1",
563            "env rung must override the baked namespace with its own value"
564        );
565        drop(g);
566
567        // Unset falls through to baked, which is the rung below it.
568        let _g = EnvGuard::unset("NROS_NODE_NAMESPACE");
569        let cfg = try_resolve_hosted(baked).expect("resolve ok");
570        assert_eq!(cfg.namespace, "/baked");
571
572        // EMPTY is unset, not "override back to root": `""` is already what an
573        // unset namespace resolves to, so honouring it would give `NROS_NODE_
574        // NAMESPACE=` a meaning no caller can distinguish from absence.
575        let _g = EnvGuard::set("NROS_NODE_NAMESPACE", "");
576        let cfg = try_resolve_hosted(baked).expect("resolve ok");
577        assert_eq!(cfg.namespace, "/baked");
578    }
579
580    /// issue 0687 — the selector reaches the config, which is how
581    /// `Executor::open` still honours `$NROS_RMW` now that it does not read the
582    /// environment itself. Both halves are asserted: a set value arrives, and
583    /// an EMPTY value means unset rather than "a backend named the empty
584    /// string" (which is what `nros-c` used to pass through).
585    #[test]
586    fn selector_reaches_the_config() {
587        let _l = env_lock();
588
589        let _g = EnvGuard::set("NROS_RMW", "cyclonedds");
590        assert_eq!(rmw_selector().as_deref(), Some("cyclonedds"));
591        assert_eq!(
592            ExecutorConfig::from_env().rmw,
593            Some("cyclonedds"),
594            "from_env must carry the selector"
595        );
596        assert_eq!(
597            resolve_hosted(BootConfig::default()).rmw,
598            Some("cyclonedds")
599        );
600
601        let _g = EnvGuard::set("NROS_RMW", "");
602        assert_eq!(rmw_selector(), None, "empty means unset");
603        assert_eq!(ExecutorConfig::from_env().rmw, None);
604    }
605
606    /// issue 0687 — `from_env` and `resolve_hosted(default)` are the same
607    /// resolution, and this asserts the fields the type does not force.
608    #[test]
609    fn from_env_is_resolve_hosted_with_nothing_baked() {
610        let _l = env_lock();
611        let _g = EnvGuard::set("NROS_LOCATOR", "tcp/agree:7447");
612        let _d = EnvGuard::set("ROS_DOMAIN_ID", "42");
613
614        let a = ExecutorConfig::from_env();
615        let b = resolve_hosted(BootConfig::default());
616        assert_eq!(a.locator, b.locator);
617        assert_eq!(a.domain_id, b.domain_id);
618        assert_eq!(a.mode, b.mode);
619        assert_eq!(a.rmw, b.rmw);
620        assert_eq!(a.namespace, b.namespace);
621        // The one deliberate divergence: `from_env` does not take the node name
622        // from the environment, and never has.
623        assert_eq!(a.node_name, "node");
624    }
625
626    /// A malformed `$ROS_DOMAIN_ID` must fail LOUD here, as it does for
627    /// `resolve_hosted` and the C / C++ entries. It used to resolve to domain 0
628    /// silently — the #206 defect, surviving on this one path.
629    #[test]
630    #[should_panic(expected = "boot-config resolution failed")]
631    fn from_env_rejects_a_malformed_domain() {
632        let _l = env_lock();
633        let _g = EnvGuard::set("ROS_DOMAIN_ID", "not-a-number");
634        let _ = ExecutorConfig::from_env();
635    }
636
637    /// …and an out-of-range one, which the same path used to coerce to 0.
638    #[test]
639    #[should_panic(expected = "boot-config resolution failed")]
640    fn from_env_rejects_an_out_of_range_domain() {
641        let _l = env_lock();
642        let _g = EnvGuard::set("ROS_DOMAIN_ID", "300");
643        let _ = ExecutorConfig::from_env();
644    }
645
646    /// A selector longer than the executor's identity capacity cannot name a
647    /// registry slot, so it is unset rather than truncated into some other
648    /// backend's name.
649    #[test]
650    fn overlong_selector_is_unset_not_truncated() {
651        let _l = env_lock();
652        let long = "x".repeat(RMW_SELECTOR_CAP + 1);
653        let _g = EnvGuard::set("NROS_RMW", &long);
654        assert_eq!(rmw_selector(), None);
655    }
656}