Skip to main content

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}