#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum NameError {
#[error("name is empty")]
Empty,
#[error("name {name:?} contains invalid character {ch:?}")]
InvalidCharacter {
name: String,
ch: char,
},
#[error("name {0:?} has a token starting with a digit")]
TokenStartsWithDigit(String),
#[error("name {0:?} has an empty token")]
EmptyToken(String),
#[error("namespace {0:?} must start with '/'")]
NotAbsolute(String),
#[error("name {0:?} uses '~' other than as the leading token")]
MisplacedTilde(String),
#[error("name {0:?} has an unknown or unterminated substitution")]
BadSubstitution(String),
#[error("name {0:?} is longer than 255 bytes")]
TooLong(String),
}
const MAX_NAME_LEN: usize = 255;
fn validate_tokens(name: &str, body: &str) -> Result<(), NameError> {
if body.is_empty() {
return Ok(());
}
for token in body.split('/') {
let Some(first) = token.chars().next() else {
return Err(NameError::EmptyToken(name.to_owned()));
};
if first.is_ascii_digit() {
return Err(NameError::TokenStartsWithDigit(name.to_owned()));
}
if let Some(ch) = token
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || *c == '_'))
{
return Err(NameError::InvalidCharacter {
name: name.to_owned(),
ch,
});
}
}
Ok(())
}
pub fn validate_node_name(name: &str) -> Result<(), NameError> {
if name.is_empty() {
return Err(NameError::Empty);
}
if name.len() > MAX_NAME_LEN {
return Err(NameError::TooLong(name.to_owned()));
}
if name.contains('/') {
return Err(NameError::InvalidCharacter {
name: name.to_owned(),
ch: '/',
});
}
validate_tokens(name, name)
}
pub fn validate_namespace(ns: &str) -> Result<(), NameError> {
if ns.is_empty() {
return Err(NameError::Empty);
}
let Some(body) = ns.strip_prefix('/') else {
return Err(NameError::NotAbsolute(ns.to_owned()));
};
if ns.len() > MAX_NAME_LEN {
return Err(NameError::TooLong(ns.to_owned()));
}
validate_tokens(ns, body)
}
pub fn validate_full_topic_name(name: &str) -> Result<(), NameError> {
if name.is_empty() {
return Err(NameError::Empty);
}
let Some(body) = name.strip_prefix('/') else {
return Err(NameError::NotAbsolute(name.to_owned()));
};
if body.is_empty() {
return Err(NameError::EmptyToken(name.to_owned()));
}
if name.len() > MAX_NAME_LEN {
return Err(NameError::TooLong(name.to_owned()));
}
validate_tokens(name, body)
}
#[must_use]
pub fn normalize_namespace(ns: &str) -> String {
let trimmed = ns.trim_matches('/');
format!("/{trimmed}")
}
pub fn expand_topic_name(
name: &str,
node_name: &str,
namespace: &str,
) -> Result<String, NameError> {
if name.is_empty() {
return Err(NameError::Empty);
}
if name.contains("//") || (name.len() > 1 && name.ends_with('/')) {
return Err(NameError::EmptyToken(name.to_owned()));
}
if name.char_indices().any(|(i, c)| c == '~' && i != 0) {
return Err(NameError::MisplacedTilde(name.to_owned()));
}
let ns = normalize_namespace(namespace);
let node_fqn = if ns == "/" {
format!("/{node_name}")
} else {
format!("{ns}/{node_name}")
};
let mut expanded = if name == "~" {
node_fqn
} else if let Some(rest) = name.strip_prefix("~/") {
format!("{node_fqn}/{rest}")
} else if name.starts_with('~') {
return Err(NameError::MisplacedTilde(name.to_owned()));
} else {
name.to_owned()
};
let mut out = String::with_capacity(expanded.len());
let mut rest = expanded.as_str();
while let Some((before, after)) = rest.split_once('{') {
out.push_str(before);
let Some((key, tail)) = after.split_once('}') else {
return Err(NameError::BadSubstitution(name.to_owned()));
};
match key {
"node" => out.push_str(node_name),
"ns" | "namespace" => out.push_str(&ns),
_ => return Err(NameError::BadSubstitution(name.to_owned())),
}
rest = tail;
}
out.push_str(rest);
expanded = out;
if !expanded.starts_with('/') {
expanded = if ns == "/" {
format!("/{expanded}")
} else {
format!("{ns}/{expanded}")
};
}
while expanded.contains("//") {
expanded = expanded.replace("//", "/");
}
validate_full_topic_name(&expanded)?;
Ok(expanded)
}
#[must_use]
pub fn sanitize_token(label: &str) -> String {
let mapped: String = label
.trim()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
let mut out = mapped.trim_start_matches('_').to_owned();
if out.is_empty() {
return "unnamed".to_owned();
}
if out.starts_with(|c: char| c.is_ascii_digit()) {
out.insert(0, 'n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expansion() {
assert_eq!(expand_topic_name("/tf", "n", "/").unwrap(), "/tf");
assert_eq!(expand_topic_name("pose", "n", "/").unwrap(), "/pose");
assert_eq!(
expand_topic_name("pose", "n", "/mocap").unwrap(),
"/mocap/pose"
);
assert_eq!(expand_topic_name("~", "n", "/mocap").unwrap(), "/mocap/n");
assert_eq!(expand_topic_name("~/srv", "n", "/").unwrap(), "/n/srv");
assert_eq!(
expand_topic_name("~/srv", "n", "mocap/").unwrap(),
"/mocap/n/srv"
);
assert_eq!(expand_topic_name("{node}/x", "n", "/a").unwrap(), "/a/n/x");
assert_eq!(expand_topic_name("{ns}/x", "n", "/").unwrap(), "/x");
assert_eq!(expand_topic_name("{ns}/x", "n", "/a").unwrap(), "/a/x");
assert!(expand_topic_name("a/~", "n", "/").is_err());
assert!(expand_topic_name("{bogus}", "n", "/").is_err());
assert!(expand_topic_name("1abc", "n", "/").is_err());
assert!(expand_topic_name("a//b", "n", "/").is_err());
assert!(expand_topic_name("a/", "n", "/").is_err());
assert!(expand_topic_name("a-b", "n", "/").is_err());
}
#[test]
fn validation() {
assert!(validate_node_name("natnet_zenoh").is_ok());
assert!(validate_node_name("9x").is_err());
assert!(validate_node_name("a/b").is_err());
assert!(validate_namespace("/").is_ok());
assert!(validate_namespace("/a/b").is_ok());
assert!(validate_namespace("a").is_err());
assert!(validate_namespace("/a/").is_err());
assert_eq!(normalize_namespace(""), "/");
assert_eq!(normalize_namespace("a/b/"), "/a/b");
}
#[test]
fn sanitize() {
assert_eq!(sanitize_token("Rover 1"), "Rover_1");
assert_eq!(sanitize_token("3dof-arm"), "n3dof_arm");
assert_eq!(sanitize_token(" "), "unnamed");
assert_eq!(sanitize_token("Ärm"), "rm");
assert_eq!(sanitize_token("_hidden"), "hidden");
assert_eq!(sanitize_token("--"), "unnamed");
}
}