pamoja_ros2/name.rs
1//! ROS 2 topic and service names: validation and the mapping onto middleware names.
2//!
3//! ROS 2 names are not free-form strings; the rules (from the ROS 2 design) bound what is legal and
4//! how a name reaches the middleware. A name is `/`-separated tokens of alphanumerics and
5//! underscores; a token never starts with a digit; the name never has an empty token (`//`), a
6//! doubled underscore (`__`), or a trailing `/`. A leading `/` makes it fully qualified; a leading
7//! `~/` is the private namespace; balanced `{}` are runtime substitutions. On the wire DDS adds a
8//! one-character subsystem prefix, `rt` for topics and `rq`/`rr` for the two halves of a service,
9//! and the middleware appends `Request` and `Reply` to a service's two topics, as `rmw_fastrtps`
10//! and `rmw_cyclonedds` both build them.
11
12use alloc::format;
13use alloc::string::String;
14
15/// The ROS 2 subsystem a name belongs to, which fixes its DDS prefix and suffix.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum EntityKind {
18 /// A topic; DDS prefix `rt`, no suffix.
19 Topic,
20 /// The request side of a service; DDS prefix `rq`, suffix `Request`.
21 ServiceRequest,
22 /// The reply side of a service; DDS prefix `rr`, suffix `Reply`.
23 ServiceResponse,
24}
25
26impl EntityKind {
27 /// Returns the DDS topic prefix for this subsystem.
28 ///
29 /// # Returns
30 ///
31 /// `"rt"` for a topic, `"rq"` for a service request, `"rr"` for a service response.
32 pub fn prefix(self) -> &'static str {
33 match self {
34 EntityKind::Topic => "rt",
35 EntityKind::ServiceRequest => "rq",
36 EntityKind::ServiceResponse => "rr",
37 }
38 }
39
40 /// Returns what the middleware appends to a name for this subsystem.
41 ///
42 /// # Returns
43 ///
44 /// `""` for a topic, `"Request"` for a service request, and `"Reply"` for a service
45 /// response.
46 pub fn suffix(self) -> &'static str {
47 match self {
48 EntityKind::Topic => "",
49 EntityKind::ServiceRequest => "Request",
50 EntityKind::ServiceResponse => "Reply",
51 }
52 }
53}
54
55/// Returns whether a string is a valid ROS 2 topic or service name.
56///
57/// # Arguments
58///
59/// * `name` - the candidate name.
60///
61/// # Returns
62///
63/// `true` if `name` obeys the ROS 2 name rules: non-empty, no trailing `/`, no `//` or `__`, every
64/// token is alphanumerics and underscores not starting with a digit, any `~` is the first character
65/// and (if anything follows) is followed by `/`, and any `{}` substitutions are balanced and hold
66/// only alphanumerics and underscores.
67///
68/// # Examples
69///
70/// ```
71/// use pamoja_ros2::name::is_valid_name;
72///
73/// assert!(is_valid_name("/robot1/camera_left/image_raw"));
74/// assert!(is_valid_name("~/setpoint"));
75/// assert!(!is_valid_name("/2foo")); // a token may not start with a digit
76/// assert!(!is_valid_name("/foo/")); // no trailing slash
77/// assert!(!is_valid_name("/foo//bar")); // no empty token
78/// ```
79pub fn is_valid_name(name: &str) -> bool {
80 if name.is_empty() || name.ends_with('/') {
81 return false;
82 }
83 if name.contains("//") || name.contains("__") {
84 return false;
85 }
86 let bytes = name.as_bytes();
87 if let Some(pos) = name.find('~') {
88 if pos != 0 || (bytes.len() > 1 && bytes[1] != b'/') {
89 return false;
90 }
91 }
92
93 let mut brace_depth: i32 = 0;
94 let mut at_token_start = true;
95 for &b in bytes {
96 match b {
97 b'/' => {
98 if brace_depth != 0 {
99 return false; // a `/` may not appear inside a substitution
100 }
101 at_token_start = true;
102 }
103 b'~' => at_token_start = false, // legal only at index 0, already checked
104 b'{' => {
105 brace_depth += 1;
106 at_token_start = false;
107 }
108 b'}' => {
109 brace_depth -= 1;
110 if brace_depth < 0 {
111 return false;
112 }
113 at_token_start = false;
114 }
115 b'0'..=b'9' => {
116 if at_token_start {
117 return false; // a token may not start with a digit
118 }
119 }
120 b'a'..=b'z' | b'A'..=b'Z' | b'_' => at_token_start = false,
121 _ => return false,
122 }
123 }
124 brace_depth == 0
125}
126
127/// Returns whether a name is fully qualified: valid, absolute, and free of substitutions.
128///
129/// # Arguments
130///
131/// * `name` - the candidate name.
132///
133/// # Returns
134///
135/// `true` if `name` is valid, starts with `/`, and contains neither `~` nor `{}`. Only a fully
136/// qualified name can be mapped onto the middleware, because the namespace is already resolved.
137pub fn is_fully_qualified(name: &str) -> bool {
138 name.starts_with('/') && !name.contains('~') && !name.contains('{') && is_valid_name(name)
139}
140
141/// Maps a fully qualified ROS 2 name to its DDS topic name.
142///
143/// # Arguments
144///
145/// * `fqn` - a fully qualified name (starting with `/`).
146/// * `kind` - the subsystem, which selects the DDS prefix.
147///
148/// # Returns
149///
150/// `Some(dds_name)` such as `rt/cmd_vel`, formed by putting the subsystem prefix before the name
151/// and its suffix after, so a service's request travels on `rq/<name>Request` and its reply on
152/// `rr/<name>Reply`; `None` if `fqn` is not fully qualified.
153///
154/// # Examples
155///
156/// ```
157/// use pamoja_ros2::name::{dds_topic, EntityKind};
158///
159/// assert_eq!(dds_topic("/foo", EntityKind::Topic).as_deref(), Some("rt/foo"));
160/// assert_eq!(
161/// dds_topic("/robot1/camera_left/image_raw", EntityKind::Topic).as_deref(),
162/// Some("rt/robot1/camera_left/image_raw"),
163/// );
164/// assert_eq!(
165/// dds_topic("/add_two_ints", EntityKind::ServiceRequest).as_deref(),
166/// Some("rq/add_two_intsRequest"),
167/// );
168/// assert_eq!(
169/// dds_topic("/add_two_ints", EntityKind::ServiceResponse).as_deref(),
170/// Some("rr/add_two_intsReply"),
171/// );
172/// assert_eq!(dds_topic("relative", EntityKind::Topic), None); // not fully qualified
173/// ```
174pub fn dds_topic(fqn: &str, kind: EntityKind) -> Option<String> {
175 if !is_fully_qualified(fqn) {
176 return None;
177 }
178 Some(format!("{}{}{}", kind.prefix(), fqn, kind.suffix()))
179}
180
181/// Mangles a name by replacing each `/` with `%`, as `rmw_zenoh` does in liveliness tokens.
182///
183/// # Arguments
184///
185/// * `name` - the name to mangle.
186///
187/// # Returns
188///
189/// The name with every `/` replaced by `%`, for example `/chatter` becomes `%chatter`.
190///
191/// # Examples
192///
193/// ```
194/// use pamoja_ros2::name::percent_mangle;
195///
196/// assert_eq!(percent_mangle("/chatter"), "%chatter");
197/// assert_eq!(percent_mangle("/robot1/chatter"), "%robot1%chatter");
198/// ```
199pub fn percent_mangle(name: &str) -> String {
200 name.replace('/', "%")
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn accepts_well_formed_names() {
209 assert!(is_valid_name("chatter"));
210 assert!(is_valid_name("/chatter"));
211 assert!(is_valid_name("/robot1/camera_left/image_raw"));
212 assert!(is_valid_name("~/setpoint"));
213 assert!(is_valid_name("_hidden")); // a leading underscore is allowed
214 assert!(is_valid_name("/ns/{node}/out")); // a balanced substitution
215 }
216
217 #[test]
218 fn rejects_malformed_names() {
219 assert!(!is_valid_name(""));
220 assert!(!is_valid_name("/foo/"));
221 assert!(!is_valid_name("/foo//bar"));
222 assert!(!is_valid_name("/foo__bar"));
223 assert!(!is_valid_name("/2foo")); // token starts with a digit
224 assert!(!is_valid_name("/foo bar")); // space is not allowed
225 assert!(!is_valid_name("foo~bar")); // tilde only at the start
226 assert!(!is_valid_name("~foo")); // tilde must be followed by a slash
227 assert!(!is_valid_name("/ns/{node/out")); // unbalanced brace
228 }
229
230 #[test]
231 fn fully_qualified_requires_absolute_and_plain() {
232 assert!(is_fully_qualified("/foo/bar"));
233 assert!(!is_fully_qualified("foo")); // relative
234 assert!(!is_fully_qualified("~/foo")); // private
235 assert!(!is_fully_qualified("/{ns}/foo")); // substitution
236 }
237
238 #[test]
239 fn maps_to_dds_topic_names() {
240 assert_eq!(
241 dds_topic("/foo", EntityKind::Topic).as_deref(),
242 Some("rt/foo")
243 );
244 assert_eq!(
245 dds_topic("/robot1/camera_left/image_raw", EntityKind::Topic).as_deref(),
246 Some("rt/robot1/camera_left/image_raw"),
247 );
248 assert_eq!(dds_topic("relative", EntityKind::Topic), None);
249 }
250
251 #[test]
252 fn a_service_travels_on_the_names_the_middleware_builds() {
253 // rmw_cyclonedds builds these in rmw_node.cpp with make_fqtopic, and rmw_fastrtps in
254 // rmw_service.cpp with _create_topic_name: the prefix, the name, then the suffix.
255 assert_eq!(
256 dds_topic("/add_two_ints", EntityKind::ServiceRequest).as_deref(),
257 Some("rq/add_two_intsRequest"),
258 );
259 assert_eq!(
260 dds_topic("/add_two_ints", EntityKind::ServiceResponse).as_deref(),
261 Some("rr/add_two_intsReply"),
262 );
263 assert_eq!(EntityKind::Topic.suffix(), "");
264 }
265
266 #[test]
267 fn percent_mangles_slashes() {
268 assert_eq!(percent_mangle("/chatter"), "%chatter");
269 assert_eq!(percent_mangle("/robot1/chatter"), "%robot1%chatter");
270 }
271}