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 http::StatusCode;
8use reqwest::Response;
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    /// 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::Redirected(_, _) => write!(f, "Redirect"),
52            Status::UnknownStatusCode(code) => write!(f, "Unknown status ({code})"),
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 => Ok(()),
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 mut s;
70
71        if let Some(code) = self.code() {
72            s = serializer.serialize_struct("Status", 2)?;
73            s.serialize_field("text", &self.to_string())?;
74            s.serialize_field("code", &code.as_u16())?;
75        } else if let Some(details) = self.details() {
76            s = serializer.serialize_struct("Status", 2)?;
77            s.serialize_field("text", &self.to_string())?;
78            s.serialize_field("details", &details)?;
79        } else {
80            s = serializer.serialize_struct("Status", 1)?;
81            s.serialize_field("text", &self.to_string())?;
82        }
83
84        if let Status::Redirected(_, redirects) = self {
85            s.serialize_field("redirects", redirects)?;
86        }
87
88        s.end()
89    }
90}
91
92impl Status {
93    #[must_use]
94    /// Create a status object from a response and the set of accepted status codes
95    pub fn new(response: &Response, accepted: &HashSet<StatusCode>) -> Self {
96        let code = response.status();
97
98        if accepted.contains(&code) {
99            Self::Ok(code)
100        } else {
101            Self::Error(ErrorKind::RejectedStatusCode(code))
102        }
103    }
104
105    /// Create a status object from a cached status (from a previous run of
106    /// lychee) and the set of accepted status codes.
107    ///
108    /// The set of accepted status codes can change between runs,
109    /// necessitating more complex logic than just using the cached status.
110    ///
111    /// Note that the accepted status codes are not of type `StatusCode`,
112    /// because they are provided by the user and can be invalid according to
113    /// the HTTP spec and IANA, but the user might still want to accept them.
114    #[must_use]
115    pub fn from_cache_status(s: CacheStatus, accepted: &HashSet<u16>) -> Self {
116        match s {
117            CacheStatus::Ok(code) => {
118                if matches!(s, CacheStatus::Ok(_)) || accepted.contains(&code) {
119                    return Self::Cached(CacheStatus::Ok(code));
120                }
121                Self::Cached(CacheStatus::Error(Some(code)))
122            }
123            CacheStatus::Error(code) => {
124                if let Some(code) = code
125                    && accepted.contains(&code)
126                {
127                    return Self::Cached(CacheStatus::Ok(code));
128                }
129                Self::Cached(CacheStatus::Error(code))
130            }
131            _ => Self::Cached(s),
132        }
133    }
134
135    /// Return more details about the status (if any)
136    ///
137    /// Which additional information we can extract depends on the underlying
138    /// request type. The output is purely meant for humans and future changes
139    /// are expected.
140    ///
141    /// It is modeled after reqwest's `details` method.
142    #[must_use]
143    #[allow(clippy::match_same_arms)]
144    pub fn details(&self) -> Option<String> {
145        match &self {
146            Status::Ok(code) => code.canonical_reason().map(String::from),
147            Status::Redirected(code, redirects) => {
148                let count = redirects.count();
149                let noun = if count == 1 { "redirect" } else { "redirects" };
150
151                let result = code
152                    .canonical_reason()
153                    .map(String::from)
154                    .unwrap_or(code.as_str().to_owned());
155                Some(format!(
156                    "Followed {count} {noun} resolving to the final status of: {result}. Redirects: {redirects}"
157                ))
158            }
159            Status::Error(e) => e.details(),
160            Status::RequestError(e) => e.error().details(),
161            Status::Timeout(_) => None,
162            Status::UnknownStatusCode(_) => None,
163            Status::Unsupported(_) => None,
164            Status::Cached(_) => None,
165            Status::Excluded => None,
166        }
167    }
168
169    #[inline]
170    #[must_use]
171    /// Returns `true` if the check was successful
172    pub const fn is_success(&self) -> bool {
173        matches!(self, Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)))
174    }
175
176    #[inline]
177    #[must_use]
178    /// Returns `true` if the check was not successful
179    pub const fn is_error(&self) -> bool {
180        matches!(
181            self,
182            Status::Error(_)
183                | Status::RequestError(_)
184                | Status::Cached(CacheStatus::Error(_))
185                | Status::Timeout(_)
186        )
187    }
188
189    #[inline]
190    #[must_use]
191    /// Returns `true` if the check was excluded
192    pub const fn is_excluded(&self) -> bool {
193        matches!(
194            self,
195            Status::Excluded | Status::Cached(CacheStatus::Excluded)
196        )
197    }
198
199    #[inline]
200    #[must_use]
201    /// Returns `true` if a check took too long to complete
202    pub const fn is_timeout(&self) -> bool {
203        matches!(self, Status::Timeout(_))
204    }
205
206    #[inline]
207    #[must_use]
208    /// Returns `true` if a URI is unsupported
209    pub const fn is_unsupported(&self) -> bool {
210        matches!(
211            self,
212            Status::Unsupported(_) | Status::Cached(CacheStatus::Unsupported)
213        )
214    }
215
216    #[must_use]
217    /// Return a unicode icon to visualize the status
218    pub const fn icon(&self) -> &str {
219        match self {
220            Status::Ok(_) => ICON_OK,
221            Status::Redirected(_, _) => ICON_REDIRECTED,
222            Status::UnknownStatusCode(_) => ICON_UNKNOWN,
223            Status::Excluded => ICON_EXCLUDED,
224            Status::Error(_) | Status::RequestError(_) => ICON_ERROR,
225            Status::Timeout(_) => ICON_TIMEOUT,
226            Status::Unsupported(_) => ICON_UNSUPPORTED,
227            Status::Cached(_) => ICON_CACHED,
228        }
229    }
230
231    #[must_use]
232    /// Return the HTTP status code (if any)
233    pub fn code(&self) -> Option<StatusCode> {
234        match self {
235            Status::Ok(code)
236            | Status::Redirected(code, _)
237            | Status::UnknownStatusCode(code)
238            | Status::Timeout(Some(code)) => Some(*code),
239            Status::Error(kind) | Status::Unsupported(kind) => match kind {
240                ErrorKind::RejectedStatusCode(status_code) => Some(*status_code),
241                _ => match kind.reqwest_error() {
242                    Some(error) => error.status(),
243                    None => None,
244                },
245            },
246            Status::Cached(CacheStatus::Ok(code) | CacheStatus::Error(Some(code))) => {
247                StatusCode::from_u16(*code).ok()
248            }
249            _ => None,
250        }
251    }
252
253    /// Return the HTTP status code as string (if any)
254    #[must_use]
255    pub fn code_as_string(&self) -> String {
256        match self {
257            Status::Ok(code) | Status::Redirected(code, _) | Status::UnknownStatusCode(code) => {
258                code.as_str().to_string()
259            }
260            Status::Excluded => "EXCLUDED".to_string(),
261            Status::Error(e) => match e {
262                ErrorKind::RejectedStatusCode(code) => code.as_str().to_string(),
263                ErrorKind::ReadResponseBody(e) | ErrorKind::BuildRequestClient(e) => {
264                    match e.status() {
265                        Some(code) => code.as_str().to_string(),
266                        None => "ERROR".to_string(),
267                    }
268                }
269                _ => "ERROR".to_string(),
270            },
271            Status::RequestError(_) => "ERROR".to_string(),
272            Status::Timeout(code) => match code {
273                Some(code) => code.as_str().to_string(),
274                None => "TIMEOUT".to_string(),
275            },
276            Status::Unsupported(_) => "IGNORED".to_string(),
277            Status::Cached(cache_status) => match cache_status {
278                CacheStatus::Ok(code) => code.to_string(),
279                CacheStatus::Error(code) => match code {
280                    Some(code) => code.to_string(),
281                    None => "ERROR".to_string(),
282                },
283                CacheStatus::Excluded => "EXCLUDED".to_string(),
284                CacheStatus::Unsupported => "IGNORED".to_string(),
285            },
286        }
287    }
288
289    /// Returns true if the status code is unknown
290    /// (i.e. not a valid HTTP status code)
291    ///
292    /// For example, `200` is a valid HTTP status code,
293    /// while `999` is not.
294    #[must_use]
295    pub const fn is_unknown(&self) -> bool {
296        matches!(self, Status::UnknownStatusCode(_))
297    }
298}
299
300impl From<ErrorKind> for Status {
301    fn from(e: ErrorKind) -> Self {
302        Self::Error(e)
303    }
304}
305
306impl From<reqwest::Error> for Status {
307    fn from(e: reqwest::Error) -> Self {
308        if e.is_timeout() {
309            Self::Timeout(e.status())
310        } else if e.is_builder() {
311            Self::Unsupported(ErrorKind::BuildRequestClient(e))
312        } else if e.is_body() || e.is_decode() {
313            Self::Unsupported(ErrorKind::ReadResponseBody(e))
314        } else {
315            Self::Error(ErrorKind::NetworkRequest(e))
316        }
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use crate::{CacheStatus, ErrorKind, Status, types::redirect_history::Redirects};
323    use http::StatusCode;
324
325    #[test]
326    fn test_status_serialization() {
327        let status_ok = Status::Ok(StatusCode::from_u16(200).unwrap());
328        let serialized_with_code = serde_json::to_string(&status_ok).unwrap();
329        assert_eq!("{\"text\":\"200 OK\",\"code\":200}", serialized_with_code);
330
331        let status_error = Status::Error(ErrorKind::EmptyUrl);
332        let serialized_with_error = serde_json::to_string(&status_error).unwrap();
333        assert_eq!(
334            "{\"text\":\"URL cannot be empty\",\"details\":\"Empty URL found. Check for missing links or malformed markdown\"}",
335            serialized_with_error
336        );
337
338        let status_timeout = Status::Timeout(None);
339        let serialized_without_code = serde_json::to_string(&status_timeout).unwrap();
340        assert_eq!("{\"text\":\"Timeout\"}", serialized_without_code);
341    }
342
343    #[test]
344    fn test_get_status_code() {
345        assert_eq!(
346            Status::Ok(StatusCode::from_u16(200).unwrap())
347                .code()
348                .unwrap(),
349            200
350        );
351        assert_eq!(
352            Status::Timeout(Some(StatusCode::from_u16(408).unwrap()))
353                .code()
354                .unwrap(),
355            408
356        );
357        assert_eq!(
358            Status::UnknownStatusCode(StatusCode::from_u16(999).unwrap())
359                .code()
360                .unwrap(),
361            999
362        );
363        assert_eq!(
364            Status::Redirected(StatusCode::from_u16(300).unwrap(), Redirects::none())
365                .code()
366                .unwrap(),
367            300
368        );
369        assert_eq!(Status::Cached(CacheStatus::Ok(200)).code().unwrap(), 200);
370        assert_eq!(
371            Status::Cached(CacheStatus::Error(Some(404)))
372                .code()
373                .unwrap(),
374            404
375        );
376        assert_eq!(Status::Timeout(None).code(), None);
377        assert_eq!(Status::Cached(CacheStatus::Error(None)).code(), None);
378        assert_eq!(Status::Excluded.code(), None);
379        assert_eq!(
380            Status::Unsupported(ErrorKind::InvalidStatusCode(999)).code(),
381            None
382        );
383    }
384
385    #[test]
386    fn test_status_unknown() {
387        assert!(Status::UnknownStatusCode(StatusCode::from_u16(999).unwrap()).is_unknown());
388        assert!(!Status::Ok(StatusCode::from_u16(200).unwrap()).is_unknown());
389    }
390}