Skip to main content

lychee_lib/types/
status.rs

1use std::{collections::HashSet, fmt::Display};
2
3use super::CacheStatus;
4use super::redirect_history::Redirects;
5use crate::ErrorKind;
6use crate::RequestError;
7use crate::ratelimit::CacheableResponse;
8use http::StatusCode;
9use serde::ser::SerializeStruct;
10use serde::{Serialize, Serializer};
11
12const ICON_OK: &str = "✔";
13const ICON_REDIRECTED: &str = "⇄";
14const ICON_EXCLUDED: &str = "?";
15const ICON_UNSUPPORTED: &str = "\u{003f}"; // ? (using same icon, but under different name for explicitness)
16const ICON_UNKNOWN: &str = "?";
17const ICON_ERROR: &str = "✗";
18const ICON_TIMEOUT: &str = "⧖";
19const ICON_CACHED: &str = "↻";
20
21/// Response status of the request.
22#[allow(variant_size_differences)]
23#[derive(Debug, Hash, PartialEq, Eq)]
24pub enum Status {
25    /// Request was successful
26    Ok(StatusCode),
27    /// Failed request
28    Error(ErrorKind),
29    /// Request could not be built
30    RequestError(RequestError),
31    /// Request timed out
32    Timeout(Option<StatusCode>),
33    /// Got redirected to different resource
34    Redirected(StatusCode, Redirects),
35    /// The given status code is not known by lychee
36    UnknownStatusCode(StatusCode),
37    /// The given mail address could not be reliably identified.
38    /// This normally happens due to restrictive measures by
39    /// mail servers (blocklisting) or your ISP (port filtering).
40    UnknownMailStatus(String),
41    /// Resource was excluded from checking
42    Excluded,
43    /// The request type is currently not supported,
44    /// for example when the URL scheme is `slack://`.
45    /// See <https://github.com/lycheeverse/lychee/issues/199>
46    Unsupported(ErrorKind),
47    /// Cached request status from previous run
48    Cached(CacheStatus),
49}
50
51impl Display for Status {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        match self {
54            Status::Ok(code) => write!(f, "{code}"),
55            Status::Redirected(_, _) => write!(f, "Redirect"),
56            Status::UnknownStatusCode(code) => write!(f, "Unknown status ({code})"),
57            Status::UnknownMailStatus(_) => write!(f, "Unknown mail status"),
58            Status::Timeout(Some(code)) => write!(f, "Timeout ({code})"),
59            Status::Timeout(None) => f.write_str("Timeout"),
60            Status::Unsupported(e) => write!(f, "Unsupported: {e}"),
61            Status::Error(e) => write!(f, "{e}"),
62            Status::RequestError(e) => write!(f, "{e}"),
63            Status::Cached(status) => write!(f, "{status}"),
64            Status::Excluded => Ok(()),
65        }
66    }
67}
68
69impl Serialize for Status {
70    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
71    where
72        S: Serializer,
73    {
74        let mut s;
75
76        if let Some(code) = self.code() {
77            s = serializer.serialize_struct("Status", 2)?;
78            s.serialize_field("text", &self.to_string())?;
79            s.serialize_field("code", &code.as_u16())?;
80        } else if let Some(details) = self.details() {
81            s = serializer.serialize_struct("Status", 2)?;
82            s.serialize_field("text", &self.to_string())?;
83            s.serialize_field("details", &details)?;
84        } else {
85            s = serializer.serialize_struct("Status", 1)?;
86            s.serialize_field("text", &self.to_string())?;
87        }
88
89        if let Status::Redirected(_, redirects) = self {
90            s.serialize_field("redirects", redirects)?;
91        }
92
93        s.end()
94    }
95}
96
97impl Status {
98    #[must_use]
99    /// Create a status object from a response and the set of accepted status codes
100    pub(crate) fn new(response: &CacheableResponse, accepted: &HashSet<StatusCode>) -> Self {
101        let status = response.status;
102        if accepted.contains(&status) {
103            Self::Ok(status)
104        } else {
105            Self::Error(ErrorKind::RejectedStatusCode(status))
106        }
107    }
108
109    /// Create a status object from a cached status (from a previous run of
110    /// lychee) and the set of accepted status codes.
111    ///
112    /// The set of accepted status codes can change between runs,
113    /// necessitating more complex logic than just using the cached status.
114    ///
115    /// Note that the accepted status codes are not of type `StatusCode`,
116    /// because they are provided by the user and can be invalid according to
117    /// the HTTP spec and IANA, but the user might still want to accept them.
118    #[must_use]
119    pub fn from_cache_status(s: CacheStatus, accepted: &HashSet<StatusCode>) -> Self {
120        match s {
121            CacheStatus::Ok(code) => {
122                if matches!(s, CacheStatus::Ok(_)) || accepted.contains(&code) {
123                    return Self::Cached(CacheStatus::Ok(code));
124                }
125                Self::Cached(CacheStatus::Error(Some(code)))
126            }
127            CacheStatus::Error(code) => {
128                if let Some(code) = code
129                    && accepted.contains(&code)
130                {
131                    return Self::Cached(CacheStatus::Ok(code));
132                }
133                Self::Cached(CacheStatus::Error(code))
134            }
135            _ => Self::Cached(s),
136        }
137    }
138
139    /// Return more details about the status (if any)
140    ///
141    /// Which additional information we can extract depends on the underlying
142    /// request type. The output is purely meant for humans and future changes
143    /// are expected.
144    ///
145    /// It is modeled after reqwest's `details` method.
146    #[must_use]
147    #[allow(clippy::match_same_arms)]
148    pub fn details(&self) -> Option<String> {
149        match &self {
150            Status::Ok(code) => code.canonical_reason().map(String::from),
151            Status::Redirected(code, redirects) => {
152                let count = redirects.count();
153                let noun = if count == 1 { "redirect" } else { "redirects" };
154
155                let result = code
156                    .canonical_reason()
157                    .map(String::from)
158                    .unwrap_or(code.as_str().to_owned());
159                Some(format!(
160                    "Followed {count} {noun} resolving to the final status of: {result}. Redirects: {redirects}"
161                ))
162            }
163            Status::Error(e) => e.details(),
164            Status::RequestError(e) => e.error().details(),
165            Status::Timeout(_) => None,
166            Status::UnknownStatusCode(_) => None,
167            Status::UnknownMailStatus(reason) => Some(reason.clone()),
168            Status::Unsupported(_) => None,
169            Status::Cached(_) => None,
170            Status::Excluded => None,
171        }
172    }
173
174    #[inline]
175    #[must_use]
176    /// Returns `true` if the check was successful
177    pub const fn is_success(&self) -> bool {
178        matches!(self, Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)))
179    }
180
181    #[inline]
182    #[must_use]
183    /// Returns `true` if the check was not successful
184    pub const fn is_error(&self) -> bool {
185        matches!(
186            self,
187            Status::Error(_)
188                | Status::RequestError(_)
189                | Status::Cached(CacheStatus::Error(_))
190                | Status::Timeout(_)
191        )
192    }
193
194    #[inline]
195    #[must_use]
196    /// Returns `true` if the check was excluded
197    pub const fn is_excluded(&self) -> bool {
198        matches!(
199            self,
200            Status::Excluded | Status::Cached(CacheStatus::Excluded)
201        )
202    }
203
204    #[inline]
205    #[must_use]
206    /// Returns `true` if a check took too long to complete
207    pub const fn is_timeout(&self) -> bool {
208        matches!(self, Status::Timeout(_))
209    }
210
211    #[inline]
212    #[must_use]
213    /// Returns `true` if a URI is unsupported
214    pub const fn is_unsupported(&self) -> bool {
215        matches!(
216            self,
217            Status::Unsupported(_) | Status::Cached(CacheStatus::Unsupported)
218        )
219    }
220
221    #[must_use]
222    /// Return a unicode icon to visualize the status
223    pub const fn icon(&self) -> &str {
224        match self {
225            Status::Ok(_) => ICON_OK,
226            Status::Redirected(_, _) => ICON_REDIRECTED,
227            Status::UnknownStatusCode(_) | Status::UnknownMailStatus(_) => ICON_UNKNOWN,
228            Status::Excluded => ICON_EXCLUDED,
229            Status::Error(_) | Status::RequestError(_) => ICON_ERROR,
230            Status::Timeout(_) => ICON_TIMEOUT,
231            Status::Unsupported(_) => ICON_UNSUPPORTED,
232            Status::Cached(_) => ICON_CACHED,
233        }
234    }
235
236    #[must_use]
237    /// Return the HTTP status code (if any)
238    pub fn code(&self) -> Option<StatusCode> {
239        match self {
240            Status::Ok(code)
241            | Status::Redirected(code, _)
242            | Status::UnknownStatusCode(code)
243            | Status::Timeout(Some(code))
244            | Status::Cached(CacheStatus::Ok(code) | CacheStatus::Error(Some(code))) => Some(*code),
245            Status::Error(kind) | Status::Unsupported(kind) => match kind {
246                ErrorKind::RejectedStatusCode(status_code) => Some(*status_code),
247                _ => match kind.reqwest_error() {
248                    Some(error) => error.status(),
249                    None => None,
250                },
251            },
252            _ => None,
253        }
254    }
255
256    /// Return the HTTP status code as string (if any)
257    #[must_use]
258    pub fn code_as_string(&self) -> String {
259        match self {
260            Status::Ok(code) | Status::Redirected(code, _) | Status::UnknownStatusCode(code) => {
261                code.as_u16().to_string()
262            }
263            Status::UnknownMailStatus(_) => "UNKNOWN".to_string(),
264            Status::Excluded => "EXCLUDED".to_string(),
265            Status::Error(e) => match e {
266                ErrorKind::RejectedStatusCode(code) => code.as_u16().to_string(),
267                ErrorKind::ReadResponseBody(e) | ErrorKind::BuildRequestClient(e) => {
268                    match e.status() {
269                        Some(code) => code.as_u16().to_string(),
270                        None => "ERROR".to_string(),
271                    }
272                }
273                _ => "ERROR".to_string(),
274            },
275            Status::RequestError(_) => "ERROR".to_string(),
276            Status::Timeout(code) => match code {
277                Some(code) => code.as_u16().to_string(),
278                None => "TIMEOUT".to_string(),
279            },
280            Status::Unsupported(_) => "IGNORED".to_string(),
281            Status::Cached(cache_status) => match cache_status {
282                CacheStatus::Ok(code) => code.as_u16().to_string(),
283                CacheStatus::Error(code) => match code {
284                    Some(code) => code.as_u16().to_string(),
285                    None => "ERROR".to_string(),
286                },
287                CacheStatus::Excluded => "EXCLUDED".to_string(),
288                CacheStatus::Unsupported => "IGNORED".to_string(),
289            },
290        }
291    }
292
293    /// Returns true if the status code is unknown
294    /// (i.e. not a valid HTTP status code)
295    ///
296    /// For example, `200` is a valid HTTP status code,
297    /// while `999` is not.
298    #[must_use]
299    pub const fn is_unknown(&self) -> bool {
300        matches!(self, Status::UnknownStatusCode(_))
301    }
302}
303
304impl From<ErrorKind> for Status {
305    fn from(e: ErrorKind) -> Self {
306        match e {
307            ErrorKind::InvalidUrlHost => Status::Unsupported(ErrorKind::InvalidUrlHost),
308            ErrorKind::NetworkRequest(e)
309            | ErrorKind::ReadResponseBody(e)
310            | ErrorKind::BuildRequestClient(e) => {
311                if e.is_timeout() {
312                    Self::Timeout(e.status())
313                } else if e.is_builder() {
314                    Self::Unsupported(ErrorKind::BuildRequestClient(e))
315                } else if e.is_body() || e.is_decode() {
316                    Self::Unsupported(ErrorKind::ReadResponseBody(e))
317                } else {
318                    Self::Error(ErrorKind::NetworkRequest(e))
319                }
320            }
321            e => Self::Error(e),
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use crate::{CacheStatus, ErrorKind, Status, types::redirect_history::Redirects};
329    use http::StatusCode;
330
331    #[test]
332    fn test_status_serialization() {
333        let status_ok = Status::Ok(StatusCode::from_u16(200).unwrap());
334        let serialized_with_code = serde_json::to_string(&status_ok).unwrap();
335        assert_eq!("{\"text\":\"200 OK\",\"code\":200}", serialized_with_code);
336
337        let status_error = Status::Error(ErrorKind::EmptyUrl);
338        let serialized_with_error = serde_json::to_string(&status_error).unwrap();
339        assert_eq!(
340            "{\"text\":\"URL cannot be empty\",\"details\":\"Empty URL found. Check for missing links or malformed markdown\"}",
341            serialized_with_error
342        );
343
344        let status_timeout = Status::Timeout(None);
345        let serialized_without_code = serde_json::to_string(&status_timeout).unwrap();
346        assert_eq!("{\"text\":\"Timeout\"}", serialized_without_code);
347    }
348
349    #[test]
350    fn test_get_status_code() {
351        assert_eq!(
352            Status::Ok(StatusCode::from_u16(200).unwrap())
353                .code()
354                .unwrap(),
355            200
356        );
357        assert_eq!(
358            Status::Timeout(Some(StatusCode::from_u16(408).unwrap()))
359                .code()
360                .unwrap(),
361            408
362        );
363        assert_eq!(
364            Status::UnknownStatusCode(StatusCode::from_u16(999).unwrap())
365                .code()
366                .unwrap(),
367            999
368        );
369        assert_eq!(
370            Status::Redirected(
371                StatusCode::from_u16(300).unwrap(),
372                Redirects::new("http://example.com".try_into().unwrap())
373            )
374            .code()
375            .unwrap(),
376            300
377        );
378        assert_eq!(
379            Status::Cached(CacheStatus::Ok(StatusCode::OK))
380                .code()
381                .unwrap(),
382            200
383        );
384        assert_eq!(
385            Status::Cached(CacheStatus::Error(Some(StatusCode::NOT_FOUND)))
386                .code()
387                .unwrap(),
388            404
389        );
390        assert_eq!(Status::Timeout(None).code(), None);
391        assert_eq!(Status::Cached(CacheStatus::Error(None)).code(), None);
392        assert_eq!(Status::Excluded.code(), None);
393        assert_eq!(
394            Status::Unsupported(ErrorKind::InvalidStatusCode(999)).code(),
395            None
396        );
397    }
398
399    #[test]
400    fn test_status_unknown() {
401        assert!(Status::UnknownStatusCode(StatusCode::from_u16(999).unwrap()).is_unknown());
402        assert!(!Status::Ok(StatusCode::from_u16(200).unwrap()).is_unknown());
403    }
404}