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