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 Excluded,
39 Unsupported(ErrorKind),
43 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 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 #[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 #[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 pub const fn is_success(&self) -> bool {
172 matches!(self, Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)))
173 }
174
175 #[inline]
176 #[must_use]
177 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 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 pub const fn is_timeout(&self) -> bool {
202 matches!(self, Status::Timeout(_))
203 }
204
205 #[inline]
206 #[must_use]
207 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 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 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 #[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 #[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}