rescrobbled 0.3.3

MPRIS music scrobbler daemon
// Copyright (C) 2019 Koen Bolhuis
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

use std::collections::HashSet;
use std::fmt;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::time::Duration;

use serde::{Deserialize, Deserializer, Serialize, Serializer};

const CONFIG_DIR: &str = "rescrobbled";
const CONFIG_FILE: &str = "config.toml";

fn deserialize_duration_seconds<'de, D: Deserializer<'de>>(
    de: D,
) -> Result<Option<Duration>, D::Error> {
    Ok(Some(Duration::from_secs(u64::deserialize(de)?)))
}

fn serialize_duration_seconds<S: Serializer>(
    value: &Option<Duration>,
    se: S,
) -> Result<S::Ok, S::Error> {
    if let Some(d) = value {
        se.serialize_some(&d.as_secs())
    } else {
        se.serialize_none()
    }
}

#[derive(Deserialize, Serialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct Config {
    #[serde(alias = "api-key")]
    pub lastfm_key: Option<String>,

    #[serde(alias = "api-secret")]
    pub lastfm_secret: Option<String>,

    #[serde(alias = "lb-token")]
    pub listenbrainz_token: Option<String>,

    pub enable_notifications: Option<bool>,

    #[serde(
        default,
        deserialize_with = "deserialize_duration_seconds",
        serialize_with = "serialize_duration_seconds"
    )]
    pub min_play_time: Option<Duration>,

    pub player_whitelist: Option<HashSet<String>>,

    pub filter_script: Option<String>,
}

impl Config {
    fn template() -> String {
        let template = Self {
            lastfm_key: Some(String::new()),
            lastfm_secret: Some(String::new()),
            listenbrainz_token: Some(String::new()),
            enable_notifications: Some(false),
            min_play_time: Some(Duration::from_secs(0)),
            player_whitelist: Some(HashSet::new()),
            filter_script: Some(String::new()),
        };
        toml::to_string(&template)
            .unwrap()
            .lines()
            .map(|l| format!("# {}\n", l))
            .collect()
    }
}

#[derive(Debug)]
pub enum ConfigError {
    Io(io::Error),
    Format(String),
    Created(PathBuf),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ConfigError::Io(err) => write!(f, "{}", err),
            ConfigError::Format(msg) => write!(f, "{}", msg),
            ConfigError::Created(path) => {
                write!(f, "Created config file at {}", path.to_string_lossy())
            }
        }
    }
}

pub fn config_dir() -> Result<PathBuf, ConfigError> {
    let mut path = dirs::config_dir().ok_or_else(|| {
        ConfigError::Io(io::Error::new(
            io::ErrorKind::NotFound,
            "User config directory not found",
        ))
    })?;

    path.push(CONFIG_DIR);

    fs::create_dir_all(&path).map_err(ConfigError::Io)?;

    Ok(path)
}

pub fn load_config() -> Result<Config, ConfigError> {
    let mut path = config_dir()?;

    path.push(CONFIG_FILE);

    if !path.exists() {
        fs::write(&path, Config::template()).map_err(ConfigError::Io)?;
        return Err(ConfigError::Created(path));
    }

    let buffer = fs::read_to_string(&path).map_err(ConfigError::Io)?;

    toml::from_str(&buffer)
        .map_err(|err| ConfigError::Format(format!("Could not parse config: {}", err)))
}