1pub const MAX_RESOLVED_NAME_LEN: usize = 128;
16
17pub type ResolvedName = heapless::String<MAX_RESOLVED_NAME_LEN>;
19
20#[allow(clippy::result_unit_err)] pub 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
61fn 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#[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 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 assert_eq!(
143 expand_name("~/status", "filter", "/").unwrap().as_str(),
144 "/filter/status"
145 );
146 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 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}