use crate::Error as RError;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GlobalTopicName {
inner: String,
}
impl GlobalTopicName {
pub fn new(name: impl Into<String>) -> Result<GlobalTopicName, RError> {
let name: String = name.into();
match validate_global_name(&name) {
Ok(()) => Ok(Self { inner: name }),
Err(failures) => Err(RError::InvalidName(format!(
"Invalid topic name: {name}, reasons: {failures:?}"
))),
}
}
}
impl std::fmt::Display for GlobalTopicName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.inner.fmt(f)
}
}
impl From<GlobalTopicName> for String {
fn from(name: GlobalTopicName) -> Self {
name.inner
}
}
impl AsRef<str> for GlobalTopicName {
fn as_ref(&self) -> &str {
&self.inner
}
}
pub trait ToGlobalTopicName: Send {
fn to_global_name(self) -> Result<GlobalTopicName, RError>;
}
impl ToGlobalTopicName for GlobalTopicName {
fn to_global_name(self) -> Result<GlobalTopicName, RError> {
Ok(self)
}
}
impl ToGlobalTopicName for &GlobalTopicName {
fn to_global_name(self) -> Result<GlobalTopicName, RError> {
Ok(self.clone())
}
}
impl ToGlobalTopicName for String {
fn to_global_name(self) -> Result<GlobalTopicName, RError> {
GlobalTopicName::new(self)
}
}
impl ToGlobalTopicName for &String {
fn to_global_name(self) -> Result<GlobalTopicName, RError> {
GlobalTopicName::new(self)
}
}
impl ToGlobalTopicName for &str {
fn to_global_name(self) -> Result<GlobalTopicName, RError> {
GlobalTopicName::new(self)
}
}
static GLOBAL_NAME_REGEX: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"(?-u)^\/([A-Za-z][A-Za-z0-9_]*)(\/[A-Za-z][A-Za-z0-9_]*)*$").unwrap()
});
fn validate_global_name(name: &str) -> Result<(), Vec<String>> {
let mut failures = vec![];
if !name.starts_with('/') {
failures.push("Name must start with a '/'".to_string());
}
if name.contains(char::is_whitespace) {
failures.push("Name must not contain whitespace".to_string());
}
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '/')
{
failures.push(
"Name must only contain alphanumeric characters, underscores, and forward slashes"
.to_string(),
);
}
if name.ends_with('/') {
failures.push("Name must not end with a '/'".to_string());
}
if !GLOBAL_NAME_REGEX.is_match(name) {
failures.push("Name must match the ROS1 name validation regex".to_string());
}
if failures.is_empty() {
Ok(())
} else {
Err(failures)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_global_names() {
assert!(GlobalTopicName::new("/chatter").is_ok());
assert!(GlobalTopicName::new("/foo/bar/baz").is_ok());
assert!(GlobalTopicName::new("/foo_bar_baz").is_ok());
assert!(GlobalTopicName::new("/abc123/def_456").is_ok());
assert!(GlobalTopicName::new("chatter").is_err());
assert!(GlobalTopicName::new("chatter/").is_err());
assert!(GlobalTopicName::new("/chatter/").is_err());
assert!(GlobalTopicName::new("/chatter ").is_err());
assert!(GlobalTopicName::new("/chatter space").is_err());
assert!(GlobalTopicName::new("/chatter#").is_err());
assert!(GlobalTopicName::new("~chatter").is_err());
assert!(GlobalTopicName::new("/chatter/{ros2}").is_err());
assert!(GlobalTopicName::new("/chatter-").is_err());
assert!(GlobalTopicName::new("/chatter/with space").is_err());
assert!(GlobalTopicName::new("/chatter/with#hash").is_err());
assert!(GlobalTopicName::new("/empty//bad").is_err());
}
#[test]
fn type_conversions_exist_and_behave() {
fn generic_with_to_global<MsgType>(name: impl ToGlobalTopicName) {
let name: GlobalTopicName = name.to_global_name().unwrap();
assert_eq!(name.to_string(), "/chatter".to_string());
}
generic_with_to_global::<String>("/chatter".to_string());
let chatter = "/chatter".to_string();
generic_with_to_global::<String>(&chatter);
generic_with_to_global::<String>("/chatter");
generic_with_to_global::<String>(GlobalTopicName::new("/chatter").unwrap());
generic_with_to_global::<String>(&GlobalTopicName::new("/chatter").unwrap());
}
}