agent_skills/
compatibility.rs

1//! Compatibility string type with validation.
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8/// Error returned when a compatibility string is invalid.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum CompatibilityError {
11    /// The compatibility string is empty.
12    Empty,
13    /// The compatibility string exceeds the maximum length.
14    TooLong {
15        /// The actual length.
16        length: usize,
17        /// The maximum allowed length.
18        max: usize,
19    },
20}
21
22impl fmt::Display for CompatibilityError {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Self::Empty => write!(f, "compatibility string cannot be empty"),
26            Self::TooLong { length, max } => {
27                write!(
28                    f,
29                    "compatibility string is {length} characters; maximum is {max}"
30                )
31            }
32        }
33    }
34}
35
36impl std::error::Error for CompatibilityError {}
37
38/// An optional compatibility string describing environment requirements.
39///
40/// # Constraints
41///
42/// - Must be 1-500 characters if provided
43///
44/// # Examples
45///
46/// ```
47/// use agent_skills::Compatibility;
48///
49/// let compat = Compatibility::new("Requires git, docker, jq").unwrap();
50/// assert_eq!(compat.as_str(), "Requires git, docker, jq");
51///
52/// // Invalid: empty
53/// assert!(Compatibility::new("").is_err());
54/// ```
55#[derive(Debug, Clone, PartialEq, Eq, Hash)]
56pub struct Compatibility(String);
57
58impl Compatibility {
59    /// Maximum length for a compatibility string.
60    pub const MAX_LENGTH: usize = 500;
61
62    /// Creates a new compatibility string after validation.
63    ///
64    /// # Errors
65    ///
66    /// Returns `CompatibilityError` if the string is invalid.
67    pub fn new(compat: impl Into<String>) -> Result<Self, CompatibilityError> {
68        let compat = compat.into();
69        validate_compatibility(&compat)?;
70        Ok(Self(compat))
71    }
72
73    /// Returns the compatibility string as a slice.
74    #[must_use]
75    pub fn as_str(&self) -> &str {
76        &self.0
77    }
78}
79
80impl fmt::Display for Compatibility {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        write!(f, "{}", self.0)
83    }
84}
85
86impl FromStr for Compatibility {
87    type Err = CompatibilityError;
88
89    fn from_str(s: &str) -> Result<Self, Self::Err> {
90        Self::new(s)
91    }
92}
93
94impl AsRef<str> for Compatibility {
95    fn as_ref(&self) -> &str {
96        &self.0
97    }
98}
99
100impl Serialize for Compatibility {
101    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
102    where
103        S: Serializer,
104    {
105        self.0.serialize(serializer)
106    }
107}
108
109impl<'de> Deserialize<'de> for Compatibility {
110    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
111    where
112        D: Deserializer<'de>,
113    {
114        let s = String::deserialize(deserializer)?;
115        Self::new(s).map_err(serde::de::Error::custom)
116    }
117}
118
119/// Validates a compatibility string.
120const fn validate_compatibility(compat: &str) -> Result<(), CompatibilityError> {
121    // Check empty
122    if compat.is_empty() {
123        return Err(CompatibilityError::Empty);
124    }
125
126    // Check length
127    if compat.len() > Compatibility::MAX_LENGTH {
128        return Err(CompatibilityError::TooLong {
129            length: compat.len(),
130            max: Compatibility::MAX_LENGTH,
131        });
132    }
133
134    Ok(())
135}
136
137#[cfg(test)]
138#[allow(clippy::unwrap_used, clippy::expect_used)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn valid_compatibility_is_accepted() {
144        let compat = Compatibility::new("Requires git, docker, jq");
145        assert!(compat.is_ok());
146    }
147
148    #[test]
149    fn compatibility_at_max_length_is_accepted() {
150        let compat = "a".repeat(500);
151        assert!(Compatibility::new(compat).is_ok());
152    }
153
154    #[test]
155    fn empty_compatibility_is_rejected() {
156        let result = Compatibility::new("");
157        assert_eq!(result, Err(CompatibilityError::Empty));
158    }
159
160    #[test]
161    fn compatibility_exceeding_max_length_is_rejected() {
162        let long_compat = "a".repeat(501);
163        let result = Compatibility::new(long_compat);
164        assert!(matches!(
165            result,
166            Err(CompatibilityError::TooLong {
167                length: 501,
168                max: 500
169            })
170        ));
171    }
172
173    #[test]
174    fn display_returns_inner_string() {
175        let compat = Compatibility::new("Designed for Claude Code").unwrap();
176        assert_eq!(format!("{compat}"), "Designed for Claude Code");
177    }
178
179    #[test]
180    fn from_str_works() {
181        let compat: Compatibility = "Requires docker".parse().unwrap();
182        assert_eq!(compat.as_str(), "Requires docker");
183    }
184
185    #[test]
186    fn as_ref_works() {
187        let compat = Compatibility::new("Test").unwrap();
188        let s: &str = compat.as_ref();
189        assert_eq!(s, "Test");
190    }
191}