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}