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)]
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)]
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_socket() -> String {
171    "/tmp/wallr.sock".to_string()
172}
173
174#[derive(Debug, thiserror::Error)]
175pub enum ConfigError {
176    #[error("failed to read config file: {0}")]
177    ReadError(#[from] std::io::Error),
178    #[error("failed to parse config: {0}")]
179    ParseError(#[from] serde_yaml::Error),
180    #[error("invalid duration format: {0}")]
181    InvalidDuration(String),
182    #[error("invalid config value: {0}")]
183    InvalidValue(String),
184}
185
186pub fn load_config(path: Option<&Path>) -> Result<WallrConfig, ConfigError> {
187    let p = path.map(|p| p.to_path_buf()).unwrap_or_else(config_path);
188    if !p.exists() {
189        return Ok(apply_environment_overrides(WallrConfig::default()));
190    }
191    let content = std::fs::read_to_string(p)?;
192    let config: WallrConfig = serde_yaml::from_str(&content)?;
193    Ok(apply_environment_overrides(config))
194}
195
196/// Applies process-scoped overrides used by launchers and isolated benchmarks.
197/// Keeping this outside YAML lets a test daemon use a private IPC socket
198/// without modifying the user's persistent configuration.
199fn apply_environment_overrides(mut config: WallrConfig) -> WallrConfig {
200    if let Ok(socket) = std::env::var("WALLR_SOCKET")
201        && !socket.is_empty()
202    {
203        config.daemon.socket = socket;
204    }
205    config
206}
207
208pub fn expand_path(path: &str) -> PathBuf {
209    let mut path_str = path.to_string();
210
211    if (path_str.starts_with("~/") || path_str == "~")
212        && let Ok(home) = std::env::var("HOME")
213    {
214        if path_str == "~" {
215            path_str = home;
216        } else {
217            path_str = path_str.replacen("~", &home, 1);
218        }
219    }
220
221    let mut expanded = String::new();
222    let mut chars = path_str.chars().peekable();
223
224    while let Some(c) = chars.next() {
225        if c == '$' {
226            let mut env_var = String::new();
227            while let Some(&next_c) = chars.peek() {
228                if next_c.is_alphanumeric() || next_c == '_' {
229                    env_var.push(next_c);
230                    chars.next();
231                } else {
232                    break;
233                }
234            }
235            if let Ok(val) = std::env::var(&env_var) {
236                expanded.push_str(&val);
237            }
238        } else {
239            expanded.push(c);
240        }
241    }
242
243    PathBuf::from(expanded)
244}
245
246pub fn config_path() -> PathBuf {
247    if let Ok(path) = std::env::var("WALLR_CONFIG") {
248        return PathBuf::from(path);
249    }
250
251    if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
252        return PathBuf::from(config_home).join("wallr/config.yaml");
253    }
254
255    if let Ok(home) = std::env::var("HOME") {
256        return PathBuf::from(home).join(".config/wallr/config.yaml");
257    }
258
259    PathBuf::from("/tmp/wallr/config.yaml")
260}
261
262pub fn parse_duration(s: &str) -> Result<std::time::Duration, ConfigError> {
263    let s = s.trim();
264    if let Some(ms) = s.strip_suffix("ms") {
265        let val: u64 = ms
266            .parse()
267            .map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
268        Ok(std::time::Duration::from_millis(val))
269    } else if let Some(sec) = s.strip_suffix('s') {
270        let val: f64 = sec
271            .parse()
272            .map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
273        if !val.is_finite() || val < 0.0 {
274            return Err(ConfigError::InvalidDuration(s.to_string()));
275        }
276        Ok(std::time::Duration::from_secs_f64(val))
277    } else if let Ok(val) = s.parse::<u64>() {
278        Ok(std::time::Duration::from_millis(val))
279    } else {
280        Err(ConfigError::InvalidDuration(s.to_string()))
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_default_config() {
290        let cfg = WallrConfig::default();
291        assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill);
292        assert!(cfg.matugen.enabled);
293    }
294
295    #[test]
296    fn test_load_config_missing_file() {
297        let path = Path::new("/nonexistent/config.yaml");
298        let cfg = load_config(Some(path)).unwrap();
299        assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill);
300    }
301
302    #[test]
303    fn test_parse_duration_ms() {
304        let dur = parse_duration("500ms").unwrap();
305        assert_eq!(dur.as_millis(), 500);
306    }
307
308    #[test]
309    fn test_parse_duration_s() {
310        let dur = parse_duration("2s").unwrap();
311        assert_eq!(dur.as_secs(), 2);
312    }
313
314    #[test]
315    fn test_expand_path_tilde() {
316        let expanded = expand_path("~/test.jpg");
317        assert!(!expanded.to_string_lossy().starts_with('~'));
318    }
319
320    #[test]
321    fn test_expand_path_env() {
322        unsafe {
323            std::env::set_var("TEST_VAR", "my_folder");
324        }
325        let expanded = expand_path("/tmp/$TEST_VAR/file.png");
326        assert!(expanded.to_string_lossy().contains("my_folder"));
327    }
328
329    #[test]
330    fn test_roundtrip_serialize() {
331        let cfg = WallrConfig::default();
332        let yaml = serde_yaml::to_string(&cfg).unwrap();
333        let parsed: WallrConfig = serde_yaml::from_str(&yaml).unwrap();
334        assert_eq!(parsed.wallpaper.mode, cfg.wallpaper.mode);
335    }
336}