mod parse;
pub mod theme_resolver;
use std::{
error::Error,
fmt::{self, Display, Formatter},
fs, io,
path::{Path, PathBuf},
};
use serde::Deserialize;
pub(crate) const DEFAULT_CONFIG: &str = include_str!("../assets/config-default.toml");
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct RawConfig {
theme: String,
hide_hwaccel_options: String,
lut_folder: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Config {
pub(crate) theme: String,
pub(crate) hide_hwaccel_options: Vec<String>,
pub(crate) lut_folder: Option<String>,
}
impl Config {
pub(crate) fn show() {
println!("# Configuration file must be located at:");
println!(
"# {}",
Self::base_config_path()
.map(|p| p.display().to_string())
.unwrap_or("N/A".to_owned())
);
println!("#");
println!("{}", DEFAULT_CONFIG);
}
pub(crate) fn load(path: Option<&Path>) -> Result<Self, ConfigError> {
let path = path
.map(Path::to_owned)
.map_or_else(Self::base_config_path, Ok)?;
let contents = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(source) if source.kind() == io::ErrorKind::NotFound => {
return Ok(Self::default());
}
Err(source) => return Err(ConfigError::Read { path, source }),
};
let mut merged: toml::Table =
toml::from_str(DEFAULT_CONFIG).expect("DEFAULT_CONFIG should be valid TOML");
let loaded: toml::Table =
toml::from_str(&contents).map_err(|source| ConfigError::Parse {
path: path.clone(),
source,
})?;
merged.extend(loaded);
let raw = toml::Value::Table(merged)
.try_into()
.map_err(|source| ConfigError::Parse {
path: path.clone(),
source,
})?;
Self::from_raw(raw)
}
fn from_raw(raw: RawConfig) -> Result<Self, ConfigError> {
Ok(Self {
theme: raw.theme,
hide_hwaccel_options: parse::hwaccel_options(&raw.hide_hwaccel_options)?,
lut_folder: raw.lut_folder,
})
}
pub fn config_dir(&self, path: Option<&Path>) -> Result<PathBuf, ConfigError> {
let path = path
.map(Path::to_owned)
.map_or_else(Self::base_config_path, Ok)?;
Ok(path.parent().unwrap_or(&path).to_path_buf())
}
fn base_config_path() -> Result<PathBuf, ConfigError> {
let config_dir = Self::base_config_dir()?;
let app_name = env!("CARGO_PKG_NAME");
Ok(config_dir.join(app_name).join(format!("{app_name}.toml")))
}
fn base_config_dir() -> Result<PathBuf, ConfigError> {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.or_else(dirs::config_dir)
.or_else(|| dirs::home_dir().map(|home| home.join(".config")))
.ok_or(ConfigError::PathUnavailable)
}
}
impl Default for Config {
fn default() -> Self {
let raw = toml::from_str(DEFAULT_CONFIG).expect("DEFAULT_CONFIG should be valid TOML");
Self::from_raw(raw).expect("DEFAULT_CONFIG values should be valid")
}
}
#[derive(Debug)]
pub(crate) enum ConfigError {
PathUnavailable,
Read {
path: PathBuf,
source: io::Error,
},
Parse {
path: PathBuf,
source: toml::de::Error,
},
Other(String),
}
impl Display for ConfigError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::PathUnavailable => write!(f, "could not determine the user config directory"),
Self::Read { path, source } => {
write!(f, "could not read config {}: {source}", path.display())
}
Self::Parse { path, source } => {
write!(f, "could not parse config {}: {source}", path.display())
}
Self::Other(cause) => write!(f, "{}", cause),
}
}
}
impl Error for ConfigError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Read { source, .. } => Some(source),
Self::Parse { source, .. } => Some(source),
Self::PathUnavailable | Self::Other(_) => None,
}
}
}
#[cfg(test)]
mod tests {
use std::{
fs,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use super::{Config, ConfigError, DEFAULT_CONFIG};
static NEXT_TEMP_DIR: AtomicU64 = AtomicU64::new(0);
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let id = NEXT_TEMP_DIR.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("effy-config-{}-{id}", std::process::id()));
fs::create_dir(&path).expect("create test directory");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn should_use_default_config_on_missing_file() {
let temp = TempDir::new();
let path = temp.path().join("missing").join("effy.toml");
assert_eq!(Config::load(Some(&path)).unwrap(), Config::default());
assert!(!path.exists());
assert!(!path.parent().unwrap().exists());
}
#[test]
fn should_use_default_config_on_empty_file() {
let temp = TempDir::new();
let path = temp.path().join("effy.toml");
fs::write(&path, "").unwrap();
assert_eq!(Config::load(Some(&path)).unwrap(), Config::default());
}
#[test]
fn should_fail_on_corrupted_file() {
let temp = TempDir::new();
let path = temp.path().join("effy.toml");
fs::write(&path, "public static void main(String[] args) {}").unwrap();
let error = Config::load(Some(&path)).unwrap_err();
assert!(matches!(error, ConfigError::Parse { .. }));
assert!(error.to_string().contains(path.to_str().unwrap()));
}
#[test]
fn should_merge_partial_config_with_defaults() {
let temp = TempDir::new();
let path = temp.path().join("effy.toml");
fs::write(&path, "theme = \"hacky\"").unwrap();
assert_eq!(
Config::load(Some(&path)).unwrap(),
Config {
theme: "hacky".to_owned(),
hide_hwaccel_options: Vec::new(),
lut_folder: None,
}
);
}
#[test]
fn should_load_default_config() {
let temp = TempDir::new();
let path = temp.path().join("effy.toml");
fs::write(&path, DEFAULT_CONFIG).unwrap();
assert_eq!(Config::load(Some(&path)).unwrap(), Config::default());
}
}