1use crate::{
9 cli::{Cli, Mode},
10 config::Config,
11 env::{Clock, is_december},
12 language::Language,
13 puzzle::{Day, Puzzle, Year},
14 template::{Params, TemplateError},
15};
16use chrono::{Datelike, NaiveDate};
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Plan {
22 Run {
24 puzzle: Puzzle,
26 language: Language,
28 project: PathBuf,
30 submit: bool,
32 },
33 Init {
35 puzzle: Puzzle,
37 language: Language,
39 project: PathBuf,
41 },
42 Path {
44 project: PathBuf,
46 },
47 Code {
49 project: PathBuf,
51 },
52 Url {
54 puzzle: Puzzle,
56 },
57}
58
59pub fn plan(
71 cli: &Cli,
72 config: &Config,
73 cwd: &Path,
74 clock: &dyn Clock,
75) -> Result<Plan, ResolveError> {
76 let today = clock.today();
77 let detected = config.template.matcher()?.detect(cwd);
78
79 let year = cli
80 .year
81 .and_then(Year::new)
82 .or(detected.year)
83 .unwrap_or_else(|| latest_available_year(today));
84
85 let day = cli
86 .day
87 .and_then(Day::new)
88 .or_else(|| detected.day.filter(|&day| year.has_day(day)))
89 .unwrap_or_else(|| default_day(year, today));
90
91 let puzzle = Puzzle::new(year, day).ok_or(ResolveError::DayOutOfRange { year, day })?;
92 let language = cli.language.or(detected.language);
93
94 if !cli.mode.needs_language() {
95 return Ok(Plan::Url { puzzle });
96 }
97
98 let language = language.ok_or(ResolveError::LanguageRequired { mode: cli.mode })?;
99 let project = config.template.render(Params {
100 year,
101 day,
102 language,
103 });
104
105 Ok(match cli.mode {
106 Mode::Run => Plan::Run {
107 puzzle,
108 language,
109 project,
110 submit: !cli.no_submit,
111 },
112 Mode::Init => Plan::Init {
113 puzzle,
114 language,
115 project,
116 },
117 Mode::Path => Plan::Path { project },
118 Mode::Code => Plan::Code { project },
119 Mode::Url => Plan::Url { puzzle },
120 })
121}
122
123#[must_use]
128pub fn latest_available_year(today: NaiveDate) -> Year {
129 let year = today.year();
130 let available = if is_december(today) { year } else { year - 1 };
131
132 u16::try_from(available)
133 .ok()
134 .and_then(Year::new)
135 .unwrap_or(Year::FIRST)
136}
137
138#[must_use]
144pub fn default_day(year: Year, today: NaiveDate) -> Day {
145 if is_december(today) {
146 Day::clamped(year, today.day())
147 } else {
148 Day::FIRST
149 }
150}
151
152#[derive(Debug, thiserror::Error)]
154pub enum ResolveError {
155 #[error(
157 "a language is required for `{mode}` - pass --language, or run from a \
158 directory the template can resolve"
159 )]
160 LanguageRequired {
161 mode: Mode,
163 },
164 #[error("{year} has no day {day} - that event ends on day {}", .year.last_day())]
166 DayOutOfRange {
167 year: Year,
169 day: Day,
171 },
172 #[error("could not build a matcher from the configured template")]
174 Template(#[from] TemplateError),
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::env::{Env, FixedClock};
181 use clap::Parser as _;
182
183 fn env(cwd: &str) -> Env {
184 Env {
185 home: PathBuf::from("/home/tester"),
186 config_dir: PathBuf::from("/home/tester/.config/aoc"),
187 config_file: PathBuf::from("/home/tester/.config/aoc/config.yaml"),
188 state_dir: PathBuf::from("/home/tester/.local/state/aoc"),
189 cwd: PathBuf::from(cwd),
190 session_cookie: None,
191 }
192 }
193
194 fn config() -> Config {
195 let (config, _) = Config::from_yaml(
196 "template_path: \"/root/{{year}}/day{{pad day}}/{{language}}\"",
197 &env("/"),
198 )
199 .expect("config should load");
200 config
201 }
202
203 fn cli(args: &[&str]) -> Cli {
204 let mut command_line = vec!["aoc"];
205 command_line.extend_from_slice(args);
206 Cli::try_parse_from(command_line).expect("arguments should parse")
207 }
208
209 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
210 NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
211 }
212
213 fn year(year: u16) -> Year {
214 Year::new(year).expect("valid year")
215 }
216
217 fn resolve(args: &[&str], cwd: &str, today: NaiveDate) -> Result<Plan, ResolveError> {
218 plan(&cli(args), &config(), Path::new(cwd), &FixedClock(today))
219 }
220
221 fn puzzle_of(plan: &Plan) -> Option<Puzzle> {
222 match plan {
223 Plan::Run { puzzle, .. } | Plan::Init { puzzle, .. } | Plan::Url { puzzle } => {
224 Some(*puzzle)
225 }
226 Plan::Path { .. } | Plan::Code { .. } => None,
227 }
228 }
229
230 fn project_of(plan: &Plan) -> Option<&Path> {
231 match plan {
232 Plan::Run { project, .. }
233 | Plan::Init { project, .. }
234 | Plan::Path { project }
235 | Plan::Code { project } => Some(project),
236 Plan::Url { .. } => None,
237 }
238 }
239
240 #[test]
241 fn the_most_recent_event_depends_on_the_month() {
242 assert_eq!(latest_available_year(date(2024, 12, 1)).get(), 2024);
243 assert_eq!(latest_available_year(date(2024, 12, 31)).get(), 2024);
244 assert_eq!(latest_available_year(date(2024, 11, 30)).get(), 2023);
245 assert_eq!(latest_available_year(date(2025, 1, 1)).get(), 2024);
246 }
247
248 #[test]
249 fn the_default_day_is_today_during_the_event() {
250 assert_eq!(default_day(year(2024), date(2024, 12, 1)).get(), 1);
251 assert_eq!(default_day(year(2024), date(2024, 12, 14)).get(), 14);
252 assert_eq!(default_day(year(2024), date(2024, 12, 25)).get(), 25);
253 }
254
255 #[test]
256 fn the_default_day_is_clamped_after_the_event_ends() {
257 for day in 26..=31 {
258 assert_eq!(
259 default_day(year(2024), date(2024, 12, day)).get(),
260 25,
261 "december {day}"
262 );
263 }
264 }
265
266 #[test]
267 fn the_default_day_is_clamped_to_a_shortened_event() {
268 for day in 13..=31 {
269 assert_eq!(
270 default_day(year(2025), date(2025, 12, day)).get(),
271 12,
272 "december {day}"
273 );
274 }
275 }
276
277 #[test]
278 fn the_default_day_is_the_first_outside_december() {
279 assert_eq!(default_day(year(2024), date(2024, 1, 20)).get(), 1);
280 assert_eq!(default_day(year(2024), date(2024, 11, 30)).get(), 1);
281 }
282
283 #[test]
284 fn explicit_arguments_win_over_everything() {
285 let plan = resolve(
286 &["-y", "2019", "-d", "3", "-l", "java", "path"],
287 "/root/2024/day07/rust",
288 date(2024, 12, 14),
289 )
290 .expect("plan should resolve");
291
292 assert_eq!(project_of(&plan), Some(Path::new("/root/2019/day03/java")));
293 }
294
295 #[test]
296 fn the_working_directory_wins_over_date_defaults() {
297 let plan = resolve(&["path"], "/root/2019/day03/java", date(2024, 12, 14))
298 .expect("plan should resolve");
299
300 assert_eq!(project_of(&plan), Some(Path::new("/root/2019/day03/java")));
301 }
302
303 #[test]
304 fn date_defaults_apply_when_nothing_else_does() {
305 let plan = resolve(&["-l", "rust", "path"], "/elsewhere", date(2024, 12, 14))
306 .expect("plan should resolve");
307
308 assert_eq!(project_of(&plan), Some(Path::new("/root/2024/day14/rust")));
309 }
310
311 #[test]
312 fn a_partial_directory_fills_the_rest_from_defaults() {
313 let plan = resolve(&["-l", "rust", "path"], "/root/2019", date(2024, 12, 14))
314 .expect("plan should resolve");
315
316 assert_eq!(project_of(&plan), Some(Path::new("/root/2019/day14/rust")));
317 }
318
319 #[test]
320 fn an_explicit_day_the_event_never_had_is_an_error() {
321 let error = resolve(
322 &["-y", "2025", "-d", "20", "url"],
323 "/elsewhere",
324 date(2025, 12, 20),
325 )
326 .expect_err("2025 ends on day 12");
327
328 assert!(
329 matches!(error, ResolveError::DayOutOfRange { .. }),
330 "{error:?}"
331 );
332 assert!(error.to_string().contains("2025 has no day 20"), "{error}");
333 }
334
335 #[test]
336 fn a_detected_day_the_event_never_had_falls_back_to_the_default() {
337 let plan = resolve(&["path"], "/root/2025/day20/rust", date(2025, 12, 5))
338 .expect("plan should resolve");
339
340 assert_eq!(project_of(&plan), Some(Path::new("/root/2025/day05/rust")));
341 }
342
343 #[test]
344 fn a_shortened_event_still_accepts_its_own_days() {
345 let plan = resolve(
346 &["-y", "2025", "-d", "12", "url"],
347 "/elsewhere",
348 date(2026, 1, 1),
349 )
350 .expect("plan should resolve");
351
352 assert_eq!(
353 puzzle_of(&plan).map(Puzzle::url).as_deref(),
354 Some("https://adventofcode.com/2025/day/12")
355 );
356 }
357
358 #[test]
359 fn url_needs_no_language() {
360 let plan = resolve(&["url"], "/elsewhere", date(2024, 12, 5)).expect("plan should resolve");
361
362 assert_eq!(
363 plan,
364 Plan::Url {
365 puzzle: puzzle_of(&plan).expect("url carries a puzzle")
366 }
367 );
368 assert_eq!(
369 puzzle_of(&plan).map(Puzzle::url).as_deref(),
370 Some("https://adventofcode.com/2024/day/5")
371 );
372 }
373
374 #[test]
375 fn other_modes_require_a_language() {
376 for mode in ["run", "init", "path", "code"] {
377 let error = resolve(&[mode], "/elsewhere", date(2024, 12, 5))
378 .expect_err("language is required");
379
380 assert!(
381 matches!(error, ResolveError::LanguageRequired { .. }),
382 "{mode}: {error:?}"
383 );
384 assert!(error.to_string().contains(mode), "{error}");
385 }
386 }
387
388 #[test]
389 fn run_submits_unless_told_otherwise() {
390 let submitting = resolve(
391 &["-l", "rust", "run"],
392 "/root/2024/day07/rust",
393 date(2024, 12, 7),
394 )
395 .expect("plan should resolve");
396 let quiet = resolve(
397 &["-l", "rust", "--no-submit", "run"],
398 "/root/2024/day07/rust",
399 date(2024, 12, 7),
400 )
401 .expect("plan should resolve");
402
403 assert!(matches!(submitting, Plan::Run { submit: true, .. }));
404 assert!(matches!(quiet, Plan::Run { submit: false, .. }));
405 }
406
407 #[test]
408 fn each_mode_produces_its_own_plan() {
409 let cwd = "/root/2024/day07/rust";
410 let today = date(2024, 12, 7);
411
412 assert!(matches!(
413 resolve(&["run"], cwd, today),
414 Ok(Plan::Run { .. })
415 ));
416 assert!(matches!(
417 resolve(&["init"], cwd, today),
418 Ok(Plan::Init { .. })
419 ));
420 assert!(matches!(
421 resolve(&["path"], cwd, today),
422 Ok(Plan::Path { .. })
423 ));
424 assert!(matches!(
425 resolve(&["code"], cwd, today),
426 Ok(Plan::Code { .. })
427 ));
428 assert!(matches!(
429 resolve(&["url"], cwd, today),
430 Ok(Plan::Url { .. })
431 ));
432 }
433}