1use std::fs;
2use std::io;
3use std::path::Path;
4
5use log::warn;
6use serde::de::{self, Deserializer, MapAccess, Visitor};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::scheduler::Settings;
11use crate::secure_io::write_user_only;
12
13pub const DEFAULT_PROFILE_NAME: &str = "Default";
14
15pub fn migrate_legacy_settings(value: &mut Value) {
16 let Some(obj) = value.as_object_mut() else {
17 return;
18 };
19 if obj.contains_key("monitor_placement") {
20 obj.remove("cover_all_monitors");
21 } else if let Some(raw) = obj.remove("cover_all_monitors") {
22 let placement = match raw.as_bool() {
23 Some(true) => "all",
24 _ => "primary",
25 };
26 obj.insert(
27 "monitor_placement".to_string(),
28 Value::String(placement.to_string()),
29 );
30 }
31 migrate_sound_fields(obj);
32}
33
34fn migrate_sound_fields(obj: &mut serde_json::Map<String, Value>) {
38 if !obj.contains_key("micro_sound") && !obj.contains_key("long_sound") {
39 let theme = obj
40 .get("sound_theme")
41 .and_then(|v| v.as_str())
42 .unwrap_or_default();
43 let (mode, sound_id) = match theme {
44 "silence" => ("off", ""),
45 "soft_chime" => ("end_chime", "337048"),
46 "bright_bell" => ("end_chime", "398496"),
47 "wood_block" => ("end_chime", "445633"),
48 _ => ("end_chime", "337048"),
49 };
50 let value = serde_json::json!({ "mode": mode, "sound_id": sound_id });
51 obj.insert("micro_sound".to_string(), value.clone());
52 obj.insert("long_sound".to_string(), value);
53 }
54 obj.remove("sound_theme");
55 obj.remove("sound_mode");
56 obj.remove("sound_end_chime");
57}
58
59#[derive(Debug, Clone, Serialize)]
60pub struct Profile {
61 pub name: String,
62 pub settings: Settings,
63}
64
65impl<'de> Deserialize<'de> for Profile {
66 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
67 where
68 D: Deserializer<'de>,
69 {
70 #[derive(Deserialize)]
71 struct Raw {
72 name: String,
73 settings: Value,
74 }
75 let Raw { name, mut settings } = Raw::deserialize(deserializer)?;
76 migrate_legacy_settings(&mut settings);
77 let mut settings: Settings = serde_json::from_value(settings).map_err(de::Error::custom)?;
78 settings.clamp();
79 Ok(Profile { name, settings })
80 }
81}
82
83#[derive(Debug, Clone, Serialize)]
84pub struct ProfilesFile {
85 pub profiles: Vec<Profile>,
86 pub active: String,
87}
88
89impl Default for ProfilesFile {
90 fn default() -> Self {
91 Self::single(DEFAULT_PROFILE_NAME.to_string(), Settings::default())
92 }
93}
94
95impl ProfilesFile {
96 pub fn single(name: String, settings: Settings) -> Self {
97 Self {
98 profiles: vec![Profile {
99 name: name.clone(),
100 settings,
101 }],
102 active: name,
103 }
104 }
105
106 pub fn active_settings(&self) -> Settings {
107 self.profiles
108 .iter()
109 .find(|p| p.name == self.active)
110 .map(|p| p.settings.clone())
111 .or_else(|| self.profiles.first().map(|p| p.settings.clone()))
112 .unwrap_or_default()
113 }
114}
115
116impl<'de> Deserialize<'de> for ProfilesFile {
117 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
118 where
119 D: Deserializer<'de>,
120 {
121 struct PFVisitor;
122
123 impl<'de> Visitor<'de> for PFVisitor {
124 type Value = ProfilesFile;
125
126 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
127 f.write_str("a profiles file or a legacy settings object")
128 }
129
130 fn visit_map<M>(self, mut map: M) -> Result<ProfilesFile, M::Error>
131 where
132 M: MapAccess<'de>,
133 {
134 let mut raw: serde_json::Map<String, Value> = serde_json::Map::new();
135 while let Some(key) = map.next_key::<String>()? {
136 let val: Value = map.next_value()?;
137 raw.insert(key, val);
138 }
139 let raw_value = Value::Object(raw);
140 let has_profiles = raw_value.get("profiles").is_some();
141 if has_profiles {
142 let profiles: Vec<Profile> = serde_json::from_value(
143 raw_value.get("profiles").cloned().unwrap_or(Value::Null),
144 )
145 .map_err(de::Error::custom)?;
146 let active = raw_value
147 .get("active")
148 .and_then(|v| v.as_str())
149 .map(String::from)
150 .or_else(|| profiles.first().map(|p| p.name.clone()))
151 .unwrap_or_else(|| DEFAULT_PROFILE_NAME.to_string());
152 Ok(ProfilesFile { profiles, active })
153 } else {
154 let mut raw_value = raw_value;
155 migrate_legacy_settings(&mut raw_value);
156 let mut settings: Settings =
157 serde_json::from_value(raw_value).map_err(de::Error::custom)?;
158 settings.clamp();
159 Ok(ProfilesFile::single(
160 DEFAULT_PROFILE_NAME.to_string(),
161 settings,
162 ))
163 }
164 }
165 }
166
167 deserializer.deserialize_map(PFVisitor)
168 }
169}
170
171pub fn load(path: &Path) -> ProfilesFile {
172 match fs::read_to_string(path) {
173 Ok(text) => serde_json::from_str(&text).unwrap_or_else(|e| {
174 warn!(
175 "config: failed to parse {}: {e} — using defaults",
176 path.display()
177 );
178 ProfilesFile::default()
179 }),
180 Err(e) if e.kind() == io::ErrorKind::NotFound => ProfilesFile::default(),
181 Err(e) => {
182 warn!(
183 "config: failed to read {}: {e} — using defaults",
184 path.display()
185 );
186 ProfilesFile::default()
187 }
188 }
189}
190
191pub fn save(path: &Path, file: &ProfilesFile) -> io::Result<()> {
192 let body = serde_json::to_string_pretty(file).map_err(io::Error::other)?;
193 write_user_only(path, body.as_bytes())
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use crate::test_support::{temp_dir, TempDir};
200
201 fn temp_file() -> (TempDir, std::path::PathBuf) {
202 let dir = temp_dir();
203 let path = dir.path().join("settings.json");
204 (dir, path)
205 }
206
207 #[test]
208 fn load_missing_returns_default_profile() {
209 let dir = temp_dir();
210 let path = dir.path().join("does-not-exist.json");
211 let f = load(&path);
212 assert_eq!(f.profiles.len(), 1);
213 assert_eq!(f.active, DEFAULT_PROFILE_NAME);
214 assert_eq!(f.profiles[0].name, DEFAULT_PROFILE_NAME);
215 let d = Settings::default();
216 assert_eq!(
217 f.profiles[0].settings.micro_interval_secs,
218 d.micro_interval_secs
219 );
220 }
221
222 #[test]
223 #[allow(clippy::field_reassign_with_default)]
224 fn save_and_load_round_trip_multiple_profiles() {
225 let (_dir, path) = temp_file();
226 let mut work = Settings::default();
227 work.micro_interval_secs = 600;
228 work.overlay_color = "forest".to_string();
229 let mut home = Settings::default();
230 home.micro_interval_secs = 1800;
231 home.overlay_color = "rose".to_string();
232
233 let file = ProfilesFile {
234 profiles: vec![
235 Profile {
236 name: "Work".to_string(),
237 settings: work,
238 },
239 Profile {
240 name: "Home".to_string(),
241 settings: home,
242 },
243 ],
244 active: "Home".to_string(),
245 };
246 save(&path, &file).unwrap();
247 let loaded = load(&path);
248 assert_eq!(loaded.profiles.len(), 2);
249 assert_eq!(loaded.active, "Home");
250 assert_eq!(loaded.profiles[0].name, "Work");
251 assert_eq!(loaded.profiles[0].settings.micro_interval_secs, 600);
252 assert_eq!(loaded.profiles[1].name, "Home");
253 assert_eq!(loaded.profiles[1].settings.overlay_color, "rose");
254 }
255
256 #[test]
257 fn load_legacy_flat_settings_wraps_into_default_profile() {
258 let (_dir, path) = temp_file();
259 fs::write(
260 &path,
261 r#"{"micro_interval_secs": 99, "overlay_color": "rose"}"#,
262 )
263 .unwrap();
264 let loaded = load(&path);
265 assert_eq!(loaded.profiles.len(), 1);
266 assert_eq!(loaded.active, DEFAULT_PROFILE_NAME);
267 assert_eq!(loaded.profiles[0].name, DEFAULT_PROFILE_NAME);
268 assert_eq!(loaded.profiles[0].settings.micro_interval_secs, 99);
269 assert_eq!(loaded.profiles[0].settings.overlay_color, "rose");
270 let d = Settings::default();
271 assert_eq!(
272 loaded.profiles[0].settings.long_interval_secs,
273 d.long_interval_secs
274 );
275 }
276
277 #[test]
278 fn load_legacy_then_save_persists_wrapped_shape() {
279 let (_dir, path) = temp_file();
280 fs::write(
281 &path,
282 r#"{"micro_interval_secs": 77, "overlay_color": "midnight"}"#,
283 )
284 .unwrap();
285 let loaded = load(&path);
286 save(&path, &loaded).unwrap();
287 let text = fs::read_to_string(&path).unwrap();
288 assert!(text.contains("\"profiles\""));
289 assert!(text.contains("\"active\""));
290 let reloaded = load(&path);
291 assert_eq!(reloaded.profiles.len(), 1);
292 assert_eq!(reloaded.profiles[0].settings.micro_interval_secs, 77);
293 }
294
295 #[test]
296 fn load_corrupt_returns_default() {
297 let (_dir, path) = temp_file();
298 fs::write(&path, "{not valid json").unwrap();
299 let loaded = load(&path);
300 assert_eq!(loaded.profiles.len(), 1);
301 assert_eq!(loaded.active, DEFAULT_PROFILE_NAME);
302 }
303
304 #[test]
305 fn save_creates_parent_dirs() {
306 let dir = temp_dir();
307 let path = dir.path().join("a").join("b").join("settings.json");
308 save(&path, &ProfilesFile::default()).unwrap();
309 assert!(path.exists());
310 }
311
312 #[test]
313 fn migrate_legacy_settings_true_becomes_all() {
314 let mut v: Value = serde_json::from_str(r#"{"cover_all_monitors": true}"#).unwrap();
315 migrate_legacy_settings(&mut v);
316 assert_eq!(
317 v.get("monitor_placement").and_then(|x| x.as_str()),
318 Some("all")
319 );
320 assert!(v.get("cover_all_monitors").is_none());
321 }
322
323 #[test]
324 fn migrate_legacy_settings_false_becomes_primary() {
325 let mut v: Value = serde_json::from_str(r#"{"cover_all_monitors": false}"#).unwrap();
326 migrate_legacy_settings(&mut v);
327 assert_eq!(
328 v.get("monitor_placement").and_then(|x| x.as_str()),
329 Some("primary")
330 );
331 assert!(v.get("cover_all_monitors").is_none());
332 }
333
334 #[test]
335 fn migrate_legacy_settings_preserves_existing_placement() {
336 let mut v: Value =
337 serde_json::from_str(r#"{"cover_all_monitors": true, "monitor_placement": "active"}"#)
338 .unwrap();
339 migrate_legacy_settings(&mut v);
340 assert_eq!(
341 v.get("monitor_placement").and_then(|x| x.as_str()),
342 Some("active")
343 );
344 assert!(v.get("cover_all_monitors").is_none());
345 }
346
347 #[test]
348 fn migrate_legacy_settings_no_op_when_neither_present() {
349 let mut v: Value = serde_json::from_str(r#"{"micro_interval_secs": 60}"#).unwrap();
350 migrate_legacy_settings(&mut v);
351 assert!(v.get("monitor_placement").is_none());
352 }
353
354 #[test]
355 fn load_legacy_cover_all_monitors_true_migrates_to_all() {
356 let (_dir, path) = temp_file();
357 fs::write(&path, r#"{"cover_all_monitors": true}"#).unwrap();
358 let loaded = load(&path);
359 assert_eq!(loaded.profiles.len(), 1);
360 assert!(matches!(
361 loaded.profiles[0].settings.monitor_placement,
362 crate::scheduler::MonitorPlacement::All
363 ));
364 }
365
366 #[test]
367 fn load_legacy_cover_all_monitors_false_migrates_to_primary() {
368 let (_dir, path) = temp_file();
369 fs::write(&path, r#"{"cover_all_monitors": false}"#).unwrap();
370 let loaded = load(&path);
371 assert!(matches!(
372 loaded.profiles[0].settings.monitor_placement,
373 crate::scheduler::MonitorPlacement::Primary
374 ));
375 }
376
377 #[test]
378 fn load_profiles_with_legacy_cover_all_monitors_migrates() {
379 let (_dir, path) = temp_file();
380 fs::write(
381 &path,
382 r#"{"profiles":[{"name":"Work","settings":{"cover_all_monitors":true}},{"name":"Home","settings":{"cover_all_monitors":false}}],"active":"Work"}"#,
383 )
384 .unwrap();
385 let loaded = load(&path);
386 assert!(matches!(
387 loaded.profiles[0].settings.monitor_placement,
388 crate::scheduler::MonitorPlacement::All
389 ));
390 assert!(matches!(
391 loaded.profiles[1].settings.monitor_placement,
392 crate::scheduler::MonitorPlacement::Primary
393 ));
394 }
395
396 #[test]
397 fn legacy_sound_theme_migrates_to_per_kind_break_sound() {
398 let cases = [
399 ("silence", "off", ""),
400 ("soft_chime", "end_chime", "337048"),
401 ("bright_bell", "end_chime", "398496"),
402 ("wood_block", "end_chime", "445633"),
403 ("garbage_value", "end_chime", "337048"),
404 ];
405 for (theme, want_mode, want_id) in cases {
406 let (_dir, path) = temp_file();
407 fs::write(
408 &path,
409 format!(r#"{{"sound_theme": "{theme}", "sound_volume": 0.4}}"#),
410 )
411 .unwrap();
412 let loaded = load(&path);
413 let s = &loaded.profiles[0].settings;
414 assert_eq!(
415 serde_json::to_string(&s.micro_sound.mode).unwrap(),
416 format!("\"{want_mode}\""),
417 "micro mode mismatch for theme {theme}"
418 );
419 assert_eq!(
420 s.micro_sound.sound_id, want_id,
421 "micro id mismatch for theme {theme}"
422 );
423 assert_eq!(
424 s.long_sound.mode, s.micro_sound.mode,
425 "long should match micro for theme {theme}"
426 );
427 assert_eq!(s.long_sound.sound_id, want_id);
428 }
429 }
430
431 #[test]
432 fn explicit_per_kind_sound_is_not_overwritten_by_migration() {
433 let (_dir, path) = temp_file();
434 fs::write(
435 &path,
436 r#"{
437 "sound_theme": "soft_chime",
438 "micro_sound": {"mode": "ambient", "sound_id": "851196"}
439 }"#,
440 )
441 .unwrap();
442 let loaded = load(&path);
443 let s = &loaded.profiles[0].settings;
444 assert_eq!(
445 serde_json::to_string(&s.micro_sound.mode).unwrap(),
446 "\"ambient\"",
447 "explicit micro_sound must win over legacy theme"
448 );
449 assert_eq!(s.micro_sound.sound_id, "851196");
450 }
451
452 #[test]
453 fn legacy_sound_theme_migrates_inside_profiles_file() {
454 let (_dir, path) = temp_file();
455 fs::write(
456 &path,
457 r#"{
458 "profiles": [
459 {"name": "A", "settings": {"sound_theme": "bright_bell"}},
460 {"name": "B", "settings": {"sound_theme": "silence"}}
461 ],
462 "active": "B"
463 }"#,
464 )
465 .unwrap();
466 let loaded = load(&path);
467 assert_eq!(loaded.profiles.len(), 2);
468 assert_eq!(loaded.profiles[0].settings.micro_sound.sound_id, "398496");
469 assert_eq!(loaded.profiles[0].settings.long_sound.sound_id, "398496");
470 assert_eq!(
471 serde_json::to_string(&loaded.profiles[1].settings.micro_sound.mode).unwrap(),
472 "\"off\""
473 );
474 assert_eq!(loaded.profiles[1].settings.micro_sound.sound_id, "");
475 }
476
477 #[test]
478 fn legacy_sound_mode_and_end_chime_are_stripped() {
479 let (_dir, path) = temp_file();
483 fs::write(
484 &path,
485 r#"{"sound_mode": "end_chime", "sound_end_chime": ["398496"], "sound_theme": "bright_bell"}"#,
486 )
487 .unwrap();
488 let loaded = load(&path);
489 let s = &loaded.profiles[0].settings;
490 assert_eq!(s.micro_sound.sound_id, "398496");
491 assert_eq!(s.long_sound.sound_id, "398496");
492 }
493
494 #[test]
495 fn active_settings_falls_back_to_first_when_missing() {
496 let file = ProfilesFile {
497 profiles: vec![Profile {
498 name: "Only".to_string(),
499 settings: Settings::default(),
500 }],
501 active: "Missing".to_string(),
502 };
503 let s = file.active_settings();
504 assert_eq!(
505 s.micro_interval_secs,
506 Settings::default().micro_interval_secs
507 );
508 }
509}