Skip to main content

lychee_lib/types/
error.rs

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