advent_of_code_rust_runner/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::{process, time::Duration};
4
5use anyhow::anyhow;
6use clap::{CommandFactory, Parser};
7use time::{Month, OffsetDateTime, UtcOffset};
8
9mod day;
10mod environment;
11
12pub use day::{Day, DayImplementation};
13pub use anyhow::{Context, Result};
14use day::TestCaseResult;
15
16const DAY_SEPARATOR: &str = "-----------------------";
17
18#[derive(Parser, Debug)]
19#[command(
20    name = "aoc-runner",
21    about = "Run Advent of Code solutions"
22)]
23pub struct RunnerArgs {
24    /// Run a specific day (mutually exclusive with --all)
25    #[arg(
26        short = 'd',
27        long = "day",
28        value_parser = clap::value_parser!(u8),
29        conflicts_with = "all_days"
30    )]
31    pub specific_day: Option<u8>,
32
33    /// Run all days
34    #[arg(short = 'a', long = "all", conflicts_with = "specific_day")]
35    pub all_days: bool,
36
37    /// Skip example tests
38    #[arg(short = 'k', long = "skip-tests", conflicts_with = "tests_only")]
39    pub skip_tests: bool,
40
41    /// Only run example tests
42    #[arg(short = 't', long = "tests-only", conflicts_with = "skip_tests")]
43    pub tests_only: bool,
44
45    /// Number of runs for timing stats (>=1)
46    #[arg(
47        short = 's',
48        long = "stats",
49        default_value_t = 1,
50        value_parser = clap::value_parser!(usize)
51    )]
52    pub num_runs: usize,
53}
54
55#[derive(Debug, Clone, Copy, Default)]
56struct RunStats {
57    min: Duration,
58    max: Duration,
59    median: Duration,
60    mean: Duration,
61}
62
63pub struct Runner {
64    env: environment::AOCEnvironment,
65    days: Vec<Box<dyn Day>>
66}
67
68impl Runner {
69    pub fn new(year: &str, days: Vec<Box<dyn Day>>) -> Result<Self> {
70        let env = environment::AOCEnvironment::initialize(year)?;
71        Ok(Self {
72            env,
73            days
74        })
75    }
76
77    /// Owns process exit; prints errors/usage on failure.
78    pub fn run_with_args(&self, args: RunnerArgs) -> ! {
79        if let Err(e) = self.run_inner(args) {
80            eprintln!("error: {e}");
81            let _ = RunnerArgs::command().print_help();
82            eprintln!();
83            process::exit(1);
84        }
85        process::exit(0);
86    }
87
88    pub fn run(&self) -> ! {
89        let args = RunnerArgs::parse();
90        self.run_with_args(args)
91    }
92
93    fn run_inner(&self, args: RunnerArgs) -> Result<()> {
94        let max_day = self.max_day()?;
95        if let Some(day) = args.specific_day
96            && (day == 0 || day > max_day)
97        {
98            return Err(anyhow!("Day must be between 1 and {}", max_day));
99        }
100        if args.num_runs == 0 {
101            return Err(anyhow!("Stats runs must be at least 1"));
102        }
103
104        if args.all_days {
105            self.run_all_days(&args)
106        } else {
107            let day_to_run = match args.specific_day {
108                Some(d) => d,
109                None => self
110                    .current_aoc_day()
111                    .context("No day specified; only allowed when running the configured year in December (AoC time)")?,
112            };
113            self.run_single_day(day_to_run, &args)
114        }
115    }
116
117    fn current_aoc_day(&self) -> Option<u8> {
118        let max_day = self.max_day().ok()?;
119        // AoC is Eastern; use fixed UTC−5 to avoid extra dependencies.
120        let offset = UtcOffset::from_hms(-5, 0, 0).ok()?;
121        let now = OffsetDateTime::now_utc().to_offset(offset);
122        if now.year().to_string() == self.env.year && now.month() == Month::December {
123            let today = now.day();
124            (today <= max_day).then_some(today)
125        } else {
126            None
127        }
128    }
129
130    fn run_all_days(&self, args: &RunnerArgs) -> Result<()> {
131        let mut medians = Vec::with_capacity(self.days.len());
132        let mut totals = RunStats::default();
133        let mut max_time = Duration::ZERO;
134
135        for (idx, day) in self.days.iter().enumerate() {
136            let stats = self.run_day(day.as_ref(), args)?;
137            medians.push(stats.median);
138            totals.min += stats.min;
139            totals.max += stats.max;
140            totals.mean += stats.mean;
141            totals.median += stats.median;
142            if stats.median > max_time {
143                max_time = stats.median;
144            }
145            println!("Day {} complete\n", idx + 1);
146        }
147
148        println!("{DAY_SEPARATOR}");
149        if !args.tests_only && max_time > Duration::ZERO {
150            if args.num_runs < 2 {
151                println!("Total time: {:?}", totals.median);
152            } else {
153                println!(
154                    "Total time: {:?} median, {:?} mean, {:?} min, {:?} max",
155                    totals.median, totals.mean, totals.min, totals.max
156                );
157            }
158
159            for threshold in (1..=10).rev().map(|t| t as f32 / 10.0) {
160                print!("| ");
161                for t in &medians {
162                    if max_time.as_secs_f64() > 0.0
163                        && (t.as_secs_f64() / max_time.as_secs_f64()) >= threshold as f64
164                    {
165                        print!("#");
166                    } else {
167                        print!(" ");
168                    }
169                }
170                println!();
171            }
172            print!("|-");
173            println!("{}", "-".repeat(self.days.len()));
174        }
175        Ok(())
176    }
177
178    fn run_single_day(&self, day_number: u8, args: &RunnerArgs) -> Result<()> {
179        let day = self
180            .days
181            .iter()
182            .find(|d| d.day() == day_number)
183            .with_context(|| format!("Day {} not registered", day_number))?;
184        self.run_day(day.as_ref(), args).map(|_| ())
185    }
186
187    fn run_day(&self, day_impl: &dyn Day, args: &RunnerArgs) -> Result<RunStats> {
188        println!("{DAY_SEPARATOR}");
189        println!("Day {}", day_impl.day());
190
191        let max_day = self.max_day()?;
192        if day_impl.day() > max_day {
193            return Err(anyhow!(
194                "Day {} is not valid for {} (max {})",
195                day_impl.day(),
196                self.env.year,
197                max_day
198            ));
199        }
200
201        // Skip if not published yet (same year, December, AoC day not reached)
202        if let Some(today) = self.current_aoc_day()
203            && today < day_impl.day()
204        {
205            println!("Skipping - day not published yet");
206            return Ok(RunStats::default());
207        }
208
209        if !args.skip_tests {
210            match day_impl.test_day() {
211                Ok(test_result) => {
212                    self.print_test_result("Part 1", &test_result.part1);
213                    self.print_test_result("Part 2", &test_result.part2);
214                }
215                Err(e) => log::warn!("Day {}: failed to run tests: {}", day_impl.day(), e),
216            }
217        }
218
219        if args.tests_only {
220            return Ok(RunStats::default());
221        }
222
223        let input = self
224            .env
225            .fetch_input(day_impl.day())
226            .with_context(|| format!("Failed to fetch input for day {}", day_impl.day()))?;
227
228        if args.num_runs < 2 {
229            let res = day_impl
230                .execute_day(input.as_str())
231                .with_context(|| format!("Day {} execution failed", day_impl.day()))?;
232            let total = res.part_1_time + res.part_2_time;
233            println!(
234                "Part 1 real: {} ({:?})\nPart 2 real: {} ({:?})\nTotal time: {:?}",
235                res.part_1_result, res.part_1_time, res.part_2_result, res.part_2_time, total
236            );
237            return Ok(RunStats {
238                min: total,
239                max: total,
240                median: total,
241                mean: total,
242            });
243        }
244
245        let mut results = Vec::with_capacity(args.num_runs);
246        for _ in 0..args.num_runs {
247            results.push(
248                day_impl
249                    .execute_day(input.as_str())
250                    .with_context(|| format!("Day {} execution failed", day_impl.day()))?,
251            );
252        }
253
254        let mut p1_times: Vec<_> = results.iter().map(|r| r.part_1_time).collect();
255        let mut p2_times: Vec<_> = results.iter().map(|r| r.part_2_time).collect();
256
257        let p1_stats = build_stats(&mut p1_times);
258        let p2_stats = build_stats(&mut p2_times);
259        let totals = RunStats {
260            min: p1_stats.min + p2_stats.min,
261            max: p1_stats.max + p2_stats.max,
262            median: p1_stats.median + p2_stats.median,
263            mean: p1_stats.mean + p2_stats.mean,
264        };
265
266        println!(
267            "Part 1: {} (median {:?}, mean {:?}, min {:?}, max {:?})",
268            results[0].part_1_result, p1_stats.median, p1_stats.mean, p1_stats.min, p1_stats.max
269        );
270        println!(
271            "Part 2: {} (median {:?}, mean {:?}, min {:?}, max {:?})",
272            results[0].part_2_result, p2_stats.median, p2_stats.mean, p2_stats.min, p2_stats.max
273        );
274        println!(
275            "Total time: median {:?}, mean {:?}, min {:?}, max {:?}",
276            totals.median, totals.mean, totals.min, totals.max
277        );
278
279        Ok(totals)
280    }
281
282    fn print_test_result(&self, label: &str, result: &TestCaseResult) {
283        match result {
284            TestCaseResult::NotExecuted => println!("{label} test: (skipped)"),
285            TestCaseResult::Passed(t) => println!("{label} test: CORRECT ({t:?})"),
286            TestCaseResult::Failed(expected, actual) => {
287                println!("{label} test: INCORRECT");
288                println!("  Expected: {expected}");
289                println!("  Received: {actual}");
290            }
291        }
292    }
293
294    fn max_day(&self) -> Result<u8> {
295        let year: u32 = self
296            .env
297            .year
298            .parse()
299            .with_context(|| format!("Invalid year value {}", self.env.year))?;
300        Ok(if year >= 2025 { 12 } else { 25 })
301    }
302}
303
304fn build_stats(times: &mut [Duration]) -> RunStats {
305    if times.is_empty() {
306        return RunStats::default();
307    }
308    times.sort();
309    let min = times[0];
310    let max = *times.last().unwrap();
311    let median = if times.len() % 2 == 1 {
312        times[times.len() / 2]
313    } else {
314        (times[times.len() / 2 - 1] + times[times.len() / 2]) / 2
315    };
316    let sum: Duration = times.iter().copied().sum();
317    let mean = sum / (times.len() as u32);
318    RunStats {
319        min,
320        max,
321        median,
322        mean,
323    }
324}