use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillDescriptionError {
Empty,
TooLong {
length: usize,
max: usize,
},
}
impl fmt::Display for SkillDescriptionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "skill description cannot be empty or whitespace-only"),
Self::TooLong { length, max } => {
write!(
f,
"skill description is {length} characters; maximum is {max}"
)
}
}
}
}
impl std::error::Error for SkillDescriptionError {}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SkillDescription(String);
impl SkillDescription {
pub const MAX_LENGTH: usize = 1024;
pub fn new(description: impl Into<String>) -> Result<Self, SkillDescriptionError> {
let description = description.into();
validate_skill_description(&description)?;
Ok(Self(description))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for SkillDescription {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for SkillDescription {
type Err = SkillDescriptionError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl AsRef<str> for SkillDescription {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Serialize for SkillDescription {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
self.0.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for SkillDescription {
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_description(description: &str) -> Result<(), SkillDescriptionError> {
if description.trim().is_empty() {
return Err(SkillDescriptionError::Empty);
}
if description.len() > SkillDescription::MAX_LENGTH {
return Err(SkillDescriptionError::TooLong {
length: description.len(),
max: SkillDescription::MAX_LENGTH,
});
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn valid_description_is_accepted() {
let desc = SkillDescription::new("Extracts text from PDF files.");
assert!(desc.is_ok());
}
#[test]
fn description_at_max_length_is_accepted() {
let desc = "a".repeat(1024);
assert!(SkillDescription::new(desc).is_ok());
}
#[test]
fn empty_description_is_rejected() {
let result = SkillDescription::new("");
assert_eq!(result, Err(SkillDescriptionError::Empty));
}
#[test]
fn whitespace_only_description_is_rejected() {
let result = SkillDescription::new(" \n\t ");
assert_eq!(result, Err(SkillDescriptionError::Empty));
}
#[test]
fn description_exceeding_max_length_is_rejected() {
let long_desc = "a".repeat(1025);
let result = SkillDescription::new(long_desc);
assert!(matches!(
result,
Err(SkillDescriptionError::TooLong {
length: 1025,
max: 1024
})
));
}
#[test]
fn description_with_leading_trailing_whitespace_is_accepted() {
let desc = SkillDescription::new(" Some description ");
assert!(desc.is_ok());
}
#[test]
fn display_returns_inner_string() {
let desc = SkillDescription::new("My description").unwrap();
assert_eq!(format!("{desc}"), "My description");
}
#[test]
fn from_str_works() {
let desc: SkillDescription = "My description".parse().unwrap();
assert_eq!(desc.as_str(), "My description");
}
#[test]
fn as_ref_works() {
let desc = SkillDescription::new("My description").unwrap();
let s: &str = desc.as_ref();
assert_eq!(s, "My description");
}
#[test]
fn error_display_is_helpful() {
let err = SkillDescriptionError::TooLong {
length: 2000,
max: 1024,
};
let msg = err.to_string();
assert!(msg.contains("2000"));
assert!(msg.contains("1024"));
}
}