1use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum SkillNameError {
11 Empty,
13 TooLong {
15 length: usize,
17 max: usize,
19 },
20 InvalidCharacter {
22 char: char,
24 position: usize,
26 },
27 StartsWithHyphen,
29 EndsWithHyphen,
31 ConsecutiveHyphens {
33 position: usize,
35 },
36}
37
38impl fmt::Display for SkillNameError {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 match self {
41 Self::Empty => write!(f, "skill name cannot be empty"),
42 Self::TooLong { length, max } => {
43 write!(f, "skill name is {length} characters; maximum is {max}")
44 }
45 Self::InvalidCharacter { char, position } => {
46 write!(
47 f,
48 "invalid character '{char}' at position {position}; only lowercase alphanumeric and hyphens allowed"
49 )
50 }
51 Self::StartsWithHyphen => write!(f, "skill name cannot start with a hyphen"),
52 Self::EndsWithHyphen => write!(f, "skill name cannot end with a hyphen"),
53 Self::ConsecutiveHyphens { position } => {
54 write!(
55 f,
56 "consecutive hyphens at position {position}; use single hyphens only"
57 )
58 }
59 }
60 }
61}
62
63impl std::error::Error for SkillNameError {}
64
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
89pub struct SkillName(String);
90
91impl SkillName {
92 pub const MAX_LENGTH: usize = 64;
94
95 pub fn new(name: impl Into<String>) -> Result<Self, SkillNameError> {
101 let name = name.into();
102 validate_skill_name(&name)?;
103 Ok(Self(name))
104 }
105
106 #[must_use]
108 pub fn as_str(&self) -> &str {
109 &self.0
110 }
111}
112
113impl fmt::Display for SkillName {
114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115 write!(f, "{}", self.0)
116 }
117}
118
119impl FromStr for SkillName {
120 type Err = SkillNameError;
121
122 fn from_str(s: &str) -> Result<Self, Self::Err> {
123 Self::new(s)
124 }
125}
126
127impl AsRef<str> for SkillName {
128 fn as_ref(&self) -> &str {
129 &self.0
130 }
131}
132
133impl Serialize for SkillName {
134 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
135 where
136 S: Serializer,
137 {
138 self.0.serialize(serializer)
139 }
140}
141
142impl<'de> Deserialize<'de> for SkillName {
143 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
144 where
145 D: Deserializer<'de>,
146 {
147 let s = String::deserialize(deserializer)?;
148 Self::new(s).map_err(serde::de::Error::custom)
149 }
150}
151
152fn validate_skill_name(name: &str) -> Result<(), SkillNameError> {
154 if name.is_empty() {
156 return Err(SkillNameError::Empty);
157 }
158
159 if name.len() > SkillName::MAX_LENGTH {
161 return Err(SkillNameError::TooLong {
162 length: name.len(),
163 max: SkillName::MAX_LENGTH,
164 });
165 }
166
167 if name.starts_with('-') {
169 return Err(SkillNameError::StartsWithHyphen);
170 }
171
172 if name.ends_with('-') {
174 return Err(SkillNameError::EndsWithHyphen);
175 }
176
177 let mut prev_was_hyphen = false;
179 for (position, char) in name.chars().enumerate() {
180 if char == '-' {
181 if prev_was_hyphen {
182 return Err(SkillNameError::ConsecutiveHyphens { position });
183 }
184 prev_was_hyphen = true;
185 } else if char.is_ascii_lowercase() || char.is_ascii_digit() {
186 prev_was_hyphen = false;
187 } else {
188 return Err(SkillNameError::InvalidCharacter { char, position });
189 }
190 }
191
192 Ok(())
193}
194
195#[cfg(test)]
196#[allow(clippy::unwrap_used, clippy::expect_used)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn valid_name_is_accepted() {
202 let name = SkillName::new("pdf-processing");
203 assert!(name.is_ok());
204 assert_eq!(name.unwrap().as_str(), "pdf-processing");
205 }
206
207 #[test]
208 fn simple_name_is_accepted() {
209 let name = SkillName::new("skill");
210 assert!(name.is_ok());
211 }
212
213 #[test]
214 fn name_with_numbers_is_accepted() {
215 let name = SkillName::new("tool2go");
216 assert!(name.is_ok());
217 }
218
219 #[test]
220 fn name_at_max_length_is_accepted() {
221 let name = "a".repeat(64);
222 assert!(SkillName::new(name).is_ok());
223 }
224
225 #[test]
226 fn empty_name_is_rejected() {
227 let result = SkillName::new("");
228 assert_eq!(result, Err(SkillNameError::Empty));
229 }
230
231 #[test]
232 fn name_exceeding_max_length_is_rejected() {
233 let long_name = "a".repeat(65);
234 let result = SkillName::new(long_name);
235 assert!(matches!(
236 result,
237 Err(SkillNameError::TooLong {
238 length: 65,
239 max: 64
240 })
241 ));
242 }
243
244 #[test]
245 fn uppercase_characters_are_rejected() {
246 let result = SkillName::new("PDF-Processing");
247 assert!(matches!(
248 result,
249 Err(SkillNameError::InvalidCharacter {
250 char: 'P',
251 position: 0
252 })
253 ));
254 }
255
256 #[test]
257 fn uppercase_in_middle_is_rejected() {
258 let result = SkillName::new("pdfProcessing");
259 assert!(matches!(
260 result,
261 Err(SkillNameError::InvalidCharacter {
262 char: 'P',
263 position: 3
264 })
265 ));
266 }
267
268 #[test]
269 fn name_starting_with_hyphen_is_rejected() {
270 let result = SkillName::new("-pdf");
271 assert_eq!(result, Err(SkillNameError::StartsWithHyphen));
272 }
273
274 #[test]
275 fn name_ending_with_hyphen_is_rejected() {
276 let result = SkillName::new("pdf-");
277 assert_eq!(result, Err(SkillNameError::EndsWithHyphen));
278 }
279
280 #[test]
281 fn consecutive_hyphens_are_rejected() {
282 let result = SkillName::new("pdf--processing");
283 assert!(matches!(
284 result,
285 Err(SkillNameError::ConsecutiveHyphens { position: 4 })
286 ));
287 }
288
289 #[test]
290 fn underscore_is_rejected() {
291 let result = SkillName::new("pdf_processing");
292 assert!(matches!(
293 result,
294 Err(SkillNameError::InvalidCharacter {
295 char: '_',
296 position: 3
297 })
298 ));
299 }
300
301 #[test]
302 fn space_is_rejected() {
303 let result = SkillName::new("pdf processing");
304 assert!(matches!(
305 result,
306 Err(SkillNameError::InvalidCharacter {
307 char: ' ',
308 position: 3
309 })
310 ));
311 }
312
313 #[test]
314 fn display_returns_inner_string() {
315 let name = SkillName::new("my-skill").unwrap();
316 assert_eq!(format!("{name}"), "my-skill");
317 }
318
319 #[test]
320 fn from_str_works() {
321 let name: SkillName = "my-skill".parse().unwrap();
322 assert_eq!(name.as_str(), "my-skill");
323 }
324
325 #[test]
326 fn as_ref_works() {
327 let name = SkillName::new("my-skill").unwrap();
328 let s: &str = name.as_ref();
329 assert_eq!(s, "my-skill");
330 }
331
332 #[test]
333 fn error_display_is_helpful() {
334 let err = SkillNameError::InvalidCharacter {
335 char: 'X',
336 position: 5,
337 };
338 let msg = err.to_string();
339 assert!(msg.contains("'X'"));
340 assert!(msg.contains("position 5"));
341 assert!(msg.contains("lowercase alphanumeric"));
342 }
343}