1use 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#[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 #[arg(short, long, value_parser = year_parser())]
27 pub year: Option<u16>,
28
29 #[arg(short, long, value_parser = day_parser())]
31 pub day: Option<u8>,
32
33 #[arg(short, long)]
35 pub language: Option<Language>,
36
37 #[arg(value_enum, default_value_t = Mode::Run)]
39 pub mode: Mode,
40
41 #[arg(long)]
43 pub no_submit: bool,
44
45 #[arg(long, value_name = "FILE")]
47 pub config: Option<PathBuf>,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
52pub enum Mode {
53 Run,
55 Init,
57 Path,
59 Code,
61 Url,
63}
64
65impl Mode {
66 #[must_use]
68 pub const fn needs_language(self) -> bool {
69 !matches!(self, Self::Url)
70 }
71
72 #[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}