Skip to main content

aoc_runtime/
env.rs

1//! Ambient state: where files live, and what day it is.
2//!
3//! [`Env::capture`] is the only place the library reads process environment
4//! variables or the current directory, and [`Clock`] is the only source of the
5//! current date. Both are passed down explicitly so the rest of the crate is
6//! deterministic under test.
7
8use chrono::{Datelike, Local, NaiveDate};
9use std::{
10    env,
11    path::{Path, PathBuf},
12};
13
14/// The name of the configuration file inside the configuration directory.
15pub const CONFIG_FILE_NAME: &str = "config.yaml";
16
17/// Environment variable holding an explicit configuration directory.
18pub const CONFIG_DIR_VAR: &str = "AOC_CONFIG_DIR";
19
20/// Environment variable holding the Advent of Code session cookie.
21pub const SESSION_VAR: &str = "AOC_SESSION";
22
23/// Where the tool reads and writes, and where it was invoked from.
24#[derive(Debug, Clone)]
25pub struct Env {
26    /// The user's home directory, used to expand a leading `~` in templates.
27    pub home: PathBuf,
28    /// The directory holding `config.yaml` and the `base` directory.
29    pub config_dir: PathBuf,
30    /// The configuration file itself. Absolute, even when `--config` named a
31    /// relative path.
32    pub config_file: PathBuf,
33    /// Where cached answers are kept.
34    pub state_dir: PathBuf,
35    /// The directory the command was invoked from.
36    pub cwd: PathBuf,
37    /// A session cookie supplied through the environment, which takes
38    /// precedence over the one in the configuration file.
39    pub session_cookie: Option<String>,
40}
41
42impl Env {
43    /// Captures the environment, optionally overriding the configuration file.
44    ///
45    /// The configuration directory is the first of: the parent of an explicit
46    /// `--config` file, `$AOC_CONFIG_DIR`, `$XDG_CONFIG_HOME/aoc`, or
47    /// `~/.config/aoc`. A relative `--config` path is resolved against the
48    /// current directory first, so both it and the directory derived from it
49    /// name a real place rather than depending on where the process later
50    /// looks from.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`EnvError`] if the home or current directory cannot be
55    /// determined.
56    pub fn capture(config_override: Option<&Path>) -> Result<Self, EnvError> {
57        let home = dirs::home_dir().ok_or(EnvError::NoHomeDirectory)?;
58        let cwd = env::current_dir().map_err(|source| EnvError::NoCurrentDirectory { source })?;
59
60        let (config_dir, config_file) = config_override.map_or_else(
61            || {
62                let dir = default_config_dir(&home);
63                let file = dir.join(CONFIG_FILE_NAME);
64                (dir, file)
65            },
66            |file| {
67                let file = cwd.join(file);
68                let dir = file.parent().unwrap_or(cwd.as_path()).to_path_buf();
69                (dir, file)
70            },
71        );
72
73        Ok(Self {
74            state_dir: default_state_dir(&home),
75            home,
76            config_dir,
77            config_file,
78            cwd,
79            session_cookie: non_empty_var(SESSION_VAR),
80        })
81    }
82}
83
84fn default_config_dir(home: &Path) -> PathBuf {
85    resolve_config_dir(
86        home,
87        absolute_dir_var(CONFIG_DIR_VAR).as_deref(),
88        absolute_dir_var("XDG_CONFIG_HOME").as_deref(),
89    )
90}
91
92fn resolve_config_dir(home: &Path, explicit: Option<&Path>, xdg: Option<&Path>) -> PathBuf {
93    match (explicit, xdg) {
94        (Some(dir), _) => dir.to_path_buf(),
95        (None, Some(xdg)) => xdg.join("aoc"),
96        (None, None) => home.join(".config").join("aoc"),
97    }
98}
99
100fn default_state_dir(home: &Path) -> PathBuf {
101    resolve_state_dir(home, absolute_dir_var("XDG_STATE_HOME").as_deref())
102}
103
104fn resolve_state_dir(home: &Path, xdg: Option<&Path>) -> PathBuf {
105    xdg.map_or_else(|| home.join(".local").join("state"), Path::to_path_buf)
106        .join("aoc")
107}
108
109fn absolute_dir_var(name: &str) -> Option<PathBuf> {
110    absolute_dir(env::var(name).ok())
111}
112
113fn non_empty_var(name: &str) -> Option<String> {
114    non_empty(env::var(name).ok())
115}
116
117fn absolute_dir(value: Option<String>) -> Option<PathBuf> {
118    let path = PathBuf::from(non_empty(value)?);
119    path.is_absolute().then_some(path)
120}
121
122fn non_empty(value: Option<String>) -> Option<String> {
123    value.filter(|value| !value.trim().is_empty())
124}
125
126/// The source of the current date.
127pub trait Clock {
128    /// Today's date in the local time zone.
129    fn today(&self) -> NaiveDate;
130}
131
132/// A clock backed by the system's local time.
133#[derive(Debug, Default, Clone, Copy)]
134pub struct SystemClock;
135
136impl Clock for SystemClock {
137    fn today(&self) -> NaiveDate {
138        Local::now().date_naive()
139    }
140}
141
142/// A clock frozen at a fixed date, for tests and reproducible runs.
143#[derive(Debug, Clone, Copy)]
144pub struct FixedClock(pub NaiveDate);
145
146impl FixedClock {
147    /// Creates a clock frozen at the given calendar date, or `None` if that
148    /// date does not exist.
149    #[must_use]
150    pub fn ymd(year: i32, month: u32, day: u32) -> Option<Self> {
151        NaiveDate::from_ymd_opt(year, month, day).map(Self)
152    }
153}
154
155impl Clock for FixedClock {
156    fn today(&self) -> NaiveDate {
157        self.0
158    }
159}
160
161/// Whether a date falls inside an Advent of Code event.
162#[must_use]
163pub fn is_december(date: NaiveDate) -> bool {
164    date.month() == 12
165}
166
167/// Errors produced while inspecting the environment.
168#[derive(Debug, thiserror::Error)]
169pub enum EnvError {
170    /// The home directory could not be determined.
171    #[error("could not determine the home directory")]
172    NoHomeDirectory,
173    /// The current directory could not be read.
174    #[error("could not determine the current directory")]
175    NoCurrentDirectory {
176        /// The underlying I/O error.
177        #[source]
178        source: std::io::Error,
179    },
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn an_explicit_config_file_sets_the_directory_to_its_parent() {
188        // A leading separator is drive-relative on Windows, so name a fully
189        // qualified path there rather than one the current drive completes.
190        let (file, dir) = if cfg!(windows) {
191            (r"C:\tmp\fixture\config.yaml", r"C:\tmp\fixture")
192        } else {
193            ("/tmp/fixture/config.yaml", "/tmp/fixture")
194        };
195
196        let env = Env::capture(Some(Path::new(file))).expect("environment should be capturable");
197
198        assert_eq!(env.config_file, Path::new(file));
199        assert_eq!(env.config_dir, Path::new(dir));
200    }
201
202    #[test]
203    fn a_bare_config_file_name_resolves_against_the_current_directory() {
204        let env =
205            Env::capture(Some(Path::new("config.yaml"))).expect("environment should be capturable");
206
207        assert_eq!(env.config_dir, env.cwd);
208        assert_eq!(env.config_file, env.cwd.join("config.yaml"));
209    }
210
211    #[test]
212    fn the_config_directory_falls_back_to_dot_config_aoc() {
213        let home = Path::new("/home/tester");
214
215        assert_eq!(
216            resolve_config_dir(home, None, None),
217            Path::new("/home/tester/.config/aoc")
218        );
219        assert_eq!(
220            resolve_config_dir(home, None, Some(Path::new("/xdg"))),
221            Path::new("/xdg/aoc")
222        );
223        assert_eq!(
224            resolve_config_dir(home, Some(Path::new("/explicit")), Some(Path::new("/xdg"))),
225            Path::new("/explicit")
226        );
227    }
228
229    #[test]
230    fn the_state_directory_follows_xdg_when_set() {
231        let home = Path::new("/home/tester");
232
233        assert_eq!(
234            resolve_state_dir(home, None),
235            Path::new("/home/tester/.local/state/aoc")
236        );
237        assert_eq!(
238            resolve_state_dir(home, Some(Path::new("/xdg"))),
239            Path::new("/xdg/aoc")
240        );
241    }
242
243    #[test]
244    fn relative_directory_variables_are_ignored() {
245        // A leading slash names the root of the current drive on Windows, which
246        // is not an absolute path there.
247        let absolute = if cfg!(windows) {
248            r"C:\absolute\path"
249        } else {
250            "/absolute/path"
251        };
252
253        assert_eq!(absolute_dir(Some("relative/path".to_owned())), None);
254        assert_eq!(
255            absolute_dir(Some(absolute.to_owned())),
256            Some(PathBuf::from(absolute))
257        );
258        assert_eq!(absolute_dir(None), None);
259    }
260
261    #[test]
262    fn blank_variables_are_treated_as_unset() {
263        assert_eq!(non_empty(Some("   ".to_owned())), None);
264        assert_eq!(non_empty(Some(String::new())), None);
265        assert_eq!(
266            non_empty(Some("value".to_owned())),
267            Some("value".to_owned())
268        );
269    }
270
271    #[test]
272    fn december_is_recognised() {
273        let december = FixedClock::ymd(2024, 12, 1).expect("valid date");
274        let november = FixedClock::ymd(2024, 11, 30).expect("valid date");
275
276        assert!(is_december(december.today()));
277        assert!(!is_december(november.today()));
278    }
279}