1use std::error::Error as StdError;
4use std::time::Duration;
5
6pub type Result<T> = std::result::Result<T, Error>;
8
9pub(crate) type BoxError = Box<dyn StdError + Send + Sync + 'static>;
11
12#[derive(Debug, thiserror::Error)]
14#[non_exhaustive]
15pub enum Error {
16 #[error("invalid URL '{url}': {reason}")]
18 InvalidUrl {
19 url: String,
21 reason: String,
23 },
24
25 #[error("page load timed out after {}s at {url}", timeout.as_secs())]
27 Timeout {
28 url: String,
30 timeout: Duration,
32 },
33
34 #[error("address not allowed: {host}")]
36 AddressNotAllowed {
37 host: String,
39 },
40
41 #[error("engine error: {source}")]
43 Engine {
44 url: Option<String>,
46 #[source]
48 source: BoxError,
49 },
50
51 #[error("JavaScript evaluation failed: {source}")]
53 JavaScript {
54 url: Option<String>,
56 #[source]
58 source: BoxError,
59 },
60
61 #[error("screenshot capture failed: {source}")]
63 Screenshot {
64 url: Option<String>,
66 #[source]
68 source: BoxError,
69 },
70
71 #[error(transparent)]
73 Extract(#[from] crate::extract::ExtractError),
74
75 #[error("failed to load cookies from {path}: {reason}")]
77 Cookies {
78 path: String,
80 reason: String,
82 },
83
84 #[error(transparent)]
86 Schema(#[from] crate::schema::SchemaError),
87
88 #[error(transparent)]
90 Io(#[from] std::io::Error),
91
92 #[error(transparent)]
94 InvalidGlob(#[from] globset::Error),
95
96 #[error("{0}")]
98 InvalidHeader(String),
99}
100
101impl Error {
102 pub(crate) fn engine(source: impl Into<BoxError>, url: Option<String>) -> Self {
104 Self::Engine {
105 url,
106 source: source.into(),
107 }
108 }
109
110 pub(crate) fn screenshot(source: impl Into<BoxError>, url: Option<String>) -> Self {
112 Self::Screenshot {
113 url,
114 source: source.into(),
115 }
116 }
117
118 pub(crate) fn javascript(source: impl Into<BoxError>, url: Option<String>) -> Self {
120 Self::JavaScript {
121 url,
122 source: source.into(),
123 }
124 }
125
126 pub(crate) fn invalid_header(message: impl Into<String>) -> Self {
128 Self::InvalidHeader(message.into())
129 }
130
131 #[must_use]
133 pub fn is_timeout(&self) -> bool {
134 matches!(self, Self::Timeout { .. })
135 }
136
137 #[must_use]
139 pub fn is_network(&self) -> bool {
140 matches!(self, Self::Timeout { .. } | Self::AddressNotAllowed { .. })
141 }
142
143 #[must_use]
145 pub fn url(&self) -> Option<&str> {
146 match self {
147 Self::InvalidUrl { url, .. } | Self::Timeout { url, .. } => Some(url),
148 Self::Engine { url, .. } | Self::JavaScript { url, .. } | Self::Screenshot { url, .. } => url.as_deref(),
149 _ => None,
150 }
151 }
152
153 #[must_use]
155 pub fn host(&self) -> Option<&str> {
156 match self {
157 Self::AddressNotAllowed { host } => Some(host),
158 _ => None,
159 }
160 }
161}
162
163#[derive(Debug)]
164pub(crate) enum UrlError {
165 Invalid(String),
166 PrivateAddress(String),
167}
168
169pub(crate) fn map_url_error(url: &str, e: UrlError) -> Error {
170 match e {
171 UrlError::PrivateAddress(host) => Error::AddressNotAllowed { host },
172 UrlError::Invalid(reason) => Error::InvalidUrl {
173 url: url.into(),
174 reason,
175 },
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn assert_send_sync() {
185 fn check<T: Send + Sync>() {}
186 check::<Error>();
187 }
188
189 #[test]
190 fn timeout_predicates() {
191 let err = Error::Timeout {
192 url: "https://example.com".into(),
193 timeout: Duration::from_secs(30),
194 };
195 assert!(err.is_timeout());
196 assert!(err.is_network());
197 assert_eq!(err.url(), Some("https://example.com"));
198 assert_eq!(err.host(), None);
199 }
200
201 #[test]
202 fn address_not_allowed_predicates() {
203 let err = Error::AddressNotAllowed {
204 host: "127.0.0.1".into(),
205 };
206 assert!(!err.is_timeout());
207 assert!(err.is_network());
208 assert_eq!(err.url(), None);
209 assert_eq!(err.host(), Some("127.0.0.1"));
210 }
211
212 #[test]
213 fn invalid_url_carries_url() {
214 let err = Error::InvalidUrl {
215 url: "bad://url".into(),
216 reason: "scheme not allowed".into(),
217 };
218 assert!(!err.is_network());
219 assert_eq!(err.url(), Some("bad://url"));
220 assert_eq!(err.host(), None);
221 }
222
223 #[test]
224 fn engine_helper_preserves_source_chain() {
225 let inner = std::io::Error::other("disk full");
226 let err = Error::engine(inner, Some("https://example.com".into()));
227 assert_eq!(err.url(), Some("https://example.com"));
228 assert!(err.source().is_some());
229 assert_eq!(err.to_string(), "engine error: disk full");
230 }
231
232 #[test]
233 fn engine_without_url_returns_none() {
234 let err = Error::engine(std::io::Error::other("crash"), None);
235 assert!(err.url().is_none());
236 }
237}