use crate::types::{GpuSelection, ScalingMode, ThemeProvider};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct WallrConfig {
#[serde(default)]
pub wallpaper: WallpaperConfig,
#[serde(default)]
pub theme: ThemeConfig,
#[serde(default)]
pub matugen: MatugenConfig,
#[serde(default)]
pub hooks: HooksConfig,
#[serde(default)]
pub reload: Vec<String>,
#[serde(default)]
pub daemon: DaemonConfig,
#[serde(default)]
pub video: VideoConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WallpaperConfig {
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub mode: ScalingMode,
#[serde(default)]
pub monitors: Vec<MonitorConfig>,
#[serde(default = "default_true")]
pub loop_video: bool,
#[serde(default = "default_true")]
pub mute: bool,
}
impl Default for WallpaperConfig {
fn default() -> Self {
Self {
default: None,
mode: ScalingMode::Fill,
monitors: vec![],
loop_video: true,
mute: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoConfig {
#[serde(default = "default_hw_decode")]
pub hw_decode: String,
#[serde(default)]
pub preferred_gpu: GpuSelection,
#[serde(default = "default_preload_frames")]
pub preload_frames: usize,
}
impl Default for VideoConfig {
fn default() -> Self {
Self {
hw_decode: default_hw_decode(),
preferred_gpu: GpuSelection::Auto,
preload_frames: default_preload_frames(),
}
}
}
fn default_hw_decode() -> String {
"auto".to_string()
}
fn default_preload_frames() -> usize {
2
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonitorConfig {
pub name: String,
#[serde(default)]
pub file: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeConfig {
#[serde(default = "default_theme_provider")]
pub provider: ThemeProvider,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
provider: ThemeProvider::Matugen,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatugenConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_mode")]
pub mode: String,
#[serde(default = "default_scheme")]
pub scheme: String,
#[serde(default)]
pub contrast: i32,
#[serde(default)]
pub wait: bool,
#[serde(default)]
pub args: Vec<String>,
}
impl Default for MatugenConfig {
fn default() -> Self {
Self {
enabled: true,
mode: "dark".to_string(),
scheme: "scheme-tonal-spot".to_string(),
contrast: 0,
wait: true,
args: vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HooksConfig {
#[serde(default)]
pub before: Vec<String>,
#[serde(default)]
pub after: Vec<String>,
#[serde(default)]
pub error: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonConfig {
#[serde(default = "default_true")]
pub auto_start: bool,
#[serde(default = "default_socket")]
pub socket: String,
#[serde(default = "default_max_fps")]
pub max_fps: Option<u32>,
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
auto_start: true,
socket: "$XDG_RUNTIME_DIR/wallr.sock".to_string(),
max_fps: Some(60),
}
}
}
fn default_max_fps() -> Option<u32> {
Some(60)
}
fn default_true() -> bool {
true
}
fn default_mode() -> String {
"dark".to_string()
}
fn default_scheme() -> String {
"scheme-tonal-spot".to_string()
}
fn default_theme_provider() -> ThemeProvider {
ThemeProvider::Matugen
}
fn default_socket() -> String {
"$XDG_RUNTIME_DIR/wallr.sock".to_string()
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file: {0}")]
ReadError(#[from] std::io::Error),
#[error("failed to parse config: {0}")]
ParseError(#[from] serde_yaml::Error),
#[error("invalid duration format: {0}")]
InvalidDuration(String),
#[error("invalid config value: {0}")]
InvalidValue(String),
#[error("environment variable {0} is not set")]
UnsetEnv(String),
}
pub fn load_config(path: Option<&Path>) -> Result<WallrConfig, ConfigError> {
match path {
Some(path) => {
let content = std::fs::read_to_string(path)?;
let config: WallrConfig = serde_yaml::from_str(&content)?;
Ok(apply_environment_overrides(config))
}
None => {
let path = config_path();
if !path.exists() {
return Ok(apply_environment_overrides(WallrConfig::default()));
}
let content = std::fs::read_to_string(path)?;
let config: WallrConfig = serde_yaml::from_str(&content)?;
Ok(apply_environment_overrides(config))
}
}
}
fn apply_environment_overrides(mut config: WallrConfig) -> WallrConfig {
if let Ok(socket) = std::env::var("WALLR_SOCKET")
&& !socket.is_empty()
{
config.daemon.socket = socket;
}
config
}
pub fn expand_path(path: &str) -> Result<PathBuf, ConfigError> {
let mut path_str = path.to_string();
if path_str == "~" || path_str.starts_with("~/") {
let home = std::env::var("HOME").map_err(|_| ConfigError::UnsetEnv("HOME".into()))?;
path_str = if path_str == "~" {
home
} else {
path_str.replacen('~', &home, 1)
};
}
let mut expanded = String::new();
let mut chars = path_str.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
expanded.push(c);
continue;
}
let mut env_var = String::new();
if chars.peek() == Some(&'{') {
chars.next();
let mut closed = false;
for next_c in chars.by_ref() {
if next_c == '}' {
closed = true;
break;
}
if !(next_c.is_ascii_alphanumeric() || next_c == '_') {
return Err(ConfigError::InvalidValue(format!(
"invalid environment variable reference near '${{{env_var}{next_c}'"
)));
}
env_var.push(next_c);
}
if !closed || env_var.is_empty() {
return Err(ConfigError::InvalidValue(
"unterminated or empty ${...} environment variable reference".into(),
));
}
} else {
while let Some(&next_c) = chars.peek() {
if next_c.is_ascii_alphanumeric() || next_c == '_' {
env_var.push(next_c);
chars.next();
} else {
break;
}
}
if env_var.is_empty() {
expanded.push('$');
continue;
}
}
let value = std::env::var(&env_var).map_err(|_| ConfigError::UnsetEnv(env_var))?;
expanded.push_str(&value);
}
Ok(PathBuf::from(expanded))
}
pub fn config_path() -> PathBuf {
if let Ok(path) = std::env::var("WALLR_CONFIG") {
return PathBuf::from(path);
}
if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
return PathBuf::from(config_home).join("wallr/config.yaml");
}
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(".config/wallr/config.yaml");
}
PathBuf::from("/tmp/wallr/config.yaml")
}
pub fn parse_duration(s: &str) -> Result<std::time::Duration, ConfigError> {
let s = s.trim();
if let Some(ms) = s.strip_suffix("ms") {
let val: u64 = ms
.parse()
.map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
Ok(std::time::Duration::from_millis(val))
} else if let Some(sec) = s.strip_suffix('s') {
let val: f64 = sec
.parse()
.map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
if !val.is_finite() || val < 0.0 {
return Err(ConfigError::InvalidDuration(s.to_string()));
}
std::time::Duration::try_from_secs_f64(val)
.map_err(|_| ConfigError::InvalidDuration(s.to_string()))
} else if let Ok(val) = s.parse::<u64>() {
Ok(std::time::Duration::from_millis(val))
} else {
Err(ConfigError::InvalidDuration(s.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let cfg = WallrConfig::default();
assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill);
assert!(cfg.matugen.enabled);
assert!(cfg.daemon.auto_start);
assert_eq!(cfg.daemon.socket, "$XDG_RUNTIME_DIR/wallr.sock");
assert_eq!(cfg.theme.provider, ThemeProvider::Matugen);
}
#[test]
fn partial_daemon_section_matches_programmatic_defaults() {
let cfg: WallrConfig = serde_yaml::from_str("daemon: {max_fps: 30}\n").unwrap();
assert!(cfg.daemon.auto_start);
assert_eq!(cfg.daemon.socket, "$XDG_RUNTIME_DIR/wallr.sock");
assert_eq!(cfg.daemon.max_fps, Some(30));
}
#[test]
fn partial_theme_section_keeps_matugen_default() {
let cfg: WallrConfig = serde_yaml::from_str("theme: {}\n").unwrap();
assert_eq!(cfg.theme.provider, ThemeProvider::Matugen);
}
#[test]
fn test_load_config_missing_file() {
let path = Path::new("/nonexistent/wallr-test-config.yaml");
assert!(load_config(Some(path)).is_err());
}
#[test]
fn test_parse_duration_ms() {
let dur = parse_duration("500ms").unwrap();
assert_eq!(dur.as_millis(), 500);
}
#[test]
fn test_parse_duration_s() {
assert_eq!(parse_duration("2s").unwrap().as_secs(), 2);
assert_eq!(parse_duration("1.5s").unwrap().as_millis(), 1500);
}
#[test]
fn parse_duration_rejects_extreme_and_invalid_values_without_panicking() {
for value in ["1e300s", "inf", "NaN", "-1s", "18446744073709551616ms"] {
assert!(parse_duration(value).is_err(), "{value} should be rejected");
}
}
#[test]
fn test_expand_path_tilde() {
let expanded = expand_path("~/test.jpg").unwrap();
assert!(!expanded.to_string_lossy().starts_with('~'));
}
#[test]
fn test_expand_path_env() {
let expanded = expand_path("/tmp/$PATH/file.png").unwrap();
assert!(
expanded
.to_string_lossy()
.contains(std::env::var("PATH").unwrap().as_str())
);
let braced = expand_path("/tmp/${PATH}/file.png").unwrap();
assert_eq!(expanded, braced);
}
#[test]
fn expand_path_reports_unset_and_malformed_references() {
let missing = format!("${}", "WALLR_TEST_VAR_THAT_IS_NOT_SET");
assert!(matches!(
expand_path(&missing),
Err(ConfigError::UnsetEnv(_))
));
assert!(expand_path("/tmp/${/x").is_err());
assert!(expand_path("/tmp/${}").is_err());
assert_eq!(expand_path("/tmp/a$").unwrap(), PathBuf::from("/tmp/a$"));
}
#[test]
fn test_roundtrip_serialize() {
let cfg = WallrConfig::default();
let yaml = serde_yaml::to_string(&cfg).unwrap();
let parsed: WallrConfig = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed.wallpaper.mode, cfg.wallpaper.mode);
assert_eq!(parsed.daemon.socket, cfg.daemon.socket);
assert_eq!(parsed.theme.provider, cfg.theme.provider);
}
}