use chrono::{Datelike, Local, NaiveDate};
use std::{
env,
path::{Path, PathBuf},
};
pub const CONFIG_FILE_NAME: &str = "config.yaml";
pub const CONFIG_DIR_VAR: &str = "AOC_CONFIG_DIR";
pub const SESSION_VAR: &str = "AOC_SESSION";
#[derive(Debug, Clone)]
pub struct Env {
pub home: PathBuf,
pub config_dir: PathBuf,
pub config_file: PathBuf,
pub state_dir: PathBuf,
pub cwd: PathBuf,
pub session_cookie: Option<String>,
}
impl Env {
pub fn capture(config_override: Option<&Path>) -> Result<Self, EnvError> {
let home = dirs::home_dir().ok_or(EnvError::NoHomeDirectory)?;
let cwd = env::current_dir().map_err(|source| EnvError::NoCurrentDirectory { source })?;
let (config_dir, config_file) = config_override.map_or_else(
|| {
let dir = default_config_dir(&home);
let file = dir.join(CONFIG_FILE_NAME);
(dir, file)
},
|file| {
let dir = file
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
(dir.to_path_buf(), file.to_path_buf())
},
);
Ok(Self {
state_dir: default_state_dir(&home),
home,
config_dir,
config_file,
cwd,
session_cookie: non_empty_var(SESSION_VAR),
})
}
}
fn default_config_dir(home: &Path) -> PathBuf {
resolve_config_dir(
home,
absolute_dir_var(CONFIG_DIR_VAR).as_deref(),
absolute_dir_var("XDG_CONFIG_HOME").as_deref(),
)
}
fn resolve_config_dir(home: &Path, explicit: Option<&Path>, xdg: Option<&Path>) -> PathBuf {
match (explicit, xdg) {
(Some(dir), _) => dir.to_path_buf(),
(None, Some(xdg)) => xdg.join("aoc"),
(None, None) => home.join(".config").join("aoc"),
}
}
fn default_state_dir(home: &Path) -> PathBuf {
resolve_state_dir(home, absolute_dir_var("XDG_STATE_HOME").as_deref())
}
fn resolve_state_dir(home: &Path, xdg: Option<&Path>) -> PathBuf {
xdg.map_or_else(|| home.join(".local").join("state"), Path::to_path_buf)
.join("aoc")
}
fn absolute_dir_var(name: &str) -> Option<PathBuf> {
absolute_dir(env::var(name).ok())
}
fn non_empty_var(name: &str) -> Option<String> {
non_empty(env::var(name).ok())
}
fn absolute_dir(value: Option<String>) -> Option<PathBuf> {
let path = PathBuf::from(non_empty(value)?);
path.is_absolute().then_some(path)
}
fn non_empty(value: Option<String>) -> Option<String> {
value.filter(|value| !value.trim().is_empty())
}
pub trait Clock {
fn today(&self) -> NaiveDate;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct SystemClock;
impl Clock for SystemClock {
fn today(&self) -> NaiveDate {
Local::now().date_naive()
}
}
#[derive(Debug, Clone, Copy)]
pub struct FixedClock(pub NaiveDate);
impl FixedClock {
#[must_use]
pub fn ymd(year: i32, month: u32, day: u32) -> Option<Self> {
NaiveDate::from_ymd_opt(year, month, day).map(Self)
}
}
impl Clock for FixedClock {
fn today(&self) -> NaiveDate {
self.0
}
}
#[must_use]
pub fn is_december(date: NaiveDate) -> bool {
date.month() == 12
}
#[derive(Debug, thiserror::Error)]
pub enum EnvError {
#[error("could not determine the home directory")]
NoHomeDirectory,
#[error("could not determine the current directory")]
NoCurrentDirectory {
#[source]
source: std::io::Error,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_explicit_config_file_sets_the_directory_to_its_parent() {
let env = Env::capture(Some(Path::new("/tmp/fixture/config.yaml")))
.expect("environment should be capturable");
assert_eq!(env.config_file, Path::new("/tmp/fixture/config.yaml"));
assert_eq!(env.config_dir, Path::new("/tmp/fixture"));
}
#[test]
fn a_bare_config_file_name_resolves_against_the_current_directory() {
let env =
Env::capture(Some(Path::new("config.yaml"))).expect("environment should be capturable");
assert_eq!(env.config_dir, Path::new("."));
}
#[test]
fn the_config_directory_falls_back_to_dot_config_aoc() {
let home = Path::new("/home/tester");
assert_eq!(
resolve_config_dir(home, None, None),
Path::new("/home/tester/.config/aoc")
);
assert_eq!(
resolve_config_dir(home, None, Some(Path::new("/xdg"))),
Path::new("/xdg/aoc")
);
assert_eq!(
resolve_config_dir(home, Some(Path::new("/explicit")), Some(Path::new("/xdg"))),
Path::new("/explicit")
);
}
#[test]
fn the_state_directory_follows_xdg_when_set() {
let home = Path::new("/home/tester");
assert_eq!(
resolve_state_dir(home, None),
Path::new("/home/tester/.local/state/aoc")
);
assert_eq!(
resolve_state_dir(home, Some(Path::new("/xdg"))),
Path::new("/xdg/aoc")
);
}
#[test]
fn relative_directory_variables_are_ignored() {
let absolute = if cfg!(windows) {
r"C:\absolute\path"
} else {
"/absolute/path"
};
assert_eq!(absolute_dir(Some("relative/path".to_owned())), None);
assert_eq!(
absolute_dir(Some(absolute.to_owned())),
Some(PathBuf::from(absolute))
);
assert_eq!(absolute_dir(None), None);
}
#[test]
fn blank_variables_are_treated_as_unset() {
assert_eq!(non_empty(Some(" ".to_owned())), None);
assert_eq!(non_empty(Some(String::new())), None);
assert_eq!(
non_empty(Some("value".to_owned())),
Some("value".to_owned())
);
}
#[test]
fn december_is_recognised() {
let december = FixedClock::ymd(2024, 12, 1).expect("valid date");
let november = FixedClock::ymd(2024, 11, 30).expect("valid date");
assert!(is_december(december.today()));
assert!(!is_december(november.today()));
}
}