nros/init.rs
1//! Phase 212.L.5 — top-level init API.
2//!
3//! Three patterns are supported (per the Phase 212.L canonical pkg shape):
4//!
5//! 1. **Node pkg** — register via the [`nros::node!`](crate::node!)
6//! macro (Phase 172 W.3); the generated runtime owns the spin loop.
7//! 2. **Application pkg + launch-aware** — call [`init_with_launch_auto`] (or
8//! [`init_with_launch`] for an explicit path). The returned [`Context`]
9//! carries launch-resolved fields (domain id, locator, RMW choice). User
10//! code drives its own spin via `Executor::open` +
11//! `Executor::spin_blocking`.
12//! 3. **Application pkg + custom spin** — call [`init()`] (or [`init_with_args`]
13//! for argv-style overrides). Launch file is ignored; env vars +
14//! `ExecutorConfig::from_env()` semantics still apply.
15//!
16//! The [`Context`] struct is a thin holder of the resolved init knobs. To
17//! actually open a session, materialise an [`crate::ExecutorConfig`] via
18//! [`Context::config`] and pass it to `Executor::open`.
19//!
20//! ## Launch overlay (current limitation)
21//!
22//! `init_with_launch_auto` / `init_with_launch` currently consume the
23//! launch-resolved knobs the parent `nros launch` process exports via env
24//! vars (`ROS_DOMAIN_ID`, `NROS_LOCATOR`, `NROS_SESSION_MODE`,
25//! `RMW_IMPLEMENTATION`, plus the placeholder `NROS_RUNTIME_OVERLAY` for
26//! the future structured overlay path). The launch XML is NOT parsed
27//! in-process; the runtime trusts the launcher to project the relevant
28//! params / remaps / env into the child environment. A follow-up wave wires
29//! the structured overlay (Option A — `nros launch --emit-runtime-overlay`
30//! → JSON sidecar consumed here). See Phase 212.L.5 notes.
31
32#[cfg(feature = "env")]
33// phase-359 W10 — kept, and it is not a spelling that can be unwound.
34// `init_with_launch` verifies a launch file EXISTS, which is a filesystem
35// question; `AsRef<Path>` is also what a Rust caller expects to pass a
36// `PathBuf`, a `&str` or a `Path` to. Narrowing the signature to `&str` would
37// trade a real ergonomic for one census point, which is moving the number
38// rather than the build. The whole module is behind `env`, which requires
39// `std`, so nothing here is reachable without one.
40use std::path::Path;
41
42use nros_node::ExecutorConfig;
43use nros_rmw::SessionMode;
44
45/// Errors returned by the init API.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum InitError {
48 /// `init_with_launch(path)` was passed a path that does not exist or
49 /// could not be read.
50 LaunchFileNotFound,
51 /// The launch file existed but could not be parsed.
52 ///
53 /// Phase 212.L.5 ships a stub — actual XML parsing arrives with the
54 /// runtime-overlay wave. Until then this variant is unused.
55 LaunchParseFailed,
56 /// A launch-derived env var (`ROS_DOMAIN_ID`, etc.) failed to parse.
57 EnvParseFailed,
58}
59
60impl core::fmt::Display for InitError {
61 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
62 match self {
63 InitError::LaunchFileNotFound => f.write_str("launch file not found"),
64 InitError::LaunchParseFailed => f.write_str("launch file parse failed"),
65 InitError::EnvParseFailed => f.write_str("env var parse failed"),
66 }
67 }
68}
69
70// `core::error::Error` — `std::error::Error` is a re-export of it since Rust
71// 1.81, so this is the same trait. The `cfg` stays: this whole module reads the
72// environment (`std::env::var`), which has no no_std equivalent.
73#[cfg(feature = "env")]
74impl core::error::Error for InitError {}
75
76/// Phase 212.L.5 — resolved init context.
77///
78/// Returned by every `init*` entry point. Carries the fields the user
79/// needs to construct an [`ExecutorConfig`] and open a session.
80///
81/// Fields are owned (`String` on hosted builds) so the `Context` can
82/// outlive transient parents (env caches, parsed launch files).
83#[cfg(feature = "env")]
84#[derive(Debug, Clone)]
85pub struct Context {
86 /// ROS 2 domain ID (`ROS_DOMAIN_ID`, default 0).
87 pub domain_id: u32,
88 /// Middleware locator (`NROS_LOCATOR` / legacy `ZENOH_LOCATOR`).
89 pub locator: alloc::string::String,
90 /// Session mode (`NROS_SESSION_MODE` / legacy `ZENOH_MODE`, default `Client`).
91 pub mode: SessionMode,
92 /// RMW implementation hint (`RMW_IMPLEMENTATION` / `NROS_RMW`).
93 ///
94 /// Empty when neither var is set. The runtime uses this to pick a
95 /// primary backend when multiple are linked; see
96 /// `crate::internals::open_session`.
97 pub rmw: alloc::string::String,
98 /// Source of this context — useful for diagnostics + tests.
99 pub source: ContextSource,
100}
101
102/// Where the [`Context`] came from. Diagnostics only.
103#[cfg(feature = "env")]
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum ContextSource {
106 /// Built from env vars by [`init`] / [`init_with_args`].
107 Env,
108 /// Built from a launch file (path supplied to [`init_with_launch`]) or
109 /// auto-discovered via [`init_with_launch_auto`]. The launch XML itself
110 /// is NOT yet parsed (see module docs); the launcher's projected env
111 /// is the source of truth for now.
112 Launch,
113}
114
115#[cfg(feature = "env")]
116impl Context {
117 /// Materialise an [`ExecutorConfig`] for a node with the given name.
118 ///
119 /// The returned config borrows from `self`, so callers usually do:
120 ///
121 /// ```ignore
122 /// let ctx = nros::init()?;
123 /// let cfg = ctx.config("talker");
124 /// let mut executor = nros::Executor::open(&cfg)?;
125 /// ```
126 pub fn config<'a>(&'a self, node_name: &'a str) -> ExecutorConfig<'a> {
127 ExecutorConfig::new(self.locator.as_str())
128 .node_name(node_name)
129 .domain_id(self.domain_id)
130 .mode(self.mode)
131 }
132}
133
134#[cfg(feature = "env")]
135fn read_env_context(source: ContextSource) -> Result<Context, InitError> {
136 // issue 0687 — through the ONE resolver, not a third parse of the same four
137 // variables. This function had its own copy, and the copies had drifted:
138 // it did not warn on the legacy `$ZENOH_LOCATOR`/`$ZENOH_MODE` spellings
139 // the other reader deprecates, and it range-checked nothing, so
140 // `ROS_DOMAIN_ID=300` reached a backend that could only fail later.
141 //
142 // Issue 0330 — no backend default for the locator: `nros` is RMW-agnostic,
143 // so unset env leaves it EMPTY (= absent) and the linked backend
144 // substitutes its own (zenoh: `nros_rmw_zenoh::DEFAULT_LOCATOR`; xrce: its
145 // agent default; cyclonedds ignores the locator entirely). That is what
146 // `resolve_hosted` does with an empty `BootConfig` too.
147 let resolved = crate::env::try_resolve_hosted(nros_node::BootConfig::default())
148 .map_err(|_| InitError::EnvParseFailed)?;
149 let locator = alloc::string::String::from(resolved.locator);
150 let domain_id = resolved.domain_id;
151 let mode = resolved.mode;
152 // issue 0687 — the `$NROS_RMW` half comes from the shared selector; the
153 // `RMW_IMPLEMENTATION` fallback stays HERE and only here. `Context.rmw` is
154 // a ROS-vocabulary HINT (`rmw_cyclonedds_cpp`), not the cffi registry
155 // selector (`cyclonedds`) — folding the two together would hand a ROS name
156 // to `resolve_backend`, which answers `Unknown` and fails the open.
157 let rmw = crate::rmw_selector()
158 .map(|s| alloc::string::String::from(s.as_str()))
159 .or_else(|| std::env::var("RMW_IMPLEMENTATION").ok())
160 .unwrap_or_default();
161 Ok(Context {
162 domain_id,
163 locator,
164 mode,
165 rmw,
166 source,
167 })
168}
169
170/// Pattern 3 — raw init, launch file ignored.
171///
172/// Reads env vars (`ROS_DOMAIN_ID`, `NROS_LOCATOR`, `NROS_SESSION_MODE`,
173/// `NROS_RMW` / `RMW_IMPLEMENTATION`) and returns a [`Context`]. The
174/// caller owns the spin loop — typically `Executor::open(&ctx.config(name))`
175/// followed by `spin_blocking` or a hand-rolled `spin_once` loop.
176#[cfg(feature = "env")]
177pub fn init() -> Result<Context, InitError> {
178 read_env_context(ContextSource::Env)
179}
180
181/// Pattern 3 — like [`init`] but accepts a `[--arg=value, ...]`-style argv
182/// iterator. Currently a thin wrapper over [`init`] that ignores the args;
183/// the structured argv parse (`--ros-args -p foo:=42`, etc.) lands with the
184/// runtime-overlay wave.
185#[cfg(feature = "env")]
186pub fn init_with_args<I, S>(_args: I) -> Result<Context, InitError>
187where
188 I: IntoIterator<Item = S>,
189 S: AsRef<str>,
190{
191 // TODO (Phase 212.L.5 follow-up): parse `--ros-args` style flags.
192 init()
193}
194
195/// Pattern 2 — launch-aware init.
196///
197/// Resolves the launch file via:
198///
199/// 1. `$NROS_RUNTIME_OVERLAY` — when set, the path points at a JSON sidecar
200/// written by `nros launch --emit-runtime-overlay`. (NOT yet consumed;
201/// placeholder for the follow-up wave.)
202/// 2. `<CARGO_MANIFEST_DIR>/launch/<pkg>.launch.xml` or
203/// `<CARGO_MANIFEST_DIR>/launch/system.launch.xml`. (NOT yet parsed;
204/// placeholder.)
205/// 3. The env vars described in [`init`] — the launcher projects launch
206/// params into the child env before `exec()`, so the env path is the
207/// de-facto launch overlay today.
208///
209/// Returns a [`Context`] whose `source = ContextSource::Launch` so callers
210/// can introspect whether the run is launch-driven.
211#[cfg(feature = "env")]
212pub fn init_with_launch_auto() -> Result<Context, InitError> {
213 // TODO (Phase 212.L.5 follow-up):
214 // 1. If $NROS_RUNTIME_OVERLAY is set, read the JSON sidecar and fold
215 // its params/remaps/env into the Context.
216 // 2. Else walk <CARGO_MANIFEST_DIR>/launch/* and parse the XML
217 // in-process (Option B — only if Option A overhead is rejected).
218 // For now the env path is the only overlay channel.
219 read_env_context(ContextSource::Launch)
220}
221
222/// Pattern 2 — explicit-path variant of [`init_with_launch_auto`].
223///
224/// Verifies the file exists (so misspelled paths fail fast at init time)
225/// but does NOT yet parse the XML; the launcher's projected env is the
226/// active overlay. See the module-level notes for the follow-up plan.
227#[cfg(feature = "env")]
228pub fn init_with_launch(path: impl AsRef<Path>) -> Result<Context, InitError> {
229 let p = path.as_ref();
230 if !p.exists() {
231 return Err(InitError::LaunchFileNotFound);
232 }
233 // TODO (Phase 212.L.5 follow-up): parse the launch XML and fold params
234 // / remaps / env into the returned Context. Today we only verify the
235 // file exists and fall through to the env overlay path.
236 read_env_context(ContextSource::Launch)
237}