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