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");
}
}