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}