1use crate::game::{DoubleHeaderKind, GameId};
6use crate::hydrations::Hydrations;
7use crate::league::LeagueId;
8use crate::meta::DayNight;
9use crate::meta::GameStatus;
10use crate::meta::GameType;
11use crate::request::RequestURL;
12use crate::season::SeasonId;
13use crate::sport::SportId;
14use crate::team::NamedTeam;
15use crate::team::TeamId;
16use crate::venue::{NamedVenue, VenueId};
17use crate::{Copyright, HomeAway, MLB_API_DATE_FORMAT, NaiveDateRange};
18use bon::Builder;
19use chrono::{DateTime, NaiveDate, Utc};
20use either::Either;
21use itertools::Itertools;
22use serde::Deserialize;
23use serde::de::DeserializeOwned;
24use serde_with::DefaultOnError;
25use serde_with::serde_as;
26use std::fmt::{Debug, Display, Formatter};
27use std::marker::PhantomData;
28use uuid::Uuid;
29
30pub mod postseason;
31pub mod tied;
32
33#[derive(Debug, Deserialize, PartialEq, Clone)]
34#[serde(rename_all = "camelCase", bound = "H: ScheduleHydrations")]
35pub struct ScheduleResponse<H: ScheduleHydrations> {
36 #[serde(default)] pub copyright: Copyright,
38 pub dates: Vec<ScheduleDate<H>>,
39}
40
41#[derive(Debug, Deserialize, PartialEq, Clone)]
42#[serde(rename_all = "camelCase", bound = "H: ScheduleHydrations")]
43pub struct ScheduleDate<H: ScheduleHydrations> {
44 pub date: NaiveDate,
45 pub games: Vec<ScheduleGame<H>>,
46}
47
48#[allow(clippy::struct_excessive_bools, reason = "false positive")]
49#[derive(Debug, Deserialize, PartialEq, Clone)]
50#[serde(from = "__ScheduleGameStruct<H>", bound = "H: ScheduleHydrations")]
51pub struct ScheduleGame<H: ScheduleHydrations> {
52 pub game_id: GameId,
53 pub game_guid: Uuid,
54 pub game_type: GameType,
55 pub season: SeasonId,
56 pub game_date: DateTime<Utc>,
57 pub official_date: NaiveDate,
59 pub status: GameStatus,
60 pub teams: HomeAway<TeamWithStandings<H>>,
61 pub venue: NamedVenue,
62 pub is_tie: bool,
63
64 pub game_ordinal: u32,
67 pub is_public_facing: bool,
68 pub double_header: DoubleHeaderKind,
69 pub is_tiebreaker: bool,
72 pub displayed_season: SeasonId,
74 pub day_night: DayNight,
75 pub description: Option<String>,
76 pub scheduled_innings: u32,
77 pub reverse_home_away_status: bool,
78 pub inning_break_length: uom::si::i32::Time,
79 pub series_data: Option<SeriesData>,
81}
82
83#[serde_as]
84#[derive(Deserialize)]
85#[serde(rename_all = "camelCase", bound = "H: ScheduleHydrations")]
86struct __ScheduleGameStruct<H: ScheduleHydrations> {
87 #[serde(rename = "gamePk")]
88 game_id: GameId,
89 game_guid: Uuid,
90 game_type: GameType,
91 season: SeasonId,
92 #[serde(deserialize_with = "crate::deserialize_datetime")]
93 game_date: DateTime<Utc>,
94 official_date: NaiveDate,
95 status: GameStatus,
96 teams: HomeAway<TeamWithStandings<H>>,
97 #[serde_as(deserialize_as = "DefaultOnError")]
98 venue: Option<NamedVenue>,
99 is_tie: Option<bool>,
100 #[serde(rename = "gameNumber")]
101 game_ordinal: u32,
102 #[serde(rename = "publicFacing")]
103 is_public_facing: bool,
104 double_header: DoubleHeaderKind,
105 #[serde(rename = "tiebreaker", deserialize_with = "crate::from_yes_no")]
108 is_tiebreaker: bool,
109 #[serde(rename = "seasonDisplay")]
111 displayed_season: SeasonId,
112 day_night: DayNight,
113 description: Option<String>,
114 scheduled_innings: u32,
115 reverse_home_away_status: bool,
116 inning_break_length: Option<u32>,
117 #[serde(flatten)]
118 series_data: Option<SeriesData>,
119}
120
121impl<H: ScheduleHydrations> From<__ScheduleGameStruct<H>> for ScheduleGame<H> {
122 #[allow(clippy::cast_possible_wrap, reason = "not gonna happen")]
123 fn from(
124 __ScheduleGameStruct {
125 game_id,
126 game_guid,
127 game_type,
128 season,
129 game_date,
130 official_date,
131 status,
132 teams,
133 venue,
134 is_tie,
135 game_ordinal,
136 is_public_facing,
137 double_header,
138 is_tiebreaker,
139 displayed_season,
140 day_night,
141 description,
142 scheduled_innings,
143 reverse_home_away_status,
144 inning_break_length,
145 series_data,
146 }: __ScheduleGameStruct<H>,
147 ) -> Self {
148 Self {
149 game_id,
150 game_guid,
151 game_type,
152 season,
153 game_date,
154 official_date,
155 status,
156 teams,
157 venue: venue.unwrap_or_else(NamedVenue::unknown_venue),
158 is_tie: is_tie.unwrap_or(false),
159 game_ordinal,
160 is_public_facing,
161 double_header,
162 is_tiebreaker,
163 displayed_season,
164 day_night,
165 description,
166 scheduled_innings,
167 reverse_home_away_status,
168 inning_break_length: uom::si::i32::Time::new::<uom::si::time::second>(inning_break_length.unwrap_or(120) as i32),
169 series_data,
170 }
171 }
172}
173
174#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
175#[serde(rename_all = "camelCase")]
176pub struct SeriesData {
177 pub games_in_series: u32,
178 #[serde(rename = "seriesGameNumber")]
179 pub game_in_series_ordinal: u32,
180}
181
182#[derive(Debug, Deserialize, PartialEq, Clone)]
183#[serde(rename_all = "camelCase", bound = "H: ScheduleHydrations")]
184pub struct TeamWithStandings<H: ScheduleHydrations> {
185 pub team: H::Team,
186 #[serde(rename = "leagueRecord")]
187 pub standings: Standings,
188 #[serde(flatten)]
189 pub score: Option<TeamWithStandingsGameScore>,
190 #[serde(rename = "splitSquad")]
191 pub is_split_squad_game: bool,
192
193 #[serde(rename = "seriesNumber")]
197 pub series_ordinal: Option<u32>,
198}
199
200#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
201#[serde(rename_all = "camelCase")]
202pub struct TeamWithStandingsGameScore {
203 #[serde(rename = "score")]
204 pub runs_scored: u32,
205 pub is_winner: bool,
206}
207
208#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone)]
209#[serde(rename_all = "camelCase")]
210pub struct Standings {
211 pub wins: u32,
212 pub losses: u32,
213}
214
215impl Standings {
216 #[must_use]
217 pub const fn games_played(self) -> u32 {
218 self.wins + self.losses
219 }
220
221 #[must_use]
222 pub fn pct(self) -> f64 {
223 f64::from(self.wins) / f64::from(self.games_played())
224 }
225}
226
227pub trait ScheduleHydrations: Hydrations<RequestData = ()> {
228 type Team: Debug + DeserializeOwned + Clone + PartialEq;
229
230 type Venue: Debug + DeserializeOwned + Clone + PartialEq;
231}
232
233impl ScheduleHydrations for () {
234 type Team = NamedTeam;
235
236 type Venue = NamedVenue;
237}
238
239#[macro_export]
299macro_rules! schedule_hydrations {
300 (@ inline_structs [team: { $($inline_tt:tt)* } $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
301 $crate::macro_use::pastey::paste! {
302 $crate::team_hydrations! {
303 $vis struct [<$name InlineTeam>] {
304 $($inline_tt)*
305 }
306 }
307
308 $crate::schedule_hydrations! { @ inline_structs [$($($tt)*)?]
309 $vis struct $name {
310 $($field_tt)*
311 team: [<$name InlineTeam>],
312 }
313 }
314 }
315 };
316 (@ inline_structs [venue: { $($inline_tt:tt)* } $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
317 $crate::macro_use::pastey::paste! {
318 $crate::venue_hydrations! {
319 $vis struct [<$name InlineVenue>] {
320 $($inline_tt)*
321 }
322 }
323
324 $crate::schedule_hydrations! { @ inline_structs [$($($tt)*)?]
325 $vis struct $name {
326 $($field_tt)*
327 venue: [<$name InlineVenue>],
328 }
329 }
330 }
331 };
332 (@ inline_structs [$_01:ident : { $($_02:tt)* } $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
333 ::core::compile_error!("Found unknown inline struct");
334 };
335 (@ inline_structs [$field:ident $(: $value:ty)? $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
336 $crate::schedule_hydrations! { @ inline_structs [$($($tt)*)?]
337 $vis struct $name {
338 $($field_tt)*
339 $field $(: $value)?,
340 }
341 }
342 };
343 (@ inline_structs [] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
344 $crate::schedule_hydrations! { @ actual
345 $vis struct $name {
346 $($field_tt)*
347 }
348 }
349 };
350
351 (@ team) => { $crate::team::NamedTeam };
352 (@ team $hydrations:ty) => { $crate::team::Team<$hydrations> };
353
354 (@ venue) => { $crate::venue::NamedVenue };
355 (@ venue $hydrations:ty) => { $crate::venue::Venue<$hydrations> };
356
357 (@ actual $vis:vis struct $name:ident {
358 $(team: $team:ty ,)?
359 $(venue: $venue:ty)?
360 }) => {
361 #[derive(::core::fmt::Debug, $crate::macro_use::serde::Deserialize, ::core::cmp::PartialEq, ::core::clone::Clone)]
362 #[serde(rename_all = "camelCase")]
363 $vis struct $name {
364
365 }
366
367 impl $crate::schedule::ScheduleHydrations for $name {
368 type Team = $crate::schedule_hydrations!(@ team $($team)?);
369
370 type Venue = $crate::schedule_hydrations!(@ venue $($venue)?);
371 }
372
373 impl $crate::hydrations::Hydrations for $name {
374 type RequestData = ();
375
376 fn hydration_text(&(): Self::RequestData) -> ::std::borrow::Cow<'static, str> {
377 let text = ::std::borrow::Cow::Borrowed(::core::concat!(
378
379 ));
380
381 $(let text = ::std::borrow::Cow::Owned(::std::format!("{text}team({}),", <$team as $crate::hydrations::Hydrations>::hydration_text(&()))))?
382 $(let text = ::std::borrow::Cow::Owned(::std::format!("{text}venue({}),", <$venue as $crate::hydrations::Hydrations>::hydration_text(&()))))?
383
384 text
385 }
386 }
387 };
388}
389
390#[allow(dead_code, reason = "rust analyzer says that opponent_id and season are dead, while being used in Display")]
391#[derive(Builder)]
392#[builder(derive(Into))]
393pub struct ScheduleRequest<H: ScheduleHydrations> {
394 #[builder(into)]
395 #[builder(default)]
396 sport_id: SportId,
397 #[builder(setters(vis = "", name = game_ids_internal))]
398 game_ids: Option<Vec<GameId>>,
399 #[builder(into)]
400 team_id: Option<TeamId>,
401 #[builder(into)]
402 league_id: Option<LeagueId>,
403 #[builder(setters(vis = "", name = venue_ids_internal))]
404 venue_ids: Option<Vec<VenueId>>,
405 #[builder(default = Either::Left(Utc::now().date_naive()))]
406 #[builder(setters(vis = "", name = date_internal))]
407 date: Either<NaiveDate, NaiveDateRange>,
408 #[builder(into)]
409 opponent_id: Option<TeamId>,
410 #[builder(into)]
411 season: Option<SeasonId>,
412 game_type: Option<GameType>,
413 #[builder(skip)]
414 _marker: PhantomData<H>,
415}
416
417impl<H: ScheduleHydrations, S: schedule_request_builder::State + schedule_request_builder::IsComplete> crate::request::RequestURLBuilderExt for ScheduleRequestBuilder<H, S> {
418 type Built = ScheduleRequest<H>;
419}
420
421impl<H: ScheduleHydrations, S: schedule_request_builder::State> ScheduleRequestBuilder<H, S> {
422 pub fn game_ids(self, game_ids: Vec<impl Into<GameId>>) -> ScheduleRequestBuilder<H, schedule_request_builder::SetGameIds<S>>
423 where
424 S::GameIds: schedule_request_builder::IsUnset,
425 {
426 self.game_ids_internal(game_ids.into_iter().map(Into::into).collect())
427 }
428
429 pub fn venue_ids(self, venue_ids: Vec<impl Into<VenueId>>) -> ScheduleRequestBuilder<H, schedule_request_builder::SetVenueIds<S>>
430 where
431 S::VenueIds: schedule_request_builder::IsUnset,
432 {
433 self.venue_ids_internal(venue_ids.into_iter().map(Into::into).collect())
434 }
435
436 pub fn date(self, date: NaiveDate) -> ScheduleRequestBuilder<H, schedule_request_builder::SetDate<S>>
437 where
438 S::Date: schedule_request_builder::IsUnset,
439 {
440 self.date_internal(Either::Left(date))
441 }
442
443 pub fn date_range(self, range: NaiveDateRange) -> ScheduleRequestBuilder<H, schedule_request_builder::SetDate<S>>
444 where
445 S::Date: schedule_request_builder::IsUnset,
446 {
447 self.date_internal(Either::Right(range))
448 }
449}
450
451impl<H: ScheduleHydrations> Display for ScheduleRequest<H> {
452 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
453 let hydrations = Some(H::hydration_text(&())).filter(|s| !s.is_empty());
454
455 write!(
456 f,
457 "http://statsapi.mlb.com/api/v1/schedule{params}",
458 params = gen_params! {
459 "hydrate"?: hydrations,
460 "sportId": self.sport_id,
461 "gamePks"?: self.game_ids.as_ref().map(|ids| ids.iter().map(ToString::to_string).join(",")),
462 "teamId"?: self.team_id,
463 "leagueId"?: self.league_id,
464 "date"?: self.date.as_ref().left().map(|x| x.format(MLB_API_DATE_FORMAT)),
465 "startDate"?: self.date.as_ref().right().map(|range| range.start().format(MLB_API_DATE_FORMAT)),
466 "endDate"?: self.date.as_ref().right().map(|range| range.end().format(MLB_API_DATE_FORMAT)),
467 "opponentId"?; self.opponent_id,
468 "season"?: self.season,
469 "venueIds"?: self.venue_ids.as_ref().map(|ids| ids.iter().map(ToString::to_string).join(",")),
470 "gameType"?: self.game_type,
471 }
472 )
473 }
474}
475
476impl<H: ScheduleHydrations> RequestURL for ScheduleRequest<H> {
477 type Response = ScheduleResponse<H>;
478}
479
480#[cfg(test)]
481mod tests {
482 use crate::TEST_YEAR;
483 use crate::request::RequestURLBuilderExt;
484 use crate::schedule::ScheduleRequest;
485 use chrono::NaiveDate;
486
487 #[tokio::test]
488 async fn test_one_date() {
489 let date = NaiveDate::from_ymd_opt(2020, 8, 2).expect("Valid date");
490 let _ = ScheduleRequest::<()>::builder().date(date).build_and_get().await.unwrap();
491 }
492
493 #[tokio::test]
494 async fn test_all_dates_current_year() {
495 let _ = ScheduleRequest::<()>::builder()
496 .date_range(NaiveDate::from_ymd_opt(TEST_YEAR.try_into().unwrap(), 1, 1).expect("Valid date")..=NaiveDate::from_ymd_opt(TEST_YEAR.try_into().unwrap(), 12, 31).expect("Valid date"))
497 .build_and_get()
498 .await
499 .unwrap();
500 }
501
502 #[tokio::test]
503 #[cfg_attr(not(feature = "_heavy_tests"), ignore)]
504 async fn test_all_dates_all_years() {
505 for year in 1876..=TEST_YEAR {
506 let _ = ScheduleRequest::<()>::builder()
507 .date_range(NaiveDate::from_ymd_opt(year.try_into().unwrap(), 1, 1).unwrap()..=NaiveDate::from_ymd_opt(year.try_into().unwrap(), 12, 31).unwrap())
508 .build_and_get()
509 .await
510 .unwrap();
511 }
512 }
513}