Skip to main content

rskit_skill/
error.rs

1//! Skill error types and application-error conversion.
2
3use std::path::PathBuf;
4
5use rskit_errors::AppError;
6use thiserror::Error;
7
8/// Skill errors.
9#[derive(Debug, Error)]
10#[non_exhaustive]
11pub enum SkillError {
12    /// File I/O failed.
13    #[error("file I/O failed for {path}: {source}")]
14    Io {
15        /// File path.
16        path: PathBuf,
17        /// Source error.
18        #[source]
19        source: std::io::Error,
20    },
21    /// File exceeded the configured skill-pack size limit.
22    #[error("skill file {path} exceeds size limit of {limit_bytes} bytes")]
23    FileTooLarge {
24        /// File path.
25        path: PathBuf,
26        /// Maximum accepted size in bytes.
27        limit_bytes: u64,
28    },
29    /// Aggregate skill-pack assets exceeded the configured size limit.
30    #[error(
31        "skill assets exceed total size limit of {limit_bytes} bytes while reading {path} ({total_bytes} bytes)"
32    )]
33    AssetsTooLarge {
34        /// Asset path being read when the aggregate limit was exceeded.
35        path: PathBuf,
36        /// Bytes read across all assets.
37        total_bytes: u64,
38        /// Maximum accepted aggregate asset size in bytes.
39        limit_bytes: u64,
40    },
41    /// A skill-pack text file is not valid UTF-8.
42    #[error("skill file {path} is not valid UTF-8: {source}")]
43    InvalidUtf8 {
44        /// File path.
45        path: PathBuf,
46        /// Source error.
47        #[source]
48        source: std::string::FromUtf8Error,
49    },
50    /// A skill-pack path is not an accepted regular pack file or directory.
51    #[error("invalid skill pack file {path}: {reason}")]
52    InvalidPackFile {
53        /// File path.
54        path: PathBuf,
55        /// Rejection reason.
56        reason: String,
57    },
58    /// YAML parsing failed.
59    #[error("manifest parse failed for {path}: {source}")]
60    ParseManifest {
61        /// Manifest path.
62        path: PathBuf,
63        /// Source error.
64        #[source]
65        source: serde_norway::Error,
66    },
67    /// Manifest is invalid.
68    #[error("invalid skill manifest: {0}")]
69    InvalidManifest(String),
70    /// Config source resolution or validation failed.
71    #[error("skill config failed: {0}")]
72    Config(String),
73    /// Verification failed.
74    #[error("skill verification failed: {0}")]
75    Verification(String),
76    /// Registry conflict.
77    #[error("skill already registered: {0}")]
78    AlreadyRegistered(String),
79    /// Skill not found.
80    #[error("skill not found: {0}")]
81    NotFound(String),
82}
83
84impl From<SkillError> for AppError {
85    fn from(value: SkillError) -> Self {
86        match value {
87            SkillError::NotFound(name) => AppError::not_found("skill", Some(name.as_str())),
88            SkillError::AlreadyRegistered(name) => {
89                AppError::already_exists(format!("skill {name}"))
90            }
91            SkillError::InvalidManifest(message)
92            | SkillError::Config(message)
93            | SkillError::Verification(message) => AppError::invalid_input("skill", message),
94            SkillError::FileTooLarge { path, limit_bytes } => AppError::invalid_input(
95                "skill",
96                format!(
97                    "file {} exceeds size limit of {limit_bytes} bytes",
98                    path.display()
99                ),
100            ),
101            SkillError::AssetsTooLarge {
102                path,
103                total_bytes,
104                limit_bytes,
105            } => AppError::invalid_input(
106                "skill",
107                format!(
108                    "assets exceed total size limit of {limit_bytes} bytes while reading {} ({total_bytes} bytes)",
109                    path.display()
110                ),
111            ),
112            SkillError::InvalidUtf8 { path, source } => AppError::invalid_input(
113                "skill",
114                format!("file {} is not valid UTF-8: {source}", path.display()),
115            )
116            .with_cause(source),
117            SkillError::InvalidPackFile { path, reason } => {
118                AppError::invalid_input("skill", format!("{}: {reason}", path.display()))
119            }
120            SkillError::Io { path, source } => AppError::new(
121                rskit_errors::ErrorCode::Internal,
122                format!("skill I/O failed for {}: {source}", path.display()),
123            )
124            .with_cause(source),
125            SkillError::ParseManifest { path, source } => AppError::new(
126                rskit_errors::ErrorCode::InvalidInput,
127                format!(
128                    "skill manifest parse failed for {}: {source}",
129                    path.display()
130                ),
131            )
132            .with_cause(source),
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use std::path::PathBuf;
140
141    use rskit_errors::{AppError, ErrorCode};
142
143    use super::SkillError;
144
145    #[test]
146    fn aggregate_asset_limit_has_distinct_message_and_app_error() {
147        let error = SkillError::AssetsTooLarge {
148            path: PathBuf::from("references/large.bin"),
149            total_bytes: 65,
150            limit_bytes: 64,
151        };
152
153        assert_eq!(
154            error.to_string(),
155            "skill assets exceed total size limit of 64 bytes while reading references/large.bin (65 bytes)"
156        );
157
158        let app_error = AppError::from(error);
159        assert_eq!(app_error.code(), ErrorCode::InvalidInput);
160        assert_eq!(
161            app_error.message(),
162            "invalid skill: assets exceed total size limit of 64 bytes while reading references/large.bin (65 bytes)"
163        );
164    }
165
166    #[test]
167    fn registry_errors_map_to_expected_app_error_codes() {
168        let not_found = AppError::from(SkillError::NotFound("resize".into()));
169        assert_eq!(not_found.code(), ErrorCode::NotFound);
170        assert!(not_found.message().contains("resize"));
171
172        let duplicate = AppError::from(SkillError::AlreadyRegistered("resize".into()));
173        assert_eq!(duplicate.code(), ErrorCode::AlreadyExists);
174        assert!(duplicate.message().contains("resize"));
175    }
176
177    #[test]
178    fn validation_errors_map_to_invalid_input() {
179        for error in [
180            SkillError::InvalidManifest("missing name".into()),
181            SkillError::Config("bad source".into()),
182            SkillError::Verification("unsigned".into()),
183        ] {
184            let app_error = AppError::from(error);
185            assert_eq!(app_error.code(), ErrorCode::InvalidInput);
186        }
187    }
188
189    #[test]
190    fn file_content_errors_preserve_context_and_causes() {
191        let too_large = AppError::from(SkillError::FileTooLarge {
192            path: PathBuf::from("SKILL.md"),
193            limit_bytes: 8,
194        });
195        assert_eq!(too_large.code(), ErrorCode::InvalidInput);
196        assert!(too_large.message().contains("SKILL.md"));
197
198        let utf8_source = String::from_utf8(vec![0xff]).unwrap_err();
199        let invalid_utf8 = AppError::from(SkillError::InvalidUtf8 {
200            path: PathBuf::from("SKILL.md"),
201            source: utf8_source,
202        });
203        assert_eq!(invalid_utf8.code(), ErrorCode::InvalidInput);
204        assert!(invalid_utf8.cause().is_some());
205    }
206
207    #[test]
208    fn filesystem_and_manifest_parse_errors_preserve_causes() {
209        let io = AppError::from(SkillError::Io {
210            path: PathBuf::from("skill.toml"),
211            source: std::io::Error::other("disk"),
212        });
213        assert_eq!(io.code(), ErrorCode::Internal);
214        assert!(io.cause().is_some());
215
216        let source = serde_norway::from_str::<serde_norway::Value>(": bad").unwrap_err();
217        let parse = AppError::from(SkillError::ParseManifest {
218            path: PathBuf::from("skill.toml"),
219            source,
220        });
221        assert_eq!(parse.code(), ErrorCode::InvalidInput);
222        assert!(parse.cause().is_some());
223    }
224
225    #[test]
226    fn invalid_pack_file_maps_reason_to_invalid_input() {
227        let error = AppError::from(SkillError::InvalidPackFile {
228            path: PathBuf::from("references/link"),
229            reason: "symlinks are not allowed".into(),
230        });
231        assert_eq!(error.code(), ErrorCode::InvalidInput);
232        assert!(
233            error
234                .message()
235                .contains("references/link: symlinks are not allowed")
236        );
237    }
238}