agent_skills/
description.rs

1//! Skill description 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 description is invalid.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum SkillDescriptionError {
11    /// The description is empty or whitespace-only.
12    Empty,
13    /// The description exceeds the maximum length.
14    TooLong {
15        /// The actual length of the description.
16        length: usize,
17        /// The maximum allowed length.
18        max: usize,
19    },
20}
21
22impl fmt::Display for SkillDescriptionError {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Self::Empty => write!(f, "skill description cannot be empty or whitespace-only"),
26            Self::TooLong { length, max } => {
27                write!(
28                    f,
29                    "skill description is {length} characters; maximum is {max}"
30                )
31            }
32        }
33    }
34}
35
36impl std::error::Error for SkillDescriptionError {}
37
38/// A validated skill description.
39///
40/// # Constraints
41///
42/// - Must be 1-1024 characters
43/// - Must not be empty or whitespace-only
44///
45/// # Examples
46///
47/// ```
48/// use agent_skills::SkillDescription;
49///
50/// let desc = SkillDescription::new("Extracts text from PDF files.").unwrap();
51/// assert!(!desc.as_str().is_empty());
52///
53/// // Invalid: empty
54/// assert!(SkillDescription::new("").is_err());
55///
56/// // Invalid: whitespace only
57/// assert!(SkillDescription::new("   ").is_err());
58/// ```
59#[derive(Debug, Clone, PartialEq, Eq, Hash)]
60pub struct SkillDescription(String);
61
62impl SkillDescription {
63    /// Maximum length for a skill description.
64    pub const MAX_LENGTH: usize = 1024;
65
66    /// Creates a new skill description after validation.
67    ///
68    /// # Errors
69    ///
70    /// Returns `SkillDescriptionError` if the description is invalid.
71    pub fn new(description: impl Into<String>) -> Result<Self, SkillDescriptionError> {
72        let description = description.into();
73        validate_skill_description(&description)?;
74        Ok(Self(description))
75    }
76
77    /// Returns the description as a string slice.
78    #[must_use]
79    pub fn as_str(&self) -> &str {
80        &self.0
81    }
82}
83
84impl fmt::Display for SkillDescription {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        write!(f, "{}", self.0)
87    }
88}
89
90impl FromStr for SkillDescription {
91    type Err = SkillDescriptionError;
92
93    fn from_str(s: &str) -> Result<Self, Self::Err> {
94        Self::new(s)
95    }
96}
97
98impl AsRef<str> for SkillDescription {
99    fn as_ref(&self) -> &str {
100        &self.0
101    }
102}
103
104impl Serialize for SkillDescription {
105    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
106    where
107        S: Serializer,
108    {
109        self.0.serialize(serializer)
110    }
111}
112
113impl<'de> Deserialize<'de> for SkillDescription {
114    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
115    where
116        D: Deserializer<'de>,
117    {
118        let s = String::deserialize(deserializer)?;
119        Self::new(s).map_err(serde::de::Error::custom)
120    }
121}
122
123/// Validates a skill description string.
124fn validate_skill_description(description: &str) -> Result<(), SkillDescriptionError> {
125    // Check empty or whitespace-only
126    if description.trim().is_empty() {
127        return Err(SkillDescriptionError::Empty);
128    }
129
130    // Check length
131    if description.len() > SkillDescription::MAX_LENGTH {
132        return Err(SkillDescriptionError::TooLong {
133            length: description.len(),
134            max: SkillDescription::MAX_LENGTH,
135        });
136    }
137
138    Ok(())
139}
140
141#[cfg(test)]
142#[allow(clippy::unwrap_used, clippy::expect_used)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn valid_description_is_accepted() {
148        let desc = SkillDescription::new("Extracts text from PDF files.");
149        assert!(desc.is_ok());
150    }
151
152    #[test]
153    fn description_at_max_length_is_accepted() {
154        let desc = "a".repeat(1024);
155        assert!(SkillDescription::new(desc).is_ok());
156    }
157
158    #[test]
159    fn empty_description_is_rejected() {
160        let result = SkillDescription::new("");
161        assert_eq!(result, Err(SkillDescriptionError::Empty));
162    }
163
164    #[test]
165    fn whitespace_only_description_is_rejected() {
166        let result = SkillDescription::new("   \n\t  ");
167        assert_eq!(result, Err(SkillDescriptionError::Empty));
168    }
169
170    #[test]
171    fn description_exceeding_max_length_is_rejected() {
172        let long_desc = "a".repeat(1025);
173        let result = SkillDescription::new(long_desc);
174        assert!(matches!(
175            result,
176            Err(SkillDescriptionError::TooLong {
177                length: 1025,
178                max: 1024
179            })
180        ));
181    }
182
183    #[test]
184    fn description_with_leading_trailing_whitespace_is_accepted() {
185        // We allow leading/trailing whitespace as long as there's content
186        let desc = SkillDescription::new("  Some description  ");
187        assert!(desc.is_ok());
188    }
189
190    #[test]
191    fn display_returns_inner_string() {
192        let desc = SkillDescription::new("My description").unwrap();
193        assert_eq!(format!("{desc}"), "My description");
194    }
195
196    #[test]
197    fn from_str_works() {
198        let desc: SkillDescription = "My description".parse().unwrap();
199        assert_eq!(desc.as_str(), "My description");
200    }
201
202    #[test]
203    fn as_ref_works() {
204        let desc = SkillDescription::new("My description").unwrap();
205        let s: &str = desc.as_ref();
206        assert_eq!(s, "My description");
207    }
208
209    #[test]
210    fn error_display_is_helpful() {
211        let err = SkillDescriptionError::TooLong {
212            length: 2000,
213            max: 1024,
214        };
215        let msg = err.to_string();
216        assert!(msg.contains("2000"));
217        assert!(msg.contains("1024"));
218    }
219}