Skip to main content

openlogi_core/
config.rs

1//! User configuration, persisted as TOML at the platform-standard config
2//! path.
3//!
4//! Per-device state (button bindings, …) lives under the
5//! [`Config::devices`] map, keyed by the HID++ identifier returned by
6//! [`DeviceModelInfo::config_key`](crate::device::DeviceModelInfo::config_key)
7//! — e.g. `"2b042"` for an MX Master 4. Schema migrations branch on
8//! [`Config::schema_version`].
9
10use std::{
11    collections::BTreeMap,
12    fs, io,
13    path::{Path, PathBuf},
14};
15
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19use crate::binding::{Action, ButtonId, GestureDirection};
20use crate::paths::{self, PathsError};
21
22/// The schema version the current build produces. Bumped on breaking layout
23/// changes; readers branch on the parsed value before consuming the rest of
24/// the file.
25pub const SCHEMA_VERSION: u32 = 1;
26
27/// Top-level config document.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Config {
30    pub schema_version: u32,
31    /// Non-device-scoped preferences (autostart, tray, language, …).
32    #[serde(default, skip_serializing_if = "AppSettings::is_default")]
33    pub app_settings: AppSettings,
34    /// HID++ `config_key` of the carousel-selected device, persisted so a
35    /// restart restores the last view rather than always landing on the
36    /// first paired device. `None` means "fall back to the first device".
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub selected_device: Option<String>,
39    #[serde(default)]
40    pub devices: BTreeMap<String, DeviceConfig>,
41}
42
43impl Default for Config {
44    fn default() -> Self {
45        Self {
46            schema_version: SCHEMA_VERSION,
47            app_settings: AppSettings::default(),
48            selected_device: None,
49            devices: BTreeMap::new(),
50        }
51    }
52}
53
54/// App-wide preferences not tied to any particular device.
55///
56/// All fields are `#[serde(default)]` so adding a new one is backward
57/// compatible — old config files just keep the default for the new field.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[allow(
60    clippy::struct_excessive_bools,
61    reason = "independent on/off user preferences, not a state machine"
62)]
63pub struct AppSettings {
64    /// When true, a macOS `LaunchAgent` plist at
65    /// `~/Library/LaunchAgents/org.openlogi.openlogi.plist` is installed
66    /// so the app starts on login (P2.2). The plist is reconciled with
67    /// this field on every startup; flipping the flag and relaunching is
68    /// enough to install / remove it.
69    #[serde(default)]
70    pub launch_at_login: bool,
71    /// Opt-in update check (P2.8). **Off by default** to honour the
72    /// README's "no telemetry, no auto-update poller" promise. When true,
73    /// the app makes exactly one `HEAD /repos/AprilNEA/OpenLogi/releases/
74    /// latest` request per launch and logs whether a newer version is
75    /// available — no automatic download.
76    #[serde(default)]
77    pub check_for_updates: bool,
78    /// True once the first-run "check for updates?" prompt has been answered
79    /// (either way), so it is never shown again. The prompt is how a
80    /// privacy-conscious default of `check_for_updates = false` still lets a
81    /// user opt in on first launch.
82    #[serde(default)]
83    pub update_prompt_seen: bool,
84    /// Whether OpenLogi shows a macOS menu-bar (status item) icon. `true`
85    /// (default) → it lives in the menu bar, dropping the Dock icon while no
86    /// window is open; `false` → it stays an ordinary Dock app with no status
87    /// item. macOS-only; ignored on other platforms.
88    #[serde(default = "default_true")]
89    pub show_in_menu_bar: bool,
90    /// UI language as a BCP-47-ish locale code matching the GUI's bundled
91    /// locales (`"en"`, `"ja"`, `"ru"`, `"zh-CN"`, `"zh-HK"`, `"zh-TW"`).
92    /// `None` means "follow the system locale", which the GUI resolves at
93    /// startup. Stored here so a user's explicit choice survives restarts
94    /// regardless of the OS setting.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub language: Option<String>,
97    /// Thumb-wheel responsiveness, on a [`MIN_THUMBWHEEL_SENSITIVITY`]–
98    /// [`MAX_THUMBWHEEL_SENSITIVITY`] scale. It scales both the speed of the
99    /// wheel's continuous horizontal scroll and how few rotation increments a
100    /// custom wheel action needs to fire. [`DEFAULT_THUMBWHEEL_SENSITIVITY`]
101    /// (the out-of-the-box value) means 1× scroll speed; the wheel is only
102    /// diverted from native scrolling once this leaves the default.
103    #[serde(default = "default_thumbwheel_sensitivity")]
104    pub thumbwheel_sensitivity: i32,
105}
106
107/// Out-of-the-box [`AppSettings::thumbwheel_sensitivity`]. At this value the
108/// wheel's horizontal scroll runs at 1× and the wheel is left to scroll
109/// natively (no HID++ diversion) unless a binding diverges from its default.
110pub const DEFAULT_THUMBWHEEL_SENSITIVITY: i32 = 14;
111/// Lowest selectable [`AppSettings::thumbwheel_sensitivity`].
112pub const MIN_THUMBWHEEL_SENSITIVITY: i32 = 1;
113/// Highest selectable [`AppSettings::thumbwheel_sensitivity`].
114pub const MAX_THUMBWHEEL_SENSITIVITY: i32 = 100;
115
116impl AppSettings {
117    /// `skip_serializing_if` helper: true when nothing diverges from the
118    /// default, so empty settings don't clutter `config.toml`.
119    #[must_use]
120    pub fn is_default(&self) -> bool {
121        self == &Self::default()
122    }
123}
124
125impl Default for AppSettings {
126    fn default() -> Self {
127        Self {
128            launch_at_login: false,
129            check_for_updates: false,
130            update_prompt_seen: false,
131            show_in_menu_bar: true,
132            language: None,
133            thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY,
134        }
135    }
136}
137
138/// serde default for [`AppSettings::show_in_menu_bar`]: `true`, so the menu-bar
139/// icon is on out of the box and configs predating the field keep that behavior.
140fn default_true() -> bool {
141    true
142}
143
144/// serde default for [`AppSettings::thumbwheel_sensitivity`]: keeps configs
145/// predating the field at the 1× default.
146const fn default_thumbwheel_sensitivity() -> i32 {
147    DEFAULT_THUMBWHEEL_SENSITIVITY
148}
149
150/// Per-device RGB lighting: a single static color, brightness, and on/off.
151/// Deliberately basic — per-key effects are a later addition.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct Lighting {
154    #[serde(default = "default_lighting_enabled")]
155    pub enabled: bool,
156    /// Static color as 6 hex digits `"RRGGBB"` (no leading `#`).
157    #[serde(default = "default_lighting_color")]
158    pub color: String,
159    /// Brightness percent, clamped to 0–100 on load.
160    #[serde(
161        default = "default_lighting_brightness",
162        deserialize_with = "deserialize_brightness"
163    )]
164    pub brightness: u8,
165}
166
167impl Default for Lighting {
168    fn default() -> Self {
169        Self {
170            enabled: default_lighting_enabled(),
171            color: default_lighting_color(),
172            brightness: default_lighting_brightness(),
173        }
174    }
175}
176
177fn default_lighting_enabled() -> bool {
178    true
179}
180
181fn default_lighting_color() -> String {
182    "ffffff".to_string()
183}
184
185fn default_lighting_brightness() -> u8 {
186    100
187}
188
189/// Clamp a deserialized brightness into the UI's `0..=100` range, so a
190/// hand-edited `config.toml` can't feed out-of-range values into the scaling
191/// math (which assumes `brightness <= 100`).
192fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
193where
194    D: serde::Deserializer<'de>,
195{
196    Ok(u8::deserialize(deserializer)?.min(100))
197}
198
199/// Settings scoped to a single physical device (keyed by HID++ model+ext).
200#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201pub struct DeviceConfig {
202    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
203    pub button_bindings: BTreeMap<ButtonId, Action>,
204    /// Per-application binding overlays (P1.4). Keyed by bundle identifier
205    /// (e.g. `"com.microsoft.VSCode"` on macOS). When the foreground app's
206    /// id matches a key here, those bindings take precedence; anything not
207    /// listed falls through to `button_bindings`.
208    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
209    pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
210    /// Sub-bindings for the gesture button: hold + swipe direction or a
211    /// plain click. Edited via the gesture picker; the legacy single
212    /// `button_bindings[GestureButton]` entry is ignored on devices that
213    /// have entries here. Hardware dispatch is a P1.5 follow-up.
214    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
215    pub gesture_bindings: BTreeMap<GestureDirection, Action>,
216    /// Ordered list of DPI presets cycled through by
217    /// [`Action::CycleDpiPresets`] and indexed by
218    /// [`Action::SetDpiPreset`]. Empty means "no presets configured" —
219    /// the cycle action becomes a no-op until the user adds at least one.
220    #[serde(default, skip_serializing_if = "Vec::is_empty")]
221    pub dpi_presets: Vec<u32>,
222    /// Per-device RGB lighting (static color + brightness + on/off). `None`
223    /// until the user changes it, so it stays out of `config.toml` otherwise.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub lighting: Option<Lighting>,
226}
227
228#[derive(Debug, Error)]
229pub enum ConfigError {
230    #[error("could not resolve config path")]
231    Path(#[from] PathsError),
232    #[error("could not read config at {path}")]
233    Read {
234        path: PathBuf,
235        #[source]
236        source: io::Error,
237    },
238    #[error("could not parse config at {path}")]
239    Parse {
240        path: PathBuf,
241        #[source]
242        source: toml::de::Error,
243    },
244    #[error("could not write config at {path}")]
245    Write {
246        path: PathBuf,
247        #[source]
248        source: io::Error,
249    },
250    #[error("could not serialize config")]
251    Serialize(#[from] toml::ser::Error),
252    #[error("config at {path} has unsupported schema_version {found}")]
253    UnsupportedSchemaVersion { path: PathBuf, found: u32 },
254}
255
256#[allow(
257    clippy::result_large_err,
258    reason = "Config I/O keeps rich parse/write context and is not a hot path"
259)]
260impl Config {
261    /// Loads the config from the default user path, returning
262    /// [`Config::default`] if the file does not exist yet.
263    pub fn load_or_default() -> Result<Self, ConfigError> {
264        Self::load_from_path(&paths::config_path()?)
265    }
266
267    /// Same as [`Self::load_or_default`] but reads from `path`. Used by tests
268    /// to avoid touching the real user config.
269    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
270        match fs::read_to_string(path) {
271            Ok(text) => {
272                let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse {
273                    path: path.to_path_buf(),
274                    source,
275                })?;
276                if config.schema_version != SCHEMA_VERSION {
277                    return Err(ConfigError::UnsupportedSchemaVersion {
278                        path: path.to_path_buf(),
279                        found: config.schema_version,
280                    });
281                }
282                Ok(config)
283            }
284            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
285            Err(source) => Err(ConfigError::Read {
286                path: path.to_path_buf(),
287                source,
288            }),
289        }
290    }
291
292    /// Writes the config atomically to the default user path: serialize to a
293    /// sibling temp file, then rename over the target. On Unix the temp file
294    /// is created with mode 0600.
295    pub fn save_atomic(&self) -> Result<(), ConfigError> {
296        self.save_to_path(&paths::config_path()?)
297    }
298
299    /// Same as [`Self::save_atomic`] but writes to `path`. Used by tests.
300    pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
301        if let Some(parent) = path.parent() {
302            fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
303                path: path.to_path_buf(),
304                source,
305            })?;
306        }
307        let body = toml::to_string_pretty(self)?;
308        write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
309            path: path.to_path_buf(),
310            source,
311        })
312    }
313
314    /// Returns the bindings stored for `device_key`, or an empty map if the
315    /// device has no committed bindings yet.
316    #[must_use]
317    pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Action> {
318        self.devices
319            .get(device_key)
320            .map(|d| d.button_bindings.clone())
321            .unwrap_or_default()
322    }
323
324    /// Records `action` as the binding for `button` on `device_key`,
325    /// creating the device entry if needed.
326    pub fn set_binding(&mut self, device_key: &str, button: ButtonId, action: Action) {
327        self.devices
328            .entry(device_key.to_string())
329            .or_default()
330            .button_bindings
331            .insert(button, action);
332    }
333
334    /// Returns the gesture sub-bindings stored for `device_key`, or an empty
335    /// map if none are set yet.
336    #[must_use]
337    pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
338        self.devices
339            .get(device_key)
340            .map(|d| d.gesture_bindings.clone())
341            .unwrap_or_default()
342    }
343
344    /// Records `action` for `direction` of `device_key`'s gesture button.
345    pub fn set_gesture_binding(
346        &mut self,
347        device_key: &str,
348        direction: GestureDirection,
349        action: Action,
350    ) {
351        self.devices
352            .entry(device_key.to_string())
353            .or_default()
354            .gesture_bindings
355            .insert(direction, action);
356    }
357
358    /// Resolve the effective binding map for `device_key`, overlaying the
359    /// per-app entry for `bundle_id` (if any) on top of the global per-device
360    /// `button_bindings`. Per-app values win; everything else falls through.
361    ///
362    /// Returns an empty map when the device has no recorded bindings yet.
363    /// Callers (the GUI / hook) layer their own defaults on top.
364    #[must_use]
365    pub fn effective_bindings(
366        &self,
367        device_key: &str,
368        bundle_id: Option<&str>,
369    ) -> BTreeMap<ButtonId, Action> {
370        let Some(device) = self.devices.get(device_key) else {
371            return BTreeMap::new();
372        };
373        let mut out = device.button_bindings.clone();
374        if let Some(bid) = bundle_id {
375            if let Some(overlay) = device.per_app_bindings.get(bid) {
376                for (k, v) in overlay {
377                    out.insert(*k, v.clone());
378                }
379            }
380        }
381        out
382    }
383
384    /// Records a per-app override. Creates the device + app entries as
385    /// needed; passing an action of `None` removes the override and prunes
386    /// the empty app map.
387    pub fn set_per_app_binding(
388        &mut self,
389        device_key: &str,
390        bundle_id: &str,
391        button: ButtonId,
392        action: Option<Action>,
393    ) {
394        let entry = self
395            .devices
396            .entry(device_key.to_string())
397            .or_default()
398            .per_app_bindings
399            .entry(bundle_id.to_string())
400            .or_default();
401        match action {
402            Some(a) => {
403                entry.insert(button, a);
404            }
405            None => {
406                entry.remove(&button);
407            }
408        }
409        if let Some(d) = self.devices.get_mut(device_key) {
410            d.per_app_bindings.retain(|_, m| !m.is_empty());
411        }
412    }
413
414    /// HID++ config key of the carousel-selected device, if any.
415    #[must_use]
416    pub fn selected_device(&self) -> Option<&str> {
417        self.selected_device.as_deref()
418    }
419
420    /// Update the carousel-selected device. Pass `None` to clear the
421    /// selection (e.g. when the previously-selected device disappears).
422    pub fn set_selected_device(&mut self, key: Option<String>) {
423        self.selected_device = key;
424    }
425
426    /// The ordered DPI preset list for `device_key`, or an empty `Vec` if the
427    /// device has none configured yet.
428    #[must_use]
429    pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
430        self.devices
431            .get(device_key)
432            .map(|d| d.dpi_presets.clone())
433            .unwrap_or_default()
434    }
435
436    /// Replace the DPI preset list for `device_key`. Pass an empty `Vec` to
437    /// clear (the device block is kept; the field is just omitted on save
438    /// thanks to `skip_serializing_if`).
439    pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
440        self.devices
441            .entry(device_key.to_string())
442            .or_default()
443            .dpi_presets = presets;
444    }
445
446    /// The lighting config for `device_key`, or `None` if unset.
447    #[must_use]
448    pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
449        self.devices
450            .get(device_key)
451            .and_then(|d| d.lighting.clone())
452    }
453
454    /// Replace the lighting config for `device_key`.
455    pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
456        self.devices
457            .entry(device_key.to_string())
458            .or_default()
459            .lighting = Some(lighting);
460    }
461}
462
463fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
464    let tmp = path.with_extension("toml.tmp");
465    {
466        #[cfg(unix)]
467        {
468            use std::os::unix::fs::OpenOptionsExt;
469            let mut f = fs::OpenOptions::new()
470                .write(true)
471                .create(true)
472                .truncate(true)
473                .mode(0o600)
474                .open(&tmp)?;
475            io::Write::write_all(&mut f, bytes)?;
476            f.sync_all()?;
477        }
478        #[cfg(not(unix))]
479        {
480            let mut f = fs::OpenOptions::new()
481                .write(true)
482                .create(true)
483                .truncate(true)
484                .open(&tmp)?;
485            io::Write::write_all(&mut f, bytes)?;
486            f.sync_all()?;
487        }
488    }
489    fs::rename(&tmp, path)
490}
491
492#[cfg(test)]
493#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
494mod tests {
495    use super::*;
496
497    fn write_and_read(config: &Config) -> Config {
498        let dir = tempfile::tempdir().expect("tempdir");
499        let path = dir.path().join("config.toml");
500        config.save_to_path(&path).expect("save");
501        Config::load_from_path(&path).expect("load")
502    }
503
504    #[test]
505    fn missing_file_yields_default() {
506        let dir = tempfile::tempdir().expect("tempdir");
507        let path = dir.path().join("nonexistent.toml");
508        let cfg = Config::load_from_path(&path).expect("load");
509        assert_eq!(cfg.schema_version, SCHEMA_VERSION);
510        assert!(cfg.devices.is_empty());
511    }
512
513    #[test]
514    fn lighting_roundtrips_per_device() {
515        let mut cfg = Config::default();
516        cfg.set_lighting(
517            "g513",
518            Lighting {
519                enabled: true,
520                color: "00aabb".to_string(),
521                brightness: 75,
522            },
523        );
524        let restored = write_and_read(&cfg);
525        assert_eq!(
526            restored.lighting("g513"),
527            Some(Lighting {
528                enabled: true,
529                color: "00aabb".to_string(),
530                brightness: 75,
531            })
532        );
533        assert_eq!(restored.lighting("absent"), None);
534    }
535
536    #[test]
537    fn bindings_roundtrip_per_device() {
538        let mut cfg = Config::default();
539        cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
540        cfg.set_binding(
541            "2b042",
542            ButtonId::DpiToggle,
543            Action::CustomShortcut(crate::binding::KeyCombo {
544                modifiers: crate::binding::KeyCombo::MOD_CMD,
545                key_code: 0x23, // kVK_ANSI_P
546                display: "⌘P".into(),
547            }),
548        );
549        cfg.set_binding("4082d", ButtonId::Back, Action::Paste);
550
551        let parsed = write_and_read(&cfg);
552
553        // Per-device isolation.
554        let a = parsed.bindings_for("2b042");
555        assert_eq!(a.get(&ButtonId::Back), Some(&Action::Copy));
556        assert_eq!(
557            a.get(&ButtonId::DpiToggle),
558            Some(&Action::CustomShortcut(crate::binding::KeyCombo {
559                modifiers: crate::binding::KeyCombo::MOD_CMD,
560                key_code: 0x23,
561                display: "⌘P".into(),
562            }))
563        );
564
565        let b = parsed.bindings_for("4082d");
566        assert_eq!(b.get(&ButtonId::Back), Some(&Action::Paste));
567        assert_eq!(b.len(), 1, "device b should only see its own bindings");
568
569        // Unknown device returns empty map without panic.
570        assert!(parsed.bindings_for("deadbeef").is_empty());
571    }
572
573    #[test]
574    fn human_readable_toml_layout() {
575        let mut cfg = Config::default();
576        cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack);
577        let body = toml::to_string_pretty(&cfg).expect("serialize");
578
579        // The model id only contains [A-Za-z0-9_], so TOML emits it as a
580        // bare-word table key (no surrounding quotes). The test asserts the
581        // observable structure rather than locking in a specific quoting.
582        assert!(body.contains("schema_version = 1"), "got: {body}");
583        assert!(
584            body.contains("[devices.2b042.button_bindings]"),
585            "got: {body}"
586        );
587        assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
588    }
589
590    #[test]
591    fn rejects_unknown_schema_version() {
592        let dir = tempfile::tempdir().expect("tempdir");
593        let path = dir.path().join("config.toml");
594        fs::write(&path, "schema_version = 99\n").expect("write");
595        let err = Config::load_from_path(&path).expect_err("should fail");
596        assert!(matches!(
597            err,
598            ConfigError::UnsupportedSchemaVersion { found: 99, .. }
599        ));
600    }
601
602    #[test]
603    fn dpi_presets_roundtrip_per_device() {
604        let mut cfg = Config::default();
605        cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
606        cfg.set_dpi_presets("4082d", vec![400, 1600]);
607
608        let parsed = write_and_read(&cfg);
609
610        assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
611        assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
612        assert!(parsed.dpi_presets("unknown").is_empty());
613    }
614
615    #[test]
616    fn empty_dpi_presets_skip_serialization() {
617        let mut cfg = Config::default();
618        // Add a binding so the device block exists.
619        cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
620        cfg.set_dpi_presets("2b042", vec![800]);
621        cfg.set_dpi_presets("2b042", vec![]); // clear
622
623        let body = toml::to_string_pretty(&cfg).expect("serialize");
624        assert!(
625            !body.contains("dpi_presets"),
626            "empty dpi_presets should be omitted: {body}"
627        );
628    }
629
630    #[test]
631    fn selected_device_roundtrips() {
632        let mut cfg = Config::default();
633        assert_eq!(cfg.selected_device(), None);
634        cfg.set_selected_device(Some("2b042".into()));
635        let parsed = write_and_read(&cfg);
636        assert_eq!(parsed.selected_device(), Some("2b042"));
637    }
638
639    #[test]
640    fn per_app_overlay_takes_precedence() {
641        let mut cfg = Config::default();
642        cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack);
643        cfg.set_binding("2b042", ButtonId::Forward, Action::BrowserForward);
644        cfg.set_per_app_binding(
645            "2b042",
646            "com.microsoft.VSCode",
647            ButtonId::Back,
648            Some(Action::Undo),
649        );
650
651        // Global: both buttons are browser nav.
652        let global = cfg.effective_bindings("2b042", None);
653        assert_eq!(global.get(&ButtonId::Back), Some(&Action::BrowserBack));
654        assert_eq!(
655            global.get(&ButtonId::Forward),
656            Some(&Action::BrowserForward)
657        );
658
659        // VSCode: Back overridden, Forward inherits.
660        let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
661        assert_eq!(vscode.get(&ButtonId::Back), Some(&Action::Undo));
662        assert_eq!(
663            vscode.get(&ButtonId::Forward),
664            Some(&Action::BrowserForward)
665        );
666
667        // Unrelated app falls through.
668        let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
669        assert_eq!(other.get(&ButtonId::Back), Some(&Action::BrowserBack));
670    }
671
672    #[test]
673    fn per_app_binding_removal_prunes_empty_app() {
674        let mut cfg = Config::default();
675        cfg.set_per_app_binding(
676            "2b042",
677            "com.example.App",
678            ButtonId::Back,
679            Some(Action::Copy),
680        );
681        cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
682        assert!(
683            cfg.devices["2b042"].per_app_bindings.is_empty(),
684            "removing last override should prune the app entry"
685        );
686    }
687
688    #[test]
689    fn app_settings_default_omits_block() {
690        let cfg = Config::default();
691        let body = toml::to_string_pretty(&cfg).expect("serialize");
692        assert!(
693            !body.contains("app_settings"),
694            "default app_settings should be omitted: {body}"
695        );
696    }
697
698    #[test]
699    fn app_settings_launch_at_login_roundtrips() {
700        let mut cfg = Config::default();
701        cfg.app_settings.launch_at_login = true;
702        let parsed = write_and_read(&cfg);
703        assert!(parsed.app_settings.launch_at_login);
704    }
705
706    #[test]
707    fn cleared_selected_device_omits_field() {
708        let mut cfg = Config::default();
709        cfg.set_selected_device(Some("2b042".into()));
710        cfg.set_selected_device(None);
711        let body = toml::to_string_pretty(&cfg).expect("serialize");
712        assert!(
713            !body.contains("selected_device"),
714            "cleared selection should not appear: {body}"
715        );
716    }
717
718    #[test]
719    fn empty_device_block_is_skipped_in_output() {
720        // Inserting then clearing should not leave a [devices."x"] header
721        // with no bindings under it (skip_serializing_if on button_bindings).
722        let mut cfg = Config::default();
723        cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
724        cfg.devices
725            .get_mut("2b042")
726            .expect("entry")
727            .button_bindings
728            .clear();
729        let body = toml::to_string_pretty(&cfg).expect("serialize");
730        assert!(
731            !body.contains("Back"),
732            "cleared bindings should not appear: {body}"
733        );
734    }
735}