diff --git a/.cargo/config.toml b/.cargo/config.toml index 96b289c..66b873e 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -6,7 +6,7 @@ read = "run --quiet --release -- read" solve = "run --quiet --release -- solve" all = "run --quiet --release -- all" -time = "run --quiet --release -- all --release --time" +time = "run --quiet --release -- time" [env] AOC_YEAR = "2023" diff --git a/.gitignore b/.gitignore index 3b6ae09..216820d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ data/puzzles/* # Dhat dhat-heap.json + +# Benchmarks + +data/timings.json diff --git a/Cargo.lock b/Cargo.lock index 3db9765..bff5d0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,7 @@ dependencies = [ "chrono", "dhat", "pico-args", + "tinyjson", ] [[package]] @@ -390,6 +391,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" +[[package]] +name = "tinyjson" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab95735ea2c8fd51154d01e39cf13912a78071c2d89abc49a7ef102a7dd725a" + [[package]] name = "unicode-ident" version = "1.0.12" diff --git a/Cargo.toml b/Cargo.toml index 371b2bc..9120a8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,11 +15,12 @@ inherits = "release" debug = 1 [features] +dhat-heap = ["dhat"] today = ["chrono"] test_lib = [] -dhat-heap = ["dhat"] [dependencies] chrono = { version = "0.4.31", optional = true } -pico-args = "0.5.0" dhat = { version = "0.3.2", optional = true } +pico-args = "0.5.0" +tinyjson = "2" diff --git a/README.md b/README.md index 0d72d22..472f8ba 100644 --- a/README.md +++ b/README.md @@ -116,13 +116,15 @@ cargo all # Total: 0.20ms ``` -This runs all solutions sequentially and prints output to the command-line. Same as for the `solve` command, the `--release` flag runs an optimized build. +This runs all solutions sequentially and prints output to the command-line. Same as for the `solve` command, the `--release` flag runs an optimized build and the `--time` flag outputs benchmarks. -#### Update readme benchmarks +### ➡️ Update readme benchmarks -The template can output a table with solution times to your readme. In order to generate a benchmarking table, run `cargo time`. If everything goes well, the command will output "_Successfully updated README with benchmarks._" after the execution finishes and the readme will be updated. +The template can write benchmark times to the README via the `cargo time` command. -Please note that these are not "scientific" benchmarks, understand them as a fun approximation. 😉 Timings, especially in the microseconds range, might change a bit between invocations. +By default, this command checks for missing benchmarks, runs those solutions, and then updates the table. If you want to (re-)time all solutions, run `cargo time --all`. If you want to (re-)time one specific solution, run `cargo time `. + +Please note that these are not _scientific_ benchmarks, understand them as a fun approximation. 😉 Timings, especially in the microseconds range, might change a bit between invocations. ### ➡️ Run all tests diff --git a/src/main.rs b/src/main.rs index 95dcb2e..57d4fe3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use advent_of_code::template::commands::{all, download, read, scaffold, solve}; +use advent_of_code::template::commands::{all, download, read, scaffold, solve, time}; use args::{parse, AppArguments}; #[cfg(feature = "today")] @@ -32,6 +32,10 @@ mod args { release: bool, time: bool, }, + Time { + all: bool, + day: Option, + }, #[cfg(feature = "today")] Today, } @@ -44,6 +48,14 @@ mod args { release: args.contains("--release"), time: args.contains("--time"), }, + Some("time") => { + let all = args.contains("--all"); + + AppArguments::Time { + all, + day: args.opt_free_from_str()?, + } + } Some("download") => AppArguments::Download { day: args.free_from_str()?, }, @@ -90,6 +102,7 @@ fn main() { } Ok(args) => match args { AppArguments::All { release, time } => all::handle(release, time), + AppArguments::Time { day, all } => time::handle(day, all), AppArguments::Download { day } => download::handle(day), AppArguments::Read { day } => read::handle(day), AppArguments::Scaffold { day, download } => { diff --git a/src/template/aoc_cli.rs b/src/template/aoc_cli.rs index a9ff3b0..2d3300d 100644 --- a/src/template/aoc_cli.rs +++ b/src/template/aoc_cli.rs @@ -11,7 +11,6 @@ pub enum AocCommandError { CommandNotFound, CommandNotCallable, BadExitStatus(Output), - IoError, } impl Display for AocCommandError { @@ -22,7 +21,6 @@ impl Display for AocCommandError { AocCommandError::BadExitStatus(_) => { write!(f, "aoc-cli exited with a non-zero status.") } - AocCommandError::IoError => write!(f, "could not write output files to file system."), } } } diff --git a/src/template/commands/all.rs b/src/template/commands/all.rs index a183445..444497d 100644 --- a/src/template/commands/all.rs +++ b/src/template/commands/all.rs @@ -1,254 +1,5 @@ -use std::io; - -use crate::template::{ - all_days, - readme_benchmarks::{self, Timings}, - Day, ANSI_BOLD, ANSI_ITALIC, ANSI_RESET, -}; +use crate::template::{all_days, run_multi::run_multi}; pub fn handle(is_release: bool, is_timed: bool) { - let mut timings: Vec = vec![]; - - all_days().for_each(|day| { - if day > 1 { - println!(); - } - - println!("{ANSI_BOLD}Day {day}{ANSI_RESET}"); - println!("------"); - - let output = child_commands::run_solution(day, is_timed, is_release).unwrap(); - - if output.is_empty() { - println!("Not solved."); - } else { - let val = child_commands::parse_exec_time(&output, day); - timings.push(val); - } - }); - - if is_timed { - let total_millis = timings.iter().map(|x| x.total_nanos).sum::() / 1_000_000_f64; - - println!("\n{ANSI_BOLD}Total:{ANSI_RESET} {ANSI_ITALIC}{total_millis:.2}ms{ANSI_RESET}"); - - if is_release { - match readme_benchmarks::update(timings, total_millis) { - Ok(()) => println!("Successfully updated README with benchmarks."), - Err(_) => { - eprintln!("Failed to update readme with benchmarks."); - } - } - } - } -} - -#[derive(Debug)] -pub enum Error { - BrokenPipe, - Parser(String), - IO(io::Error), -} - -impl From for Error { - fn from(e: std::io::Error) -> Self { - Error::IO(e) - } -} - -#[must_use] -pub fn get_path_for_bin(day: Day) -> String { - format!("./src/bin/{day}.rs") -} - -/// All solutions live in isolated binaries. -/// This module encapsulates interaction with these binaries, both invoking them as well as parsing the timing output. -mod child_commands { - use super::{get_path_for_bin, Error}; - use crate::template::Day; - use std::{ - io::{BufRead, BufReader}, - path::Path, - process::{Command, Stdio}, - thread, - }; - - /// Run the solution bin for a given day - pub fn run_solution(day: Day, is_timed: bool, is_release: bool) -> Result, Error> { - // skip command invocation for days that have not been scaffolded yet. - if !Path::new(&get_path_for_bin(day)).exists() { - return Ok(vec![]); - } - - let day_padded = day.to_string(); - let mut args = vec!["run", "--quiet", "--bin", &day_padded]; - - if is_release { - args.push("--release"); - } - - if is_timed { - // mirror `--time` flag to child invocations. - args.push("--"); - args.push("--time"); - } - - // spawn child command with piped stdout/stderr. - // forward output to stdout/stderr while grabbing stdout lines. - - let mut cmd = Command::new("cargo") - .args(&args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - - let stdout = BufReader::new(cmd.stdout.take().ok_or(super::Error::BrokenPipe)?); - let stderr = BufReader::new(cmd.stderr.take().ok_or(super::Error::BrokenPipe)?); - - let mut output = vec![]; - - let thread = thread::spawn(move || { - stderr.lines().for_each(|line| { - eprintln!("{}", line.unwrap()); - }); - }); - - for line in stdout.lines() { - let line = line.unwrap(); - println!("{line}"); - output.push(line); - } - - thread.join().unwrap(); - cmd.wait()?; - - Ok(output) - } - - pub fn parse_exec_time(output: &[String], day: Day) -> super::Timings { - let mut timings = super::Timings { - day, - part_1: None, - part_2: None, - total_nanos: 0_f64, - }; - - output - .iter() - .filter_map(|l| { - if !l.contains(" samples)") { - return None; - } - - let Some((timing_str, nanos)) = parse_time(l) else { - eprintln!("Could not parse timings from line: {l}"); - return None; - }; - - let part = l.split(':').next()?; - Some((part, timing_str, nanos)) - }) - .for_each(|(part, timing_str, nanos)| { - if part.contains("Part 1") { - timings.part_1 = Some(timing_str.into()); - } else if part.contains("Part 2") { - timings.part_2 = Some(timing_str.into()); - } - - timings.total_nanos += nanos; - }); - - timings - } - - fn parse_to_float(s: &str, postfix: &str) -> Option { - s.split(postfix).next()?.parse().ok() - } - - fn parse_time(line: &str) -> Option<(&str, f64)> { - // for possible time formats, see: https://github.com/rust-lang/rust/blob/1.64.0/library/core/src/time.rs#L1176-L1200 - let str_timing = line - .split(" samples)") - .next()? - .split('(') - .last()? - .split('@') - .next()? - .trim(); - - let parsed_timing = match str_timing { - s if s.contains("ns") => s.split("ns").next()?.parse::().ok(), - s if s.contains("µs") => parse_to_float(s, "µs").map(|x| x * 1000_f64), - s if s.contains("ms") => parse_to_float(s, "ms").map(|x| x * 1_000_000_f64), - s => parse_to_float(s, "s").map(|x| x * 1_000_000_000_f64), - }?; - - Some((str_timing, parsed_timing)) - } - - /// copied from: https://github.com/rust-lang/rust/blob/1.64.0/library/std/src/macros.rs#L328-L333 - #[cfg(feature = "test_lib")] - macro_rules! assert_approx_eq { - ($a:expr, $b:expr) => {{ - let (a, b) = (&$a, &$b); - assert!( - (*a - *b).abs() < 1.0e-6, - "{} is not approximately equal to {}", - *a, - *b - ); - }}; - } - - #[cfg(feature = "test_lib")] - mod tests { - use super::parse_exec_time; - - use crate::day; - - #[test] - fn test_well_formed() { - let res = parse_exec_time( - &[ - "Part 1: 0 (74.13ns @ 100000 samples)".into(), - "Part 2: 10 (74.13ms @ 99999 samples)".into(), - "".into(), - ], - day!(1), - ); - assert_approx_eq!(res.total_nanos, 74130074.13_f64); - assert_eq!(res.part_1.unwrap(), "74.13ns"); - assert_eq!(res.part_2.unwrap(), "74.13ms"); - } - - #[test] - fn test_patterns_in_input() { - let res = parse_exec_time( - &[ - "Part 1: @ @ @ ( ) ms (2s @ 5 samples)".into(), - "Part 2: 10s (100ms @ 1 samples)".into(), - "".into(), - ], - day!(1), - ); - assert_approx_eq!(res.total_nanos, 2100000000_f64); - assert_eq!(res.part_1.unwrap(), "2s"); - assert_eq!(res.part_2.unwrap(), "100ms"); - } - - #[test] - fn test_missing_parts() { - let res = parse_exec_time( - &[ - "Part 1: ✖ ".into(), - "Part 2: ✖ ".into(), - "".into(), - ], - day!(1), - ); - assert_approx_eq!(res.total_nanos, 0_f64); - assert_eq!(res.part_1.is_none(), true); - assert_eq!(res.part_2.is_none(), true); - } - } + run_multi(all_days().collect(), is_release, is_timed); } diff --git a/src/template/commands/mod.rs b/src/template/commands/mod.rs index 88f4696..36be280 100644 --- a/src/template/commands/mod.rs +++ b/src/template/commands/mod.rs @@ -3,3 +3,4 @@ pub mod download; pub mod read; pub mod scaffold; pub mod solve; +pub mod time; diff --git a/src/template/commands/time.rs b/src/template/commands/time.rs new file mode 100644 index 0000000..ce110ab --- /dev/null +++ b/src/template/commands/time.rs @@ -0,0 +1,35 @@ +use std::collections::HashSet; + +use crate::template::run_multi::run_multi; +use crate::template::timings::Timings; +use crate::template::{all_days, readme_benchmarks, Day}; + +pub fn handle(day: Option, recreate_all: bool) { + let stored_timings = Timings::read_from_file(); + + let days_to_run = day.map(|day| HashSet::from([day])).unwrap_or_else(|| { + if recreate_all { + all_days().collect() + } else { + // when the `--all` flag is not set, filter out days that are fully benched. + all_days() + .filter(|day| !stored_timings.is_day_complete(day)) + .collect() + } + }); + + let timings = run_multi(days_to_run, true, true).unwrap(); + + let merged_timings = stored_timings.merge(&timings); + merged_timings.store_file().unwrap(); + + println!(); + match readme_benchmarks::update(merged_timings) { + Ok(()) => { + println!("Stored updated benchmarks.") + } + Err(_) => { + eprintln!("Failed to store updated benchmarks."); + } + } +} diff --git a/src/template/mod.rs b/src/template/mod.rs index 4d84a72..dd8e4c0 100644 --- a/src/template/mod.rs +++ b/src/template/mod.rs @@ -2,12 +2,15 @@ use std::{env, fs}; pub mod aoc_cli; pub mod commands; -mod day; -pub mod readme_benchmarks; pub mod runner; pub use day::*; +mod day; +mod readme_benchmarks; +mod run_multi; +mod timings; + pub const ANSI_ITALIC: &str = "\x1b[3m"; pub const ANSI_BOLD: &str = "\x1b[1m"; pub const ANSI_RESET: &str = "\x1b[0m"; diff --git a/src/template/readme_benchmarks.rs b/src/template/readme_benchmarks.rs index b282196..1498dbb 100644 --- a/src/template/readme_benchmarks.rs +++ b/src/template/readme_benchmarks.rs @@ -2,6 +2,7 @@ /// The approach taken is similar to how `aoc-readme-stars` handles this. use std::{fs, io}; +use crate::template::timings::Timings; use crate::template::Day; static MARKER: &str = ""; @@ -18,14 +19,6 @@ impl From for Error { } } -#[derive(Clone)] -pub struct Timings { - pub day: Day, - pub part_1: Option, - pub part_2: Option, - pub total_nanos: f64, -} - pub struct TablePosition { pos_start: usize, pos_end: usize, @@ -58,7 +51,7 @@ fn locate_table(readme: &str) -> Result { Ok(TablePosition { pos_start, pos_end }) } -fn construct_table(prefix: &str, timings: Vec, total_millis: f64) -> String { +fn construct_table(prefix: &str, timings: Timings, total_millis: f64) -> String { let header = format!("{prefix} Benchmarks"); let mut lines: Vec = vec![ @@ -69,7 +62,7 @@ fn construct_table(prefix: &str, timings: Vec, total_millis: f64) -> St "| :---: | :---: | :---: |".into(), ]; - for timing in timings { + for timing in timings.data { let path = get_path_for_bin(timing.day); lines.push(format!( "| [Day {}]({}) | `{}` | `{}` |", @@ -87,16 +80,17 @@ fn construct_table(prefix: &str, timings: Vec, total_millis: f64) -> St lines.join("\n") } -fn update_content(s: &mut String, timings: Vec, total_millis: f64) -> Result<(), Error> { +fn update_content(s: &mut String, timings: Timings, total_millis: f64) -> Result<(), Error> { let positions = locate_table(s)?; let table = construct_table("##", timings, total_millis); s.replace_range(positions.pos_start..positions.pos_end, &table); Ok(()) } -pub fn update(timings: Vec, total_millis: f64) -> Result<(), Error> { +pub fn update(timings: Timings) -> Result<(), Error> { let path = "README.md"; let mut readme = String::from_utf8_lossy(&fs::read(path)?).to_string(); + let total_millis = timings.total_millis(); update_content(&mut readme, timings, total_millis)?; fs::write(path, &readme)?; Ok(()) @@ -104,30 +98,32 @@ pub fn update(timings: Vec, total_millis: f64) -> Result<(), Error> { #[cfg(feature = "test_lib")] mod tests { - use super::{update_content, Timings, MARKER}; - use crate::day; - - fn get_mock_timings() -> Vec { - vec![ - Timings { - day: day!(1), - part_1: Some("10ms".into()), - part_2: Some("20ms".into()), - total_nanos: 3e+10, - }, - Timings { - day: day!(2), - part_1: Some("30ms".into()), - part_2: Some("40ms".into()), - total_nanos: 7e+10, - }, - Timings { - day: day!(4), - part_1: Some("40ms".into()), - part_2: Some("50ms".into()), - total_nanos: 9e+10, - }, - ] + use super::{update_content, MARKER}; + use crate::{day, template::timings::Timing, template::timings::Timings}; + + fn get_mock_timings() -> Timings { + Timings { + data: vec![ + Timing { + day: day!(1), + part_1: Some("10ms".into()), + part_2: Some("20ms".into()), + total_nanos: 3e+10, + }, + Timing { + day: day!(2), + part_1: Some("30ms".into()), + part_2: Some("40ms".into()), + total_nanos: 7e+10, + }, + Timing { + day: day!(4), + part_1: Some("40ms".into()), + part_2: Some("50ms".into()), + total_nanos: 9e+10, + }, + ], + } } #[test] diff --git a/src/template/run_multi.rs b/src/template/run_multi.rs new file mode 100644 index 0000000..0fdf2d5 --- /dev/null +++ b/src/template/run_multi.rs @@ -0,0 +1,255 @@ +use std::{collections::HashSet, io}; + +use crate::template::{Day, ANSI_BOLD, ANSI_ITALIC, ANSI_RESET}; + +use super::{ + all_days, + timings::{Timing, Timings}, +}; + +pub fn run_multi(days_to_run: HashSet, is_release: bool, is_timed: bool) -> Option { + let mut timings: Vec = Vec::with_capacity(days_to_run.len()); + + all_days().for_each(|day| { + if day > 1 { + println!(); + } + + println!("{ANSI_BOLD}Day {day}{ANSI_RESET}"); + println!("------"); + + if !days_to_run.contains(&day) { + println!("Skipped."); + return; + } + + let output = child_commands::run_solution(day, is_timed, is_release).unwrap(); + + if output.is_empty() { + println!("Not solved."); + } else { + let val = child_commands::parse_exec_time(&output, day); + timings.push(val); + } + }); + + if is_timed { + let timings = Timings { data: timings }; + let total_millis = timings.total_millis(); + println!( + "\n{ANSI_BOLD}Total (Run):{ANSI_RESET} {ANSI_ITALIC}{total_millis:.2}ms{ANSI_RESET}" + ); + Some(timings) + } else { + None + } +} + +#[derive(Debug)] +pub enum Error { + BrokenPipe, + IO(io::Error), +} + +impl From for Error { + fn from(e: std::io::Error) -> Self { + Error::IO(e) + } +} + +#[must_use] +pub fn get_path_for_bin(day: Day) -> String { + format!("./src/bin/{day}.rs") +} + +/// All solutions live in isolated binaries. +/// This module encapsulates interaction with these binaries, both invoking them as well as parsing the timing output. +pub mod child_commands { + use super::{get_path_for_bin, Error}; + use crate::template::Day; + use std::{ + io::{BufRead, BufReader}, + path::Path, + process::{Command, Stdio}, + thread, + }; + + /// Run the solution bin for a given day + pub fn run_solution(day: Day, is_timed: bool, is_release: bool) -> Result, Error> { + // skip command invocation for days that have not been scaffolded yet. + if !Path::new(&get_path_for_bin(day)).exists() { + return Ok(vec![]); + } + + let day_padded = day.to_string(); + let mut args = vec!["run", "--quiet", "--bin", &day_padded]; + + if is_release { + args.push("--release"); + } + + if is_timed { + // mirror `--time` flag to child invocations. + args.push("--"); + args.push("--time"); + } + + // spawn child command with piped stdout/stderr. + // forward output to stdout/stderr while grabbing stdout lines. + + let mut cmd = Command::new("cargo") + .args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let stdout = BufReader::new(cmd.stdout.take().ok_or(super::Error::BrokenPipe)?); + let stderr = BufReader::new(cmd.stderr.take().ok_or(super::Error::BrokenPipe)?); + + let mut output = vec![]; + + let thread = thread::spawn(move || { + stderr.lines().for_each(|line| { + eprintln!("{}", line.unwrap()); + }); + }); + + for line in stdout.lines() { + let line = line.unwrap(); + println!("{line}"); + output.push(line); + } + + thread.join().unwrap(); + cmd.wait()?; + + Ok(output) + } + + pub fn parse_exec_time(output: &[String], day: Day) -> super::Timing { + let mut timings = super::Timing { + day, + part_1: None, + part_2: None, + total_nanos: 0_f64, + }; + + output + .iter() + .filter_map(|l| { + if !l.contains(" samples)") { + return None; + } + + let Some((timing_str, nanos)) = parse_time(l) else { + eprintln!("Could not parse timings from line: {l}"); + return None; + }; + + let part = l.split(':').next()?; + Some((part, timing_str, nanos)) + }) + .for_each(|(part, timing_str, nanos)| { + if part.contains("Part 1") { + timings.part_1 = Some(timing_str.into()); + } else if part.contains("Part 2") { + timings.part_2 = Some(timing_str.into()); + } + + timings.total_nanos += nanos; + }); + + timings + } + + fn parse_to_float(s: &str, postfix: &str) -> Option { + s.split(postfix).next()?.parse().ok() + } + + fn parse_time(line: &str) -> Option<(&str, f64)> { + // for possible time formats, see: https://github.com/rust-lang/rust/blob/1.64.0/library/core/src/time.rs#L1176-L1200 + let str_timing = line + .split(" samples)") + .next()? + .split('(') + .last()? + .split('@') + .next()? + .trim(); + + let parsed_timing = match str_timing { + s if s.contains("ns") => s.split("ns").next()?.parse::().ok(), + s if s.contains("µs") => parse_to_float(s, "µs").map(|x| x * 1000_f64), + s if s.contains("ms") => parse_to_float(s, "ms").map(|x| x * 1_000_000_f64), + s => parse_to_float(s, "s").map(|x| x * 1_000_000_000_f64), + }?; + + Some((str_timing, parsed_timing)) + } + + /// copied from: https://github.com/rust-lang/rust/blob/1.64.0/library/std/src/macros.rs#L328-L333 + #[cfg(feature = "test_lib")] + macro_rules! assert_approx_eq { + ($a:expr, $b:expr) => {{ + let (a, b) = (&$a, &$b); + assert!( + (*a - *b).abs() < 1.0e-6, + "{} is not approximately equal to {}", + *a, + *b + ); + }}; + } + + #[cfg(feature = "test_lib")] + mod tests { + use super::parse_exec_time; + + use crate::day; + + #[test] + fn parses_execution_times() { + let res = parse_exec_time( + &[ + "Part 1: 0 (74.13ns @ 100000 samples)".into(), + "Part 2: 10 (74.13ms @ 99999 samples)".into(), + "".into(), + ], + day!(1), + ); + assert_approx_eq!(res.total_nanos, 74130074.13_f64); + assert_eq!(res.part_1.unwrap(), "74.13ns"); + assert_eq!(res.part_2.unwrap(), "74.13ms"); + } + + #[test] + fn parses_with_patterns_in_input() { + let res = parse_exec_time( + &[ + "Part 1: @ @ @ ( ) ms (2s @ 5 samples)".into(), + "Part 2: 10s (100ms @ 1 samples)".into(), + "".into(), + ], + day!(1), + ); + assert_approx_eq!(res.total_nanos, 2100000000_f64); + assert_eq!(res.part_1.unwrap(), "2s"); + assert_eq!(res.part_2.unwrap(), "100ms"); + } + + #[test] + fn parses_missing_parts() { + let res = parse_exec_time( + &[ + "Part 1: ✖ ".into(), + "Part 2: ✖ ".into(), + "".into(), + ], + day!(1), + ); + assert_approx_eq!(res.total_nanos, 0_f64); + assert_eq!(res.part_1.is_none(), true); + assert_eq!(res.part_2.is_none(), true); + } + } +} diff --git a/src/template/runner.rs b/src/template/runner.rs index 021c9cd..b4e41bc 100644 --- a/src/template/runner.rs +++ b/src/template/runner.rs @@ -1,5 +1,4 @@ /// Encapsulates code that interacts with solution functions. -use crate::template::{aoc_cli, Day, ANSI_ITALIC, ANSI_RESET}; use std::fmt::Display; use std::hint::black_box; use std::io::{stdout, Write}; @@ -7,7 +6,8 @@ use std::process::Output; use std::time::{Duration, Instant}; use std::{cmp, env, process}; -use super::ANSI_BOLD; +use crate::template::ANSI_BOLD; +use crate::template::{aoc_cli, Day, ANSI_ITALIC, ANSI_RESET}; pub fn run_part(func: impl Fn(I) -> Option, input: I, day: Day, part: u8) { let part_str = format!("Part {part}"); diff --git a/src/template/timings.rs b/src/template/timings.rs new file mode 100644 index 0000000..f7dc091 --- /dev/null +++ b/src/template/timings.rs @@ -0,0 +1,391 @@ +use std::{collections::HashMap, fs, io::Error, str::FromStr}; +use tinyjson::JsonValue; + +use crate::template::Day; + +static TIMINGS_FILE_PATH: &str = "./data/timings.json"; + +/// Represents benchmark times for a single day. +#[derive(Clone, Debug)] +pub struct Timing { + pub day: Day, + pub part_1: Option, + pub part_2: Option, + pub total_nanos: f64, +} + +/// Represents benchmark times for a set of days. +/// Can be serialized from / to JSON. +#[derive(Clone, Debug, Default)] +pub struct Timings { + pub data: Vec, +} + +impl Timings { + /// Dehydrate timings to a JSON file. + pub fn store_file(&self) -> Result<(), Error> { + let json = JsonValue::from(self.clone()); + let mut file = fs::File::create(TIMINGS_FILE_PATH)?; + json.format_to(&mut file) + } + + /// Rehydrate timings from a JSON file. If not present, returns empty timings. + pub fn read_from_file() -> Self { + let s = fs::read_to_string(TIMINGS_FILE_PATH) + .map_err(|x| x.to_string()) + .and_then(Timings::try_from); + + match s { + Ok(timings) => timings, + Err(e) => { + eprintln!("{}", e); + Timings::default() + } + } + } + + /// Merge two sets of timings, overwriting `self` with `other` if present. + pub fn merge(&self, new: &Self) -> Self { + let mut data: Vec = vec![]; + + for timing in &new.data { + data.push(timing.clone()); + } + + for timing in &self.data { + if !data.iter().any(|t| t.day == timing.day) { + data.push(timing.clone()); + } + } + + data.sort_unstable_by(|a, b| a.day.cmp(&b.day)); + Timings { data } + } + + /// Sum up total duration of timings as millis. + pub fn total_millis(&self) -> f64 { + self.data.iter().map(|x| x.total_nanos).sum::() / 1_000_000_f64 + } + + pub fn is_day_complete(&self, day: &Day) -> bool { + self.data + .iter() + .any(|t| &t.day == day && t.part_1.is_some() && t.part_2.is_some()) + } +} + +/* -------------------------------------------------------------------------- */ + +impl From for JsonValue { + fn from(value: Timings) -> Self { + let mut map: HashMap = HashMap::new(); + + map.insert( + "data".into(), + JsonValue::Array(value.data.iter().map(JsonValue::from).collect()), + ); + + JsonValue::Object(map) + } +} + +impl TryFrom for Timings { + type Error = String; + + fn try_from(value: String) -> Result { + let json = JsonValue::from_str(&value).or(Err("not valid JSON file."))?; + + let json_data = json + .get::>() + .ok_or("expected JSON document to be an object.")? + .get("data") + .ok_or("expected JSON document to have key `data`.")? + .get::>() + .ok_or("expected `json.data` to be an array.")?; + + Ok(Timings { + data: json_data + .iter() + .map(Timing::try_from) + .collect::>()?, + }) + } +} + +/* -------------------------------------------------------------------------- */ + +impl From<&Timing> for JsonValue { + fn from(value: &Timing) -> Self { + let mut map: HashMap = HashMap::new(); + + map.insert("day".into(), JsonValue::String(value.day.to_string())); + map.insert("total_nanos".into(), JsonValue::Number(value.total_nanos)); + + let part_1 = value.part_1.clone().map(JsonValue::String); + let part_2 = value.part_2.clone().map(JsonValue::String); + + map.insert( + "part_1".into(), + match part_1 { + Some(x) => x, + None => JsonValue::Null, + }, + ); + + map.insert( + "part_2".into(), + match part_2 { + Some(x) => x, + None => JsonValue::Null, + }, + ); + + JsonValue::Object(map) + } +} + +impl TryFrom<&JsonValue> for Timing { + type Error = String; + + fn try_from(value: &JsonValue) -> Result { + let json = value + .get::>() + .ok_or("Expected timing to be a JSON object.")?; + + let day = json + .get("day") + .and_then(|v| v.get::()) + .and_then(|day| Day::from_str(day).ok()) + .ok_or("Expected timing.day to be a Day struct.")?; + + let part_1 = json + .get("part_1") + .map(|v| if v.is_null() { None } else { v.get::() }) + .ok_or("Expected timing.part_1 to be null or string.")?; + + let part_2 = json + .get("part_2") + .map(|v| if v.is_null() { None } else { v.get::() }) + .ok_or("Expected timing.part_2 to be null or string.")?; + + let total_nanos = json + .get("total_nanos") + .and_then(|v| v.get::().copied()) + .ok_or("Expected timing.total_nanos to be a number.")?; + + Ok(Timing { + day, + part_1: part_1.cloned(), + part_2: part_2.cloned(), + total_nanos, + }) + } +} + +/* -------------------------------------------------------------------------- */ + +#[cfg(feature = "test_lib")] +mod tests { + use crate::day; + + use super::{Timing, Timings}; + + fn get_mock_timings() -> Timings { + Timings { + data: vec![ + Timing { + day: day!(1), + part_1: Some("10ms".into()), + part_2: Some("20ms".into()), + total_nanos: 3e+10, + }, + Timing { + day: day!(2), + part_1: Some("30ms".into()), + part_2: Some("40ms".into()), + total_nanos: 7e+10, + }, + Timing { + day: day!(4), + part_1: Some("40ms".into()), + part_2: None, + total_nanos: 4e+10, + }, + ], + } + } + + mod deserialization { + use crate::{day, template::timings::Timings}; + + #[test] + fn handles_json_timings() { + let json = r#"{ "data": [{ "day": "01", "part_1": "1ms", "part_2": null, "total_nanos": 1000000000 }] }"#.to_string(); + let timings = Timings::try_from(json).unwrap(); + assert_eq!(timings.data.len(), 1); + let timing = timings.data.first().unwrap(); + assert_eq!(timing.day, day!(1)); + assert_eq!(timing.part_1, Some("1ms".to_string())); + assert_eq!(timing.part_2, None); + assert_eq!(timing.total_nanos, 1_000_000_000_f64); + } + + #[test] + fn handles_empty_timings() { + let json = r#"{ "data": [] }"#.to_string(); + let timings = Timings::try_from(json).unwrap(); + assert_eq!(timings.data.len(), 0); + } + + #[test] + #[should_panic] + fn panics_for_invalid_json() { + let json = r#"{}"#.to_string(); + Timings::try_from(json).unwrap(); + } + + #[test] + #[should_panic] + fn panics_for_malformed_timings() { + let json = r#"{ "data": [{ "day": "01" }, { "day": "26" }, { "day": "02", "part_2": null, "total_nanos": 0 }] }"#.to_string(); + Timings::try_from(json).unwrap(); + } + } + + mod serialization { + use super::get_mock_timings; + use std::collections::HashMap; + use tinyjson::JsonValue; + + #[test] + fn serializes_timings() { + let timings = get_mock_timings(); + let value = JsonValue::try_from(timings).unwrap(); + assert_eq!( + value + .get::>() + .unwrap() + .get("data") + .unwrap() + .get::>() + .unwrap() + .len(), + 3 + ); + } + } + + mod is_day_complete { + use crate::{ + day, + template::timings::{Timing, Timings}, + }; + + #[test] + fn handles_completed_days() { + let timings = Timings { + data: vec![Timing { + day: day!(1), + part_1: Some("1ms".into()), + part_2: Some("2ms".into()), + total_nanos: 3_000_000_000_f64, + }], + }; + + assert_eq!(timings.is_day_complete(&day!(1)), true); + } + + #[test] + fn handles_partial_days() { + let timings = Timings { + data: vec![Timing { + day: day!(1), + part_1: Some("1ms".into()), + part_2: None, + total_nanos: 1_000_000_000_f64, + }], + }; + + assert_eq!(timings.is_day_complete(&day!(1)), false); + } + + #[test] + fn handles_uncompleted_days() { + let timings = Timings { + data: vec![Timing { + day: day!(1), + part_1: None, + part_2: None, + total_nanos: 0.0, + }], + }; + + assert_eq!(timings.is_day_complete(&day!(1)), false); + } + } + + mod merge { + use crate::{ + day, + template::timings::{Timing, Timings}, + }; + + use super::get_mock_timings; + + #[test] + fn handles_disjunct_timings() { + let timings = get_mock_timings(); + let other = Timings { + data: vec![Timing { + day: day!(3), + part_1: None, + part_2: None, + total_nanos: 0_f64, + }], + }; + let merged = timings.merge(&other); + assert_eq!(merged.data.len(), 4); + assert_eq!(merged.data[0].day, day!(1)); + assert_eq!(merged.data[1].day, day!(2)); + assert_eq!(merged.data[2].day, day!(3)); + assert_eq!(merged.data[3].day, day!(4)); + } + + #[test] + fn handles_overlapping_timings() { + let timings = get_mock_timings(); + + let other = Timings { + data: vec![Timing { + day: day!(2), + part_1: None, + part_2: None, + total_nanos: 0_f64, + }], + }; + let merged = timings.merge(&other); + + assert_eq!(merged.data.len(), 3); + assert_eq!(merged.data[0].day, day!(1)); + assert_eq!(merged.data[1].day, day!(2)); + assert_eq!(merged.data[1].total_nanos, 0_f64); + assert_eq!(merged.data[2].day, day!(4)); + } + + #[test] + fn handles_empty_timings() { + let timings = Timings::default(); + let other = get_mock_timings(); + let merged = timings.merge(&other); + assert_eq!(merged.data.len(), 3); + } + + #[test] + fn handles_empty_other_timings() { + let timings = get_mock_timings(); + let other = Timings::default(); + let merged = timings.merge(&other); + assert_eq!(merged.data.len(), 3); + } + } +}