use crate::{ConfigSource, CoreError};
use camino::{Utf8Path, Utf8PathBuf};
use std::ffi::OsStr;
const APP_NAME: &str = "cook";
pub const CONFIG_DIR_ENV: &str = "COOK_CONFIG_DIR";
pub(crate) const LOCAL_CONFIG_DIR: &str = "config";
const AUTO_AISLE: &str = "aisle.conf";
pub(crate) const AUTO_PANTRY: &str = "pantry.conf";
#[derive(Debug, Clone)]
pub struct Context {
base_path: Utf8PathBuf,
aisle: ConfigSource,
pantry: ConfigSource,
}
impl Context {
pub fn new(base_path: Utf8PathBuf) -> Self {
Self {
base_path,
aisle: ConfigSource::None,
pantry: ConfigSource::None,
}
}
pub fn discover(base_path: Utf8PathBuf) -> Self {
let aisle = Self::discover_one(&base_path, AUTO_AISLE);
let pantry = Self::discover_one(&base_path, AUTO_PANTRY);
Self {
base_path,
aisle,
pantry,
}
}
fn discover_one(base_path: &Utf8Path, name: &str) -> ConfigSource {
Self::search(base_path, name, global_config_path(name).ok().as_deref())
}
fn search(base_path: &Utf8Path, name: &str, global: Option<&Utf8Path>) -> ConfigSource {
let local = base_path.join(LOCAL_CONFIG_DIR).join(name);
tracing::trace!("checking local config file: {local}");
if local.is_file() {
return ConfigSource::Path(local);
}
match global {
Some(global) => {
tracing::trace!("checking global config file: {global}");
if global.is_file() {
ConfigSource::Path(global.to_owned())
} else {
ConfigSource::None
}
}
None => ConfigSource::None,
}
}
pub fn with_aisle(mut self, source: ConfigSource) -> Self {
self.aisle = source;
self
}
pub fn with_pantry(mut self, source: ConfigSource) -> Self {
self.pantry = source;
self
}
pub fn base_path(&self) -> &Utf8Path {
&self.base_path
}
pub fn aisle(&self) -> &ConfigSource {
&self.aisle
}
pub fn pantry(&self) -> &ConfigSource {
&self.pantry
}
}
pub fn global_config_path(name: &str) -> Result<Utf8PathBuf, CoreError> {
global_config_path_in(std::env::var_os(CONFIG_DIR_ENV).as_deref(), name)
}
fn global_config_path_in(
override_dir: Option<&OsStr>,
name: &str,
) -> Result<Utf8PathBuf, CoreError> {
if let Some(dir) = override_dir.filter(|dir| !dir.is_empty()) {
let dir =
Utf8Path::from_path(std::path::Path::new(dir)).ok_or_else(|| CoreError::Config {
path: None,
message: format!(
"{CONFIG_DIR_ENV} is not valid utf-8, and cook only supports utf-8 paths"
),
})?;
return Ok(dir.join(name));
}
let dirs =
directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| CoreError::Config {
path: None,
message: format!("could not determine the home directory to locate {name}"),
})?;
let config = Utf8Path::from_path(dirs.config_dir()).ok_or_else(|| CoreError::Config {
path: None,
message: format!(
"the configuration directory holding {name} is not valid utf-8, \
and cook only supports utf-8 paths"
),
})?;
Ok(config.join(name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ConfigSource;
#[test]
fn new_touches_nothing() {
let dir = tempfile::TempDir::new().unwrap();
let base = utf8(&dir);
write(&base.join("config").join("aisle.conf"), "[produce]\nleek");
write(
&base.join("config").join("pantry.conf"),
"[freezer]\npeas = \"1kg\"",
);
let ctx = Context::new(base.clone());
assert!(ctx.aisle().is_unset(), "new must not discover local config");
assert!(
ctx.pantry().is_unset(),
"new must not discover local config"
);
assert_eq!(ctx.base_path(), base);
}
#[test]
fn with_aisle_overrides() {
let ctx = Context::new(Utf8PathBuf::from("/tmp"))
.with_aisle(ConfigSource::Inline("[produce]\nleek".to_string()));
assert_eq!(
ctx.aisle().read().unwrap().as_deref(),
Some("[produce]\nleek")
);
assert!(
ctx.pantry().is_unset(),
"with_aisle must not set the pantry"
);
}
#[test]
fn with_pantry_overrides() {
let ctx = Context::new(Utf8PathBuf::from("/tmp")).with_pantry(ConfigSource::Inline(
"[freezer]\npeas = \"1kg\"".to_string(),
));
assert_eq!(
ctx.pantry().read().unwrap().as_deref(),
Some("[freezer]\npeas = \"1kg\"")
);
assert!(ctx.aisle().is_unset(), "with_pantry must not set the aisle");
}
#[test]
fn discover_finds_local_config() {
let dir = tempfile::TempDir::new().unwrap();
let base = utf8(&dir);
write(&base.join("config").join("aisle.conf"), "[produce]\nleek");
write(
&base.join("config").join("pantry.conf"),
"[freezer]\npeas = \"1kg\"",
);
let ctx = Context::discover(base.clone());
assert_eq!(
ctx.aisle().path(),
Some(base.join("config").join("aisle.conf").as_path())
);
assert_eq!(
ctx.pantry().path(),
Some(base.join("config").join("pantry.conf").as_path())
);
}
#[test]
fn global_config_path_joins_the_app_name() {
let path = global_config_path_in(None, "aisle.conf").expect("a home directory");
assert_eq!(
path.file_name(),
Some("aisle.conf"),
"the name asked for must be the last component: {path}"
);
assert!(
path.components().any(|c| c.as_str() == APP_NAME),
"expected a `{APP_NAME}` component in {path}"
);
assert!(
path.is_absolute(),
"the platform config directory is absolute: {path}"
);
}
#[test]
fn config_dir_env_replaces_the_platform_directory() {
let dir = Utf8PathBuf::from("/somewhere/else");
let path =
global_config_path_in(Some(OsStr::new(dir.as_str())), "session.json").expect("a path");
assert_eq!(path, dir.join("session.json"));
}
#[test]
fn config_dir_env_leaves_nothing_of_the_platform_directory() {
let dir = Utf8PathBuf::from("/somewhere/isolated");
let overridden =
global_config_path_in(Some(OsStr::new(dir.as_str())), "sync.db").expect("a path");
let platform = global_config_path_in(None, "sync.db").expect("a home directory");
assert_eq!(overridden, dir.join("sync.db"));
assert_ne!(overridden, platform);
}
#[test]
fn an_empty_config_dir_env_falls_back_to_the_platform_directory() {
let empty = global_config_path_in(Some(OsStr::new("")), "aisle.conf").expect("a path");
let unset = global_config_path_in(None, "aisle.conf").expect("a home directory");
assert_eq!(empty, unset);
}
#[test]
fn a_config_dir_env_aisle_is_discoverable() {
let dir = tempfile::TempDir::new().unwrap();
let base = utf8(&dir);
let config = base.join("isolated");
write(&config.join("aisle.conf"), "[produce]\nleek");
let global =
global_config_path_in(Some(OsStr::new(config.as_str())), "aisle.conf").expect("a path");
let found = Context::search(&base, "aisle.conf", Some(&global));
assert_eq!(found, ConfigSource::Path(config.join("aisle.conf")));
}
#[cfg(unix)]
#[test]
fn a_non_utf8_config_dir_env_is_an_error() {
use std::os::unix::ffi::OsStrExt;
let err = global_config_path_in(Some(OsStr::from_bytes(b"/tmp/\xff")), "session.json")
.expect_err("non-utf-8 override");
assert!(
err.to_string().contains(CONFIG_DIR_ENV),
"the message should name the variable at fault: {err}"
);
}
fn write(path: &Utf8Path, text: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
fn utf8(dir: &tempfile::TempDir) -> Utf8PathBuf {
Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
}
#[test]
fn local_config_wins_over_global() {
let dir = tempfile::TempDir::new().unwrap();
let base = utf8(&dir);
let local = base.join("config").join("aisle.conf");
let global = base.join("global").join("aisle.conf");
write(&local, "[produce]\nleek");
write(&global, "[dairy]\nmilk");
let found = Context::search(&base, "aisle.conf", Some(&global));
assert_eq!(found, ConfigSource::Path(local));
}
#[test]
fn global_config_is_used_when_there_is_no_local_one() {
let dir = tempfile::TempDir::new().unwrap();
let base = utf8(&dir);
let global = base.join("global").join("pantry.conf");
write(&global, "[freezer]\npeas = \"1kg\"");
let found = Context::search(&base, "pantry.conf", Some(&global));
assert_eq!(found, ConfigSource::Path(global));
}
#[test]
fn absent_everywhere_is_unset() {
let dir = tempfile::TempDir::new().unwrap();
let base = utf8(&dir);
let global = base.join("global").join("pantry.conf");
assert!(Context::search(&base, "pantry.conf", Some(&global)).is_unset());
assert!(Context::search(&base, "pantry.conf", None).is_unset());
}
}