Skip to main content

wallr_common/
config.rs

1use crate::types::{GpuSelection, ScalingMode, ThemeProvider};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Serialize, Deserialize, Default)]
6pub struct WallrConfig {
7    #[serde(default)]
8    pub wallpaper: WallpaperConfig,
9    #[serde(default)]
10    pub theme: ThemeConfig,
11    #[serde(default)]
12    pub matugen: MatugenConfig,
13    #[serde(default)]
14    pub hooks: HooksConfig,
15    #[serde(default)]
16    pub reload: Vec<String>,
17    #[serde(default)]
18    pub daemon: DaemonConfig,
19    #[serde(default)]
20    pub video: VideoConfig,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct WallpaperConfig {
25    #[serde(default)]
26    pub default: Option<String>,
27    #[serde(default)]
28    pub mode: ScalingMode,
29    #[serde(default)]
30    pub monitors: Vec<MonitorConfig>,
31    #[serde(default = "default_true")]
32    pub loop_video: bool,
33    #[serde(default = "default_true")]
34    pub mute: bool,
35}
36
37impl Default for WallpaperConfig {
38    fn default() -> Self {
39        Self {
40            default: None,
41            mode: ScalingMode::Fill,
42            monitors: vec![],
43            loop_video: true,
44            mute: true,
45        }
46    }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct VideoConfig {
51    #[serde(default = "default_hw_decode")]
52    pub hw_decode: String,
53    #[serde(default)]
54    pub preferred_gpu: GpuSelection,
55    #[serde(default = "default_preload_frames")]
56    pub preload_frames: usize,
57}
58
59impl Default for VideoConfig {
60    fn default() -> Self {
61        Self {
62            hw_decode: default_hw_decode(),
63            preferred_gpu: GpuSelection::Auto,
64            preload_frames: default_preload_frames(),
65        }
66    }
67}
68
69fn default_hw_decode() -> String {
70    "auto".to_string()
71}
72
73fn default_preload_frames() -> usize {
74    2
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct MonitorConfig {
79    pub name: String,
80    #[serde(default)]
81    pub file: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ThemeConfig {
86    #[serde(default = "default_theme_provider")]
87    pub provider: ThemeProvider,
88}
89
90impl Default for ThemeConfig {
91    fn default() -> Self {
92        Self {
93            provider: ThemeProvider::Matugen,
94        }
95    }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct MatugenConfig {
100    #[serde(default = "default_true")]
101    pub enabled: bool,
102    #[serde(default = "default_mode")]
103    pub mode: String,
104    #[serde(default = "default_scheme")]
105    pub scheme: String,
106    #[serde(default)]
107    pub contrast: i32,
108    #[serde(default)]
109    pub wait: bool,
110    #[serde(default)]
111    pub args: Vec<String>,
112}
113
114impl Default for MatugenConfig {
115    fn default() -> Self {
116        Self {
117            enabled: true,
118            mode: "dark".to_string(),
119            scheme: "scheme-tonal-spot".to_string(),
120            contrast: 0,
121            wait: true,
122            args: vec![],
123        }
124    }
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, Default)]
128pub struct HooksConfig {
129    #[serde(default)]
130    pub before: Vec<String>,
131    #[serde(default)]
132    pub after: Vec<String>,
133    #[serde(default)]
134    pub error: Vec<String>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct DaemonConfig {
139    #[serde(default = "default_true")]
140    pub auto_start: bool,
141    #[serde(default = "default_socket")]
142    pub socket: String,
143    #[serde(default = "default_max_fps")]
144    pub max_fps: Option<u32>,
145}
146
147impl Default for DaemonConfig {
148    fn default() -> Self {
149        Self {
150            auto_start: true,
151            socket: "$XDG_RUNTIME_DIR/wallr.sock".to_string(),
152            max_fps: Some(60),
153        }
154    }
155}
156
157fn default_max_fps() -> Option<u32> {
158    Some(60)
159}
160
161fn default_true() -> bool {
162    true
163}
164fn default_mode() -> String {
165    "dark".to_string()
166}
167fn default_scheme() -> String {
168    "scheme-tonal-spot".to_string()
169}
170fn default_theme_provider() -> ThemeProvider {
171    ThemeProvider::Matugen
172}
173
174fn default_socket() -> String {
175    "$XDG_RUNTIME_DIR/wallr.sock".to_string()
176}
177
178#[derive(Debug, thiserror::Error)]
179pub enum ConfigError {
180    #[error("failed to read config file: {0}")]
181    ReadError(#[from] std::io::Error),
182    #[error("failed to parse config: {0}")]
183    ParseError(#[from] serde_yaml::Error),
184    #[error("invalid duration format: {0}")]
185    InvalidDuration(String),
186    #[error("invalid config value: {0}")]
187    InvalidValue(String),
188    #[error("environment variable {0} is not set")]
189    UnsetEnv(String),
190}
191
192pub fn load_config(path: Option<&Path>) -> Result<WallrConfig, ConfigError> {
193    match path {
194        Some(path) => {
195            let content = std::fs::read_to_string(path)?;
196            let config: WallrConfig = serde_yaml::from_str(&content)?;
197            Ok(apply_environment_overrides(config))
198        }
199        None => {
200            let path = config_path();
201            if !path.exists() {
202                return Ok(apply_environment_overrides(WallrConfig::default()));
203            }
204            let content = std::fs::read_to_string(path)?;
205            let config: WallrConfig = serde_yaml::from_str(&content)?;
206            Ok(apply_environment_overrides(config))
207        }
208    }
209}
210
211/// Applies process-scoped overrides used by launchers and isolated benchmarks.
212/// Keeping this outside YAML lets a test daemon use a private IPC socket
213/// without modifying the user's persistent configuration.
214fn apply_environment_overrides(mut config: WallrConfig) -> WallrConfig {
215    if let Ok(socket) = std::env::var("WALLR_SOCKET")
216        && !socket.is_empty()
217    {
218        config.daemon.socket = socket;
219    }
220    config
221}
222
223/// Expands a leading `~`, `$VAR`, and `${VAR}` in a configured path.
224///
225/// Referencing a variable that is not set is an error instead of silently
226/// deleting the reference: dropping `$XDG_RUNTIME_DIR` from a socket path
227/// used to turn `/run/user/1000/wallr.sock` into `/wallr.sock`, which either
228/// failed with a confusing permission error or connected to the wrong path.
229pub fn expand_path(path: &str) -> Result<PathBuf, ConfigError> {
230    let mut path_str = path.to_string();
231
232    if path_str == "~" || path_str.starts_with("~/") {
233        let home = std::env::var("HOME").map_err(|_| ConfigError::UnsetEnv("HOME".into()))?;
234        path_str = if path_str == "~" {
235            home
236        } else {
237            path_str.replacen('~', &home, 1)
238        };
239    }
240
241    let mut expanded = String::new();
242    let mut chars = path_str.chars().peekable();
243
244    while let Some(c) = chars.next() {
245        if c != '$' {
246            expanded.push(c);
247            continue;
248        }
249
250        let mut env_var = String::new();
251        if chars.peek() == Some(&'{') {
252            chars.next();
253            let mut closed = false;
254            for next_c in chars.by_ref() {
255                if next_c == '}' {
256                    closed = true;
257                    break;
258                }
259                if !(next_c.is_ascii_alphanumeric() || next_c == '_') {
260                    return Err(ConfigError::InvalidValue(format!(
261                        "invalid environment variable reference near '${{{env_var}{next_c}'"
262                    )));
263                }
264                env_var.push(next_c);
265            }
266            if !closed || env_var.is_empty() {
267                return Err(ConfigError::InvalidValue(
268                    "unterminated or empty ${...} environment variable reference".into(),
269                ));
270            }
271        } else {
272            while let Some(&next_c) = chars.peek() {
273                if next_c.is_ascii_alphanumeric() || next_c == '_' {
274                    env_var.push(next_c);
275                    chars.next();
276                } else {
277                    break;
278                }
279            }
280            if env_var.is_empty() {
281                // A literal `$` (including `$$`) is not an environment reference.
282                expanded.push('$');
283                continue;
284            }
285        }
286
287        let value = std::env::var(&env_var).map_err(|_| ConfigError::UnsetEnv(env_var))?;
288        expanded.push_str(&value);
289    }
290
291    Ok(PathBuf::from(expanded))
292}
293
294pub fn config_path() -> PathBuf {
295    if let Ok(path) = std::env::var("WALLR_CONFIG") {
296        return PathBuf::from(path);
297    }
298
299    if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
300        return PathBuf::from(config_home).join("wallr/config.yaml");
301    }
302
303    if let Ok(home) = std::env::var("HOME") {
304        return PathBuf::from(home).join(".config/wallr/config.yaml");
305    }
306
307    PathBuf::from("/tmp/wallr/config.yaml")
308}
309
310pub fn parse_duration(s: &str) -> Result<std::time::Duration, ConfigError> {
311    let s = s.trim();
312    if let Some(ms) = s.strip_suffix("ms") {
313        let val: u64 = ms
314            .parse()
315            .map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
316        Ok(std::time::Duration::from_millis(val))
317    } else if let Some(sec) = s.strip_suffix('s') {
318        let val: f64 = sec
319            .parse()
320            .map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
321        if !val.is_finite() || val < 0.0 {
322            return Err(ConfigError::InvalidDuration(s.to_string()));
323        }
324        // `from_secs_f64` panics when the value exceeds `Duration::MAX`;
325        // malformed or hostile config must produce an error instead.
326        std::time::Duration::try_from_secs_f64(val)
327            .map_err(|_| ConfigError::InvalidDuration(s.to_string()))
328    } else if let Ok(val) = s.parse::<u64>() {
329        Ok(std::time::Duration::from_millis(val))
330    } else {
331        Err(ConfigError::InvalidDuration(s.to_string()))
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_default_config() {
341        let cfg = WallrConfig::default();
342        assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill);
343        assert!(cfg.matugen.enabled);
344        assert!(cfg.daemon.auto_start);
345        assert_eq!(cfg.daemon.socket, "$XDG_RUNTIME_DIR/wallr.sock");
346        assert_eq!(cfg.theme.provider, ThemeProvider::Matugen);
347    }
348
349    #[test]
350    fn partial_daemon_section_matches_programmatic_defaults() {
351        let cfg: WallrConfig = serde_yaml::from_str("daemon: {max_fps: 30}\n").unwrap();
352        assert!(cfg.daemon.auto_start);
353        assert_eq!(cfg.daemon.socket, "$XDG_RUNTIME_DIR/wallr.sock");
354        assert_eq!(cfg.daemon.max_fps, Some(30));
355    }
356
357    #[test]
358    fn partial_theme_section_keeps_matugen_default() {
359        let cfg: WallrConfig = serde_yaml::from_str("theme: {}\n").unwrap();
360        assert_eq!(cfg.theme.provider, ThemeProvider::Matugen);
361    }
362
363    #[test]
364    fn test_load_config_missing_file() {
365        let path = Path::new("/nonexistent/wallr-test-config.yaml");
366        assert!(load_config(Some(path)).is_err());
367    }
368
369    #[test]
370    fn test_parse_duration_ms() {
371        let dur = parse_duration("500ms").unwrap();
372        assert_eq!(dur.as_millis(), 500);
373    }
374
375    #[test]
376    fn test_parse_duration_s() {
377        assert_eq!(parse_duration("2s").unwrap().as_secs(), 2);
378        assert_eq!(parse_duration("1.5s").unwrap().as_millis(), 1500);
379    }
380
381    #[test]
382    fn parse_duration_rejects_extreme_and_invalid_values_without_panicking() {
383        for value in ["1e300s", "inf", "NaN", "-1s", "18446744073709551616ms"] {
384            assert!(parse_duration(value).is_err(), "{value} should be rejected");
385        }
386    }
387
388    #[test]
389    fn test_expand_path_tilde() {
390        let expanded = expand_path("~/test.jpg").unwrap();
391        assert!(!expanded.to_string_lossy().starts_with('~'));
392    }
393
394    #[test]
395    fn test_expand_path_env() {
396        let expanded = expand_path("/tmp/$PATH/file.png").unwrap();
397        assert!(
398            expanded
399                .to_string_lossy()
400                .contains(std::env::var("PATH").unwrap().as_str())
401        );
402        let braced = expand_path("/tmp/${PATH}/file.png").unwrap();
403        assert_eq!(expanded, braced);
404    }
405
406    #[test]
407    fn expand_path_reports_unset_and_malformed_references() {
408        let missing = format!("${}", "WALLR_TEST_VAR_THAT_IS_NOT_SET");
409        assert!(matches!(
410            expand_path(&missing),
411            Err(ConfigError::UnsetEnv(_))
412        ));
413        assert!(expand_path("/tmp/${/x").is_err());
414        assert!(expand_path("/tmp/${}").is_err());
415        assert_eq!(expand_path("/tmp/a$").unwrap(), PathBuf::from("/tmp/a$"));
416    }
417
418    #[test]
419    fn test_roundtrip_serialize() {
420        let cfg = WallrConfig::default();
421        let yaml = serde_yaml::to_string(&cfg).unwrap();
422        let parsed: WallrConfig = serde_yaml::from_str(&yaml).unwrap();
423        assert_eq!(parsed.wallpaper.mode, cfg.wallpaper.mode);
424        assert_eq!(parsed.daemon.socket, cfg.daemon.socket);
425        assert_eq!(parsed.theme.provider, cfg.theme.provider);
426    }
427}