1use chrono::{Datelike, Local, NaiveDate};
9use std::{
10 env,
11 path::{Path, PathBuf},
12};
13
14pub const CONFIG_FILE_NAME: &str = "config.yaml";
16
17pub const CONFIG_DIR_VAR: &str = "AOC_CONFIG_DIR";
19
20pub const SESSION_VAR: &str = "AOC_SESSION";
22
23#[derive(Debug, Clone)]
25pub struct Env {
26 pub home: PathBuf,
28 pub config_dir: PathBuf,
30 pub config_file: PathBuf,
33 pub state_dir: PathBuf,
35 pub cwd: PathBuf,
37 pub session_cookie: Option<String>,
40}
41
42impl Env {
43 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
126pub trait Clock {
128 fn today(&self) -> NaiveDate;
130}
131
132#[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#[derive(Debug, Clone, Copy)]
144pub struct FixedClock(pub NaiveDate);
145
146impl FixedClock {
147 #[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#[must_use]
163pub fn is_december(date: NaiveDate) -> bool {
164 date.month() == 12
165}
166
167#[derive(Debug, thiserror::Error)]
169pub enum EnvError {
170 #[error("could not determine the home directory")]
172 NoHomeDirectory,
173 #[error("could not determine the current directory")]
175 NoCurrentDirectory {
176 #[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 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 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}