1use crate::{
19 error::Error,
20 http::{ClientOptions, Request, ReqwestTransport, Response, Transport},
21 parse::{self, Hint, ParseError, Submission},
22 puzzle::{BASE_URL, Part, Puzzle, Year},
23};
24use std::{collections::BTreeMap, fmt, time::Duration};
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum Verdict {
30 Correct,
32
33 Incorrect {
35 hint: Option<Hint>,
37 wait: Option<Duration>,
40 },
41
42 AlreadyComplete {
48 correct: bool,
50 },
51
52 WrongLevel,
60}
61
62impl Verdict {
63 #[must_use]
65 pub const fn is_correct(&self) -> bool {
66 matches!(
67 self,
68 Self::Correct | Self::AlreadyComplete { correct: true }
69 )
70 }
71}
72
73impl fmt::Display for Verdict {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 match self {
76 Self::Correct => f.write_str("that's the right answer"),
77 Self::Incorrect { hint, wait } => {
78 f.write_str("that's not the right answer")?;
79 if let Some(hint) = hint {
80 write!(f, "; it is {hint}")?;
81 }
82 if let Some(wait) = wait {
83 write!(f, " (wait {} before trying again)", parse::describe(*wait))?;
84 }
85 Ok(())
86 }
87 Self::AlreadyComplete { correct: true } => {
88 f.write_str("already solved, with this answer")
89 }
90 Self::AlreadyComplete { correct: false } => {
91 f.write_str("already solved, with a different answer")
92 }
93 Self::WrongLevel => f.write_str("there is nothing to answer for this part"),
94 }
95 }
96}
97
98#[derive(Debug, Clone)]
119pub struct Session<T = ReqwestTransport> {
120 transport: T,
121}
122
123impl Session {
124 pub fn new(cookie: &str, identification: &str) -> Result<Self, Error> {
135 Self::configured(&ClientOptions::new(cookie, identification))
136 }
137
138 pub fn configured(options: &ClientOptions) -> Result<Self, Error> {
144 Ok(Self {
145 transport: ReqwestTransport::new(options)?,
146 })
147 }
148}
149
150impl<T: Transport> Session<T> {
151 pub const fn with_transport(transport: T) -> Self {
154 Self { transport }
155 }
156
157 pub const fn transport(&self) -> &T {
159 &self.transport
160 }
161
162 pub async fn input_text(&self, puzzle: Puzzle) -> Result<String, Error> {
170 input_text(&self.transport, puzzle).await
171 }
172
173 pub async fn input_lines(&self, puzzle: Puzzle) -> Result<Vec<String>, Error> {
181 input_lines(&self.transport, puzzle).await
182 }
183
184 pub async fn samples(&self, puzzle: Puzzle) -> Result<Vec<String>, Error> {
193 samples(&self.transport, puzzle).await
194 }
195
196 pub async fn sample_text(&self, puzzle: Puzzle, nth: u8) -> Result<String, Error> {
205 sample_text(&self.transport, puzzle, nth).await
206 }
207
208 pub async fn sample_lines(&self, puzzle: Puzzle, nth: u8) -> Result<Vec<String>, Error> {
217 sample_lines(&self.transport, puzzle, nth).await
218 }
219
220 pub async fn stars(&self) -> Result<BTreeMap<Year, u8>, Error> {
228 stars(&self.transport).await
229 }
230
231 pub async fn accepted_answer(&self, puzzle: Puzzle, part: Part) -> Result<String, Error> {
240 accepted_answer(&self.transport, puzzle, part).await
241 }
242
243 pub async fn submit(&self, puzzle: Puzzle, part: Part, answer: &str) -> Result<Verdict, Error> {
256 submit(&self.transport, puzzle, part, answer).await
257 }
258}
259
260pub async fn input_text<T: Transport>(transport: &T, puzzle: Puzzle) -> Result<String, Error> {
281 let body = get(transport, puzzle.input_url(), Some(puzzle)).await?;
282
283 Ok(body.trim_end_matches('\n').to_owned())
284}
285
286pub async fn input_lines<T: Transport>(
292 transport: &T,
293 puzzle: Puzzle,
294) -> Result<Vec<String>, Error> {
295 Ok(lines(&input_text(transport, puzzle).await?))
296}
297
298pub async fn samples<T: Transport>(transport: &T, puzzle: Puzzle) -> Result<Vec<String>, Error> {
305 Ok(parse::samples(&page(transport, puzzle).await?))
306}
307
308pub async fn sample_text<T: Transport>(
315 transport: &T,
316 puzzle: Puzzle,
317 nth: u8,
318) -> Result<String, Error> {
319 if nth == 0 {
322 return Err(ParseError::SampleZero.into());
323 }
324
325 Ok(parse::sample(&page(transport, puzzle).await?, nth)?)
326}
327
328pub async fn sample_lines<T: Transport>(
334 transport: &T,
335 puzzle: Puzzle,
336 nth: u8,
337) -> Result<Vec<String>, Error> {
338 Ok(lines(&sample_text(transport, puzzle, nth).await?))
339}
340
341pub async fn stars<T: Transport>(transport: &T) -> Result<BTreeMap<Year, u8>, Error> {
349 let body = get(transport, format!("{BASE_URL}/events"), None).await?;
350
351 Ok(parse::stars(&body)?)
352}
353
354pub async fn accepted_answer<T: Transport>(
360 transport: &T,
361 puzzle: Puzzle,
362 part: Part,
363) -> Result<String, Error> {
364 Ok(parse::accepted_answer(
365 &page(transport, puzzle).await?,
366 part,
367 )?)
368}
369
370pub async fn submit<T: Transport>(
387 transport: &T,
388 puzzle: Puzzle,
389 part: Part,
390 answer: &str,
391) -> Result<Verdict, Error> {
392 let request = Request::post_form(
393 puzzle.answer_url(),
394 vec![
395 ("level".to_owned(), part.number().to_string()),
396 ("answer".to_owned(), answer.to_owned()),
397 ],
398 );
399
400 let body = send(transport, request, Some(puzzle)).await?;
401
402 match parse::submission(&body)? {
403 Submission::Correct => Ok(Verdict::Correct),
404 Submission::Incorrect { hint, wait } => Ok(Verdict::Incorrect { hint, wait }),
405 Submission::TooRecent { wait } => Err(Error::Cooldown { wait }),
406 Submission::LoggedOut => Err(Error::Unauthorized),
407 Submission::AlreadyComplete => match accepted_answer(transport, puzzle, part).await {
408 Ok(accepted) => Ok(Verdict::AlreadyComplete {
409 correct: accepted.trim() == answer.trim(),
410 }),
411 Err(Error::Parse(ParseError::AcceptedAnswer { .. })) => Ok(Verdict::WrongLevel),
412 Err(error) => Err(error),
413 },
414 }
415}
416
417async fn page(transport: &impl Transport, puzzle: Puzzle) -> Result<String, Error> {
419 get(transport, puzzle.url(), Some(puzzle)).await
420}
421
422async fn get(
424 transport: &impl Transport,
425 url: String,
426 puzzle: Option<Puzzle>,
427) -> Result<String, Error> {
428 send(transport, Request::get(url), puzzle).await
429}
430
431async fn send(
434 transport: &impl Transport,
435 request: Request,
436 puzzle: Option<Puzzle>,
437) -> Result<String, Error> {
438 let response = transport.execute(request).await?;
439 check(&response, puzzle)?;
440
441 Ok(response.body)
442}
443
444fn lines(body: &str) -> Vec<String> {
446 body.lines().map(ToOwned::to_owned).collect()
447}
448
449fn check(response: &Response, puzzle: Option<Puzzle>) -> Result<(), Error> {
458 if parse::is_logged_out(&response.body) {
459 return Err(Error::Unauthorized);
460 }
461
462 if response.is_success() {
463 return Ok(());
464 }
465
466 match (response.status, puzzle) {
467 (401 | 403, _) => Err(Error::Unauthorized),
468 (404, Some(puzzle)) => Err(Error::Locked { puzzle }),
469 (status, _) => Err(Error::Status { status }),
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use crate::http::fake::FakeTransport;
477
478 const PUZZLE: &str = include_str!("../tests/fixtures/puzzle-day.html");
479 const EVENTS: &str = include_str!("../tests/fixtures/events.html");
480 const EVENTS_LOGGED_OUT: &str = include_str!("../tests/fixtures/events-logged-out.html");
481 const CORRECT: &str = include_str!("../tests/fixtures/submit-correct.html");
482 const WRONG: &str = include_str!("../tests/fixtures/submit-wrong.html");
483 const COOLDOWN: &str = include_str!("../tests/fixtures/submit-cooldown.html");
484 const COMPLETED: &str = include_str!("../tests/fixtures/submit-already-complete.html");
485 const LOGGED_OUT: &str = include_str!("../tests/fixtures/logged-out.html");
486
487 fn puzzle() -> Puzzle {
488 Puzzle::at(2020, 1).expect("2020 has a day 1")
489 }
490
491 fn session(transport: FakeTransport) -> Session<FakeTransport> {
492 Session::with_transport(transport)
493 }
494
495 #[tokio::test]
496 async fn an_input_is_fetched_from_the_input_endpoint_without_its_trailing_newline() {
497 let session = session(FakeTransport::serving("1721\n979\n366\n"));
498
499 let input = session
500 .input_text(puzzle())
501 .await
502 .expect("the fake replies");
503
504 assert_eq!(input, "1721\n979\n366");
505 assert_eq!(
506 session.transport().requested_urls(),
507 ["https://adventofcode.com/2020/day/1/input"]
508 );
509 }
510
511 #[tokio::test]
512 async fn an_input_can_be_read_as_lines() {
513 let session = session(FakeTransport::serving("1721\n979\n366\n"));
514
515 let lines = session
516 .input_lines(puzzle())
517 .await
518 .expect("the fake replies");
519
520 assert_eq!(lines, ["1721", "979", "366"]);
521 }
522
523 #[tokio::test]
524 async fn samples_come_from_the_puzzle_page() {
525 let session = session(FakeTransport::serving(PUZZLE));
526
527 let sample = session
528 .sample_lines(puzzle(), 1)
529 .await
530 .expect("the fake replies");
531
532 assert_eq!(sample, ["1721", "979", "366", "299", "675", "1456"]);
533 assert_eq!(
534 session.transport().requested_urls(),
535 ["https://adventofcode.com/2020/day/1"]
536 );
537 }
538
539 #[tokio::test]
540 async fn a_sample_zero_never_reaches_the_transport() {
541 let session = session(FakeTransport::new());
542
543 let error = session
544 .sample_text(puzzle(), 0)
545 .await
546 .expect_err("sample blocks count from one");
547
548 assert!(
549 matches!(error, Error::Parse(ParseError::SampleZero)),
550 "{error}"
551 );
552 assert!(session.transport().requests().is_empty());
553 }
554
555 #[tokio::test]
556 async fn stars_are_read_from_the_events_page() {
557 let session = session(FakeTransport::serving(EVENTS));
558
559 let stars = session.stars().await.expect("the fake replies");
560
561 assert_eq!(stars.values().copied().collect::<Vec<_>>(), [0, 9, 50, 19]);
562 assert_eq!(
563 session.transport().requested_urls(),
564 ["https://adventofcode.com/events"]
565 );
566 }
567
568 #[tokio::test]
569 async fn an_accepted_answer_is_posted_to_the_answer_endpoint() {
570 let session = session(FakeTransport::serving(CORRECT));
571
572 let verdict = session
573 .submit(puzzle(), Part::Two, "241861950")
574 .await
575 .expect("the fake replies");
576
577 assert_eq!(verdict, Verdict::Correct);
578 assert!(verdict.is_correct());
579
580 let request = session
581 .transport()
582 .requests()
583 .pop()
584 .expect("exactly one request");
585 assert_eq!(request.url, "https://adventofcode.com/2020/day/1/answer");
586 assert_eq!(
587 request.form,
588 [
589 ("level".to_owned(), "2".to_owned()),
590 ("answer".to_owned(), "241861950".to_owned()),
591 ]
592 );
593 }
594
595 #[tokio::test]
596 async fn a_rejected_answer_is_a_verdict_rather_than_an_error() {
597 let session = session(FakeTransport::serving(WRONG));
598
599 let verdict = session
600 .submit(puzzle(), Part::One, "0")
601 .await
602 .expect("the fake replies");
603
604 assert_eq!(
605 verdict,
606 Verdict::Incorrect {
607 hint: None,
608 wait: Some(Duration::from_secs(60)),
609 }
610 );
611 assert!(!verdict.is_correct());
612 }
613
614 #[tokio::test]
615 async fn a_submission_on_cooldown_reports_the_remaining_wait() {
616 let session = session(FakeTransport::serving(COOLDOWN));
617
618 let error = session
619 .submit(puzzle(), Part::One, "514579")
620 .await
621 .expect_err("nothing was judged");
622
623 assert!(matches!(
624 error,
625 Error::Cooldown { wait } if wait == Duration::from_secs(270)
626 ));
627 }
628
629 #[tokio::test]
630 async fn an_answer_to_a_solved_part_is_checked_against_the_puzzle_page() {
631 let transport = FakeTransport::new();
632 transport.push_body(COMPLETED).push_body(PUZZLE);
633 let session = session(transport);
634
635 let verdict = session
636 .submit(puzzle(), Part::One, "514579")
637 .await
638 .expect("the fake replies twice");
639
640 assert_eq!(verdict, Verdict::AlreadyComplete { correct: true });
641 assert_eq!(
642 session.transport().requested_urls(),
643 [
644 "https://adventofcode.com/2020/day/1/answer",
645 "https://adventofcode.com/2020/day/1",
646 ]
647 );
648 }
649
650 #[tokio::test]
651 async fn a_different_answer_to_a_solved_part_is_still_wrong() {
652 let transport = FakeTransport::new();
653 transport.push_body(COMPLETED).push_body(PUZZLE);
654
655 let verdict = session(transport)
656 .submit(puzzle(), Part::One, "1")
657 .await
658 .expect("the fake replies twice");
659
660 assert_eq!(verdict, Verdict::AlreadyComplete { correct: false });
661 assert!(!verdict.is_correct());
662 }
663
664 #[tokio::test]
670 async fn an_answer_the_site_never_asked_for_is_a_verdict_rather_than_a_parse_error() {
671 let pages = [
672 "<p>Your puzzle answer was <code>514579</code>.</p>",
674 "<p>To begin, please identify yourself.</p>",
676 ];
677
678 for page in pages {
679 let transport = FakeTransport::new();
680 transport.push_body(COMPLETED).push_body(page);
681
682 let verdict = session(transport)
683 .submit(puzzle(), Part::Two, "241861950")
684 .await
685 .expect("the reply was understood");
686
687 assert_eq!(verdict, Verdict::WrongLevel);
688 assert!(!verdict.is_correct());
689 }
690 }
691
692 #[tokio::test]
693 async fn a_reply_asking_for_a_login_is_an_expired_cookie_whatever_its_status() {
694 let transport = FakeTransport::new();
695 transport.push(Response::new(400, LOGGED_OUT));
696
697 let error = session(transport)
698 .input_text(puzzle())
699 .await
700 .expect_err("the cookie was not accepted");
701
702 assert!(matches!(error, Error::Unauthorized), "{error}");
703 }
704
705 #[tokio::test]
706 async fn a_page_offering_a_login_is_an_expired_cookie_even_at_status_200() {
707 let session = session(FakeTransport::serving(EVENTS_LOGGED_OUT));
708
709 let error = session
710 .stars()
711 .await
712 .expect_err("the page has no stars because nobody is logged in");
713
714 assert!(matches!(error, Error::Unauthorized), "{error}");
715 }
716
717 #[tokio::test]
718 async fn a_puzzle_that_has_not_unlocked_says_so() {
719 let transport = FakeTransport::new();
720 transport.push(Response::new(
721 404,
722 "Please don't repeatedly request this endpoint before it unlocks!",
723 ));
724
725 let error = session(transport)
726 .input_text(puzzle())
727 .await
728 .expect_err("the puzzle is locked");
729
730 assert!(matches!(error, Error::Locked { puzzle } if puzzle == self::puzzle()));
731 }
732
733 #[tokio::test]
734 async fn an_unexpected_status_is_reported_as_itself() {
735 let transport = FakeTransport::new();
736 transport.push(Response::new(500, "<h1>Internal Server Error</h1>"));
737
738 let error = session(transport)
739 .stars()
740 .await
741 .expect_err("the site is unwell");
742
743 assert!(matches!(error, Error::Status { status: 500 }), "{error}");
744 }
745
746 #[tokio::test]
747 async fn a_reply_the_parser_does_not_know_is_an_error_rather_than_a_guess() {
748 let session = session(FakeTransport::serving("<p>Ho ho ho.</p>"));
749
750 let error = session
751 .submit(puzzle(), Part::One, "514579")
752 .await
753 .expect_err("the reply is unrecognised");
754
755 assert!(
756 matches!(error, Error::Parse(ParseError::Submission { .. })),
757 "{error}"
758 );
759 }
760
761 #[tokio::test]
762 async fn a_transport_failure_is_reported_rather_than_retried() {
763 let session = session(FakeTransport::new());
764
765 let error = session
766 .input_text(puzzle())
767 .await
768 .expect_err("nothing was queued");
769
770 assert!(matches!(error, Error::Transport(_)), "{error}");
771 assert_eq!(session.transport().requests().len(), 1);
772 }
773
774 #[test]
775 fn a_verdict_reads_like_the_site_phrases_it() {
776 assert_eq!(Verdict::Correct.to_string(), "that's the right answer");
777 assert_eq!(
778 Verdict::Incorrect {
779 hint: Some(Hint::TooHigh),
780 wait: Some(Duration::from_secs(60)),
781 }
782 .to_string(),
783 "that's not the right answer; it is too high (wait 1m before trying again)"
784 );
785 assert_eq!(
786 Verdict::AlreadyComplete { correct: true }.to_string(),
787 "already solved, with this answer"
788 );
789 assert_eq!(
790 Verdict::WrongLevel.to_string(),
791 "there is nothing to answer for this part"
792 );
793 }
794}