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}