1use std::{collections::HashSet, fs, path::PathBuf, sync::LazyLock};
23
24use jiff::{Zoned, civil};
25use regex::Regex;
26
27use crate::error::{Error, cargo_error};
28
29mod error;
30
31fn list_days(root_dir: &str) -> Result<impl Iterator<Item = String>, Error> {
32 let mut src_dir = PathBuf::from(root_dir);
33 src_dir.push("src");
34
35 static DAY_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^day[0-2][0-9]$").unwrap());
36
37 Ok(src_dir
38 .read_dir()
39 .map_err(|e| Error::IO(src_dir.to_string_lossy().to_string(), e))?
40 .flatten()
41 .flat_map(|e| e.path().file_stem().map(|x| x.to_os_string()))
42 .flat_map(|name| name.into_string())
43 .filter(|name| DAY_REGEX.is_match(name)))
44}
45
46fn fetch_input(today: &Zoned, session_cookie: &str, year: i16, day: i8) -> Result<String, Error> {
47 const AOC_URL: &str = "https://adventofcode.com";
48 const AOC_USER_AGENT: &str =
49 "https://github.com/olilag/aoc-input-build by oliver.oli.lago@gmail.com";
50
51 const AOC_RELEASE_MONTH: i8 = 12;
52 const AOC_RELEASE_HOUR: i8 = 0;
53 const AOC_RELEASE_TZ: &str = "America/New_York";
54
55 let puzzle_release = civil::datetime(year, AOC_RELEASE_MONTH, day, AOC_RELEASE_HOUR, 0, 0, 0)
56 .in_tz(AOC_RELEASE_TZ)
57 .expect("Failed to create puzzle release datetime");
58
59 if today < puzzle_release {
60 return Err(Error::Date(day, Box::new(puzzle_release)));
61 }
62
63 let url = format!("{AOC_URL}/{year}/day/{day}/input");
64
65 let mut resp = ureq::get(&url)
66 .header("User-Agent", AOC_USER_AGENT)
67 .header("Cookie", session_cookie)
68 .call()
69 .map_err(|e| Error::Request(url.clone(), e))?
70 .into_body();
71 resp.read_to_string().map_err(|e| Error::Request(url, e))
72}
73
74fn validate_year(today: &Zoned, year: i16) -> bool {
75 if !(2015..=today.year()).contains(&year) {
77 println!(
78 "cargo::error=AoC for provided year '{year}' does not exist. AoC exists for years 2015 to {}.",
79 today.year()
80 );
81 false
82 } else {
83 true
84 }
85}
86
87fn validate_day(year: i16, day: i8) -> bool {
88 match year {
89 2025.. if !(1..=12).contains(&day) => {
91 println!(
92 "cargo::warning=Detected a day with number '{day}' out of valid range 1-12, skipping",
93 );
94 false
95 }
96 _ if !(1..=25).contains(&day) => {
97 println!(
98 "cargo::warning=Detected a day with number '{day}' out of valid range 1-25, skipping",
99 );
100 false
101 }
102 _ => true,
103 }
104}
105
106pub fn download_inputs(root_dir: &str, token: &str, year: i16) -> Option<()> {
121 const DOWNLOAD_DIR_NAME: &str = "input";
122
123 println!("cargo::rerun-if-changed=src");
124 println!("cargo::rerun-if-changed=input"); let today = Zoned::now();
127 if !validate_year(&today, year) {
128 return None;
129 }
130
131 let res = list_days(root_dir);
132 let days = cargo_error(res)?;
133
134 let mut download_dir = PathBuf::from(root_dir);
135 download_dir.push(DOWNLOAD_DIR_NAME);
136
137 if !download_dir.exists() {
138 let res = fs::create_dir(&download_dir)
139 .map_err(|e| Error::IO(download_dir.to_string_lossy().to_string(), e));
140 cargo_error(res)?;
141 }
142
143 let res = download_dir
144 .read_dir()
145 .map_err(|e| Error::IO(download_dir.to_string_lossy().to_string(), e));
146 let cached: HashSet<String> = cargo_error(res)?
147 .flatten()
148 .flat_map(|e| e.path().file_stem().map(|x| x.to_os_string()))
149 .flat_map(|name| name.into_string())
150 .collect();
151
152 let formatted_token = format!("session={token}");
153
154 for day in days {
155 if !cached.contains(&day) {
156 let n = day[3..]
157 .parse::<i8>()
158 .expect("Failed to convert day string to number");
159
160 if !validate_day(year, n) {
161 continue;
162 }
163
164 let res = fetch_input(&today, &formatted_token, year, n);
165 if let Some(inp) = cargo_error(res) {
166 let file = download_dir.join(format!("{day}.txt"));
167 let res = fs::write(&file, inp)
168 .map_err(|e| Error::IO(file.to_string_lossy().to_string(), e));
169 let _ = cargo_error(res);
170 }
171 }
172 }
173
174 Some(())
175}