From 6c8eeb17c410eb694fe61aa311252f648431b71d Mon Sep 17 00:00:00 2001 From: Konrad Pagacz Date: Mon, 17 Jul 2023 23:03:53 +0200 Subject: [PATCH] feat: added getting the leaderboard (#31) * Added a new command to grab the leaderboard of a particular year * Refactored a bit of AocApi Closes #31 --- Cargo.toml | 2 +- README.md | 41 ++++ src/application/cli/cli_command.rs | 6 + src/domain.rs | 3 + src/domain/leaderboard.rs | 139 +++++++++++ src/domain/ports.rs | 7 + src/domain/ports/aoc_client.rs | 12 + src/domain/ports/get_leaderboard.rs | 6 + src/domain/ports/input_cache.rs | 7 + src/driver.rs | 33 ++- src/infrastructure.rs | 3 + src/infrastructure/aoc_api.rs | 13 ++ src/infrastructure/aoc_api/aoc_api_impl.rs | 70 ++++++ .../aoc_api/aoc_client_impl.rs} | 114 ++------- .../aoc_api/get_leaderboard_impl.rs | 89 ++++++++ src/infrastructure/cli_display.rs | 14 +- src/infrastructure/configuration.rs | 6 +- src/{ => infrastructure}/input_cache.rs | 25 +- src/lib.rs | 2 - src/main.rs | 9 + tests/resources/leaderboards.html | 216 ++++++++++++++++++ 21 files changed, 700 insertions(+), 117 deletions(-) create mode 100644 src/domain/leaderboard.rs create mode 100644 src/domain/ports.rs create mode 100644 src/domain/ports/aoc_client.rs create mode 100644 src/domain/ports/get_leaderboard.rs create mode 100644 src/domain/ports/input_cache.rs create mode 100644 src/infrastructure/aoc_api.rs create mode 100644 src/infrastructure/aoc_api/aoc_api_impl.rs rename src/{aoc_api.rs => infrastructure/aoc_api/aoc_client_impl.rs} (66%) create mode 100644 src/infrastructure/aoc_api/get_leaderboard_impl.rs rename src/{ => infrastructure}/input_cache.rs (78%) create mode 100644 tests/resources/leaderboards.html diff --git a/Cargo.toml b/Cargo.toml index a00aabe..93262c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "elv" description = "A little CLI helper for Advent of Code. 🎄" -version = "0.12.2" +version = "0.12.3" authors = ["Konrad Pagacz "] edition = "2021" readme = "README.md" diff --git a/README.md b/README.md index 21ac702..2e6ebde 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,47 @@ elv -t -y 2021 -d 1 input # downloads the input for the riddle published on the 1st of December 2021 ``` +### Submitting the solution + +#### Submitting the solution for today's riddle + +This works only while the event is being held, not all the time of the +year. While the event is not held, you need to specify the year and day +of the challenge explicitly using `-y' and`-d' parameters. + +```console +elv -t submit one +elv -t submit two +``` + +#### Submitting the solution for a particular riddle + +You specify the day and the year of the riddle. + +```console +elv -t -y 2021 -d 1 submit one +``` + +### Getting the leaderboard + +#### Getting the leaderboard for this year + +This works only while the event is being held, not all the time of the +year. While the event is not held, you need to specify the year +explicitly using `-y' parameter. + +```console +elv -t leaderboard +``` + +#### Getting the leaderboard for a particular year + +You specify the year of the leaderboard. + +```console +elv -t -y 2021 -d 1 leaderboard +``` + ## FAQ ### How can I store the session token? diff --git a/src/application/cli/cli_command.rs b/src/application/cli/cli_command.rs index a0bac29..4c53431 100644 --- a/src/application/cli/cli_command.rs +++ b/src/application/cli/cli_command.rs @@ -59,6 +59,12 @@ pub enum CliCommand { answer: String, }, + /// Show the leaderboard + /// + /// This command downloads the leaderboard rankings for a particular year. + #[command(visible_aliases = ["l"])] + Leaderboard, + /// 🗑️ Clears the cache /// /// This command will clear the cache of the application. The cache is used diff --git a/src/domain.rs b/src/domain.rs index 6781112..fbc8de2 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -1,6 +1,8 @@ mod description; mod duration_string; pub mod errors; +mod leaderboard; +pub mod ports; mod riddle_part; mod submission; mod submission_result; @@ -8,6 +10,7 @@ mod submission_status; pub use crate::domain::description::Description; pub use crate::domain::duration_string::DurationString; +pub use crate::domain::leaderboard::{Leaderboard, LeaderboardEntry}; pub use crate::domain::riddle_part::RiddlePart; pub use crate::domain::submission::Submission; pub use crate::domain::submission_result::SubmissionResult; diff --git a/src/domain/leaderboard.rs b/src/domain/leaderboard.rs new file mode 100644 index 0000000..83d7fa8 --- /dev/null +++ b/src/domain/leaderboard.rs @@ -0,0 +1,139 @@ +use error_chain::bail; + +use crate::domain::errors::*; + +#[derive(PartialEq, Debug)] +pub struct LeaderboardEntry { + pub position: i32, + pub points: i32, + pub username: String, +} + +impl TryFrom<&str> for LeaderboardEntry { + type Error = Error; + + fn try_from(value: &str) -> Result { + let values: Vec<&str> = value.split_whitespace().collect(); + let (entry_position, entry_points, entry_username); + if let Some(&position) = values.get(0) { + entry_position = position; + } else { + bail!("No leaderboard position"); + } + if let Some(&points) = values.get(1) { + entry_points = points; + } else { + bail!("No points in a leaderboard entry"); + } + entry_username = values + .iter() + .skip(2) + .map(|x| x.to_string()) + .collect::>() + .join(" "); + + Ok(Self { + position: entry_position.replace(r")", "").parse().chain_err(|| { + format!("Error parsing a leaderboard position: {}", entry_position) + })?, + points: entry_points + .parse() + .chain_err(|| format!("Error parsing points: {}", entry_points))?, + username: entry_username, + }) + } +} + +#[derive(PartialEq, Debug)] +pub struct Leaderboard { + pub entries: Vec, +} + +impl FromIterator for Leaderboard { + fn from_iter>(iter: T) -> Self { + Self { + entries: iter.into_iter().collect(), + } + } +} + +impl TryFrom> for Leaderboard { + type Error = Error; + + fn try_from(value: Vec) -> Result { + let entries: Result> = value + .iter() + .map(|entry| LeaderboardEntry::try_from(entry.as_ref())) + .collect(); + match entries { + Ok(entries) => Ok(Leaderboard::from_iter(entries)), + Err(e) => bail!(e.chain_err(|| "One of the entries failed parsing")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn try_from_for_leaderboard_entry() { + let entry = "1) 3693 betaveros"; + let expected_entry = { + let username = "betaveros".to_owned(); + LeaderboardEntry { + position: 1, + points: 3693, + username, + } + }; + let result_entry = LeaderboardEntry::try_from(entry); + match result_entry { + Ok(result) => assert_eq!(expected_entry, result), + Err(e) => panic!("error parsing the entry: {}", e.description()), + } + } + + #[test] + fn try_from_for_leaderboard_entry_anonymous_user() { + let entry = "3) 3042 (anonymous user #1510407)"; + let expected_entry = { + let username = "(anonymous user #1510407)".to_owned(); + LeaderboardEntry { + position: 3, + points: 3042, + username, + } + }; + let result_entry = LeaderboardEntry::try_from(entry); + match result_entry { + Ok(result) => assert_eq!(expected_entry, result), + Err(e) => panic!("error parsing the entry: {}", e.description()), + } + } + + #[test] + fn try_from_string_vec_for_leaderboard() { + let entries: Vec = vec!["1) 3693 betaveros", "2) 14 me"] + .iter() + .map(|&x| x.to_owned()) + .collect(); + let expected_leaderboard = Leaderboard { + entries: vec![ + LeaderboardEntry { + position: 1, + points: 3693, + username: "betaveros".to_owned(), + }, + LeaderboardEntry { + position: 2, + points: 14, + username: "me".to_owned(), + }, + ], + }; + match Leaderboard::try_from(entries) { + Ok(result) => assert_eq!(expected_leaderboard, result), + Err(e) => panic!("Test case failed {}", e.description()), + } + } +} diff --git a/src/domain/ports.rs b/src/domain/ports.rs new file mode 100644 index 0000000..db70d36 --- /dev/null +++ b/src/domain/ports.rs @@ -0,0 +1,7 @@ +mod aoc_client; +mod get_leaderboard; +mod input_cache; + +pub use aoc_client::AocClient; +pub use get_leaderboard::GetLeaderboard; +pub use input_cache::InputCache; diff --git a/src/domain/ports/aoc_client.rs b/src/domain/ports/aoc_client.rs new file mode 100644 index 0000000..b2f201f --- /dev/null +++ b/src/domain/ports/aoc_client.rs @@ -0,0 +1,12 @@ +use crate::domain::errors::*; +use crate::domain::Description; +use crate::domain::{Submission, SubmissionResult}; +use crate::infrastructure::aoc_api::aoc_client_impl::InputResponse; + +pub trait AocClient { + fn submit_answer(&self, submission: Submission) -> Result; + fn get_description(&self, year: &u16, day: &u8) -> Result + where + Desc: Description + TryFrom; + fn get_input(&self, year: &u16, day: &u8) -> InputResponse; +} diff --git a/src/domain/ports/get_leaderboard.rs b/src/domain/ports/get_leaderboard.rs new file mode 100644 index 0000000..25708b0 --- /dev/null +++ b/src/domain/ports/get_leaderboard.rs @@ -0,0 +1,6 @@ +use crate::domain::errors::*; +use crate::domain::Leaderboard; + +pub trait GetLeaderboard { + fn get_leaderboard(&self, year: u16) -> Result; +} diff --git a/src/domain/ports/input_cache.rs b/src/domain/ports/input_cache.rs new file mode 100644 index 0000000..b766e39 --- /dev/null +++ b/src/domain/ports/input_cache.rs @@ -0,0 +1,7 @@ +use crate::domain::errors::*; + +pub trait InputCache { + fn save(input: &str, year: u16, day: u8) -> Result<()>; + fn load(year: u16, day: u8) -> Result; + fn clear() -> Result<()>; +} diff --git a/src/driver.rs b/src/driver.rs index a712427..1615d89 100644 --- a/src/driver.rs +++ b/src/driver.rs @@ -3,10 +3,12 @@ use std::collections::HashMap; use chrono::TimeZone; use error_chain::bail; -use crate::aoc_api::{AocApi, ResponseStatus}; +use crate::domain::ports::{AocClient, GetLeaderboard, InputCache}; use crate::domain::{errors::*, DurationString, Submission, SubmissionStatus}; -use crate::infrastructure::{CliDisplay, Configuration}; -use crate::input_cache::InputCache; +use crate::infrastructure::aoc_api::aoc_client_impl::ResponseStatus; +use crate::infrastructure::aoc_api::AocApi; +use crate::infrastructure::{CliDisplay, FileInputCache}; +use crate::infrastructure::{Configuration, HttpDescription}; use crate::submission_history::SubmissionHistory; #[derive(Debug, Default)] @@ -31,7 +33,7 @@ impl Driver { bail!("The input is not released yet"); } - match InputCache::load(year, day) { + match FileInputCache::load(year, day) { Ok(input) => return Ok(input), Err(e) => match e { Error(ErrorKind::NoCacheFound(message), _) => { @@ -46,10 +48,11 @@ impl Driver { }, }; - let aoc_api = AocApi::new(&self.configuration); + let http_client = AocApi::prepare_http_client(&self.configuration); + let aoc_api = AocApi::new(http_client, self.configuration.clone()); let input = aoc_api.get_input(&year, &day); if input.status == ResponseStatus::Ok { - if InputCache::cache(&input.body, year, day).is_err() { + if FileInputCache::save(&input.body, year, day).is_err() { eprintln!("Failed saving the input to the cache"); } } else { @@ -65,7 +68,8 @@ impl Driver { part: crate::domain::RiddlePart, answer: String, ) -> Result<()> { - let aoc_api = AocApi::new(&self.configuration); + let http_client = AocApi::prepare_http_client(&self.configuration); + let aoc_api = AocApi::new(http_client, self.configuration.clone()); let mut cache: Option = match SubmissionHistory::from_cache(&year, &day) { @@ -127,16 +131,17 @@ impl Driver { } pub fn clear_cache(&self) -> Result<()> { - InputCache::clear().chain_err(|| "Failed to clear the input cache")?; + FileInputCache::clear().chain_err(|| "Failed to clear the input cache")?; SubmissionHistory::clear().chain_err(|| "Failed to clear the submission history cache")?; Ok(()) } /// Returns the description of the riddles pub fn get_description(&self, year: u16, day: u8) -> Result { - let aoc_api = AocApi::new(&self.configuration); + let http_client = AocApi::prepare_http_client(&self.configuration); + let aoc_api = AocApi::new(http_client, self.configuration.clone()); Ok(aoc_api - .get_description(&year, &day)? + .get_description::(&year, &day)? .cli_fmt(&self.configuration)) } @@ -180,6 +185,14 @@ impl Driver { } Ok(directories) } + + pub fn get_leaderboard(&self, year: u16) -> Result { + let http_client = AocApi::prepare_http_client(&self.configuration); + let aoc_client = AocApi::new(http_client, self.configuration.clone()); + let leaderboard = aoc_client.get_leaderboard(year)?; + + Ok(leaderboard.cli_fmt(&self.configuration)) + } } #[cfg(test)] diff --git a/src/infrastructure.rs b/src/infrastructure.rs index e4da8a4..dae9fa9 100644 --- a/src/infrastructure.rs +++ b/src/infrastructure.rs @@ -1,7 +1,10 @@ +pub mod aoc_api; mod cli_display; mod configuration; mod http_description; +mod input_cache; pub use crate::infrastructure::cli_display::CliDisplay; pub use crate::infrastructure::configuration::Configuration; pub use crate::infrastructure::http_description::HttpDescription; +pub use crate::infrastructure::input_cache::FileInputCache; diff --git a/src/infrastructure/aoc_api.rs b/src/infrastructure/aoc_api.rs new file mode 100644 index 0000000..f4d98df --- /dev/null +++ b/src/infrastructure/aoc_api.rs @@ -0,0 +1,13 @@ +use crate::Configuration; + +const AOC_URL: &str = "https://adventofcode.com"; + +#[derive(Debug)] +pub struct AocApi { + http_client: reqwest::blocking::Client, + configuration: Configuration, +} + +mod aoc_api_impl; +pub mod aoc_client_impl; +pub mod get_leaderboard_impl; diff --git a/src/infrastructure/aoc_api/aoc_api_impl.rs b/src/infrastructure/aoc_api/aoc_api_impl.rs new file mode 100644 index 0000000..70b656b --- /dev/null +++ b/src/infrastructure/aoc_api/aoc_api_impl.rs @@ -0,0 +1,70 @@ +use crate::domain::errors::*; +use crate::Configuration; + +use super::{AocApi, AOC_URL}; + +impl AocApi { + pub fn new(http_client: reqwest::blocking::Client, configuration: Configuration) -> AocApi { + Self { + http_client, + configuration, + } + } + + pub fn prepare_http_client(configuration: &Configuration) -> reqwest::blocking::Client { + let cookie = format!("session={}", configuration.aoc.token); + let url = AOC_URL.parse::().expect("Invalid URL"); + let jar = reqwest::cookie::Jar::default(); + jar.add_cookie_str(&cookie, &url); + + reqwest::blocking::Client::builder() + .cookie_provider(std::sync::Arc::new(jar)) + .user_agent(Self::aoc_elf_user_agent()) + .build() + .chain_err(|| "Failed to create HTTP client") + .unwrap() + } + + pub fn aoc_elf_user_agent() -> String { + let pkg_name: &str = env!("CARGO_PKG_NAME"); + let pkg_version: &str = env!("CARGO_PKG_VERSION"); + + format!( + "{}/{} (+{} author:{})", + pkg_name, pkg_version, "https://github.com/kpagacz/elv", "konrad.pagacz@gmail.com" + ) + } + + pub fn extract_wait_time_from_message(message: &str) -> std::time::Duration { + let please_wait_marker = "lease wait "; + let please_wait_position = match message.find(please_wait_marker) { + Some(position) => position, + None => return std::time::Duration::new(0, 0), + }; + let minutes_position = please_wait_position + please_wait_marker.len(); + let next_space_position = message[minutes_position..].find(' ').unwrap(); + let minutes = &message[minutes_position..minutes_position + next_space_position]; + if minutes == "one" { + std::time::Duration::from_secs(60) + } else { + std::time::Duration::from_secs(60 * minutes.parse::().unwrap_or(0)) + } + } + + pub fn get_aoc_answer_selector() -> scraper::Selector { + scraper::Selector::parse("main > article > p").unwrap() + } + + pub fn parse_submission_answer_body(self: &Self, body: &str) -> Result { + let document = scraper::Html::parse_document(body); + let answer = document + .select(&Self::get_aoc_answer_selector()) + .next() + .chain_err(|| "Failed to parse the answer")?; + let answer_text = html2text::from_read( + answer.text().collect::>().join("").as_bytes(), + self.configuration.cli.output_width, + ); + Ok(answer_text) + } +} diff --git a/src/aoc_api.rs b/src/infrastructure/aoc_api/aoc_client_impl.rs similarity index 66% rename from src/aoc_api.rs rename to src/infrastructure/aoc_api/aoc_client_impl.rs index f30e2ef..11fa0ed 100644 --- a/src/aoc_api.rs +++ b/src/infrastructure/aoc_api/aoc_client_impl.rs @@ -1,27 +1,13 @@ -use std::io::Read; - -use crate::domain::{errors::*, RiddlePart, Submission, SubmissionResult, SubmissionStatus}; -use crate::infrastructure::{Configuration, HttpDescription}; +use super::{AocApi, AOC_URL}; +use crate::domain::{ + errors::*, ports::AocClient, RiddlePart, Submission, SubmissionResult, SubmissionStatus, +}; use error_chain::bail; use reqwest::header::{CONTENT_TYPE, ORIGIN}; +use std::io::Read; -const AOC_URL: &str = "https://adventofcode.com"; - -#[derive(Debug)] -pub struct AocApi<'a> { - http_client: reqwest::blocking::Client, - configuration: &'a Configuration, -} - -impl<'a> AocApi<'a> { - pub fn new(configuration: &'a Configuration) -> AocApi<'a> { - Self { - http_client: Self::prepare_http_client(configuration), - configuration, - } - } - - pub fn get_input(&self, year: &u16, day: &u8) -> InputResponse { +impl AocClient for AocApi { + fn get_input(&self, year: &u16, day: &u8) -> InputResponse { let url = match reqwest::Url::parse(&format!("{}/{}/day/{}/input", AOC_URL, year, day)) { Ok(url) => url, Err(_) => { @@ -34,7 +20,9 @@ impl<'a> AocApi<'a> { }; let mut response = match self.http_client.get(url).send() { Ok(response) => response, - Err(_) => return InputResponse::failed(), + Err(_) => { + return InputResponse::new("Failed to get input".to_string(), ResponseStatus::Error) + } }; if response.status() != reqwest::StatusCode::OK { return InputResponse::new( @@ -58,7 +46,7 @@ impl<'a> AocApi<'a> { InputResponse::new(body, ResponseStatus::Ok) } - pub fn submit_answer(&self, submission: Submission) -> Result { + fn submit_answer(&self, submission: Submission) -> Result { let url = reqwest::Url::parse(&format!( "{}/{}/day/{}/answer", AOC_URL, submission.year, submission.day @@ -123,72 +111,20 @@ impl<'a> AocApi<'a> { /// Queries the Advent of Code website for the description of a riddle /// for a given day and year and returns it as a formatted string. - pub fn get_description(&self, year: &u16, day: &u8) -> Result { + fn get_description>( + &self, + year: &u16, + day: &u8, + ) -> Result { let url = reqwest::Url::parse(&format!("{}/{}/day/{}", AOC_URL, year, day)) .chain_err(|| "Failed to form the url for the description")?; Ok(self .http_client .get(url) .send() - .chain_err(|| "Failed to get the response from the AoC server")? - .try_into()?) - } - - fn prepare_http_client(configuration: &Configuration) -> reqwest::blocking::Client { - let cookie = format!("session={}", configuration.aoc.token); - let url = AOC_URL.parse::().expect("Invalid URL"); - let jar = reqwest::cookie::Jar::default(); - jar.add_cookie_str(&cookie, &url); - - reqwest::blocking::Client::builder() - .cookie_provider(std::sync::Arc::new(jar)) - .user_agent(Self::aoc_elf_user_agent()) - .build() - .chain_err(|| "Failed to create HTTP client") - .unwrap() - } - - fn aoc_elf_user_agent() -> String { - let pkg_name: &str = env!("CARGO_PKG_NAME"); - let pkg_version: &str = env!("CARGO_PKG_VERSION"); - - format!( - "{}/{} (+{} author:{})", - pkg_name, pkg_version, "https://github.com/kpagacz/elv", "konrad.pagacz@gmail.com" - ) - } - - fn extract_wait_time_from_message(message: &str) -> std::time::Duration { - let please_wait_marker = "lease wait "; - let please_wait_position = match message.find(please_wait_marker) { - Some(position) => position, - None => return std::time::Duration::new(0, 0), - }; - let minutes_position = please_wait_position + please_wait_marker.len(); - let next_space_position = message[minutes_position..].find(' ').unwrap(); - let minutes = &message[minutes_position..minutes_position + next_space_position]; - if minutes == "one" { - std::time::Duration::from_secs(60) - } else { - std::time::Duration::from_secs(60 * minutes.parse::().unwrap_or(0)) - } - } - - fn get_aoc_answer_selector() -> scraper::Selector { - scraper::Selector::parse("main > article > p").unwrap() - } - - fn parse_submission_answer_body(self: &Self, body: &str) -> Result { - let document = scraper::Html::parse_document(body); - let answer = document - .select(&Self::get_aoc_answer_selector()) - .next() - .chain_err(|| "Failed to parse the answer")?; - let answer_text = html2text::from_read( - answer.text().collect::>().join("").as_bytes(), - self.configuration.cli.output_width, - ); - Ok(answer_text) + .map_err(|_e| "Failed to get the response from the AoC server")? + .try_into() + .map_err(|_e| "Failed to parse the description from the AoC servers' response")?) } } @@ -209,14 +145,12 @@ impl InputResponse { pub fn new(body: String, status: ResponseStatus) -> Self { Self { body, status } } - - pub fn failed() -> Self { - Self::new("Failed to get input".to_string(), ResponseStatus::Error) - } } #[cfg(test)] mod tests { + use crate::Configuration; + use super::*; #[test] @@ -243,7 +177,8 @@ mod tests { "#; let configuration = Configuration::default(); - let api = AocApi::new(&configuration); + let api_client = AocApi::prepare_http_client(&configuration); + let api = AocApi::new(api_client, configuration); let message = api.parse_submission_answer_body(body).unwrap(); assert_eq!(message, "That's the right answer! You are one gold star closer to saving your vacation. [Continue to Part Two]\n"); } @@ -262,7 +197,8 @@ mod tests { "#; let configuration = Configuration::default(); - let api = AocApi::new(&configuration); + let http_client = AocApi::prepare_http_client(&configuration); + let api = AocApi::new(http_client, configuration); let message = api.parse_submission_answer_body(body).unwrap(); assert_eq!(message, concat!( "That's not the right answer. If you're stuck, make sure you're using the full input data; there are also some general\n", diff --git a/src/infrastructure/aoc_api/get_leaderboard_impl.rs b/src/infrastructure/aoc_api/get_leaderboard_impl.rs new file mode 100644 index 0000000..ef639f6 --- /dev/null +++ b/src/infrastructure/aoc_api/get_leaderboard_impl.rs @@ -0,0 +1,89 @@ +use std::io::Read; + +use crate::domain::{errors::*, ports::GetLeaderboard, Leaderboard}; + +use super::{AocApi, AOC_URL}; + +impl AocApi { + fn parse_leaderboard_response(response_body: String) -> Result { + let leaderboard_entries_selector = scraper::Selector::parse(".leaderboard-entry") + .map_err(|_err| "Error when parsing the leaderboard css selector")?; + let html = scraper::Html::parse_document(&response_body); + let leaderboard_position_selector = + scraper::Selector::parse(".leaderboard-position").unwrap(); + let leaderboard_points_selector = + scraper::Selector::parse(".leaderboard-totalscore").unwrap(); + let entries = html + .select(&leaderboard_entries_selector) + .enumerate() + .map(|(id, selected)| { + let mut position = selected + .select(&leaderboard_position_selector) + .flat_map(|position| position.text()) + .collect::>() + .join("") + .trim() + .trim_end_matches(")") + .to_owned(); + if position.is_empty() { + position = id.to_string(); + } + let points = selected + .select(&leaderboard_points_selector) + .flat_map(|points| points.text()) + .collect::>() + .join("") + .trim() + .to_owned(); + let mut name = selected + .children() + .filter_map(|node| match node.value() { + scraper::Node::Text(text) => Some(&text[..]), + _ => None, + }) + .collect::>() + .join("") + .trim() + .to_owned(); + if name.is_empty() { + let a_selector = scraper::Selector::parse(".leaderboard-anon, a").unwrap(); + name = selected + .select(&a_selector) + .take(1) + .flat_map(|node| node.text()) + .collect::>() + .join(""); + } + format!("{position} {points} {name}") + }) + .collect::>(); + + Leaderboard::try_from(entries) + } +} + +impl GetLeaderboard for AocApi { + fn get_leaderboard(&self, year: u16) -> Result { + let url = reqwest::Url::parse(&format!("{}/{}/leaderboard", AOC_URL, year))?; + let mut response = self.http_client.get(url).send()?.error_for_status()?; + let mut body = String::from(""); + response.read_to_string(&mut body)?; + + Self::parse_leaderboard_response(body) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + #[test] + fn parse_leaderboard_response() { + let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + d.push("tests/resources/leaderboards.html"); + let contents = std::fs::read_to_string(d.into_os_string().into_string().unwrap()).unwrap(); + let leaderboard = AocApi::parse_leaderboard_response(contents); + assert!(leaderboard.is_ok()); + } +} diff --git a/src/infrastructure/cli_display.rs b/src/infrastructure/cli_display.rs index 6d53165..8a64744 100644 --- a/src/infrastructure/cli_display.rs +++ b/src/infrastructure/cli_display.rs @@ -1,5 +1,17 @@ -use crate::Configuration; +use crate::{domain::Leaderboard, Configuration}; pub trait CliDisplay { fn cli_fmt(&self, configuration: &Configuration) -> String; } + +impl CliDisplay for Leaderboard { + fn cli_fmt(&self, _configuration: &Configuration) -> String { + let leaderboard_text = self + .entries + .iter() + .map(|entry| format!("{}) {} {}", entry.position, entry.points, entry.username)) + .collect::>() + .join("\n"); + format!("{}", leaderboard_text) + } +} diff --git a/src/infrastructure/configuration.rs b/src/infrastructure/configuration.rs index 29c9164..ba91c75 100644 --- a/src/infrastructure/configuration.rs +++ b/src/infrastructure/configuration.rs @@ -1,6 +1,6 @@ use crate::domain::errors::*; -#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct AocConfiguration { #[serde(default = "default_token")] pub token: String, @@ -18,7 +18,7 @@ fn default_token() -> String { "".to_string() } -#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize)] pub struct CliConfiguration { pub output_width: usize, } @@ -29,7 +29,7 @@ impl Default for CliConfiguration { } } -#[derive(Debug, serde::Deserialize, serde::Serialize, Default)] +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)] pub struct Configuration { #[serde(default)] pub aoc: AocConfiguration, diff --git a/src/input_cache.rs b/src/infrastructure/input_cache.rs similarity index 78% rename from src/input_cache.rs rename to src/infrastructure/input_cache.rs index 0509282..04da194 100644 --- a/src/input_cache.rs +++ b/src/infrastructure/input_cache.rs @@ -1,18 +1,21 @@ use crate::domain::errors::*; +use crate::domain::ports::InputCache; use crate::Configuration; use error_chain::bail; -pub struct InputCache {} +pub struct FileInputCache; -impl InputCache { +impl FileInputCache { fn cache_path(year: u16, day: u8) -> std::path::PathBuf { Configuration::get_project_directories() .cache_dir() .join("inputs") .join(format!("input-{}-{:02}", year, day)) } +} - pub fn cache(input: &str, year: u16, day: u8) -> Result<()> { +impl InputCache for FileInputCache { + fn save(input: &str, year: u16, day: u8) -> Result<()> { let cache_path = Self::cache_path(year, day); if !cache_path.exists() { std::fs::create_dir_all(cache_path.parent().unwrap()).chain_err(|| { @@ -31,7 +34,7 @@ impl InputCache { Ok(()) } - pub fn load(year: u16, day: u8) -> Result { + fn load(year: u16, day: u8) -> Result { let cache_path = Self::cache_path(year, day); if !cache_path.exists() { bail!(ErrorKind::NoCacheFound(format!( @@ -48,7 +51,7 @@ impl InputCache { } } - pub fn clear() -> Result<()> { + fn clear() -> Result<()> { let binding = Configuration::get_project_directories(); let cache_dir = binding.cache_dir().join("inputs"); if cache_dir.exists() { @@ -62,20 +65,20 @@ impl InputCache { #[cfg(test)] mod tests { - use super::InputCache; - use crate::domain::errors::*; + use super::FileInputCache; + use crate::domain::{errors::*, ports::InputCache}; #[test] fn cache_tests() -> Result<()> { let input = "test input"; let year = 1000; let day = 1; - InputCache::cache(input, year, day)?; - let cached_input = InputCache::load(year, day)?; + FileInputCache::save(input, year, day)?; + let cached_input = FileInputCache::load(year, day)?; assert_eq!(input, cached_input); - InputCache::clear()?; - assert!(InputCache::load(year, day).is_err()); + FileInputCache::clear()?; + assert!(FileInputCache::load(year, day).is_err()); Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 84f5a02..7b439c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,7 @@ -mod aoc_api; mod application; mod domain; mod driver; mod infrastructure; -mod input_cache; mod submission_history; pub use crate::application::cli::CliCommand; diff --git a/src/main.rs b/src/main.rs index 457b4f4..7849509 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,7 @@ fn main() { handle_description_command(builder, year.unwrap(), day.unwrap(), width) } CliCommand::ListDirs => handle_list_dirs_command(builder), + CliCommand::Leaderboard => handle_get_leaderboard(builder, year.unwrap()), } fn handle_input_command( @@ -138,4 +139,12 @@ fn main() { }); configuration } + + fn handle_get_leaderboard(configuration_builder: ConfigBuilder, year: u16) { + let driver = Driver::new(get_configuration(configuration_builder)); + match driver.get_leaderboard(year) { + Ok(text) => println!("{text}"), + Err(e) => eprintln!("Error when getting the leaderboards: {}", e.description()), + } + } } diff --git a/tests/resources/leaderboards.html b/tests/resources/leaderboards.html new file mode 100644 index 0000000..e189f0d --- /dev/null +++ b/tests/resources/leaderboards.html @@ -0,0 +1,216 @@ + + + + +Leaderboard - Advent of Code 2022 + + + + + + + + +
+ + + +
+

Per Day: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25

+
+

Below is the Advent of Code 2022 overall leaderboard; these are the 100 users with the highest total score. Getting a star first is worth 100 points, second is 99, and so on down to 1 point at 100th place.

+

You can change how you appear here on the [Settings] page. You can also view your own [Personal Stats] or use a [Private Leaderboard].

+
1) 3693 betaveros (AoC++)
+
2) 3119 dan-simon
+
3) 3042 (anonymous user #1510407) (AoC++)
+
4) 2860 Antonio Molina
+
5) 2804 tckmn
+
6) 2754 jonathanpaulson (AoC++)
+
7) 2707 leijurv (AoC++)
+
8) 2704 5space (AoC++)
+
9) 2663 D. Salgado
+
10) 2609 hyper-neutrino (AoC++)
+
11) 2535 Nathan Fenner (AoC++)
+
12) 2517 boboquack
+
13) 2459 David Wahler (AoC++)
+
14) 2447 bluepichu
+
15) 2386 nthistle (AoC++)
+
16) 2358 Carl Schildkraut
+
17) 2320 Ian DeHaan
+
18) 2169 nim-ka
+
19) 2110 Robert Xiao
+
20) 2105 Tim Vermeulen (AoC++)
+
21) 2096 Michal Forišek
+
22) 1976 mrphlip (AoC++)
+
23) 1962 Brandon Lin
+
24) 1902 Oskar Haarklou Veileborg (AoC++)
+
25) 1886 mserrano
+
26) 1832 Maxwell Zen
+
27) 1724 Lewin Gan
+
28) 1716 Joshua Chen
+
29) 1699 Oliver Ni (AoC++)
+
30) 1694 LegionMammal978
+
31) 1660 Kirby703
+
32) 1644 Kroppeb (AoC++)
+
33) 1612 connorjayr (AoC++)
+
34) 1569 Patrick Hogg (AoC++)
+
35) 1562 ZED.Charley William Thairo
+
36) 1556 larry
+
37) 1515 Anish Singhani
+
38) 1485 Vladislav Isenbaev
+
39) 1438 Ryan Hitchman (AoC++)
+
40) 1386 dtinth (AoC++)
+
41) 1325 Paul Draper
+
42) 1295 Jared Hughes
+
43) 1245 xnkvbo
+
44) 1243 Noble Mushtak
+
45) 1240 Jan Verbeek
+
46) 1200 Kami Leon
+
47) 1170 alcatrazEscapee
+
48) 1167 FillyAutomatic
+
49) 1159 Xavier Cooney (AoC++)
+
50) 1148 (anonymous user #44119)
+
51) 1141 Golovanov399
+
52) 1134 Martin Camacho (AoC++)
+
53) 1132 mangoqwq
+
54) 1126 Erik Amirell Eklöf (Eae02)
+
55) 1121 Neal Wu
+
56) 1116 Max Murin
+
57) 1105 Craig Gidney
+
58) 1059 SeanRBurton
+
59) 1054 bcc32 (AoC++)
+
60) 1042 Kevin Sheng
+
61) 1034 Zeyu Chen
+
62) 1017 Gorane7
+
63) 993 Tobias-Glimmerfors
+
64) 992 GregorKikelj
+
65) 971 Joseph Durie
+
66) 910 Kye W. Shi (AoC++)
+
67) 906 Thomas Feld
+
68) 896 hughcoleman
+
69) 887 Ivan Galakhov (AoC++)
+
70) 882 petertseng
+
71) 879 Zack Lee
+
72) 876 Adib Surani
+
73) 855 vstrimaitis (AoC++)
+
74) 852 Balint R
+
75) 851 Epiphane (AoC++)
+
76) 824 Thomas Neill
+
77) 823 zacharybarbanell
+
78) 820 Andrey Anurin (AoC++)
+
79) 816 Yiming Li
+
80) 813 Evan Zhang
+
81) 800 Verulean
+
82) 781 Benedikt Werner
+
83) 759 Tris Emmy Wilson (AoC++)
+
84) 755 Cyprien Mangin
+
85) 751 jebouin (AoC++)
+
86) 739 Daniel Huang
+
87) 738 ruuddotorg
+
88) 733 rhendric
+
89) 731 lucifer1004 (AoC++)
+
90) 720 (anonymous user #60233) (AoC++)
+
91) 719 PoustouFlan
+
92) 716 Akimitsu Hogge
+
93) 709 Davis Yoshida
+
709 PikMike
+
95) 703 btnlq
+
96) 685 Evan Howard (AoC++)
+
97) 658 msullivan (AoC++)
+
98) 657 Mercerenies
+
99) 648 skaldskaparmal
+
100) 640 glguy (AoC++)
+
+ + + + + +