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}"; const ICON_UNKNOWN: &str = "?";
17const ICON_ERROR: &str = "✗";
18const ICON_TIMEOUT: &str = "⧖";
19const ICON_CACHED: &str = "↻";
20
21#[allow(variant_size_differences)]
23#[derive(Debug, Hash, PartialEq, Eq)]
24pub enum Status {
25 Ok(StatusCode),
27 Error(ErrorKind),
29 RequestError(RequestError),
31 Timeout(Option<StatusCode>),
33 Redirected(StatusCode, Redirects),
35 UnknownStatusCode(StatusCode),
37 UnknownMailStatus(String),
41 Excluded,
43 Unsupported(ErrorKind),
47 Cached(CacheStatus),
49}
50
51impl Display for Status {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 Status::Ok(code) => write!(f, "{code}"),
55 Status::Redirected(_, _) => write!(f, "Redirect"),
56 Status::UnknownStatusCode(code) => write!(f, "Unknown status ({code})"),
57 Status::UnknownMailStatus(_) => write!(f, "Unknown mail status"),
58 Status::Timeout(Some(code)) => write!(f, "Timeout ({code})"),
59 Status::Timeout(None) => f.write_str("Timeout"),
60 Status::Unsupported(e) => write!(f, "Unsupported: {e}"),
61 Status::Error(e) => write!(f, "{e}"),
62 Status::RequestError(e) => write!(f, "{e}"),
63 Status::Cached(status) => write!(f, "{status}"),
64 Status::Excluded => Ok(()),
65 }
66 }
67}
68
69impl Serialize for Status {
70 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
71 where
72 S: Serializer,
73 {
74 let mut s;
75
76 if let Some(code) = self.code() {
77 s = serializer.serialize_struct("Status", 2)?;
78 s.serialize_field("text", &self.to_string())?;
79 s.serialize_field("code", &code.as_u16())?;
80 } else if let Some(details) = self.details() {
81 s = serializer.serialize_struct("Status", 2)?;
82 s.serialize_field("text", &self.to_string())?;
83 s.serialize_field("details", &details)?;
84 } else {
85 s = serializer.serialize_struct("Status", 1)?;
86 s.serialize_field("text", &self.to_string())?;
87 }
88
89 if let Status::Redirected(_, redirects) = self {
90 s.serialize_field("redirects", redirects)?;
91 }
92
93 s.end()
94 }
95}
96
97impl Status {
98 #[must_use]
99 pub(crate) fn new(response: &CacheableResponse, accepted: &HashSet<StatusCode>) -> Self {
101 let status = response.status;
102 if accepted.contains(&status) {
103 Self::Ok(status)
104 } else {
105 Self::Error(ErrorKind::RejectedStatusCode(status))
106 }
107 }
108
109 #[must_use]
119 pub fn from_cache_status(s: CacheStatus, accepted: &HashSet<StatusCode>) -> Self {
120 match s {
121 CacheStatus::Ok(code) => {
122 if matches!(s, CacheStatus::Ok(_)) || accepted.contains(&code) {
123 return Self::Cached(CacheStatus::Ok(code));
124 }
125 Self::Cached(CacheStatus::Error(Some(code)))
126 }
127 CacheStatus::Error(code) => {
128 if let Some(code) = code
129 && accepted.contains(&code)
130 {
131 return Self::Cached(CacheStatus::Ok(code));
132 }
133 Self::Cached(CacheStatus::Error(code))
134 }
135 _ => Self::Cached(s),
136 }
137 }
138
139 #[must_use]
147 #[allow(clippy::match_same_arms)]
148 pub fn details(&self) -> Option<String> {
149 match &self {
150 Status::Ok(code) => code.canonical_reason().map(String::from),
151 Status::Redirected(code, redirects) => {
152 let count = redirects.count();
153 let noun = if count == 1 { "redirect" } else { "redirects" };
154
155 let result = code
156 .canonical_reason()
157 .map(String::from)
158 .unwrap_or(code.as_str().to_owned());
159 Some(format!(
160 "Followed {count} {noun} resolving to the final status of: {result}. Redirects: {redirects}"
161 ))
162 }
163 Status::Error(e) => e.details(),
164 Status::RequestError(e) => e.error().details(),
165 Status::Timeout(_) => None,
166 Status::UnknownStatusCode(_) => None,
167 Status::UnknownMailStatus(reason) => Some(reason.clone()),
168 Status::Unsupported(_) => None,
169 Status::Cached(_) => None,
170 Status::Excluded => None,
171 }
172 }
173
174 #[inline]
175 #[must_use]
176 pub const fn is_success(&self) -> bool {
178 matches!(self, Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)))
179 }
180
181 #[inline]
182 #[must_use]
183 pub const fn is_error(&self) -> bool {
185 matches!(
186 self,
187 Status::Error(_)
188 | Status::RequestError(_)
189 | Status::Cached(CacheStatus::Error(_))
190 | Status::Timeout(_)
191 )
192 }
193
194 #[inline]
195 #[must_use]
196 pub const fn is_excluded(&self) -> bool {
198 matches!(
199 self,
200 Status::Excluded | Status::Cached(CacheStatus::Excluded)
201 )
202 }
203
204 #[inline]
205 #[must_use]
206 pub const fn is_timeout(&self) -> bool {
208 matches!(self, Status::Timeout(_))
209 }
210
211 #[inline]
212 #[must_use]
213 pub const fn is_unsupported(&self) -> bool {
215 matches!(
216 self,
217 Status::Unsupported(_) | Status::Cached(CacheStatus::Unsupported)
218 )
219 }
220
221 #[must_use]
222 pub const fn icon(&self) -> &str {
224 match self {
225 Status::Ok(_) => ICON_OK,
226 Status::Redirected(_, _) => ICON_REDIRECTED,
227 Status::UnknownStatusCode(_) | Status::UnknownMailStatus(_) => ICON_UNKNOWN,
228 Status::Excluded => ICON_EXCLUDED,
229 Status::Error(_) | Status::RequestError(_) => ICON_ERROR,
230 Status::Timeout(_) => ICON_TIMEOUT,
231 Status::Unsupported(_) => ICON_UNSUPPORTED,
232 Status::Cached(_) => ICON_CACHED,
233 }
234 }
235
236 #[must_use]
237 pub fn code(&self) -> Option<StatusCode> {
239 match self {
240 Status::Ok(code)
241 | Status::Redirected(code, _)
242 | Status::UnknownStatusCode(code)
243 | Status::Timeout(Some(code))
244 | Status::Cached(CacheStatus::Ok(code) | CacheStatus::Error(Some(code))) => Some(*code),
245 Status::Error(kind) | Status::Unsupported(kind) => match kind {
246 ErrorKind::RejectedStatusCode(status_code) => Some(*status_code),
247 _ => match kind.reqwest_error() {
248 Some(error) => error.status(),
249 None => None,
250 },
251 },
252 _ => None,
253 }
254 }
255
256 #[must_use]
258 pub fn code_as_string(&self) -> String {
259 match self {
260 Status::Ok(code) | Status::Redirected(code, _) | Status::UnknownStatusCode(code) => {
261 code.as_u16().to_string()
262 }
263 Status::UnknownMailStatus(_) => "UNKNOWN".to_string(),
264 Status::Excluded => "EXCLUDED".to_string(),
265 Status::Error(e) => match e {
266 ErrorKind::RejectedStatusCode(code) => code.as_u16().to_string(),
267 ErrorKind::ReadResponseBody(e) | ErrorKind::BuildRequestClient(e) => {
268 match e.status() {
269 Some(code) => code.as_u16().to_string(),
270 None => "ERROR".to_string(),
271 }
272 }
273 _ => "ERROR".to_string(),
274 },
275 Status::RequestError(_) => "ERROR".to_string(),
276 Status::Timeout(code) => match code {
277 Some(code) => code.as_u16().to_string(),
278 None => "TIMEOUT".to_string(),
279 },
280 Status::Unsupported(_) => "IGNORED".to_string(),
281 Status::Cached(cache_status) => match cache_status {
282 CacheStatus::Ok(code) => code.as_u16().to_string(),
283 CacheStatus::Error(code) => match code {
284 Some(code) => code.as_u16().to_string(),
285 None => "ERROR".to_string(),
286 },
287 CacheStatus::Excluded => "EXCLUDED".to_string(),
288 CacheStatus::Unsupported => "IGNORED".to_string(),
289 },
290 }
291 }
292
293 #[must_use]
299 pub const fn is_unknown(&self) -> bool {
300 matches!(self, Status::UnknownStatusCode(_))
301 }
302}
303
304impl From<ErrorKind> for Status {
305 fn from(e: ErrorKind) -> Self {
306 match e {
307 ErrorKind::InvalidUrlHost => Status::Unsupported(ErrorKind::InvalidUrlHost),
308 ErrorKind::NetworkRequest(e)
309 | ErrorKind::ReadResponseBody(e)
310 | ErrorKind::BuildRequestClient(e) => {
311 if e.is_timeout() {
312 Self::Timeout(e.status())
313 } else if e.is_builder() {
314 Self::Unsupported(ErrorKind::BuildRequestClient(e))
315 } else if e.is_body() || e.is_decode() {
316 Self::Unsupported(ErrorKind::ReadResponseBody(e))
317 } else {
318 Self::Error(ErrorKind::NetworkRequest(e))
319 }
320 }
321 e => Self::Error(e),
322 }
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use crate::{CacheStatus, ErrorKind, Status, types::redirect_history::Redirects};
329 use http::StatusCode;
330
331 #[test]
332 fn test_status_serialization() {
333 let status_ok = Status::Ok(StatusCode::from_u16(200).unwrap());
334 let serialized_with_code = serde_json::to_string(&status_ok).unwrap();
335 assert_eq!("{\"text\":\"200 OK\",\"code\":200}", serialized_with_code);
336
337 let status_error = Status::Error(ErrorKind::EmptyUrl);
338 let serialized_with_error = serde_json::to_string(&status_error).unwrap();
339 assert_eq!(
340 "{\"text\":\"URL cannot be empty\",\"details\":\"Empty URL found. Check for missing links or malformed markdown\"}",
341 serialized_with_error
342 );
343
344 let status_timeout = Status::Timeout(None);
345 let serialized_without_code = serde_json::to_string(&status_timeout).unwrap();
346 assert_eq!("{\"text\":\"Timeout\"}", serialized_without_code);
347 }
348
349 #[test]
350 fn test_get_status_code() {
351 assert_eq!(
352 Status::Ok(StatusCode::from_u16(200).unwrap())
353 .code()
354 .unwrap(),
355 200
356 );
357 assert_eq!(
358 Status::Timeout(Some(StatusCode::from_u16(408).unwrap()))
359 .code()
360 .unwrap(),
361 408
362 );
363 assert_eq!(
364 Status::UnknownStatusCode(StatusCode::from_u16(999).unwrap())
365 .code()
366 .unwrap(),
367 999
368 );
369 assert_eq!(
370 Status::Redirected(
371 StatusCode::from_u16(300).unwrap(),
372 Redirects::new("http://example.com".try_into().unwrap())
373 )
374 .code()
375 .unwrap(),
376 300
377 );
378 assert_eq!(
379 Status::Cached(CacheStatus::Ok(StatusCode::OK))
380 .code()
381 .unwrap(),
382 200
383 );
384 assert_eq!(
385 Status::Cached(CacheStatus::Error(Some(StatusCode::NOT_FOUND)))
386 .code()
387 .unwrap(),
388 404
389 );
390 assert_eq!(Status::Timeout(None).code(), None);
391 assert_eq!(Status::Cached(CacheStatus::Error(None)).code(), None);
392 assert_eq!(Status::Excluded.code(), None);
393 assert_eq!(
394 Status::Unsupported(ErrorKind::InvalidStatusCode(999)).code(),
395 None
396 );
397 }
398
399 #[test]
400 fn test_status_unknown() {
401 assert!(Status::UnknownStatusCode(StatusCode::from_u16(999).unwrap()).is_unknown());
402 assert!(!Status::Ok(StatusCode::from_u16(200).unwrap()).is_unknown());
403 }
404}