Skip to main content

aoc_runtime/
report.rs

1//! User-facing output.
2//!
3//! Data goes to standard output so `cd $(aoc path)` works; diagnostics go to
4//! standard error. Handlers emit semantic [`Event`]s, so tests assert on what
5//! happened rather than on ANSI escape sequences.
6
7use crate::{aoc::Verdict, puzzle::Part};
8use colored::Colorize as _;
9use std::io::{self, Write as _};
10
11/// Something worth telling the user about.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Event {
14    /// A machine-consumable result, such as a path or a URL.
15    Data(String),
16    /// A solution's own output, passed through verbatim.
17    Raw(String),
18    /// An answer and how the site judged it.
19    Answer {
20        /// Which part the answer belongs to.
21        part: Part,
22        /// The answer itself.
23        answer: String,
24        /// The site's verdict, or `None` when nothing was submitted.
25        verdict: Option<Verdict>,
26    },
27    /// A non-fatal problem.
28    Warning(String),
29}
30
31/// Receives [`Event`]s and presents them.
32pub trait Reporter {
33    /// Presents a single event.
34    fn report(&mut self, event: Event);
35
36    /// Emits a machine-consumable result.
37    fn data(&mut self, text: &str) {
38        self.report(Event::Data(text.to_owned()));
39    }
40
41    /// Emits a solution's own output.
42    fn raw(&mut self, text: &str) {
43        self.report(Event::Raw(text.to_owned()));
44    }
45
46    /// Emits an answer and its verdict.
47    fn answer(&mut self, part: Part, answer: &str, verdict: Option<Verdict>) {
48        self.report(Event::Answer {
49            part,
50            answer: answer.to_owned(),
51            verdict,
52        });
53    }
54
55    /// Emits a non-fatal problem.
56    fn warn(&mut self, text: &str) {
57        self.report(Event::Warning(text.to_owned()));
58    }
59}
60
61/// Writes events to the terminal, colouring answers by verdict.
62#[derive(Debug, Default, Clone, Copy)]
63pub struct TermReporter;
64
65impl Reporter for TermReporter {
66    fn report(&mut self, event: Event) {
67        match event {
68            Event::Data(text) => println!("{text}"),
69            Event::Raw(text) => {
70                print!("{text}");
71                if !text.ends_with('\n') {
72                    println!();
73                }
74                let _ = io::stdout().flush();
75            }
76            Event::Answer {
77                part,
78                answer,
79                verdict,
80            } => match &verdict {
81                None => println!("{answer}"),
82                Some(Verdict::Correct) => println!("{}", answer.green()),
83                Some(verdict) => {
84                    // Red is an answer the site judged and rejected; yellow is
85                    // one it declined to judge, which says nothing about the
86                    // answer itself.
87                    let coloured = if verdict.is_judged() {
88                        answer.red()
89                    } else {
90                        answer.yellow()
91                    };
92
93                    println!("{coloured}");
94                    eprintln!("{part}: {verdict}");
95                }
96            },
97            Event::Warning(text) => eprintln!("{} {text}", "warning:".yellow().bold()),
98        }
99    }
100}
101
102#[cfg(test)]
103pub(crate) mod recording {
104    use super::{Event, Reporter};
105
106    #[derive(Debug, Default)]
107    pub(crate) struct RecordingReporter {
108        pub(crate) events: Vec<Event>,
109    }
110
111    impl RecordingReporter {
112        pub(crate) fn new() -> Self {
113            Self::default()
114        }
115
116        pub(crate) fn raw_output(&self) -> String {
117            self.events
118                .iter()
119                .filter_map(|event| match event {
120                    Event::Raw(text) => Some(text.as_str()),
121                    _ => None,
122                })
123                .collect()
124        }
125
126        pub(crate) fn answers(&self) -> Vec<Event> {
127            self.events
128                .iter()
129                .filter(|event| matches!(event, Event::Answer { .. }))
130                .cloned()
131                .collect()
132        }
133
134        pub(crate) fn warnings(&self) -> Vec<&str> {
135            self.events
136                .iter()
137                .filter_map(|event| match event {
138                    Event::Warning(text) => Some(text.as_str()),
139                    _ => None,
140                })
141                .collect()
142        }
143    }
144
145    impl Reporter for RecordingReporter {
146        fn report(&mut self, event: Event) {
147            self.events.push(event);
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::{recording::RecordingReporter, *};
155
156    #[test]
157    fn records_every_kind_of_event() {
158        let mut reporter = RecordingReporter::new();
159
160        reporter.data("/aoc/2024/day07/rust");
161        reporter.raw("hello\n");
162        reporter.answer(Part::One, "1227", Some(Verdict::Correct));
163        reporter.warn("no cookie");
164
165        assert_eq!(reporter.events.len(), 4);
166        assert_eq!(reporter.raw_output(), "hello\n");
167        assert_eq!(reporter.warnings(), ["no cookie"]);
168        assert_eq!(
169            reporter.events[0],
170            Event::Data("/aoc/2024/day07/rust".to_owned())
171        );
172        assert_eq!(
173            reporter.events[2],
174            Event::Answer {
175                part: Part::One,
176                answer: "1227".to_owned(),
177                verdict: Some(Verdict::Correct),
178            }
179        );
180    }
181}