aoc_client/
lib.rs

1use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeZone, Utc};
2use colored::{Color, Colorize};
3use dirs::{config_dir, home_dir};
4use html2md::parse_html;
5use html2text::{
6    from_read, from_read_with_decorator,
7    render::text_renderer::TrivialDecorator,
8};
9use http::StatusCode;
10use log::{debug, info, warn};
11use regex::Regex;
12use reqwest::blocking::Client as HttpClient;
13use reqwest::header::{
14    HeaderMap, HeaderValue, CONTENT_TYPE, COOKIE, USER_AGENT,
15};
16use reqwest::redirect::Policy;
17use serde::Deserialize;
18use std::cmp::{Ordering, Reverse};
19use std::collections::HashMap;
20use std::env;
21use std::fmt::{Display, Formatter};
22use std::fs::{read_to_string, OpenOptions};
23use std::io::Write;
24use std::path::{Path, PathBuf};
25use thiserror::Error;
26
27pub type PuzzleYear = i32;
28pub type PuzzleDay = u32;
29pub type LeaderboardId = u32;
30type MemberId = u64;
31type Score = u64;
32
33#[derive(Debug)]
34pub enum PuzzlePart {
35    PartOne,
36    PartTwo,
37}
38
39#[derive(Debug)]
40pub enum SubmissionOutcome {
41    Correct,
42    Incorrect,
43    Wait,
44    WrongLevel,
45}
46
47const FIRST_EVENT_YEAR: PuzzleYear = 2015;
48const DECEMBER: u32 = 12;
49const FIRST_PUZZLE_DAY: PuzzleDay = 1;
50const LAST_PUZZLE_DAY: PuzzleDay = 25;
51const RELEASE_TIMEZONE_OFFSET: i32 = -5 * 3600;
52
53const SESSION_COOKIE_FILE: &str = "adventofcode.session";
54const HIDDEN_SESSION_COOKIE_FILE: &str = ".adventofcode.session";
55const SESSION_COOKIE_ENV_VAR: &str = "ADVENT_OF_CODE_SESSION";
56
57const DEFAULT_COL_WIDTH: usize = 80;
58
59const PKG_REPO: &str = env!("CARGO_PKG_REPOSITORY");
60const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
61
62const GOLD: Color = Color::Yellow;
63const SILVER: Color = Color::TrueColor {
64    r: 160,
65    g: 160,
66    b: 160,
67};
68const DARK_GRAY: Color = Color::TrueColor {
69    r: 96,
70    g: 96,
71    b: 96,
72};
73
74pub type AocResult<T> = Result<T, AocError>;
75
76#[derive(Error, Debug)]
77pub enum AocError {
78    #[error("Invalid puzzle date: day {0}, year {1}")]
79    InvalidPuzzleDate(PuzzleDay, PuzzleYear),
80
81    #[error("{0} is not a valid Advent of Code year")]
82    InvalidEventYear(PuzzleYear),
83
84    #[error("{0} is not a valid Advent of Code day")]
85    InvalidPuzzleDay(PuzzleDay),
86
87    #[error("Puzzle {0} of {1} is still locked")]
88    LockedPuzzle(PuzzleDay, PuzzleYear),
89
90    #[error("Session cookie file not found in home or config directory")]
91    SessionFileNotFound,
92
93    #[error("Failed to read session cookie from '{filename}': {source}")]
94    SessionFileReadError {
95        filename: String,
96        #[source]
97        source: std::io::Error,
98    },
99
100    #[error("Invalid session cookie")]
101    InvalidSessionCookie,
102
103    #[error("HTTP request error: {0}")]
104    HttpRequestError(#[from] reqwest::Error),
105
106    #[error("Failed to parse Advent of Code response")]
107    AocResponseError,
108
109    #[error("The private leaderboard does not exist or you are not a member")]
110    PrivateLeaderboardNotAvailable,
111
112    #[error("Failed to write to file '{filename}': {source}")]
113    FileWriteError {
114        filename: String,
115        #[source]
116        source: std::io::Error,
117    },
118
119    #[error("Failed to create client due to missing field: {0}")]
120    ClientFieldMissing(String),
121
122    #[error("Invalid puzzle part number")]
123    InvalidPuzzlePart,
124
125    #[error("Output width must be greater than zero")]
126    InvalidOutputWidth,
127}
128
129pub struct AocClient {
130    session_cookie: String,
131    unlock_datetime: DateTime<FixedOffset>,
132    year: PuzzleYear,
133    day: PuzzleDay,
134    output_width: usize,
135    overwrite_files: bool,
136    input_filename: PathBuf,
137    puzzle_filename: PathBuf,
138    show_html_markup: bool,
139}
140
141#[must_use]
142pub struct AocClientBuilder {
143    session_cookie: Option<String>,
144    year: Option<PuzzleYear>,
145    day: Option<PuzzleDay>,
146    output_width: usize,
147    overwrite_files: bool,
148    input_filename: PathBuf,
149    puzzle_filename: PathBuf,
150    show_html_markup: bool,
151}
152
153impl AocClient {
154    pub fn builder() -> AocClientBuilder {
155        AocClientBuilder::default()
156    }
157
158    pub fn day_unlocked(&self) -> bool {
159        let timezone = FixedOffset::east_opt(RELEASE_TIMEZONE_OFFSET).unwrap();
160        let now = timezone.from_utc_datetime(&Utc::now().naive_utc());
161        now.signed_duration_since(self.unlock_datetime)
162            .num_milliseconds()
163            >= 0
164    }
165
166    fn ensure_day_unlocked(&self) -> AocResult<()> {
167        if self.day_unlocked() {
168            Ok(())
169        } else {
170            Err(AocError::LockedPuzzle(self.day, self.year))
171        }
172    }
173
174    pub fn get_puzzle_html(&self) -> AocResult<String> {
175        self.ensure_day_unlocked()?;
176
177        debug!("🦌 Fetching puzzle for day {}, {}", self.day, self.year);
178
179        let url =
180            format!("https://adventofcode.com/{}/day/{}", self.year, self.day);
181        let response = http_client(&self.session_cookie, "text/html")?
182            .get(url)
183            .send()
184            .and_then(|response| response.error_for_status())
185            .and_then(|response| response.text())?;
186        let puzzle_html = Regex::new(r"(?i)(?s)<main>(?P<main>.*)</main>")
187            .unwrap()
188            .captures(&response)
189            .ok_or(AocError::AocResponseError)?
190            .name("main")
191            .unwrap()
192            .as_str()
193            .to_string();
194
195        Ok(puzzle_html)
196    }
197
198    pub fn get_input(&self) -> AocResult<String> {
199        self.ensure_day_unlocked()?;
200
201        debug!("🦌 Fetching input for day {}, {}", self.day, self.year);
202
203        let url = format!(
204            "https://adventofcode.com/{}/day/{}/input",
205            self.year, self.day
206        );
207        http_client(&self.session_cookie, "text/plain")?
208            .get(url)
209            .send()
210            .and_then(|response| response.error_for_status())
211            .and_then(|response| response.text())
212            .map_err(AocError::from)
213    }
214
215    fn submit_answer_html<P, D>(
216        &self,
217        puzzle_part: P,
218        answer: D,
219    ) -> AocResult<String>
220    where
221        P: TryInto<PuzzlePart>,
222        AocError: From<P::Error>,
223        D: Display,
224    {
225        self.ensure_day_unlocked()?;
226        let part: PuzzlePart = puzzle_part.try_into()?;
227
228        debug!(
229            "🦌 Submitting answer for part {part}, day {}, {}",
230            self.day, self.year
231        );
232
233        let url = format!(
234            "https://adventofcode.com/{}/day/{}/answer",
235            self.year, self.day
236        );
237        let content_type = "application/x-www-form-urlencoded";
238        let response = http_client(&self.session_cookie, content_type)?
239            .post(url)
240            .body(format!("level={part}&answer={answer}"))
241            .send()
242            .and_then(|response| response.error_for_status())
243            .and_then(|response| response.text())
244            .map_err(AocError::HttpRequestError)?;
245
246        let outcome_html = Regex::new(r"(?i)(?s)<main>(?P<main>.*)</main>")
247            .unwrap()
248            .captures(&response)
249            .ok_or(AocError::AocResponseError)?
250            .name("main")
251            .unwrap()
252            .as_str()
253            .to_string();
254
255        Ok(outcome_html)
256    }
257
258    pub fn submit_answer<P, D>(
259        &self,
260        puzzle_part: P,
261        answer: D,
262    ) -> AocResult<SubmissionOutcome>
263    where
264        P: TryInto<PuzzlePart>,
265        AocError: From<P::Error>,
266        D: Display,
267    {
268        let outcome = self.submit_answer_html(puzzle_part, answer)?;
269        if outcome.contains("That's the right answer") {
270            Ok(SubmissionOutcome::Correct)
271        } else if outcome.contains("That's not the right answer") {
272            Ok(SubmissionOutcome::Incorrect)
273        } else if outcome.contains("You gave an answer too recently") {
274            Ok(SubmissionOutcome::Wait)
275        } else if outcome
276            .contains("You don't seem to be solving the right level")
277        {
278            Ok(SubmissionOutcome::WrongLevel)
279        } else {
280            Err(AocError::AocResponseError)
281        }
282    }
283
284    pub fn submit_answer_and_show_outcome<P, D>(
285        &self,
286        puzzle_part: P,
287        answer: D,
288    ) -> AocResult<()>
289    where
290        P: TryInto<PuzzlePart>,
291        AocError: From<P::Error>,
292        D: Display,
293    {
294        let outcome_html = self.submit_answer_html(puzzle_part, answer)?;
295        println!("\n{}", self.html2text(&outcome_html));
296        Ok(())
297    }
298
299    pub fn show_puzzle(&self) -> AocResult<()> {
300        let puzzle_html = self.get_puzzle_html()?;
301        println!("\n{}", self.html2text(&puzzle_html));
302        Ok(())
303    }
304
305    pub fn save_puzzle_markdown(&self) -> AocResult<()> {
306        let puzzle_html = self.get_puzzle_html()?;
307        let puzzle_markdow = parse_html(&puzzle_html);
308        save_file(
309            &self.puzzle_filename,
310            self.overwrite_files,
311            &puzzle_markdow,
312        )?;
313        info!("🎅 Saved puzzle to '{}'", self.puzzle_filename.display());
314        Ok(())
315    }
316
317    pub fn save_input(&self) -> AocResult<()> {
318        let input = self.get_input()?;
319        save_file(&self.input_filename, self.overwrite_files, &input)?;
320        info!("🎅 Saved input to '{}'", self.input_filename.display());
321        Ok(())
322    }
323
324    pub fn get_calendar_html(&self) -> AocResult<String> {
325        debug!("🦌 Fetching {} calendar", self.year);
326
327        let url = format!("https://adventofcode.com/{}", self.year);
328        let response = http_client(&self.session_cookie, "text/html")?
329            .get(url)
330            .send()?;
331
332        if response.status() == StatusCode::NOT_FOUND {
333            // A 402 reponse means the calendar for
334            // the requested year is not yet available
335            return Err(AocError::InvalidEventYear(self.year));
336        }
337
338        let contents = response.error_for_status()?.text()?;
339
340        if Regex::new(r#"href="/[0-9]{4}/auth/login""#)
341            .unwrap()
342            .is_match(&contents)
343        {
344            warn!(
345                "🍪 It looks like you are not logged in, try logging in again"
346            );
347        }
348
349        let main = Regex::new(r"(?i)(?s)<main>(?P<main>.*)</main>")
350            .unwrap()
351            .captures(&contents)
352            .ok_or(AocError::AocResponseError)?
353            .name("main")
354            .unwrap()
355            .as_str()
356            .to_string();
357
358        // Remove elements that won't render well in the terminal
359        let cleaned_up = Regex::new(concat!(
360            // Remove 2015 "calendar-bkg"
361            r#"(<div class="calendar-bkg">[[:space:]]*"#,
362            r#"(<div>[^<]*</div>[[:space:]]*)*</div>)"#,
363            // Remove 2017 "naughty/nice" animation
364            r#"|(<div class="calendar-printer">(?s:.)*"#,
365            r#"\|O\|</span></div>[[:space:]]*)"#,
366            // Remove 2018 "space mug"
367            r#"|(<pre id="spacemug"[^>]*>[^<]*</pre>)"#,
368            // Remove 2019 shadows
369            r#"|(<span style="color[^>]*position:absolute"#,
370            r#"[^>]*>\.</span>)"#,
371            // Remove 2019 "sunbeam"
372            r#"|(<span class="sunbeam"[^>]*>"#,
373            r#"<span style="animation-delay[^>]*>\*</span></span>)"#,
374        ))
375        .unwrap()
376        .replace_all(&main, "")
377        .to_string();
378
379        let class_regex =
380            Regex::new(r#"<a [^>]*class="(?P<class>[^"]*)""#).unwrap();
381        let star_regex = Regex::new(concat!(
382            r#"(?P<stars><span class="calendar-mark-complete">\*</span>"#,
383            r#"<span class="calendar-mark-verycomplete">\*</span>)"#,
384        ))
385        .unwrap();
386
387        // Remove stars that have not been collected
388        let calendar = cleaned_up
389            .lines()
390            .map(|line| {
391                let class = class_regex
392                    .captures(line)
393                    .and_then(|c| c.name("class"))
394                    .map(|c| c.as_str())
395                    .unwrap_or("");
396
397                let stars = if class.contains("calendar-verycomplete") {
398                    "**"
399                } else if class.contains("calendar-complete") {
400                    "*"
401                } else {
402                    ""
403                };
404
405                star_regex.replace(line, stars)
406            })
407            .collect::<Vec<_>>()
408            .join("\n");
409
410        Ok(calendar)
411    }
412
413    pub fn show_calendar(&self) -> AocResult<()> {
414        let calendar_html = self.get_calendar_html()?;
415        let calendar_text = from_read_with_decorator(
416            calendar_html.as_bytes(),
417            self.output_width,
418            TrivialDecorator::new(),
419        );
420        println!("\n{calendar_text}");
421        Ok(())
422    }
423
424    fn get_private_leaderboard(
425        &self,
426        leaderboard_id: LeaderboardId,
427    ) -> AocResult<PrivateLeaderboard> {
428        debug!("🦌 Fetching private leaderboard {leaderboard_id}");
429
430        let url = format!(
431            "https://adventofcode.com/{}/leaderboard/private/view\
432            /{leaderboard_id}.json",
433            self.year,
434        );
435        let response = http_client(&self.session_cookie, "application/json")?
436            .get(url)
437            .send()
438            .and_then(|response| response.error_for_status())?;
439
440        if response.status() == StatusCode::FOUND {
441            // A 302 reponse is a redirect and it means
442            // the leaderboard doesn't exist or we can't access it
443            return Err(AocError::PrivateLeaderboardNotAvailable);
444        }
445
446        response.json().map_err(AocError::from)
447    }
448
449    pub fn show_private_leaderboard(
450        &self,
451        leaderboard_id: LeaderboardId,
452    ) -> AocResult<()> {
453        let last_unlocked_day = last_unlocked_day(self.year)
454            .ok_or(AocError::InvalidEventYear(self.year))?;
455        let leaderboard = self.get_private_leaderboard(leaderboard_id)?;
456        let owner_name = leaderboard
457            .get_owner_name()
458            .ok_or(AocError::AocResponseError)?;
459
460        println!(
461            "Private leaderboard of {} for Advent of Code {}.\n\n\
462            {} indicates the user got both stars for that day,\n\
463            {} means just the first star, and a {} means none.\n",
464            owner_name.bold(),
465            self.year.to_string().bold(),
466            "Gold *".color(GOLD),
467            "silver *".color(SILVER),
468            "gray dot (.)".color(DARK_GRAY),
469        );
470
471        let mut members: Vec<_> = leaderboard.members.values().collect();
472        members.sort_by_key(|member| Reverse(*member));
473
474        let highest_score = members.first().map(|m| m.local_score).unwrap_or(0);
475        let score_width = highest_score.to_string().len();
476        let highest_rank = 1 + leaderboard.members.len();
477        let rank_width = highest_rank.to_string().len();
478        let header_pad: String =
479            vec![' '; rank_width + score_width].into_iter().collect();
480
481        for header in ["         1111111111222222", "1234567890123456789012345"]
482        {
483            let (on, off) = header.split_at(last_unlocked_day as usize);
484            println!("{header_pad}   {}{}", on, off.color(DARK_GRAY));
485        }
486
487        for (member, rank) in members.iter().zip(1..) {
488            let stars: String = (FIRST_PUZZLE_DAY..=LAST_PUZZLE_DAY)
489                .map(|day| {
490                    if day > last_unlocked_day {
491                        " ".normal()
492                    } else {
493                        match member.count_stars(day) {
494                            2 => "*".color(GOLD),
495                            1 => "*".color(SILVER),
496                            _ => ".".color(DARK_GRAY),
497                        }
498                    }
499                    .to_string()
500                })
501                .collect();
502
503            println!(
504                "{rank:rank_width$}) {:score_width$} {stars}  {}",
505                member.local_score,
506                member.get_name(),
507            );
508        }
509
510        Ok(())
511    }
512
513    fn html2text(&self, html: &str) -> String {
514        if self.show_html_markup {
515            from_read(html.as_bytes(), self.output_width)
516        } else {
517            from_read_with_decorator(
518                html.as_bytes(),
519                self.output_width,
520                TrivialDecorator::new(),
521            )
522        }
523    }
524}
525
526impl Default for AocClientBuilder {
527    fn default() -> Self {
528        let session_cookie = None;
529        let year = None;
530        let day = None;
531        let output_width = term_size::dimensions()
532            .map(|(w, _)| w)
533            .unwrap_or(DEFAULT_COL_WIDTH);
534        let overwrite_files = false;
535        let input_filename = "input".into();
536        let puzzle_filename = "puzzle.md".into();
537        let show_html_markup = false;
538
539        Self {
540            session_cookie,
541            year,
542            day,
543            output_width,
544            overwrite_files,
545            input_filename,
546            puzzle_filename,
547            show_html_markup,
548        }
549    }
550}
551
552impl AocClientBuilder {
553    pub fn build(&self) -> AocResult<AocClient> {
554        for (missing, field) in [
555            (self.session_cookie.is_none(), "session cookie"),
556            (self.year.is_none(), "year"),
557            (self.day.is_none(), "day"),
558        ] {
559            if missing {
560                return Err(AocError::ClientFieldMissing(field.to_string()));
561            }
562        }
563
564        let day = self.day.unwrap();
565        let year = self.year.unwrap();
566        let timezone = FixedOffset::east_opt(RELEASE_TIMEZONE_OFFSET).unwrap();
567        let local_datetime = NaiveDate::from_ymd_opt(year, DECEMBER, day)
568            .ok_or(AocError::InvalidPuzzleDate(day, year))?
569            .and_hms_opt(0, 0, 0)
570            .unwrap();
571        let unlock_datetime = timezone
572            .from_local_datetime(&local_datetime)
573            .single()
574            .ok_or(AocError::InvalidPuzzleDate(day, year))?;
575
576        Ok(AocClient {
577            session_cookie: self.session_cookie.clone().unwrap(),
578            unlock_datetime,
579            year: self.year.unwrap(),
580            day: self.day.unwrap(),
581            output_width: self.output_width,
582            overwrite_files: self.overwrite_files,
583            input_filename: self.input_filename.clone(),
584            puzzle_filename: self.puzzle_filename.clone(),
585            show_html_markup: self.show_html_markup,
586        })
587    }
588
589    pub fn session_cookie(
590        &mut self,
591        session_cookie: impl AsRef<str>,
592    ) -> AocResult<&mut Self> {
593        let cookie = session_cookie.as_ref().trim();
594        if cookie.is_empty() || !cookie.chars().all(|c| c.is_ascii_hexdigit()) {
595            return Err(AocError::InvalidSessionCookie);
596        }
597        self.session_cookie = Some(cookie.to_string());
598        Ok(self)
599    }
600
601    pub fn session_cookie_from_default_locations(
602        &mut self,
603    ) -> AocResult<&mut Self> {
604        if let Ok(cookie) = env::var(SESSION_COOKIE_ENV_VAR) {
605            if !cookie.trim().is_empty() {
606                debug!(
607                    "🍪 Loading session cookie from '{SESSION_COOKIE_ENV_VAR}' \
608                    environment variable"
609                );
610
611                return self.session_cookie(&cookie);
612            }
613
614            warn!(
615                "🍪 Environment variable '{SESSION_COOKIE_ENV_VAR}' is set \
616                but it is empty, ignoring"
617            );
618        }
619
620        let path = if let Some(home_path) = home_dir()
621            .map(|dir| dir.join(HIDDEN_SESSION_COOKIE_FILE))
622            .filter(|file| file.exists())
623        {
624            home_path
625        } else if let Some(config_path) = config_dir()
626            .map(|dir| dir.join(SESSION_COOKIE_FILE))
627            .filter(|file| file.exists())
628        {
629            config_path
630        } else {
631            return Err(AocError::SessionFileNotFound);
632        };
633
634        self.session_cookie_from_file(path)
635    }
636
637    pub fn session_cookie_from_file<P: AsRef<Path>>(
638        &mut self,
639        file: P,
640    ) -> AocResult<&mut Self> {
641        let cookie = read_to_string(&file).map_err(|err| {
642            AocError::SessionFileReadError {
643                filename: file.as_ref().display().to_string(),
644                source: err,
645            }
646        })?;
647
648        debug!(
649            "🍪 Loading session cookie from '{}'",
650            file.as_ref().display()
651        );
652        self.session_cookie(&cookie)
653    }
654
655    pub fn year(&mut self, year: PuzzleYear) -> AocResult<&mut Self> {
656        if year >= FIRST_EVENT_YEAR {
657            self.year = Some(year);
658            Ok(self)
659        } else {
660            Err(AocError::InvalidEventYear(year))
661        }
662    }
663
664    pub fn latest_event_year(&mut self) -> AocResult<&mut Self> {
665        let now = FixedOffset::east_opt(RELEASE_TIMEZONE_OFFSET)
666            .unwrap()
667            .from_utc_datetime(&Utc::now().naive_utc());
668
669        let year = if now.month() < DECEMBER {
670            now.year() - 1
671        } else {
672            now.year()
673        };
674
675        self.year(year)
676    }
677
678    pub fn day(&mut self, day: PuzzleDay) -> AocResult<&mut Self> {
679        if (FIRST_PUZZLE_DAY..=LAST_PUZZLE_DAY).contains(&day) {
680            self.day = Some(day);
681            Ok(self)
682        } else {
683            Err(AocError::InvalidPuzzleDay(day))
684        }
685    }
686
687    pub fn latest_puzzle_day(&mut self) -> AocResult<&mut Self> {
688        if self.year.is_none() {
689            self.latest_event_year()?;
690        }
691
692        let event_year = self.year.unwrap();
693        let now = FixedOffset::east_opt(RELEASE_TIMEZONE_OFFSET)
694            .unwrap()
695            .from_utc_datetime(&Utc::now().naive_utc());
696
697        if event_year == now.year() && now.month() == DECEMBER {
698            if now.day() <= LAST_PUZZLE_DAY {
699                self.day(now.day())
700            } else {
701                self.day(LAST_PUZZLE_DAY)
702            }
703        } else if event_year < now.year() {
704            // For past events, return the last puzzle day
705            self.day(LAST_PUZZLE_DAY)
706        } else {
707            // For future events, return the first puzzle day
708            self.day(FIRST_PUZZLE_DAY)
709        }
710    }
711
712    pub fn output_width(&mut self, width: usize) -> AocResult<&mut Self> {
713        if width > 0 {
714            self.output_width = width;
715            Ok(self)
716        } else {
717            Err(AocError::InvalidOutputWidth)
718        }
719    }
720
721    pub fn overwrite_files(&mut self, overwrite: bool) -> &mut Self {
722        self.overwrite_files = overwrite;
723        self
724    }
725
726    pub fn input_filename<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
727        self.input_filename = path.as_ref().into();
728        self
729    }
730
731    pub fn puzzle_filename<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
732        self.puzzle_filename = path.as_ref().into();
733        self
734    }
735
736    pub fn show_html_markup(&mut self, show: bool) -> &mut Self {
737        self.show_html_markup = show;
738        self
739    }
740}
741
742pub fn last_unlocked_day(year: PuzzleYear) -> Option<PuzzleDay> {
743    let now = FixedOffset::east_opt(RELEASE_TIMEZONE_OFFSET)
744        .unwrap()
745        .from_utc_datetime(&Utc::now().naive_utc());
746
747    if year == now.year() && now.month() == DECEMBER {
748        if now.day() > LAST_PUZZLE_DAY {
749            Some(LAST_PUZZLE_DAY)
750        } else {
751            Some(now.day())
752        }
753    } else if year >= FIRST_EVENT_YEAR && year < now.year() {
754        Some(LAST_PUZZLE_DAY)
755    } else {
756        None
757    }
758}
759
760fn http_client(
761    session_cookie: &str,
762    content_type: &str,
763) -> AocResult<HttpClient> {
764    let cookie_header =
765        HeaderValue::from_str(&format!("session={}", session_cookie.trim()))
766            .map_err(|_| AocError::InvalidSessionCookie)?;
767    let content_type_header = HeaderValue::from_str(content_type).unwrap();
768    let user_agent = format!("{PKG_REPO} {PKG_VERSION}");
769    let user_agent_header = HeaderValue::from_str(&user_agent).unwrap();
770
771    let mut headers = HeaderMap::new();
772    headers.insert(COOKIE, cookie_header);
773    headers.insert(CONTENT_TYPE, content_type_header);
774    headers.insert(USER_AGENT, user_agent_header);
775
776    HttpClient::builder()
777        .default_headers(headers)
778        .redirect(Policy::none())
779        .build()
780        .map_err(AocError::from)
781}
782
783fn save_file<P: AsRef<Path>>(
784    path: P,
785    overwrite: bool,
786    contents: &str,
787) -> AocResult<()> {
788    let mut file = OpenOptions::new();
789    if overwrite {
790        file.create(true);
791    } else {
792        file.create_new(true);
793    };
794
795    file.write(true)
796        .truncate(true)
797        .open(&path)
798        .and_then(|mut file| file.write_all(contents.as_bytes()))
799        .map_err(|err| AocError::FileWriteError {
800            filename: path.as_ref().to_string_lossy().into(),
801            source: err,
802        })
803}
804
805#[derive(Deserialize)]
806struct PrivateLeaderboard {
807    owner_id: MemberId,
808    members: HashMap<MemberId, Member>,
809}
810
811impl PrivateLeaderboard {
812    fn get_owner_name(&self) -> Option<String> {
813        self.members.get(&self.owner_id).map(|m| m.get_name())
814    }
815}
816
817#[derive(Eq, Deserialize)]
818struct Member {
819    id: MemberId,
820    name: Option<String>,
821    local_score: Score,
822    completion_day_level: HashMap<PuzzleDay, DayLevel>,
823}
824
825type DayLevel = HashMap<String, CollectedStar>;
826
827#[derive(Eq, Deserialize, PartialEq)]
828struct CollectedStar {}
829
830impl Member {
831    fn get_name(&self) -> String {
832        self.name
833            .as_ref()
834            .cloned()
835            .unwrap_or(format!("(anonymous user #{})", self.id))
836    }
837
838    fn count_stars(&self, day: PuzzleDay) -> usize {
839        self.completion_day_level
840            .get(&day)
841            .map(|stars| stars.len())
842            .unwrap_or(0)
843    }
844}
845
846impl Ord for Member {
847    fn cmp(&self, other: &Self) -> Ordering {
848        // Members are sorted by increasing local score and then decreasing ID
849        self.local_score
850            .cmp(&other.local_score)
851            .then(self.id.cmp(&other.id).reverse())
852    }
853}
854
855impl PartialOrd for Member {
856    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
857        Some(self.cmp(other))
858    }
859}
860
861impl PartialEq for Member {
862    fn eq(&self, other: &Self) -> bool {
863        self.id == other.id
864    }
865}
866
867impl Display for PuzzlePart {
868    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
869        match self {
870            Self::PartOne => write!(f, "1"),
871            Self::PartTwo => write!(f, "2"),
872        }
873    }
874}
875
876impl TryFrom<&String> for PuzzlePart {
877    type Error = AocError;
878
879    fn try_from(s: &String) -> Result<Self, Self::Error> {
880        s.as_str().try_into()
881    }
882}
883
884impl TryFrom<&str> for PuzzlePart {
885    type Error = AocError;
886
887    fn try_from(s: &str) -> Result<Self, Self::Error> {
888        match s {
889            "1" => Ok(Self::PartOne),
890            "2" => Ok(Self::PartTwo),
891            _ => Err(AocError::InvalidPuzzlePart),
892        }
893    }
894}
895
896impl TryFrom<i64> for PuzzlePart {
897    type Error = AocError;
898
899    fn try_from(n: i64) -> Result<Self, Self::Error> {
900        match n {
901            1 => Ok(Self::PartOne),
902            2 => Ok(Self::PartTwo),
903            _ => Err(AocError::InvalidPuzzlePart),
904        }
905    }
906}