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