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
211fn 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
223pub 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 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 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}