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