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 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 let cleaned_up = Regex::new(concat!(
360 r#"(<div class="calendar-bkg">[[:space:]]*"#,
362 r#"(<div>[^<]*</div>[[:space:]]*)*</div>)"#,
363 r#"|(<div class="calendar-printer">(?s:.)*"#,
365 r#"\|O\|</span></div>[[:space:]]*)"#,
366 r#"|(<pre id="spacemug"[^>]*>[^<]*</pre>)"#,
368 r#"|(<span style="color[^>]*position:absolute"#,
370 r#"[^>]*>\.</span>)"#,
371 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 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 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 self.day(LAST_PUZZLE_DAY)
706 } else {
707 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 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}