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}
205
206impl Default for DaemonConfig {
207    fn default() -> Self {
208        Self {
209            auto_start: true,
210            socket: "$XDG_RUNTIME_DIR/wallr.sock".to_string(),
211        }
212    }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct WatchConfig {
217    #[serde(default)]
218    pub enabled: bool,
219    #[serde(default)]
220    pub dir: Option<String>,
221    #[serde(default = "default_debounce")]
222    pub debounce: String,
223}
224
225impl Default for WatchConfig {
226    fn default() -> Self {
227        Self {
228            enabled: false,
229            dir: None,
230            debounce: "500ms".to_string(),
231        }
232    }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct CacheConfig {
237    #[serde(default = "default_cache_dir")]
238    pub dir: String,
239    #[serde(default = "default_max_size")]
240    pub max_size: String,
241}
242
243impl Default for CacheConfig {
244    fn default() -> Self {
245        Self {
246            dir: "~/.cache/wallr".to_string(),
247            max_size: "512MB".to_string(),
248        }
249    }
250}
251
252fn default_true() -> bool {
253    true
254}
255fn default_mode() -> String {
256    "dark".to_string()
257}
258fn default_scheme() -> String {
259    "scheme-tonal-spot".to_string()
260}
261fn default_duration() -> String {
262    "2000ms".to_string()
263}
264fn default_socket() -> String {
265    "/tmp/wallr.sock".to_string()
266}
267fn default_debounce() -> String {
268    "500ms".to_string()
269}
270fn default_cache_dir() -> String {
271    "~/.cache/wallr".to_string()
272}
273fn default_max_size() -> String {
274    "512MB".to_string()
275}
276
277#[derive(Debug, thiserror::Error)]
278pub enum ConfigError {
279    #[error("failed to read config file: {0}")]
280    ReadError(#[from] std::io::Error),
281    #[error("failed to parse config: {0}")]
282    ParseError(#[from] serde_yaml::Error),
283    #[error("invalid duration format: {0}")]
284    InvalidDuration(String),
285    #[error("invalid size format: {0}")]
286    InvalidSize(String),
287    #[error("invalid config value: {0}")]
288    InvalidValue(String),
289}
290
291pub fn load_config(path: Option<&Path>) -> Result<WallrConfig, ConfigError> {
292    let p = path.map(|p| p.to_path_buf()).unwrap_or_else(config_path);
293    if !p.exists() {
294        return Ok(WallrConfig::default());
295    }
296    let content = std::fs::read_to_string(p)?;
297    let config: WallrConfig = serde_yaml::from_str(&content)?;
298    Ok(config)
299}
300
301pub fn expand_path(path: &str) -> PathBuf {
302    let mut path_str = path.to_string();
303
304    if (path_str.starts_with("~/") || path_str == "~")
305        && let Ok(home) = std::env::var("HOME")
306    {
307        if path_str == "~" {
308            path_str = home;
309        } else {
310            path_str = path_str.replacen("~", &home, 1);
311        }
312    }
313
314    let mut expanded = String::new();
315    let mut chars = path_str.chars().peekable();
316
317    while let Some(c) = chars.next() {
318        if c == '$' {
319            let mut env_var = String::new();
320            while let Some(&next_c) = chars.peek() {
321                if next_c.is_alphanumeric() || next_c == '_' {
322                    env_var.push(next_c);
323                    chars.next();
324                } else {
325                    break;
326                }
327            }
328            if let Ok(val) = std::env::var(&env_var) {
329                expanded.push_str(&val);
330            }
331        } else {
332            expanded.push(c);
333        }
334    }
335
336    PathBuf::from(expanded)
337}
338
339pub fn config_path() -> PathBuf {
340    if let Ok(path) = std::env::var("WALLR_CONFIG") {
341        return PathBuf::from(path);
342    }
343
344    if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
345        return PathBuf::from(config_home).join("wallr/config.yaml");
346    }
347
348    if let Ok(home) = std::env::var("HOME") {
349        return PathBuf::from(home).join(".config/wallr/config.yaml");
350    }
351
352    PathBuf::from("/tmp/wallr/config.yaml")
353}
354
355pub fn parse_duration(s: &str) -> Result<std::time::Duration, ConfigError> {
356    let s = s.trim();
357    if let Some(ms) = s.strip_suffix("ms") {
358        let val: u64 = ms
359            .parse()
360            .map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
361        Ok(std::time::Duration::from_millis(val))
362    } else if let Some(sec) = s.strip_suffix('s') {
363        let val: f64 = sec
364            .parse()
365            .map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
366        if !val.is_finite() || val < 0.0 {
367            return Err(ConfigError::InvalidDuration(s.to_string()));
368        }
369        Ok(std::time::Duration::from_secs_f64(val))
370    } else if let Ok(val) = s.parse::<u64>() {
371        Ok(std::time::Duration::from_millis(val))
372    } else {
373        Err(ConfigError::InvalidDuration(s.to_string()))
374    }
375}
376
377pub fn parse_size(s: &str) -> Result<u64, ConfigError> {
378    let s = s.trim().to_uppercase();
379    if let Some(num) = s.strip_suffix("GB") {
380        let val: u64 = num
381            .parse()
382            .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
383        Ok(val * 1024 * 1024 * 1024)
384    } else if let Some(num) = s.strip_suffix("MB") {
385        let val: u64 = num
386            .parse()
387            .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
388        Ok(val * 1024 * 1024)
389    } else if let Some(num) = s.strip_suffix("KB") {
390        let val: u64 = num
391            .parse()
392            .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
393        Ok(val * 1024)
394    } else if let Some(num) = s.strip_suffix('B') {
395        let val: u64 = num
396            .parse()
397            .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
398        Ok(val)
399    } else if let Ok(val) = s.parse::<u64>() {
400        Ok(val)
401    } else {
402        Err(ConfigError::InvalidSize(s.to_string()))
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn test_default_config() {
412        let cfg = WallrConfig::default();
413        assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill);
414        assert_eq!(cfg.animation.duration, "2000ms");
415        assert!(cfg.matugen.enabled);
416    }
417
418    #[test]
419    fn test_load_config_missing_file() {
420        let path = Path::new("/nonexistent/config.yaml");
421        let cfg = load_config(Some(path)).unwrap();
422        assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill);
423    }
424
425    #[test]
426    fn test_parse_duration_ms() {
427        let dur = parse_duration("500ms").unwrap();
428        assert_eq!(dur.as_millis(), 500);
429    }
430
431    #[test]
432    fn test_parse_duration_s() {
433        let dur = parse_duration("2s").unwrap();
434        assert_eq!(dur.as_secs(), 2);
435    }
436
437    #[test]
438    fn test_parse_size_mb() {
439        let bytes = parse_size("512MB").unwrap();
440        assert_eq!(bytes, 512 * 1024 * 1024);
441    }
442
443    #[test]
444    fn test_parse_size_gb() {
445        let bytes = parse_size("2GB").unwrap();
446        assert_eq!(bytes, 2 * 1024 * 1024 * 1024);
447    }
448
449    #[test]
450    fn test_expand_path_tilde() {
451        let expanded = expand_path("~/test.jpg");
452        assert!(!expanded.to_string_lossy().starts_with('~'));
453    }
454
455    #[test]
456    fn test_expand_path_env() {
457        unsafe {
458            std::env::set_var("TEST_VAR", "my_folder");
459        }
460        let expanded = expand_path("/tmp/$TEST_VAR/file.png");
461        assert!(expanded.to_string_lossy().contains("my_folder"));
462    }
463
464    #[test]
465    fn test_roundtrip_serialize() {
466        let cfg = WallrConfig::default();
467        let yaml = serde_yaml::to_string(&cfg).unwrap();
468        let parsed: WallrConfig = serde_yaml::from_str(&yaml).unwrap();
469        assert_eq!(parsed.animation.duration, cfg.animation.duration);
470    }
471}