|
| 1 | +use crate::Error as RError; |
| 2 | + |
| 3 | +/// A unified representation for the names of topics, services, and actions. |
| 4 | +/// |
| 5 | +/// This type tries to conform to the naming conventions of both ROS1 and ROS2, |
| 6 | +/// but only supports a subset of the valid names as a result. |
| 7 | +/// |
| 8 | +/// For the time being roslibrust has decided to perform no automatic substitutions of name |
| 9 | +/// elements for users, so all names must be "Global" and be fully resolved and start with a `/`. |
| 10 | +/// ROS2 refers to these names a "fully qualified name". |
| 11 | +/// |
| 12 | +/// Examples of valid global names (to roslibrust's standards are): |
| 13 | +/// * `/chatter` |
| 14 | +/// * `/foo/bar/baz` |
| 15 | +/// * `/foo_bar_baz` |
| 16 | +/// * `/abc123/def_456` |
| 17 | +/// |
| 18 | +/// Examples of invalid Global names: |
| 19 | +/// * `chatter` - ROS1 / ROS2 may namespace this differently |
| 20 | +/// * `~chatter` - ROS2 rejects this without a '/' between `~` and the name |
| 21 | +/// * `~/chatter` - Even with the '/' RosLibRust will reject this as we don't support the '~' |
| 22 | +/// |
| 23 | +/// |
| 24 | +// For developers of this area look here for ROS1 documentation https://wiki.ros.org/Names |
| 25 | +// and here for ROS2 documentation https://design.ros2.org/articles/topic_and_service_names.html |
| 26 | +#[derive(Debug, Clone, PartialEq, Eq, Hash)] |
| 27 | +pub struct GlobalTopicName { |
| 28 | + inner: String, |
| 29 | +} |
| 30 | + |
| 31 | +impl GlobalTopicName { |
| 32 | + pub fn new(name: impl Into<String>) -> Result<GlobalTopicName, RError> { |
| 33 | + let name: String = name.into(); |
| 34 | + match validate_global_name(&name) { |
| 35 | + Ok(()) => Ok(Self { inner: name }), |
| 36 | + Err(failures) => Err(RError::InvalidName(format!( |
| 37 | + "Invalid topic name: {name}, reasons: {failures:?}" |
| 38 | + ))), |
| 39 | + } |
| 40 | + } |
| 41 | +} |
| 42 | + |
| 43 | +/// Can print the name with `{}` syntax as it has a canonical string representation |
| 44 | +impl std::fmt::Display for GlobalTopicName { |
| 45 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 46 | + self.inner.fmt(f) |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +// Conversion into a String is allowed as it is the canonical representation |
| 51 | +impl From<GlobalTopicName> for String { |
| 52 | + fn from(name: GlobalTopicName) -> Self { |
| 53 | + name.inner |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +// Allow GlobalTopicName to be used as &str |
| 58 | +impl AsRef<str> for GlobalTopicName { |
| 59 | + fn as_ref(&self) -> &str { |
| 60 | + &self.inner |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +/// This trait represents types that can be converted into a GlobalTopicName |
| 65 | +/// |
| 66 | +/// This trait is in use within roslibrust because we have APIs that want to take either |
| 67 | +/// &str, String, GlobalTopicName, or &GlobalTopicName, but we don't want to use TryFrom |
| 68 | +/// as the error types are not compatible, and would force additional boilerplate on users. |
| 69 | +pub trait ToGlobalTopicName: Send { |
| 70 | + fn to_global_name(self) -> Result<GlobalTopicName, RError>; |
| 71 | +} |
| 72 | + |
| 73 | +impl ToGlobalTopicName for GlobalTopicName { |
| 74 | + fn to_global_name(self) -> Result<GlobalTopicName, RError> { |
| 75 | + Ok(self) |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +impl ToGlobalTopicName for &GlobalTopicName { |
| 80 | + fn to_global_name(self) -> Result<GlobalTopicName, RError> { |
| 81 | + Ok(self.clone()) |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +impl ToGlobalTopicName for String { |
| 86 | + fn to_global_name(self) -> Result<GlobalTopicName, RError> { |
| 87 | + GlobalTopicName::new(self) |
| 88 | + } |
| 89 | +} |
| 90 | + |
| 91 | +impl ToGlobalTopicName for &str { |
| 92 | + fn to_global_name(self) -> Result<GlobalTopicName, RError> { |
| 93 | + GlobalTopicName::new(self) |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +static GLOBAL_NAME_REGEX: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| { |
| 98 | + // Best attempt at a regex that matches both ROS1 and ROS2 naming conventions |
| 99 | + regex::Regex::new(r"(?-u)^\/([A-Za-z][A-Za-z0-9_]*)(\/[A-Za-z][A-Za-z0-9_]*)*$").unwrap() |
| 100 | +}); |
| 101 | + |
| 102 | +/// Check the name against our set of rules for validity |
| 103 | +/// Returns a list of reasons the name is invalid |
| 104 | +fn validate_global_name(name: &str) -> Result<(), Vec<String>> { |
| 105 | + // First character must be a '/' |
| 106 | + let mut failures = vec![]; |
| 107 | + if !name.starts_with('/') { |
| 108 | + failures.push("Name must start with a '/'".to_string()); |
| 109 | + } |
| 110 | + |
| 111 | + // Name must not contain any whitespace |
| 112 | + if name.contains(char::is_whitespace) { |
| 113 | + failures.push("Name must not contain whitespace".to_string()); |
| 114 | + } |
| 115 | + |
| 116 | + // Name must not contain characters other than alphanumeric, underscore, and forward slash |
| 117 | + if !name |
| 118 | + .chars() |
| 119 | + .all(|c| c.is_alphanumeric() || c == '_' || c == '/') |
| 120 | + { |
| 121 | + failures.push( |
| 122 | + "Name must only contain alphanumeric characters, underscores, and forward slashes" |
| 123 | + .to_string(), |
| 124 | + ); |
| 125 | + } |
| 126 | + |
| 127 | + // Name must not end with a '/' |
| 128 | + if name.ends_with('/') { |
| 129 | + failures.push("Name must not end with a '/'".to_string()); |
| 130 | + } |
| 131 | + |
| 132 | + // Use the ROS1 validation regex for a final check (in a lazy cell) |
| 133 | + if !GLOBAL_NAME_REGEX.is_match(name) { |
| 134 | + failures.push("Name must match the ROS1 name validation regex".to_string()); |
| 135 | + } |
| 136 | + |
| 137 | + if failures.is_empty() { |
| 138 | + Ok(()) |
| 139 | + } else { |
| 140 | + Err(failures) |
| 141 | + } |
| 142 | +} |
| 143 | + |
| 144 | +#[cfg(test)] |
| 145 | +mod tests { |
| 146 | + use super::*; |
| 147 | + |
| 148 | + #[test] |
| 149 | + fn test_valid_global_names() { |
| 150 | + assert!(GlobalTopicName::new("/chatter").is_ok()); |
| 151 | + assert!(GlobalTopicName::new("/foo/bar/baz").is_ok()); |
| 152 | + assert!(GlobalTopicName::new("/foo_bar_baz").is_ok()); |
| 153 | + assert!(GlobalTopicName::new("/abc123/def_456").is_ok()); |
| 154 | + |
| 155 | + assert!(GlobalTopicName::new("chatter").is_err()); |
| 156 | + assert!(GlobalTopicName::new("chatter/").is_err()); |
| 157 | + assert!(GlobalTopicName::new("/chatter/").is_err()); |
| 158 | + assert!(GlobalTopicName::new("/chatter ").is_err()); |
| 159 | + assert!(GlobalTopicName::new("/chatter space").is_err()); |
| 160 | + assert!(GlobalTopicName::new("/chatter#").is_err()); |
| 161 | + assert!(GlobalTopicName::new("~chatter").is_err()); |
| 162 | + assert!(GlobalTopicName::new("/chatter/{ros2}").is_err()); |
| 163 | + assert!(GlobalTopicName::new("/chatter-").is_err()); |
| 164 | + assert!(GlobalTopicName::new("/chatter/with space").is_err()); |
| 165 | + assert!(GlobalTopicName::new("/chatter/with#hash").is_err()); |
| 166 | + assert!(GlobalTopicName::new("/empty//bad").is_err()); |
| 167 | + |
| 168 | + // It is unclear for the ROS documentation if this should be valid or not |
| 169 | + // assert!(GlobalTopicName::new("/123/_456/").is_err()); |
| 170 | + // assert!(GlobalTopicName::new("/123").is_err()); |
| 171 | + } |
| 172 | + |
| 173 | + #[test] |
| 174 | + fn type_conversions_exist_and_behave() { |
| 175 | + // Test for the ToGlobalTopicName trait - this is how roslibrust traits work! |
| 176 | + fn generic_with_to_global<MsgType>(name: impl ToGlobalTopicName) { |
| 177 | + let name: GlobalTopicName = name.to_global_name().unwrap(); |
| 178 | + assert_eq!(name.to_string(), "/chatter".to_string()); |
| 179 | + } |
| 180 | + |
| 181 | + // Works with String |
| 182 | + generic_with_to_global::<String>("/chatter".to_string()); |
| 183 | + // Works with &str |
| 184 | + generic_with_to_global::<String>("/chatter"); |
| 185 | + // Works with GlobalTopicName |
| 186 | + generic_with_to_global::<String>(GlobalTopicName::new("/chatter").unwrap()); |
| 187 | + // Works with &GlobalTopicName |
| 188 | + generic_with_to_global::<String>(&GlobalTopicName::new("/chatter").unwrap()); |
| 189 | + } |
| 190 | +} |
0 commit comments