Skip to main content

wallr_core/config/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, Serialize, Deserialize, Default)]
5pub struct WallrConfig {
6    #[serde(default)]
7    pub wallpaper: WallpaperConfig,
8    #[serde(default)]
9    pub animation: AnimationConfig,
10    #[serde(default)]
11    pub theme: ThemeConfig,
12    #[serde(default)]
13    pub matugen: MatugenConfig,
14    #[serde(default)]
15    pub hooks: HooksConfig,
16    #[serde(default)]
17    pub reload: Vec<String>,
18    #[serde(default)]
19    pub daemon: DaemonConfig,
20    #[serde(default)]
21    pub watch: WatchConfig,
22    #[serde(default)]
23    pub cache: CacheConfig,
24    #[serde(default)]
25    pub plugins: PluginsConfig,
26    #[serde(default)]
27    pub video: VideoConfig,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct PluginsConfig {
32    #[serde(default)]
33    pub matugen: PluginConfig,
34    #[serde(default)]
35    pub pywal: PluginConfig,
36    #[serde(default)]
37    pub wallust: PluginConfig,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
41pub struct PluginConfig {
42    #[serde(default)]
43    pub enabled: bool,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct WallpaperConfig {
48    #[serde(default)]
49    pub default: Option<String>,
50    #[serde(default)]
51    pub mode: ScalingMode,
52    #[serde(default)]
53    pub monitors: Vec<MonitorConfig>,
54    #[serde(default = "default_true")]
55    pub loop_video: bool,
56    #[serde(default = "default_true")]
57    pub mute: bool,
58}
59
60impl Default for WallpaperConfig {
61    fn default() -> Self {
62        Self {
63            default: None,
64            mode: ScalingMode::Fill,
65            monitors: vec![],
66            loop_video: true,
67            mute: true,
68        }
69    }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct VideoConfig {
74    #[serde(default = "default_hw_decode")]
75    pub hw_decode: String,
76    #[serde(default)]
77    pub preferred_gpu: crate::video::GpuSelection,
78    #[serde(default = "default_preload_frames")]
79    pub preload_frames: usize,
80}
81
82impl Default for VideoConfig {
83    fn default() -> Self {
84        Self {
85            hw_decode: default_hw_decode(),
86            preferred_gpu: crate::video::GpuSelection::Auto,
87            preload_frames: default_preload_frames(),
88        }
89    }
90}
91
92fn default_hw_decode() -> String {
93    "auto".to_string()
94}
95
96fn default_preload_frames() -> usize {
97    2
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct MonitorConfig {
102    pub name: String,
103    #[serde(default)]
104    pub file: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, clap::ValueEnum, Default, PartialEq)]
108#[serde(rename_all = "snake_case")]
109pub enum ScalingMode {
110    #[default]
111    Fill,
112    Fit,
113    Stretch,
114    Center,
115    Tile,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct AnimationConfig {
120    #[serde(rename = "use", default)]
121    pub r#use: Option<String>,
122    #[serde(default = "default_duration")]
123    pub duration: String,
124}
125
126impl Default for AnimationConfig {
127    fn default() -> Self {
128        Self {
129            r#use: None,
130            duration: "2000ms".to_string(),
131        }
132    }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ThemeConfig {
137    #[serde(default)]
138    pub provider: ThemeProvider,
139}
140
141impl Default for ThemeConfig {
142    fn default() -> Self {
143        Self {
144            provider: ThemeProvider::Matugen,
145        }
146    }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, clap::ValueEnum, Default, PartialEq)]
150#[serde(rename_all = "snake_case")]
151pub enum ThemeProvider {
152    Matugen,
153    Wallust,
154    Pywal,
155    #[default]
156    None,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct MatugenConfig {
161    #[serde(default = "default_true")]
162    pub enabled: bool,
163    #[serde(default = "default_mode")]
164    pub mode: String,
165    #[serde(default = "default_scheme")]
166    pub scheme: String,
167    #[serde(default)]
168    pub contrast: i32,
169    #[serde(default)]
170    pub wait: bool,
171    #[serde(default)]
172    pub args: Vec<String>,
173}
174
175impl Default for MatugenConfig {
176    fn default() -> Self {
177        Self {
178            enabled: true,
179            mode: "dark".to_string(),
180            scheme: "scheme-tonal-spot".to_string(),
181            contrast: 0,
182            wait: true,
183            args: vec![],
184        }
185    }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, Default)]
189pub struct HooksConfig {
190    #[serde(default)]
191    pub before: Vec<String>,
192    #[serde(default)]
193    pub after: Vec<String>,
194    #[serde(default)]
195    pub error: Vec<String>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct DaemonConfig {
200    #[serde(default)]
201    pub auto_start: bool,
202    #[serde(default = "default_socket")]
203    pub socket: String,
204    #[serde(default = "default_max_fps")]
205    pub max_fps: Option<u32>,
206}
207
208impl Default for DaemonConfig {
209    fn default() -> Self {
210        Self {
211            auto_start: true,
212            socket: "$XDG_RUNTIME_DIR/wallr.sock".to_string(),
213            max_fps: Some(60),
214        }
215    }
216}
217
218fn default_max_fps() -> Option<u32> {
219    Some(60)
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct WatchConfig {
224    #[serde(default)]
225    pub enabled: bool,
226    #[serde(default)]
227    pub dir: Option<String>,
228    #[serde(default = "default_debounce")]
229    pub debounce: String,
230}
231
232impl Default for WatchConfig {
233    fn default() -> Self {
234        Self {
235            enabled: false,
236            dir: None,
237            debounce: "500ms".to_string(),
238        }
239    }
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct CacheConfig {
244    #[serde(default = "default_cache_dir")]
245    pub dir: String,
246    #[serde(default = "default_max_size")]
247    pub max_size: String,
248}
249
250impl Default for CacheConfig {
251    fn default() -> Self {
252        Self {
253            dir: "~/.cache/wallr".to_string(),
254            max_size: "512MB".to_string(),
255        }
256    }
257}
258
259fn default_true() -> bool {
260    true
261}
262fn default_mode() -> String {
263    "dark".to_string()
264}
265fn default_scheme() -> String {
266    "scheme-tonal-spot".to_string()
267}
268fn default_duration() -> String {
269    "2000ms".to_string()
270}
271fn default_socket() -> String {
272    "/tmp/wallr.sock".to_string()
273}
274fn default_debounce() -> String {
275    "500ms".to_string()
276}
277fn default_cache_dir() -> String {
278    "~/.cache/wallr".to_string()
279}
280fn default_max_size() -> String {
281    "512MB".to_string()
282}
283
284#[derive(Debug, thiserror::Error)]
285pub enum ConfigError {
286    #[error("failed to read config file: {0}")]
287    ReadError(#[from] std::io::Error),
288    #[error("failed to parse config: {0}")]
289    ParseError(#[from] serde_yaml::Error),
290    #[error("invalid duration format: {0}")]
291    InvalidDuration(String),
292    #[error("invalid size format: {0}")]
293    InvalidSize(String),
294    #[error("invalid config value: {0}")]
295    InvalidValue(String),
296}
297
298pub fn load_config(path: Option<&Path>) -> Result<WallrConfig, ConfigError> {
299    let p = path.map(|p| p.to_path_buf()).unwrap_or_else(config_path);
300    if !p.exists() {
301        return Ok(WallrConfig::default());
302    }
303    let content = std::fs::read_to_string(p)?;
304    let config: WallrConfig = serde_yaml::from_str(&content)?;
305    Ok(config)
306}
307
308pub fn expand_path(path: &str) -> PathBuf {
309    let mut path_str = path.to_string();
310
311    if (path_str.starts_with("~/") || path_str == "~")
312        && let Ok(home) = std::env::var("HOME")
313    {
314        if path_str == "~" {
315            path_str = home;
316        } else {
317            path_str = path_str.replacen("~", &home, 1);
318        }
319    }
320
321    let mut expanded = String::new();
322    let mut chars = path_str.chars().peekable();
323
324    while let Some(c) = chars.next() {
325        if c == '$' {
326            let mut env_var = String::new();
327            while let Some(&next_c) = chars.peek() {
328                if next_c.is_alphanumeric() || next_c == '_' {
329                    env_var.push(next_c);
330                    chars.next();
331                } else {
332                    break;
333                }
334            }
335            if let Ok(val) = std::env::var(&env_var) {
336                expanded.push_str(&val);
337            }
338        } else {
339            expanded.push(c);
340        }
341    }
342
343    PathBuf::from(expanded)
344}
345
346pub fn config_path() -> PathBuf {
347    if let Ok(path) = std::env::var("WALLR_CONFIG") {
348        return PathBuf::from(path);
349    }
350
351    if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
352        return PathBuf::from(config_home).join("wallr/config.yaml");
353    }
354
355    if let Ok(home) = std::env::var("HOME") {
356        return PathBuf::from(home).join(".config/wallr/config.yaml");
357    }
358
359    PathBuf::from("/tmp/wallr/config.yaml")
360}
361
362pub fn parse_duration(s: &str) -> Result<std::time::Duration, ConfigError> {
363    let s = s.trim();
364    if let Some(ms) = s.strip_suffix("ms") {
365        let val: u64 = ms
366            .parse()
367            .map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
368        Ok(std::time::Duration::from_millis(val))
369    } else if let Some(sec) = s.strip_suffix('s') {
370        let val: f64 = sec
371            .parse()
372            .map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
373        if !val.is_finite() || val < 0.0 {
374            return Err(ConfigError::InvalidDuration(s.to_string()));
375        }
376        Ok(std::time::Duration::from_secs_f64(val))
377    } else if let Ok(val) = s.parse::<u64>() {
378        Ok(std::time::Duration::from_millis(val))
379    } else {
380        Err(ConfigError::InvalidDuration(s.to_string()))
381    }
382}
383
384pub fn parse_size(s: &str) -> Result<u64, ConfigError> {
385    let s = s.trim().to_uppercase();
386    if let Some(num) = s.strip_suffix("GB") {
387        let val: u64 = num
388            .parse()
389            .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
390        Ok(val * 1024 * 1024 * 1024)
391    } else if let Some(num) = s.strip_suffix("MB") {
392        let val: u64 = num
393            .parse()
394            .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
395        Ok(val * 1024 * 1024)
396    } else if let Some(num) = s.strip_suffix("KB") {
397        let val: u64 = num
398            .parse()
399            .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
400        Ok(val * 1024)
401    } else if let Some(num) = s.strip_suffix('B') {
402        let val: u64 = num
403            .parse()
404            .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
405        Ok(val)
406    } else if let Ok(val) = s.parse::<u64>() {
407        Ok(val)
408    } else {
409        Err(ConfigError::InvalidSize(s.to_string()))
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn test_default_config() {
419        let cfg = WallrConfig::default();
420        assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill);
421        assert_eq!(cfg.animation.duration, "2000ms");
422        assert!(cfg.matugen.enabled);
423    }
424
425    #[test]
426    fn test_load_config_missing_file() {
427        let path = Path::new("/nonexistent/config.yaml");
428        let cfg = load_config(Some(path)).unwrap();
429        assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill);
430    }
431
432    #[test]
433    fn test_parse_duration_ms() {
434        let dur = parse_duration("500ms").unwrap();
435        assert_eq!(dur.as_millis(), 500);
436    }
437
438    #[test]
439    fn test_parse_duration_s() {
440        let dur = parse_duration("2s").unwrap();
441        assert_eq!(dur.as_secs(), 2);
442    }
443
444    #[test]
445    fn test_parse_size_mb() {
446        let bytes = parse_size("512MB").unwrap();
447        assert_eq!(bytes, 512 * 1024 * 1024);
448    }
449
450    #[test]
451    fn test_parse_size_gb() {
452        let bytes = parse_size("2GB").unwrap();
453        assert_eq!(bytes, 2 * 1024 * 1024 * 1024);
454    }
455
456    #[test]
457    fn test_expand_path_tilde() {
458        let expanded = expand_path("~/test.jpg");
459        assert!(!expanded.to_string_lossy().starts_with('~'));
460    }
461
462    #[test]
463    fn test_expand_path_env() {
464        unsafe {
465            std::env::set_var("TEST_VAR", "my_folder");
466        }
467        let expanded = expand_path("/tmp/$TEST_VAR/file.png");
468        assert!(expanded.to_string_lossy().contains("my_folder"));
469    }
470
471    #[test]
472    fn test_roundtrip_serialize() {
473        let cfg = WallrConfig::default();
474        let yaml = serde_yaml::to_string(&cfg).unwrap();
475        let parsed: WallrConfig = serde_yaml::from_str(&yaml).unwrap();
476        assert_eq!(parsed.animation.duration, cfg.animation.duration);
477    }
478}