use std::env;
use std::path::{Path, PathBuf};
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::error::{ConfigError, locate};
pub trait AppConfig: DeserializeOwned + Default {
const APPLICATION: &'static str;
const FILE: &'static str = "config.toml";
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigSource {
File(PathBuf),
Defaults,
}
fn resolve_xdg(
env_value: Option<&std::ffi::OsStr>,
home: Option<&Path>,
fallback_subdir: &str,
) -> Result<PathBuf, ConfigError> {
if let Some(raw) = env_value
&& !raw.is_empty()
{
let p = PathBuf::from(raw);
if p.is_absolute() {
return Ok(p);
}
}
home.map(|h| h.join(fallback_subdir))
.ok_or(ConfigError::NoHomeDir)
}
fn xdg_base(env_var: &str, fallback_subdir: &str) -> Result<PathBuf, ConfigError> {
let env_value = env::var_os(env_var);
let home = dirs::home_dir();
resolve_xdg(env_value.as_deref(), home.as_deref(), fallback_subdir)
}
pub fn config_dir(app: &str) -> Result<PathBuf, ConfigError> {
Ok(xdg_base("XDG_CONFIG_HOME", ".config")?.join(app))
}
pub fn data_dir(app: &str) -> Result<PathBuf, ConfigError> {
Ok(xdg_base("XDG_DATA_HOME", ".local/share")?.join(app))
}
pub fn cache_dir(app: &str) -> Result<PathBuf, ConfigError> {
Ok(xdg_base("XDG_CACHE_HOME", ".cache")?.join(app))
}
pub fn config_path<C: AppConfig>() -> Result<PathBuf, ConfigError> {
Ok(config_dir(C::APPLICATION)?.join(C::FILE))
}
pub fn load<C: AppConfig>() -> Result<(C, ConfigSource), ConfigError> {
let path = match config_path::<C>() {
Ok(p) => p,
Err(ConfigError::NoHomeDir) => {
return Ok((C::default(), ConfigSource::Defaults));
}
Err(e) => return Err(e),
};
if !path.exists() {
return Ok((C::default(), ConfigSource::Defaults));
}
let cfg = load_from::<C>(&path)?;
Ok((cfg, ConfigSource::File(path)))
}
pub fn load_from<C: AppConfig>(path: &Path) -> Result<C, ConfigError> {
let src = std::fs::read_to_string(path).map_err(|e| ConfigError::Io {
path: path.to_path_buf(),
source: e,
})?;
toml::from_str::<C>(&src).map_err(|e| {
let span = e.span().unwrap_or(0..0);
let (line, col, snippet) = locate(&src, span.start);
ConfigError::Parse {
path: path.to_path_buf(),
line,
col,
message: e.message().to_string(),
snippet,
}
})
}
pub fn load_layered<C: AppConfig>(defaults_toml: &str) -> Result<(C, ConfigSource), ConfigError> {
let user_path = match config_path::<C>() {
Ok(p) if p.exists() => Some(p),
Ok(_) => None,
Err(ConfigError::NoHomeDir) => None,
Err(e) => return Err(e),
};
match user_path {
Some(p) => {
let cfg = load_layered_from::<C>(defaults_toml, &p)?;
Ok((cfg, ConfigSource::File(p)))
}
None => {
let cfg = parse_defaults_only::<C>(defaults_toml)?;
Ok((cfg, ConfigSource::Defaults))
}
}
}
pub fn load_layered_from<C: AppConfig>(defaults_toml: &str, path: &Path) -> Result<C, ConfigError> {
let user_src = std::fs::read_to_string(path).map_err(|e| ConfigError::Io {
path: path.to_path_buf(),
source: e,
})?;
let user_table: toml::Table = toml::from_str(&user_src).map_err(|e| {
let span = e.span().unwrap_or(0..0);
let (line, col, snippet) = locate(&user_src, span.start);
ConfigError::Parse {
path: path.to_path_buf(),
line,
col,
message: e.message().to_string(),
snippet,
}
})?;
let mut merged: toml::Table =
toml::from_str(defaults_toml).map_err(|e| ConfigError::Invalid {
path: PathBuf::from("<bundled defaults>"),
message: format!("bundled defaults TOML is invalid: {e}"),
})?;
deep_merge(&mut merged, user_table);
toml::Value::Table(merged)
.try_into()
.map_err(|e: toml::de::Error| ConfigError::Invalid {
path: path.to_path_buf(),
message: e.to_string(),
})
}
fn parse_defaults_only<C: AppConfig>(defaults_toml: &str) -> Result<C, ConfigError> {
toml::from_str::<C>(defaults_toml).map_err(|e| ConfigError::Invalid {
path: PathBuf::from("<bundled defaults>"),
message: format!("bundled defaults TOML is invalid: {e}"),
})
}
pub(crate) fn deep_merge(into: &mut toml::Table, from: toml::Table) {
for (k, v) in from {
match (into.get_mut(&k), v) {
(Some(toml::Value::Table(into_t)), toml::Value::Table(from_t)) => {
deep_merge(into_t, from_t);
}
(_, v) => {
into.insert(k, v);
}
}
}
}
pub fn write_default<C: AppConfig + Serialize>(path: &Path, cfg: &C) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| ConfigError::Write {
path: parent.to_path_buf(),
source: e,
})?;
}
let body = toml::to_string_pretty(cfg).map_err(|e| ConfigError::Write {
path: path.to_path_buf(),
source: std::io::Error::other(e.to_string()),
})?;
std::fs::write(path, body).map_err(|e| ConfigError::Write {
path: path.to_path_buf(),
source: e,
})
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use super::*;
fn os(s: &str) -> OsString {
OsString::from(s)
}
#[test]
fn xdg_set_absolute_wins_over_fallback() {
let tmp = tempfile::tempdir().unwrap();
let env = os(tmp.path().to_str().unwrap());
let home = std::path::PathBuf::from("/some/home");
let r = resolve_xdg(Some(&env), Some(&home), ".config").unwrap();
assert_eq!(r, tmp.path());
}
#[test]
fn xdg_empty_falls_back_to_home() {
let env = os("");
let home = std::path::PathBuf::from("/some/home");
let r = resolve_xdg(Some(&env), Some(&home), ".config").unwrap();
assert_eq!(r, std::path::PathBuf::from("/some/home/.config"));
}
#[test]
fn xdg_unset_falls_back_to_home() {
let home = std::path::PathBuf::from("/some/home");
let r = resolve_xdg(None, Some(&home), ".local/share").unwrap();
assert_eq!(r, std::path::PathBuf::from("/some/home/.local/share"));
}
#[test]
fn xdg_relative_ignored_per_spec() {
let env = os("relative/path");
let home = std::path::PathBuf::from("/some/home");
let r = resolve_xdg(Some(&env), Some(&home), ".cache").unwrap();
assert_eq!(r, std::path::PathBuf::from("/some/home/.cache"));
}
#[test]
fn xdg_no_env_no_home_errs() {
let r = resolve_xdg(None, None, ".config");
assert!(matches!(r, Err(ConfigError::NoHomeDir)));
}
#[test]
fn config_dir_smoke() {
let p = config_dir("myapp").unwrap();
assert!(
p.ends_with("myapp"),
"expected path ending in `myapp`, got {p:?}"
);
}
#[test]
fn data_dir_smoke() {
let p = data_dir("myapp").unwrap();
assert!(p.ends_with("myapp"));
}
#[test]
fn cache_dir_smoke() {
let p = cache_dir("myapp").unwrap();
assert!(p.ends_with("myapp"));
}
}