effy 0.11.0

A small and friendly terminal FFmpeg helper that simplifies common tasks
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,
            }
        );
    }

    // Full options config

    #[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());
    }
}