diff --git a/Cargo.lock b/Cargo.lock index b62e131..d71976c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1055,6 +1055,7 @@ dependencies = [ "chrono", "clap", "colored", + "common", "globalenv", "humantime", "once_cell", diff --git a/common/src/lib.rs b/common/src/lib.rs index 7733e33..7877013 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -3,5 +3,5 @@ mod misc; mod solution; pub use answer::Answer; -pub use misc::load; +pub use misc::{human_time, load}; pub use solution::{DummySolution, Solution}; diff --git a/common/src/misc.rs b/common/src/misc.rs index 503ce96..f42ec40 100644 --- a/common/src/misc.rs +++ b/common/src/misc.rs @@ -11,3 +11,17 @@ pub fn load_raw(year: u32, day: u32) -> io::Result { let file = format!("data/{year}/{:02}.txt", day); fs::read_to_string(file) } + +pub fn human_time(time: u128) -> String { + const TIME_UNITS: &[&str] = &["ns", "μs", "ms", "s"]; + + let mut time = time; + for i in TIME_UNITS { + if time < 1000 { + return format!("{}{}", time, i); + } + time /= 1000; + } + + format!("{}{}", time, TIME_UNITS.last().unwrap()) +} diff --git a/scaffold/Cargo.toml b/scaffold/Cargo.toml index ae8af05..4b6b470 100644 --- a/scaffold/Cargo.toml +++ b/scaffold/Cargo.toml @@ -10,6 +10,7 @@ anyhow = "1.0.75" chrono = "0.4.31" clap = { version = "4.4.8", features = ["derive"] } colored = "2.0.4" +common = { path = "../common" } globalenv = "0.4.2" humantime = "2.1.0" once_cell = "1.18.0" diff --git a/scaffold/src/args.rs b/scaffold/src/args.rs index 5744740..fb7ca48 100644 --- a/scaffold/src/args.rs +++ b/scaffold/src/args.rs @@ -1,4 +1,5 @@ use clap::Parser; +use regex::Regex; use url::Url; use crate::misc::current_year; @@ -28,6 +29,8 @@ pub enum SubCommand { /// Fetch the puzzle input for a given day and write to a file. /// Also creates a base solution file for the given day. Init(InitArgs), + /// Submit a solution to the Advent of Code server. + Submit(SubmitArgs), } #[derive(Parser, Debug)] @@ -91,3 +94,57 @@ pub struct InitArgs { #[arg(default_value_t = current_year())] pub year: u16, } + +#[derive(Parser, Debug)] +pub struct SubmitArgs { + /// Command to run to get the solution for the given day. + #[arg( + short, + long, + default_value = "cargo r -r -- run {{day}} {{part}} {{year}}" + )] + pub command: String, + /// A regex that will be used to extract the solution from the output of the command. + #[arg(long, default_value = r"OUT: (.*) \(")] + pub extraction_regex: Regex, + /// The group of the regex that contains the solution. + #[arg(long, default_value = "1")] + pub extraction_group: usize, + /// Don't actually submit the solution. + /// Useful for testing that the command and extraction regex are correct. + #[arg(short, long)] + pub dry_run: bool, + + /// The day to submit the solution for. + pub day: u8, + /// The part to submit the solution for. + #[arg(value_parser = parse_part)] + pub part: Part, + /// The year to submit the solution for. + #[arg(default_value_t = current_year())] + pub year: u16, +} + +#[derive(Debug, Clone, Copy)] +pub enum Part { + A, + B, +} + +fn parse_part(s: &str) -> Result { + match s { + "a" => Ok(Part::A), + "b" => Ok(Part::B), + _ => Err("part must be `a` or `b`".to_owned()), + } +} + +impl ToString for Part { + fn to_string(&self) -> String { + match self { + Part::A => "a", + Part::B => "b", + } + .to_owned() + } +} diff --git a/scaffold/src/commands/mod.rs b/scaffold/src/commands/mod.rs index 9693875..b3d4790 100644 --- a/scaffold/src/commands/mod.rs +++ b/scaffold/src/commands/mod.rs @@ -2,3 +2,4 @@ pub mod init; pub mod timer; pub mod token; pub mod verify; +pub mod submit; \ No newline at end of file diff --git a/scaffold/src/commands/submit.rs b/scaffold/src/commands/submit.rs new file mode 100644 index 0000000..c5322b7 --- /dev/null +++ b/scaffold/src/commands/submit.rs @@ -0,0 +1,103 @@ +use std::{ + process::{Command, Stdio}, + time::Instant, +}; + +use anyhow::{Context, Result}; +use common::human_time; +use scraper::Html; + +use crate::{ + args::{Args, SubmitArgs}, + formatter::Formatter, + session::{Authenticated, Session}, +}; + +pub fn submit(session: &Session, cmd: &SubmitArgs, args: &Args) -> Result<()> { + let answer = get_answer(cmd).context("Getting answer")?; + + if cmd.dry_run { + println!("[*] Aborting due to dry run"); + return Ok(()); + } + + submit_answer(session, cmd, args, &answer).context("Submitting answer")?; + Ok(()) +} + +fn get_answer(cmd: &SubmitArgs) -> Result { + let formats: &[(&str, String)] = &[ + ("day", cmd.day.to_string()), + ("year", cmd.year.to_string()), + ("part", cmd.part.to_string()), + ]; + let command = Formatter::new(&cmd.command)?.format(formats)?; + let args = shell_words::split(&command)?; + let executable = which::which(&args[0])?; + + if cmd.dry_run { + println!("[*] Running command: {}", command); + } + + let executable = Command::new(&executable) + .args(&args[1..]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let start = Instant::now(); + let output = executable.wait_with_output()?; + let time = start.elapsed().as_nanos(); + + if output.status.code() != Some(0) { + anyhow::bail!( + "Command failed with status code {}\n{}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + + let output = String::from_utf8_lossy(&output.stdout); + let answer = cmd + .extraction_regex + .captures(&output) + .context("Failed to extract answer, regex didn't match")? + .get(cmd.extraction_group) + .context("Failed to extract answer, too few capture groups")? + .as_str() + .trim() + .to_owned(); + + println!("[*] Answer: `{answer}` ({})", human_time(time)); + + Ok(answer) +} + +fn submit_answer(session: &Session, cmd: &SubmitArgs, args: &Args, answer: &str) -> Result<()> { + let url = args + .address + .join(&format!("{}/day/{}/answer", cmd.year, cmd.day))?; + + // POST https://adventofcode.com/{{year}}/day/{{day}}/answer + // level={{part:int}}&answer={{answer}} + let result = ureq::post(url.as_str()) + .authenticated(session) + .send_form(&[ + ("level", &(cmd.part as u8 + 1).to_string()), + ("answer", answer), + ])?; + + let document = Html::parse_document(&result.into_string()?); + let result = document + .select(selector!("article p")) + .next() + .context("No response message found")?; + let result_text = result.text().collect::>().join(""); + + // Remove duplicate whitespace + let result_text = regex!(r"[\[\(].*?[\]\)]").replace_all(&result_text, ""); + let result_text = regex!(r"\s+").replace_all(&result_text, " "); + + println!("[*] {result_text}"); + Ok(()) +} diff --git a/scaffold/src/main.rs b/scaffold/src/main.rs index fd04d5c..b730c54 100644 --- a/scaffold/src/main.rs +++ b/scaffold/src/main.rs @@ -26,6 +26,7 @@ fn main() -> Result<()> { SubCommand::Token(e) => commands::token::token(&session.ok(), e, &args)?, SubCommand::Timer(e) => commands::timer::timer(e)?, SubCommand::Init(e) => commands::init::init(&session?, e, &args)?, + SubCommand::Submit(e) => commands::submit::submit(&session?, e, &args)?, } Ok(()) diff --git a/scaffold/template.txt b/scaffold/template.txt index e95958a..5946329 100644 --- a/scaffold/template.txt +++ b/scaffold/template.txt @@ -15,3 +15,24 @@ impl Solution for Day{{day:pad(2)}} { Answer::Unimplemented } } + +#[cfg(test)] +mod test { + use indoc::indoc; + + use super::Day{{day:pad(2)}}; + + const CASE: &str = indoc! {" + ... + "}; + + #[test] + fn test_a() { + assert_eq!(Day{{day:pad(2)}}.part_a(CASE), Answer::Unimplemented); + } + + #[test] + fn test_b() { + assert_eq!(Day{{day:pad(2)}}.part_b(CASE), Answer::Unimplemented); + } +} \ No newline at end of file diff --git a/scaffold/todo.md b/scaffold/todo.md index 06a8914..7cf86f3 100644 --- a/scaffold/todo.md +++ b/scaffold/todo.md @@ -9,4 +9,4 @@ - [x] Verify - [x] Token - [x] Timer -- [ ] Allow not filling the solution array + - [x] Submit diff --git a/src/main.rs b/src/main.rs index f5e412a..0777ebd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use std::time::Instant; use clap::Parser; -use common::Solution; +use common::{human_time, Solution}; use args::{Args, Commands}; mod args; @@ -67,17 +67,3 @@ fn get_year(year: u32) -> &'static [&'static dyn Solution] { _ => &[], } } - -pub fn human_time(time: u128) -> String { - const TIME_UNITS: &[&str] = &["ns", "μs", "ms", "s"]; - - let mut time = time; - for i in TIME_UNITS { - if time < 1000 { - return format!("{}{}", time, i); - } - time /= 1000; - } - - format!("{}{}", time, TIME_UNITS.last().unwrap()) -}