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}
98
99impl AppSettings {
100    /// `skip_serializing_if` helper: true when nothing diverges from the
101    /// default, so empty settings don't clutter `config.toml`.
102    #[must_use]
103    pub fn is_default(&self) -> bool {
104        self == &Self::default()
105    }
106}
107
108impl Default for AppSettings {
109    fn default() -> Self {
110        Self {
111            launch_at_login: false,
112            check_for_updates: false,
113            update_prompt_seen: false,
114            show_in_menu_bar: true,
115            language: None,
116        }
117    }
118}
119
120/// serde default for [`AppSettings::show_in_menu_bar`]: `true`, so the menu-bar
121/// icon is on out of the box and configs predating the field keep that behavior.
122fn default_true() -> bool {
123    true
124}
125
126/// Per-device RGB lighting: a single static color, brightness, and on/off.
127/// Deliberately basic — per-key effects are a later addition.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct Lighting {
130    #[serde(default = "default_lighting_enabled")]
131    pub enabled: bool,
132    /// Static color as 6 hex digits `"RRGGBB"` (no leading `#`).
133    #[serde(default = "default_lighting_color")]
134    pub color: String,
135    /// Brightness percent, clamped to 0–100 on load.
136    #[serde(
137        default = "default_lighting_brightness",
138        deserialize_with = "deserialize_brightness"
139    )]
140    pub brightness: u8,
141}
142
143impl Default for Lighting {
144    fn default() -> Self {
145        Self {
146            enabled: default_lighting_enabled(),
147            color: default_lighting_color(),
148            brightness: default_lighting_brightness(),
149        }
150    }
151}
152
153fn default_lighting_enabled() -> bool {
154    true
155}
156
157fn default_lighting_color() -> String {
158    "ffffff".to_string()
159}
160
161fn default_lighting_brightness() -> u8 {
162    100
163}
164
165/// Clamp a deserialized brightness into the UI's `0..=100` range, so a
166/// hand-edited `config.toml` can't feed out-of-range values into the scaling
167/// math (which assumes `brightness <= 100`).
168fn deserialize_brightness<'de, D>(deserializer: D) -> Result<u8, D::Error>
169where
170    D: serde::Deserializer<'de>,
171{
172    Ok(u8::deserialize(deserializer)?.min(100))
173}
174
175/// Settings scoped to a single physical device (keyed by HID++ model+ext).
176#[derive(Debug, Clone, Default, Serialize, Deserialize)]
177pub struct DeviceConfig {
178    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
179    pub button_bindings: BTreeMap<ButtonId, Action>,
180    /// Per-application binding overlays (P1.4). Keyed by bundle identifier
181    /// (e.g. `"com.microsoft.VSCode"` on macOS). When the foreground app's
182    /// id matches a key here, those bindings take precedence; anything not
183    /// listed falls through to `button_bindings`.
184    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
185    pub per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
186    /// Sub-bindings for the gesture button: hold + swipe direction or a
187    /// plain click. Edited via the gesture picker; the legacy single
188    /// `button_bindings[GestureButton]` entry is ignored on devices that
189    /// have entries here. Hardware dispatch is a P1.5 follow-up.
190    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
191    pub gesture_bindings: BTreeMap<GestureDirection, Action>,
192    /// Ordered list of DPI presets cycled through by
193    /// [`Action::CycleDpiPresets`] and indexed by
194    /// [`Action::SetDpiPreset`]. Empty means "no presets configured" —
195    /// the cycle action becomes a no-op until the user adds at least one.
196    #[serde(default, skip_serializing_if = "Vec::is_empty")]
197    pub dpi_presets: Vec<u32>,
198    /// Per-device RGB lighting (static color + brightness + on/off). `None`
199    /// until the user changes it, so it stays out of `config.toml` otherwise.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub lighting: Option<Lighting>,
202}
203
204#[derive(Debug, Error)]
205pub enum ConfigError {
206    #[error("could not resolve config path")]
207    Path(#[from] PathsError),
208    #[error("could not read config at {path}")]
209    Read {
210        path: PathBuf,
211        #[source]
212        source: io::Error,
213    },
214    #[error("could not parse config at {path}")]
215    Parse {
216        path: PathBuf,
217        #[source]
218        source: toml::de::Error,
219    },
220    #[error("could not write config at {path}")]
221    Write {
222        path: PathBuf,
223        #[source]
224        source: io::Error,
225    },
226    #[error("could not serialize config")]
227    Serialize(#[from] toml::ser::Error),
228    #[error("config at {path} has unsupported schema_version {found}")]
229    UnsupportedSchemaVersion { path: PathBuf, found: u32 },
230}
231
232impl Config {
233    /// Loads the config from the default user path, returning
234    /// [`Config::default`] if the file does not exist yet.
235    pub fn load_or_default() -> Result<Self, ConfigError> {
236        Self::load_from_path(&paths::config_path()?)
237    }
238
239    /// Same as [`Self::load_or_default`] but reads from `path`. Used by tests
240    /// to avoid touching the real user config.
241    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
242        match fs::read_to_string(path) {
243            Ok(text) => {
244                let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse {
245                    path: path.to_path_buf(),
246                    source,
247                })?;
248                if config.schema_version != SCHEMA_VERSION {
249                    return Err(ConfigError::UnsupportedSchemaVersion {
250                        path: path.to_path_buf(),
251                        found: config.schema_version,
252                    });
253                }
254                Ok(config)
255            }
256            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
257            Err(source) => Err(ConfigError::Read {
258                path: path.to_path_buf(),
259                source,
260            }),
261        }
262    }
263
264    /// Writes the config atomically to the default user path: serialize to a
265    /// sibling temp file, then rename over the target. On Unix the temp file
266    /// is created with mode 0600.
267    pub fn save_atomic(&self) -> Result<(), ConfigError> {
268        self.save_to_path(&paths::config_path()?)
269    }
270
271    /// Same as [`Self::save_atomic`] but writes to `path`. Used by tests.
272    pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
273        if let Some(parent) = path.parent() {
274            fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
275                path: path.to_path_buf(),
276                source,
277            })?;
278        }
279        let body = toml::to_string_pretty(self)?;
280        write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
281            path: path.to_path_buf(),
282            source,
283        })
284    }
285
286    /// Returns the bindings stored for `device_key`, or an empty map if the
287    /// device has no committed bindings yet.
288    #[must_use]
289    pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Action> {
290        self.devices
291            .get(device_key)
292            .map(|d| d.button_bindings.clone())
293            .unwrap_or_default()
294    }
295
296    /// Records `action` as the binding for `button` on `device_key`,
297    /// creating the device entry if needed.
298    pub fn set_binding(&mut self, device_key: &str, button: ButtonId, action: Action) {
299        self.devices
300            .entry(device_key.to_string())
301            .or_default()
302            .button_bindings
303            .insert(button, action);
304    }
305
306    /// Returns the gesture sub-bindings stored for `device_key`, or an empty
307    /// map if none are set yet.
308    #[must_use]
309    pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
310        self.devices
311            .get(device_key)
312            .map(|d| d.gesture_bindings.clone())
313            .unwrap_or_default()
314    }
315
316    /// Records `action` for `direction` of `device_key`'s gesture button.
317    pub fn set_gesture_binding(
318        &mut self,
319        device_key: &str,
320        direction: GestureDirection,
321        action: Action,
322    ) {
323        self.devices
324            .entry(device_key.to_string())
325            .or_default()
326            .gesture_bindings
327            .insert(direction, action);
328    }
329
330    /// Resolve the effective binding map for `device_key`, overlaying the
331    /// per-app entry for `bundle_id` (if any) on top of the global per-device
332    /// `button_bindings`. Per-app values win; everything else falls through.
333    ///
334    /// Returns an empty map when the device has no recorded bindings yet.
335    /// Callers (the GUI / hook) layer their own defaults on top.
336    #[must_use]
337    pub fn effective_bindings(
338        &self,
339        device_key: &str,
340        bundle_id: Option<&str>,
341    ) -> BTreeMap<ButtonId, Action> {
342        let Some(device) = self.devices.get(device_key) else {
343            return BTreeMap::new();
344        };
345        let mut out = device.button_bindings.clone();
346        if let Some(bid) = bundle_id {
347            if let Some(overlay) = device.per_app_bindings.get(bid) {
348                for (k, v) in overlay {
349                    out.insert(*k, v.clone());
350                }
351            }
352        }
353        out
354    }
355
356    /// Records a per-app override. Creates the device + app entries as
357    /// needed; passing an action of `None` removes the override and prunes
358    /// the empty app map.
359    pub fn set_per_app_binding(
360        &mut self,
361        device_key: &str,
362        bundle_id: &str,
363        button: ButtonId,
364        action: Option<Action>,
365    ) {
366        let entry = self
367            .devices
368            .entry(device_key.to_string())
369            .or_default()
370            .per_app_bindings
371            .entry(bundle_id.to_string())
372            .or_default();
373        match action {
374            Some(a) => {
375                entry.insert(button, a);
376            }
377            None => {
378                entry.remove(&button);
379            }
380        }
381        if let Some(d) = self.devices.get_mut(device_key) {
382            d.per_app_bindings.retain(|_, m| !m.is_empty());
383        }
384    }
385
386    /// HID++ config key of the carousel-selected device, if any.
387    #[must_use]
388    pub fn selected_device(&self) -> Option<&str> {
389        self.selected_device.as_deref()
390    }
391
392    /// Update the carousel-selected device. Pass `None` to clear the
393    /// selection (e.g. when the previously-selected device disappears).
394    pub fn set_selected_device(&mut self, key: Option<String>) {
395        self.selected_device = key;
396    }
397
398    /// The ordered DPI preset list for `device_key`, or an empty `Vec` if the
399    /// device has none configured yet.
400    #[must_use]
401    pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
402        self.devices
403            .get(device_key)
404            .map(|d| d.dpi_presets.clone())
405            .unwrap_or_default()
406    }
407
408    /// Replace the DPI preset list for `device_key`. Pass an empty `Vec` to
409    /// clear (the device block is kept; the field is just omitted on save
410    /// thanks to `skip_serializing_if`).
411    pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
412        self.devices
413            .entry(device_key.to_string())
414            .or_default()
415            .dpi_presets = presets;
416    }
417
418    /// The lighting config for `device_key`, or `None` if unset.
419    #[must_use]
420    pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
421        self.devices
422            .get(device_key)
423            .and_then(|d| d.lighting.clone())
424    }
425
426    /// Replace the lighting config for `device_key`.
427    pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
428        self.devices
429            .entry(device_key.to_string())
430            .or_default()
431            .lighting = Some(lighting);
432    }
433}
434
435fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
436    let tmp = path.with_extension("toml.tmp");
437    {
438        #[cfg(unix)]
439        {
440            use std::os::unix::fs::OpenOptionsExt;
441            let mut f = fs::OpenOptions::new()
442                .write(true)
443                .create(true)
444                .truncate(true)
445                .mode(0o600)
446                .open(&tmp)?;
447            io::Write::write_all(&mut f, bytes)?;
448            f.sync_all()?;
449        }
450        #[cfg(not(unix))]
451        {
452            let mut f = fs::OpenOptions::new()
453                .write(true)
454                .create(true)
455                .truncate(true)
456                .open(&tmp)?;
457            io::Write::write_all(&mut f, bytes)?;
458            f.sync_all()?;
459        }
460    }
461    fs::rename(&tmp, path)
462}
463
464#[cfg(test)]
465#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
466mod tests {
467    use super::*;
468
469    fn write_and_read(config: &Config) -> Config {
470        let dir = tempfile::tempdir().expect("tempdir");
471        let path = dir.path().join("config.toml");
472        config.save_to_path(&path).expect("save");
473        Config::load_from_path(&path).expect("load")
474    }
475
476    #[test]
477    fn missing_file_yields_default() {
478        let dir = tempfile::tempdir().expect("tempdir");
479        let path = dir.path().join("nonexistent.toml");
480        let cfg = Config::load_from_path(&path).expect("load");
481        assert_eq!(cfg.schema_version, SCHEMA_VERSION);
482        assert!(cfg.devices.is_empty());
483    }
484
485    #[test]
486    fn lighting_roundtrips_per_device() {
487        let mut cfg = Config::default();
488        cfg.set_lighting(
489            "g513",
490            Lighting {
491                enabled: true,
492                color: "00aabb".to_string(),
493                brightness: 75,
494            },
495        );
496        let restored = write_and_read(&cfg);
497        assert_eq!(
498            restored.lighting("g513"),
499            Some(Lighting {
500                enabled: true,
501                color: "00aabb".to_string(),
502                brightness: 75,
503            })
504        );
505        assert_eq!(restored.lighting("absent"), None);
506    }
507
508    #[test]
509    fn bindings_roundtrip_per_device() {
510        let mut cfg = Config::default();
511        cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
512        cfg.set_binding(
513            "2b042",
514            ButtonId::DpiToggle,
515            Action::CustomShortcut(crate::binding::KeyCombo {
516                modifiers: crate::binding::KeyCombo::MOD_CMD,
517                key_code: 0x23, // kVK_ANSI_P
518                display: "⌘P".into(),
519            }),
520        );
521        cfg.set_binding("4082d", ButtonId::Back, Action::Paste);
522
523        let parsed = write_and_read(&cfg);
524
525        // Per-device isolation.
526        let a = parsed.bindings_for("2b042");
527        assert_eq!(a.get(&ButtonId::Back), Some(&Action::Copy));
528        assert_eq!(
529            a.get(&ButtonId::DpiToggle),
530            Some(&Action::CustomShortcut(crate::binding::KeyCombo {
531                modifiers: crate::binding::KeyCombo::MOD_CMD,
532                key_code: 0x23,
533                display: "⌘P".into(),
534            }))
535        );
536
537        let b = parsed.bindings_for("4082d");
538        assert_eq!(b.get(&ButtonId::Back), Some(&Action::Paste));
539        assert_eq!(b.len(), 1, "device b should only see its own bindings");
540
541        // Unknown device returns empty map without panic.
542        assert!(parsed.bindings_for("deadbeef").is_empty());
543    }
544
545    #[test]
546    fn human_readable_toml_layout() {
547        let mut cfg = Config::default();
548        cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack);
549        let body = toml::to_string_pretty(&cfg).expect("serialize");
550
551        // The model id only contains [A-Za-z0-9_], so TOML emits it as a
552        // bare-word table key (no surrounding quotes). The test asserts the
553        // observable structure rather than locking in a specific quoting.
554        assert!(body.contains("schema_version = 1"), "got: {body}");
555        assert!(
556            body.contains("[devices.2b042.button_bindings]"),
557            "got: {body}"
558        );
559        assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
560    }
561
562    #[test]
563    fn rejects_unknown_schema_version() {
564        let dir = tempfile::tempdir().expect("tempdir");
565        let path = dir.path().join("config.toml");
566        fs::write(&path, "schema_version = 99\n").expect("write");
567        let err = Config::load_from_path(&path).expect_err("should fail");
568        assert!(matches!(
569            err,
570            ConfigError::UnsupportedSchemaVersion { found: 99, .. }
571        ));
572    }
573
574    #[test]
575    fn dpi_presets_roundtrip_per_device() {
576        let mut cfg = Config::default();
577        cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
578        cfg.set_dpi_presets("4082d", vec![400, 1600]);
579
580        let parsed = write_and_read(&cfg);
581
582        assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
583        assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
584        assert!(parsed.dpi_presets("unknown").is_empty());
585    }
586
587    #[test]
588    fn empty_dpi_presets_skip_serialization() {
589        let mut cfg = Config::default();
590        // Add a binding so the device block exists.
591        cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
592        cfg.set_dpi_presets("2b042", vec![800]);
593        cfg.set_dpi_presets("2b042", vec![]); // clear
594
595        let body = toml::to_string_pretty(&cfg).expect("serialize");
596        assert!(
597            !body.contains("dpi_presets"),
598            "empty dpi_presets should be omitted: {body}"
599        );
600    }
601
602    #[test]
603    fn selected_device_roundtrips() {
604        let mut cfg = Config::default();
605        assert_eq!(cfg.selected_device(), None);
606        cfg.set_selected_device(Some("2b042".into()));
607        let parsed = write_and_read(&cfg);
608        assert_eq!(parsed.selected_device(), Some("2b042"));
609    }
610
611    #[test]
612    fn per_app_overlay_takes_precedence() {
613        let mut cfg = Config::default();
614        cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack);
615        cfg.set_binding("2b042", ButtonId::Forward, Action::BrowserForward);
616        cfg.set_per_app_binding(
617            "2b042",
618            "com.microsoft.VSCode",
619            ButtonId::Back,
620            Some(Action::Undo),
621        );
622
623        // Global: both buttons are browser nav.
624        let global = cfg.effective_bindings("2b042", None);
625        assert_eq!(global.get(&ButtonId::Back), Some(&Action::BrowserBack));
626        assert_eq!(
627            global.get(&ButtonId::Forward),
628            Some(&Action::BrowserForward)
629        );
630
631        // VSCode: Back overridden, Forward inherits.
632        let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
633        assert_eq!(vscode.get(&ButtonId::Back), Some(&Action::Undo));
634        assert_eq!(
635            vscode.get(&ButtonId::Forward),
636            Some(&Action::BrowserForward)
637        );
638
639        // Unrelated app falls through.
640        let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
641        assert_eq!(other.get(&ButtonId::Back), Some(&Action::BrowserBack));
642    }
643
644    #[test]
645    fn per_app_binding_removal_prunes_empty_app() {
646        let mut cfg = Config::default();
647        cfg.set_per_app_binding(
648            "2b042",
649            "com.example.App",
650            ButtonId::Back,
651            Some(Action::Copy),
652        );
653        cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
654        assert!(
655            cfg.devices["2b042"].per_app_bindings.is_empty(),
656            "removing last override should prune the app entry"
657        );
658    }
659
660    #[test]
661    fn app_settings_default_omits_block() {
662        let cfg = Config::default();
663        let body = toml::to_string_pretty(&cfg).expect("serialize");
664        assert!(
665            !body.contains("app_settings"),
666            "default app_settings should be omitted: {body}"
667        );
668    }
669
670    #[test]
671    fn app_settings_launch_at_login_roundtrips() {
672        let mut cfg = Config::default();
673        cfg.app_settings.launch_at_login = true;
674        let parsed = write_and_read(&cfg);
675        assert!(parsed.app_settings.launch_at_login);
676    }
677
678    #[test]
679    fn cleared_selected_device_omits_field() {
680        let mut cfg = Config::default();
681        cfg.set_selected_device(Some("2b042".into()));
682        cfg.set_selected_device(None);
683        let body = toml::to_string_pretty(&cfg).expect("serialize");
684        assert!(
685            !body.contains("selected_device"),
686            "cleared selection should not appear: {body}"
687        );
688    }
689
690    #[test]
691    fn empty_device_block_is_skipped_in_output() {
692        // Inserting then clearing should not leave a [devices."x"] header
693        // with no bindings under it (skip_serializing_if on button_bindings).
694        let mut cfg = Config::default();
695        cfg.set_binding("2b042", ButtonId::Back, Action::Copy);
696        cfg.devices
697            .get_mut("2b042")
698            .expect("entry")
699            .button_bindings
700            .clear();
701        let body = toml::to_string_pretty(&cfg).expect("serialize");
702        assert!(
703            !body.contains("Back"),
704            "cleared bindings should not appear: {body}"
705        );
706    }
707}