1use crate::puzzle::Part;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Answers {
12 pub part1: String,
14 pub part2: Option<String>,
16}
17
18impl Answers {
19 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#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum Outcome {
29 Answers(Answers),
31 Raw,
33}
34
35#[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}