Skip to main content

entracte_lib/
config.rs

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
34// Map legacy global `sound_theme` to per-kind `micro_sound` + `long_sound`.
35// Only fires when neither per-kind field is present, so user-set new values are never clobbered.
36// Also strips obsolete fields so they don't leak into the deserialized Settings via flatten.
37fn 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        // Users on the prior WIP build had `sound_mode` + `sound_end_chime`.
480        // Migration discards both — Settings no longer carries those fields,
481        // and the new per-kind config takes the legacy theme's defaults instead.
482        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}