Skip to main content

lychee_lib/types/
status.rs

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