git/grrs/main
src/config.rs
236 lines7 KB last change raw · history
use std::{fs, path::PathBuf};

use ratatui::style::Color;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
#[derive(Default)]
pub struct AppConfig {
    pub player: PlayerConfig,
    pub ui: UiConfig,
    pub system: SystemConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PlayerConfig {
    pub startup_volume: u8,
    pub loudness_normalization: bool,
    pub stream_url: String,
    pub connection_retries: u8,
}

impl Default for PlayerConfig {
    fn default() -> Self {
        Self {
            startup_volume: 100,
            loudness_normalization: false,
            stream_url: "https://stream.gensokyoradio.net/3".to_string(),
            connection_retries: 15,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct UiConfig {
    pub default_mode: String,
    pub layout: String,
    pub theme_color: String,
    pub error_color: String,
    pub warning_color: String,
    pub highlight_bg: String,
    pub highlight_fg: String,
    pub secondary_text: String,
}

impl Default for UiConfig {
    fn default() -> Self {
        Self {
            default_mode: "playing".to_string(),
            layout: "auto".to_string(),
            theme_color: "green".to_string(),
            error_color: "red".to_string(),
            warning_color: "yellow".to_string(),
            highlight_bg: "darkgray".to_string(),
            highlight_fg: "white".to_string(),
            secondary_text: "darkgray".to_string(),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayoutPreference {
    Auto,
    Horizontal,
    Vertical,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SystemConfig {
    pub mpris_enabled: bool,
    pub notifications: bool,
    pub socket_path: String,
    pub data_dir: String,
}

impl Default for SystemConfig {
    fn default() -> Self {
        Self {
            mpris_enabled: true,
            notifications: true,
            socket_path: "/tmp/grrs.sock".to_string(),
            data_dir: String::new(),
        }
    }
}

impl AppConfig {
    pub fn load_or_create(config_path: &PathBuf) -> Result<Self, String> {
        if config_path.exists() {
            let contents = fs::read_to_string(config_path).map_err(|e| format!("Failed to read file: {e}"))?;

            match toml::from_str::<AppConfig>(&contents) {
                Ok(cfg) => return Ok(cfg),
                Err(e) => return Err(format!("TOML syntax error: {e}")),
            }
        }

        let default_config = AppConfig::default();
        if let Ok(serialized) = toml::to_string_pretty(&default_config) {
            let _ = fs::write(config_path, serialized);
        }
        Ok(default_config)
    }

    pub fn get_warnings(&self) -> Vec<String> {
        let mut warnings = Vec::new();
        if parse_color(&self.ui.theme_color).is_none() {
            warnings.push("theme_color is not a valid color".to_string());
        }
        if parse_color(&self.ui.error_color).is_none() {
            warnings.push("error_color is not a valid color".to_string());
        }
        if parse_color(&self.ui.warning_color).is_none() {
            warnings.push("warning_color is not a valid color".to_string());
        }
        if parse_color(&self.ui.highlight_bg).is_none() {
            warnings.push("highlight_bg is not a valid color".to_string());
        }
        if parse_color(&self.ui.highlight_fg).is_none() {
            warnings.push("highlight_fg is not a valid color".to_string());
        }
        if parse_color(&self.ui.secondary_text).is_none() {
            warnings.push("secondary_text is not a valid color".to_string());
        }

        let mode = self.ui.default_mode.to_lowercase();
        if mode != "playing" && mode != "library" {
            warnings.push("default_mode must be 'playing' or 'library'".to_string());
        }

        let layout = self.ui.layout.to_lowercase();
        if layout != "auto" && layout != "horizontal" && layout != "vertical" {
            warnings.push("layout must be 'auto', 'horizontal' or 'vertical'".to_string());
        }

        warnings
    }

    pub fn get_layout_preference(&self) -> LayoutPreference {
        match self.ui.layout.to_lowercase().as_str() {
            "horizontal" => LayoutPreference::Horizontal,
            "vertical" => LayoutPreference::Vertical,
            _ => LayoutPreference::Auto,
        }
    }

    pub fn get_theme_color(&self) -> Color {
        parse_color(&self.ui.theme_color).unwrap_or(Color::Green)
    }

    pub fn get_error_color(&self) -> Color {
        parse_color(&self.ui.error_color).unwrap_or(Color::Red)
    }

    pub fn get_warning_color(&self) -> Color {
        parse_color(&self.ui.warning_color).unwrap_or(Color::Yellow)
    }

    pub fn get_highlight_bg(&self) -> Color {
        parse_color(&self.ui.highlight_bg).unwrap_or(Color::DarkGray)
    }

    pub fn get_highlight_fg(&self) -> Color {
        parse_color(&self.ui.highlight_fg).unwrap_or(Color::White)
    }

    pub fn get_secondary_text(&self) -> Color {
        parse_color(&self.ui.secondary_text).unwrap_or(Color::DarkGray)
    }
}

pub fn parse_color(c: &str) -> Option<Color> {
    let c = c.trim().to_lowercase();

    if c.starts_with('#') {
        let hex = c.trim_start_matches('#');
        if hex.len() != 6 {
            return None;
        }
        let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
        let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
        let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
        return Some(Color::Rgb(r, g, b));
    }

    match c.as_str() {
        "black" => Some(Color::Black),
        "red" => Some(Color::Red),
        "green" => Some(Color::Green),
        "yellow" => Some(Color::Yellow),
        "blue" => Some(Color::Blue),
        "magenta" => Some(Color::Magenta),
        "cyan" => Some(Color::Cyan),
        "gray" | "grey" => Some(Color::Gray),
        "darkgray" | "darkgrey" => Some(Color::DarkGray),
        "lightred" => Some(Color::LightRed),
        "lightgreen" => Some(Color::LightGreen),
        "lightyellow" => Some(Color::LightYellow),
        "lightblue" => Some(Color::LightBlue),
        "lightmagenta" => Some(Color::LightMagenta),
        "lightcyan" => Some(Color::LightCyan),
        "white" => Some(Color::White),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn config_without_layout_defaults_to_auto() {
        let config: AppConfig = toml::from_str("[ui]\ntheme_color = \"green\"\n").expect("parse config");
        assert_eq!(config.get_layout_preference(), LayoutPreference::Auto);
        assert!(config.get_warnings().is_empty());
    }

    #[test]
    fn explicit_layout_is_case_insensitive() {
        let config: AppConfig = toml::from_str("[ui]\nlayout = \"Vertical\"\n").expect("parse config");
        assert_eq!(config.get_layout_preference(), LayoutPreference::Vertical);

        let config: AppConfig = toml::from_str("[ui]\nlayout = \"horizontal\"\n").expect("parse config");
        assert_eq!(config.get_layout_preference(), LayoutPreference::Horizontal);
    }

    #[test]
    fn invalid_layout_produces_a_warning() {
        let config: AppConfig = toml::from_str("[ui]\nlayout = \"diagonal\"\n").expect("parse config");
        assert!(config.get_warnings().iter().any(|w| w.contains("layout")), "expected a layout warning");
    }
}