diff --git a/.cargo/config.toml b/.cargo/config.toml index cf0d80d..2f82933 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,7 +3,10 @@ today = "run --quiet --release --features today -- today" scaffold = "run --quiet --release -- scaffold" download = "run --quiet --release -- download" read = "run --quiet --release -- read" - +set-year = "run --quiet --release -- set-year" +new-year = "run --quiet --release -- new-year" +get-year = "run --quiet --release -- get-year" +try = "run --quiet --release -- try" solve = "run --quiet --release -- solve" all = "run --quiet --release -- all" time = "run --quiet --release -- time" diff --git a/Cargo.lock b/Cargo.lock index 9504be6..0ecc99f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,16 @@ dependencies = [ "tinyjson", ] +[[package]] +name = "advent_of_code_YEAR_NUMBER" +version = "0.11.0" +dependencies = [ + "chrono", + "dhat", + "pico-args", + "tinyjson", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -225,9 +235,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.17" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -278,18 +288,18 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "proc-macro2" -version = "1.0.70" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.33" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] @@ -366,9 +376,9 @@ checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970" [[package]] name = "syn" -version = "2.0.39" +version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 038a1a3..9149320 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,9 @@ edition = "2021" default-run = "advent_of_code" publish = false +[workspace] +members = ["year_template"] + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] doctest = false diff --git a/README.md b/README.md index e3db903..b29f412 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,6 @@ This template supports all major OS (macOS, Linux, Windows). 1. Open [the template repository](https://github.com/fspoettel/advent-of-code-rust) on Github. 2. Click [Use this template](https://github.com/fspoettel/advent-of-code-rust/generate) and create your repository. 3. Clone your repository to your computer. -4. If you are solving a previous year's advent of code, change the `AOC_YEAR` variable in `.cargo/config.toml` to reflect the year you are solving. ### 💻 Setup rust @@ -33,6 +32,22 @@ This template supports all major OS (macOS, Linux, Windows). ## Usage +### ➡️ Start a new year + +```sh +# example: `cargo new-year 2024` +cargo new-year + +# output: +# Created 2024 workspace project. +# Set the repository's current working year to 2024. +# --- +# 🎄 Type `cargo scaffold ` to get started on the year. +# 🎄 Or type `cargo set-year ` to switch to working on a different year. +``` + +A year has its own directory `.//` within the repository. This subdirectory behaves as its own crate with its own dependencies separate from the repository root. Its contents are copied from the directory `./year_template/` so if you add dependencies or utility files there they will be copied into any new year project you create. You can run all of the following commands from within the `.//` directory. You can also run the Advent of Code custom commands from the project root directory so long as the repository year is set to `` (see the `set-year` command). + ### ➡️ Scaffold a day ```sh @@ -74,6 +89,24 @@ cargo download # 🎄 Successfully wrote puzzle to "data/puzzles/01.md". ``` +### ➡️ Run tests for a day + +```sh +# example: `cargo try 1` +cargo try + +# Finished `test` profile [unoptimized + debuginfo] target(s) in 0.84s +# Running unittests src/bin/01.rs (target/debug/deps/01-faca1023160cfe39) + +# running 2 tests +# test tests::test_part_two ... ok +# test tests::test_part_one ... ok +# +# test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s +``` + +The `try` command runs the tests for your solution against the example puzzle inputs. You can narrow it down to a specific test or set of tests, e.g. `cargo try 1 part_one` to run just the part one test. + ### ➡️ Run solutions for a day ```sh @@ -112,7 +145,7 @@ 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 for a year sequentially and prints output to the command-line. Same as for the `solve` command, the `--release` flag runs an optimized build. ### ➡️ Benchmark your solutions @@ -149,7 +182,7 @@ By default, `cargo time` does not write to the readme. In order to do so, append cargo test ``` -To run tests for a specific day, append `--bin `, e.g. `cargo test --bin 01`. You can further scope it down to a specific part, e.g. `cargo test --bin 01 part_one`. +To run tests for a specific day, append `--bin `, e.g. `cargo test --bin 01`. You can further scope it down to a specific part, e.g. `cargo test --bin 01 part_one`. This command only works within a given year's subdirectory (such as running all the 2024 tests by running `cargo test` in `./2024/`). ### ➡️ Read puzzle description @@ -201,18 +234,42 @@ cargo today # ...the input... ``` +### ➡️ Change what year the repository is set to +```sh +# example: `cargo set-year 2024` +cargo set-year 2024 + +# output: +# Set repository to year 2024. +``` + +This sets the repository's "configured year" which is tracked in `./.cargo/config.toml`. When running Advent of Code custom commands from the project's root directory, it will execute them for this year. Creating a new year subproject automatically sets the repository's year to that year. + +### ➡️ Check what year the repository is set to +```sh +# example: `cargo get-year` when you've been working on 2024 +cargo get-year + +# output: +# The repository is currently set to 2024. +``` + ### ➡️ Format code ```sh cargo fmt ``` +Run this inside the `./` directory. + ### ➡️ Lint code ```sh cargo clippy ``` +Run this inside the `./` directory. + ## Optional template features ### Configure aoc-cli integration diff --git a/src/main.rs b/src/main.rs index 2a360fc..5870ad3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,6 @@ -use advent_of_code::template::commands::{all, download, read, scaffold, solve, time}; +use advent_of_code::template::commands::{ + all, attempt, download, new_year, read, scaffold, set_year, solve, time, +}; use args::{parse, AppArguments}; #[cfg(feature = "today")] @@ -28,6 +30,11 @@ mod args { dhat: bool, submit: Option, }, + Try { + day: Day, + test: Option, + dhat: bool, + }, All { release: bool, }, @@ -36,6 +43,13 @@ mod args { day: Option, store: bool, }, + NewYear { + year: u32, + }, + SetYear { + year: u32, + }, + GetYear, #[cfg(feature = "today")] Today, } @@ -74,6 +88,18 @@ mod args { submit: args.opt_value_from_str("--submit")?, dhat: args.contains("--dhat"), }, + Some("try") => AppArguments::Try { + day: args.free_from_str()?, + test: args.free_from_str().ok(), + dhat: args.contains("--dhat"), + }, + Some("new-year") => AppArguments::NewYear { + year: args.free_from_str()?, + }, + Some("set-year") => AppArguments::SetYear { + year: args.free_from_str()?, + }, + Some("get-year") => AppArguments::GetYear, #[cfg(feature = "today")] Some("today") => AppArguments::Today, Some(x) => { @@ -122,6 +148,7 @@ fn main() { dhat, submit, } => solve::handle(day, release, dhat, submit), + AppArguments::Try { day, test, dhat } => attempt::handle(day, test, dhat), #[cfg(feature = "today")] AppArguments::Today => { match Day::today() { @@ -139,6 +166,12 @@ fn main() { } }; } + AppArguments::NewYear { year } => new_year::handle(year), + AppArguments::SetYear { year } => set_year::handle(year), + AppArguments::GetYear => { + let year = advent_of_code::template::get_year_exit_on_fail(); + println!("The repository is currently set to {}.", year); + } }, }; } diff --git a/src/template/aoc_cli.rs b/src/template/aoc_cli.rs index 2d3300d..7dc7265 100644 --- a/src/template/aoc_cli.rs +++ b/src/template/aoc_cli.rs @@ -81,24 +81,19 @@ pub fn submit(day: Day, part: u8, result: &str) -> Result String { - format!("data/inputs/{day}.txt") + let year = crate::template::get_year_exit_on_fail(); + format!("{year}/data/inputs/{day}.txt") } fn get_puzzle_path(day: Day) -> String { - format!("data/puzzles/{day}.md") -} - -fn get_year() -> Option { - match std::env::var("AOC_YEAR") { - Ok(x) => x.parse().ok().or(None), - Err(_) => None, - } + let year = crate::template::get_year_exit_on_fail(); + format!("{year}/data/puzzles/{day}.md") } fn build_args(command: &str, args: &[String], day: Day) -> Vec { let mut cmd_args = args.to_vec(); - if let Some(year) = get_year() { + if let Some(year) = super::get_year() { cmd_args.push("--year".into()); cmd_args.push(year.to_string()); } diff --git a/src/template/commands/attempt.rs b/src/template/commands/attempt.rs new file mode 100644 index 0000000..44f5142 --- /dev/null +++ b/src/template/commands/attempt.rs @@ -0,0 +1,37 @@ +use std::process::{Command, Stdio}; + +use crate::template::Day; + +pub fn handle(day: Day, test: Option, dhat: bool) { + let year = crate::template::get_year_exit_on_fail(); + let year = format!("advent_of_code_{}", year); + let mut cmd_args = vec![ + "test".to_string(), + "-p".to_string(), + year, + "--bin".to_string(), + day.to_string(), + ]; + + if dhat { + cmd_args.extend([ + "--profile".to_string(), + "dhat".to_string(), + "--features".to_string(), + "dhat-heap".to_string(), + ]); + } else if let Some(test_id) = test { + cmd_args.push(test_id); + } + + cmd_args.push("--".to_string()); + + let mut cmd = Command::new("cargo") + .args(&cmd_args) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + + cmd.wait().unwrap(); +} diff --git a/src/template/commands/mod.rs b/src/template/commands/mod.rs index 36be280..5c22018 100644 --- a/src/template/commands/mod.rs +++ b/src/template/commands/mod.rs @@ -1,6 +1,43 @@ +use std::{ + fs::{File, OpenOptions}, + io::Write, + path::PathBuf, +}; + pub mod all; +pub mod attempt; pub mod download; +pub mod new_year; pub mod read; pub mod scaffold; +pub mod set_year; pub mod solve; pub mod time; + +#[derive(Debug)] +enum WriteError { + Open, + Write, +} + +fn open_file(filepath: &PathBuf) -> Result { + OpenOptions::new().write(true).truncate(true).open(filepath) +} + +fn write_file(filepath: &PathBuf, to_write: &[u8]) -> Result<(), WriteError> { + let file = open_file(filepath); + if file.is_err() { + eprintln!("Failed to open file {}", filepath.to_str().unwrap()); + return Err(WriteError::Open); + } + let mut file = file.unwrap(); + + match file.write_all(to_write) { + Ok(()) => Ok(()), + Err(e) => { + let filepath = filepath.to_str().unwrap(); + eprintln!("Failed to write to {filepath}: {e}"); + Err(WriteError::Write) + } + } +} diff --git a/src/template/commands/new_year.rs b/src/template/commands/new_year.rs new file mode 100644 index 0000000..bc48961 --- /dev/null +++ b/src/template/commands/new_year.rs @@ -0,0 +1,170 @@ +use std::{ + fs::{self}, + path::{Path, PathBuf}, + process::{self, Command, Stdio}, + str::FromStr, +}; + +use crate::template::commands::set_year; + +use super::{write_file, WriteError}; + +const YEAR_NUMBER_FILES: [&str; 7] = [ + "Cargo.toml", + "src/main.rs", + "src/template/aoc_cli.rs", + "src/template/run_multi.rs", + "src/template/template.txt", + "src/template/commands/scaffold.rs", + ".cargo/config.toml", +]; + +pub fn handle(year: u32) { + let project_root = PathBuf::from_str(env!("CARGO_MANIFEST_DIR")) + .unwrap() + .join("year_template"); + let new_root = PathBuf::from_str(env!("CARGO_MANIFEST_DIR")) + .unwrap() + .join(format!("{}", year)); + if new_root.exists() { + eprintln!("{} directory already exists", year); + process::exit(1); + } + + copy_year_template(&project_root, &new_root); + set_year_numbers(year, &new_root); + set_year(year); + add_to_workspace(year); + println!("Created {} workspace project.", year); + println!("Set the repository's current working year to {}.", year); + println!("---"); + println!("🎄 Type `cargo scaffold ` to get started on the year."); + println!("🎄 Or type `cargo set-year ` to switch to working on a different year."); +} + +fn copy_year_template(project_root: &Path, new_root: &Path) { + let cmd_args = vec![ + project_root.to_str().unwrap(), + &new_root.to_str().unwrap(), + "-r", + ]; + let mut cmd = Command::new("cp") + .args(&cmd_args) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + + cmd.wait().unwrap(); +} + +fn set_year_numbers(year: u32, new_root: &Path) { + for filename in YEAR_NUMBER_FILES { + let filepath = new_root.join(filename); + + let original_contents = match fs::read_to_string(filepath.clone()) { + Ok(original) => original, + Err(_) => { + eprintln!("Could not read from file {} to set year numbers.", + filepath.to_str().unwrap()); + cleanup(year); + process::exit(1); + } + }; + let mut new_contents = original_contents.clone(); + new_contents = new_contents.replace("%YEAR_NUMBER%", &year.to_string()); + if !filename.contains("scaffold") { + new_contents = new_contents.replace("YEAR_NUMBER", &year.to_string()); + } + let new_contents = new_contents.as_bytes(); + + if write_file(&filepath, new_contents).is_err() { + cleanup(year); + process::exit(1); + } + } +} + +fn set_year(year: u32) { + if !set_year::set_year(year) { + cleanup(year); + process::exit(1); + } +} + +fn add_to_workspace(year: u32) { + let filepath = PathBuf::from_str(env!("CARGO_MANIFEST_DIR")) + .unwrap() + .join("Cargo.toml"); + let original_contents = read_toml_file(); + if original_contents.is_err() { + cleanup(year); + process::exit(1); + } + let original_contents = original_contents.unwrap(); + let new_contents = add_year_to_toml_str(year, &original_contents); + match write_file(&filepath, new_contents.to_string().as_bytes()) { + Ok(()) => (), + Err(WriteError::Open) => (), + Err(WriteError::Write) => { + cleanup(year); + write_file(&filepath, original_contents.to_string().as_bytes()).unwrap(); + process::exit(1); + } + } +} + +fn read_toml_file() -> Result { + let filepath = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"); + let f = fs::read_to_string(filepath); + if f.is_err() { + eprintln!("Failed to read Cargo.toml."); + return Err(()); + } + Ok(f.unwrap()) +} + +fn add_year_to_toml_str(year: u32, original: &str) -> String { + let end_pos = get_end_pos_of_members(original); + if end_pos.is_err() { + cleanup(year); + process::exit(1); + } + let end_pos = end_pos.unwrap(); + + let start = &original[..end_pos]; + let end = &original[end_pos..]; + let new = format!(", \"{}\"", year); + format!("{}{}{}", start, new, end) +} + +fn get_end_pos_of_members(original: &str) -> Result { + let start_idx = original[..].find("members = ["); + if start_idx.is_none() { + eprintln!("Failed to find a members section of Cargo.toml."); + return Err(()); + } + let start_idx = start_idx.unwrap(); + let end_idx = original[start_idx..].find("]"); + match end_idx { + Some(i) => Ok(i + start_idx), + None => { + eprintln!("Failed to find the end of the members section of Cargo.toml."); + Err(()) + } + } +} + +fn cleanup(year: u32) { + let mut new_root = String::from_str(env!("CARGO_MANIFEST_DIR")).unwrap(); + new_root.push_str(&format!("/{}/", year)); + + let cmd_args = vec![&new_root, "-r"]; + let mut cmd = Command::new("rm") + .args(&cmd_args) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + cmd.wait().unwrap(); +} diff --git a/src/template/commands/scaffold.rs b/src/template/commands/scaffold.rs index fc3d950..76b6421 100644 --- a/src/template/commands/scaffold.rs +++ b/src/template/commands/scaffold.rs @@ -4,10 +4,12 @@ use std::{ process, }; -use crate::template::Day; +use crate::template::{get_year_exit_on_fail, Day}; -const MODULE_TEMPLATE: &str = - include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/template.txt")); +const MODULE_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/template/template.txt" +)); fn safe_create_file(path: &str, overwrite: bool) -> Result { let mut file = OpenOptions::new(); @@ -28,9 +30,10 @@ fn create_file(path: &str) -> Result { } pub fn handle(day: Day, overwrite: bool) { - let input_path = format!("data/inputs/{day}.txt"); - let example_path = format!("data/examples/{day}.txt"); - let module_path = format!("src/bin/{day}.rs"); + let year = get_year_exit_on_fail(); + let input_path = format!("{year}/data/inputs/{day}.txt"); + let example_path = format!("{year}/data/examples/{day}.txt"); + let module_path = format!("{year}/src/bin/{day}.rs"); let mut file = match safe_create_file(&module_path, overwrite) { Ok(file) => file, @@ -42,7 +45,8 @@ pub fn handle(day: Day, overwrite: bool) { match file.write_all( MODULE_TEMPLATE - .replace("%DAY_NUMBER%", &day.into_inner().to_string()) + .replace("YEAR_NUMBER", &year.to_string()) + .replace("DAY_NUMBER", &day.into_inner().to_string()) .as_bytes(), ) { Ok(()) => { diff --git a/src/template/commands/set_year.rs b/src/template/commands/set_year.rs new file mode 100644 index 0000000..46e1ae8 --- /dev/null +++ b/src/template/commands/set_year.rs @@ -0,0 +1,58 @@ +use std::{ + fs::{self}, + path::PathBuf, + process, + str::FromStr, +}; + +use super::write_file; + +pub fn handle(year: u32) { + if !set_year(year) { + process::exit(1); + } + println!("Set repository to year {}.", year); +} + +pub fn set_year(year: u32) -> bool { + let config_path = get_config_path(); + let new_aoc_year_line = format!("AOC_YEAR = \"{year}\""); + let config_contents = read_config(&config_path); + if config_contents.is_err() { + return false; + } + let config_contents = config_contents.unwrap(); + let lines = config_contents.lines().map(|x| { + if !x.contains("AOC_YEAR") { + x + } else { + &new_aoc_year_line + } + }); + let mut new_contents: String = lines.collect::>().join("\n"); + new_contents.push('\n'); + + match write_file(&config_path, new_contents.as_bytes()) { + Ok(_) => true, + Err(_) => { + eprintln!("Failed to write the new year to config.toml."); + false + } + } +} + +fn get_config_path() -> PathBuf { + PathBuf::from_str(env!("CARGO_MANIFEST_DIR")) + .unwrap() + .join(".cargo") + .join("config.toml") +} + +fn read_config(filepath: &PathBuf) -> Result { + let f = fs::read_to_string(filepath); + if f.is_err() { + eprintln!("Failed to read config.toml."); + return Err(()); + } + Ok(f.unwrap()) +} diff --git a/src/template/commands/solve.rs b/src/template/commands/solve.rs index ec92a6f..72d7510 100644 --- a/src/template/commands/solve.rs +++ b/src/template/commands/solve.rs @@ -3,7 +3,15 @@ use std::process::{Command, Stdio}; use crate::template::Day; pub fn handle(day: Day, release: bool, dhat: bool, submit_part: Option) { - let mut cmd_args = vec!["run".to_string(), "--bin".to_string(), day.to_string()]; + let year = crate::template::get_year_exit_on_fail(); + let year = format!("advent_of_code_{}", year); + let mut cmd_args = vec![ + "run".to_string(), + "-p".to_string(), + year, + "--bin".to_string(), + day.to_string(), + ]; if dhat { cmd_args.extend([ diff --git a/src/template/mod.rs b/src/template/mod.rs index dd8e4c0..122e535 100644 --- a/src/template/mod.rs +++ b/src/template/mod.rs @@ -1,15 +1,15 @@ -use std::{env, fs}; +use std::{env, fs, path::PathBuf, str::FromStr}; pub mod aoc_cli; pub mod commands; +pub mod readme_benchmarks; +pub mod run_multi; pub mod runner; +pub mod timings; 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"; @@ -18,7 +18,7 @@ pub const ANSI_RESET: &str = "\x1b[0m"; /// Helper function that reads a text file to a string. #[must_use] pub fn read_file(folder: &str, day: Day) -> String { - let cwd = env::current_dir().unwrap(); + let cwd = PathBuf::from_str(env!("CARGO_MANIFEST_DIR")).unwrap(); let filepath = cwd.join("data").join(folder).join(format!("{day}.txt")); let f = fs::read_to_string(filepath); f.expect("could not open input file") @@ -27,7 +27,7 @@ pub fn read_file(folder: &str, day: Day) -> String { /// Helper function that reads a text file to string, appending a part suffix. E.g. like `01-2.txt`. #[must_use] pub fn read_file_part(folder: &str, day: Day, part: u8) -> String { - let cwd = env::current_dir().unwrap(); + let cwd = PathBuf::from_str(env!("CARGO_MANIFEST_DIR")).unwrap(); let filepath = cwd .join("data") .join(folder) @@ -36,6 +36,21 @@ pub fn read_file_part(folder: &str, day: Day, part: u8) -> String { f.expect("could not open input file") } +pub fn get_year() -> Option { + std::env::var("AOC_YEAR") + .ok() + .and_then(|x| x.parse::().ok()) +} + +pub fn get_year_exit_on_fail() -> u32 { + let year = get_year(); + if year.is_none() { + eprintln!("Failed to get the currently set year."); + std::process::exit(1); + } + year.unwrap() +} + /// Creates the constant `DAY` and sets up the input and runner for each part. /// /// The optional, second parameter (1 or 2) allows you to only run a single part of the solution. diff --git a/src/template/readme_benchmarks.rs b/src/template/readme_benchmarks.rs index 5c42ae4..888d557 100644 --- a/src/template/readme_benchmarks.rs +++ b/src/template/readme_benchmarks.rs @@ -27,7 +27,8 @@ pub struct TablePosition { #[must_use] pub fn get_path_for_bin(day: Day) -> String { - format!("./src/bin/{day}.rs") + let year = crate::template::get_year_exit_on_fail(); + format!("{year}/src/bin/{day}.rs") } fn locate_table(readme: &str) -> Result { diff --git a/src/template/run_multi.rs b/src/template/run_multi.rs index c951faa..790828b 100644 --- a/src/template/run_multi.rs +++ b/src/template/run_multi.rs @@ -61,14 +61,15 @@ impl From for Error { #[must_use] pub fn get_path_for_bin(day: Day) -> String { - format!("./src/bin/{day}.rs") + let year = crate::template::get_year_exit_on_fail(); + format!("{year}/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 crate::template::{get_year_exit_on_fail, Day}; use std::{ io::{BufRead, BufReader}, path::Path, @@ -83,8 +84,11 @@ pub mod child_commands { return Ok(vec![]); } + let year = get_year_exit_on_fail(); + let year = format!("advent_of_code_{}", year); + let day_padded = day.to_string(); - let mut args = vec!["run", "--quiet", "--bin", &day_padded]; + let mut args = vec!["run", "-p", &year, "--quiet", "--bin", &day_padded]; if is_release { args.push("--release"); diff --git a/src/template.txt b/src/template/template.txt similarity index 56% rename from src/template.txt rename to src/template/template.txt index 87eac99..4da9d32 100644 --- a/src/template.txt +++ b/src/template/template.txt @@ -1,4 +1,4 @@ -advent_of_code::solution!(%DAY_NUMBER%); +advent_of_code_YEAR_NUMBER::solution!(DAY_NUMBER); pub fn part_one(input: &str) -> Option { None @@ -14,13 +14,13 @@ mod tests { #[test] fn test_part_one() { - let result = part_one(&advent_of_code::template::read_file("examples", DAY)); + let result = part_one(&advent_of_code_YEAR_NUMBER::template::read_file("examples", DAY)); assert_eq!(result, None); } #[test] fn test_part_two() { - let result = part_two(&advent_of_code::template::read_file("examples", DAY)); + let result = part_two(&advent_of_code_YEAR_NUMBER::template::read_file("examples", DAY)); assert_eq!(result, None); } } diff --git a/year_template/.cargo/config.toml b/year_template/.cargo/config.toml new file mode 100644 index 0000000..1ab0c3f --- /dev/null +++ b/year_template/.cargo/config.toml @@ -0,0 +1,13 @@ +[alias] +today = "run --quiet --release --bin advent_of_code_YEAR_NUMBER --features today -- today" +scaffold = "run --quiet --release --bin advent_of_code_YEAR_NUMBER -- scaffold" +download = "run --quiet --release --bin advent_of_code_YEAR_NUMBER -- download" +read = "run --quiet --release --bin advent_of_code_YEAR_NUMBER -- read" +set-year = "run --quiet --release --bin advent_of_code_YEAR_NUMBER -- set-year" +new-year = "run --quiet --release --bin advent_of_code_YEAR_NUMBER -- new-year" +get-year = "run --quiet --release --bin advent_of_code_YEAR_NUMBER -- get-year" +try = "run --quiet --release --bin advent_of_code_YEAR_NUMBER -- try" +solve = "run --quiet --release --bin advent_of_code_YEAR_NUMBER -- solve" +all = "run --quiet --release --bin advent_of_code_YEAR_NUMBER -- all" +time = "run --quiet --release --bin advent_of_code_YEAR_NUMBER -- time" + diff --git a/year_template/Cargo.toml b/year_template/Cargo.toml new file mode 100644 index 0000000..27462e8 --- /dev/null +++ b/year_template/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "advent_of_code_YEAR_NUMBER" +version = "0.11.0" +authors = ["Felix Spöttel <1682504+fspoettel@users.noreply.github.com>"] +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +dhat-heap = ["dhat"] +today = ["chrono"] +test_lib = [] + +[dependencies] + +# Template dependencies +chrono = { version = "0.4.38", optional = true } +dhat = { version = "0.3.3", optional = true } +pico-args = "0.5.0" +tinyjson = "2.5.1" + +# Solution dependencies diff --git a/data/examples/.keep b/year_template/data/examples/.keep similarity index 100% rename from data/examples/.keep rename to year_template/data/examples/.keep diff --git a/data/inputs/.keep b/year_template/data/inputs/.keep similarity index 100% rename from data/inputs/.keep rename to year_template/data/inputs/.keep diff --git a/data/puzzles/.keep b/year_template/data/puzzles/.keep similarity index 100% rename from data/puzzles/.keep rename to year_template/data/puzzles/.keep diff --git a/src/bin/.keep b/year_template/src/bin/.keep similarity index 100% rename from src/bin/.keep rename to year_template/src/bin/.keep diff --git a/year_template/src/lib.rs b/year_template/src/lib.rs new file mode 100644 index 0000000..27d7df8 --- /dev/null +++ b/year_template/src/lib.rs @@ -0,0 +1,3 @@ +pub mod template; + +// Use this file to add helper functions and additional modules. diff --git a/year_template/src/main.rs b/year_template/src/main.rs new file mode 100644 index 0000000..fe9f177 --- /dev/null +++ b/year_template/src/main.rs @@ -0,0 +1,175 @@ +use advent_of_code_YEAR_NUMBER::template::commands::{ + all, attempt, download, read, scaffold, set_year, solve, time, +}; +use args::{parse, AppArguments}; + +#[cfg(feature = "today")] +use advent_of_code::template::Day; +#[cfg(feature = "today")] +use std::process; + +mod args { + use advent_of_code_YEAR_NUMBER::template::Day; + use std::process; + + pub enum AppArguments { + Download { + day: Day, + }, + Read { + day: Day, + }, + Scaffold { + day: Day, + download: bool, + overwrite: bool, + }, + Solve { + day: Day, + release: bool, + dhat: bool, + submit: Option, + }, + Try { + day: Day, + test: Option, + dhat: bool, + }, + All { + release: bool, + }, + Time { + all: bool, + day: Option, + store: bool, + }, + NewYear, + SetYear { + year: u32, + }, + GetYear, + #[cfg(feature = "today")] + Today, + } + + pub fn parse() -> Result> { + let mut args = pico_args::Arguments::from_env(); + + let app_args = match args.subcommand()?.as_deref() { + Some("all") => AppArguments::All { + release: args.contains("--release"), + }, + Some("time") => { + let all = args.contains("--all"); + let store = args.contains("--store"); + + AppArguments::Time { + all, + day: args.opt_free_from_str()?, + store, + } + } + Some("download") => AppArguments::Download { + day: args.free_from_str()?, + }, + Some("read") => AppArguments::Read { + day: args.free_from_str()?, + }, + Some("scaffold") => AppArguments::Scaffold { + day: args.free_from_str()?, + download: args.contains("--download"), + overwrite: args.contains("--overwrite"), + }, + Some("solve") => AppArguments::Solve { + day: args.free_from_str()?, + release: args.contains("--release"), + submit: args.opt_value_from_str("--submit")?, + dhat: args.contains("--dhat"), + }, + Some("try") => AppArguments::Try { + day: args.free_from_str()?, + test: args.free_from_str().ok(), + dhat: args.contains("--dhat"), + }, + Some("new-year") => AppArguments::NewYear, + Some("set-year") => AppArguments::SetYear { + year: args.free_from_str()?, + }, + Some("get-year") => AppArguments::GetYear, + #[cfg(feature = "today")] + Some("today") => AppArguments::Today, + Some(x) => { + eprintln!("Unknown command: {x}"); + process::exit(1); + } + None => { + eprintln!("No command specified."); + process::exit(1); + } + }; + + let remaining = args.finish(); + if !remaining.is_empty() { + eprintln!("Warning: unknown argument(s): {remaining:?}."); + } + + Ok(app_args) + } +} + +fn main() { + match parse() { + Err(err) => { + eprintln!("Error: {err}"); + std::process::exit(1); + } + Ok(args) => match args { + AppArguments::All { release } => all::handle(release), + AppArguments::Time { day, all, store } => time::handle(day, all, store), + AppArguments::Download { day } => download::handle(day), + AppArguments::Read { day } => read::handle(day), + AppArguments::Scaffold { + day, + download, + overwrite, + } => { + scaffold::handle(day, overwrite); + if download { + download::handle(day); + } + } + AppArguments::Solve { + day, + release, + dhat, + submit, + } => solve::handle(day, release, dhat, submit), + AppArguments::Try { day, test, dhat } => attempt::handle(day, test, dhat), + #[cfg(feature = "today")] + AppArguments::Today => { + match Day::today() { + Some(day) => { + scaffold::handle(day, false); + download::handle(day); + read::handle(day) + } + None => { + eprintln!( + "`today` command can only be run between the 1st and \ + the 25th of december. Please use `scaffold` with a specific day." + ); + process::exit(1) + } + }; + } + AppArguments::NewYear => { + println!("You can only generate new year folders at the project root"); + } + AppArguments::SetYear { year } => set_year::handle(year), + AppArguments::GetYear => { + let year = advent_of_code_YEAR_NUMBER::template::get_year_exit_on_fail(); + println!("The repository is currently set to {}.", year); + } + }, + }; +} diff --git a/year_template/src/template/aoc_cli.rs b/year_template/src/template/aoc_cli.rs new file mode 100644 index 0000000..4b7c573 --- /dev/null +++ b/year_template/src/template/aoc_cli.rs @@ -0,0 +1,116 @@ +/// Wrapper module around the "aoc-cli" command-line. +use std::{ + fmt::Display, + process::{Command, Output, Stdio}, +}; + +use crate::template::Day; + +#[derive(Debug)] +pub enum AocCommandError { + CommandNotFound, + CommandNotCallable, + BadExitStatus(Output), +} + +impl Display for AocCommandError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AocCommandError::CommandNotFound => write!(f, "aoc-cli is not present in environment."), + AocCommandError::CommandNotCallable => write!(f, "aoc-cli could not be called."), + AocCommandError::BadExitStatus(_) => { + write!(f, "aoc-cli exited with a non-zero status.") + } + } + } +} + +pub fn check() -> Result<(), AocCommandError> { + Command::new("aoc") + .arg("-V") + .output() + .map_err(|_| AocCommandError::CommandNotFound)?; + Ok(()) +} + +pub fn read(day: Day) -> Result { + let puzzle_path = get_puzzle_path(day); + + let args = build_args( + "read", + &[ + "--description-only".into(), + "--puzzle-file".into(), + puzzle_path, + ], + day, + ); + + call_aoc_cli(&args) +} + +pub fn download(day: Day) -> Result { + let input_path = get_input_path(day); + let puzzle_path = get_puzzle_path(day); + + let args = build_args( + "download", + &[ + "--overwrite".into(), + "--input-file".into(), + input_path.to_string(), + "--puzzle-file".into(), + puzzle_path.to_string(), + ], + day, + ); + + let output = call_aoc_cli(&args)?; + println!("---"); + println!("🎄 Successfully wrote input to \"{}\".", &input_path); + println!("🎄 Successfully wrote puzzle to \"{}\".", &puzzle_path); + Ok(output) +} + +pub fn submit(day: Day, part: u8, result: &str) -> Result { + // workaround: the argument order is inverted for submit. + let mut args = build_args("submit", &[], day); + args.push(part.to_string()); + args.push(result.to_string()); + call_aoc_cli(&args) +} + +fn get_input_path(day: Day) -> String { + format!("data/inputs/{day}.txt") +} + +fn get_puzzle_path(day: Day) -> String { + format!("data/puzzles/{day}.md") +} + +fn build_args(command: &str, args: &[String], day: Day) -> Vec { + let mut cmd_args = args.to_vec(); + + cmd_args.push("--year".into()); + cmd_args.push("%YEAR_NUMBER%".to_string()); + + cmd_args.append(&mut vec!["--day".into(), day.to_string(), command.into()]); + + cmd_args +} + +fn call_aoc_cli(args: &[String]) -> Result { + // println!("Calling >aoc with: {}", args.join(" ")); + let output = Command::new("aoc") + .args(args) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .output() + .map_err(|_| AocCommandError::CommandNotCallable)?; + + if output.status.success() { + Ok(output) + } else { + Err(AocCommandError::BadExitStatus(output)) + } +} diff --git a/year_template/src/template/commands/all.rs b/year_template/src/template/commands/all.rs new file mode 100644 index 0000000..b844e1e --- /dev/null +++ b/year_template/src/template/commands/all.rs @@ -0,0 +1,5 @@ +use crate::template::{all_days, run_multi::run_multi}; + +pub fn handle(is_release: bool) { + run_multi(&all_days().collect(), is_release, false); +} diff --git a/year_template/src/template/commands/attempt.rs b/year_template/src/template/commands/attempt.rs new file mode 100644 index 0000000..861dca4 --- /dev/null +++ b/year_template/src/template/commands/attempt.rs @@ -0,0 +1,29 @@ +use std::process::{Command, Stdio}; + +use crate::template::Day; + +pub fn handle(day: Day, test: Option, dhat: bool) { + let mut cmd_args = vec!["test".to_string(), "--bin".to_string(), day.to_string()]; + + if dhat { + cmd_args.extend([ + "--profile".to_string(), + "dhat".to_string(), + "--features".to_string(), + "dhat-heap".to_string(), + ]); + } else if let Some(test_id) = test { + cmd_args.push(test_id); + } + + cmd_args.push("--".to_string()); + + let mut cmd = Command::new("cargo") + .args(&cmd_args) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + + cmd.wait().unwrap(); +} diff --git a/year_template/src/template/commands/download.rs b/year_template/src/template/commands/download.rs new file mode 100644 index 0000000..9274f05 --- /dev/null +++ b/year_template/src/template/commands/download.rs @@ -0,0 +1,14 @@ +use crate::template::{aoc_cli, Day}; +use std::process; + +pub fn handle(day: Day) { + if aoc_cli::check().is_err() { + eprintln!("command \"aoc\" not found or not callable. Try running \"cargo install aoc-cli\" to install it."); + process::exit(1); + } + + if let Err(e) = aoc_cli::download(day) { + eprintln!("failed to call aoc-cli: {e}"); + process::exit(1); + }; +} diff --git a/year_template/src/template/commands/mod.rs b/year_template/src/template/commands/mod.rs new file mode 100644 index 0000000..f6caf7a --- /dev/null +++ b/year_template/src/template/commands/mod.rs @@ -0,0 +1,42 @@ +use std::{ + fs::{File, OpenOptions}, + io::Write, + path::PathBuf, +}; + +pub mod all; +pub mod attempt; +pub mod download; +pub mod read; +pub mod scaffold; +pub mod set_year; +pub mod solve; +pub mod time; + +#[derive(Debug)] +enum WriteError { + Open, + Write, +} + +fn open_file(filepath: &PathBuf) -> Result { + OpenOptions::new().write(true).truncate(true).open(filepath) +} + +fn write_file(filepath: &PathBuf, to_write: &[u8]) -> Result<(), WriteError> { + let file = open_file(filepath); + if file.is_err() { + eprintln!("Failed to open file {}", filepath.to_str().unwrap()); + return Err(WriteError::Open); + } + let mut file = file.unwrap(); + + match file.write_all(to_write) { + Ok(()) => Ok(()), + Err(e) => { + let filepath = filepath.to_str().unwrap(); + eprintln!("Failed to write to {filepath}: {e}"); + Err(WriteError::Write) + } + } +} diff --git a/year_template/src/template/commands/read.rs b/year_template/src/template/commands/read.rs new file mode 100644 index 0000000..3e1a307 --- /dev/null +++ b/year_template/src/template/commands/read.rs @@ -0,0 +1,15 @@ +use std::process; + +use crate::template::{aoc_cli, Day}; + +pub fn handle(day: Day) { + if aoc_cli::check().is_err() { + eprintln!("command \"aoc\" not found or not callable. Try running \"cargo install aoc-cli\" to install it."); + process::exit(1); + } + + if let Err(e) = aoc_cli::read(day) { + eprintln!("failed to call aoc-cli: {e}"); + process::exit(1); + }; +} diff --git a/year_template/src/template/commands/scaffold.rs b/year_template/src/template/commands/scaffold.rs new file mode 100644 index 0000000..558f418 --- /dev/null +++ b/year_template/src/template/commands/scaffold.rs @@ -0,0 +1,82 @@ +use std::{ + fs::{File, OpenOptions}, + io::Write, + process, +}; + +use crate::template::Day; + +const MODULE_TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/template/template.txt" +)); + +fn safe_create_file(path: &str, overwrite: bool) -> Result { + let mut file = OpenOptions::new(); + if overwrite { + file.create(true); + } else { + file.create_new(true); + } + file.truncate(true).write(true).open(path) +} + +fn create_file(path: &str) -> Result { + OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) +} + +pub fn handle(day: Day, overwrite: bool) { + let input_path = format!("data/inputs/{day}.txt"); + let example_path = format!("data/examples/{day}.txt"); + let module_path = format!("src/bin/{day}.rs"); + + let mut file = match safe_create_file(&module_path, overwrite) { + Ok(file) => file, + Err(e) => { + eprintln!("Failed to create module file: {e}"); + process::exit(1); + } + }; + + match file.write_all( + MODULE_TEMPLATE + .replace("YEAR_NUMBER", "%YEAR_NUMBER%") + .replace("DAY_NUMBER", &day.into_inner().to_string()) + .as_bytes(), + ) { + Ok(()) => { + println!("Created module file \"{}\"", &module_path); + } + Err(e) => { + eprintln!("Failed to write module contents: {e}"); + process::exit(1); + } + } + + match create_file(&input_path) { + Ok(_) => { + println!("Created empty input file \"{}\"", &input_path); + } + Err(e) => { + eprintln!("Failed to create input file: {e}"); + process::exit(1); + } + } + + match create_file(&example_path) { + Ok(_) => { + println!("Created empty example file \"{}\"", &example_path); + } + Err(e) => { + eprintln!("Failed to create example file: {e}"); + process::exit(1); + } + } + + println!("---"); + println!("🎄 Type `cargo solve {day}` to run your solution."); +} diff --git a/year_template/src/template/commands/set_year.rs b/year_template/src/template/commands/set_year.rs new file mode 100644 index 0000000..b583830 --- /dev/null +++ b/year_template/src/template/commands/set_year.rs @@ -0,0 +1,39 @@ +use std::process; + +use crate::template::{get_config_path, read_config}; + +use super::write_file; + +pub fn handle(year: u32) { + if !set_year(year) { + process::exit(1); + } + println!("Set repository to year {}.", year); +} + +pub fn set_year(year: u32) -> bool { + let config_path = get_config_path(); + let new_aoc_year_line = format!("AOC_YEAR = \"{year}\""); + let config_contents = read_config(&config_path); + if config_contents.is_err() { + return false; + } + let config_contents = config_contents.unwrap(); + let lines = config_contents.lines().map(|x| { + if !x.contains("AOC_YEAR") { + x + } else { + &new_aoc_year_line + } + }); + let mut new_contents: String = lines.collect::>().join("\n"); + new_contents.push('\n'); + + match write_file(&config_path, new_contents.as_bytes()) { + Ok(_) => true, + Err(_) => { + eprintln!("Failed to write the new year to config.toml."); + false + } + } +} diff --git a/year_template/src/template/commands/solve.rs b/year_template/src/template/commands/solve.rs new file mode 100644 index 0000000..ec92a6f --- /dev/null +++ b/year_template/src/template/commands/solve.rs @@ -0,0 +1,34 @@ +use std::process::{Command, Stdio}; + +use crate::template::Day; + +pub fn handle(day: Day, release: bool, dhat: bool, submit_part: Option) { + let mut cmd_args = vec!["run".to_string(), "--bin".to_string(), day.to_string()]; + + if dhat { + cmd_args.extend([ + "--profile".to_string(), + "dhat".to_string(), + "--features".to_string(), + "dhat-heap".to_string(), + ]); + } else if release { + cmd_args.push("--release".to_string()); + } + + cmd_args.push("--".to_string()); + + if let Some(submit_part) = submit_part { + cmd_args.push("--submit".to_string()); + cmd_args.push(submit_part.to_string()); + } + + let mut cmd = Command::new("cargo") + .args(&cmd_args) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + + cmd.wait().unwrap(); +} diff --git a/year_template/src/template/commands/time.rs b/year_template/src/template/commands/time.rs new file mode 100644 index 0000000..49b91a8 --- /dev/null +++ b/year_template/src/template/commands/time.rs @@ -0,0 +1,40 @@ +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, run_all: bool, store: bool) { + let stored_timings = Timings::read_from_file(); + + let days_to_run = day.map_or_else( + || { + if run_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() + } + }, + |day| HashSet::from([day]), + ); + + let timings = run_multi(&days_to_run, true, true).unwrap(); + + if store { + 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/year_template/src/template/day.rs b/year_template/src/template/day.rs new file mode 100644 index 0000000..99b8280 --- /dev/null +++ b/year_template/src/template/day.rs @@ -0,0 +1,192 @@ +use std::error::Error; +use std::fmt::Display; +use std::str::FromStr; + +#[cfg(feature = "today")] +use chrono::{Datelike, FixedOffset, Utc}; + +#[cfg(feature = "today")] +const SERVER_UTC_OFFSET: i32 = -5; + +/// A valid day number of advent (i.e. an integer in range 1 to 25). +/// +/// # Display +/// This value displays as a two digit number. +/// +/// ``` +/// # use advent_of_code::Day; +/// let day = Day::new(8).unwrap(); +/// assert_eq!(day.to_string(), "08") +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Day(u8); + +impl Day { + /// Creates a [`Day`] from the provided value if it's in the valid range, + /// returns [`None`] otherwise. + pub fn new(day: u8) -> Option { + if day == 0 || day > 25 { + return None; + } + Some(Self(day)) + } + + // Not part of the public API + #[doc(hidden)] + pub const fn __new_unchecked(day: u8) -> Self { + Self(day) + } + + /// Converts the [`Day`] into an [`u8`]. + pub fn into_inner(self) -> u8 { + self.0 + } +} + +#[cfg(feature = "today")] +impl Day { + /// Returns the current day if it's between the 1st and the 25th of december, `None` otherwise. + pub fn today() -> Option { + let offset = FixedOffset::east_opt(SERVER_UTC_OFFSET * 3600)?; + let today = Utc::now().with_timezone(&offset); + if today.month() == 12 && today.day() <= 25 { + Self::new(u8::try_from(today.day()).ok()?) + } else { + None + } + } +} + +impl Display for Day { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:02}", self.0) + } +} + +impl PartialEq for Day { + fn eq(&self, other: &u8) -> bool { + self.0.eq(other) + } +} + +impl PartialOrd for Day { + fn partial_cmp(&self, other: &u8) -> Option { + self.0.partial_cmp(other) + } +} + +/* -------------------------------------------------------------------------- */ + +impl FromStr for Day { + type Err = DayFromStrError; + + fn from_str(s: &str) -> Result { + let day = s.parse().map_err(|_| DayFromStrError)?; + Self::new(day).ok_or(DayFromStrError) + } +} + +/// An error which can be returned when parsing a [`Day`]. +#[derive(Debug)] +pub struct DayFromStrError; + +impl Error for DayFromStrError {} + +impl Display for DayFromStrError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("expecting a day number between 1 and 25") + } +} + +/* -------------------------------------------------------------------------- */ + +/// An iterator that yields every day of advent from the 1st to the 25th. +pub fn all_days() -> AllDays { + AllDays::new() +} + +/// An iterator that yields every day of advent from the 1st to the 25th. +pub struct AllDays { + current: u8, +} + +impl AllDays { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self { current: 1 } + } +} + +impl Iterator for AllDays { + type Item = Day; + + fn next(&mut self) -> Option { + if self.current > 25 { + return None; + } + // NOTE: the iterator starts at 1 and we have verified that the value is not above 25. + let day = Day(self.current); + self.current += 1; + + Some(day) + } +} + +/* -------------------------------------------------------------------------- */ + +/// Creates a [`Day`] value in a const context. +#[macro_export] +macro_rules! day { + ($day:expr) => {{ + const _ASSERT: () = assert!( + $day != 0 && $day <= 25, + concat!( + "invalid day number `", + $day, + "`, expecting a value between 1 and 25" + ), + ); + $crate::template::Day::__new_unchecked($day) + }}; +} + +/* -------------------------------------------------------------------------- */ + +#[cfg(feature = "test_lib")] +mod tests { + use super::{all_days, Day}; + + #[test] + fn all_days_iterator() { + let mut iter = all_days(); + + assert_eq!(iter.next(), Some(Day(1))); + assert_eq!(iter.next(), Some(Day(2))); + assert_eq!(iter.next(), Some(Day(3))); + assert_eq!(iter.next(), Some(Day(4))); + assert_eq!(iter.next(), Some(Day(5))); + assert_eq!(iter.next(), Some(Day(6))); + assert_eq!(iter.next(), Some(Day(7))); + assert_eq!(iter.next(), Some(Day(8))); + assert_eq!(iter.next(), Some(Day(9))); + assert_eq!(iter.next(), Some(Day(10))); + assert_eq!(iter.next(), Some(Day(11))); + assert_eq!(iter.next(), Some(Day(12))); + assert_eq!(iter.next(), Some(Day(13))); + assert_eq!(iter.next(), Some(Day(14))); + assert_eq!(iter.next(), Some(Day(15))); + assert_eq!(iter.next(), Some(Day(16))); + assert_eq!(iter.next(), Some(Day(17))); + assert_eq!(iter.next(), Some(Day(18))); + assert_eq!(iter.next(), Some(Day(19))); + assert_eq!(iter.next(), Some(Day(20))); + assert_eq!(iter.next(), Some(Day(21))); + assert_eq!(iter.next(), Some(Day(22))); + assert_eq!(iter.next(), Some(Day(23))); + assert_eq!(iter.next(), Some(Day(24))); + assert_eq!(iter.next(), Some(Day(25))); + assert_eq!(iter.next(), None); + } +} + +/* -------------------------------------------------------------------------- */ diff --git a/year_template/src/template/mod.rs b/year_template/src/template/mod.rs new file mode 100644 index 0000000..9b8dee3 --- /dev/null +++ b/year_template/src/template/mod.rs @@ -0,0 +1,111 @@ +use std::{env, fs, path::PathBuf, str::FromStr}; + +pub mod aoc_cli; +pub mod commands; +pub mod readme_benchmarks; +pub mod run_multi; +pub mod runner; +pub mod timings; + +pub use day::*; + +mod day; + +pub const ANSI_ITALIC: &str = "\x1b[3m"; +pub const ANSI_BOLD: &str = "\x1b[1m"; +pub const ANSI_RESET: &str = "\x1b[0m"; + +/// Helper function that reads a text file to a string. +#[must_use] +pub fn read_file(folder: &str, day: Day) -> String { + let cwd = PathBuf::from_str(env!("CARGO_MANIFEST_DIR")).unwrap(); + let filepath = cwd.join("data").join(folder).join(format!("{day}.txt")); + let f = fs::read_to_string(filepath); + f.expect("could not open input file") +} + +/// Helper function that reads a text file to string, appending a part suffix. E.g. like `01-2.txt`. +#[must_use] +pub fn read_file_part(folder: &str, day: Day, part: u8) -> String { + let cwd = PathBuf::from_str(env!("CARGO_MANIFEST_DIR")).unwrap(); + let filepath = cwd + .join("data") + .join(folder) + .join(format!("{day}-{part}.txt")); + let f = fs::read_to_string(filepath); + f.expect("could not open input file") +} + +pub fn get_year() -> Option { + let config_path = get_config_path(); + let config_contents = read_config(&config_path); + if let Err(()) = config_contents { + std::process::exit(1); + } + let config_contents = config_contents.unwrap(); + let year: String = config_contents + .lines() + .filter(|s| s.contains("AOC_YEAR")) + .collect(); + let year: Vec<&str> = year.split("\"").collect(); + let year = year.get(year.len() - 2).unwrap(); + year.parse::().ok() +} + +pub fn get_year_exit_on_fail() -> u32 { + let year = get_year(); + if year.is_none() { + eprintln!("Failed to get the currently set year."); + std::process::exit(1); + } + year.unwrap() +} + +fn get_config_path() -> PathBuf { + let config_path = PathBuf::from_str(env!("CARGO_MANIFEST_DIR")) + .unwrap() + .join("..") + .join(".cargo") + .join("config.toml"); + config_path.canonicalize().unwrap() +} + +fn read_config(filepath: &PathBuf) -> Result { + let f = fs::read_to_string(filepath); + if f.is_err() { + eprintln!("Failed to read config.toml."); + return Err(()); + } + Ok(f.unwrap()) +} + +/// Creates the constant `DAY` and sets up the input and runner for each part. +/// +/// The optional, second parameter (1 or 2) allows you to only run a single part of the solution. +#[macro_export] +macro_rules! solution { + ($day:expr) => { + $crate::solution!(@impl $day, [part_one, 1] [part_two, 2]); + }; + ($day:expr, 1) => { + $crate::solution!(@impl $day, [part_one, 1]); + }; + ($day:expr, 2) => { + $crate::solution!(@impl $day, [part_two, 2]); + }; + + (@impl $day:expr, $( [$func:expr, $part:expr] )*) => { + /// The current day. + const DAY: $crate::template::Day = $crate::day!($day); + + #[cfg(feature = "dhat-heap")] + #[global_allocator] + static ALLOC: dhat::Alloc = dhat::Alloc; + + fn main() { + use $crate::template::runner::*; + let input = $crate::template::read_file("inputs", DAY); + $( run_part($func, &input, DAY, $part); )* + } + }; +} diff --git a/year_template/src/template/readme_benchmarks.rs b/year_template/src/template/readme_benchmarks.rs new file mode 100644 index 0000000..6a9522d --- /dev/null +++ b/year_template/src/template/readme_benchmarks.rs @@ -0,0 +1,183 @@ +/// Module that updates the readme me with timing information. +/// 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 = ""; + +#[allow(dead_code)] +#[derive(Debug)] +pub enum Error { + Parser(String), + IO(io::Error), +} + +impl From for Error { + fn from(e: std::io::Error) -> Self { + Error::IO(e) + } +} + +pub struct TablePosition { + pos_start: usize, + pos_end: usize, +} + +#[must_use] +pub fn get_path_for_bin(day: Day) -> String { + format!("src/bin/{day}.rs") +} + +fn locate_table(readme: &str) -> Result { + let matches: Vec<_> = readme.match_indices(MARKER).collect(); + + if matches.len() > 2 { + return Err(Error::Parser( + "{}: too many occurences of marker in README.".into(), + )); + } + + let pos_start = matches + .first() + .map(|m| m.0) + .ok_or_else(|| Error::Parser("Could not find table start position.".into()))?; + + let pos_end = matches + .last() + .map(|m| m.0 + m.1.len()) + .ok_or_else(|| Error::Parser("Could not find table end position.".into()))?; + + Ok(TablePosition { pos_start, pos_end }) +} + +fn construct_table(prefix: &str, timings: Timings, total_millis: f64) -> String { + let header = format!("{prefix} Benchmarks"); + + let mut lines: Vec = vec![ + MARKER.into(), + header, + String::new(), + "| Day | Part 1 | Part 2 |".into(), + "| :---: | :---: | :---: |".into(), + ]; + + for timing in timings.data { + let path = get_path_for_bin(timing.day); + lines.push(format!( + "| [Day {}]({}) | `{}` | `{}` |", + timing.day.into_inner(), + path, + timing.part_1.unwrap_or_else(|| "-".into()), + timing.part_2.unwrap_or_else(|| "-".into()) + )); + } + + lines.push(String::new()); + lines.push(format!("**Total: {total_millis:.2}ms**")); + lines.push(MARKER.into()); + + lines.join("\n") +} + +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: 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(()) +} + +#[cfg(feature = "test_lib")] +mod tests { + 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] + #[should_panic] + fn errors_if_marker_not_present() { + let mut s = "# readme".to_string(); + update_content(&mut s, get_mock_timings(), 190.0).unwrap(); + } + + #[test] + #[should_panic] + fn errors_if_too_many_markers_present() { + let mut s = format!("{} {} {}", MARKER, MARKER, MARKER); + update_content(&mut s, get_mock_timings(), 190.0).unwrap(); + } + + #[test] + fn updates_empty_benchmarks() { + let mut s = format!("foo\nbar\n{}{}\nbaz", MARKER, MARKER); + update_content(&mut s, get_mock_timings(), 190.0).unwrap(); + assert_eq!(s.contains("## Benchmarks"), true); + } + + #[test] + fn updates_existing_benchmarks() { + let mut s = format!("foo\nbar\n{}{}\nbaz", MARKER, MARKER); + update_content(&mut s, get_mock_timings(), 190.0).unwrap(); + update_content(&mut s, get_mock_timings(), 190.0).unwrap(); + assert_eq!(s.matches(MARKER).collect::>().len(), 2); + assert_eq!(s.matches("## Benchmarks").collect::>().len(), 1); + } + + #[test] + fn format_benchmarks() { + let mut s = format!("foo\nbar\n{}\n{}\nbaz", MARKER, MARKER); + update_content(&mut s, get_mock_timings(), 190.0).unwrap(); + let expected = [ + "foo", + "bar", + "", + "## Benchmarks", + "", + "| Day | Part 1 | Part 2 |", + "| :---: | :---: | :---: |", + "| [Day 1](./src/bin/01.rs) | `10ms` | `20ms` |", + "| [Day 2](./src/bin/02.rs) | `30ms` | `40ms` |", + "| [Day 4](./src/bin/04.rs) | `40ms` | `50ms` |", + "", + "**Total: 190.00ms**", + "", + "baz", + ] + .join("\n"); + assert_eq!(s, expected); + } +} diff --git a/year_template/src/template/run_multi.rs b/year_template/src/template/run_multi.rs new file mode 100644 index 0000000..e501115 --- /dev/null +++ b/year_template/src/template/run_multi.rs @@ -0,0 +1,257 @@ +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()); + + let mut need_space = false; + + // NOTE: use non-duplicate, sorted day values. + all_days() + .filter(|day| days_to_run.contains(day)) + .for_each(|day| { + if need_space { + println!(); + } + need_space = true; + + 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 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 + } +} + +#[allow(dead_code)] +#[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/year_template/src/template/runner.rs b/year_template/src/template/runner.rs new file mode 100644 index 0000000..0a48767 --- /dev/null +++ b/year_template/src/template/runner.rs @@ -0,0 +1,169 @@ +/// Encapsulates code that interacts with solution functions. +use std::fmt::Display; +use std::hint::black_box; +use std::io::{stdout, Write}; +use std::process::Output; +use std::time::{Duration, Instant}; +use std::{cmp, env, process}; + +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}"); + + let (result, duration, samples) = + run_timed(func, input, |result| print_result(result, &part_str, "")); + + print_result(&result, &part_str, &format_duration(&duration, samples)); + + if let Some(result) = result { + submit_result(result, day, part); + } +} + +/// Run a solution part. The behavior differs depending on whether we are running a release or debug build: +/// 1. in debug, the function is executed once. +/// 2. in release, the function is benched (approx. 1 second of execution time or 10 samples, whatever take longer.) +fn run_timed( + func: impl Fn(I) -> T, + input: I, + hook: impl Fn(&T), +) -> (T, Duration, u128) { + let timer = Instant::now(); + let result = { + let input = input.clone(); + + #[cfg(feature = "dhat-heap")] + let _profiler = dhat::Profiler::new_heap(); + + func(input) + }; + let base_time = timer.elapsed(); + + hook(&result); + + let run = if std::env::args().any(|x| x == "--time") { + bench(func, input, &base_time) + } else { + (base_time, 1) + }; + + (result, run.0, run.1) +} + +fn bench(func: impl Fn(I) -> T, input: I, base_time: &Duration) -> (Duration, u128) { + let mut stdout = stdout(); + + print!(" > {ANSI_ITALIC}benching{ANSI_RESET}"); + let _ = stdout.flush(); + + let bench_iterations = + (Duration::from_secs(1).as_nanos() / cmp::max(base_time.as_nanos(), 10)).clamp(10, 10000); + + let mut timers: Vec = vec![]; + + for _ in 0..bench_iterations { + // need a clone here to make the borrow checker happy. + let cloned = input.clone(); + let timer = Instant::now(); + black_box(func(black_box(cloned))); + timers.push(timer.elapsed()); + } + + ( + #[allow(clippy::cast_possible_truncation)] + Duration::from_nanos(average_duration(&timers) as u64), + bench_iterations, + ) +} + +fn average_duration(numbers: &[Duration]) -> u128 { + numbers + .iter() + .map(std::time::Duration::as_nanos) + .sum::() + / numbers.len() as u128 +} + +fn format_duration(duration: &Duration, samples: u128) -> String { + if samples == 1 { + format!(" ({duration:.1?})") + } else { + format!(" ({duration:.1?} @ {samples} samples)") + } +} + +fn print_result(result: &Option, part: &str, duration_str: &str) { + let is_intermediate_result = duration_str.is_empty(); + + match result { + Some(result) => { + if result.to_string().contains('\n') { + let str = format!("{part}: ▼ {duration_str}"); + if is_intermediate_result { + print!("{str}"); + } else { + print!("\r"); + println!("{str}"); + println!("{result}"); + } + } else { + let str = format!("{part}: {ANSI_BOLD}{result}{ANSI_RESET}{duration_str}"); + if is_intermediate_result { + print!("{str}"); + } else { + print!("\r"); + println!("{str}"); + } + } + } + None => { + if is_intermediate_result { + print!("{part}: ✖"); + } else { + print!("\r"); + println!("{part}: ✖ "); + } + } + } +} + +/// Parse the arguments passed to `solve` and try to submit one part of the solution if: +/// 1. we are in `--release` mode. +/// 2. aoc-cli is installed. +fn submit_result( + result: T, + day: Day, + part: u8, +) -> Option> { + let args: Vec = env::args().collect(); + + if !args.contains(&"--submit".into()) { + return None; + } + + if args.len() < 3 { + eprintln!("Unexpected command-line input. Format: cargo solve 1 --submit 1"); + process::exit(1); + } + + let part_index = args.iter().position(|x| x == "--submit").unwrap() + 1; + + let Ok(part_submit) = args[part_index].parse::() else { + eprintln!("Unexpected command-line input. Format: cargo solve 1 --submit 1"); + process::exit(1); + }; + + if part_submit != part { + return None; + } + + if aoc_cli::check().is_err() { + eprintln!("command \"aoc\" not found or not callable. Try running \"cargo install aoc-cli\" to install it."); + process::exit(1); + } + + println!("Submitting result via aoc-cli..."); + Some(aoc_cli::submit(day, part, &result.to_string())) +} diff --git a/year_template/src/template/template.txt b/year_template/src/template/template.txt new file mode 100644 index 0000000..4da9d32 --- /dev/null +++ b/year_template/src/template/template.txt @@ -0,0 +1,26 @@ +advent_of_code_YEAR_NUMBER::solution!(DAY_NUMBER); + +pub fn part_one(input: &str) -> Option { + None +} + +pub fn part_two(input: &str) -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_part_one() { + let result = part_one(&advent_of_code_YEAR_NUMBER::template::read_file("examples", DAY)); + assert_eq!(result, None); + } + + #[test] + fn test_part_two() { + let result = part_two(&advent_of_code_YEAR_NUMBER::template::read_file("examples", DAY)); + assert_eq!(result, None); + } +} diff --git a/year_template/src/template/timings.rs b/year_template/src/template/timings.rs new file mode 100644 index 0000000..fb79835 --- /dev/null +++ b/year_template/src/template/timings.rs @@ -0,0 +1,384 @@ +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 { + fs::read_to_string(TIMINGS_FILE_PATH) + .map_err(|x| x.to_string()) + .and_then(Timings::try_from) + .unwrap_or_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); + } + } +} diff --git a/year_template/src/utils/.keep b/year_template/src/utils/.keep new file mode 100644 index 0000000..e69de29