Skip to main content

ssg/
error.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Error handling types and context extension traits for the SSG library.
5
6use std::path::PathBuf;
7use thiserror::Error;
8
9/// Error variants for the main `ssg` library.
10#[derive(Debug, Error)]
11#[non_exhaustive]
12pub enum SsgError {
13    /// Errors originating from the pure-logic compilation core.
14    #[error("Core compilation error: {0}")]
15    Core(#[from] ssg_core::Error),
16
17    /// File I/O failure with context.
18    #[error("I/O error at '{path}': {source}")]
19    Io {
20        /// The path where the I/O error occurred.
21        path: PathBuf,
22        /// The underlying I/O error source.
23        #[source]
24        source: std::io::Error,
25    },
26
27    /// Path traversal detected in configuration paths.
28    #[error(
29        "Security violation: path contains directory traversal ('..'): {path}"
30    )]
31    PathTraversal {
32        /// The path violating safety requirements.
33        path: PathBuf,
34    },
35
36    /// Symlinks are not allowed for security reasons.
37    #[error("Security violation: path resolves to a symlink: {path}")]
38    SymlinkForbidden {
39        /// The symlink path.
40        path: PathBuf,
41    },
42
43    /// Configuration field validation failure.
44    #[error("Validation failed for field '{field}': {message}")]
45    Validation {
46        /// The configuration field that failed validation.
47        field: String,
48        /// The validation failure message.
49        message: String,
50    },
51
52    /// Template engine rendering errors. Gated by template feature.
53    #[cfg(feature = "templates")]
54    #[error("Template engine error: {0}")]
55    Template(#[from] minijinja::Error),
56
57    /// The local LLM endpoint (Ollama, llama.cpp) could not be
58    /// reached. Surfaced from the `ureq`-backed `LlmPlugin` HTTP
59    /// path (issue #520) when the TCP connection is refused, the
60    /// host is unresolvable, or the transport layer fails before
61    /// the request is sent.
62    #[error("LLM endpoint unreachable at '{url}': {source}")]
63    LlmEndpointUnreachable {
64        /// The endpoint URL that failed to connect.
65        url: String,
66        /// The underlying transport error from `ureq`.
67        #[source]
68        source: Box<dyn std::error::Error + Send + Sync>,
69    },
70
71    /// The local LLM call exceeded the configured `llm.timeout_secs`
72    /// budget before returning a response. Reported as a typed error
73    /// (issue #520) so callers can distinguish a slow model from a
74    /// genuine network outage. There is no zombie subprocess to
75    /// reap — the previous `curl` shellout has been removed.
76    #[error("LLM call timed out after {duration:?}")]
77    LlmTimeout {
78        /// The timeout budget that was exceeded.
79        duration: std::time::Duration,
80    },
81
82    /// The LLM responded but the payload was not a well-formed JSON
83    /// generation response (missing the `response` field, non-UTF-8
84    /// body, malformed JSON, or HTTP non-2xx status code without a
85    /// usable error envelope). Surfaced from the `ureq` HTTP path
86    /// (issue #520).
87    #[error("LLM returned an invalid response: {message}")]
88    LlmInvalidResponse {
89        /// Human-readable description of what was malformed.
90        message: String,
91    },
92}
93
94impl SsgError {
95    /// Converts a generic error and path context into an `SsgError::Io` variant.
96    ///
97    /// # Examples
98    ///
99    /// ```rust
100    /// use ssg::SsgError;
101    /// use std::io;
102    /// use std::path::PathBuf;
103    ///
104    /// let io_err = io::Error::other("oops");
105    /// let err = SsgError::io(io_err, "data/file.txt");
106    /// assert!(matches!(err, SsgError::Io { .. }));
107    /// assert!(format!("{err}").contains("data/file.txt"));
108    /// ```
109    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
121/// Context extension trait for mapping `std::io::Error` contexts with path info.
122///
123/// # Examples
124///
125/// ```rust
126/// use ssg::{PathErrorExt, SsgError};
127/// use std::io;
128///
129/// let res: io::Result<()> = Err(io::Error::other("denied"));
130/// let mapped = res.with_path("restricted.txt");
131/// assert!(matches!(mapped, Err(SsgError::Io { .. })));
132/// ```
133pub trait PathErrorExt<T> {
134    /// Converts a `std::io::Result` into an `SsgError` mapping the path context.
135    ///
136    /// # Examples
137    ///
138    /// ```rust
139    /// use ssg::{PathErrorExt, SsgError};
140    /// use std::io;
141    ///
142    /// let ok: io::Result<u32> = Ok(7);
143    /// assert_eq!(ok.with_path("any").unwrap(), 7);
144    /// ```
145    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}