agent_skills/
name.rs

1//! Skill name type with validation.
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8/// Error returned when a skill name is invalid.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum SkillNameError {
11    /// The name is empty.
12    Empty,
13    /// The name exceeds the maximum length.
14    TooLong {
15        /// The actual length of the name.
16        length: usize,
17        /// The maximum allowed length.
18        max: usize,
19    },
20    /// The name contains an invalid character.
21    InvalidCharacter {
22        /// The invalid character.
23        char: char,
24        /// The position of the invalid character.
25        position: usize,
26    },
27    /// The name starts with a hyphen.
28    StartsWithHyphen,
29    /// The name ends with a hyphen.
30    EndsWithHyphen,
31    /// The name contains consecutive hyphens.
32    ConsecutiveHyphens {
33        /// The position of the first consecutive hyphen.
34        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/// A validated skill name.
66///
67/// # Constraints
68///
69/// - Must be 1-64 characters
70/// - May only contain lowercase alphanumeric characters and hyphens (`a-z`, `0-9`, `-`)
71/// - Must not start or end with a hyphen
72/// - Must not contain consecutive hyphens (`--`)
73///
74/// # Examples
75///
76/// ```
77/// use agent_skills::SkillName;
78///
79/// let name = SkillName::new("pdf-processing").unwrap();
80/// assert_eq!(name.as_str(), "pdf-processing");
81///
82/// // Invalid: uppercase
83/// assert!(SkillName::new("PDF-Processing").is_err());
84///
85/// // Invalid: consecutive hyphens
86/// assert!(SkillName::new("pdf--processing").is_err());
87/// ```
88#[derive(Debug, Clone, PartialEq, Eq, Hash)]
89pub struct SkillName(String);
90
91impl SkillName {
92    /// Maximum length for a skill name.
93    pub const MAX_LENGTH: usize = 64;
94
95    /// Creates a new skill name after validation.
96    ///
97    /// # Errors
98    ///
99    /// Returns `SkillNameError` if the name is invalid.
100    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    /// Returns the skill name as a string slice.
107    #[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
152/// Validates a skill name string.
153fn validate_skill_name(name: &str) -> Result<(), SkillNameError> {
154    // Check empty
155    if name.is_empty() {
156        return Err(SkillNameError::Empty);
157    }
158
159    // Check length
160    if name.len() > SkillName::MAX_LENGTH {
161        return Err(SkillNameError::TooLong {
162            length: name.len(),
163            max: SkillName::MAX_LENGTH,
164        });
165    }
166
167    // Check starts with hyphen
168    if name.starts_with('-') {
169        return Err(SkillNameError::StartsWithHyphen);
170    }
171
172    // Check ends with hyphen
173    if name.ends_with('-') {
174        return Err(SkillNameError::EndsWithHyphen);
175    }
176
177    // Check each character and consecutive hyphens
178    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}