aoc_input_build/
lib.rs

1//! AoC input build is helper library to download input files for [Advent of Code](https://adventofcode.com).
2//!
3//! Provides single function [`download_inputs`]. This function needs to be called from [build.rs](https://doc.rust-lang.org/cargo/reference/build-scripts.html) build script.
4//! It will download all necessary input files for given Advent of Code year.
5//!
6//!
7//! ```no_run
8//!# #![allow(clippy::needless_doctest_main)]
9//! use aoc_input_build::download_inputs;
10//!
11//! fn main() {
12//!     let root_dir = env!("CARGO_MANIFEST_DIR"); // root of the project, should always be set to CARGO_MANIFEST_DIR env var
13//!     let token = env!("AOC_TOKEN"); // session cookie from https://adventofcode.com/
14//!     let year = 2025; // which year of Advent of Code to use
15//!     download_inputs(root_dir, token, year);
16//! }
17//! ```
18//!
19//! This snippet should be placed inside `build.rs`. It will download input file for each `dayXX.rs` inside `root_dir/src/` to `root_dir/input/dayXX.txt`.
20//! If the input file already exists, it does not re-download it.
21
22use 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    // NOTE: this assumes that AoC will be available each year
76    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        // starting from 2025 there will only be 12 days - https://adventofcode.com/2025/about#faq_num_days
90        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
106/// Downloads input files for `year`'s Advent of Code. Should be called from `build.rs` build script.
107///
108/// `root_dir` should be set to `env!("CARGO_MANIFEST_DIR")`, this directory is used as parent for `input/` folder and for reading `src/`.
109///
110/// Downloaded input files will be placed to `root_dir/input` and called `dayXX.txt` where `XX` is day's number.
111///
112/// `token` is AoC's cookie called `session`. You can find it in your browser.
113///
114/// When `year` is smaller than 2015 or greater than current year, build script will report an error as AoC for that year doesn't exist.
115///
116/// To download a day, there needs to exist file `root_dir/dayXX.rs` where `XX` is day's number.
117/// If the input file is not yet released or the file for the day does not exist, it will issue a warning and continue.
118///
119/// It will also report any IO or network errors that occurred while fetching and saving input files.
120pub 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"); // ensure re-run when a input file was deleted
125
126    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}