diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b640009..37c7029 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,9 @@ In particular, the commit message should start with one of the following types: - **build**: Changes that affect the build system or external dependencies - **ci**: Changes to CI or release configuration files and scripts +Before submitting your code, please run `cargo clippy` and resolve any warnings +it finds, and make sure your code is properly formatted with `cargo fmt`. + # Ideas - Add a configuration file (in home or `.config` directory) that can store @@ -43,7 +46,7 @@ In particular, the commit message should start with one of the following types: and create a configuration file. - `[l]ogin` - prompt for session cookie and save it in a user-protected file. - `[pe]rsonal-stats` - show [personal stats](https://adventofcode.com/2022/leaderboard/self). - - `[pr]ivate-leaderboard` - show [private leaderboards](https://adventofcode.com/2022/leaderboard/private). + - `[pr]ivate-leaderboard` - already implemented! - `[r]ead` - already implemented! - `[se]t-config` - make changes to configuration file (e.g. `aoc set-config year 2015`). - `[st]ats` - show [event stats](https://adventofcode.com/2022/stats). diff --git a/Cargo.lock b/Cargo.lock index 1bf9532..bf6330f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,7 +22,7 @@ dependencies = [ [[package]] name = "aoc-cli" -version = "0.8.0" +version = "0.9.0" dependencies = [ "chrono", "clap", @@ -73,9 +73,9 @@ checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c" [[package]] name = "cc" -version = "1.0.77" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f73505338f7d905b19d18738976aae232eb46b8efc15554ffc56deb5d9ebe4" +checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d" [[package]] name = "cesu8" @@ -654,9 +654,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec947b7a4ce12e3b87e353abae7ce124d025b6c7d6c5aea5cc0bcf92e9510ded" +checksum = "11b0d96e660696543b251e58030cf9787df56da39dab19ad60eae7353040917e" [[package]] name = "is-terminal" @@ -728,9 +728,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f9f08d8963a6c613f4b1a78f4f4a4dbfadf8e6545b2d72861731e4858b8b47f" +checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4" [[package]] name = "lock_api" @@ -1358,18 +1358,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "256b9932320c590e707b94576e3cc1f7c9024d0ee6612dfbcf1cb106cbe8e055" +checksum = "e326c9ec8042f1b5da33252c8a37e9ffbd2c9bef0155215b6e6c80c790e05f91" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4eae9b04cbffdfd550eb462ed33bc6a1b68c935127d008b27444d08380f94e4" +checksum = "42a3df25b0713732468deadad63ab9da1f1fd75a48a15024b50363f128db627e" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 97836a8..fc57d23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "aoc-cli" description = "Advent of Code command-line tool" -version = "0.8.0" +version = "0.9.0" authors = ["Sergio de Carvalho "] categories = ["command-line-utilities"] edition = "2021" diff --git a/README.md b/README.md index 4647ee6..6b40b4c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ comfort of your terminal. Code event. - Load Advent of Code session cookie from a file. - Save puzzle description to a file in Markdown format. +- Show the state of private leaderboards. ## Installation options 🎅 @@ -112,10 +113,11 @@ Advent of Code command-line tool Usage: aoc [OPTIONS] [COMMAND] Commands: - read Read puzzle statement (the default command) [aliases: r] - download Save puzzle description and input to files [aliases: d] - submit Submit puzzle answer [aliases: s] - help Print this message or the help of the given subcommand(s) + read Read puzzle statement (the default command) [aliases: r] + download Save puzzle description and input to files [aliases: d] + submit Submit puzzle answer [aliases: s] + private-leaderboard Show the state of a private leaderboard [aliases: p] + help Print this message or the help of the given subcommand(s) Options: -d, --day Puzzle day [default: last unlocked day (during Advent of Code month)] @@ -206,12 +208,37 @@ That's the right answer! You are one gold star closer to saving your vacation. [ [1] /2022/day/2#part2 ``` +### Show private leaderboard + +If you are a member of a [private leaderboard](https://adventofcode.com/leaderboard/private), +you can see how you are faring against your friends by passing the leaderboad +number: + +``` +# aoc private-leaderboard 1234 + +[INFO aoc] 🎄 aoc-cli - Advent of Code command-line tool +Private leaderboard of me & my friends for Advent of Code 2022. + + 1111111111222222 + 1234567890123456789012345 + 1) 254 ★★★★★★★★★★★★... Whitney Effertz + 2) 252 ★★★★★★★★★★.★★★. Emery Zboncak + 3) 134 ★★★★★★★........ Ezra Parisian + 4) 72 ★★★★........... Asha Gerlach + 5) 54 ★★★★........... Frederik Robel + 6) 20 ★.............. Graciela Herzog + 7) 0 ............... Thad Prohaska +``` + ### Command abbreviations -Any prefix of a command can be used instead of the full command name. For instance: +Any non-ambiguous prefix of a command can be used instead of the full command +name. For instance: - Instead of `aoc read`, type `aoc r`, `aoc re` or `aoc rea`. - Instead of `aoc download`, type `aoc d`, `aoc do`, `aoc dow`, `aoc down`, etc. - Instead of `aoc submit`, type `aoc s`, `aoc su`, `aoc sub`, etc. +- Instead of `aoc private-leaderboard`, type `aoc p`, `aoc pr`, `aoc pri` etc. ### More examples diff --git a/src/aoc.rs b/src/aoc.rs index 75c4915..4ba5fff 100644 --- a/src/aoc.rs +++ b/src/aoc.rs @@ -3,6 +3,7 @@ use chrono::{Datelike, FixedOffset, NaiveDate, TimeZone, Utc}; use dirs::{config_dir, home_dir}; use html2md::parse_html; use html2text::from_read; +use http::StatusCode; use log::{debug, info}; use regex::Regex; use reqwest::blocking::Client; @@ -12,7 +13,7 @@ use reqwest::header::{ }; use reqwest::redirect::Policy; use serde::Deserialize; -use std::cmp::Reverse; +use std::cmp::{Ordering, Reverse}; use std::collections::HashMap; use std::env; use std::fs::{read_to_string, OpenOptions}; @@ -22,6 +23,8 @@ use thiserror::Error; pub type PuzzleYear = i32; pub type PuzzleDay = u32; +pub type MemberId = u64; +pub type Score = u64; const FIRST_EVENT_YEAR: PuzzleYear = 2015; const DECEMBER: u32 = 12; @@ -43,6 +46,9 @@ pub enum AocError { #[error("Invalid puzzle date: day {0}, year {1}")] InvalidPuzzleDate(PuzzleDay, PuzzleYear), + #[error("{0} is not a valid Advent of Code year")] + InvalidEventYear(PuzzleYear), + #[error("Could not infer puzzle day for year {0}")] NonInferablePuzzleDate(PuzzleYear), @@ -74,6 +80,9 @@ pub enum AocError { #[error("Failed to parse Advent of Code response")] AocResponseError, + #[error("The private leaderboard does not exist or you are not a member")] + PrivateLeaderboardNotAvailable, + #[error("Failed to write to file '{filename}': {source}")] FileWriteError { filename: String, @@ -107,7 +116,11 @@ fn current_event_day(year: PuzzleYear) -> Option { .from_utc_datetime(&Utc::now().naive_utc()); if now.month() == DECEMBER && now.year() == year { - Some(now.day()) + if now.day() > LAST_PUZZLE_DAY { + Some(LAST_PUZZLE_DAY) + } else { + Some(now.day()) + } } else { None } @@ -129,6 +142,22 @@ fn puzzle_unlocked(year: PuzzleYear, day: PuzzleDay) -> AocResult { } } +fn last_unlocked_day(year: PuzzleYear) -> AocResult { + if let Some(day) = current_event_day(year) { + return Ok(day); + } + + let now = FixedOffset::east_opt(RELEASE_TIMEZONE_OFFSET) + .unwrap() + .from_utc_datetime(&Utc::now().naive_utc()); + + if year >= FIRST_EVENT_YEAR && year < now.year() { + Ok(LAST_PUZZLE_DAY) + } else { + Err(AocError::InvalidEventYear(year)) + } +} + fn puzzle_year_day( opt_year: Option, opt_day: Option, @@ -321,53 +350,67 @@ pub fn read( Ok(()) } -fn get_private_leaderboard_results( - args: &Args, +fn get_private_leaderboard( session: &str, - leaderboard: &str, + leaderboard_id: &str, year: PuzzleYear, ) -> AocResult { - debug!("🦌 Fetching private leaderboard {}", leaderboard); + debug!("🦌 Fetching private leaderboard {leaderboard_id}"); let url = format!( - "https://adventofcode.com/{}/leaderboard/private/view/{}.json", - year, leaderboard + "https://adventofcode.com/{year}/leaderboard/private/view\ + /{leaderboard_id}.json", ); - let leaderboard: PrivateLeaderboard = - build_client(session, "application/json")? - .get(&url) - .send() - .and_then(|response| response.error_for_status()) - .and_then(|response| response.json()) - .map_err(AocError::from)?; - Ok(leaderboard) + let response = build_client(session, "application/json")? + .get(&url) + .send() + .and_then(|response| response.error_for_status())?; + + if response.status() == StatusCode::FOUND { + // A 302 reponse is a redirect and it means + // the leaderboard doesn't exist or we can't access it + return Err(AocError::PrivateLeaderboardNotAvailable); + } + + response.json().map_err(AocError::from) } -pub fn show_private_leaderboard_results( +pub fn private_leaderboard( args: &Args, session: &str, - leaderboard: &str, + leaderboard_id: &str, ) -> AocResult<()> { - let (year, day) = puzzle_year_day(args.year, args.day)?; - let leaderboard = - get_private_leaderboard_results(args, session, leaderboard, year)?; + let year = args.year.unwrap_or_else(latest_event_year); + let last_unlocked_day = last_unlocked_day(year)?; + let leaderboard = get_private_leaderboard(session, leaderboard_id, year)?; + let owner_name = leaderboard + .get_owner_name() + .ok_or(AocError::AocResponseError)?; + + println!( + "Private leaderboard of {owner_name} for Advent of Code {year}.\n" + ); let mut members: Vec<_> = leaderboard.members.values().collect(); - members.sort_by_key(|m| Reverse(m.local_score)); - members.iter().enumerate().for_each(|(idx, m)| { - let display_name = m - .name - .clone() - .unwrap_or(format!("anonymous user #{}", m.id)); - - let stars: String = (1..=25) - .map(|d| { - if d > day { + members.sort_by_key(|member| Reverse(*member)); + + let highest_score = members.first().map(|m| m.local_score).unwrap_or(0); + let score_width = highest_score.to_string().len(); + let highest_rank = 1 + leaderboard.members.len(); + let rank_width = highest_rank.to_string().len(); + let header_pad: String = + vec![' '; rank_width + score_width].into_iter().collect(); + println!("{header_pad} 1111111111222222"); + println!("{header_pad} 1234567890123456789012345"); + + for (member, rank) in members.iter().zip(1..) { + let stars: String = (FIRST_PUZZLE_DAY..=LAST_PUZZLE_DAY) + .map(|day| { + if day > last_unlocked_day { ' ' } else { - let stars = m.stars_per_day(d); - match stars { + match member.count_stars(day) { 2 => '★', 1 => '☆', _ => '.', @@ -376,47 +419,74 @@ pub fn show_private_leaderboard_results( }) .collect(); - let order = idx + 1; - println!("{}\t{}\t{}\t{}", order, m.local_score, stars, display_name); - }); + println!( + "{rank:rank_width$}) {:score_width$} {stars} {}", + member.local_score, + member.get_name(), + ); + } Ok(()) } #[derive(Deserialize)] struct PrivateLeaderboard { - owner_id: usize, - event: String, - members: HashMap, + owner_id: MemberId, + members: HashMap, } -#[derive(Deserialize)] +impl PrivateLeaderboard { + fn get_owner_name(&self) -> Option { + self.members.get(&self.owner_id).map(|m| m.get_name()) + } +} + +#[derive(Eq, Deserialize)] struct Member { + id: MemberId, name: Option, - id: u64, - global_score: u64, - local_score: u64, - stars: u8, - completion_day_level: HashMap, + local_score: Score, + completion_day_level: HashMap, } +type DayLevel = HashMap; + +#[derive(Eq, Deserialize, PartialEq)] +struct CollectedStar {} + impl Member { - fn stars_per_day(&self, day: u32) -> u8 { + fn get_name(&self) -> String { + self.name + .as_ref() + .cloned() + .unwrap_or(format!("(anonymous user #{})", self.id)) + } + + fn count_stars(&self, day: PuzzleDay) -> usize { self.completion_day_level .get(&day) - .map(|d| d.stars.len() as u8) + .map(|stars| stars.len()) .unwrap_or(0) } } -#[derive(Deserialize)] -struct DayLevel { - #[serde(flatten)] - stars: HashMap, +impl Ord for Member { + fn cmp(&self, other: &Self) -> Ordering { + // Members are sorted by increasing local score and then decreasing ID + self.local_score + .cmp(&other.local_score) + .then(self.id.cmp(&other.id).reverse()) + } } -#[derive(Deserialize)] -struct Star { - get_star_ts: u64, - star_index: u64, +impl PartialOrd for Member { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl PartialEq for Member { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } } diff --git a/src/args.rs b/src/args.rs index 4e5c530..1d43758 100644 --- a/src/args.rs +++ b/src/args.rs @@ -96,9 +96,12 @@ pub enum Command { answer: String, }, - /// Get private leaderboard results - #[command(visible_alias = "pr")] - PrivateLeaderboard { leaderboard: String }, + /// Show the state of a private leaderboard + #[command(visible_alias = "p")] + PrivateLeaderboard { + /// Private leaderboard ID + leaderboard_id: String, + }, } fn convert_number(s: &str) -> Result diff --git a/src/main.rs b/src/main.rs index c003004..0afeb15 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,7 @@ use args::*; use clap::{crate_description, crate_name, Parser}; use env_logger::{Builder, Env}; use exit_code::*; -use http::StatusCode; -use log::{error, info, LevelFilter}; +use log::{error, info, warn, LevelFilter}; use std::process::exit; const DEFAULT_COL_WIDTH: usize = 80; @@ -25,27 +24,27 @@ fn main() { error!("🔔 {err}"); let exit_code = match err { AocError::InvalidPuzzleDate(..) => USAGE_ERROR, + AocError::InvalidEventYear(..) => USAGE_ERROR, AocError::NonInferablePuzzleDate(..) => USAGE_ERROR, AocError::LockedPuzzle(..) => USAGE_ERROR, AocError::MissingConfigDir => NO_INPUT, AocError::SessionFileReadError { .. } => IO_ERROR, AocError::InvalidSessionCookie { .. } => DATA_ERROR, - AocError::HttpRequestError { source } => { - if let Some(StatusCode::INTERNAL_SERVER_ERROR) = - source.status() - { - // adventofcode.com returns HTTP 500 when session cookie - // is no longer valid - error!( - "🔔 Your session cookie may have expired, try \ - logging in again" - ); - } - FAILURE - } + AocError::HttpRequestError { .. } => FAILURE, AocError::AocResponseError => FAILURE, + AocError::PrivateLeaderboardNotAvailable => FAILURE, AocError::FileWriteError { .. } => CANNOT_CREATE, }; + + if exit_code == FAILURE { + // Unexpected responses from adventofcode.com including + // HTTP 302/400/500 may be due to invalid or expired cookies + warn!( + "🍪 Your session cookie may be invalid or expired, try \ + logging in again" + ); + } + exit(exit_code); } }; @@ -77,8 +76,8 @@ fn run(args: &Args) -> AocResult<()> { Some(Command::Submit { part, answer }) => { submit(args, &session, width, part, answer) } - Some(Command::PrivateLeaderboard { leaderboard }) => { - show_private_leaderboard_results(args, &session, leaderboard) + Some(Command::PrivateLeaderboard { leaderboard_id }) => { + private_leaderboard(args, &session, leaderboard_id) } _ => read(args, &session, width), }