1use std::path::PathBuf;
7use thiserror::Error;
8
9#[derive(Debug, Error)]
11#[non_exhaustive]
12pub enum SsgError {
13 #[error("Core compilation error: {0}")]
15 Core(#[from] ssg_core::Error),
16
17 #[error("I/O error at '{path}': {source}")]
19 Io {
20 path: PathBuf,
22 #[source]
24 source: std::io::Error,
25 },
26
27 #[error(
29 "Security violation: path contains directory traversal ('..'): {path}"
30 )]
31 PathTraversal {
32 path: PathBuf,
34 },
35
36 #[error("Security violation: path resolves to a symlink: {path}")]
38 SymlinkForbidden {
39 path: PathBuf,
41 },
42
43 #[error("Validation failed for field '{field}': {message}")]
45 Validation {
46 field: String,
48 message: String,
50 },
51
52 #[cfg(feature = "templates")]
54 #[error("Template engine error: {0}")]
55 Template(#[from] minijinja::Error),
56
57 #[error("LLM endpoint unreachable at '{url}': {source}")]
63 LlmEndpointUnreachable {
64 url: String,
66 #[source]
68 source: Box<dyn std::error::Error + Send + Sync>,
69 },
70
71 #[error("LLM call timed out after {duration:?}")]
77 LlmTimeout {
78 duration: std::time::Duration,
80 },
81
82 #[error("LLM returned an invalid response: {message}")]
88 LlmInvalidResponse {
89 message: String,
91 },
92}
93
94impl SsgError {
95 pub fn io(err: impl Into<anyhow::Error>, path: impl Into<PathBuf>) -> Self {
110 let anyhow_err = err.into();
111 let io_err = anyhow_err
112 .downcast::<std::io::Error>()
113 .unwrap_or_else(|e| std::io::Error::other(e.to_string()));
114 Self::Io {
115 path: path.into(),
116 source: io_err,
117 }
118 }
119}
120
121pub trait PathErrorExt<T> {
134 fn with_path(self, path: impl Into<PathBuf>) -> Result<T, SsgError>;
146}
147
148impl<T> PathErrorExt<T> for std::io::Result<T> {
149 fn with_path(self, path: impl Into<PathBuf>) -> Result<T, SsgError> {
150 self.map_err(|source| SsgError::Io {
151 path: path.into(),
152 source,
153 })
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use std::io;
161 use std::path::Path;
162
163 #[test]
164 fn test_core_error() {
165 let core_err = ssg_core::Error::InvalidSlug {
166 input: "foo bar".into(),
167 };
168 let err = SsgError::Core(core_err);
169 let msg = format!("{err}");
170 assert!(msg.contains("Core compilation error"));
171 }
172
173 #[test]
174 fn test_io_error() {
175 let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
176 let err = SsgError::Io {
177 path: PathBuf::from("foo/bar"),
178 source: io_err,
179 };
180 let msg = format!("{err}");
181 assert!(msg.contains("I/O error at 'foo/bar'"));
182 }
183
184 #[test]
185 fn test_path_traversal() {
186 let err = SsgError::PathTraversal {
187 path: PathBuf::from("../escaped"),
188 };
189 let msg = format!("{err}");
190 assert!(msg
191 .contains("Security violation: path contains directory traversal"));
192 }
193
194 #[test]
195 fn test_symlink_forbidden() {
196 let err = SsgError::SymlinkForbidden {
197 path: PathBuf::from("symlink/path"),
198 };
199 let msg = format!("{err}");
200 assert!(msg.contains("Security violation: path resolves to a symlink"));
201 }
202
203 #[test]
204 fn test_validation() {
205 let err = SsgError::Validation {
206 field: "output".into(),
207 message: "cannot be empty".into(),
208 };
209 let msg = format!("{err}");
210 assert!(msg.contains("Validation failed for field 'output'"));
211 }
212
213 #[test]
214 #[cfg(feature = "templates")]
215 fn test_template_error() {
216 let source = minijinja::Error::new(
217 minijinja::ErrorKind::TemplateNotFound,
218 "missing template",
219 );
220 let err = SsgError::from(source);
221 let msg = format!("{err}");
222 assert!(msg.contains("Template engine error"));
223 }
224
225 #[test]
226 fn test_llm_endpoint_unreachable() {
227 let io_err =
228 io::Error::new(io::ErrorKind::ConnectionRefused, "refused");
229 let err = SsgError::LlmEndpointUnreachable {
230 url: "http://localhost:11434".into(),
231 source: Box::new(io_err),
232 };
233 let msg = format!("{err}");
234 assert!(msg.contains("LLM endpoint unreachable"));
235 assert!(msg.contains("http://localhost:11434"));
236 }
237
238 #[test]
239 fn test_llm_timeout() {
240 let err = SsgError::LlmTimeout {
241 duration: std::time::Duration::from_secs(60),
242 };
243 let msg = format!("{err}");
244 assert!(msg.contains("LLM call timed out"));
245 assert!(msg.contains("60"));
246 }
247
248 #[test]
249 fn test_llm_invalid_response() {
250 let err = SsgError::LlmInvalidResponse {
251 message: "missing 'response' field".into(),
252 };
253 let msg = format!("{err}");
254 assert!(msg.contains("LLM returned an invalid response"));
255 assert!(msg.contains("missing 'response' field"));
256 }
257
258 #[test]
259 fn test_path_error_ext() {
260 let res: io::Result<()> =
261 Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied"));
262 let ssg_res = res.with_path("restricted/file");
263 assert!(ssg_res.is_err());
264 let err = ssg_res.unwrap_err();
265 assert!(
266 matches!(
267 &err,
268 SsgError::Io { path, .. }
269 if path.as_path() == Path::new("restricted/file")
270 ),
271 "expected SsgError::Io for restricted/file, got {err:?}"
272 );
273 }
274}