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