use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillNameError {
Empty,
TooLong {
length: usize,
max: usize,
},
InvalidCharacter {
char: char,
position: usize,
},
StartsWithHyphen,
EndsWithHyphen,
ConsecutiveHyphens {
position: usize,
},
}
impl fmt::Display for SkillNameError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "skill name cannot be empty"),
Self::TooLong { length, max } => {
write!(f, "skill name is {length} characters; maximum is {max}")
}
Self::InvalidCharacter { char, position } => {
write!(
f,
"invalid character '{char}' at position {position}; only lowercase alphanumeric and hyphens allowed"
)
}
Self::StartsWithHyphen => write!(f, "skill name cannot start with a hyphen"),
Self::EndsWithHyphen => write!(f, "skill name cannot end with a hyphen"),
Self::ConsecutiveHyphens { position } => {
write!(
f,
"consecutive hyphens at position {position}; use single hyphens only"
)
}
}
}
}
impl std::error::Error for SkillNameError {}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SkillName(String);
impl SkillName {
pub const MAX_LENGTH: usize = 64;
pub fn new(name: impl Into<String>) -> Result<Self, SkillNameError> {
let name = name.into();
validate_skill_name(&name)?;
Ok(Self(name))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for SkillName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for SkillName {
type Err = SkillNameError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl AsRef<str> for SkillName {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Serialize for SkillName {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
self.0.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SkillName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::new(s).map_err(serde::de::Error::custom)
}
}
fn validate_skill_name(name: &str) -> Result<(), SkillNameError> {
if name.is_empty() {
return Err(SkillNameError::Empty);
}
if name.len() > SkillName::MAX_LENGTH {
return Err(SkillNameError::TooLong {
length: name.len(),
max: SkillName::MAX_LENGTH,
});
}
if name.starts_with('-') {
return Err(SkillNameError::StartsWithHyphen);
}
if name.ends_with('-') {
return Err(SkillNameError::EndsWithHyphen);
}
let mut prev_was_hyphen = false;
for (position, char) in name.chars().enumerate() {
if char == '-' {
if prev_was_hyphen {
return Err(SkillNameError::ConsecutiveHyphens { position });
}
prev_was_hyphen = true;
} else if char.is_ascii_lowercase() || char.is_ascii_digit() {
prev_was_hyphen = false;
} else {
return Err(SkillNameError::InvalidCharacter { char, position });
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn valid_name_is_accepted() {
let name = SkillName::new("pdf-processing");
assert!(name.is_ok());
assert_eq!(name.unwrap().as_str(), "pdf-processing");
}
#[test]
fn simple_name_is_accepted() {
let name = SkillName::new("skill");
assert!(name.is_ok());
}
#[test]
fn name_with_numbers_is_accepted() {
let name = SkillName::new("tool2go");
assert!(name.is_ok());
}
#[test]
fn name_at_max_length_is_accepted() {
let name = "a".repeat(64);
assert!(SkillName::new(name).is_ok());
}
#[test]
fn empty_name_is_rejected() {
let result = SkillName::new("");
assert_eq!(result, Err(SkillNameError::Empty));
}
#[test]
fn name_exceeding_max_length_is_rejected() {
let long_name = "a".repeat(65);
let result = SkillName::new(long_name);
assert!(matches!(
result,
Err(SkillNameError::TooLong {
length: 65,
max: 64
})
));
}
#[test]
fn uppercase_characters_are_rejected() {
let result = SkillName::new("PDF-Processing");
assert!(matches!(
result,
Err(SkillNameError::InvalidCharacter {
char: 'P',
position: 0
})
));
}
#[test]
fn uppercase_in_middle_is_rejected() {
let result = SkillName::new("pdfProcessing");
assert!(matches!(
result,
Err(SkillNameError::InvalidCharacter {
char: 'P',
position: 3
})
));
}
#[test]
fn name_starting_with_hyphen_is_rejected() {
let result = SkillName::new("-pdf");
assert_eq!(result, Err(SkillNameError::StartsWithHyphen));
}
#[test]
fn name_ending_with_hyphen_is_rejected() {
let result = SkillName::new("pdf-");
assert_eq!(result, Err(SkillNameError::EndsWithHyphen));
}
#[test]
fn consecutive_hyphens_are_rejected() {
let result = SkillName::new("pdf--processing");
assert!(matches!(
result,
Err(SkillNameError::ConsecutiveHyphens { position: 4 })
));
}
#[test]
fn underscore_is_rejected() {
let result = SkillName::new("pdf_processing");
assert!(matches!(
result,
Err(SkillNameError::InvalidCharacter {
char: '_',
position: 3
})
));
}
#[test]
fn space_is_rejected() {
let result = SkillName::new("pdf processing");
assert!(matches!(
result,
Err(SkillNameError::InvalidCharacter {
char: ' ',
position: 3
})
));
}
#[test]
fn display_returns_inner_string() {
let name = SkillName::new("my-skill").unwrap();
assert_eq!(format!("{name}"), "my-skill");
}
#[test]
fn from_str_works() {
let name: SkillName = "my-skill".parse().unwrap();
assert_eq!(name.as_str(), "my-skill");
}
#[test]
fn as_ref_works() {
let name = SkillName::new("my-skill").unwrap();
let s: &str = name.as_ref();
assert_eq!(s, "my-skill");
}
#[test]
fn error_display_is_helpful() {
let err = SkillNameError::InvalidCharacter {
char: 'X',
position: 5,
};
let msg = err.to_string();
assert!(msg.contains("'X'"));
assert!(msg.contains("position 5"));
assert!(msg.contains("lowercase alphanumeric"));
}
}