Skip to main content

aoc_runtime/
answer.rs

1//! The stdout protocol used to recognise puzzle answers.
2//!
3//! A solution communicates answers by printing one or two lines: the first is
4//! part one, the second is part two. Anything else is treated as ordinary
5//! program output and passed through untouched.
6
7use crate::puzzle::Part;
8
9/// The answers a solution printed.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Answers {
12    /// The part one answer.
13    pub part1: String,
14    /// The part two answer, if the solution printed one.
15    pub part2: Option<String>,
16}
17
18impl Answers {
19    /// Iterates over the answers together with the part they belong to.
20    pub fn iter(&self) -> impl Iterator<Item = (Part, &str)> {
21        std::iter::once((Part::One, self.part1.as_str()))
22            .chain(self.part2.as_deref().map(|answer| (Part::Two, answer)))
23    }
24}
25
26/// How a solution's standard output was interpreted.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum Outcome {
29    /// Output matched the answer protocol.
30    Answers(Answers),
31    /// Output did not match the protocol and should be printed verbatim.
32    Raw,
33}
34
35/// Classifies a solution's standard output.
36///
37/// One or two non-blank lines are answers; zero, three or more lines - or any
38/// blank line - is ordinary output.
39///
40/// ```
41/// use aoc_runtime::answer::{classify, Outcome};
42///
43/// let Outcome::Answers(answers) = classify("1227\n23262\n") else {
44///     panic!("two lines are answers");
45/// };
46/// assert_eq!(answers.part1, "1227");
47/// assert_eq!(answers.part2.as_deref(), Some("23262"));
48///
49/// assert_eq!(classify("running...\n1227\n23262\n"), Outcome::Raw);
50/// ```
51#[must_use]
52pub fn classify(stdout: &str) -> Outcome {
53    let body = stdout.strip_suffix('\n').unwrap_or(stdout);
54    if body.is_empty() {
55        return Outcome::Raw;
56    }
57
58    let mut lines = body.split('\n').map(str::trim);
59    let (Some(part1), part2, None) = (lines.next(), lines.next(), lines.next()) else {
60        return Outcome::Raw;
61    };
62
63    if part1.is_empty() || part2.is_some_and(str::is_empty) {
64        return Outcome::Raw;
65    }
66
67    Outcome::Answers(Answers {
68        part1: part1.to_owned(),
69        part2: part2.map(ToOwned::to_owned),
70    })
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    fn answers(stdout: &str) -> Option<Answers> {
78        match classify(stdout) {
79            Outcome::Answers(answers) => Some(answers),
80            Outcome::Raw => None,
81        }
82    }
83
84    fn parts(stdout: &str) -> Option<(String, Option<String>)> {
85        answers(stdout).map(|a| (a.part1, a.part2))
86    }
87
88    #[test]
89    fn one_line_is_part_one() {
90        assert_eq!(parts("42\n"), Some(("42".to_owned(), None)));
91    }
92
93    #[test]
94    fn two_lines_are_both_parts() {
95        assert_eq!(
96            parts("42\n99\n"),
97            Some(("42".to_owned(), Some("99".to_owned())))
98        );
99    }
100
101    #[test]
102    fn a_missing_trailing_newline_still_yields_both_parts() {
103        assert_eq!(
104            parts("42\n99"),
105            Some(("42".to_owned(), Some("99".to_owned())))
106        );
107        assert_eq!(parts("42"), Some(("42".to_owned(), None)));
108    }
109
110    #[test]
111    fn multibyte_output_splits_on_character_boundaries() {
112        assert_eq!(
113            parts("ä→é\n42\n"),
114            Some(("ä→é".to_owned(), Some("42".to_owned())))
115        );
116    }
117
118    #[test]
119    fn surrounding_whitespace_is_trimmed() {
120        assert_eq!(
121            parts("  42  \n\t99\t\n"),
122            Some(("42".to_owned(), Some("99".to_owned())))
123        );
124    }
125
126    #[test]
127    fn carriage_returns_are_trimmed() {
128        assert_eq!(
129            parts("42\r\n99\r\n"),
130            Some(("42".to_owned(), Some("99".to_owned())))
131        );
132    }
133
134    #[test]
135    fn three_or_more_lines_are_raw_output() {
136        assert_eq!(classify("1\n2\n3\n"), Outcome::Raw);
137        assert_eq!(classify("a\nb\nc\nd\n"), Outcome::Raw);
138    }
139
140    #[test]
141    fn empty_and_blank_output_is_raw() {
142        assert_eq!(classify(""), Outcome::Raw);
143        assert_eq!(classify("\n"), Outcome::Raw);
144        assert_eq!(classify("   \n"), Outcome::Raw);
145        assert_eq!(classify("42\n\n"), Outcome::Raw);
146        assert_eq!(classify("\n42\n"), Outcome::Raw);
147    }
148
149    #[test]
150    fn iterates_parts_in_order() {
151        let answers = Answers {
152            part1: "a".to_owned(),
153            part2: Some("b".to_owned()),
154        };
155
156        let collected: Vec<_> = answers.iter().collect();
157        assert_eq!(collected, [(Part::One, "a"), (Part::Two, "b")]);
158
159        let single = Answers {
160            part1: "a".to_owned(),
161            part2: None,
162        };
163        assert_eq!(single.iter().count(), 1);
164    }
165}