Skip to main content

aoc_runtime/
resolve.rs

1//! Turning command line arguments into a concrete plan of work.
2//!
3//! Resolution is pure: it takes the parsed arguments, the configuration, a
4//! directory and a clock, and produces a [`Plan`] whose variants carry exactly
5//! what their handler needs. A mode that requires a language cannot be
6//! constructed without one, so no downstream code has to re-check.
7
8use 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/// A fully resolved unit of work.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Plan {
22    /// Build and run a solution, optionally submitting its answers.
23    Run {
24        /// The puzzle being solved.
25        puzzle: Puzzle,
26        /// The language the solution is written in.
27        language: Language,
28        /// The project directory.
29        project: PathBuf,
30        /// Whether answers should be submitted.
31        submit: bool,
32    },
33    /// Scaffold a new solution.
34    Init {
35        /// The puzzle being solved.
36        puzzle: Puzzle,
37        /// The language to scaffold.
38        language: Language,
39        /// The project directory.
40        project: PathBuf,
41    },
42    /// Print a project directory.
43    Path {
44        /// The project directory.
45        project: PathBuf,
46    },
47    /// Open a project directory in the editor.
48    Code {
49        /// The project directory.
50        project: PathBuf,
51    },
52    /// Print a puzzle URL.
53    Url {
54        /// The puzzle to link to.
55        puzzle: Puzzle,
56    },
57}
58
59/// Resolves arguments, the working directory and the clock into a [`Plan`].
60///
61/// Precedence for each value is: explicit argument, then whatever the working
62/// directory reveals through the configured template, then a date-based
63/// default.
64///
65/// # Errors
66///
67/// Returns [`ResolveError::LanguageRequired`] if the mode needs a language and
68/// none could be determined, or [`ResolveError::Template`] if the template's
69/// matcher cannot be compiled.
70pub 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/// The most recent event that has started on the given date.
124///
125/// Advent of Code begins on 1 December, so before December the current year's
126/// event does not exist yet.
127#[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/// The day to use when none was given: today during the event, otherwise the
139/// first puzzle.
140///
141/// Today is clamped to the end of `year`'s event, which is day 12 from
142/// [`Year::FIRST_SHORT`] on.
143#[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/// Errors produced while resolving a plan.
153#[derive(Debug, thiserror::Error)]
154pub enum ResolveError {
155    /// The mode needs a language and none was given or detected.
156    #[error(
157        "a language is required for `{mode}` - pass --language, or run from a \
158         directory the template can resolve"
159    )]
160    LanguageRequired {
161        /// The mode that needs a language.
162        mode: Mode,
163    },
164    /// The requested day is past the end of the requested event.
165    #[error("{year} has no day {day} - that event ends on day {}", .year.last_day())]
166    DayOutOfRange {
167        /// The event the day was requested in.
168        year: Year,
169        /// The requested day.
170        day: Day,
171    },
172    /// The template could not be compiled into a matcher.
173    #[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}