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,
32 pub state_dir: PathBuf,
34 pub cwd: PathBuf,
36 pub session_cookie: Option<String>,
39}
40
41impl Env {
42 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
124pub trait Clock {
126 fn today(&self) -> NaiveDate;
128}
129
130#[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#[derive(Debug, Clone, Copy)]
142pub struct FixedClock(pub NaiveDate);
143
144impl FixedClock {
145 #[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#[must_use]
161pub fn is_december(date: NaiveDate) -> bool {
162 date.month() == 12
163}
164
165#[derive(Debug, thiserror::Error)]
167pub enum EnvError {
168 #[error("could not determine the home directory")]
170 NoHomeDirectory,
171 #[error("could not determine the current directory")]
173 NoCurrentDirectory {
174 #[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 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}