#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum NameError {
#[error("name must not be empty")]
Empty,
#[error("name is {length} bytes, exceeds maximum of {max}")]
TooLong { length: usize, max: usize },
#[error("name contains invalid character '{ch}' at byte offset {offset}")]
InvalidChar { ch: char, offset: usize },
#[error("name must start with a letter or underscore, got '{ch}'")]
BadStart { ch: char },
}
pub trait Name: Sized + AsRef<str> {
const KIND: &'static str;
const MAX_LEN: usize = 64;
fn validate_char(offset: usize, ch: char) -> Result<(), NameError> {
if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
Ok(())
} else {
Err(NameError::InvalidChar { ch, offset })
}
}
fn validate(s: &str) -> Result<(), NameError> {
if s.is_empty() {
return Err(NameError::Empty);
}
if s.len() > Self::MAX_LEN {
return Err(NameError::TooLong {
length: s.len(),
max: Self::MAX_LEN,
});
}
let first = s.chars().next().expect("non-empty checked above");
if !(first.is_ascii_alphabetic() || first == '_') {
return Err(NameError::BadStart { ch: first });
}
for (offset, ch) in s.char_indices() {
Self::validate_char(offset, ch)?;
}
Ok(())
}
}
#[macro_export]
macro_rules! name_type {
(
$(#[$meta:meta])*
$vis:vis struct $Name:ident {
kind: $kind:literal
$(, max_len: $max_len:expr )?
$(,)?
}
) => {
$(#[$meta])*
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
$vis struct $Name(String);
impl $Name {
pub fn new(candidate: impl Into<String>) -> Result<Self, $crate::names::NameError> {
let s = candidate.into();
<Self as $crate::names::Name>::validate(&s)?;
Ok(Self(s))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_inner(self) -> String {
self.0
}
}
impl $crate::names::Name for $Name {
const KIND: &'static str = $kind;
$( const MAX_LEN: usize = $max_len; )?
}
impl AsRef<str> for $Name {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for $Name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::fmt::Debug for $Name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}({:?})", <Self as $crate::names::Name>::KIND, self.0)
}
}
impl std::str::FromStr for $Name {
type Err = $crate::names::NameError;
fn from_str(s: &str) -> Result<Self, $crate::names::NameError> {
Self::new(s.to_owned())
}
}
impl TryFrom<&str> for $Name {
type Error = $crate::names::NameError;
fn try_from(s: &str) -> Result<Self, $crate::names::NameError> {
Self::new(s.to_owned())
}
}
impl TryFrom<String> for $Name {
type Error = $crate::names::NameError;
fn try_from(s: String) -> Result<Self, $crate::names::NameError> {
Self::new(s)
}
}
impl serde::Serialize for $Name {
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl<'de> serde::Deserialize<'de> for $Name {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::new(s).map_err(serde::de::Error::custom)
}
}
};
}
name_type! {
pub struct ParameterName { kind: "ParameterName" }
}
name_type! {
pub struct ElementName { kind: "ElementName" }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_names_are_accepted() {
for s in ["threads", "max_connections", "api-v1", "dataset.main", "_reserved"] {
ParameterName::new(s).expect(s);
}
}
#[test]
fn empty_names_are_rejected() {
assert_eq!(ParameterName::new(""), Err(NameError::Empty));
}
#[test]
fn names_must_start_with_letter_or_underscore() {
assert!(matches!(
ParameterName::new("1starts-with-digit"),
Err(NameError::BadStart { ch: '1' })
));
assert!(matches!(
ParameterName::new(".leading-dot"),
Err(NameError::BadStart { ch: '.' })
));
}
#[test]
fn names_reject_forbidden_chars() {
let err = ParameterName::new("has space").unwrap_err();
assert_eq!(
err,
NameError::InvalidChar { ch: ' ', offset: 3 }
);
}
#[test]
fn names_reject_overlong_candidates() {
let long = "a".repeat(65);
let err = ParameterName::new(long).unwrap_err();
assert_eq!(err, NameError::TooLong { length: 65, max: 64 });
}
#[test]
fn debug_format_includes_kind() {
let p = ParameterName::new("threads").unwrap();
assert_eq!(format!("{p:?}"), "ParameterName(\"threads\")");
let e = ElementName::new("jvector").unwrap();
assert_eq!(format!("{e:?}"), "ElementName(\"jvector\")");
}
#[test]
fn different_kinds_are_type_distinct() {
let _p = ParameterName::new("x").unwrap();
let _e = ElementName::new("x").unwrap();
}
#[test]
fn serde_roundtrip() {
let name = ParameterName::new("threads").unwrap();
let json = serde_json::to_string(&name).unwrap();
assert_eq!(json, "\"threads\"");
let back: ParameterName = serde_json::from_str(&json).unwrap();
assert_eq!(name, back);
}
#[test]
fn deserialise_rejects_invalid_names() {
let err = serde_json::from_str::<ParameterName>("\"has space\"");
assert!(err.is_err());
}
use proptest::prelude::*;
proptest! {
#[test]
fn valid_names_roundtrip(
s in "[A-Za-z_][A-Za-z0-9_\\-.]{0,63}"
) {
let name = ParameterName::new(s.clone()).expect(&s);
prop_assert_eq!(name.as_str(), s.as_str());
}
}
}