feat: make cargo time incremental by default (#53)

Co-authored-by: Tristan Guichaoua <33934311+tguichaoua@users.noreply.github.com>
This commit is contained in:
Felix Spöttel 2023-12-10 13:55:17 +01:00 committed by GitHub
parent 4c4232139a
commit 874f57b359
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 757 additions and 300 deletions

View file

@ -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"

4
.gitignore vendored
View file

@ -24,3 +24,7 @@ data/puzzles/*
# Dhat
dhat-heap.json
# Benchmarks
data/timings.json

7
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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 <day>`.
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

View file

@ -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<Day>,
},
#[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 } => {

View file

@ -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."),
}
}
}

View file

@ -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<Timings> = 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::<f64>() / 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<std::io::Error> 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<Vec<String>, 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<f64> {
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::<f64>().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);
}

View file

@ -3,3 +3,4 @@ pub mod download;
pub mod read;
pub mod scaffold;
pub mod solve;
pub mod time;

View file

@ -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<Day>, 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.");
}
}
}

View file

@ -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";

View file

@ -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 = "<!--- benchmarking table --->";
@ -18,14 +19,6 @@ impl From<std::io::Error> for Error {
}
}
#[derive(Clone)]
pub struct Timings {
pub day: Day,
pub part_1: Option<String>,
pub part_2: Option<String>,
pub total_nanos: f64,
}
pub struct TablePosition {
pos_start: usize,
pos_end: usize,
@ -58,7 +51,7 @@ fn locate_table(readme: &str) -> Result<TablePosition, Error> {
Ok(TablePosition { pos_start, pos_end })
}
fn construct_table(prefix: &str, timings: Vec<Timings>, total_millis: f64) -> String {
fn construct_table(prefix: &str, timings: Timings, total_millis: f64) -> String {
let header = format!("{prefix} Benchmarks");
let mut lines: Vec<String> = vec![
@ -69,7 +62,7 @@ fn construct_table(prefix: &str, timings: Vec<Timings>, 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<Timings>, total_millis: f64) -> St
lines.join("\n")
}
fn update_content(s: &mut String, timings: Vec<Timings>, 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<Timings>, 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<Timings>, total_millis: f64) -> Result<(), Error> {
#[cfg(feature = "test_lib")]
mod tests {
use super::{update_content, Timings, MARKER};
use crate::day;
use super::{update_content, MARKER};
use crate::{day, template::timings::Timing, template::timings::Timings};
fn get_mock_timings() -> Vec<Timings> {
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,
},
]
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]

255
src/template/run_multi.rs Normal file
View file

@ -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<Day>, is_release: bool, is_timed: bool) -> Option<Timings> {
let mut timings: Vec<Timing> = 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<std::io::Error> 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<Vec<String>, 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<f64> {
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::<f64>().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);
}
}
}

View file

@ -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<I: Clone, T: Display>(func: impl Fn(I) -> Option<T>, input: I, day: Day, part: u8) {
let part_str = format!("Part {part}");

391
src/template/timings.rs Normal file
View file

@ -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<String>,
pub part_2: Option<String>,
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<Timing>,
}
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<Timing> = 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::<f64>() / 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<Timings> for JsonValue {
fn from(value: Timings) -> Self {
let mut map: HashMap<String, JsonValue> = HashMap::new();
map.insert(
"data".into(),
JsonValue::Array(value.data.iter().map(JsonValue::from).collect()),
);
JsonValue::Object(map)
}
}
impl TryFrom<String> for Timings {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
let json = JsonValue::from_str(&value).or(Err("not valid JSON file."))?;
let json_data = json
.get::<HashMap<String, JsonValue>>()
.ok_or("expected JSON document to be an object.")?
.get("data")
.ok_or("expected JSON document to have key `data`.")?
.get::<Vec<JsonValue>>()
.ok_or("expected `json.data` to be an array.")?;
Ok(Timings {
data: json_data
.iter()
.map(Timing::try_from)
.collect::<Result<_, _>>()?,
})
}
}
/* -------------------------------------------------------------------------- */
impl From<&Timing> for JsonValue {
fn from(value: &Timing) -> Self {
let mut map: HashMap<String, JsonValue> = 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<Self, Self::Error> {
let json = value
.get::<HashMap<String, JsonValue>>()
.ok_or("Expected timing to be a JSON object.")?;
let day = json
.get("day")
.and_then(|v| v.get::<String>())
.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::<String>() })
.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::<String>() })
.ok_or("Expected timing.part_2 to be null or string.")?;
let total_nanos = json
.get("total_nanos")
.and_then(|v| v.get::<f64>().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::<HashMap<String, JsonValue>>()
.unwrap()
.get("data")
.unwrap()
.get::<Vec<JsonValue>>()
.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);
}
}
}