use std::{fmt, fs, io, path::Path, path::PathBuf};
use anyhow::{bail, Context, Result};
use dirs::{config_dir, home_dir};
use log::debug;
use serde::{de::Visitor, Deserialize, Deserializer};
use serde_toml_merge::merge;
use toml::Value;
pub trait TomlConfig: for<'de> Deserialize<'de> {
type Account;
fn project_name() -> &'static str;
fn take_default_account(&mut self) -> Option<(String, Self::Account)>;
fn take_named_account(&mut self, name: &str) -> Option<(String, Self::Account)>;
fn take_account(&mut self, name: Option<&str>) -> Result<Option<(String, Self::Account)>> {
match name {
Some("default") | Some("") | None => Ok(self.take_default_account()),
Some(name) => match self.take_named_account(name) {
None => bail!("Get account `{name}` error"),
account => Ok(account),
},
}
}
fn from_paths(paths: &[PathBuf]) -> Result<Self> {
match paths.len() {
0 => {
bail!("Read TOML config from empty paths error");
}
1 => {
let path = &paths[0];
let ref content =
fs::read_to_string(path).context("Read TOML config file error")?;
toml::from_str(content).context("Parse TOML config error")
}
_ => {
let path = &paths[0];
let mut merged_content = fs::read_to_string(path)
.context("Read TOML config file error")?
.parse::<Value>()
.context("Parse TOML config error")?;
for path in &paths[1..] {
let content = fs::read_to_string(path);
let content = match content {
Ok(content) => content.parse().context("Parse TOML config error")?,
Err(err) => {
debug!("skip invalid subconfig at {}: {err}", path.display());
continue;
}
};
match merge(merged_content, content) {
Ok(content) => merged_content = content,
Err(err) => bail!("Merge TOML subconfigs error: {err}"),
}
}
merged_content.try_into().context("Parse TOML config error")
}
}
}
fn from_paths_or_default(paths: &[PathBuf]) -> Result<Option<Self>> {
match paths.first() {
None => Self::from_default_paths(),
Some(path) => match path_status(path)? {
PathStatus::Missing => Ok(None),
PathStatus::Present => Self::from_paths(paths).map(Some),
},
}
}
fn from_default_paths() -> Result<Option<Self>> {
match Self::first_valid_default_path() {
Some(path) => Self::from_paths(&[path]).map(Some),
None => Ok(None),
}
}
fn target_path(paths: &[PathBuf]) -> Result<PathBuf> {
match paths.first() {
Some(path) => Ok(path.clone()),
None => Self::default_path(),
}
}
fn default_path() -> Result<PathBuf> {
let Some(dir) = config_dir() else {
bail!("Get XDG config directory error");
};
Ok(dir.join(Self::project_name()).join("config.toml"))
}
fn first_valid_default_path() -> Option<PathBuf> {
let project = Self::project_name();
Self::default_path()
.ok()
.filter(|p| p.exists())
.or_else(|| home_dir().map(|p| p.join(".config").join(project).join("config.toml")))
.filter(|p| p.exists())
.or_else(|| home_dir().map(|p| p.join(format!(".{project}rc"))))
.filter(|p| p.exists())
}
}
enum PathStatus {
Missing,
Present,
}
fn path_status(path: &Path) -> Result<PathStatus> {
match fs::metadata(path) {
Ok(_) => Ok(PathStatus::Present),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(PathStatus::Missing),
Err(err) => {
Err(err).with_context(|| format!("Stat TOML config file `{}` error", path.display()))
}
}
}
struct ShellExpandedStringVisitor;
impl<'de> Visitor<'de> for ShellExpandedStringVisitor {
type Value = String;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("an string containing environment variable(s)")
}
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
match shellexpand::full(&v) {
Ok(v) => Ok(v.to_string()),
Err(_) => Ok(v),
}
}
}
pub fn shell_expanded_string<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<String, D::Error> {
deserializer.deserialize_string(ShellExpandedStringVisitor)
}
pub fn shell_expanded_path<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<PathBuf, D::Error> {
shell_expanded_string(deserializer).map(Into::into)
}