use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr;
use serde_with::serde_as;
#[serde_as]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Leaderboard {
#[serde(rename = "event")]
#[serde_as(as = "DisplayFromStr")]
pub year: i32,
pub owner_id: u64,
#[serde(default)]
pub day1_ts: i64,
#[serde(default)]
#[serde_as(as = "HashMap<DisplayFromStr, _>")]
pub members: HashMap<u64, LeaderboardMember>,
}
#[cfg(feature = "http")]
impl Leaderboard {
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg_attr(not(coverage), tracing::instrument(ret(level = "trace"), err))]
pub async fn get(
year: i32,
id: u64,
credentials: &LeaderboardCredentials,
) -> crate::Result<Self> {
Self::get_from(Self::http_client()?, "https://adventofcode.com", year, id, credentials)
.await
}
#[cfg_attr(
not(coverage),
tracing::instrument(skip(http_client), level = "debug", ret(level = "trace"), err)
)]
pub async fn get_from<B>(
http_client: reqwest::Client,
base: B,
year: i32,
id: u64,
credentials: &LeaderboardCredentials,
) -> crate::Result<Self>
where
B: AsRef<str> + std::fmt::Debug,
{
let mut request = http_client.get(format!(
"{}/{year}/leaderboard/private/view/{id}.json{}",
base.as_ref(),
credentials.view_key_url_suffix()
));
if let Some(cookie_header) = credentials.session_cookie_header_value() {
request = request.header(reqwest::header::COOKIE, cookie_header);
}
let response = request
.send()
.await
.and_then(reqwest::Response::error_for_status);
match response {
Ok(response) => Ok(response.json().await?),
Err(err)
if err
.status()
.is_some_and(|status| status == reqwest::StatusCode::BAD_REQUEST) =>
{
Err(crate::Error::NoAccess)
},
Err(err) => Err(err.into()),
}
}
#[cfg_attr(not(coverage), tracing::instrument(level = "trace", err))]
pub fn http_client() -> crate::Result<reqwest::Client> {
Ok(reqwest::Client::builder()
.user_agent(Self::http_user_agent())
.build()?)
}
#[cfg_attr(not(coverage), tracing::instrument(level = "trace", ret))]
fn http_user_agent() -> String {
format!("clechasseur/{}@{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
}
}
#[cfg(feature = "http")]
#[derive(
veil::Redact,
Clone,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
gratte::EnumDiscriminants,
gratte::EnumIs,
)]
#[serde(rename_all = "snake_case")]
#[strum_discriminants(
name(LeaderboardCredentialsKind),
derive(Serialize, Deserialize, gratte::EnumIs)
)]
pub enum LeaderboardCredentials {
#[redact(all)]
ViewKey(String),
#[redact(all)]
SessionCookie(String),
}
#[cfg(feature = "http")]
impl LeaderboardCredentials {
pub fn view_key(&self) -> Option<&str> {
match self {
LeaderboardCredentials::ViewKey(key) => Some(key.as_ref()),
LeaderboardCredentials::SessionCookie(_) => None,
}
}
pub fn session_cookie(&self) -> Option<&str> {
match self {
LeaderboardCredentials::SessionCookie(cookie) => Some(cookie.as_ref()),
LeaderboardCredentials::ViewKey(_) => None,
}
}
pub fn view_key_url_suffix(&self) -> String {
self.view_key()
.map(|key| format!("?view_key={key}"))
.unwrap_or_default()
}
pub fn session_cookie_header_value(&self) -> Option<String> {
self.session_cookie()
.map(|cookie| format!("session={cookie}"))
}
}
#[cfg(feature = "http")]
impl PartialEq<LeaderboardCredentialsKind> for LeaderboardCredentials {
fn eq(&self, other: &LeaderboardCredentialsKind) -> bool {
LeaderboardCredentialsKind::from(self) == *other
}
}
#[cfg(feature = "http")]
impl PartialEq<LeaderboardCredentials> for LeaderboardCredentialsKind {
fn eq(&self, other: &LeaderboardCredentials) -> bool {
*self == Self::from(other)
}
}
#[serde_as]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeaderboardMember {
pub name: Option<String>,
pub id: u64,
#[serde(default)]
pub stars: u32,
#[serde(default)]
pub local_score: u64,
#[serde(default)]
pub global_score: u64,
#[serde(default)]
pub last_star_ts: i64,
#[serde(default)]
#[serde_as(as = "HashMap<DisplayFromStr, _>")]
pub completion_day_level: HashMap<u32, CompletionDayLevel>,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct CompletionDayLevel {
#[serde(rename = "1")]
pub part_1: PuzzleCompletionInfo,
#[serde(rename = "2", default, skip_serializing_if = "Option::is_none")]
pub part_2: Option<PuzzleCompletionInfo>,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct PuzzleCompletionInfo {
pub get_star_ts: i64,
#[serde(default)]
pub star_index: u64,
}
#[cfg(all(test, feature = "__test_helpers"))]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
mod leaderboard {
use rstest::rstest;
use super::*;
use crate::test_helpers::{
TEST_LEADERBOARD_ID, TEST_YEAR, mock_server_with_inaccessible_leaderboard,
mock_server_with_leaderboard, mock_server_with_leaderboard_with_invalid_json,
test_leaderboard, test_leaderboard_credentials,
};
mod deserialize {
use super::*;
#[rstest]
#[test_log::test]
fn test_deserialize(#[from(test_leaderboard)] leaderboard: Leaderboard) {
assert_eq!(leaderboard.year, 2024);
assert_eq!(leaderboard.members.len(), 8);
assert!(
leaderboard.members[&12345].completion_day_level[&2]
.part_2
.is_some()
);
}
}
#[cfg(feature = "http")]
mod get {
use assert_matches::assert_matches;
use reqwest::StatusCode;
use wiremock::MockServer;
use super::*;
async fn get_mock_leaderboard(
credentials: &LeaderboardCredentials,
mock_server: &MockServer,
) -> crate::Result<Leaderboard> {
Leaderboard::get_from(
Leaderboard::http_client()?,
mock_server.uri(),
TEST_YEAR,
TEST_LEADERBOARD_ID,
credentials,
)
.await
}
#[rstest]
#[awt]
#[test_log::test(tokio::test)]
async fn success(
#[from(test_leaderboard)] expected: Leaderboard,
#[values(
LeaderboardCredentialsKind::ViewKey,
LeaderboardCredentialsKind::SessionCookie
)]
credentials_kind: LeaderboardCredentialsKind,
#[from(test_leaderboard_credentials)]
#[with(credentials_kind)]
credentials: LeaderboardCredentials,
#[future]
#[from(mock_server_with_leaderboard)]
#[with(expected.clone(), credentials.clone())]
mock_server: MockServer,
) {
let _ = credentials_kind;
let actual = get_mock_leaderboard(&credentials, &mock_server).await;
assert_matches!(actual, Ok(actual) => {
assert_eq!(actual, expected);
});
}
mod errors {
use super::*;
#[rstest]
#[awt]
#[test_log::test(tokio::test)]
async fn no_access(
#[values(
LeaderboardCredentialsKind::ViewKey,
LeaderboardCredentialsKind::SessionCookie
)]
credentials_kind: LeaderboardCredentialsKind,
#[future]
#[from(mock_server_with_inaccessible_leaderboard)]
mock_server: MockServer,
) {
let credentials = test_leaderboard_credentials(credentials_kind);
let actual = get_mock_leaderboard(&credentials, &mock_server).await;
assert_matches!(actual, Err(crate::Error::NoAccess));
}
#[rstest]
#[test_log::test(tokio::test)]
async fn not_found(
#[values(
LeaderboardCredentialsKind::ViewKey,
LeaderboardCredentialsKind::SessionCookie
)]
credentials_kind: LeaderboardCredentialsKind,
) {
let credentials = test_leaderboard_credentials(credentials_kind);
let mock_server = MockServer::start().await;
let actual = get_mock_leaderboard(&credentials, &mock_server).await;
assert_matches!(actual, Err(crate::Error::HttpGet(err)) => {
assert!(err.is_status());
assert_eq!(err.status(), Some(StatusCode::NOT_FOUND));
});
}
#[rstest]
#[awt]
#[test_log::test(tokio::test)]
async fn invalid_json(
#[values(
LeaderboardCredentialsKind::ViewKey,
LeaderboardCredentialsKind::SessionCookie
)]
credentials_kind: LeaderboardCredentialsKind,
#[from(test_leaderboard_credentials)]
#[with(credentials_kind)]
credentials: LeaderboardCredentials,
#[future]
#[from(mock_server_with_leaderboard_with_invalid_json)]
#[with(credentials.clone())]
mock_server: MockServer,
) {
let _ = credentials_kind;
let actual = get_mock_leaderboard(&credentials, &mock_server).await;
assert_matches!(actual, Err(crate::Error::HttpGet(err)) if err.is_decode());
}
}
}
}
}