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 { 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::(&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 { 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 { 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"); } }