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")]
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 string `{1}` as website url: {0}")]
63 ParseUrl(#[source] url::ParseError, String),
64
65 #[error("Cannot find file")]
67 InvalidFilePath(Uri),
68
69 #[error("Cannot find fragment")]
71 InvalidFragment(Uri),
72
73 #[error("Cannot find index file within directory")]
75 InvalidIndexFile(Vec<String>),
76
77 #[error("Invalid path to URL conversion: {0}")]
79 InvalidUrlFromPath(PathBuf),
80
81 #[error("Unreachable mail address: {0}: {1}")]
83 UnreachableEmailAddress(Uri, String),
84
85 #[error("Header could not be parsed.")]
89 InvalidHeader(#[from] http::header::InvalidHeaderValue),
90
91 #[error("Error with base dir `{0}` : {1}")]
93 InvalidBase(String, String),
94
95 #[error("Cannot join '{0}' with the base URL")]
97 InvalidBaseJoin(String),
98
99 #[error("Cannot convert path '{0}' to a URI")]
101 InvalidPathToUri(String),
102
103 #[error("Root dir must be an absolute path: '{0}'")]
105 RootDirMustBeAbsolute(PathBuf),
106
107 #[error("Unsupported URI type: '{0}'")]
109 UnsupportedUriType(String),
110
111 #[error("Error remapping URL: `{0}`")]
113 InvalidUrlRemap(String),
114
115 #[error("Invalid file path: {0}")]
117 InvalidFile(PathBuf),
118
119 #[error("Cannot traverse input directory: {0}")]
121 DirTraversal(#[from] ignore::Error),
122
123 #[error("UNIX glob pattern is invalid")]
125 InvalidGlobPattern(#[from] glob::PatternError),
126
127 #[error(
129 "GitHub token not specified. To check GitHub links reliably, use `--github-token` flag / `GITHUB_TOKEN` env var."
130 )]
131 MissingGitHubToken,
132
133 #[error("This URI is available in HTTPS protocol, but HTTP is provided. Use '{0}' instead")]
135 InsecureURL(Uri),
136
137 #[error("Cannot send/receive message from channel")]
139 Channel(#[from] tokio::sync::mpsc::error::SendError<InputContent>),
140
141 #[error("URL is missing a host")]
143 InvalidUrlHost,
144
145 #[error("The given URI is invalid: {0}")]
147 InvalidURI(Uri),
148
149 #[error("Invalid status code: {0}")]
151 InvalidStatusCode(u16),
152
153 #[error(r#"Rejected status code (this depends on your "accept" configuration)"#)]
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 #[cfg(any(test, debug_assertions))]
176 #[error("Generic test error")]
177 TestError,
178}
179
180impl ErrorKind {
181 #[must_use]
187 #[allow(clippy::too_many_lines)]
188 pub fn details(&self) -> Option<String> {
189 match self {
190 ErrorKind::NetworkRequest(e) => {
191 Some(utils::reqwest::analyze_error_chain(e))
193 }
194 ErrorKind::RejectedStatusCode(status) => Some(
195 status
196 .canonical_reason()
197 .unwrap_or("Unknown status code")
198 .to_string(),
199 ),
200 ErrorKind::GithubRequest(e) => {
201 if let octocrab::Error::GitHub { source, .. } = &**e {
202 Some(source.message.clone())
203 } else {
204 Some(e.to_string())
206 }
207 }
208 ErrorKind::InvalidFilePath(_uri) => Some(
209 "File not found. Check if file exists and path is correct".to_string()
210 ),
211 ErrorKind::ReadFileInput(e, path) => match e.kind() {
212 std::io::ErrorKind::NotFound => Some(
213 "Check if file path is correct".to_string()
214 ),
215 std::io::ErrorKind::PermissionDenied => Some(format!(
216 "Permission denied: '{}'. Check file permissions",
217 path.display()
218 )),
219 std::io::ErrorKind::IsADirectory => Some(format!(
220 "Path is a directory, not a file: '{}'. Check file path",
221 path.display()
222 )),
223 _ => Some(format!("File read error for '{}': {}", path.display(), e)),
224 },
225 ErrorKind::ReadStdinInput(e) => match e.kind() {
226 std::io::ErrorKind::UnexpectedEof => {
227 Some("Stdin input ended unexpectedly. Check input data".to_string())
228 }
229 std::io::ErrorKind::InvalidData => {
230 Some("Invalid data from stdin. Check input format".to_string())
231 }
232 _ => Some(format!("Stdin read error: {e}")),
233 },
234 ErrorKind::ParseUrl(_, url) => {
235 Some(format!("Invalid URL format: '{url}'. Check URL syntax"))
236 }
237 ErrorKind::EmptyUrl => {
238 Some("Empty URL found. Check for missing links or malformed markdown".to_string())
239 }
240 #[cfg(any(test, debug_assertions))]
241 ErrorKind::TestError => Some("Test error for formatter testing".to_string()),
242 ErrorKind::InvalidFile(path) => Some(format!(
243 "Invalid file path: '{}'. Check if file exists and is readable",
244 path.display()
245 )),
246 ErrorKind::ReadResponseBody(error) => Some(format!(
247 "Failed to read response body: {error}. Server may have sent invalid data",
248 )),
249 ErrorKind::BuildRequestClient(error) => Some(format!(
250 "Failed to create HTTP client: {error}. Check system configuration",
251 )),
252 ErrorKind::RuntimeJoin(join_error) => Some(format!(
253 "Task execution failed: {join_error}. Internal processing error"
254 )),
255 ErrorKind::Utf8(_utf8_error) => Some(
256 "Invalid UTF-8 sequence found. File contains non-UTF-8 characters".to_string()
257 ),
258 ErrorKind::BuildGithubClient(error) => Some(format!(
259 "Failed to create GitHub client: {error}. Check token and network connectivity",
260 )),
261 ErrorKind::InvalidGithubUrl(url) => Some(format!(
262 "Invalid GitHub URL format: '{url}'. Check URL syntax",
263 )),
264 ErrorKind::InvalidFragment(_uri) => Some(
265 "Fragment not found in document. Check if fragment exists or page structure".to_string()
266 ),
267 ErrorKind::InvalidUrlFromPath(path_buf) => Some(format!(
268 "Cannot convert path to URL: '{}'. Check path format",
269 path_buf.display()
270 )),
271 ErrorKind::UnreachableEmailAddress(uri, reason) => Some(format!(
272 "Email address unreachable: '{uri}'. {reason}",
273 )),
274 ErrorKind::InvalidHeader(invalid_header_value) => Some(format!(
275 "Invalid HTTP header: {invalid_header_value}. Check header format",
276 )),
277 ErrorKind::InvalidBase(base, reason) => Some(format!(
278 "Invalid base URL or directory: '{base}'. {reason}",
279 )),
280 ErrorKind::InvalidBaseJoin(text) => Some(format!(
281 "Cannot join '{text}' with base URL. Check relative path format",
282 )),
283 ErrorKind::InvalidPathToUri(path) => Some(format!(
284 "Cannot convert path to URI: '{path}'. Check path format",
285 )),
286 ErrorKind::RootDirMustBeAbsolute(path_buf) => Some(format!(
287 "Root directory must be absolute: '{}'. Use full path",
288 path_buf.display()
289 )),
290 ErrorKind::UnsupportedUriType(uri_type) => Some(format!(
291 "Unsupported URI type: '{uri_type}'. Only http, https, file, and mailto are supported",
292 )),
293 ErrorKind::InvalidUrlRemap(remap) => Some(format!(
294 "Invalid URL remapping: '{remap}'. Check remapping syntax",
295 )),
296 ErrorKind::DirTraversal(error) => Some(format!(
297 "Directory traversal failed: {error}. Check directory permissions",
298 )),
299 ErrorKind::InvalidGlobPattern(pattern_error) => Some(format!(
300 "Invalid glob pattern: {pattern_error}. Check pattern syntax",
301 )),
302 ErrorKind::MissingGitHubToken => Some(
303 "GitHub token required. Use --github-token flag or GITHUB_TOKEN environment variable".to_string()
304 ),
305 ErrorKind::InsecureURL(uri) => Some(format!(
306 "Insecure HTTP URL detected: use '{}' instead of HTTP",
307 uri.as_str().replace("http://", "https://")
308 )),
309 ErrorKind::Channel(_send_error) => Some(
310 "Internal communication error. Processing thread failed".to_string()
311 ),
312 ErrorKind::InvalidUrlHost => Some(
313 "URL missing hostname. Check URL format".to_string()
314 ),
315 ErrorKind::InvalidURI(uri) => Some(format!(
316 "Invalid URI format: '{uri}'. Check URI syntax",
317 )),
318 ErrorKind::InvalidStatusCode(code) => Some(format!(
319 "Invalid HTTP status code: {code}. Must be between 100-999",
320 )),
321 ErrorKind::Regex(error) => Some(format!(
322 "Regular expression error: {error}. Check regex syntax",
323 )),
324 ErrorKind::BasicAuthExtractorError(basic_auth_extractor_error) => Some(format!(
325 "Basic authentication error: {basic_auth_extractor_error}. Check credentials format",
326 )),
327 ErrorKind::Cookies(reason) => Some(format!(
328 "Cookie handling error: {reason}. Check cookie file format",
329 )),
330 ErrorKind::StatusCodeSelectorError(status_code_selector_error) => Some(format!(
331 "Status code selector error: {status_code_selector_error}. Check accept configuration",
332 )),
333 ErrorKind::InvalidIndexFile(index_files) => match &index_files[..] {
334 [] => "No directory links are allowed because index_files is defined and empty".to_string(),
335 [name] => format!("An index file ({name}) is required"),
336 [init @ .., tail] => format!("An index file ({}, or {}) is required", init.join(", "), tail),
337 }.into()
338 }
339 }
340
341 #[must_use]
346 #[allow(clippy::redundant_closure_for_method_calls)]
347 pub(crate) fn reqwest_error(&self) -> Option<&reqwest::Error> {
348 self.source()
349 .and_then(|e| e.downcast_ref::<reqwest::Error>())
350 }
351
352 #[must_use]
357 #[allow(clippy::redundant_closure_for_method_calls)]
358 pub(crate) fn github_error(&self) -> Option<&octocrab::Error> {
359 self.source()
360 .and_then(|e| e.downcast_ref::<octocrab::Error>())
361 }
362}
363
364#[allow(clippy::match_same_arms)]
365impl PartialEq for ErrorKind {
366 fn eq(&self, other: &Self) -> bool {
367 match (self, other) {
368 (Self::NetworkRequest(e1), Self::NetworkRequest(e2)) => {
369 e1.to_string() == e2.to_string()
370 }
371 (Self::ReadResponseBody(e1), Self::ReadResponseBody(e2)) => {
372 e1.to_string() == e2.to_string()
373 }
374 (Self::BuildRequestClient(e1), Self::BuildRequestClient(e2)) => {
375 e1.to_string() == e2.to_string()
376 }
377 (Self::RuntimeJoin(e1), Self::RuntimeJoin(e2)) => e1.to_string() == e2.to_string(),
378 (Self::ReadFileInput(e1, s1), Self::ReadFileInput(e2, s2)) => {
379 e1.kind() == e2.kind() && s1 == s2
380 }
381 (Self::ReadStdinInput(e1), Self::ReadStdinInput(e2)) => e1.kind() == e2.kind(),
382 (Self::GithubRequest(e1), Self::GithubRequest(e2)) => e1.to_string() == e2.to_string(),
383 (Self::InvalidGithubUrl(s1), Self::InvalidGithubUrl(s2)) => s1 == s2,
384 (Self::ParseUrl(s1, e1), Self::ParseUrl(s2, e2)) => s1 == s2 && e1 == e2,
385 (Self::UnreachableEmailAddress(u1, ..), Self::UnreachableEmailAddress(u2, ..)) => {
386 u1 == u2
387 }
388 (Self::InsecureURL(u1), Self::InsecureURL(u2)) => u1 == u2,
389 (Self::InvalidGlobPattern(e1), Self::InvalidGlobPattern(e2)) => {
390 e1.msg == e2.msg && e1.pos == e2.pos
391 }
392 (Self::InvalidHeader(_), Self::InvalidHeader(_))
393 | (Self::MissingGitHubToken, Self::MissingGitHubToken) => true,
394 (Self::InvalidStatusCode(c1), Self::InvalidStatusCode(c2)) => c1 == c2,
395 (Self::InvalidUrlHost, Self::InvalidUrlHost) => true,
396 (Self::InvalidURI(u1), Self::InvalidURI(u2)) => u1 == u2,
397 (Self::Regex(e1), Self::Regex(e2)) => e1.to_string() == e2.to_string(),
398 (Self::DirTraversal(e1), Self::DirTraversal(e2)) => e1.to_string() == e2.to_string(),
399 (Self::Channel(_), Self::Channel(_)) => true,
400 (Self::BasicAuthExtractorError(e1), Self::BasicAuthExtractorError(e2)) => {
401 e1.to_string() == e2.to_string()
402 }
403 (Self::Cookies(e1), Self::Cookies(e2)) => e1 == e2,
404 (Self::InvalidFile(p1), Self::InvalidFile(p2)) => p1 == p2,
405 (Self::InvalidFilePath(u1), Self::InvalidFilePath(u2)) => u1 == u2,
406 (Self::InvalidFragment(u1), Self::InvalidFragment(u2)) => u1 == u2,
407 (Self::InvalidIndexFile(p1), Self::InvalidIndexFile(p2)) => p1 == p2,
408 (Self::InvalidUrlFromPath(p1), Self::InvalidUrlFromPath(p2)) => p1 == p2,
409 (Self::InvalidBase(b1, e1), Self::InvalidBase(b2, e2)) => b1 == b2 && e1 == e2,
410 (Self::InvalidUrlRemap(r1), Self::InvalidUrlRemap(r2)) => r1 == r2,
411 (Self::EmptyUrl, Self::EmptyUrl) => true,
412 (Self::RejectedStatusCode(c1), Self::RejectedStatusCode(c2)) => c1 == c2,
413 #[cfg(any(test, debug_assertions))]
414 (Self::TestError, Self::TestError) => true,
415
416 _ => false,
417 }
418 }
419}
420
421impl Eq for ErrorKind {}
422
423#[allow(clippy::match_same_arms)]
424impl Hash for ErrorKind {
425 fn hash<H>(&self, state: &mut H)
426 where
427 H: std::hash::Hasher,
428 {
429 match self {
430 Self::RuntimeJoin(e) => e.to_string().hash(state),
431 Self::ReadFileInput(e, s) => (e.kind(), s).hash(state),
432 Self::ReadStdinInput(e) => e.kind().hash(state),
433 Self::NetworkRequest(e) => e.to_string().hash(state),
434 Self::ReadResponseBody(e) => e.to_string().hash(state),
435 Self::BuildRequestClient(e) => e.to_string().hash(state),
436 Self::BuildGithubClient(e) => e.to_string().hash(state),
437 Self::GithubRequest(e) => e.to_string().hash(state),
438 Self::InvalidGithubUrl(s) => s.hash(state),
439 Self::DirTraversal(e) => e.to_string().hash(state),
440 Self::InvalidFile(e) => e.to_string_lossy().hash(state),
441 Self::EmptyUrl => "Empty URL".hash(state),
442 Self::ParseUrl(e, s) => (e.to_string(), s).hash(state),
443 Self::InvalidURI(u) => u.hash(state),
444 Self::InvalidUrlFromPath(p) => p.hash(state),
445 Self::Utf8(e) => e.to_string().hash(state),
446 Self::InvalidFilePath(u) => u.hash(state),
447 Self::InvalidFragment(u) => u.hash(state),
448 Self::InvalidIndexFile(p) => p.hash(state),
449 Self::UnreachableEmailAddress(u, ..) => u.hash(state),
450 Self::InsecureURL(u, ..) => u.hash(state),
451 Self::InvalidBase(base, e) => (base, e).hash(state),
452 Self::InvalidBaseJoin(s) => s.hash(state),
453 Self::InvalidPathToUri(s) => s.hash(state),
454 Self::RootDirMustBeAbsolute(s) => s.hash(state),
455 Self::UnsupportedUriType(s) => s.hash(state),
456 Self::InvalidUrlRemap(remap) => (remap).hash(state),
457 Self::InvalidHeader(e) => e.to_string().hash(state),
458 Self::InvalidGlobPattern(e) => e.to_string().hash(state),
459 Self::InvalidStatusCode(c) => c.hash(state),
460 Self::RejectedStatusCode(c) => c.hash(state),
461 Self::Channel(e) => e.to_string().hash(state),
462 Self::MissingGitHubToken | Self::InvalidUrlHost => {
463 std::mem::discriminant(self).hash(state);
464 }
465 #[cfg(any(test, debug_assertions))]
466 Self::TestError => {
467 std::mem::discriminant(self).hash(state);
468 }
469 Self::Regex(e) => e.to_string().hash(state),
470 Self::BasicAuthExtractorError(e) => e.to_string().hash(state),
471 Self::Cookies(e) => e.to_string().hash(state),
472 Self::StatusCodeSelectorError(e) => e.to_string().hash(state),
473 }
474 }
475}
476
477impl Serialize for ErrorKind {
478 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
479 where
480 S: Serializer,
481 {
482 serializer.collect_str(self)
483 }
484}
485
486impl From<Infallible> for ErrorKind {
487 fn from(_: Infallible) -> Self {
488 unreachable!()
490 }
491}