agent_skills/
error.rs

1//! Error types for parsing and loading skills.
2
3use std::fmt;
4
5use crate::compatibility::CompatibilityError;
6use crate::description::SkillDescriptionError;
7use crate::name::SkillNameError;
8
9/// Error returned when parsing a SKILL.md file fails.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum ParseError {
12    /// The content doesn't start with the frontmatter delimiter.
13    MissingFrontmatter,
14    /// The frontmatter is not properly terminated.
15    UnterminatedFrontmatter,
16    /// The YAML in the frontmatter is invalid.
17    InvalidYaml {
18        /// The error message from the YAML parser.
19        message: String,
20    },
21    /// A required field is missing.
22    MissingField {
23        /// The name of the missing field.
24        field: &'static str,
25    },
26    /// The name field is invalid.
27    InvalidName(SkillNameError),
28    /// The description field is invalid.
29    InvalidDescription(SkillDescriptionError),
30    /// The compatibility field is invalid.
31    InvalidCompatibility(CompatibilityError),
32}
33
34impl fmt::Display for ParseError {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            Self::MissingFrontmatter => {
38                write!(
39                    f,
40                    "SKILL.md must start with '---' (YAML frontmatter delimiter)"
41                )
42            }
43            Self::UnterminatedFrontmatter => {
44                write!(f, "frontmatter must end with '---' on its own line")
45            }
46            Self::InvalidYaml { message } => {
47                write!(f, "invalid YAML in frontmatter: {message}")
48            }
49            Self::MissingField { field } => {
50                write!(f, "missing required field '{field}' in frontmatter")
51            }
52            Self::InvalidName(e) => write!(f, "invalid name: {e}"),
53            Self::InvalidDescription(e) => write!(f, "invalid description: {e}"),
54            Self::InvalidCompatibility(e) => write!(f, "invalid compatibility: {e}"),
55        }
56    }
57}
58
59impl std::error::Error for ParseError {
60    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
61        match self {
62            Self::InvalidName(e) => Some(e),
63            Self::InvalidDescription(e) => Some(e),
64            Self::InvalidCompatibility(e) => Some(e),
65            _ => None,
66        }
67    }
68}
69
70impl From<SkillNameError> for ParseError {
71    fn from(e: SkillNameError) -> Self {
72        Self::InvalidName(e)
73    }
74}
75
76impl From<SkillDescriptionError> for ParseError {
77    fn from(e: SkillDescriptionError) -> Self {
78        Self::InvalidDescription(e)
79    }
80}
81
82impl From<CompatibilityError> for ParseError {
83    fn from(e: CompatibilityError) -> Self {
84        Self::InvalidCompatibility(e)
85    }
86}
87
88/// Error returned when loading a skill from a directory fails.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum LoadError {
91    /// The directory doesn't exist.
92    DirectoryNotFound {
93        /// The path that was not found.
94        path: String,
95    },
96    /// The SKILL.md file is missing.
97    SkillFileNotFound {
98        /// The directory path.
99        path: String,
100    },
101    /// An I/O error occurred.
102    IoError {
103        /// The path being accessed.
104        path: String,
105        /// The kind of I/O error.
106        kind: std::io::ErrorKind,
107        /// The error message.
108        message: String,
109    },
110    /// The SKILL.md file couldn't be parsed.
111    ParseError(ParseError),
112    /// The skill name doesn't match the directory name.
113    NameMismatch {
114        /// The directory name.
115        directory_name: String,
116        /// The skill name from frontmatter.
117        skill_name: String,
118    },
119    /// A referenced file was not found.
120    FileNotFound {
121        /// The path that was not found.
122        path: String,
123    },
124}
125
126impl fmt::Display for LoadError {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            Self::DirectoryNotFound { path } => {
130                write!(f, "skill directory not found: '{path}'")
131            }
132            Self::SkillFileNotFound { path } => {
133                write!(f, "SKILL.md not found in '{path}'")
134            }
135            Self::IoError { path, message, .. } => {
136                write!(f, "error reading '{path}': {message}")
137            }
138            Self::ParseError(e) => write!(f, "{e}"),
139            Self::NameMismatch {
140                directory_name,
141                skill_name,
142            } => {
143                write!(
144                    f,
145                    "skill name '{skill_name}' must match directory name '{directory_name}' (per specification)"
146                )
147            }
148            Self::FileNotFound { path } => {
149                write!(f, "file not found: '{path}'")
150            }
151        }
152    }
153}
154
155impl std::error::Error for LoadError {
156    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
157        match self {
158            Self::ParseError(e) => Some(e),
159            _ => None,
160        }
161    }
162}
163
164impl From<ParseError> for LoadError {
165    fn from(e: ParseError) -> Self {
166        Self::ParseError(e)
167    }
168}
169
170#[cfg(test)]
171#[allow(clippy::unwrap_used, clippy::expect_used)]
172mod tests {
173    use super::*;
174    use std::error::Error;
175
176    #[test]
177    fn parse_error_display_is_helpful() {
178        let err = ParseError::MissingFrontmatter;
179        let msg = err.to_string();
180        assert!(msg.contains("---"));
181        assert!(msg.contains("SKILL.md"));
182    }
183
184    #[test]
185    fn parse_error_invalid_yaml_includes_message() {
186        let err = ParseError::InvalidYaml {
187            message: "expected ':'".to_string(),
188        };
189        let msg = err.to_string();
190        assert!(msg.contains("expected ':'"));
191    }
192
193    #[test]
194    fn parse_error_missing_field_includes_field_name() {
195        let err = ParseError::MissingField { field: "name" };
196        let msg = err.to_string();
197        assert!(msg.contains("name"));
198    }
199
200    #[test]
201    fn parse_error_source_returns_inner_error() {
202        let inner = SkillNameError::Empty;
203        let err = ParseError::InvalidName(inner.clone());
204        assert!(err.source().is_some());
205    }
206
207    #[test]
208    fn load_error_display_is_helpful() {
209        let err = LoadError::DirectoryNotFound {
210            path: "/path/to/skill".to_string(),
211        };
212        let msg = err.to_string();
213        assert!(msg.contains("/path/to/skill"));
214    }
215
216    #[test]
217    fn load_error_name_mismatch_is_clear() {
218        let err = LoadError::NameMismatch {
219            directory_name: "my-skill".to_string(),
220            skill_name: "other-skill".to_string(),
221        };
222        let msg = err.to_string();
223        assert!(msg.contains("my-skill"));
224        assert!(msg.contains("other-skill"));
225        assert!(msg.contains("must match"));
226    }
227
228    #[test]
229    fn load_error_source_returns_parse_error() {
230        let inner = ParseError::MissingFrontmatter;
231        let err = LoadError::ParseError(inner);
232        assert!(err.source().is_some());
233    }
234}