Skip to content

Commit 1fa327a

Browse files
committed
Add integrated test runner for real inputs
1 parent c85d360 commit 1fa327a

File tree

18 files changed

+1890
-103
lines changed

18 files changed

+1890
-103
lines changed

crates/aoc/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ year2017 = { path = "../year2017", optional = true }
1616
year2024 = { path = "../year2024", optional = true }
1717

1818
[features]
19-
default = ["all-years", "all-simd", "unsafe"]
19+
default = ["all-years", "all-simd", "unsafe", "test-runner"]
2020
const_lut = ["year2024?/const_lut"]
2121
all-simd = ["utils/all-simd"]
22+
test-runner = []
2223
# xtask update features
2324
all-years = ["year2015", "year2016", "year2017", "year2024"]
2425
unsafe = ["year2015?/unsafe", "year2016?/unsafe", "year2017?/unsafe", "year2024?/unsafe", "utils/unsafe"]

crates/aoc/src/cli/options.rs renamed to crates/aoc/src/cli/arguments.rs

Lines changed: 96 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,54 @@
1+
use crate::cli::UsageError;
2+
use crate::cli::mode::{self, MainFn};
13
use aoc::{PUZZLES, PuzzleFn};
24
use std::collections::VecDeque;
35
use std::error::Error;
46
use std::num::NonZeroUsize;
7+
use std::path::PathBuf;
8+
use std::{fs, io};
59
use utils::date::{Day, Year};
610
use utils::multiversion::{VERSIONS, Version};
711

812
#[derive(Debug, Default)]
9-
pub struct Options {
13+
pub struct Arguments {
1014
program_name: Option<String>,
1115
pub help: bool,
1216
pub version_override: Option<Version>,
1317
pub threads_override: Option<NonZeroUsize>,
18+
pub inputs_dir: Option<PathBuf>,
19+
mode: Option<MainFn>,
1420
pub year: Option<Year>,
1521
pub day: Option<Day>,
22+
pub extra_args: VecDeque<String>,
1623
}
1724

18-
impl Options {
19-
pub fn parse() -> Result<Self, String> {
25+
impl Arguments {
26+
pub fn parse() -> Result<Self, UsageError> {
2027
let mut result = Self::default();
2128

2229
let mut args: VecDeque<String> = std::env::args().collect();
2330
result.program_name = args.pop_front();
2431

2532
while let Some(option) = args.pop_front() {
2633
if option == "--" {
27-
break;
34+
result.extra_args = args;
35+
return Ok(result);
2836
}
2937

3038
if let Some(option) = option.strip_prefix("--") {
3139
// Long form options
3240
if let Some((before, after)) = option.split_once('=') {
3341
result
3442
.handle_long(before, ArgumentValue::Provided(after.to_string()))
35-
.map_err(|e| format!("option --{before}: {e}"))?;
43+
.map_err(|e| {
44+
UsageError::InvalidArguments(format!("option --{before}: {e}").into())
45+
})?;
3646
} else {
3747
result
3848
.handle_long(option, ArgumentValue::Available(&mut args))
39-
.map_err(|e| format!("option --{option}: {e}"))?;
49+
.map_err(|e| {
50+
UsageError::InvalidArguments(format!("option --{option}: {e}").into())
51+
})?;
4052
}
4153
continue;
4254
}
@@ -50,13 +62,19 @@ impl Options {
5062
for option in options {
5163
result
5264
.handle_short(option, ArgumentValue::None)
53-
.map_err(|e| format!("option -{option}: {e}"))?;
65+
.map_err(|e| {
66+
UsageError::InvalidArguments(
67+
format!("option -{option}: {e}").into(),
68+
)
69+
})?;
5470
}
5571

56-
// Last short form option can consume a value
72+
// The last short form option can consume a value
5773
result
5874
.handle_short(last, ArgumentValue::Available(&mut args))
59-
.map_err(|e| format!("option -{last}: {e}"))?;
75+
.map_err(|e| {
76+
UsageError::InvalidArguments(format!("option -{last}: {e}").into())
77+
})?;
6078
continue;
6179
}
6280
}
@@ -65,46 +83,64 @@ impl Options {
6583
break;
6684
}
6785

86+
if let Some(i) = args.iter().position(|x| x == "--") {
87+
result.extra_args = args.split_off(i + 1);
88+
args.pop_back();
89+
}
90+
6891
if let Some(year) = args.pop_front() {
6992
result.year = match year.parse() {
7093
Ok(y) => Some(y),
71-
Err(err) => return Err(err.to_string()),
94+
Err(err) => return Err(UsageError::InvalidArguments(err.into())),
7295
};
7396

7497
if let Some(day) = args.pop_front() {
7598
result.day = match day.parse() {
7699
Ok(y) => Some(y),
77-
Err(err) => return Err(err.to_string()),
100+
Err(err) => return Err(UsageError::InvalidArguments(err.into())),
78101
};
79102

80103
if !args.is_empty() {
81-
return Err("too many arguments".to_string());
104+
return Err(UsageError::TooManyArguments);
82105
}
83106
}
84107
}
85108

86109
Ok(result)
87110
}
88111

89-
pub fn help(&self) -> String {
112+
pub fn help_string(&self) -> String {
90113
format!(
91114
r"Usage:
92115
{program_name}
93116
Run all solutions
94117
95118
{program_name} $year
96119
Run all solutions for the provided year
97-
120+
98121
{program_name} $year $day
99122
Run the solution for the provided date
100123
101124
Options:
125+
--stdin
126+
Run a single solution, reading input from stdin. $year and $day must be provided.
127+
128+
--test
129+
Runs all solutions against all inputs from the inputs directory, comparing the outputs to
130+
the stored correct answers in the inputs directory. $year may be provided to only test the
131+
provided year. A custom command template may be provided following a `--` argument to test
132+
another binary. Requires the 'test-runner' feature to be enabled.
133+
102134
--multiversion/-m $version
103135
Override which implementation of multiversioned functions should be used.
104136
Supported versions: {multiversion_options:?}
105-
137+
106138
--threads/-t $threads
107139
Override the number of threads to use for multithreaded solutions.
140+
In `--test` mode this controls the number of simultaneous tests.
141+
142+
--inputs $dir
143+
Specify the directory storing inputs. Defaults to './inputs'.
108144
109145
--help/-h
110146
Print this help
@@ -121,6 +157,10 @@ Options:
121157
"help" => self.option_help(value),
122158
"multiversion" => self.option_multiversion(value),
123159
"threads" => self.option_threads(value),
160+
"inputs" => self.option_inputs(value),
161+
"stdin" => self.option_mode(value, mode::stdin::main),
162+
#[cfg(feature = "test-runner")]
163+
"test" => self.option_mode(value, mode::test::main),
124164
_ => Err("unknown option".into()),
125165
}
126166
}
@@ -158,13 +198,54 @@ Options:
158198
Ok(())
159199
}
160200

201+
fn option_inputs(&mut self, value: ArgumentValue) -> Result<(), Box<dyn Error>> {
202+
let value = value.required()?.into();
203+
if self.inputs_dir.is_some() {
204+
return Err("option provided more than once".into());
205+
}
206+
if !fs::metadata(&value).is_ok_and(|m| m.is_dir()) {
207+
return Err("inputs path must be a directory".into());
208+
}
209+
self.inputs_dir = Some(value);
210+
Ok(())
211+
}
212+
213+
fn option_mode(&mut self, value: ArgumentValue, mode: MainFn) -> Result<(), Box<dyn Error>> {
214+
value.none()?;
215+
if self.mode.is_some() {
216+
return Err("mode options are mutually exclusive".into());
217+
}
218+
self.mode = Some(mode);
219+
Ok(())
220+
}
221+
222+
pub fn main_fn(&self) -> MainFn {
223+
self.mode.unwrap_or(mode::default::main)
224+
}
225+
161226
pub fn matching_puzzles(&self) -> Vec<(Year, Day, PuzzleFn)> {
162227
PUZZLES
163228
.iter()
164229
.copied()
165230
.filter(|&(y, d, ..)| self.year.unwrap_or(y) == y && self.day.unwrap_or(d) == d)
166231
.collect()
167232
}
233+
234+
pub fn inputs_dir(&self) -> PathBuf {
235+
self.inputs_dir
236+
.clone()
237+
.unwrap_or_else(|| PathBuf::from("./inputs"))
238+
}
239+
240+
pub fn read_input(&self, year: Year, day: Day) -> Result<String, (String, io::Error)> {
241+
let mut path = self.inputs_dir();
242+
path.push(format!("year{year:#}"));
243+
path.push(format!("day{day:#}.txt"));
244+
match fs::read_to_string(&path) {
245+
Ok(s) => Ok(s.trim_ascii_end().replace("\r\n", "\n")),
246+
Err(err) => Err((path.to_string_lossy().to_string(), err)),
247+
}
248+
}
168249
}
169250

170251
#[must_use]

crates/aoc/src/cli/error.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
use std::error::Error;
2+
use std::fmt::{Debug, Display, Formatter};
3+
use std::process::ExitCode;
4+
use utils::date::{Day, Year};
5+
6+
#[derive(Debug)]
7+
pub enum UsageError {
8+
InvalidArguments(Box<dyn Error>),
9+
MissingArguments(Box<dyn Error>),
10+
TooManyArguments,
11+
UnsupportedPuzzle(Year, Day),
12+
NoSupportedPuzzles,
13+
}
14+
15+
impl Display for UsageError {
16+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
17+
match self {
18+
UsageError::InvalidArguments(err) => write!(f, "invalid arguments: {err}"),
19+
UsageError::MissingArguments(err) => write!(f, "missing required arguments: {err}"),
20+
UsageError::TooManyArguments => write!(f, "too many arguments"),
21+
UsageError::UnsupportedPuzzle(year, day) => {
22+
write!(f, "unsupported puzzle: {year:#} day {day:#}")
23+
}
24+
UsageError::NoSupportedPuzzles => write!(f, "no matching supported puzzles"),
25+
}
26+
}
27+
}
28+
29+
impl Error for UsageError {
30+
fn source(&self) -> Option<&(dyn Error + 'static)> {
31+
match self {
32+
UsageError::InvalidArguments(err) | UsageError::MissingArguments(err) => {
33+
Some(err.as_ref())
34+
}
35+
_ => None,
36+
}
37+
}
38+
}
39+
40+
impl UsageError {
41+
pub fn exit_code() -> ExitCode {
42+
ExitCode::from(2)
43+
}
44+
}
45+
46+
// Used by aoc::cli::mode::test to indicate that the process should exit with failure, but without
47+
// printing an error message as it has already printed the failures.
48+
#[derive(Debug)]
49+
pub struct FailedNoErrorMessage;
50+
51+
impl Display for FailedNoErrorMessage {
52+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
53+
write!(f, "failed")
54+
}
55+
}
56+
57+
impl Error for FailedNoErrorMessage {}

crates/aoc/src/cli/mod.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1-
mod options;
1+
mod arguments;
2+
mod error;
3+
pub mod mode;
24

3-
pub use options::Options;
5+
pub use arguments::Arguments;
6+
pub use error::{FailedNoErrorMessage, UsageError};

crates/aoc/src/cli/mode/default.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
use crate::cli::{Arguments, UsageError};
2+
use std::error::Error;
3+
use std::time::{Duration, Instant};
4+
5+
#[expect(clippy::print_stdout)]
6+
pub fn main(args: &Arguments) -> Result<(), Box<dyn Error>> {
7+
if !args.extra_args.is_empty() {
8+
return Err(UsageError::TooManyArguments.into());
9+
}
10+
11+
let puzzles = args.matching_puzzles();
12+
if puzzles.is_empty() {
13+
return Err(UsageError::NoSupportedPuzzles.into());
14+
}
15+
16+
// FIXME support 80 character wide output (without time?)
17+
println!(
18+
"Puzzle │ Part 1 │ Part 2 │ Time "
19+
);
20+
println!(
21+
"────────┼──────────────────────┼────────────────────────────────────────┼───────────"
22+
);
23+
let mut total = Duration::default();
24+
for (year, day, f) in puzzles {
25+
let input = args
26+
.read_input(year, day)
27+
.map_err(|(path, err)| format!("{year:#} {day:#}: failed to read {path:?}: {err}"))?;
28+
29+
let start = Instant::now();
30+
let (part1, part2) = f(&input).map_err(|err| format!("{year:#} {day:#}: {err}"))?;
31+
let elapsed = start.elapsed();
32+
total += elapsed;
33+
34+
// Hack to treat "🎄" as two characters wide
35+
// ("🎄" is 1 wide in Unicode 8 but 2 wide in Unicode 9+)
36+
let part1_width = if part1 == "🎄" { 19 } else { 20 };
37+
let part2_width = if part2 == "🎄" { 37 } else { 38 };
38+
39+
println!(
40+
"{year:#} {day:#} │ {part1:<part1_width$} │ {part2:<part2_width$} │ {}",
41+
format_duration(elapsed)
42+
);
43+
}
44+
45+
println!(
46+
"────────┼──────────────────────┼────────────────────────────────────────┼───────────"
47+
);
48+
println!(
49+
" │ {}",
50+
format_duration(total),
51+
);
52+
53+
Ok(())
54+
}
55+
56+
fn format_duration(d: Duration) -> String {
57+
let (unit, multiplier) = if d.as_micros() < 1000 {
58+
("µ", 1_000_000.)
59+
} else {
60+
("m", 1_000.)
61+
};
62+
63+
let float = d.as_secs_f64() * multiplier;
64+
let precision = if float < 1000. { 3 } else { 0 };
65+
format!("{float:7.precision$} {unit}s")
66+
}

crates/aoc/src/cli/mode/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
use crate::cli::Arguments;
2+
use std::error::Error;
3+
4+
pub mod default;
5+
pub mod stdin;
6+
#[cfg(feature = "test-runner")]
7+
pub mod test;
8+
9+
pub type MainFn = fn(&Arguments) -> Result<(), Box<dyn Error>>;

0 commit comments

Comments
 (0)