lychee_lib/types/
status.rs

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