Skip to main content

aoc_runtime/
cli.rs

1//! Command line surface.
2//!
3//! This module only describes and parses arguments. Everything that needs a
4//! clock, the filesystem or configuration happens in [`crate::resolve`].
5
6use crate::{
7    language::Language,
8    puzzle::{Day, Year},
9    resolve::latest_available_year,
10};
11use chrono::NaiveDate;
12use clap::{Parser, ValueEnum};
13use std::{fmt, path::PathBuf};
14
15/// Automate the Advent of Code loop: scaffold, run and submit solutions.
16#[derive(Debug, Parser)]
17#[command(
18    name = "aoc",
19    version,
20    about,
21    after_help = "Unspecified values are recovered from the current directory using the \
22                  configured path template, then fall back to today's puzzle."
23)]
24pub struct Cli {
25    /// Puzzle year [default: the most recent event]
26    #[arg(short, long, value_parser = year_parser())]
27    pub year: Option<u16>,
28
29    /// Puzzle day [default: today in December, otherwise 1]
30    #[arg(short, long, value_parser = day_parser())]
31    pub day: Option<u8>,
32
33    /// Solution language
34    #[arg(short, long)]
35    pub language: Option<Language>,
36
37    /// What to do with the puzzle
38    #[arg(value_enum, default_value_t = Mode::Run)]
39    pub mode: Mode,
40
41    /// Run the solution without submitting its answers
42    #[arg(long)]
43    pub no_submit: bool,
44
45    /// Use this config file instead of the default location
46    #[arg(long, value_name = "FILE")]
47    pub config: Option<PathBuf>,
48}
49
50/// What `aoc` should do with the selected puzzle.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
52pub enum Mode {
53    /// Build the solution, run it, and submit the answers it prints.
54    Run,
55    /// Create the project directory and scaffold a solution in it.
56    Init,
57    /// Print the project directory.
58    Path,
59    /// Open the project directory in the configured editor.
60    Code,
61    /// Print the puzzle URL.
62    Url,
63}
64
65impl Mode {
66    /// Whether this mode needs a language to identify a project.
67    #[must_use]
68    pub const fn needs_language(self) -> bool {
69        !matches!(self, Self::Url)
70    }
71
72    /// The mode's lowercase name, as accepted on the command line.
73    #[must_use]
74    pub const fn name(self) -> &'static str {
75        match self {
76            Self::Run => "run",
77            Self::Init => "init",
78            Self::Path => "path",
79            Self::Code => "code",
80            Self::Url => "url",
81        }
82    }
83}
84
85impl fmt::Display for Mode {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        f.write_str(self.name())
88    }
89}
90
91fn year_parser() -> clap::builder::RangedI64ValueParser<u16> {
92    year_parser_for(chrono::Local::now().date_naive())
93}
94
95fn year_parser_for(today: NaiveDate) -> clap::builder::RangedI64ValueParser<u16> {
96    clap::value_parser!(u16)
97        .range(i64::from(Year::FIRST.get())..=i64::from(latest_available_year(today).get()))
98}
99
100fn day_parser() -> clap::builder::RangedI64ValueParser<u8> {
101    clap::value_parser!(u8).range(i64::from(Day::FIRST.get())..=i64::from(Day::LAST_FULL.get()))
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use clap::CommandFactory;
108
109    #[test]
110    fn the_command_definition_is_valid() {
111        Cli::command().debug_assert();
112    }
113
114    #[test]
115    fn mode_defaults_to_run() {
116        let cli = Cli::try_parse_from(["aoc"]).expect("no arguments is valid");
117
118        assert_eq!(cli.mode, Mode::Run);
119        assert_eq!(cli.year, None);
120        assert_eq!(cli.day, None);
121        assert_eq!(cli.language, None);
122        assert!(!cli.no_submit);
123        assert_eq!(cli.config, None);
124    }
125
126    #[test]
127    fn accepts_every_mode_positionally() {
128        for mode in [Mode::Run, Mode::Init, Mode::Path, Mode::Code, Mode::Url] {
129            let cli = Cli::try_parse_from(["aoc", mode.name()]).expect("mode should parse");
130            assert_eq!(cli.mode, mode);
131        }
132    }
133
134    #[test]
135    fn accepts_short_and_long_flags() {
136        let short = Cli::try_parse_from(["aoc", "-y", "2024", "-d", "7", "-l", "csharp", "path"])
137            .expect("short flags should parse");
138        let long = Cli::try_parse_from([
139            "aoc",
140            "--year",
141            "2024",
142            "--day",
143            "7",
144            "--language",
145            "csharp",
146            "path",
147        ])
148        .expect("long flags should parse");
149
150        assert_eq!(short.year, Some(2024));
151        assert_eq!(short.day, Some(7));
152        assert_eq!(short.language, Some(Language::CSharp));
153        assert_eq!(short.mode, Mode::Path);
154        assert_eq!(long.year, short.year);
155        assert_eq!(long.day, short.day);
156        assert_eq!(long.language, short.language);
157    }
158
159    #[test]
160    fn rejects_days_outside_the_puzzle_range() {
161        for day in ["0", "26", "31"] {
162            assert!(
163                Cli::try_parse_from(["aoc", "-d", day]).is_err(),
164                "day {day}"
165            );
166        }
167        assert!(Cli::try_parse_from(["aoc", "-d", "25"]).is_ok());
168    }
169
170    #[test]
171    fn rejects_years_outside_the_available_range() {
172        assert!(Cli::try_parse_from(["aoc", "-y", "2014"]).is_err());
173        assert!(Cli::try_parse_from(["aoc", "-y", "2015"]).is_ok());
174        assert!(
175            Cli::try_parse_from(["aoc", "-y", "2999"]).is_err(),
176            "a year that has not happened is a usage error"
177        );
178    }
179
180    #[test]
181    fn rejects_unknown_languages_and_modes() {
182        assert!(Cli::try_parse_from(["aoc", "-l", "cobol"]).is_err());
183        assert!(Cli::try_parse_from(["aoc", "-l", "c-sharp"]).is_err());
184        assert!(Cli::try_parse_from(["aoc", "compile"]).is_err());
185    }
186
187    #[test]
188    fn only_url_works_without_a_language() {
189        assert!(!Mode::Url.needs_language());
190        for mode in [Mode::Run, Mode::Init, Mode::Path, Mode::Code] {
191            assert!(mode.needs_language(), "{mode}");
192        }
193    }
194}