use crate::{
cli::{Cli, Mode},
config::Config,
env::{Clock, is_december},
language::Language,
puzzle::{Day, Puzzle, Year},
template::{Params, TemplateError},
};
use chrono::{Datelike, NaiveDate};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Plan {
Run {
puzzle: Puzzle,
language: Language,
project: PathBuf,
submit: bool,
},
Init {
puzzle: Puzzle,
language: Language,
project: PathBuf,
},
Path {
project: PathBuf,
},
Code {
project: PathBuf,
},
Url {
puzzle: Puzzle,
},
}
pub fn plan(
cli: &Cli,
config: &Config,
cwd: &Path,
clock: &dyn Clock,
) -> Result<Plan, ResolveError> {
let today = clock.today();
let detected = config.template.matcher()?.detect(cwd);
let year = cli
.year
.and_then(Year::new)
.or(detected.year)
.unwrap_or_else(|| latest_available_year(today));
let day = cli
.day
.and_then(Day::new)
.or_else(|| detected.day.filter(|&day| year.has_day(day)))
.unwrap_or_else(|| default_day(year, today));
let puzzle = Puzzle::new(year, day).ok_or(ResolveError::DayOutOfRange { year, day })?;
let language = cli.language.or(detected.language);
if !cli.mode.needs_language() {
return Ok(Plan::Url { puzzle });
}
let language = language.ok_or(ResolveError::LanguageRequired { mode: cli.mode })?;
let project = config.template.render(Params {
year,
day,
language,
});
Ok(match cli.mode {
Mode::Run => Plan::Run {
puzzle,
language,
project,
submit: !cli.no_submit,
},
Mode::Init => Plan::Init {
puzzle,
language,
project,
},
Mode::Path => Plan::Path { project },
Mode::Code => Plan::Code { project },
Mode::Url => Plan::Url { puzzle },
})
}
#[must_use]
pub fn latest_available_year(today: NaiveDate) -> Year {
let year = today.year();
let available = if is_december(today) { year } else { year - 1 };
u16::try_from(available)
.ok()
.and_then(Year::new)
.unwrap_or(Year::FIRST)
}
#[must_use]
pub fn default_day(year: Year, today: NaiveDate) -> Day {
if is_december(today) {
Day::clamped(year, today.day())
} else {
Day::FIRST
}
}
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error(
"a language is required for `{mode}` - pass --language, or run from a \
directory the template can resolve"
)]
LanguageRequired {
mode: Mode,
},
#[error("{year} has no day {day} - that event ends on day {}", .year.last_day())]
DayOutOfRange {
year: Year,
day: Day,
},
#[error("could not build a matcher from the configured template")]
Template(#[from] TemplateError),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::env::{Env, FixedClock};
use clap::Parser as _;
fn env(cwd: &str) -> Env {
Env {
home: PathBuf::from("/home/tester"),
config_dir: PathBuf::from("/home/tester/.config/aoc"),
config_file: PathBuf::from("/home/tester/.config/aoc/config.yaml"),
state_dir: PathBuf::from("/home/tester/.local/state/aoc"),
cwd: PathBuf::from(cwd),
session_cookie: None,
}
}
fn config() -> Config {
let (config, _) = Config::from_yaml(
"template_path: \"/root/{{year}}/day{{pad day}}/{{language}}\"",
&env("/"),
)
.expect("config should load");
config
}
fn cli(args: &[&str]) -> Cli {
let mut command_line = vec!["aoc"];
command_line.extend_from_slice(args);
Cli::try_parse_from(command_line).expect("arguments should parse")
}
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
fn year(year: u16) -> Year {
Year::new(year).expect("valid year")
}
fn resolve(args: &[&str], cwd: &str, today: NaiveDate) -> Result<Plan, ResolveError> {
plan(&cli(args), &config(), Path::new(cwd), &FixedClock(today))
}
fn puzzle_of(plan: &Plan) -> Option<Puzzle> {
match plan {
Plan::Run { puzzle, .. } | Plan::Init { puzzle, .. } | Plan::Url { puzzle } => {
Some(*puzzle)
}
Plan::Path { .. } | Plan::Code { .. } => None,
}
}
fn project_of(plan: &Plan) -> Option<&Path> {
match plan {
Plan::Run { project, .. }
| Plan::Init { project, .. }
| Plan::Path { project }
| Plan::Code { project } => Some(project),
Plan::Url { .. } => None,
}
}
#[test]
fn the_most_recent_event_depends_on_the_month() {
assert_eq!(latest_available_year(date(2024, 12, 1)).get(), 2024);
assert_eq!(latest_available_year(date(2024, 12, 31)).get(), 2024);
assert_eq!(latest_available_year(date(2024, 11, 30)).get(), 2023);
assert_eq!(latest_available_year(date(2025, 1, 1)).get(), 2024);
}
#[test]
fn the_default_day_is_today_during_the_event() {
assert_eq!(default_day(year(2024), date(2024, 12, 1)).get(), 1);
assert_eq!(default_day(year(2024), date(2024, 12, 14)).get(), 14);
assert_eq!(default_day(year(2024), date(2024, 12, 25)).get(), 25);
}
#[test]
fn the_default_day_is_clamped_after_the_event_ends() {
for day in 26..=31 {
assert_eq!(
default_day(year(2024), date(2024, 12, day)).get(),
25,
"december {day}"
);
}
}
#[test]
fn the_default_day_is_clamped_to_a_shortened_event() {
for day in 13..=31 {
assert_eq!(
default_day(year(2025), date(2025, 12, day)).get(),
12,
"december {day}"
);
}
}
#[test]
fn the_default_day_is_the_first_outside_december() {
assert_eq!(default_day(year(2024), date(2024, 1, 20)).get(), 1);
assert_eq!(default_day(year(2024), date(2024, 11, 30)).get(), 1);
}
#[test]
fn explicit_arguments_win_over_everything() {
let plan = resolve(
&["-y", "2019", "-d", "3", "-l", "java", "path"],
"/root/2024/day07/rust",
date(2024, 12, 14),
)
.expect("plan should resolve");
assert_eq!(project_of(&plan), Some(Path::new("/root/2019/day03/java")));
}
#[test]
fn the_working_directory_wins_over_date_defaults() {
let plan = resolve(&["path"], "/root/2019/day03/java", date(2024, 12, 14))
.expect("plan should resolve");
assert_eq!(project_of(&plan), Some(Path::new("/root/2019/day03/java")));
}
#[test]
fn date_defaults_apply_when_nothing_else_does() {
let plan = resolve(&["-l", "rust", "path"], "/elsewhere", date(2024, 12, 14))
.expect("plan should resolve");
assert_eq!(project_of(&plan), Some(Path::new("/root/2024/day14/rust")));
}
#[test]
fn a_partial_directory_fills_the_rest_from_defaults() {
let plan = resolve(&["-l", "rust", "path"], "/root/2019", date(2024, 12, 14))
.expect("plan should resolve");
assert_eq!(project_of(&plan), Some(Path::new("/root/2019/day14/rust")));
}
#[test]
fn an_explicit_day_the_event_never_had_is_an_error() {
let error = resolve(
&["-y", "2025", "-d", "20", "url"],
"/elsewhere",
date(2025, 12, 20),
)
.expect_err("2025 ends on day 12");
assert!(
matches!(error, ResolveError::DayOutOfRange { .. }),
"{error:?}"
);
assert!(error.to_string().contains("2025 has no day 20"), "{error}");
}
#[test]
fn a_detected_day_the_event_never_had_falls_back_to_the_default() {
let plan = resolve(&["path"], "/root/2025/day20/rust", date(2025, 12, 5))
.expect("plan should resolve");
assert_eq!(project_of(&plan), Some(Path::new("/root/2025/day05/rust")));
}
#[test]
fn a_shortened_event_still_accepts_its_own_days() {
let plan = resolve(
&["-y", "2025", "-d", "12", "url"],
"/elsewhere",
date(2026, 1, 1),
)
.expect("plan should resolve");
assert_eq!(
puzzle_of(&plan).map(Puzzle::url).as_deref(),
Some("https://adventofcode.com/2025/day/12")
);
}
#[test]
fn url_needs_no_language() {
let plan = resolve(&["url"], "/elsewhere", date(2024, 12, 5)).expect("plan should resolve");
assert_eq!(
plan,
Plan::Url {
puzzle: puzzle_of(&plan).expect("url carries a puzzle")
}
);
assert_eq!(
puzzle_of(&plan).map(Puzzle::url).as_deref(),
Some("https://adventofcode.com/2024/day/5")
);
}
#[test]
fn other_modes_require_a_language() {
for mode in ["run", "init", "path", "code"] {
let error = resolve(&[mode], "/elsewhere", date(2024, 12, 5))
.expect_err("language is required");
assert!(
matches!(error, ResolveError::LanguageRequired { .. }),
"{mode}: {error:?}"
);
assert!(error.to_string().contains(mode), "{error}");
}
}
#[test]
fn run_submits_unless_told_otherwise() {
let submitting = resolve(
&["-l", "rust", "run"],
"/root/2024/day07/rust",
date(2024, 12, 7),
)
.expect("plan should resolve");
let quiet = resolve(
&["-l", "rust", "--no-submit", "run"],
"/root/2024/day07/rust",
date(2024, 12, 7),
)
.expect("plan should resolve");
assert!(matches!(submitting, Plan::Run { submit: true, .. }));
assert!(matches!(quiet, Plan::Run { submit: false, .. }));
}
#[test]
fn each_mode_produces_its_own_plan() {
let cwd = "/root/2024/day07/rust";
let today = date(2024, 12, 7);
assert!(matches!(
resolve(&["run"], cwd, today),
Ok(Plan::Run { .. })
));
assert!(matches!(
resolve(&["init"], cwd, today),
Ok(Plan::Init { .. })
));
assert!(matches!(
resolve(&["path"], cwd, today),
Ok(Plan::Path { .. })
));
assert!(matches!(
resolve(&["code"], cwd, today),
Ok(Plan::Code { .. })
));
assert!(matches!(
resolve(&["url"], cwd, today),
Ok(Plan::Url { .. })
));
}
}