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
196fn 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}