1use super::{Segment, TemplateError};
9use crate::{
10 language::Language,
11 puzzle::{Day, Year},
12};
13use regex::Regex;
14use std::path::Path;
15
16#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
19pub struct Detected {
20 pub year: Option<Year>,
22 pub day: Option<Day>,
24 pub language: Option<Language>,
26}
27
28#[derive(Debug, Clone)]
30pub struct CwdMatcher {
31 pattern: Regex,
32}
33
34impl CwdMatcher {
35 pub fn build(segments: &[Segment]) -> Result<Self, TemplateError> {
41 Ok(Self {
42 pattern: Regex::new(&build_pattern(segments))?,
43 })
44 }
45
46 #[must_use]
48 pub fn pattern(&self) -> &str {
49 self.pattern.as_str()
50 }
51
52 #[must_use]
54 pub fn detect(&self, directory: &Path) -> Detected {
55 let path = directory.to_string_lossy();
56 let path = path.trim_end_matches(['/', '\\']);
57
58 let Some(captures) = self.pattern.captures(path) else {
59 return Detected::default();
60 };
61
62 let capture = |name| captures.name(name).map(|m| m.as_str());
63
64 Detected {
65 year: capture("year")
66 .and_then(|v| v.parse().ok())
67 .and_then(Year::new),
68 day: capture("day")
69 .and_then(|v| v.parse().ok())
70 .and_then(Day::new),
71 language: capture("language").and_then(|v| v.parse().ok()),
72 }
73 }
74}
75
76fn build_pattern(segments: &[Segment]) -> String {
77 let mut chunks: Vec<String> = Vec::new();
78 let mut pending = String::new();
79 let mut seen = Seen::default();
80
81 for segment in segments {
82 match segment {
83 Segment::Literal(text) => pending.push_str(&literal_pattern(text)),
84 Segment::Year | Segment::Day { .. } | Segment::Language => {
85 pending.push_str(&placeholder_pattern(segment, &mut seen));
86 chunks.push(std::mem::take(&mut pending));
87 }
88 }
89 }
90
91 let mut pattern = String::from("^");
92 let mut groups = 0usize;
93
94 for (index, chunk) in chunks.iter().enumerate() {
95 if index > 0 {
96 pattern.push_str("(?:");
97 groups += 1;
98 }
99 pattern.push_str(chunk);
100 }
101
102 if !pending.is_empty() {
103 pattern.push_str("(?:");
104 groups += 1;
105 pattern.push_str(&pending);
106 }
107
108 for _ in 0..groups {
109 pattern.push_str(")?");
110 }
111
112 pattern.push_str("(?:");
113 pattern.push_str(SEPARATOR);
114 pattern.push_str(".*)?$");
115 pattern
116}
117
118const SEPARATOR: &str = r"[/\\]";
122
123fn literal_pattern(text: &str) -> String {
124 let mut pattern = String::with_capacity(text.len());
125 let mut rest = text;
126
127 while let Some(at) = rest.find(['/', '\\']) {
128 pattern.push_str(®ex::escape(&rest[..at]));
129 pattern.push_str(SEPARATOR);
130 rest = &rest[at + 1..];
131 }
132
133 pattern.push_str(®ex::escape(rest));
134 pattern
135}
136
137#[derive(Default)]
138struct Seen {
139 year: bool,
140 day: bool,
141 language: bool,
142}
143
144fn placeholder_pattern(segment: &Segment, seen: &mut Seen) -> String {
145 let (first, name, body) = match segment {
146 Segment::Year => (
147 !std::mem::replace(&mut seen.year, true),
148 "year",
149 r"\d{4}".to_owned(),
150 ),
151 Segment::Day { .. } => (
152 !std::mem::replace(&mut seen.day, true),
153 "day",
154 r"\d{1,2}".to_owned(),
155 ),
156 Segment::Language => (
157 !std::mem::replace(&mut seen.language, true),
158 "language",
159 language_alternation(),
160 ),
161 Segment::Literal(_) => return String::new(),
162 };
163
164 if first {
165 format!("(?<{name}>{body})")
166 } else {
167 format!("(?:{body})")
168 }
169}
170
171fn language_alternation() -> String {
172 let mut names: Vec<&str> = Language::ALL
173 .iter()
174 .map(|language| language.name())
175 .collect();
176 names.sort_unstable_by_key(|name| (std::cmp::Reverse(name.len()), *name));
177 names.join("|")
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::template::Template;
184
185 fn matcher(source: &str) -> CwdMatcher {
186 Template::parse(source)
187 .expect("template should parse")
188 .matcher()
189 .expect("pattern should compile")
190 }
191
192 fn detect(source: &str, cwd: &str) -> Detected {
193 matcher(source).detect(Path::new(cwd))
194 }
195
196 fn triple(detected: Detected) -> (Option<u16>, Option<u8>, Option<Language>) {
197 (
198 detected.year.map(Year::get),
199 detected.day.map(Day::get),
200 detected.language,
201 )
202 }
203
204 const CANONICAL: &str = "/root/{{year}}/day{{pad day}}/{{language}}";
205
206 #[test]
207 fn generates_an_anchored_pattern_with_nested_optional_groups() {
208 assert_eq!(
209 matcher(CANONICAL).pattern(),
210 r"^[/\\]root[/\\](?<year>\d{4})(?:[/\\]day(?<day>\d{1,2})(?:[/\\](?<language>csharp|python|java|rust))?)?(?:[/\\].*)?$"
211 );
212 }
213
214 #[test]
215 fn recovers_every_parameter_from_a_full_path() {
216 assert_eq!(
217 triple(detect(CANONICAL, "/root/2024/day07/rust")),
218 (Some(2024), Some(7), Some(Language::Rust))
219 );
220 }
221
222 #[test]
223 fn recovers_partial_parameters_from_a_prefix() {
224 assert_eq!(triple(detect(CANONICAL, "/root")), (None, None, None));
225 assert_eq!(
226 triple(detect(CANONICAL, "/root/2024")),
227 (Some(2024), None, None)
228 );
229 assert_eq!(
230 triple(detect(CANONICAL, "/root/2024/day07")),
231 (Some(2024), Some(7), None)
232 );
233 }
234
235 #[test]
236 fn recovers_parameters_from_a_deeper_directory() {
237 assert_eq!(
238 triple(detect(CANONICAL, "/root/2024/day07/rust/src/bin")),
239 (Some(2024), Some(7), Some(Language::Rust))
240 );
241 }
242
243 #[test]
244 fn two_digit_days_are_not_truncated() {
245 for (day, expected) in [
246 (1, 1),
247 (5, 5),
248 (9, 9),
249 (10, 10),
250 (15, 15),
251 (19, 19),
252 (25, 25),
253 ] {
254 let padded = format!("/root/2024/day{day:02}/rust");
255 let plain = format!("/root/2024/day{day}/rust");
256
257 assert_eq!(
258 triple(detect(CANONICAL, &padded)).1,
259 Some(expected),
260 "{padded}"
261 );
262 assert_eq!(
263 triple(detect("/root/{{year}}/day{{day}}/{{language}}", &plain)).1,
264 Some(expected),
265 "{plain}"
266 );
267 }
268 }
269
270 #[test]
271 fn out_of_range_values_are_dropped_individually() {
272 assert_eq!(
273 triple(detect(CANONICAL, "/root/2024/day26/rust")),
274 (Some(2024), None, Some(Language::Rust))
275 );
276 assert_eq!(
277 triple(detect(CANONICAL, "/root/2024/day00/rust")),
278 (Some(2024), None, Some(Language::Rust))
279 );
280 assert_eq!(
281 triple(detect(CANONICAL, "/root/1999/day07/rust")),
282 (None, Some(7), Some(Language::Rust))
283 );
284 }
285
286 #[test]
287 fn unrelated_directories_yield_nothing() {
288 assert_eq!(triple(detect(CANONICAL, "/tmp")), (None, None, None));
289 assert_eq!(
290 triple(detect(CANONICAL, "/elsewhere/2024/day07/rust")),
291 (None, None, None)
292 );
293 }
294
295 #[test]
296 fn a_broken_middle_segment_stops_detection_there() {
297 assert_eq!(
298 triple(detect(CANONICAL, "/root/2024/scratch/rust")),
299 (Some(2024), None, None)
300 );
301 }
302
303 #[test]
304 fn partial_matching_follows_template_order_not_a_fixed_order() {
305 let reordered = "/root/{{language}}/{{year}}/day{{day}}";
306
307 assert_eq!(
308 triple(detect(reordered, "/root/rust")),
309 (None, None, Some(Language::Rust))
310 );
311 assert_eq!(
312 triple(detect(reordered, "/root/rust/2024")),
313 (Some(2024), None, Some(Language::Rust))
314 );
315 assert_eq!(
316 triple(detect(reordered, "/root/rust/2024/day7")),
317 (Some(2024), Some(7), Some(Language::Rust))
318 );
319 }
320
321 #[test]
322 fn a_trailing_literal_is_optional() {
323 let with_suffix = "/root/{{year}}/day{{pad day}}/{{language}}/solution";
324
325 assert_eq!(
326 triple(detect(with_suffix, "/root/2024/day07/rust")),
327 (Some(2024), Some(7), Some(Language::Rust))
328 );
329 assert_eq!(
330 triple(detect(with_suffix, "/root/2024/day07/rust/solution")),
331 (Some(2024), Some(7), Some(Language::Rust))
332 );
333 }
334
335 #[test]
336 fn spaced_out_placeholders_still_detect() {
337 assert_eq!(
338 triple(detect(
339 "/root/{{ year }}/day{{ pad day }}/{{ language }}",
340 "/root/2024/day07/java"
341 )),
342 (Some(2024), Some(7), Some(Language::Java))
343 );
344 }
345
346 #[test]
347 fn literal_regex_metacharacters_are_escaped() {
348 let dotted = "/root/a.c/{{year}}/day{{pad day}}";
349
350 assert_eq!(triple(detect(dotted, "/root/a.c/2024/day07")).0, Some(2024));
351 assert_eq!(triple(detect(dotted, "/root/abc/2024/day07")).0, None);
352 }
353
354 #[test]
355 fn either_separator_matches_whichever_the_template_used() {
356 assert_eq!(
357 triple(detect(CANONICAL, r"\root\2024\day07\rust")),
358 (Some(2024), Some(7), Some(Language::Rust))
359 );
360 assert_eq!(
361 triple(detect(
362 r"C:\aoc\{{year}}\day{{pad day}}\{{language}}",
363 r"C:\aoc\2024\day07\rust"
364 )),
365 (Some(2024), Some(7), Some(Language::Rust))
366 );
367 assert_eq!(
368 triple(detect(
369 r"C:\aoc\{{year}}\day{{pad day}}\{{language}}",
370 "C:/aoc/2024/day07/rust"
371 )),
372 (Some(2024), Some(7), Some(Language::Rust))
373 );
374 }
375
376 #[test]
377 fn trailing_separators_are_ignored() {
378 assert_eq!(
379 triple(detect(CANONICAL, "/root/2024/day07/rust/")),
380 (Some(2024), Some(7), Some(Language::Rust))
381 );
382 }
383
384 #[test]
385 fn every_language_is_recognised() {
386 for language in Language::ALL {
387 let cwd = format!("/root/2024/day07/{}", language.name());
388 assert_eq!(triple(detect(CANONICAL, &cwd)).2, Some(*language), "{cwd}");
389 }
390 }
391
392 #[test]
393 fn matching_is_case_sensitive() {
394 assert_eq!(
395 triple(detect(CANONICAL, "/Root/2024/day07/rust")),
396 (None, None, None)
397 );
398 assert_eq!(triple(detect(CANONICAL, "/root/2024/day07/Rust")).2, None);
399 }
400
401 #[test]
402 fn repeated_placeholders_capture_once() {
403 let repeated = "/root/{{year}}/day{{pad day}}/{{year}}";
404
405 assert_eq!(
406 triple(detect(repeated, "/root/2024/day07/2024")),
407 (Some(2024), Some(7), None)
408 );
409 }
410}