clck 0.1.4

A responsive cross-platform countdown alarm for the terminal.
Documentation
use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::{
    fs,
    path::{Path, PathBuf},
};

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum SoundSetting {
    System(String),
    File(PathBuf),
    TerminalBell,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    pub font: String,
    pub notification: bool,
    pub sound: SoundSetting,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            font: "standard".into(),
            notification: true,
            sound: SoundSetting::System("Glass".into()),
        }
    }
}

#[derive(Clone, Debug, Default)]
pub struct Overrides {
    pub font: Option<String>,
    pub notification: Option<bool>,
    pub sound: Option<SoundSetting>,
}

impl Config {
    pub fn path() -> Result<PathBuf> {
        let directories = ProjectDirs::from("", "", "clck")
            .context("could not determine the platform configuration directory")?;
        Ok(directories.config_dir().join("config.toml"))
    }

    pub fn load() -> Result<Self> {
        Self::load_from(&Self::path()?)
    }

    pub fn load_from(path: &Path) -> Result<Self> {
        if !path.exists() {
            return Ok(Self::default());
        }
        let contents = fs::read_to_string(path)
            .with_context(|| format!("could not read configuration {}", path.display()))?;
        toml::from_str(&contents)
            .with_context(|| format!("could not parse configuration {}", path.display()))
    }

    pub fn save(&self) -> Result<()> {
        self.save_to(&Self::path()?)
    }

    pub fn save_to(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("could not create {}", parent.display()))?;
        }
        let contents = toml::to_string_pretty(self).context("could not serialize configuration")?;
        fs::write(path, contents)
            .with_context(|| format!("could not write configuration {}", path.display()))
    }

    pub fn resolve(&self, overrides: Overrides) -> Self {
        Self {
            font: overrides.font.unwrap_or_else(|| self.font.clone()),
            notification: overrides.notification.unwrap_or(self.notification),
            sound: overrides.sound.unwrap_or_else(|| self.sound.clone()),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{Config, Overrides, SoundSetting};

    #[test]
    fn command_line_overrides_saved_configuration() {
        let saved = Config {
            font: "box".into(),
            notification: false,
            sound: SoundSetting::System("Glass".into()),
        };
        let resolved = saved.resolve(Overrides {
            font: Some("banner".into()),
            notification: Some(true),
            sound: None,
        });
        assert_eq!(resolved.font, "banner");
        assert!(resolved.notification);
        assert_eq!(resolved.sound, SoundSetting::System("Glass".into()));
    }

    #[test]
    fn missing_configuration_uses_defaults() {
        let directory = tempfile::tempdir().unwrap();
        let config = Config::load_from(&directory.path().join("missing.toml")).unwrap();
        assert_eq!(config, Config::default());
    }

    #[test]
    fn configuration_round_trips_as_toml() {
        let directory = tempfile::tempdir().unwrap();
        let path = directory.path().join("config.toml");
        let config = Config::default();
        config.save_to(&path).unwrap();
        assert_eq!(Config::load_from(&path).unwrap(), config);
    }
}