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