use regex::Regex; use serde::Deserialize; use std::fs; use std::path::PathBuf; use std::sync::LazyLock; use tracing::{debug, info, warn}; #[derive(Deserialize, Default, Clone)] pub struct Config { #[serde(default)] pub general: GeneralConfig, #[serde(default)] pub network: NetworkConfig, #[serde(default)] pub cpu: CpuConfig, #[serde(default)] pub memory: MemoryConfig, #[serde(default)] pub gpu: GpuConfig, #[serde(default)] pub sys: SysConfig, #[serde(default)] pub disk: DiskConfig, #[serde(default)] pub pool: PoolConfig, #[serde(default)] pub power: PowerConfig, #[serde(default)] pub audio: AudioConfig, #[serde(default)] pub bt: BtConfig, #[serde(default)] pub game: GameConfig, } #[derive(Deserialize, Clone)] pub struct GeneralConfig { pub menu_command: String, } impl Default for GeneralConfig { fn default() -> Self { Self { menu_command: "fuzzel --dmenu --prompt '{prompt}'".to_string(), } } } #[derive(Deserialize, Clone)] pub struct NetworkConfig { pub format: String, } impl Default for NetworkConfig { fn default() -> Self { Self { format: "{interface} ({ip}):  {rx:>5.2} MB/s  {tx:>5.2} MB/s".to_string(), } } } #[derive(Deserialize, Clone)] pub struct CpuConfig { pub format: String, } impl Default for CpuConfig { fn default() -> Self { Self { format: "CPU: {usage:>4.1}% {temp:>4.1}C".to_string(), } } } #[derive(Deserialize, Clone)] pub struct MemoryConfig { pub format: String, } impl Default for MemoryConfig { fn default() -> Self { Self { format: "{used:>5.2}/{total:>5.2}GB".to_string(), } } } #[derive(Deserialize, Clone)] pub struct GpuConfig { pub format_amd: String, pub format_intel: String, pub format_nvidia: String, } impl Default for GpuConfig { fn default() -> Self { Self { format_amd: "AMD: {usage:>3.0}% {vram_used:>4.1}/{vram_total:>4.1}GB {temp:>4.1}C" .to_string(), format_intel: "iGPU: {usage:>3.0}%".to_string(), format_nvidia: "NV: {usage:>3.0}% {vram_used:>4.1}/{vram_total:>4.1}GB {temp:>4.1}C" .to_string(), } } } #[derive(Deserialize, Clone)] pub struct SysConfig { pub format: String, } impl Default for SysConfig { fn default() -> Self { Self { format: "UP: {uptime} | LOAD: {load1:>4.2} {load5:>4.2} {load15:>4.2}".to_string(), } } } #[derive(Deserialize, Clone)] pub struct DiskConfig { pub format: String, } impl Default for DiskConfig { fn default() -> Self { Self { format: "{mount} {used:>5.1}/{total:>5.1}G".to_string(), } } } #[derive(Deserialize, Clone)] pub struct PoolConfig { pub format: String, } impl Default for PoolConfig { fn default() -> Self { Self { format: "{used:>4.0}G / {total:>4.0}G".to_string(), } } } #[derive(Deserialize, Clone)] pub struct PowerConfig { pub format: String, } impl Default for PowerConfig { fn default() -> Self { Self { format: "{percentage:>3}% {icon}".to_string(), } } } #[derive(Deserialize, Clone)] pub struct AudioConfig { pub format_sink_unmuted: String, pub format_sink_muted: String, pub format_source_unmuted: String, pub format_source_muted: String, } impl Default for AudioConfig { fn default() -> Self { Self { format_sink_unmuted: "{name} {volume:>3}% {icon}".to_string(), format_sink_muted: "{name} {icon}".to_string(), format_source_unmuted: "{name} {volume:>3}% {icon}".to_string(), format_source_muted: "{name} {icon}".to_string(), } } } #[derive(Deserialize, Clone)] pub struct BtConfig { pub format_connected: String, pub format_plugin: String, pub format_disconnected: String, pub format_disabled: String, } impl Default for BtConfig { fn default() -> Self { Self { format_connected: "{alias} 󰂰".to_string(), format_plugin: "{alias} [{left}|{right}] {anc} 󰂰".to_string(), format_disconnected: "󰂯".to_string(), format_disabled: "󰂲 Off".to_string(), } } } #[derive(Deserialize, Clone)] pub struct GameConfig { pub format_active: String, pub format_inactive: String, } impl Default for GameConfig { fn default() -> Self { Self { format_active: "󰊖".to_string(), format_inactive: "".to_string(), } } } static TOKEN_RE: LazyLock = LazyLock::new(|| Regex::new(r"\{([a-zA-Z0-9_]+)(?::([<>\^])?(\d+)?(?:\.(\d+))?)?\}").unwrap()); fn extract_tokens(format_str: &str) -> Vec { TOKEN_RE .captures_iter(format_str) .map(|cap| cap[1].to_string()) .collect() } fn validate_format(label: &str, format_str: &str, known_tokens: &[&str]) { for token in extract_tokens(format_str) { if !known_tokens.contains(&token.as_str()) { warn!( "Config [{}]: unknown token '{{{}}}' in format string. Known tokens: {:?}", label, token, known_tokens ); } } } impl Config { pub fn validate(&self) { validate_format( "network", &self.network.format, &["interface", "ip", "rx", "tx"], ); validate_format("cpu", &self.cpu.format, &["usage", "temp"]); validate_format("memory", &self.memory.format, &["used", "total"]); validate_format( "gpu.amd", &self.gpu.format_amd, &["usage", "vram_used", "vram_total", "temp"], ); validate_format("gpu.intel", &self.gpu.format_intel, &["usage", "freq"]); validate_format( "gpu.nvidia", &self.gpu.format_nvidia, &["usage", "vram_used", "vram_total", "temp"], ); validate_format( "sys", &self.sys.format, &["uptime", "load1", "load5", "load15", "procs"], ); validate_format("disk", &self.disk.format, &["mount", "used", "total"]); validate_format("pool", &self.pool.format, &["used", "total"]); validate_format("power", &self.power.format, &["percentage", "icon"]); validate_format( "audio.sink_unmuted", &self.audio.format_sink_unmuted, &["name", "icon", "volume"], ); validate_format( "audio.sink_muted", &self.audio.format_sink_muted, &["name", "icon"], ); validate_format( "audio.source_unmuted", &self.audio.format_source_unmuted, &["name", "icon", "volume"], ); validate_format( "audio.source_muted", &self.audio.format_source_muted, &["name", "icon"], ); validate_format("bt.connected", &self.bt.format_connected, &["alias"]); validate_format( "bt.plugin", &self.bt.format_plugin, &["alias", "left", "right", "anc", "mac"], ); } } pub fn load_config(custom_path: Option) -> Config { let config_path = custom_path.unwrap_or_else(|| { let config_dir = std::env::var("XDG_CONFIG_HOME") .map(PathBuf::from) .unwrap_or_else(|_| { let home = std::env::var("HOME").unwrap_or_else(|_| String::from("/")); PathBuf::from(home).join(".config") }); config_dir.join("fluxo/config.toml") }); if let Ok(content) = fs::read_to_string(&config_path) { match toml::from_str::(&content) { Ok(cfg) => { info!("Successfully loaded configuration from {:?}", config_path); cfg.validate(); cfg } Err(e) => { warn!("Failed to parse config at {:?}: {}", config_path, e); warn!("Falling back to default configuration."); Config::default() } } } else { debug!( "No config file found at {:?}, using default settings.", config_path ); Config::default() } } #[cfg(test)] mod tests { use super::*; use std::io::Write; #[test] fn test_default_config() { let config = Config::default(); assert_eq!( config.general.menu_command, "fuzzel --dmenu --prompt '{prompt}'" ); assert!(config.cpu.format.contains("usage")); assert!(config.cpu.format.contains("temp")); assert!(config.memory.format.contains("used")); assert!(config.memory.format.contains("total")); } #[test] fn test_load_missing_config() { let config = load_config(Some(PathBuf::from("/nonexistent/config.toml"))); // Should fallback to defaults without panicking assert_eq!( config.general.menu_command, "fuzzel --dmenu --prompt '{prompt}'" ); } #[test] fn test_load_valid_partial_config() { let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); // In TOML, braces have no special meaning in strings writeln!(tmpfile, "[cpu]").unwrap(); writeln!(tmpfile, "format = \"custom: {{usage}}\"").unwrap(); let config = load_config(Some(tmpfile.path().to_path_buf())); // TOML treats {{ as literal {{ (no escape), so the value is "custom: {{usage}}" assert!(config.cpu.format.contains("usage")); // Other sections still have defaults assert!(config.memory.format.contains("used")); } #[test] fn test_load_invalid_toml() { let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); write!(tmpfile, "this is not valid toml {{{{").unwrap(); let config = load_config(Some(tmpfile.path().to_path_buf())); // Should fallback to defaults assert_eq!( config.general.menu_command, "fuzzel --dmenu --prompt '{prompt}'" ); } #[test] fn test_load_empty_config() { let tmpfile = tempfile::NamedTempFile::new().unwrap(); // Empty file is valid TOML, all sections default let config = load_config(Some(tmpfile.path().to_path_buf())); assert_eq!( config.general.menu_command, "fuzzel --dmenu --prompt '{prompt}'" ); assert!(config.cpu.format.contains("usage")); } }