1pub mod matcher;
9
10use crate::{
11 language::Language,
12 puzzle::{Day, Year},
13};
14use std::{fmt, mem, path::PathBuf, str::FromStr};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Segment {
19 Literal(String),
21 Year,
23 Day {
25 padded: bool,
27 },
28 Language,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Params {
35 pub year: Year,
37 pub day: Day,
39 pub language: Language,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Template {
46 segments: Vec<Segment>,
47}
48
49impl Template {
50 pub fn parse(source: &str) -> Result<Self, TemplateError> {
74 let mut segments = Vec::new();
75 let mut literal = String::new();
76 let mut rest = source;
77 let mut offset = 0usize;
78
79 while let Some((before, after_open)) = rest.split_once("{{") {
80 literal.push_str(before);
81 let open_at = offset + before.len();
82
83 let Some((name, after_close)) = after_open.split_once("}}") else {
84 return Err(TemplateError::Unclosed { offset: open_at });
85 };
86
87 let segment = parse_placeholder(name, open_at)?;
88 if !literal.is_empty() {
89 segments.push(Segment::Literal(mem::take(&mut literal)));
90 }
91 segments.push(segment);
92
93 offset = open_at + name.len() + 4;
94 rest = after_close;
95 }
96
97 literal.push_str(rest);
98 if !literal.is_empty() {
99 segments.push(Segment::Literal(literal));
100 }
101
102 let template = Self { segments };
103 for (which, present) in [
104 ("year", template.contains(&Segment::Year)),
105 ("day", template.day_padding().is_some()),
106 ] {
107 if !present {
108 return Err(TemplateError::MissingPlaceholder { which });
109 }
110 }
111
112 Ok(template)
113 }
114
115 #[must_use]
117 pub fn segments(&self) -> &[Segment] {
118 &self.segments
119 }
120
121 #[must_use]
126 pub fn render(&self, params: Params) -> PathBuf {
127 let mut rendered = String::with_capacity(64);
128
129 for segment in &self.segments {
130 match segment {
131 Segment::Literal(text) => rendered.push_str(text),
132 Segment::Year => rendered.push_str(¶ms.year.get().to_string()),
133 Segment::Day { padded } => {
134 if *padded && params.day.get() < 10 {
135 rendered.push('0');
136 }
137 rendered.push_str(¶ms.day.get().to_string());
138 }
139 Segment::Language => rendered.push_str(params.language.name()),
140 }
141 }
142
143 PathBuf::from(rendered)
144 }
145
146 pub fn matcher(&self) -> Result<matcher::CwdMatcher, TemplateError> {
153 matcher::CwdMatcher::build(&self.segments)
154 }
155
156 #[must_use]
158 pub fn has_language(&self) -> bool {
159 self.contains(&Segment::Language)
160 }
161
162 fn contains(&self, wanted: &Segment) -> bool {
163 self.segments.iter().any(|segment| segment == wanted)
164 }
165
166 fn day_padding(&self) -> Option<bool> {
167 self.segments.iter().find_map(|segment| match segment {
168 Segment::Day { padded } => Some(*padded),
169 _ => None,
170 })
171 }
172}
173
174impl FromStr for Template {
175 type Err = TemplateError;
176
177 fn from_str(source: &str) -> Result<Self, Self::Err> {
178 Self::parse(source)
179 }
180}
181
182impl fmt::Display for Template {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 for segment in &self.segments {
185 match segment {
186 Segment::Literal(text) => f.write_str(text)?,
187 Segment::Year => f.write_str("{{year}}")?,
188 Segment::Day { padded: false } => f.write_str("{{day}}")?,
189 Segment::Day { padded: true } => f.write_str("{{pad day}}")?,
190 Segment::Language => f.write_str("{{language}}")?,
191 }
192 }
193 Ok(())
194 }
195}
196
197fn parse_placeholder(raw: &str, offset: usize) -> Result<Segment, TemplateError> {
198 let name = raw.split_whitespace().collect::<Vec<_>>().join(" ");
199
200 match name.as_str() {
201 "year" => Ok(Segment::Year),
202 "day" => Ok(Segment::Day { padded: false }),
203 "pad day" => Ok(Segment::Day { padded: true }),
204 "language" => Ok(Segment::Language),
205 _ => Err(TemplateError::UnknownPlaceholder { name, offset }),
206 }
207}
208
209#[derive(Debug, thiserror::Error)]
211pub enum TemplateError {
212 #[error("unclosed `{{{{` at position {offset}")]
214 Unclosed {
215 offset: usize,
217 },
218 #[error(
220 "unknown placeholder `{{{{{name}}}}}` at position {offset} \
221 (expected `year`, `day`, `pad day` or `language`)"
222 )]
223 UnknownPlaceholder {
224 name: String,
226 offset: usize,
228 },
229 #[error("template is missing the `{{{{{which}}}}}` placeholder")]
231 MissingPlaceholder {
232 which: &'static str,
234 },
235 #[error("template produced an invalid pattern")]
237 Regex(#[from] regex::Error),
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 fn template(source: &str) -> Template {
245 Template::parse(source).expect("template should parse")
246 }
247
248 fn params(year: u16, day: u8, language: Language) -> Params {
249 Params {
250 year: Year::new(year).expect("valid year"),
251 day: Day::new(day).expect("valid day"),
252 language,
253 }
254 }
255
256 #[test]
257 fn parses_literals_and_placeholders_in_order() {
258 assert_eq!(
259 template("/aoc/{{year}}/day{{pad day}}/{{language}}").segments(),
260 [
261 Segment::Literal("/aoc/".to_owned()),
262 Segment::Year,
263 Segment::Literal("/day".to_owned()),
264 Segment::Day { padded: true },
265 Segment::Literal("/".to_owned()),
266 Segment::Language,
267 ]
268 );
269 }
270
271 #[test]
272 fn whitespace_inside_braces_is_insignificant() {
273 let spaced = template("/aoc/{{ year }}/day{{ pad day }}/{{\tlanguage\t}}");
274 assert_eq!(
275 spaced,
276 template("/aoc/{{year}}/day{{pad day}}/{{language}}")
277 );
278 }
279
280 #[test]
281 fn distinguishes_padded_from_unpadded_days() {
282 assert_eq!(
283 template("{{year}}/{{day}}").segments().last(),
284 Some(&Segment::Day { padded: false })
285 );
286 assert_eq!(
287 template("{{year}}/{{pad day}}").segments().last(),
288 Some(&Segment::Day { padded: true })
289 );
290 }
291
292 #[test]
293 fn rejects_unknown_placeholders() {
294 let error = Template::parse("/aoc/{{year}}/{{month}}/{{day}}")
295 .expect_err("month is not a placeholder");
296
297 assert!(
298 matches!(&error, TemplateError::UnknownPlaceholder { name, .. } if name == "month"),
299 "got {error:?}"
300 );
301 assert!(error.to_string().contains("month"));
302 }
303
304 #[test]
305 fn rejects_unclosed_placeholders() {
306 let error = Template::parse("/aoc/{{year}}/day{{pad day").expect_err("brace is unclosed");
307 assert!(
308 matches!(error, TemplateError::Unclosed { .. }),
309 "got {error:?}"
310 );
311 }
312
313 #[test]
314 fn rejects_templates_that_can_never_render() {
315 for (source, which) in [("/aoc/{{day}}", "year"), ("/aoc/{{year}}", "day")] {
316 let error = Template::parse(source).expect_err("placeholder is missing");
317 assert!(
318 matches!(error, TemplateError::MissingPlaceholder { which: w } if w == which),
319 "got {error:?}"
320 );
321 }
322 }
323
324 #[test]
325 fn language_placeholder_is_optional() {
326 let template = template("/aoc/{{year}}/day{{pad day}}");
327
328 assert!(!template.has_language());
329 assert_eq!(
330 template.render(params(2024, 7, Language::Rust)),
331 PathBuf::from("/aoc/2024/day07")
332 );
333 }
334
335 #[test]
336 fn renders_padded_and_unpadded_days() {
337 let padded = template("/aoc/{{year}}/day{{pad day}}/{{language}}");
338 let plain = template("/aoc/{{year}}/day{{day}}/{{language}}");
339
340 assert_eq!(
341 padded.render(params(2024, 7, Language::Rust)),
342 PathBuf::from("/aoc/2024/day07/rust")
343 );
344 assert_eq!(
345 padded.render(params(2015, 25, Language::CSharp)),
346 PathBuf::from("/aoc/2015/day25/csharp")
347 );
348 assert_eq!(
349 plain.render(params(2024, 7, Language::Python)),
350 PathBuf::from("/aoc/2024/day7/python")
351 );
352 assert_eq!(
353 plain.render(params(2024, 25, Language::Java)),
354 PathBuf::from("/aoc/2024/day25/java")
355 );
356 }
357
358 #[test]
359 fn renders_every_occurrence_of_a_repeated_placeholder() {
360 let template = template("/aoc/{{year}}/day{{pad day}}/{{year}}-{{pad day}}");
361
362 assert_eq!(
363 template.render(params(2024, 3, Language::Rust)),
364 PathBuf::from("/aoc/2024/day03/2024-03")
365 );
366 }
367
368 #[test]
369 fn round_trips_through_display() {
370 let source = "/aoc/{{year}}/day{{pad day}}/{{language}}";
371 assert_eq!(template(source).to_string(), source);
372 }
373}