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