Skip to main content

nros_node/
names.rs

1//! ROS 2 name expansion + launch remap resolution (issue 0255 / phase-306 W3).
2//!
3//! The ONE resolution seam both entry-codegen twins funnel through: the Rust
4//! `ExecutorSink` (nros `node_runtime`) and the C-ABI registration paths
5//! (nros-c / nros-cpp via the executor-side remap table) call these functions
6//! so the two languages can never drift on name semantics.
7//!
8//! Scope: **basic name remapping only** — a rule matches when its expanded
9//! `from` equals the expanded source name (exact FQN comparison, no
10//! wildcards, no node-name prefixes). First matching rule wins.
11
12/// Maximum bytes in a fully-qualified resolved entity name. Matches
13/// `nros::node_metadata::METADATA_STRING_CAPACITY` (the source-name bound
14/// entering the resolution seam).
15pub const MAX_RESOLVED_NAME_LEN: usize = 128;
16
17/// Owned storage for a resolved (fully-qualified) entity name.
18pub type ResolvedName = heapless::String<MAX_RESOLVED_NAME_LEN>;
19
20/// Expand a source-level ROS name to its fully-qualified form (ROS 2 name
21/// expansion rules):
22///
23/// - `/absolute/name` → unchanged.
24/// - `~` / `~/rest` (private) → `/<ns>/<node>` / `/<ns>/<node>/rest`
25///   (`ns == "/"` collapses: `/<node>/rest`).
26/// - `relative/name` → `/<ns>/relative/name` (`ns == "/"` collapses:
27///   `/relative/name`).
28///
29/// `namespace` may be given with or without a leading `/`; empty means root.
30/// Errors on: empty `source`, a private name with an empty `node_name`, or a
31/// result exceeding [`MAX_RESOLVED_NAME_LEN`].
32#[allow(clippy::result_unit_err)] // matches the RuntimeCtx seam precedent — no_std, no Error type
33pub fn expand_name(source: &str, node_name: &str, namespace: &str) -> Result<ResolvedName, ()> {
34    if source.is_empty() {
35        return Err(());
36    }
37    let mut out = ResolvedName::new();
38    if source.starts_with('/') {
39        out.push_str(source)?;
40        return Ok(out);
41    }
42    push_namespace(&mut out, namespace)?;
43    if let Some(rest) = source.strip_prefix('~') {
44        if node_name.is_empty() {
45            return Err(());
46        }
47        out.push('/')?;
48        out.push_str(node_name)?;
49        let rest = rest.strip_prefix('/').unwrap_or(rest);
50        if !rest.is_empty() {
51            out.push('/')?;
52            out.push_str(rest)?;
53        }
54    } else {
55        out.push('/')?;
56        out.push_str(source)?;
57    }
58    Ok(out)
59}
60
61/// Append a normalized namespace: leading `/` guaranteed, trailing `/`
62/// stripped, root (`""` / `"/"`) appends nothing (the caller's `/` before the
63/// next segment is the only separator — the "ns=/ collapse").
64fn push_namespace(out: &mut ResolvedName, namespace: &str) -> Result<(), ()> {
65    let ns = namespace.trim_end_matches('/');
66    if ns.is_empty() {
67        return Ok(());
68    }
69    if !ns.starts_with('/') {
70        out.push('/')?;
71    }
72    out.push_str(ns)
73}
74
75/// Resolve a source-level entity name through launch remap rules: expand the
76/// source name AND each rule's `from` to fully-qualified form, compare exact,
77/// substitute the (also expanded) `to` of the first matching rule; no match →
78/// the expanded source name. A rule whose `from`/`to` fails to expand is
79/// skipped (never masks the name expansion itself).
80#[allow(clippy::result_unit_err)]
81pub fn resolve_name<'a, I>(
82    source: &str,
83    node_name: &str,
84    namespace: &str,
85    remaps: I,
86) -> Result<ResolvedName, ()>
87where
88    I: IntoIterator<Item = (&'a str, &'a str)>,
89{
90    let expanded = expand_name(source, node_name, namespace)?;
91    for (from, to) in remaps {
92        if let Ok(from_fq) = expand_name(from, node_name, namespace)
93            && from_fq == expanded
94            && let Ok(to_fq) = expand_name(to, node_name, namespace)
95        {
96            return Ok(to_fq);
97        }
98    }
99    Ok(expanded)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn absolute_passes_through() {
108        assert_eq!(
109            expand_name("/scan", "lidar", "/sensing").unwrap().as_str(),
110            "/scan"
111        );
112    }
113
114    #[test]
115    fn relative_expands_against_namespace() {
116        assert_eq!(
117            expand_name("scan", "lidar", "/sensing").unwrap().as_str(),
118            "/sensing/scan"
119        );
120        // Namespace without a leading slash normalizes.
121        assert_eq!(
122            expand_name("scan", "lidar", "sensing").unwrap().as_str(),
123            "/sensing/scan"
124        );
125    }
126
127    #[test]
128    fn relative_root_namespace_collapses() {
129        assert_eq!(expand_name("scan", "lidar", "/").unwrap().as_str(), "/scan");
130        assert_eq!(expand_name("scan", "lidar", "").unwrap().as_str(), "/scan");
131    }
132
133    #[test]
134    fn private_expands_against_node_fqn() {
135        assert_eq!(
136            expand_name("~/input/points", "filter", "/sensing")
137                .unwrap()
138                .as_str(),
139            "/sensing/filter/input/points"
140        );
141        // ns=/ collapse.
142        assert_eq!(
143            expand_name("~/status", "filter", "/").unwrap().as_str(),
144            "/filter/status"
145        );
146        // Bare `~` names the node itself.
147        assert_eq!(
148            expand_name("~", "filter", "/sensing").unwrap().as_str(),
149            "/sensing/filter"
150        );
151    }
152
153    #[test]
154    fn private_without_node_name_errors() {
155        assert!(expand_name("~/x", "", "/").is_err());
156    }
157
158    #[test]
159    fn empty_source_errors() {
160        assert!(expand_name("", "n", "/").is_err());
161    }
162
163    #[test]
164    fn oversized_result_errors() {
165        let long = "x".repeat(MAX_RESOLVED_NAME_LEN);
166        assert!(expand_name(&long, "n", "/ns").is_err());
167    }
168
169    #[test]
170    fn remap_matches_on_expanded_fqn() {
171        // `from` written relative, source written private — both expand to the
172        // same FQN, so the rule fires; `to` expands as well.
173        let remaps = [("filter/input/points", "/sensing/points_raw")];
174        let r = resolve_name("~/input/points", "filter", "/", remaps).unwrap();
175        assert_eq!(r.as_str(), "/sensing/points_raw");
176    }
177
178    #[test]
179    fn first_matching_rule_wins() {
180        let remaps = [("/a", "/first"), ("/a", "/second")];
181        assert_eq!(
182            resolve_name("/a", "n", "/", remaps).unwrap().as_str(),
183            "/first"
184        );
185    }
186
187    #[test]
188    fn no_match_returns_expansion() {
189        let remaps = [("/other", "/elsewhere")];
190        assert_eq!(
191            resolve_name("chatter", "n", "/ns", remaps)
192                .unwrap()
193                .as_str(),
194            "/ns/chatter"
195        );
196    }
197
198    #[test]
199    fn relative_to_expands_against_namespace() {
200        let remaps = [("chatter", "chatter_remapped")];
201        assert_eq!(
202            resolve_name("chatter", "n", "/ns", remaps)
203                .unwrap()
204                .as_str(),
205            "/ns/chatter_remapped"
206        );
207    }
208}