1use http::StatusCode;
2use serde::{Serialize, Serializer};
3use std::error::Error;
4use std::hash::Hash;
5use std::{convert::Infallible, path::PathBuf};
6use thiserror::Error;
7use tokio::task::JoinError;
8
9use super::InputContent;
10use crate::types::StatusCodeSelectorError;
11use crate::{Uri, basic_auth::BasicAuthExtractorError, utils};
12
13#[derive(Error, Debug)]
16#[non_exhaustive]
17pub enum ErrorKind {
18 #[error("Network error: {analysis} ({error})", analysis=utils::reqwest::analyze_error_chain(.0), error=.0)]
21 NetworkRequest(#[source] reqwest::Error),
22 #[error("Error reading response body: {0}")]
24 ReadResponseBody(#[source] reqwest::Error),
25 #[error("Error creating request client: {0}")]
27 BuildRequestClient(#[source] reqwest::Error),
28
29 #[error("Network error (GitHub client)")]
31 GithubRequest(#[from] Box<octocrab::Error>),
32
33 #[error("Task failed to execute to completion")]
35 RuntimeJoin(#[from] JoinError),
36
37 #[error("Cannot read input content from file `{1}`")]
39 ReadFileInput(#[source] std::io::Error, PathBuf),
40
41 #[error("Cannot read input content from stdin")]
43 ReadStdinInput(#[from] std::io::Error),
44
45 #[error("Attempted to interpret an invalid sequence of bytes as a string")]
47 Utf8(#[from] std::str::Utf8Error),
48
49 #[error("Error creating GitHub client")]
51 BuildGithubClient(#[source] Box<octocrab::Error>),
52
53 #[error("GitHub URL is invalid: {0}")]
55 InvalidGithubUrl(String),
56
57 #[error("URL cannot be empty")]
59 EmptyUrl,
60
61 #[error("Cannot parse '{1}' into a URL: {0}")]
63 ParseUrl(#[source] url::ParseError, String),
64
65 #[error("Cannot resolve root-relative link '{0}'")]
67 RootRelativeLinkWithoutRoot(String),
68
69 #[error("Cannot find file")]
71 InvalidFilePath(Uri),
72
73 #[error("Cannot find fragment")]
75 InvalidFragment(Uri),
76
77 #[error("Cannot find index file within directory")]
79 InvalidIndexFile(Vec<String>),
80
81 #[error("Invalid path to URL conversion: {0}")]
83 InvalidUrlFromPath(PathBuf),
84
85 #[error("Unreachable mail address {0}")]
87 UnreachableEmailAddress(Uri, String),
88
89 #[error("Header could not be parsed.")]
93 InvalidHeader(#[from] http::header::InvalidHeaderValue),
94
95 #[error("Error with base dir '{0}': {1}")]
97 InvalidBase(String, String),
98
99 #[error("Invalid root directory '{0}': {1}")]
101 InvalidRootDir(PathBuf, #[source] std::io::Error),
102
103 #[error("Unsupported URI type: '{0}'")]
105 UnsupportedUriType(String),
106
107 #[error("Error remapping URL: `{0}`")]
109 InvalidUrlRemap(String),
110
111 #[error("Invalid file path: {0}")]
113 InvalidFile(PathBuf),
114
115 #[error("Cannot traverse input directory: {0}")]
117 DirTraversal(#[from] ignore::Error),
118
119 #[error("UNIX glob pattern is invalid")]
121 InvalidGlobPattern(#[from] glob::PatternError),
122
123 #[error(
125 "GitHub token not specified. To check GitHub links reliably, use `--github-token` flag / `GITHUB_TOKEN` env var."
126 )]
127 MissingGitHubToken,
128
129 #[error("This URI is available in HTTPS protocol, but HTTP is provided. Use '{0}' instead")]
131 InsecureURL(Uri),
132
133 #[error("Cannot send/receive message from channel")]
135 Channel(#[from] tokio::sync::mpsc::error::SendError<InputContent>),
136
137 #[error("URL is missing a host")]
139 InvalidUrlHost,
140
141 #[error("The given URI is invalid: {0}")]
143 InvalidURI(Uri),
144
145 #[error("Invalid status code: {0}")]
147 InvalidStatusCode(u16),
148
149 #[error(
151 r#"Rejected status code: {code} {reason} (configurable with "accept" option)"#,
152 code = .0.as_str(),
153 reason = .0.canonical_reason().unwrap_or("Unknown status code")
154 )]
155 RejectedStatusCode(StatusCode),
156
157 #[error("Error when using regex engine: {0}")]
159 Regex(#[from] regex::Error),
160
161 #[error("Basic auth extractor error")]
163 BasicAuthExtractorError(#[from] BasicAuthExtractorError),
164
165 #[error("Cannot load cookies")]
167 Cookies(String),
168
169 #[error("Status code range error")]
171 StatusCodeSelectorError(#[from] StatusCodeSelectorError),
172
173 #[error("Preprocessor command '{command}' failed: {reason}")]
175 PreprocessorError {
176 command: String,
178 reason: String,
180 },
181
182 #[error("Wikilink {0} not found at {1}")]
184 WikilinkNotFound(Uri, PathBuf),
185
186 #[error("Failed to initialize wikilink checker: {0}")]
188 WikilinkInvalidBase(String),
189}
190
191impl ErrorKind {
192 #[must_use]
198 #[allow(clippy::too_many_lines)]
199 pub fn details(&self) -> Option<String> {
200 match self {
201 ErrorKind::NetworkRequest(e) => {
202 Some(utils::reqwest::analyze_error_chain(e))
204 }
205 ErrorKind::RejectedStatusCode(status) => status
206 .is_redirection()
207 .then_some(r#"Redirects may have been limited by "max-redirects"."#.to_string()),
208 ErrorKind::GithubRequest(e) => {
209 if let octocrab::Error::GitHub { source, .. } = &**e {
210 Some(source.message.clone())
211 } else {
212 Some(e.to_string())
214 }
215 }
216 ErrorKind::InvalidFilePath(_uri) => {
217 Some("File not found. Check if file exists and path is correct".to_string())
218 }
219 ErrorKind::ReadFileInput(e, path) => match e.kind() {
220 std::io::ErrorKind::NotFound => Some("Check if file path is correct".to_string()),
221 std::io::ErrorKind::PermissionDenied => Some(format!(
222 "Permission denied: '{}'. Check file permissions",
223 path.display()
224 )),
225 std::io::ErrorKind::IsADirectory => Some(format!(
226 "Path is a directory, not a file: '{}'. Check file path",
227 path.display()
228 )),
229 _ => Some(format!("File read error for '{}': {}", path.display(), e)),
230 },
231 ErrorKind::ReadStdinInput(e) => match e.kind() {
232 std::io::ErrorKind::UnexpectedEof => {
233 Some("Stdin input ended unexpectedly. Check input data".to_string())
234 }
235 std::io::ErrorKind::InvalidData => {
236 Some("Invalid data from stdin. Check input format".to_string())
237 }
238 _ => Some(format!("Stdin read error: {e}")),
239 },
240 ErrorKind::ParseUrl(e, _url) => match e {
241 url::ParseError::RelativeUrlWithoutBase => Some(
242 "This relative link was found inside an input source that has no base location"
243 .to_string(),
244 ),
245 _ => None,
246 },
247 ErrorKind::RootRelativeLinkWithoutRoot(_) => Some(
248 "To resolve root-relative links in local files, provide a root dir".to_string(),
249 ),
250 ErrorKind::EmptyUrl => {
251 Some("Empty URL found. Check for missing links or malformed markdown".to_string())
252 }
253 ErrorKind::InvalidFile(path) => Some(format!(
254 "Invalid file path: '{}'. Check if file exists and is readable",
255 path.display()
256 )),
257 ErrorKind::ReadResponseBody(error) => Some(format!(
258 "Failed to read response body: {error}. Server may have sent invalid data",
259 )),
260 ErrorKind::BuildRequestClient(error) => Some(format!(
261 "Failed to create HTTP client: {error}. Check system configuration",
262 )),
263 ErrorKind::RuntimeJoin(join_error) => Some(format!(
264 "Task execution failed: {join_error}. Internal processing error"
265 )),
266 ErrorKind::Utf8(_utf8_error) => {
267 Some("Invalid UTF-8 sequence found. File contains non-UTF-8 characters".to_string())
268 }
269 ErrorKind::BuildGithubClient(error) => Some(format!(
270 "Failed to create GitHub client: {error}. Check token and network connectivity",
271 )),
272 ErrorKind::InvalidGithubUrl(url) => Some(format!(
273 "Invalid GitHub URL format: '{url}'. Check URL syntax",
274 )),
275 ErrorKind::InvalidFragment(_uri) => Some(
276 "Fragment not found in document. Check if fragment exists or page structure"
277 .to_string(),
278 ),
279 ErrorKind::InvalidUrlFromPath(path_buf) => Some(format!(
280 "Cannot convert path to URL: '{}'. Check path format",
281 path_buf.display()
282 )),
283 ErrorKind::UnreachableEmailAddress(_uri, reason) => Some(reason.clone()),
284 ErrorKind::InvalidHeader(invalid_header_value) => Some(format!(
285 "Invalid HTTP header: {invalid_header_value}. Check header format",
286 )),
287 ErrorKind::InvalidBase(base, reason) => {
288 Some(format!("Invalid base URL or directory: '{base}'. {reason}",))
289 }
290 ErrorKind::InvalidRootDir(_, _) => {
291 Some("Check the root dir exists and is accessible".to_string())
292 }
293 ErrorKind::UnsupportedUriType(uri_type) => Some(format!(
294 "Unsupported URI type: '{uri_type}'. {}",
295 "Only http, https, file, and mailto are supported",
296 )),
297 ErrorKind::InvalidUrlRemap(remap) => Some(format!(
298 "Invalid URL remapping: '{remap}'. Check remapping syntax",
299 )),
300 ErrorKind::DirTraversal(error) => Some(format!(
301 "Directory traversal failed: {error}. Check directory permissions",
302 )),
303 ErrorKind::InvalidGlobPattern(pattern_error) => Some(format!(
304 "Invalid glob pattern: {pattern_error}. Check pattern syntax",
305 )),
306 ErrorKind::MissingGitHubToken => Some(format!(
307 "GitHub token required. {}",
308 "Use --github-token flag or GITHUB_TOKEN environment variable",
309 )),
310 ErrorKind::InsecureURL(uri) => Some(format!(
311 "Insecure HTTP URL detected: use '{}' instead of HTTP",
312 uri.as_str().replace("http://", "https://")
313 )),
314 ErrorKind::Channel(_send_error) => {
315 Some("Internal communication error. Processing thread failed".to_string())
316 }
317 ErrorKind::InvalidUrlHost => Some("URL missing hostname. Check URL format".to_string()),
318 ErrorKind::InvalidURI(uri) => {
319 Some(format!("Invalid URI format: '{uri}'. Check URI syntax",))
320 }
321 ErrorKind::InvalidStatusCode(code) => Some(format!(
322 "Invalid HTTP status code: {code}. Must be between 100-999",
323 )),
324 ErrorKind::Regex(error) => Some(format!(
325 "Regular expression error: {error}. Check regex syntax",
326 )),
327 ErrorKind::BasicAuthExtractorError(basic_auth_extractor_error) => Some(format!(
328 "Basic authentication error: {basic_auth_extractor_error}. {}",
329 "Check credentials format",
330 )),
331 ErrorKind::Cookies(reason) => Some(format!(
332 "Cookie handling error: {reason}. Check cookie file format",
333 )),
334 ErrorKind::StatusCodeSelectorError(status_code_selector_error) => Some(format!(
335 "Status code selector error: {status_code_selector_error}. {}",
336 "Check accept configuration",
337 )),
338 ErrorKind::InvalidIndexFile(index_files) => match &index_files[..] {
339 [] => "No directory links are allowed because index_files is defined and empty"
340 .to_string(),
341 [name] => format!("An index file ({name}) is required"),
342 [init @ .., tail] => format!(
343 "An index file ({}, or {}) is required",
344 init.join(", "),
345 tail
346 ),
347 }
348 .into(),
349 ErrorKind::PreprocessorError { command, reason } => Some(format!(
350 "Command '{command}' failed {reason}. Check value of the pre option"
351 )),
352 ErrorKind::WikilinkNotFound(uri, pathbuf) => Some(format!(
353 "WikiLink {uri} could not be found at {:}",
354 pathbuf.display()
355 )),
356 ErrorKind::WikilinkInvalidBase(reason) => {
357 Some(format!("WikiLink Resolver could not be created: {reason} ",))
358 }
359 }
360 }
361
362 #[must_use]
367 #[allow(clippy::redundant_closure_for_method_calls)]
368 pub(crate) fn reqwest_error(&self) -> Option<&reqwest::Error> {
369 self.source()
370 .and_then(|e| e.downcast_ref::<reqwest::Error>())
371 }
372
373 #[must_use]
378 #[allow(clippy::redundant_closure_for_method_calls)]
379 pub(crate) fn github_error(&self) -> Option<&octocrab::Error> {
380 self.source()
381 .and_then(|e| e.downcast_ref::<octocrab::Error>())
382 }
383}
384
385#[allow(clippy::match_same_arms)]
386impl PartialEq for ErrorKind {
387 fn eq(&self, other: &Self) -> bool {
388 match (self, other) {
389 (Self::NetworkRequest(e1), Self::NetworkRequest(e2)) => {
390 e1.to_string() == e2.to_string()
391 }
392 (Self::ReadResponseBody(e1), Self::ReadResponseBody(e2)) => {
393 e1.to_string() == e2.to_string()
394 }
395 (Self::BuildRequestClient(e1), Self::BuildRequestClient(e2)) => {
396 e1.to_string() == e2.to_string()
397 }
398 (Self::RuntimeJoin(e1), Self::RuntimeJoin(e2)) => e1.to_string() == e2.to_string(),
399 (Self::ReadFileInput(e1, s1), Self::ReadFileInput(e2, s2)) => {
400 e1.kind() == e2.kind() && s1 == s2
401 }
402 (Self::ReadStdinInput(e1), Self::ReadStdinInput(e2)) => e1.kind() == e2.kind(),
403 (Self::GithubRequest(e1), Self::GithubRequest(e2)) => e1.to_string() == e2.to_string(),
404 (Self::InvalidGithubUrl(s1), Self::InvalidGithubUrl(s2)) => s1 == s2,
405 (Self::ParseUrl(s1, e1), Self::ParseUrl(s2, e2)) => s1 == s2 && e1 == e2,
406 (Self::UnreachableEmailAddress(u1, ..), Self::UnreachableEmailAddress(u2, ..)) => {
407 u1 == u2
408 }
409 (Self::InsecureURL(u1), Self::InsecureURL(u2)) => u1 == u2,
410 (Self::InvalidGlobPattern(e1), Self::InvalidGlobPattern(e2)) => {
411 e1.msg == e2.msg && e1.pos == e2.pos
412 }
413 (Self::InvalidHeader(_), Self::InvalidHeader(_))
414 | (Self::MissingGitHubToken, Self::MissingGitHubToken) => true,
415 (Self::InvalidStatusCode(c1), Self::InvalidStatusCode(c2)) => c1 == c2,
416 (Self::InvalidUrlHost, Self::InvalidUrlHost) => true,
417 (Self::InvalidURI(u1), Self::InvalidURI(u2)) => u1 == u2,
418 (Self::Regex(e1), Self::Regex(e2)) => e1.to_string() == e2.to_string(),
419 (Self::DirTraversal(e1), Self::DirTraversal(e2)) => e1.to_string() == e2.to_string(),
420 (Self::Channel(_), Self::Channel(_)) => true,
421 (Self::BasicAuthExtractorError(e1), Self::BasicAuthExtractorError(e2)) => {
422 e1.to_string() == e2.to_string()
423 }
424 (Self::Cookies(e1), Self::Cookies(e2)) => e1 == e2,
425 (Self::InvalidFile(p1), Self::InvalidFile(p2)) => p1 == p2,
426 (Self::InvalidFilePath(u1), Self::InvalidFilePath(u2)) => u1 == u2,
427 (Self::InvalidFragment(u1), Self::InvalidFragment(u2)) => u1 == u2,
428 (Self::InvalidIndexFile(p1), Self::InvalidIndexFile(p2)) => p1 == p2,
429 (Self::InvalidUrlFromPath(p1), Self::InvalidUrlFromPath(p2)) => p1 == p2,
430 (Self::InvalidBase(b1, e1), Self::InvalidBase(b2, e2)) => b1 == b2 && e1 == e2,
431 (Self::InvalidUrlRemap(r1), Self::InvalidUrlRemap(r2)) => r1 == r2,
432 (Self::EmptyUrl, Self::EmptyUrl) => true,
433 (Self::RejectedStatusCode(c1), Self::RejectedStatusCode(c2)) => c1 == c2,
434
435 _ => false,
436 }
437 }
438}
439
440impl Eq for ErrorKind {}
441
442#[allow(clippy::match_same_arms)]
443impl Hash for ErrorKind {
444 fn hash<H>(&self, state: &mut H)
445 where
446 H: std::hash::Hasher,
447 {
448 match self {
449 Self::RuntimeJoin(e) => e.to_string().hash(state),
450 Self::ReadFileInput(e, s) => (e.kind(), s).hash(state),
451 Self::ReadStdinInput(e) => e.kind().hash(state),
452 Self::NetworkRequest(e) => e.to_string().hash(state),
453 Self::ReadResponseBody(e) => e.to_string().hash(state),
454 Self::BuildRequestClient(e) => e.to_string().hash(state),
455 Self::BuildGithubClient(e) => e.to_string().hash(state),
456 Self::GithubRequest(e) => e.to_string().hash(state),
457 Self::InvalidGithubUrl(s) => s.hash(state),
458 Self::DirTraversal(e) => e.to_string().hash(state),
459 Self::InvalidFile(e) => e.to_string_lossy().hash(state),
460 Self::EmptyUrl => "Empty URL".hash(state),
461 Self::ParseUrl(e, s) => (e.to_string(), s).hash(state),
462 Self::RootRelativeLinkWithoutRoot(s) => s.hash(state),
463 Self::InvalidURI(u) => u.hash(state),
464 Self::InvalidUrlFromPath(p) => p.hash(state),
465 Self::Utf8(e) => e.to_string().hash(state),
466 Self::InvalidFilePath(u) => u.hash(state),
467 Self::InvalidFragment(u) => u.hash(state),
468 Self::InvalidIndexFile(p) => p.hash(state),
469 Self::UnreachableEmailAddress(u, ..) => u.hash(state),
470 Self::InsecureURL(u, ..) => u.hash(state),
471 Self::InvalidBase(base, e) => (base, e).hash(state),
472 Self::InvalidRootDir(s, _) => s.hash(state),
473 Self::UnsupportedUriType(s) => s.hash(state),
474 Self::InvalidUrlRemap(remap) => (remap).hash(state),
475 Self::InvalidHeader(e) => e.to_string().hash(state),
476 Self::InvalidGlobPattern(e) => e.to_string().hash(state),
477 Self::InvalidStatusCode(c) => c.hash(state),
478 Self::RejectedStatusCode(c) => c.hash(state),
479 Self::Channel(e) => e.to_string().hash(state),
480 Self::MissingGitHubToken | Self::InvalidUrlHost => {
481 std::mem::discriminant(self).hash(state);
482 }
483 Self::Regex(e) => e.to_string().hash(state),
484 Self::BasicAuthExtractorError(e) => e.to_string().hash(state),
485 Self::Cookies(e) => e.hash(state),
486 Self::StatusCodeSelectorError(e) => e.to_string().hash(state),
487 Self::PreprocessorError { command, reason } => (command, reason).hash(state),
488 Self::WikilinkNotFound(uri, pathbuf) => (uri, pathbuf).hash(state),
489 Self::WikilinkInvalidBase(e) => e.hash(state),
490 }
491 }
492}
493
494impl Serialize for ErrorKind {
495 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
496 where
497 S: Serializer,
498 {
499 serializer.collect_str(self)
500 }
501}
502
503impl From<Infallible> for ErrorKind {
504 fn from(_: Infallible) -> Self {
505 unreachable!()
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use crate::ErrorKind;
513 #[test]
514 fn test_error_kind_details() {
515 let status_error = ErrorKind::RejectedStatusCode(http::StatusCode::NOT_FOUND);
517 assert!(status_error.to_string().contains("Not Found"));
518
519 let redir_error = ErrorKind::RejectedStatusCode(http::StatusCode::MOVED_PERMANENTLY);
521 assert!(redir_error.details().is_some_and(|x| x.contains(
522 "Redirects may have been limited by \"max-redirects\""
523 )));
524 }
525}