Skip to main content

wc_data/
provider.rs

1//! The provider abstraction: a uniform [`ScoreProvider`] interface plus a
2//! runtime-selectable [`Provider`] enum.
3//!
4//! The TUI builds one [`Provider`] from a [`ProviderConfig`] and calls its
5//! inherent async methods; it never names a concrete backend. New backends are
6//! added by implementing [`ScoreProvider`] and adding a variant here.
7
8use time::Date;
9
10use crate::backends::{ApiFootballProvider, EspnProvider, FootballDataProvider};
11use crate::domain::{Bracket, Calendar, Group, Match, MatchDetail};
12use crate::error::{DataError, Result};
13use crate::transport::Http;
14
15/// A uniform interface every backend implements.
16///
17/// `async fn` in trait is intentional: backends are only ever used through the
18/// concrete [`Provider`] enum (not as `dyn ScoreProvider`), so the absence of an
19/// auto-`Send` bound is not a problem — the concrete futures are `Send`.
20#[allow(async_fn_in_trait)]
21pub trait ScoreProvider {
22    /// A short, stable name for diagnostics and UI ("ESPN", "API-Football", …).
23    fn name(&self) -> &'static str;
24
25    /// The competition calendar (stage windows).
26    async fn calendar(&self) -> Result<Calendar>;
27
28    /// Matches for the tournament. `None` returns the full schedule (every
29    /// fixture, group stage through the final); `Some(day)` filters to a single
30    /// UTC day.
31    async fn scoreboard(&self, day: Option<Date>) -> Result<Vec<Match>>;
32
33    /// All group tables.
34    async fn standings(&self) -> Result<Vec<Group>>;
35
36    /// The knockout bracket.
37    async fn bracket(&self) -> Result<Bracket>;
38
39    /// Full detail for a single match by its provider id.
40    async fn match_detail(&self, id: &str) -> Result<MatchDetail>;
41}
42
43/// Which backend to use.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub enum ProviderKind {
46    /// ESPN hidden API (free, no key). The default.
47    #[default]
48    Espn,
49    /// API-Football (`api-sports.io`); requires an API key.
50    ApiFootball,
51    /// football-data.org; requires an API key.
52    FootballData,
53}
54
55impl ProviderKind {
56    /// All variants, in UI order.
57    #[must_use]
58    pub fn all() -> [ProviderKind; 3] {
59        [
60            ProviderKind::Espn,
61            ProviderKind::ApiFootball,
62            ProviderKind::FootballData,
63        ]
64    }
65
66    /// The lowercase config token, e.g. `"espn"`.
67    #[must_use]
68    pub fn as_str(self) -> &'static str {
69        match self {
70            ProviderKind::Espn => "espn",
71            ProviderKind::ApiFootball => "api-football",
72            ProviderKind::FootballData => "football-data",
73        }
74    }
75
76    /// Parse a config token (case-insensitive; accepts a few aliases).
77    #[must_use]
78    pub fn parse(s: &str) -> Option<ProviderKind> {
79        match s.trim().to_ascii_lowercase().as_str() {
80            "espn" => Some(ProviderKind::Espn),
81            "api-football" | "apifootball" | "api_football" => Some(ProviderKind::ApiFootball),
82            "football-data" | "footballdata" | "football_data" => Some(ProviderKind::FootballData),
83            _ => None,
84        }
85    }
86}
87
88/// Everything needed to build a [`Provider`] at runtime.
89#[derive(Debug, Clone, Default)]
90pub struct ProviderConfig {
91    /// Which backend to use.
92    pub kind: ProviderKind,
93    /// API key for API-Football, if that backend is selected.
94    pub api_football_key: Option<String>,
95    /// API key for football-data.org, if that backend is selected.
96    pub football_data_key: Option<String>,
97}
98
99/// A runtime-selected backend. Dispatches to the concrete provider.
100pub enum Provider {
101    /// ESPN backend.
102    Espn(EspnProvider),
103    /// API-Football backend.
104    ApiFootball(ApiFootballProvider),
105    /// football-data.org backend.
106    FootballData(FootballDataProvider),
107}
108
109impl Provider {
110    /// Build the configured provider, validating that any required API key is
111    /// present.
112    ///
113    /// # Errors
114    /// Returns [`DataError::MissingKey`] when the selected backend needs an API
115    /// key that was not supplied.
116    pub fn from_config(config: &ProviderConfig, http: Http) -> Result<Self> {
117        match config.kind {
118            ProviderKind::Espn => Ok(Provider::Espn(EspnProvider::new(http))),
119            ProviderKind::ApiFootball => {
120                let key = config
121                    .api_football_key
122                    .clone()
123                    .filter(|k| !k.trim().is_empty())
124                    .ok_or(DataError::MissingKey("API-Football"))?;
125                Ok(Provider::ApiFootball(ApiFootballProvider::new(http, key)))
126            }
127            ProviderKind::FootballData => {
128                let key = config
129                    .football_data_key
130                    .clone()
131                    .filter(|k| !k.trim().is_empty())
132                    .ok_or(DataError::MissingKey("football-data.org"))?;
133                Ok(Provider::FootballData(FootballDataProvider::new(http, key)))
134            }
135        }
136    }
137
138    /// The active backend's display name.
139    #[must_use]
140    pub fn name(&self) -> &'static str {
141        match self {
142            Provider::Espn(p) => p.name(),
143            Provider::ApiFootball(p) => p.name(),
144            Provider::FootballData(p) => p.name(),
145        }
146    }
147
148    /// See [`ScoreProvider::calendar`].
149    ///
150    /// # Errors
151    /// Propagates the active backend's error.
152    pub async fn calendar(&self) -> Result<Calendar> {
153        match self {
154            Provider::Espn(p) => p.calendar().await,
155            Provider::ApiFootball(p) => p.calendar().await,
156            Provider::FootballData(p) => p.calendar().await,
157        }
158    }
159
160    /// See [`ScoreProvider::scoreboard`].
161    ///
162    /// # Errors
163    /// Propagates the active backend's error.
164    pub async fn scoreboard(&self, day: Option<Date>) -> Result<Vec<Match>> {
165        match self {
166            Provider::Espn(p) => p.scoreboard(day).await,
167            Provider::ApiFootball(p) => p.scoreboard(day).await,
168            Provider::FootballData(p) => p.scoreboard(day).await,
169        }
170    }
171
172    /// See [`ScoreProvider::standings`].
173    ///
174    /// # Errors
175    /// Propagates the active backend's error.
176    pub async fn standings(&self) -> Result<Vec<Group>> {
177        match self {
178            Provider::Espn(p) => p.standings().await,
179            Provider::ApiFootball(p) => p.standings().await,
180            Provider::FootballData(p) => p.standings().await,
181        }
182    }
183
184    /// See [`ScoreProvider::bracket`].
185    ///
186    /// # Errors
187    /// Propagates the active backend's error.
188    pub async fn bracket(&self) -> Result<Bracket> {
189        match self {
190            Provider::Espn(p) => p.bracket().await,
191            Provider::ApiFootball(p) => p.bracket().await,
192            Provider::FootballData(p) => p.bracket().await,
193        }
194    }
195
196    /// See [`ScoreProvider::match_detail`].
197    ///
198    /// # Errors
199    /// Propagates the active backend's error.
200    pub async fn match_detail(&self, id: &str) -> Result<MatchDetail> {
201        match self {
202            Provider::Espn(p) => p.match_detail(id).await,
203            Provider::ApiFootball(p) => p.match_detail(id).await,
204            Provider::FootballData(p) => p.match_detail(id).await,
205        }
206    }
207}