use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SportsUpdateMessage {
pub league_abbreviation: String,
pub score: String,
pub period: String,
pub live: bool,
pub ended: bool,
#[serde(default)]
pub game_id: Option<u64>,
#[serde(default)]
pub metadata_game_id: Option<String>,
#[serde(default)]
pub home_team: Option<String>,
#[serde(default)]
pub away_team: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub elapsed: Option<String>,
#[serde(default)]
pub finished_timestamp: Option<String>,
#[serde(default)]
pub event_state: Option<serde_json::Value>,
#[serde(flatten)]
pub extra: serde_json::Value,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
#[non_exhaustive]
pub enum SportsMessage {
Update(SportsUpdateMessage),
}
impl SportsMessage {
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
Ok(SportsMessage::Update(serde_json::from_str(json)?))
}
}
#[cfg(test)]
pub(crate) mod fixtures {
pub const SOCCER: &str = r#"{"gameId":90106111,"leagueAbbreviation":"kor","homeTeam":"Gimcheon Sangmu FC","awayTeam":"Daejeon Hana Citizen FC","status":"InProgress","eventState":{"type":"soccer","createdAt":"2026-07-25T12:02:18.395759286Z","updatedAt":"2026-07-25T12:02:18.395759286Z","score":"2-1","elapsed":"74","period":"2H","live":true,"ended":false},"score":"2-1","elapsed":"74","period":"2H","live":true,"ended":false}"#;
pub const TENNIS: &str = r#"{"gameId":5968822,"leagueAbbreviation":"atp","homeTeam":"Alexander Bublik","awayTeam":"Quentin Halys","status":"inprogress","eventState":{"type":"tennis","createdAt":"2026-07-25T12:02:23.120698555Z","updatedAt":"2026-07-25T12:02:23.120698555Z","score":"4-6, 1-2","period":"S2","live":true,"ended":false,"tournamentName":"Generali Open","tennisRound":"Final"},"score":"4-6, 1-2","period":"S2","live":true,"ended":false}"#;
pub const ESPORTS: &str = r#"{"gameId":1590176,"leagueAbbreviation":"lol","homeTeam":"Caldya Esport","awayTeam":"Galions Sharks","status":"running","score":"000-000|0-0|Bo1","period":"1/1","live":true,"ended":false}"#;
pub const CRICKET: &str = r#"{"metadataGameId":"id2703680373085574","leagueAbbreviation":"cricket","score":"21-178","period":"Live","live":true,"ended":false}"#;
pub const CRICKET_FINISHED: &str = r#"{"metadataGameId":"id2703438269077680","leagueAbbreviation":"cricket","score":"116-38","period":"FT","live":false,"ended":true,"finishedTimestamp":"2026-07-25T12:06:54.595448902Z"}"#;
pub const ALL: [&str; 5] = [SOCCER, TENNIS, ESPORTS, CRICKET, CRICKET_FINISHED];
}
#[cfg(test)]
mod tests {
use super::{fixtures, SportsMessage};
#[test]
fn every_captured_frame_shape_parses() {
for (i, frame) in fixtures::ALL.iter().enumerate() {
let SportsMessage::Update(msg) = SportsMessage::from_json(frame)
.unwrap_or_else(|e| panic!("fixture {i} must parse: {e}\n{frame}"));
assert!(
!msg.league_abbreviation.is_empty(),
"fixture {i} lost its league"
);
}
}
#[test]
fn parses_a_soccer_frame() {
let SportsMessage::Update(msg) = SportsMessage::from_json(fixtures::SOCCER).unwrap();
assert_eq!(msg.game_id, Some(90106111));
assert_eq!(msg.league_abbreviation, "kor");
assert_eq!(msg.home_team.as_deref(), Some("Gimcheon Sangmu FC"));
assert_eq!(msg.score, "2-1");
assert_eq!(msg.period, "2H");
assert_eq!(msg.elapsed.as_deref(), Some("74"));
assert!(msg.live);
assert!(!msg.ended);
assert_eq!(msg.event_state.as_ref().unwrap()["type"], "soccer");
}
#[test]
fn parses_a_cricket_frame_that_has_no_game_id() {
let SportsMessage::Update(msg) = SportsMessage::from_json(fixtures::CRICKET).unwrap();
assert_eq!(msg.game_id, None);
assert_eq!(msg.metadata_game_id.as_deref(), Some("id2703680373085574"));
assert_eq!(msg.home_team, None);
assert_eq!(msg.status, None);
assert_eq!(msg.league_abbreviation, "cricket");
}
#[test]
fn parses_a_finished_match() {
let SportsMessage::Update(msg) =
SportsMessage::from_json(fixtures::CRICKET_FINISHED).unwrap();
assert!(msg.ended);
assert!(!msg.live);
assert_eq!(
msg.finished_timestamp.as_deref(),
Some("2026-07-25T12:06:54.595448902Z")
);
}
#[test]
fn retains_fields_the_type_does_not_model() {
let json = r#"{"leagueAbbreviation":"nfl","score":"14-7","period":"Q2",
"live":true,"ended":false,"turn":"sea","somethingNew":42}"#;
let SportsMessage::Update(msg) = SportsMessage::from_json(json).unwrap();
assert_eq!(msg.extra["turn"], "sea");
assert_eq!(msg.extra["somethingNew"], 42);
}
#[test]
fn rejects_a_frame_that_is_not_a_sports_update() {
assert!(SportsMessage::from_json(r#"{"some":"ack"}"#).is_err());
}
}