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}"; const ICON_UNKNOWN: &str = "?";
16const ICON_ERROR: &str = "✗";
17const ICON_TIMEOUT: &str = "⧖";
18const ICON_CACHED: &str = "↻";
19
20#[allow(variant_size_differences)]
22#[derive(Debug, Hash, PartialEq, Eq)]
23pub enum Status {
24 Ok(StatusCode),
26 Error(ErrorKind),
28 Timeout(Option<StatusCode>),
30 Redirected(StatusCode, Redirects),
32 UnknownStatusCode(StatusCode),
34 Excluded,
36 Unsupported(ErrorKind),
40 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 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 #[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 #[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 pub const fn is_success(&self) -> bool {
168 matches!(self, Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)))
169 }
170
171 #[inline]
172 #[must_use]
173 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 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 pub const fn is_timeout(&self) -> bool {
195 matches!(self, Status::Timeout(_))
196 }
197
198 #[inline]
199 #[must_use]
200 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 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 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 #[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 #[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}