Skip to main content

mlb_api/
types.rs

1//! Shared types across multiple requests
2
3#![allow(clippy::redundant_pub_crate, reason = "re-exported as pub lol")]
4
5use chrono::{Datelike, Local, NaiveDate, NaiveDateTime, NaiveTime, DateTime, Utc, TimeDelta, Timelike};
6use derive_more::{Display, FromStr};
7use serde::de::Error;
8use serde::{Deserialize, Deserializer};
9use std::fmt::{Debug, Display, Formatter};
10use std::num::{ParseFloatError, ParseIntError};
11use std::ops::{Add, RangeInclusive};
12use std::str::FromStr;
13use std::ops::Not;
14use thiserror::Error;
15use crate::season::SeasonId;
16
17/// The copyright at the top of every request
18#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
19#[serde(from = "__CopyrightStruct")]
20pub enum Copyright {
21	/// Typical copyright format
22	Typical {
23		/// Year of the copyright, typically the current year.
24		year: u32,
25	},
26	/// Unknown copyright format
27	UnknownSpec(Box<str>),
28}
29
30#[derive(Deserialize)]
31#[doc(hidden)]
32struct __CopyrightStruct(String);
33
34impl From<__CopyrightStruct> for Copyright {
35	fn from(value: __CopyrightStruct) -> Self {
36		let __CopyrightStruct(value) = value;
37		if let Some(value) = value.strip_prefix("Copyright ") && let Some(value) = value.strip_suffix(" MLB Advanced Media, L.P.  Use of any content on this page acknowledges agreement to the terms posted here http://gdx.mlb.com/components/copyright.txt") && let Ok(year) = value.parse::<u32>() {
38			Self::Typical { year }
39		} else {
40			Self::UnknownSpec(value.into_boxed_str())
41		}
42	}
43}
44
45impl Display for Copyright {
46	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
47		match self {
48			Self::Typical { year } => write!(f, "Copyright {year} MLB Advanced Media, L.P.  Use of any content on this page acknowledges agreement to the terms posted here http://gdx.mlb.com/components/copyright.txt"),
49			Self::UnknownSpec(copyright) => write!(f, "{copyright}"),
50		}
51	}
52}
53
54impl Default for Copyright {
55	#[allow(clippy::cast_sign_loss, reason = "jesus is not alive")]
56	fn default() -> Self {
57		Self::Typical { year: Local::now().year() as _ }
58	}
59}
60
61/// Try to deserialize a type using its [`FromStr`] implementation, fallback to `None` if it doesn't work.
62/// # Errors
63/// If a string cannot be parsed from the deserializer.
64pub fn try_from_str<'de, D: Deserializer<'de>, T: FromStr>(deserializer: D) -> Result<Option<T>, D::Error> {
65	Ok(String::deserialize(deserializer)?.parse::<T>().ok())
66}
67
68/// Deserializes a type using its [`FromStr`] implementation.
69///
70/// # Errors
71/// 1. If a string cannot be parsed from the deserializer.
72/// 2. If the type cannot be parsed from the string.
73pub fn from_str<'de, D: Deserializer<'de>, T: FromStr>(deserializer: D) -> Result<T, D::Error>
74where
75	<T as FromStr>::Err: Debug,
76{
77	String::deserialize(deserializer)?.parse::<T>().map_err(|e| Error::custom(format!("{e:?}")))
78}
79
80/// Deserializes a `"Y"` or `"N"` into a `bool`
81///
82/// # Errors
83/// If the type cannot be parsed into a Y or N string
84pub fn from_yes_no<'de, D: Deserializer<'de>>(deserializer: D) -> Result<bool, D::Error> {
85	#[derive(Deserialize)]
86	#[repr(u8)]
87	enum Boolean {
88		#[serde(rename = "Y")]
89		Yes = 1,
90		#[serde(rename = "N")]
91		No = 0,
92	}
93
94	Ok(match Boolean::deserialize(deserializer)? {
95		Boolean::Yes => true,
96		Boolean::No => false,
97	})
98}
99
100/// Measurement of a person's height
101///
102/// Not using [`uom`] because we want feet and inches, not just one of the measurements.
103#[derive(Debug, PartialEq, Eq, Copy, Clone)]
104pub enum HeightMeasurement {
105	/// `{a: u8}' {b: u8}"`
106	FeetAndInches { feet: u8, inches: u8 },
107	/// '{x: u16} cm'
108	Centimeters { cm: u16 },
109}
110
111impl FromStr for HeightMeasurement {
112	type Err = HeightMeasurementParseError;
113
114	/// Spec
115	/// 1. `{x: u16} cm`
116	/// 2. `{a: u8}' {b: u8}"`
117	fn from_str(s: &str) -> Result<Self, Self::Err> {
118		if let Some((feet, Some((inches, "")))) = s.split_once("' ").map(|(feet, rest)| (feet, rest.split_once('"'))) {
119			let feet = feet.parse::<u8>()?;
120			let inches = inches.parse::<u8>()?;
121			Ok(Self::FeetAndInches { feet, inches })
122		} else if let Some((cm, "")) = s.split_once("cm") {
123			let cm = cm.parse::<u16>()?;
124			Ok(Self::Centimeters { cm })
125		} else {
126			Err(HeightMeasurementParseError::UnknownSpec(s.to_owned()))
127		}
128	}
129}
130
131impl<'de> Deserialize<'de> for HeightMeasurement {
132	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
133	where
134		D: Deserializer<'de>
135	{
136		String::deserialize(deserializer)?.parse().map_err(D::Error::custom)
137	}
138}
139
140/// Error for [`<HeightMeasurement as FromStr>::from_str`]
141#[derive(Debug, Error)]
142pub enum HeightMeasurementParseError {
143	/// Failed to parse an integer in the height measurement
144	#[error(transparent)]
145	ParseIntError(#[from] ParseIntError),
146	/// Was neither `{a}' {b}"` or `{x} cm`
147	#[error("Unknown height '{0}'")]
148	UnknownSpec(String),
149}
150
151/// General filter for players in requests
152#[derive(Debug, Display, PartialEq, Eq, Copy, Clone, Default)]
153pub enum PlayerPool {
154	/// All players (no filter)
155	#[default]
156	#[display("ALL")]
157	All,
158	/// Qualified PAs or IP for a season, can be checked manually via [`QualificationMultipliers`](crate::season::QualificationMultipliers)
159	#[display("QUALIFIED")]
160	Qualified,
161	/// Rookie season
162	#[display("ROOKIES")]
163	Rookies,
164	/// Qualified && Rookie
165	#[display("QUALIFIED_ROOKIES")]
166	QualifiedAndRookies,
167	/// ?
168	#[display("ORGANIZATION")]
169	Organization,
170	/// ?
171	#[display("ORGANIZATION_NO_MLB")]
172	OrganizationNotMlb,
173	/// Active Player (?)
174	#[display("CURRENT")]
175	Current,
176	/// ?
177	#[display("ALL_CURRENT")]
178	AllCurrent,
179	/// Qualified && Current
180	#[display("QUALIFIED_CURRENT")]
181	QualifiedAndCurrent,
182}
183
184/// Gender
185///
186/// Used on [`Ballplayer`](crate::person::Ballplayer)
187#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Default)]
188pub enum Gender {
189	#[serde(rename = "M")]
190	Male,
191	#[serde(rename = "F")]
192	Female,
193	#[default]
194	#[serde(other)]
195	Other,
196}
197
198/// Handedness
199///
200/// Either for batting or pitching
201#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Display)]
202#[serde(try_from = "__HandednessStruct")]
203#[display("{}", self.into_char())]
204pub enum Handedness {
205	Left,
206	Right,
207	Switch,
208}
209
210impl Handedness {
211	#[must_use]
212	pub const fn into_char(self) -> char {
213		match self {
214			Self::Left => 'L',
215			Self::Right => 'R',
216			Self::Switch => 'S',
217		}
218	}
219}
220
221#[derive(Deserialize)]
222#[doc(hidden)]
223struct __HandednessStruct {
224	code: String,
225}
226
227/// Error for handedness parsing
228#[derive(Debug, Error)]
229pub enum HandednessParseError {
230	/// Did not match any of the known handedness variants
231	#[error("Invalid handedness '{0}'")]
232	InvalidHandedness(String),
233}
234
235impl TryFrom<__HandednessStruct> for Handedness {
236	type Error = HandednessParseError;
237
238	fn try_from(value: __HandednessStruct) -> Result<Self, Self::Error> {
239		Ok(match &*value.code {
240			"L" => Self::Left,
241			"R" => Self::Right,
242			"S" => Self::Switch,
243			_ => return Err(HandednessParseError::InvalidHandedness(value.code)),
244		})
245	}
246}
247
248/// Represents a range from one date to another (inclusive on both ends)
249///
250/// # Examples
251/// ```
252/// let range: NaiveDateRange = NaiveDate::from_ymd(1, 1, 2025)..=NaiveDate::from_ymd(12, 31, 2025);
253/// ```
254pub type NaiveDateRange = RangeInclusive<NaiveDate>;
255
256pub(crate) const MLB_API_DATE_FORMAT: &str = "%m/%d/%Y";
257
258/// # Errors
259/// 1. If a string cannot be deserialized
260/// 2. If the data does not appear in the format `%Y-%m-%dT%H:%M:%SZ(%#z)?`. Why the MLB removes the +00:00 or -00:00 sometimes? I have no clue.
261pub(crate) fn deserialize_datetime<'de, D: Deserializer<'de>>(deserializer: D) -> Result<DateTime<Utc>, D::Error> {
262	let string = String::deserialize(deserializer)?;
263	if let Ok(parsed) = NaiveDateTime::from_str(&string) {
264		return Ok(parsed.and_utc())
265	}
266	let fmt = match (string.ends_with('Z'), string.contains('.')) {
267		(false, false) => "%FT%TZ%#z",
268		(false, true) => "%FT%TZ%.3f%#z",
269		(true, false) => "%FT%TZ",
270		(true, true) => "%FT%T%.3fZ",
271	};
272	NaiveDateTime::parse_from_str(&string, fmt).map(|x| x.and_utc()).map_err(D::Error::custom)
273}
274
275/// # Errors
276/// Never.
277#[allow(clippy::unnecessary_wraps, reason = "serde return type")]
278pub(crate) fn try_deserialize_datetime<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error> {
279	Ok(deserialize_datetime(deserializer).ok())
280}
281
282/// # Errors
283/// 1. If a string cannot be deserialized
284/// 2. If the data does not appear in the format of `/(?:<t parser here>,)*<t parser here>?/g`
285pub(crate) fn deserialize_comma_separated_vec<'de, D: Deserializer<'de>, T: FromStr>(deserializer: D) -> Result<Vec<T>, D::Error>
286where
287	<T as FromStr>::Err: Debug,
288{
289	String::deserialize(deserializer)?
290		.split(", ")
291		.map(|entry| T::from_str(entry))
292		.collect::<Result<Vec<T>, <T as FromStr>::Err>>()
293		.map_err(|e| Error::custom(format!("{e:?}")))
294}
295
296#[derive(Debug, PartialEq, Eq, Copy, Clone, Default)]
297pub enum TeamSide {
298	#[default]
299	Home,
300	Away,
301}
302
303impl Not for TeamSide {
304	type Output = Self;
305
306	fn not(self) -> Self::Output {
307		match self {
308			Self::Home => Self::Away,
309			Self::Away => Self::Home,
310		}
311	}
312}
313
314impl TeamSide {
315	#[must_use]
316	pub const fn is_home(self) -> bool {
317		matches!(self, Self::Home)
318	}
319
320	#[must_use]
321	pub const fn is_away(self) -> bool {
322		matches!(self, Self::Away)
323	}
324}
325
326pub fn deserialize_team_side_from_is_home<'de, D: Deserializer<'de>>(deserializer: D) -> Result<TeamSide, D::Error> {
327	Ok(if bool::deserialize(deserializer)? { TeamSide::Home } else { TeamSide::Away })
328}
329
330/// General type that represents two fields where one is home and one is away
331///
332/// Examples:
333/// ```json
334/// {
335///     "home": { "name": "New York Yankees", "id": ... },
336///     "away": { "name": "Boston Red Sox", "id": ... }
337/// }
338/// ```
339#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Default)]
340pub struct HomeAway<T> {
341	pub home: T,
342	pub away: T,
343}
344
345impl<T> HomeAway<T> {
346	/// Constructs a new [`HomeAwaySplit`]
347	#[must_use]
348	pub const fn new(home: T, away: T) -> Self {
349		Self { home, away }
350	}
351
352	/// Choose the value depending on the [`TeamSide`]
353	#[must_use]
354	pub fn choose(self, side: TeamSide) -> T {
355		match side {
356			TeamSide::Home => self.home,
357			TeamSide::Away => self.away,
358		}
359	}
360
361	#[must_use]
362	pub const fn as_ref(&self) -> HomeAway<&T> {
363		HomeAway {
364			home: &self.home,
365			away: &self.away,
366		}
367	}
368
369	#[must_use]
370	pub const fn as_mut(&mut self) -> HomeAway<&mut T> {
371		HomeAway {
372			home: &mut self.home,
373			away: &mut self.away,
374		}
375	}
376
377	#[must_use]
378	pub fn map<U, F: FnMut(T) -> U>(self, mut f: F) -> HomeAway<U> {
379		HomeAway {
380			home: f(self.home),
381			away: f(self.away),
382		}
383	}
384
385	/// Switches the home and away sides
386	#[must_use]
387	pub fn swap(self) -> Self {
388		Self {
389			home: self.away,
390			away: self.home,
391		}
392	}
393
394	#[must_use]
395	pub fn zip<U>(self, other: HomeAway<U>) -> HomeAway<(T, U)> {
396		HomeAway {
397			home: (self.home, other.home),
398			away: (self.away, other.away),
399		}
400	}
401	
402	#[must_use]
403	pub fn zip_with<U, V, F: FnMut(T, U) -> V>(self, other: HomeAway<U>, mut f: F) -> HomeAway<V> {
404		HomeAway {
405			home: f(self.home, other.home),
406			away: f(self.away, other.away),
407		}
408	}
409
410	#[must_use]
411	pub fn combine<U, F: FnOnce(T, T) -> U>(self, f: F) -> U {
412		f(self.home, self.away)
413	}
414
415	/// Adds home and away values (typically used in stats)
416	#[must_use]
417	pub fn added(self) -> <T as Add>::Output where T: Add {
418		self.home + self.away
419	}
420
421	#[must_use]
422	pub fn both(self, mut f: impl FnMut(T) -> bool) -> bool {
423		f(self.home) && f(self.away)
424	}
425
426	#[must_use]
427	pub fn either(self, mut f: impl FnMut(T) -> bool) -> bool {
428		f(self.home) || f(self.away)
429	}
430}
431
432impl<T> HomeAway<Option<T>> {
433	#[must_use]
434	pub fn flatten(self) -> Option<HomeAway<T>> {
435		Some(HomeAway {
436			home: self.home?,
437			away: self.away?,
438		})
439	}
440}
441
442impl<T> From<(T, T)> for HomeAway<T> {
443	fn from((home, away): (T, T)) -> Self {
444		Self {
445			home,
446			away
447		}
448	}
449}
450
451/// Street address, city, etc.
452///
453/// Pretty much nothing *has* to be supplied so you either get an address, phone number, everything, or just a country.
454#[derive(Debug, Deserialize, PartialEq, Clone, Default)]
455#[serde(rename_all = "camelCase")]
456pub struct Location {
457	pub address_line_1: Option<String>,
458	pub address_line_2: Option<String>,
459	pub address_line_3: Option<String>,
460	pub address_line_4: Option<String>,
461	pub attention: Option<String>,
462	#[serde(alias = "phone")]
463	pub phone_number: Option<String>,
464	pub city: Option<String>,
465	#[serde(alias = "province")]
466	pub state: Option<String>,
467	pub country: Option<String>,
468	#[serde(rename = "stateAbbrev")] pub state_abbreviation: Option<String>,
469	pub postal_code: Option<String>,
470	pub latitude: Option<f64>,
471	pub longitude: Option<f64>,
472	pub azimuth_angle: Option<f64>,
473	pub elevation: Option<u32>,
474}
475
476/// Various information regarding a field.
477#[derive(Debug, Deserialize, PartialEq, Clone)]
478#[serde(rename_all = "camelCase")]
479pub struct FieldInfo {
480	pub capacity: u32,
481	pub turf_type: TurfType,
482	pub roof_type: RoofType,
483	pub left_line: Option<u32>,
484	pub left: Option<u32>,
485	pub left_center: Option<u32>,
486	pub center: Option<u32>,
487	pub right_center: Option<u32>,
488	pub right: Option<u32>,
489	pub right_line: Option<u32>,
490}
491
492/// Different types of turf.
493#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Display)]
494pub enum TurfType {
495	#[serde(rename = "Artificial Turf")]
496	#[display("Artificial Turf")]
497	ArtificialTurf,
498
499	#[serde(rename = "Grass")]
500	#[display("Grass")]
501	Grass,
502}
503
504/// Different types of roof setups.
505#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Display)]
506pub enum RoofType {
507	#[serde(rename = "Retractable")]
508	#[display("Retractable")]
509	Retractable,
510
511	#[serde(rename = "Open")]
512	#[display("Open")]
513	Open,
514
515	#[serde(rename = "Dome")]
516	#[display("Dome")]
517	Dome,
518}
519
520/// Data regarding a timezone, uses [`chrono_tz`].
521#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
522#[serde(rename_all = "camelCase")]
523pub struct TimeZoneData {
524	#[serde(rename = "id")]
525	pub timezone: chrono_tz::Tz,
526	pub offset: i32,
527	pub offset_at_game_time: i32,
528}
529
530/// More generalized than social media, includes retrosheet, fangraphs, (+ some socials), etc.
531#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
532pub struct ExternalReference {
533	#[serde(rename = "xrefId")]
534	pub id: String,
535	#[serde(rename = "xrefType")]
536	pub xref_type: String,
537	pub season: Option<SeasonId>,
538}
539
540/// Tracking equipment, Hawk-Eye, `PitchFx`, etc.
541#[derive(Debug, Deserialize, PartialEq, Clone)]
542#[serde(rename_all = "camelCase")]
543pub struct TrackingSystem {
544	pub id: TrackingSystemVendorId,
545	pub description: String,
546	pub pitch_vendor: Option<TrackingSystemVendor>,
547	pub hit_vendor: Option<TrackingSystemVendor>,
548	pub player_vendor: Option<TrackingSystemVendor>,
549	pub skeletal_vendor: Option<TrackingSystemVendor>,
550	pub bat_vendor: Option<TrackingSystemVendor>,
551	pub biomechanics_vendor: Option<TrackingSystemVendor>,
552}
553
554id!(TrackingSystemVendorId { id: u32 });
555
556/// A vendor for specific tracking concepts, such as Hawk-Eye for skeletal data.
557#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
558pub struct TrackingSystemVendor {
559	pub id: TrackingSystemVendorId,
560	pub description: String,
561	#[serde(rename = "version")]
562	pub details: String,
563}
564
565/// Stat that is either an integer or float.
566///
567/// Used in [`StatLeader`](crate::stats::leaders::StatLeader)
568#[derive(Debug, Copy, Clone)]
569pub enum IntegerOrFloatStat {
570	/// [`integer`](i64) stat, likely counting
571	Integer(i64),
572	/// [`float`](f64) stat, likely rate
573	Float(f64),
574}
575
576impl PartialEq for IntegerOrFloatStat {
577	fn eq(&self, other: &Self) -> bool {
578		match (*self, *other) {
579			(Self::Integer(lhs), Self::Integer(rhs)) => lhs == rhs,
580			(Self::Float(lhs), Self::Float(rhs)) => lhs == rhs,
581
582			#[allow(clippy::cast_precision_loss, reason = "we checked if it's perfectly representable")]
583			#[allow(clippy::cast_possible_truncation, reason = "we checked if it's perfectly representable")]
584			(Self::Integer(int), Self::Float(float)) | (Self::Float(float), Self::Integer(int)) => {
585				// fast way to check if the float is representable perfectly as an integer and if it's within range of `i64`
586				// we inline the f64 casts of i64::MIN and i64::MAX, and change the upper bound to be < as i64::MAX is not perfectly representable.
587				if float.is_normal() && float.floor() == float && (i64::MIN as f64..-(i64::MIN as f64)).contains(&float) {
588					float as i64 == int
589				} else {
590					false
591				}
592			},
593		}
594	}
595}
596
597impl<'de> Deserialize<'de> for IntegerOrFloatStat {
598	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
599	where
600		D: Deserializer<'de>
601	{
602		struct Visitor;
603
604		impl serde::de::Visitor<'_> for Visitor {
605			type Value = IntegerOrFloatStat;
606
607			fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
608				formatter.write_str("integer, float, or string that can be parsed to either")
609			}
610
611			fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
612			where
613				E: Error,
614			{
615				Ok(IntegerOrFloatStat::Integer(v))
616			}
617
618			fn visit_f64<E>(self, v: f64) -> Result<Self::Value, E>
619			where
620				E: Error,
621			{
622				Ok(IntegerOrFloatStat::Float(v))
623			}
624
625			fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
626			where
627				E: Error,
628			{
629				if v == "-.--" || v == ".---" {
630					Ok(IntegerOrFloatStat::Float(0.0))
631				} else if let Ok(i) = v.parse::<i64>() {
632					Ok(IntegerOrFloatStat::Integer(i))
633				} else if let Ok(f) = v.parse::<f64>() {
634					Ok(IntegerOrFloatStat::Float(f))
635				} else {
636					Err(E::invalid_value(serde::de::Unexpected::Str(v), &self))
637				}
638			}
639		}
640
641		deserializer.deserialize_any(Visitor)
642	}
643}
644
645/// Represents an error parsing an HTTP request
646///
647/// Not a reqwest error, this typically happens from a bad payload like asking for games at a date in BCE.
648#[derive(Debug, Deserialize, Display)]
649#[display("An error occurred parsing the statsapi http request: {message}")]
650pub struct MLBError {
651	pub(crate) message: String,
652}
653
654impl MLBError {
655	pub fn new(message: impl Into<String>) -> Self {
656		Self { message: message.into() }
657	}
658}
659
660impl std::error::Error for MLBError {}
661
662/// `rgba({red}, {green}, {blue})` into a type
663#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Default)]
664#[serde(try_from = "&str")]
665pub struct RGBAColor {
666	pub red: u8,
667	pub green: u8,
668	pub blue: u8,
669	pub alpha: u8,
670}
671
672impl Display for RGBAColor {
673	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
674		write!(f, "0x{:02x}{:02x}{:02x}{:02x}", self.alpha, self.red, self.green, self.blue)
675	}
676}
677
678/// Spec: `rgba({red}, {green}, {blue})`
679#[derive(Debug, Error)]
680pub enum RGBAColorFromStrError {
681	#[error("Invalid spec")]
682	InvalidFormat,
683	#[error(transparent)]
684	InvalidInt(#[from] ParseIntError),
685	#[error(transparent)]
686	InvalidFloat(#[from] ParseFloatError),
687}
688
689impl<'a> TryFrom<&'a str> for RGBAColor {
690	type Error = <Self as FromStr>::Err;
691
692	fn try_from(value: &'a str) -> Result<Self, Self::Error> {
693		<Self as FromStr>::from_str(value)
694	}
695}
696
697impl FromStr for RGBAColor {
698	type Err = RGBAColorFromStrError;
699
700	/// Spec: `rgba({red}, {green}, {blue})`
701	#[allow(clippy::single_char_pattern, reason = "other patterns are strings, the choice to make that one a char does not denote any special case")]
702	#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "intended behaviour with alpha channel")]
703	fn from_str(mut s: &str) -> Result<Self, Self::Err> {
704		s = s.strip_suffix("rgba(").ok_or(Self::Err::InvalidFormat)?;
705		let (red, s) = s.split_once(", ").ok_or(Self::Err::InvalidFormat)?;
706		let red = red.parse::<u8>()?;
707		let (green, s) = s.split_once(", ").ok_or(Self::Err::InvalidFormat)?;
708		let green = green.parse::<u8>()?;
709		let (blue, s) = s.split_once(", ").ok_or(Self::Err::InvalidFormat)?;
710		let blue = blue.parse::<u8>()?;
711		let (alpha, s) = s.split_once(")").ok_or(Self::Err::InvalidFormat)?;
712		let alpha = (alpha.parse::<f32>()? * 255.0).round() as u8;
713		if !s.is_empty() { return Err(Self::Err::InvalidFormat); }
714		Ok(Self {
715			red,
716			green,
717			blue,
718			alpha
719		})
720	}
721}
722
723/// Used in [`HittingHotColdZones`](crate::stats::raw::HittingHotColdZones) and [`PitchingHotColdZones`](crate::stats::raw::PitchingHotColdZones).
724#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Display, FromStr)]
725#[serde(try_from = "&str")]
726pub enum HeatmapTemperature {
727	Hot,
728	Warm,
729	Lukewarm,
730	Cool,
731	Cold,
732}
733
734impl<'a> TryFrom<&'a str> for HeatmapTemperature {
735	type Error = <Self as FromStr>::Err;
736
737	fn try_from(value: &'a str) -> Result<Self, Self::Error> {
738		<Self as FromStr>::from_str(value)
739	}
740}
741
742pub(crate) fn write_nth(n: usize, f: &mut Formatter<'_>) -> std::fmt::Result {
743	write!(f, "{n}")?;
744	let (tens, ones) = (n / 10, n % 10);
745	let is_teen = (tens % 10) == 1;
746	if is_teen {
747		write!(f, "th")?;
748	} else {
749		write!(f, "{}", match ones {
750			1 => "st",
751			2 => "nd",
752			3 => "rd",
753			_ => "th",
754		})?;
755	}
756	Ok(())
757}
758
759/// # Errors
760/// 1. if format is not `"{hours}:{minutes}:{seconds}"`
761pub fn deserialize_time_delta_from_hms<'de, D: Deserializer<'de>>(deserializer: D) -> Result<TimeDelta, D::Error> {
762	let string = String::deserialize(deserializer)?;
763	let (hour, rest) = string.split_once(':').ok_or_else(|| D::Error::custom("Unable to find `:`"))?;
764	let (minute, second) = rest.split_once(':').ok_or_else(|| D::Error::custom("Unable to find `:`"))?;
765	let hour = hour.parse::<u32>().map_err(D::Error::custom)?;
766	let minute = minute.parse::<u32>().map_err(D::Error::custom)?;
767	let second = second.parse::<u32>().map_err(D::Error::custom)?;
768
769	TimeDelta::new(((hour * 24 + minute) * 60 + second) as _, 0).ok_or_else(|| D::Error::custom("Invalid time quantity, overflow."))
770}
771
772/// AM/PM
773#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone, Display, FromStr)]
774#[serde(try_from = "&str")]
775pub enum DayHalf {
776	AM,
777	PM,
778}
779
780impl DayHalf {
781	/// Converts a 12-hour time into it's 24-hour version.
782	#[must_use]
783	pub fn into_24_hour_time(self, mut time: NaiveTime) -> NaiveTime {
784		if (self == Self::PM) ^ (time.hour() == 12) {
785			time += TimeDelta::hours(12);
786		}
787
788		time
789	}
790}
791
792impl<'a> TryFrom<&'a str> for DayHalf {
793	type Error = <Self as FromStr>::Err;
794
795	fn try_from(value: &'a str) -> Result<Self, Self::Error> {
796		<Self as FromStr>::from_str(value)
797	}
798}
799
800#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
801#[serde(rename_all = "camelCase")]
802pub struct ResourceUsage {
803	pub used: u32,
804	pub remaining: u32,
805}
806
807#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Default)]
808#[serde(rename_all = "camelCase")]
809pub struct ResultHoldingResourceUsage {
810	#[serde(rename = "usedSuccessful")]
811	pub used_successfully: u32,
812	pub used_failed: u32,
813	pub remaining: u32,
814}
815
816#[cfg(test)]
817mod tests {
818	use super::*;
819
820	#[test]
821	fn test_ampm() {
822		assert_eq!(NaiveTime::from_hms_opt(0, 0, 0).unwrap(), DayHalf::AM.into_24_hour_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap()));
823		assert_eq!(NaiveTime::from_hms_opt(12, 0, 0).unwrap(), DayHalf::PM.into_24_hour_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap()));
824		assert_eq!(NaiveTime::from_hms_opt(0, 1, 0).unwrap(), DayHalf::AM.into_24_hour_time(NaiveTime::from_hms_opt(12, 1, 0).unwrap()));
825		assert_eq!(NaiveTime::from_hms_opt(12, 1, 0).unwrap(), DayHalf::PM.into_24_hour_time(NaiveTime::from_hms_opt(12, 1, 0).unwrap()));
826		assert_eq!(NaiveTime::from_hms_opt(0, 1, 0).unwrap(), DayHalf::AM.into_24_hour_time(NaiveTime::from_hms_opt(12, 1, 0).unwrap()));
827		assert_eq!(NaiveTime::from_hms_opt(23, 59, 0).unwrap(), DayHalf::PM.into_24_hour_time(NaiveTime::from_hms_opt(11, 59, 0).unwrap()));
828		assert_eq!(NaiveTime::from_hms_opt(1, 1, 0).unwrap(), DayHalf::AM.into_24_hour_time(NaiveTime::from_hms_opt(1, 1, 0).unwrap()));
829	}
830}