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