1use std::collections::HashMap;
9
10use serde::Deserialize;
11use time::{Date, macros::format_description};
12
13use crate::backends::common::{
14 f64_to_i16, f64_to_u8, f64_to_u16, group_from_text, minute_from_clock, parse_time,
15 parse_u8_str, stage_for_date, stage_from_label, stage_from_slug,
16};
17use crate::domain::{
18 Bracket, BracketRound, Calendar, Group, GroupStanding, Lineup, Match, MatchDetail, MatchEvent,
19 MatchEventKind, MatchStatus, Player, Score, Stage, StageWindow, Team, TeamStat,
20};
21use crate::error::Result;
22use crate::provider::ScoreProvider;
23use crate::transport::Http;
24
25const SCOREBOARD_URL: &str =
26 "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard";
27const STANDINGS_URL: &str = "https://site.api.espn.com/apis/v2/sports/soccer/fifa.world/standings";
28const TOURNAMENT_DATE_RANGE: &str = "20260611-20260719";
34const SCHEDULE_LIMIT: u32 = 300;
36const SUMMARY_URL: &str =
37 "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/summary?event=";
38
39#[derive(Debug, Clone)]
41pub struct EspnProvider {
42 http: Http,
43}
44
45impl EspnProvider {
46 #[must_use]
48 pub fn new(http: Http) -> Self {
49 Self { http }
50 }
51
52 async fn fetch_scoreboard(&self, day: Option<Date>) -> Result<EspnScoreboard> {
53 let url = day.map_or_else(
54 || SCOREBOARD_URL.to_owned(),
55 |date| {
56 let fmt = format_description!("[year][month][day]");
57 let dates = date.format(fmt).unwrap_or_default();
58 format!("{SCOREBOARD_URL}?dates={dates}")
59 },
60 );
61 self.http.get_json(&url, &[]).await
62 }
63
64 async fn fetch_full_schedule(&self) -> Result<EspnScoreboard> {
70 let url = format!("{SCOREBOARD_URL}?dates={TOURNAMENT_DATE_RANGE}&limit={SCHEDULE_LIMIT}");
71 self.http.get_json(&url, &[]).await
72 }
73}
74
75impl ScoreProvider for EspnProvider {
76 fn name(&self) -> &'static str {
77 "ESPN"
78 }
79
80 async fn calendar(&self) -> Result<Calendar> {
81 self.fetch_scoreboard(None).await?.calendar()
82 }
83
84 async fn scoreboard(&self, day: Option<Date>) -> Result<Vec<Match>> {
85 let dto = match day {
86 Some(_) => self.fetch_scoreboard(day).await?,
87 None => self.fetch_full_schedule().await?,
88 };
89 let calendar = dto.calendar()?;
90 dto.matches(&calendar)
91 }
92
93 async fn standings(&self) -> Result<Vec<Group>> {
94 let dto: EspnStandings = self.http.get_json(STANDINGS_URL, &[]).await?;
95 Ok(dto.groups())
96 }
97
98 async fn bracket(&self) -> Result<Bracket> {
99 let dto = self.fetch_full_schedule().await?;
100 let calendar = dto.calendar()?;
101 let matches = dto.matches(&calendar)?;
102 let mut rounds = Vec::new();
103 for stage in Stage::knockout_order() {
104 let round_matches = matches
105 .iter()
106 .filter(|m| m.stage == stage)
107 .cloned()
108 .collect();
109 rounds.push(BracketRound {
110 stage,
111 matches: round_matches,
112 });
113 }
114 Ok(Bracket { rounds })
115 }
116
117 async fn match_detail(&self, id: &str) -> Result<MatchDetail> {
118 let url = format!("{SUMMARY_URL}{id}");
119 let dto: EspnSummary = self.http.get_json(&url, &[]).await?;
120 dto.detail()
121 }
122}
123
124#[derive(Debug, Deserialize)]
125#[serde(rename_all = "camelCase")]
126struct EspnScoreboard {
127 leagues: Vec<EspnLeague>,
128 #[serde(default)]
129 events: Vec<EspnEvent>,
130}
131
132impl EspnScoreboard {
133 fn calendar(&self) -> Result<Calendar> {
134 let stages = self
135 .leagues
136 .first()
137 .into_iter()
138 .flat_map(|league| &league.calendar)
139 .flat_map(|entry| {
140 if entry.entries.is_empty() {
141 vec![entry]
142 } else {
143 entry.entries.iter().collect()
144 }
145 })
146 .filter_map(|entry| {
147 let stage = stage_from_label(&entry.label)?;
148 Some((stage, entry))
149 })
150 .map(|(stage, entry)| {
151 Ok(StageWindow {
152 stage,
153 label: entry.label.clone(),
154 start: parse_time(&entry.start_date)?,
155 end: parse_time(&entry.end_date)?,
156 })
157 })
158 .collect::<Result<Vec<_>>>()?;
159 Ok(Calendar { stages })
160 }
161
162 fn matches(&self, calendar: &Calendar) -> Result<Vec<Match>> {
163 self.events
164 .iter()
165 .map(|event| event.to_match(calendar))
166 .collect()
167 }
168}
169
170#[derive(Debug, Deserialize)]
171#[serde(rename_all = "camelCase")]
172struct EspnLeague {
173 #[serde(default)]
174 calendar: Vec<EspnCalendarEntry>,
175}
176
177#[derive(Debug, Deserialize)]
178#[serde(rename_all = "camelCase")]
179struct EspnCalendarEntry {
180 label: String,
181 start_date: String,
182 end_date: String,
183 #[serde(default)]
184 entries: Vec<EspnCalendarEntry>,
185}
186
187#[derive(Debug, Deserialize)]
188#[serde(rename_all = "camelCase")]
189struct EspnEvent {
190 id: String,
191 date: String,
192 name: Option<String>,
193 season: Option<EspnSeason>,
194 #[serde(default)]
195 competitions: Vec<EspnCompetition>,
196}
197
198#[derive(Debug, Deserialize)]
199struct EspnSeason {
200 slug: Option<String>,
201}
202
203impl EspnEvent {
204 fn to_match(&self, calendar: &Calendar) -> Result<Match> {
205 let kickoff = parse_time(&self.date)?;
206 let competition = self.competitions.first();
207 let home = competition
208 .and_then(|c| c.competitor("home"))
209 .map_or_else(|| Team::placeholder("TBD"), EspnCompetitor::team);
210 let away = competition
211 .and_then(|c| c.competitor("away"))
212 .map_or_else(|| Team::placeholder("TBD"), EspnCompetitor::team);
213 let score = competition.and_then(EspnCompetition::score);
214 let status = competition.map_or(MatchStatus::Scheduled, EspnCompetition::status);
215 let note = competition
216 .and_then(|c| c.alt_game_note.as_deref())
217 .or(self.name.as_deref());
218 let stage = self
219 .season
220 .as_ref()
221 .and_then(|s| s.slug.as_deref())
222 .and_then(stage_from_slug)
223 .unwrap_or_else(|| {
224 let fallback =
225 stage_from_label(note.unwrap_or_default()).unwrap_or(Stage::GroupStage);
226 stage_for_date(calendar, kickoff, fallback)
227 });
228 Ok(Match {
229 id: self.id.clone(),
230 stage,
231 group: group_from_text(note),
232 home,
233 away,
234 score,
235 status,
236 kickoff,
237 venue: competition
238 .and_then(|c| c.venue.as_ref())
239 .and_then(|v| v.full_name.clone()),
240 })
241 }
242}
243
244#[derive(Clone, Debug, Deserialize)]
245#[serde(rename_all = "camelCase")]
246struct EspnCompetition {
247 date: Option<String>,
248 #[serde(default)]
249 competitors: Vec<EspnCompetitor>,
250 status: Option<EspnStatus>,
251 venue: Option<EspnVenue>,
252 alt_game_note: Option<String>,
253}
254
255impl EspnCompetition {
256 fn competitor(&self, home_away: &str) -> Option<&EspnCompetitor> {
257 self.competitors
258 .iter()
259 .find(|c| c.home_away.as_deref() == Some(home_away))
260 }
261
262 fn score(&self) -> Option<Score> {
263 let status = self.status.as_ref()?;
264 if status
265 .type_
266 .as_ref()
267 .is_some_and(|t| t.state.as_deref() == Some("pre"))
268 {
269 return None;
270 }
271 Some(Score {
272 home: parse_u8_str(self.competitor("home").and_then(|c| c.score.as_deref()))
273 .unwrap_or_default(),
274 away: parse_u8_str(self.competitor("away").and_then(|c| c.score.as_deref()))
275 .unwrap_or_default(),
276 home_pens: None,
277 away_pens: None,
278 })
279 }
280
281 fn status(&self) -> MatchStatus {
282 self.status
283 .as_ref()
284 .map_or(MatchStatus::Unknown, EspnStatus::to_domain)
285 }
286}
287
288#[derive(Clone, Debug, Deserialize)]
289#[serde(rename_all = "camelCase")]
290struct EspnCompetitor {
291 home_away: Option<String>,
292 score: Option<String>,
293 team: Option<EspnTeam>,
294}
295
296impl EspnCompetitor {
297 fn team(&self) -> Team {
298 self.team
299 .as_ref()
300 .map_or_else(|| Team::placeholder("TBD"), EspnTeam::to_domain)
301 }
302}
303
304#[derive(Clone, Debug, Deserialize)]
305#[serde(rename_all = "camelCase")]
306struct EspnTeam {
307 id: Option<String>,
308 display_name: Option<String>,
309 abbreviation: Option<String>,
310 logo: Option<String>,
311 #[serde(default)]
312 logos: Vec<EspnLogo>,
313}
314
315impl EspnTeam {
316 fn to_domain(&self) -> Team {
317 let name = self
318 .display_name
319 .clone()
320 .unwrap_or_else(|| "TBD".to_owned());
321 let abbreviation = self
322 .abbreviation
323 .clone()
324 .unwrap_or_else(|| name.chars().take(3).collect::<String>().to_uppercase());
325 Team {
326 id: self.id.clone().unwrap_or_default(),
327 name,
328 abbreviation,
329 country_code: self.abbreviation.clone(),
330 crest_url: self
331 .logo
332 .clone()
333 .or_else(|| self.logos.first().map(|logo| logo.href.clone())),
334 }
335 }
336}
337
338#[derive(Clone, Debug, Deserialize)]
339struct EspnLogo {
340 href: String,
341}
342
343#[derive(Clone, Debug, Deserialize)]
344#[serde(rename_all = "camelCase")]
345struct EspnVenue {
346 full_name: Option<String>,
347}
348
349#[derive(Clone, Debug, Deserialize)]
350struct EspnStatus {
351 #[serde(rename = "type")]
352 type_: Option<EspnStatusType>,
353 display_clock: Option<String>,
354}
355
356impl EspnStatus {
357 fn to_domain(&self) -> MatchStatus {
358 let detail = self.type_.as_ref().and_then(|t| t.detail.clone());
359 match self.type_.as_ref().and_then(|t| t.state.as_deref()) {
360 Some("pre") => MatchStatus::Scheduled,
361 Some("in") => {
362 let desc = self
363 .type_
364 .as_ref()
365 .and_then(|t| t.description.as_deref())
366 .unwrap_or_default()
367 .to_ascii_lowercase();
368 if desc.contains("half") && desc.contains("time") {
369 MatchStatus::HalfTime
370 } else {
371 MatchStatus::Live {
372 minute: minute_from_clock(self.display_clock.as_deref())
373 .or_else(|| minute_from_clock(detail.as_deref())),
374 detail,
375 }
376 }
377 }
378 Some("post") => {
379 let desc = self
380 .type_
381 .as_ref()
382 .and_then(|t| t.description.as_deref())
383 .unwrap_or_default()
384 .to_ascii_lowercase();
385 if desc.contains("pen") {
386 MatchStatus::Penalties
387 } else if desc.contains("extra") {
388 MatchStatus::AfterExtraTime
389 } else {
390 MatchStatus::FullTime
391 }
392 }
393 _ => MatchStatus::Unknown,
394 }
395 }
396}
397
398#[derive(Clone, Debug, Deserialize)]
399struct EspnStatusType {
400 state: Option<String>,
401 description: Option<String>,
402 detail: Option<String>,
403}
404
405#[derive(Debug, Deserialize)]
406struct EspnStandings {
407 #[serde(default)]
408 children: Vec<EspnStandingChild>,
409}
410
411impl EspnStandings {
412 fn groups(&self) -> Vec<Group> {
413 self.children
414 .iter()
415 .map(|child| Group {
416 name: child
417 .name
418 .strip_prefix("Group ")
419 .unwrap_or(&child.name)
420 .to_owned(),
421 standings: child
422 .standings
423 .entries
424 .iter()
425 .map(EspnStandingEntry::to_domain)
426 .collect(),
427 })
428 .collect()
429 }
430}
431
432#[derive(Debug, Deserialize)]
433struct EspnStandingChild {
434 name: String,
435 standings: EspnStandingEntries,
436}
437
438#[derive(Debug, Deserialize)]
439struct EspnStandingEntries {
440 #[serde(default)]
441 entries: Vec<EspnStandingEntry>,
442}
443
444#[derive(Debug, Deserialize)]
445struct EspnStandingEntry {
446 team: EspnTeam,
447 #[serde(default)]
448 stats: Vec<EspnStat>,
449}
450
451impl EspnStandingEntry {
452 fn stat(&self, names: &[&str]) -> Option<f64> {
453 self.stats
454 .iter()
455 .find(|s| {
456 names
457 .iter()
458 .any(|name| s.name == *name || s.type_.as_deref() == Some(*name))
459 })
460 .and_then(|s| s.value)
461 }
462
463 fn to_domain(&self) -> GroupStanding {
464 GroupStanding {
465 team: self.team.to_domain(),
466 rank: f64_to_u8(self.stat(&["rank"])),
467 played: f64_to_u8(self.stat(&["gamesPlayed", "gamesplayed"])),
468 won: f64_to_u8(self.stat(&["wins"])),
469 drawn: f64_to_u8(self.stat(&["ties"])),
470 lost: f64_to_u8(self.stat(&["losses"])),
471 goals_for: f64_to_u16(self.stat(&["pointsFor", "pointsfor"])),
472 goals_against: f64_to_u16(self.stat(&["pointsAgainst", "pointsagainst"])),
473 goal_diff: f64_to_i16(self.stat(&["pointDifferential", "pointdifferential"])),
474 points: f64_to_u16(self.stat(&["points"])),
475 }
476 }
477}
478
479#[derive(Debug, Deserialize)]
480struct EspnStat {
481 name: String,
482 #[serde(rename = "type")]
483 type_: Option<String>,
484 value: Option<f64>,
485 #[serde(default, rename = "displayValue")]
486 display_value: String,
487 #[serde(default)]
488 label: String,
489}
490
491#[derive(Debug, Deserialize)]
492struct EspnSummary {
493 header: EspnHeader,
494 #[serde(default, rename = "keyEvents")]
495 key_events: Vec<EspnKeyEvent>,
496 #[serde(default)]
497 rosters: Vec<EspnRoster>,
498 boxscore: Option<EspnBoxscore>,
499}
500
501impl EspnSummary {
502 fn detail(&self) -> Result<MatchDetail> {
503 let calendar = Calendar::default();
504 let event = self.header.to_event();
505 let summary = event.to_match(&calendar)?;
506 Ok(MatchDetail {
507 summary,
508 events: self
509 .key_events
510 .iter()
511 .filter_map(EspnKeyEvent::to_domain)
512 .collect(),
513 lineups: self.rosters.iter().map(EspnRoster::to_domain).collect(),
514 stats: self
515 .boxscore
516 .as_ref()
517 .map_or_else(Vec::new, EspnBoxscore::team_stats),
518 })
519 }
520}
521
522#[derive(Debug, Deserialize)]
523struct EspnHeader {
524 id: String,
525 competitions: Vec<EspnCompetition>,
526}
527
528impl EspnHeader {
529 fn to_event(&self) -> EspnEvent {
530 let date = self
531 .competitions
532 .first()
533 .and_then(|c| c.date.clone())
534 .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_owned());
535 EspnEvent {
536 id: self.id.clone(),
537 date,
538 name: None,
539 season: None,
540 competitions: self.competitions.clone(),
541 }
542 }
543}
544
545#[derive(Debug, Deserialize)]
546struct EspnKeyEvent {
547 #[serde(rename = "type")]
548 type_: Option<EspnEventType>,
549 text: Option<String>,
550 clock: Option<EspnClock>,
551 team: Option<EspnEventTeam>,
552 #[serde(default)]
553 participants: Vec<EspnParticipant>,
554}
555
556impl EspnKeyEvent {
557 fn to_domain(&self) -> Option<MatchEvent> {
558 let kind = event_kind(
559 self.type_
560 .as_ref()
561 .and_then(|t| t.text.as_deref())
562 .or_else(|| self.type_.as_ref().and_then(|t| t.type_.as_deref()))?,
563 );
564 let player = self
565 .participants
566 .first()
567 .and_then(|p| p.athlete.display_name.clone());
568 Some(MatchEvent {
569 minute: self.clock.as_ref().and_then(EspnClock::minute),
570 stoppage: self.clock.as_ref().and_then(EspnClock::stoppage),
571 kind,
572 team_id: self.team.as_ref().and_then(|t| t.id.clone()),
573 player,
574 detail: self.text.clone(),
575 })
576 }
577}
578
579#[derive(Debug, Deserialize)]
580struct EspnEventType {
581 text: Option<String>,
582 #[serde(rename = "type")]
583 type_: Option<String>,
584}
585
586#[derive(Debug, Deserialize)]
587struct EspnClock {
588 value: Option<f64>,
589 #[serde(rename = "displayValue")]
590 display_value: Option<String>,
591}
592
593impl EspnClock {
594 fn minute(&self) -> Option<u16> {
595 minute_from_clock(self.display_value.as_deref())
596 .or_else(|| self.value.map(|v| (v / 60.0).floor() as u16))
597 }
598
599 fn stoppage(&self) -> Option<u16> {
600 let text = self.display_value.as_deref()?;
601 let plus = text.find('+')?;
602 let digits: String = text[plus + 1..]
603 .chars()
604 .take_while(char::is_ascii_digit)
605 .collect();
606 digits.parse().ok()
607 }
608}
609
610#[derive(Debug, Deserialize)]
611struct EspnEventTeam {
612 id: Option<String>,
613}
614
615#[derive(Debug, Deserialize)]
616struct EspnParticipant {
617 athlete: EspnAthlete,
618}
619
620#[derive(Debug, Deserialize)]
621struct EspnAthlete {
622 #[serde(rename = "displayName")]
623 display_name: Option<String>,
624 #[serde(rename = "fullName")]
625 full_name: Option<String>,
626}
627
628#[derive(Debug, Deserialize)]
629struct EspnRoster {
630 team: EspnTeam,
631 #[serde(default)]
632 roster: Vec<EspnRosterPlayer>,
633 formation: Option<String>,
634}
635
636impl EspnRoster {
637 fn to_domain(&self) -> Lineup {
638 let team = self.team.to_domain();
639 let mut starters = Vec::new();
640 let mut substitutes = Vec::new();
641 for player in &self.roster {
642 if player.starter.unwrap_or_default() {
643 starters.push(player.to_domain());
644 } else {
645 substitutes.push(player.to_domain());
646 }
647 }
648 Lineup {
649 team_id: team.id,
650 formation: self.formation.clone(),
651 starters,
652 substitutes,
653 }
654 }
655}
656
657#[derive(Debug, Deserialize)]
658struct EspnRosterPlayer {
659 starter: Option<bool>,
660 jersey: Option<String>,
661 athlete: EspnAthlete,
662 position: Option<EspnPosition>,
663}
664
665impl EspnRosterPlayer {
666 fn to_domain(&self) -> Player {
667 Player {
668 name: self
669 .athlete
670 .display_name
671 .clone()
672 .or_else(|| self.athlete.full_name.clone())
673 .unwrap_or_default(),
674 number: parse_u8_str(self.jersey.as_deref()),
675 position: self.position.as_ref().and_then(|p| p.abbreviation.clone()),
676 }
677 }
678}
679
680#[derive(Debug, Deserialize)]
681struct EspnPosition {
682 abbreviation: Option<String>,
683}
684
685#[derive(Debug, Deserialize)]
686struct EspnBoxscore {
687 #[serde(default)]
688 teams: Vec<EspnBoxTeam>,
689}
690
691impl EspnBoxscore {
692 fn team_stats(&self) -> Vec<TeamStat> {
693 if self.teams.len() < 2 {
694 return Vec::new();
695 }
696 let home = self
697 .teams
698 .iter()
699 .find(|t| t.home_away.as_deref() == Some("home"))
700 .unwrap_or(&self.teams[0]);
701 let away = self
702 .teams
703 .iter()
704 .find(|t| t.home_away.as_deref() == Some("away"))
705 .unwrap_or(&self.teams[1]);
706 let away_by_name: HashMap<&str, &EspnStat> = away
707 .statistics
708 .iter()
709 .map(|s| (s.name.as_str(), s))
710 .collect();
711 home.statistics
712 .iter()
713 .filter_map(|stat| {
714 let away_stat = away_by_name.get(stat.name.as_str())?;
715 Some(TeamStat {
716 label: if stat.label.is_empty() {
717 stat.name.clone()
718 } else {
719 stat.label.clone()
720 },
721 home: stat.display_value.clone(),
722 away: away_stat.display_value.clone(),
723 })
724 })
725 .collect()
726 }
727}
728
729#[derive(Debug, Deserialize)]
730struct EspnBoxTeam {
731 #[serde(rename = "homeAway")]
732 home_away: Option<String>,
733 #[serde(default)]
734 statistics: Vec<EspnStat>,
735}
736
737fn event_kind(text: &str) -> MatchEventKind {
738 let lower = text.to_ascii_lowercase();
739 if lower.contains("own") {
740 MatchEventKind::OwnGoal
741 } else if lower.contains("penalty") && lower.contains("miss") {
742 MatchEventKind::PenaltyMiss
743 } else if lower.contains("penalty") {
744 MatchEventKind::PenaltyGoal
745 } else if lower.contains("goal") {
746 MatchEventKind::Goal
747 } else if lower.contains("second") && lower.contains("yellow") {
748 MatchEventKind::SecondYellow
749 } else if lower.contains("yellow") {
750 MatchEventKind::YellowCard
751 } else if lower.contains("red") {
752 MatchEventKind::RedCard
753 } else if lower.contains("sub") {
754 MatchEventKind::Substitution
755 } else if lower.contains("var") {
756 MatchEventKind::Var
757 } else {
758 MatchEventKind::Other
759 }
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765 use crate::error::DataError;
766
767 #[test]
768 fn maps_scoreboard_fixture() -> Result<()> {
769 let dto: EspnScoreboard =
770 serde_json::from_str(include_str!("../../tests/fixtures/espn_scoreboard.json"))
771 .map_err(|e| DataError::Decode(e.to_string()))?;
772 let calendar = dto.calendar()?;
773 let matches = dto.matches(&calendar)?;
774 assert_eq!(calendar.stages.len(), 7);
775 assert_eq!(matches.len(), 2);
776 assert_eq!(matches[0].home.name, "Canada");
777 assert!(matches[0].status.is_live());
778 assert_eq!(matches[0].group.as_deref(), Some("B"));
779 Ok(())
780 }
781
782 #[test]
783 fn maps_standings_fixture() -> Result<()> {
784 let dto: EspnStandings =
785 serde_json::from_str(include_str!("../../tests/fixtures/espn_standings.json"))
786 .map_err(|e| DataError::Decode(e.to_string()))?;
787 let groups = dto.groups();
788 assert_eq!(groups.len(), 1);
789 assert_eq!(groups[0].standings[0].team.name, "Mexico");
790 assert_eq!(groups[0].standings[0].points, 3);
791 Ok(())
792 }
793
794 #[test]
795 fn maps_summary_fixture() -> Result<()> {
796 let dto: EspnSummary =
797 serde_json::from_str(include_str!("../../tests/fixtures/espn_summary.json"))
798 .map_err(|e| DataError::Decode(e.to_string()))?;
799 let detail = dto.detail()?;
800 assert_eq!(detail.events[0].kind, MatchEventKind::YellowCard);
801 assert_eq!(
802 detail.events[0].player.as_deref(),
803 Some("Alistair Johnston")
804 );
805 assert_eq!(detail.lineups.len(), 2);
806 assert!(!detail.stats.is_empty());
807 Ok(())
808 }
809}