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 a stable physical-device identifier such
6//! as `"receiver:abc123:slot:2"`. Schema migrations branch on
7//! [`Config::schema_version`].
8
9use std::{
10    collections::BTreeMap,
11    fs, io,
12    path::{Path, PathBuf},
13};
14
15use atomic_write_file::AtomicWriteFile;
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19mod device;
20mod settings;
21
22pub use device::{DeviceConfig, DeviceIdentity};
23pub use settings::{
24    AppSettings, Appearance, DEFAULT_THUMBWHEEL_SENSITIVITY, GestureOwner, Lighting,
25    MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY, SMARTSHIFT_AUTO_DISENGAGE_DEFAULT,
26    SMARTSHIFT_MIN_AUTO_DISENGAGE, SmartShift, WheelMode,
27};
28
29use crate::binding::{Action, Binding, ButtonId, GestureDirection, default_binding_for};
30use crate::paths::{self, PathsError};
31
32/// The schema version the current build produces. Bumped on breaking layout
33/// changes; readers branch on the parsed value before consuming the rest of
34/// the file.
35///
36/// v3 changes the device map from model keys to physical-device keys. No v2
37/// device entries are migrated because model-scoped settings cannot be assigned
38/// safely when two identical devices exist.
39///
40/// v2 merged the per-device `button_bindings` + `gesture_bindings` maps into a
41/// single `bindings: BTreeMap<ButtonId, Binding>`. A v1 file still loads (the
42/// `RawDeviceConfig` shim folds the legacy fields) and self-heals to v2 on the
43/// next save; [`Config::load_from_path`] rejects only versions *newer* than this
44/// so a forward file fails loudly instead of silently losing bindings.
45pub const SCHEMA_VERSION: u32 = 3;
46
47/// Top-level config document.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Config {
50    /// Schema version the file was written with. Compared against
51    /// [`SCHEMA_VERSION`] on load: older layouts migrate, newer ones are
52    /// rejected loudly rather than silently losing settings.
53    pub schema_version: u32,
54    /// Non-device-scoped preferences (autostart, tray, language, …).
55    #[serde(default, skip_serializing_if = "AppSettings::is_default")]
56    pub app_settings: AppSettings,
57    /// Physical config key of the carousel-selected device, persisted so a
58    /// restart restores the last view rather than always landing on the
59    /// first paired device. `None` means "fall back to the first device".
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub selected_device: Option<String>,
62    /// Per-device state, keyed by the stable physical-device identifier
63    /// (e.g. `"receiver:abc123:slot:2"`) so two identical models never share
64    /// an entry.
65    #[serde(default)]
66    pub devices: BTreeMap<String, DeviceConfig>,
67}
68
69impl Default for Config {
70    fn default() -> Self {
71        Self {
72            schema_version: SCHEMA_VERSION,
73            app_settings: AppSettings::default(),
74            selected_device: None,
75            devices: BTreeMap::new(),
76        }
77    }
78}
79
80/// Failure loading or persisting `config.toml`. The file-scoped variants
81/// carry the offending path so callers can surface an actionable message.
82#[derive(Debug, Error)]
83pub enum ConfigError {
84    /// The platform config directory could not be resolved (no home
85    /// directory for the current user).
86    #[error("could not resolve config path")]
87    Path(#[from] PathsError),
88    /// Reading the config file from disk failed.
89    #[error("could not read config at {path}")]
90    Read {
91        /// The config file the read targeted.
92        path: PathBuf,
93        /// The underlying I/O error.
94        #[source]
95        source: io::Error,
96    },
97    /// The file was read but is not valid TOML for this schema.
98    #[error("could not parse config at {path}")]
99    Parse {
100        /// The config file that failed to parse.
101        path: PathBuf,
102        /// The underlying TOML deserialization error.
103        #[source]
104        source: toml::de::Error,
105    },
106    /// Writing the updated config back to disk failed.
107    #[error("could not write config at {path}")]
108    Write {
109        /// The config file the write targeted.
110        path: PathBuf,
111        /// The underlying I/O error.
112        #[source]
113        source: io::Error,
114    },
115    /// The in-memory config could not be serialized to TOML — a bug in the
116    /// config types rather than user error, since [`Config`] always
117    /// serializes cleanly.
118    #[error("could not serialize config")]
119    Serialize(#[from] toml::ser::Error),
120    /// The file declares a `schema_version` newer than this build
121    /// understands; failing loudly avoids silently dropping settings a newer
122    /// build wrote.
123    #[error("config at {path} has unsupported schema_version {found}")]
124    UnsupportedSchemaVersion {
125        /// The config file carrying the unsupported version.
126        path: PathBuf,
127        /// The `schema_version` the file declared.
128        found: u32,
129    },
130}
131
132#[allow(
133    clippy::result_large_err,
134    reason = "Config I/O keeps rich parse/write context and is not a hot path"
135)]
136impl Config {
137    /// Loads the config from the default user path, returning
138    /// [`Config::default`] if the file does not exist yet.
139    pub fn load_or_default() -> Result<Self, ConfigError> {
140        Self::load_from_path(&paths::config_path()?)
141    }
142
143    /// Same as [`Self::load_or_default`] but reads from `path`. Used by tests
144    /// to avoid touching the real user config.
145    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
146        match fs::read_to_string(path) {
147            Ok(text) => {
148                let mut config: Self =
149                    toml::from_str(&text).map_err(|source| ConfigError::Parse {
150                        path: path.to_path_buf(),
151                        source,
152                    })?;
153                // Accept any version up to the current one: older files migrate
154                // through the per-device [`RawDeviceConfig`] shim and self-heal on
155                // the next save. Only a *newer* file is rejected — loudly, so a
156                // downgraded binary refuses to load (and silently wipe) a config
157                // it can't represent.
158                if config.schema_version > SCHEMA_VERSION {
159                    return Err(ConfigError::UnsupportedSchemaVersion {
160                        path: path.to_path_buf(),
161                        found: config.schema_version,
162                    });
163                }
164                // Stamp the in-memory doc to the current version so a re-save
165                // writes the migrated v2 shape (the device shim already folded
166                // the legacy fields during deserialize).
167                config.schema_version = SCHEMA_VERSION;
168                Ok(config)
169            }
170            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
171            Err(source) => Err(ConfigError::Read {
172                path: path.to_path_buf(),
173                source,
174            }),
175        }
176    }
177
178    /// Writes the config atomically to the default user path: serialize to a
179    /// sibling temp file, then rename over the target. On Unix the temp file
180    /// is created with mode 0600.
181    pub fn save_atomic(&self) -> Result<(), ConfigError> {
182        self.save_to_path(&paths::config_path()?)
183    }
184
185    /// Same as [`Self::save_atomic`] but writes to `path`. Used by tests.
186    pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
187        if let Some(parent) = path.parent() {
188            fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
189                path: path.to_path_buf(),
190                source,
191            })?;
192        }
193        let body = toml::to_string_pretty(self)?;
194        write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
195            path: path.to_path_buf(),
196            source,
197        })
198    }
199
200    /// Returns the bindings stored for `device_key`, or an empty map if the
201    /// device has no committed bindings yet.
202    #[must_use]
203    pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
204        self.devices
205            .get(device_key)
206            .map(|d| d.bindings.clone())
207            .unwrap_or_default()
208    }
209
210    /// Records `binding` for `button` on `device_key`, creating the device
211    /// entry if needed. Replaces the whole binding (use
212    /// [`Self::set_gesture_direction`] to edit one direction of a gesture
213    /// binding in place).
214    pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
215        self.devices
216            .entry(device_key.to_string())
217            .or_default()
218            .bindings
219            .insert(button, binding);
220    }
221
222    /// Returns the gesture sub-bindings for `device_key`'s gesture button, or an
223    /// empty map if it isn't in gesture mode. Derived from the unified
224    /// [`DeviceConfig::bindings`]; kept as a convenience for the agent-side
225    /// per-direction adapter.
226    #[must_use]
227    pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
228        match self
229            .devices
230            .get(device_key)
231            .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
232        {
233            Some(Binding::Gesture(map)) => map.clone(),
234            _ => BTreeMap::new(),
235        }
236    }
237
238    /// Records `action` for one `direction` of `button`'s gesture binding,
239    /// creating the device entry if needed.
240    ///
241    /// A button with no binding yet is seeded from its canonical
242    /// [`default_binding_for`] — for [`ButtonId::GestureButton`] that is the full
243    /// default direction map (including a [`GestureDirection::Click`]), so the
244    /// merged map never persists a gesture binding whose click projection is a
245    /// no-op. A prior [`Binding::Single`] is upgraded to [`Binding::Gesture`],
246    /// preserving its action as the `Click` entry.
247    pub fn set_gesture_direction(
248        &mut self,
249        device_key: &str,
250        button: ButtonId,
251        direction: GestureDirection,
252        action: Action,
253    ) {
254        if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
255            map.insert(direction, action);
256        }
257    }
258
259    /// Ensure `button` on `device_key` is a [`Binding::Gesture`], creating the
260    /// device + a default binding if needed and upgrading a [`Binding::Single`]
261    /// in place (its action kept as the [`GestureDirection::Click`]). Returns the
262    /// entry so the caller can finish it — seed every direction
263    /// ([`Binding::fill_gesture_defaults`]) or set just one. Shared by
264    /// [`Self::set_gesture_owner`] and [`Self::set_gesture_direction`] so the two
265    /// promote a button into gesture mode identically.
266    fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
267        let entry = self
268            .devices
269            .entry(device_key.to_string())
270            .or_default()
271            .bindings
272            .entry(button)
273            .or_insert_with(|| default_binding_for(button));
274        entry.upgrade_to_gesture();
275        entry
276    }
277
278    /// The button that owns `device_key`'s single gesture role, or `None` when
279    /// gestures are turned off.
280    ///
281    /// Resolved from the explicit [`DeviceConfig::gesture_owner`] when present;
282    /// otherwise inferred (see `Self::infer_gesture_owner`) for configs
283    /// predating the field and freshly-migrated pre-v2 files. The dedicated
284    /// HID++ gesture button ([`ButtonId::GestureButton`]) owns the role by
285    /// default. At most one button gestures per device.
286    #[must_use]
287    pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
288        let Some(device) = self.devices.get(device_key) else {
289            // No config yet → the dedicated HID++ gesture button is the default gesture owner.
290            return Some(ButtonId::GestureButton);
291        };
292        match device.gesture_owner {
293            Some(GestureOwner::Off) => None,
294            Some(GestureOwner::Button(id)) => Some(id),
295            None => Self::infer_gesture_owner(&device.bindings),
296        }
297    }
298
299    /// Infer the gesture owner for a config predating the explicit
300    /// [`DeviceConfig::gesture_owner`] field, from the shape of `bindings` — the
301    /// pre-field behavior, so old/migrated configs keep working until the first
302    /// explicit owner change stamps the field.
303    fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
304        // An OS-hook button left in gesture mode took the role over.
305        if let Some((id, _)) = bindings
306            .iter()
307            .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
308        {
309            return Some(*id);
310        }
311        // A dedicated HID++ gesture button explicitly demoted to a single action means gestures off.
312        if matches!(
313            bindings.get(&ButtonId::GestureButton),
314            Some(Binding::Single(_))
315        ) {
316            return None;
317        }
318        // Default: the dedicated HID++ gesture button owns the gesture role.
319        Some(ButtonId::GestureButton)
320    }
321
322    /// Make `button` the device's sole gesture button.
323    ///
324    /// Records `button` as the explicit [`gesture_owner`](Self::gesture_owner), so
325    /// the one-gesture-button-per-device lock is a data-model fact rather than a
326    /// destructive demotion of the others — every other gesture-capable button
327    /// keeps its own gesture map intact, ready to restore if re-chosen, and is
328    /// simply not dispatched while it isn't the owner. `button` is given a full
329    /// [`Binding::Gesture`] map: a prior [`Binding::Single`] is kept as the
330    /// [`GestureDirection::Click`] action, any existing swipe arms are preserved,
331    /// and unbound directions are seeded from
332    /// [`default_gesture_binding`](crate::binding::default_gesture_binding) so every
333    /// gesture button exposes the same full five-direction set.
334    pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
335        self.devices
336            .entry(device_key.to_string())
337            .or_default()
338            .gesture_owner = Some(GestureOwner::Button(button));
339        self.ensure_gesture_binding(device_key, button)
340            .fill_gesture_defaults();
341    }
342
343    /// Turn gestures off for `device_key`, recording the explicit "off" choice.
344    /// Every button keeps its gesture map intact (nothing is destroyed), so
345    /// re-selecting a gesture owner later restores its directions exactly.
346    pub fn disable_gestures(&mut self, device_key: &str) {
347        self.devices
348            .entry(device_key.to_string())
349            .or_default()
350            .gesture_owner = Some(GestureOwner::Off);
351    }
352
353    /// Resolve the effective binding map for `device_key`, overlaying the
354    /// per-app entry for `bundle_id` (if any) on top of the global per-device
355    /// `bindings`. A per-app override replaces the whole button with a
356    /// [`Binding::Single`]; everything else falls through.
357    ///
358    /// Returns an empty map when the device has no recorded bindings yet.
359    /// Callers (the GUI / hook) layer their own defaults on top.
360    #[must_use]
361    pub fn effective_bindings(
362        &self,
363        device_key: &str,
364        bundle_id: Option<&str>,
365    ) -> BTreeMap<ButtonId, Binding> {
366        let Some(device) = self.devices.get(device_key) else {
367            return BTreeMap::new();
368        };
369        let mut out = device.bindings.clone();
370        if let Some(bid) = bundle_id
371            && let Some(overlay) = device.per_app_bindings.get(bid)
372        {
373            for (k, v) in overlay {
374                out.insert(*k, Binding::Single(v.clone()));
375            }
376        }
377        out
378    }
379
380    /// Records a per-app override. Creates the device + app entries as
381    /// needed; passing an action of `None` removes the override and prunes
382    /// the empty app map.
383    pub fn set_per_app_binding(
384        &mut self,
385        device_key: &str,
386        bundle_id: &str,
387        button: ButtonId,
388        action: Option<Action>,
389    ) {
390        let entry = self
391            .devices
392            .entry(device_key.to_string())
393            .or_default()
394            .per_app_bindings
395            .entry(bundle_id.to_string())
396            .or_default();
397        match action {
398            Some(a) => {
399                entry.insert(button, a);
400            }
401            None => {
402                entry.remove(&button);
403            }
404        }
405        if let Some(d) = self.devices.get_mut(device_key) {
406            d.per_app_bindings.retain(|_, m| !m.is_empty());
407        }
408    }
409
410    /// HID++ config key of the carousel-selected device, if any.
411    #[must_use]
412    pub fn selected_device(&self) -> Option<&str> {
413        self.selected_device.as_deref()
414    }
415
416    /// Update the carousel-selected device. Pass `None` to clear the
417    /// selection (e.g. when the previously-selected device disappears).
418    pub fn set_selected_device(&mut self, key: Option<String>) {
419        self.selected_device = key;
420    }
421
422    /// The ordered DPI preset list for `device_key`, or an empty `Vec` if the
423    /// device has none configured yet.
424    #[must_use]
425    pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
426        self.devices
427            .get(device_key)
428            .map(|d| d.dpi_presets.clone())
429            .unwrap_or_default()
430    }
431
432    /// Replace the DPI preset list for `device_key`. Pass an empty `Vec` to
433    /// clear (the device block is kept; the field is just omitted on save
434    /// thanks to `skip_serializing_if`).
435    pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
436        self.devices
437            .entry(device_key.to_string())
438            .or_default()
439            .dpi_presets = presets;
440    }
441
442    /// The last-known [`DeviceIdentity`] for `device_key`, or `None` if the
443    /// device has never been seen online (or was configured before identities
444    /// were recorded).
445    #[must_use]
446    pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
447        self.devices
448            .get(device_key)
449            .and_then(|d| d.identity.as_ref())
450    }
451
452    /// Record (or refresh) the identity captured for `device_key` while it was
453    /// online, creating the device entry if needed.
454    pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
455        self.devices
456            .entry(device_key.to_string())
457            .or_default()
458            .identity = Some(identity);
459    }
460
461    /// Whether `device_key` has a non-empty per-app binding overlay for the
462    /// foreground app `app` (bundle id). Drives the menu-bar popover's "override
463    /// active" badge — when the current app has its own bindings for this
464    /// device, the global bindings are (partly) overridden.
465    #[must_use]
466    pub fn has_app_override(&self, device_key: &str, app: &str) -> bool {
467        self.devices.get(device_key).is_some_and(|d| {
468            d.per_app_bindings
469                .get(app)
470                .is_some_and(|overlay| !overlay.is_empty())
471        })
472    }
473
474    /// Iterate every device we've recorded an identity for, as
475    /// `(config_key, identity)`. Used to seed offline placeholder cards so a
476    /// known device stays visible (with its panels) before any live probe.
477    pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
478        self.devices
479            .iter()
480            .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
481    }
482
483    /// The lighting config for `device_key`, or `None` if unset.
484    #[must_use]
485    pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
486        self.devices
487            .get(device_key)
488            .and_then(|d| d.lighting.clone())
489    }
490
491    /// Replace the lighting config for `device_key`.
492    pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
493        self.devices
494            .entry(device_key.to_string())
495            .or_default()
496            .lighting = Some(lighting);
497    }
498
499    /// The committed sensor DPI for `device_key`, or `None` if never set.
500    #[must_use]
501    pub fn dpi(&self, device_key: &str) -> Option<u32> {
502        self.devices.get(device_key).and_then(|d| d.dpi)
503    }
504
505    /// Record the committed sensor DPI for `device_key`, so the agent can
506    /// re-apply it when the device reconnects (#189).
507    pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
508        self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
509    }
510
511    /// The SmartShift wheel config for `device_key`, or `None` if never set.
512    #[must_use]
513    pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
514        self.devices.get(device_key).and_then(|d| d.smartshift)
515    }
516
517    /// Record the SmartShift wheel config for `device_key`, so the agent can
518    /// re-apply it when the device reconnects (#189).
519    pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
520        self.devices
521            .entry(device_key.to_string())
522            .or_default()
523            .smartshift = Some(smartshift);
524    }
525
526    /// Whether `device_key`'s scroll wheel is inverted (issue #126). `false`
527    /// (the native direction) for an unconfigured or absent device.
528    #[must_use]
529    pub fn invert_scroll(&self, device_key: &str) -> bool {
530        self.devices
531            .get(device_key)
532            .is_some_and(|d| d.invert_scroll)
533    }
534
535    /// Set whether `device_key`'s scroll wheel is inverted. The agent reads this
536    /// on the next `ReloadConfig` and applies it in the OS hook.
537    pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
538        self.devices
539            .entry(device_key.to_string())
540            .or_default()
541            .invert_scroll = invert;
542    }
543}
544
545/// Write `bytes` to `path` atomically via a randomized temp file + rename,
546/// with the directory fsync the old hand-rolled writer lacked.
547fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
548    #[cfg_attr(
549        not(unix),
550        expect(unused_mut, reason = "only the unix path mutates the options")
551    )]
552    let mut options = AtomicWriteFile::options();
553    #[cfg(unix)]
554    {
555        use atomic_write_file::unix::OpenOptionsExt as _;
556        use std::os::unix::fs::OpenOptionsExt as _;
557        // Force 0600 on every save, matching the previous writer.
558        options.preserve_mode(false).mode(0o600);
559    }
560    let mut file = options.open(path)?;
561    io::Write::write_all(&mut file, bytes)?;
562    file.commit()
563}
564
565#[cfg(test)]
566#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
567mod tests {
568    use std::assert_matches;
569
570    use super::*;
571    use crate::binding::{default_binding, default_gesture_binding};
572
573    fn write_and_read(config: &Config) -> Config {
574        let dir = tempfile::tempdir().expect("tempdir");
575        let path = dir.path().join("config.toml");
576        config.save_to_path(&path).expect("save");
577        Config::load_from_path(&path).expect("load")
578    }
579
580    #[test]
581    fn missing_file_yields_default() {
582        let dir = tempfile::tempdir().expect("tempdir");
583        let path = dir.path().join("nonexistent.toml");
584        let cfg = Config::load_from_path(&path).expect("load");
585        assert_eq!(cfg.schema_version, SCHEMA_VERSION);
586        assert!(cfg.devices.is_empty());
587    }
588
589    #[test]
590    fn lighting_roundtrips_per_device() {
591        let mut cfg = Config::default();
592        cfg.set_lighting(
593            "g513",
594            Lighting {
595                enabled: true,
596                color: "00aabb".parse().expect("valid hex"),
597                brightness: 75,
598            },
599        );
600        let restored = write_and_read(&cfg);
601        assert_eq!(
602            restored.lighting("g513"),
603            Some(Lighting {
604                enabled: true,
605                color: "00aabb".parse().expect("valid hex"),
606                brightness: 75,
607            })
608        );
609        assert_eq!(restored.lighting("absent"), None);
610    }
611
612    #[test]
613    fn unparseable_lighting_color_falls_back_to_white() {
614        let cfg: Config = toml::from_str(
615            r#"
616                schema_version = 3
617                [devices.g513.lighting]
618                enabled = true
619                color = "red"
620                brightness = 50
621            "#,
622        )
623        .expect("config with a bad color still loads");
624        assert_eq!(
625            cfg.lighting("g513").map(|l| l.color),
626            Some(crate::color::Rgb::WHITE)
627        );
628    }
629
630    #[test]
631    fn hash_prefixed_lighting_color_migrates_to_canonical_hex() {
632        let dir = tempfile::tempdir().expect("tempdir");
633        let path = dir.path().join("config.toml");
634        fs::write(
635            &path,
636            r##"
637                schema_version = 3
638                [devices.g513.lighting]
639                enabled = true
640                color = "#ff0000"
641                brightness = 50
642            "##,
643        )
644        .expect("write config");
645
646        let cfg = Config::load_from_path(&path).expect("load hash-prefixed color");
647        assert_eq!(
648            cfg.lighting("g513").map(|lighting| lighting.color),
649            Some(crate::color::Rgb::new(0xff, 0x00, 0x00))
650        );
651
652        cfg.save_to_path(&path).expect("save canonical color");
653        let saved = fs::read_to_string(path).expect("read saved config");
654        assert!(saved.contains("color = \"ff0000\""));
655        assert!(!saved.contains("color = \"#"));
656    }
657
658    #[test]
659    fn dpi_roundtrips_per_device() {
660        let mut cfg = Config::default();
661        cfg.set_dpi("2b042", 1600);
662        let restored = write_and_read(&cfg);
663        assert_eq!(restored.dpi("2b042"), Some(1600));
664        assert_eq!(restored.dpi("absent"), None);
665    }
666
667    #[test]
668    fn smartshift_roundtrips_per_device() {
669        let mut cfg = Config::default();
670        cfg.set_smartshift(
671            "2b042",
672            SmartShift {
673                mode: WheelMode::Ratchet,
674                auto_disengage: 16,
675                tunable_torque: 30,
676            },
677        );
678        let restored = write_and_read(&cfg);
679        assert_eq!(
680            restored.smartshift("2b042"),
681            Some(SmartShift {
682                mode: WheelMode::Ratchet,
683                auto_disengage: 16,
684                tunable_torque: 30,
685            })
686        );
687        assert_eq!(restored.smartshift("absent"), None);
688    }
689
690    #[test]
691    fn invert_scroll_roundtrips_per_device() {
692        let mut cfg = Config::default();
693        // Default is the native direction for any device, present or not.
694        assert!(!cfg.invert_scroll("2b042"));
695        cfg.set_invert_scroll("2b042", true);
696        let restored = write_and_read(&cfg);
697        assert!(restored.invert_scroll("2b042"));
698        assert!(!restored.invert_scroll("absent"));
699    }
700
701    #[test]
702    fn default_invert_scroll_is_omitted_from_toml() {
703        // A device block with only the default (false) invert_scroll must not
704        // emit the field — `skip_serializing_if` keeps configs clean.
705        let mut cfg = Config::default();
706        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
707        cfg.set_invert_scroll("2b042", false);
708        let body = toml::to_string_pretty(&cfg).expect("serialize");
709        assert!(
710            !body.contains("invert_scroll"),
711            "default invert_scroll should be omitted: {body}"
712        );
713    }
714
715    #[test]
716    fn bindings_roundtrip_per_device() {
717        let mut cfg = Config::default();
718        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
719        cfg.set_binding(
720            "2b042",
721            ButtonId::DpiToggle,
722            Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
723                modifiers: crate::binding::KeyCombo::MOD_CMD,
724                key_code: 0x23, // kVK_ANSI_P
725                display: "⌘P".into(),
726            })),
727        );
728        cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
729
730        let parsed = write_and_read(&cfg);
731
732        // Per-device isolation.
733        let a = parsed.bindings_for("2b042");
734        assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
735        assert_eq!(
736            a.get(&ButtonId::DpiToggle),
737            Some(&Binding::Single(Action::CustomShortcut(
738                crate::binding::KeyCombo {
739                    modifiers: crate::binding::KeyCombo::MOD_CMD,
740                    key_code: 0x23,
741                    display: "⌘P".into(),
742                }
743            )))
744        );
745
746        let b = parsed.bindings_for("4082d");
747        assert_eq!(
748            b.get(&ButtonId::Back),
749            Some(&Binding::Single(Action::Paste))
750        );
751        assert_eq!(b.len(), 1, "device b should only see its own bindings");
752
753        // Unknown device returns empty map without panic.
754        assert!(parsed.bindings_for("deadbeef").is_empty());
755    }
756
757    #[test]
758    fn human_readable_toml_layout() {
759        let mut cfg = Config::default();
760        cfg.set_binding(
761            "2b042",
762            ButtonId::Back,
763            Binding::Single(Action::BrowserBack),
764        );
765        let body = toml::to_string_pretty(&cfg).expect("serialize");
766
767        // The key only contains [A-Za-z0-9_], so TOML emits it as a bare-word
768        // table key (no surrounding quotes). The test asserts the observable
769        // structure rather than locking in a specific quoting.
770        assert!(body.contains("schema_version = 3"), "got: {body}");
771        assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
772        // A `Single` binding serializes byte-identically to the pre-v2 bare
773        // `Action`, so the leaf line is unchanged.
774        assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
775    }
776
777    #[test]
778    fn dpi_presets_roundtrip_per_device() {
779        let mut cfg = Config::default();
780        cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
781        cfg.set_dpi_presets("4082d", vec![400, 1600]);
782
783        let parsed = write_and_read(&cfg);
784
785        assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
786        assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
787        assert!(parsed.dpi_presets("unknown").is_empty());
788    }
789
790    #[test]
791    fn empty_dpi_presets_skip_serialization() {
792        let mut cfg = Config::default();
793        // Add a binding so the device block exists.
794        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
795        cfg.set_dpi_presets("2b042", vec![800]);
796        cfg.set_dpi_presets("2b042", vec![]); // clear
797
798        let body = toml::to_string_pretty(&cfg).expect("serialize");
799        assert!(
800            !body.contains("dpi_presets"),
801            "empty dpi_presets should be omitted: {body}"
802        );
803    }
804
805    #[test]
806    fn device_identity_roundtrips_and_is_iterable() {
807        use crate::device::{Capabilities, DeviceKind};
808
809        let mut cfg = Config::default();
810        let mouse = DeviceIdentity {
811            display_name: "MX Master 3S".to_string(),
812            model_info: None,
813            codename: None,
814            kind: DeviceKind::Mouse,
815            capabilities: Capabilities {
816                buttons: true,
817                pointer: true,
818                lighting: false,
819                scroll_inversion: false,
820            },
821        };
822        cfg.set_device_identity("2b034", mouse.clone());
823        // Recording an identity must not disturb unrelated per-device state.
824        cfg.set_binding(
825            "2b034",
826            ButtonId::Back,
827            Binding::Single(Action::BrowserBack),
828        );
829
830        let parsed = write_and_read(&cfg);
831        assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
832        assert_eq!(parsed.device_identity("absent"), None);
833        assert_eq!(
834            parsed.bindings_for("2b034").get(&ButtonId::Back),
835            Some(&Binding::Single(Action::BrowserBack)),
836            "identity must coexist with bindings on the same device block"
837        );
838        assert_eq!(
839            parsed.known_identities().collect::<Vec<_>>(),
840            vec![("2b034", &mouse)]
841        );
842    }
843
844    #[test]
845    fn selected_device_roundtrips() {
846        let mut cfg = Config::default();
847        assert_eq!(cfg.selected_device(), None);
848        cfg.set_selected_device(Some("2b042".into()));
849        let parsed = write_and_read(&cfg);
850        assert_eq!(parsed.selected_device(), Some("2b042"));
851    }
852
853    #[test]
854    fn per_app_overlay_takes_precedence() {
855        let mut cfg = Config::default();
856        cfg.set_binding(
857            "2b042",
858            ButtonId::Back,
859            Binding::Single(Action::BrowserBack),
860        );
861        cfg.set_binding(
862            "2b042",
863            ButtonId::Forward,
864            Binding::Single(Action::BrowserForward),
865        );
866        cfg.set_per_app_binding(
867            "2b042",
868            "com.microsoft.VSCode",
869            ButtonId::Back,
870            Some(Action::Undo),
871        );
872
873        // Global: both buttons are browser nav.
874        let global = cfg.effective_bindings("2b042", None);
875        assert_eq!(
876            global.get(&ButtonId::Back),
877            Some(&Binding::Single(Action::BrowserBack))
878        );
879        assert_eq!(
880            global.get(&ButtonId::Forward),
881            Some(&Binding::Single(Action::BrowserForward))
882        );
883
884        // VSCode: Back overridden (wrapped as Single), Forward inherits.
885        let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
886        assert_eq!(
887            vscode.get(&ButtonId::Back),
888            Some(&Binding::Single(Action::Undo))
889        );
890        assert_eq!(
891            vscode.get(&ButtonId::Forward),
892            Some(&Binding::Single(Action::BrowserForward))
893        );
894
895        // Unrelated app falls through.
896        let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
897        assert_eq!(
898            other.get(&ButtonId::Back),
899            Some(&Binding::Single(Action::BrowserBack))
900        );
901    }
902
903    #[test]
904    fn per_app_binding_removal_prunes_empty_app() {
905        let mut cfg = Config::default();
906        cfg.set_per_app_binding(
907            "2b042",
908            "com.example.App",
909            ButtonId::Back,
910            Some(Action::Copy),
911        );
912        cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
913        assert!(
914            cfg.devices["2b042"].per_app_bindings.is_empty(),
915            "removing last override should prune the app entry"
916        );
917    }
918
919    #[test]
920    fn app_settings_default_omits_block() {
921        let cfg = Config::default();
922        let body = toml::to_string_pretty(&cfg).expect("serialize");
923        assert!(
924            !body.contains("app_settings"),
925            "default app_settings should be omitted: {body}"
926        );
927    }
928
929    #[test]
930    fn app_settings_launch_at_login_roundtrips() {
931        let mut cfg = Config::default();
932        cfg.app_settings.launch_at_login = true;
933        let parsed = write_and_read(&cfg);
934        assert!(parsed.app_settings.launch_at_login);
935    }
936
937    #[test]
938    fn cleared_selected_device_omits_field() {
939        let mut cfg = Config::default();
940        cfg.set_selected_device(Some("2b042".into()));
941        cfg.set_selected_device(None);
942        let body = toml::to_string_pretty(&cfg).expect("serialize");
943        assert!(
944            !body.contains("selected_device"),
945            "cleared selection should not appear: {body}"
946        );
947    }
948
949    #[test]
950    fn empty_device_block_is_skipped_in_output() {
951        // Inserting then clearing should not leave a [devices."x"] header
952        // with no bindings under it (skip_serializing_if on bindings).
953        let mut cfg = Config::default();
954        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
955        cfg.devices
956            .get_mut("2b042")
957            .expect("entry")
958            .bindings
959            .clear();
960        let body = toml::to_string_pretty(&cfg).expect("serialize");
961        assert!(
962            !body.contains("Back"),
963            "cleared bindings should not appear: {body}"
964        );
965    }
966
967    #[test]
968    fn migrates_v1_button_and_gesture_bindings() {
969        // A pre-v2 file: split button_bindings + a flat gesture_bindings map.
970        let v1 = "\
971schema_version = 1
972
973[devices.2b042.button_bindings]
974Back = \"BrowserBack\"
975
976[devices.2b042.gesture_bindings]
977Up = \"Copy\"
978Click = \"Paste\"
979";
980        let dir = tempfile::tempdir().expect("tempdir");
981        let path = dir.path().join("config.toml");
982        fs::write(&path, v1).expect("write");
983
984        // v1 still loads (version <= current) and folds into the merged map.
985        let cfg = Config::load_from_path(&path).expect("load v1");
986        let bindings = cfg.bindings_for("2b042");
987        assert_eq!(
988            bindings.get(&ButtonId::Back),
989            Some(&Binding::Single(Action::BrowserBack))
990        );
991        let mut gesture = BTreeMap::new();
992        gesture.insert(GestureDirection::Up, Action::Copy);
993        gesture.insert(GestureDirection::Click, Action::Paste);
994        assert_eq!(
995            bindings.get(&ButtonId::GestureButton),
996            Some(&Binding::Gesture(gesture))
997        );
998
999        // Saving self-heals to the current shape: stamped version + merged table,
1000        // legacy field names gone.
1001        let body = toml::to_string_pretty(&cfg).expect("serialize");
1002        assert!(body.contains("schema_version = 3"), "got: {body}");
1003        assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1004        assert!(!body.contains("button_bindings"), "got: {body}");
1005        assert!(!body.contains("gesture_bindings"), "got: {body}");
1006    }
1007
1008    #[test]
1009    fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1010        // The data-loss guard: when a legacy single button_bindings[GestureButton]
1011        // entry coexists with a gesture_bindings map (reachable via hand-edited
1012        // or very old configs), the gesture map must survive — not be shadowed by
1013        // the single entry. Mirrors the pre-v2 "gesture entries win" rule.
1014        let v1 = "\
1015schema_version = 1
1016
1017[devices.2b042.button_bindings]
1018GestureButton = \"MissionControl\"
1019
1020[devices.2b042.gesture_bindings]
1021Up = \"Copy\"
1022Down = \"Paste\"
1023";
1024        let dir = tempfile::tempdir().expect("tempdir");
1025        let path = dir.path().join("config.toml");
1026        fs::write(&path, v1).expect("write");
1027
1028        let cfg = Config::load_from_path(&path).expect("load v1");
1029        let mut gesture = BTreeMap::new();
1030        gesture.insert(GestureDirection::Up, Action::Copy);
1031        gesture.insert(GestureDirection::Down, Action::Paste);
1032        assert_eq!(
1033            cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1034            Some(&Binding::Gesture(gesture)),
1035            "gesture map must win over the legacy single GestureButton entry"
1036        );
1037    }
1038
1039    #[test]
1040    fn migration_drops_vestigial_lone_gesture_button_single() {
1041        // A v1 file with only `button_bindings[GestureButton]` and no
1042        // `gesture_bindings` (the pre-gesture-picker shape). That entry never
1043        // dispatched in v1 — the gesture button's plain press routes through the
1044        // gesture `Click` slot, not the per-button map — so migrating it to a
1045        // `Binding::Single` would leave an unreachable entry the GUI hides and the
1046        // runtime ignores. It must be dropped, not shadow the gesture path.
1047        let v1 = "\
1048schema_version = 1
1049
1050[devices.2b042.button_bindings]
1051GestureButton = \"MissionControl\"
1052Back = \"BrowserBack\"
1053";
1054        let dir = tempfile::tempdir().expect("tempdir");
1055        let path = dir.path().join("config.toml");
1056        fs::write(&path, v1).expect("write");
1057
1058        let bindings = Config::load_from_path(&path)
1059            .expect("load v1")
1060            .bindings_for("2b042");
1061        // An ordinary button still migrates to a `Single`...
1062        assert_eq!(
1063            bindings.get(&ButtonId::Back),
1064            Some(&Binding::Single(Action::BrowserBack))
1065        );
1066        // ...but the vestigial gesture-button single is gone, leaving the button
1067        // to fall back to its canonical default rather than an unreachable entry.
1068        assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1069    }
1070
1071    #[test]
1072    fn rejects_newer_schema_version_but_accepts_v1() {
1073        // A future version is rejected loudly; the current and older versions
1074        // load (older ones migrate through the shim).
1075        let dir = tempfile::tempdir().expect("tempdir");
1076        let path = dir.path().join("config.toml");
1077        fs::write(&path, "schema_version = 99\n").expect("write");
1078        assert_matches!(
1079            Config::load_from_path(&path).expect_err("v99 should fail"),
1080            ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1081        );
1082
1083        fs::write(&path, "schema_version = 1\n").expect("write");
1084        assert!(
1085            Config::load_from_path(&path).is_ok(),
1086            "v1 should still load"
1087        );
1088    }
1089
1090    #[test]
1091    fn set_gesture_direction_upgrades_single_to_gesture() {
1092        let mut cfg = Config::default();
1093        // Start from a Single binding, then bind a swipe direction.
1094        cfg.set_binding(
1095            "2b042",
1096            ButtonId::Back,
1097            Binding::Single(Action::BrowserBack),
1098        );
1099        cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1100
1101        match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1102            Some(Binding::Gesture(map)) => {
1103                // The prior single action is preserved as the Click entry.
1104                assert_eq!(
1105                    map.get(&GestureDirection::Click),
1106                    Some(&Action::BrowserBack)
1107                );
1108                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1109            }
1110            other => panic!("expected Gesture after upgrade, got {other:?}"),
1111        }
1112    }
1113
1114    #[test]
1115    fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1116        // Binding one direction on a never-configured gesture button must still
1117        // persist a `Click`, so the click projection is the canonical default
1118        // rather than `Action::None` (which reads as a no-op press).
1119        let mut cfg = Config::default();
1120        cfg.set_gesture_direction(
1121            "2b042",
1122            ButtonId::GestureButton,
1123            GestureDirection::Up,
1124            Action::Copy,
1125        );
1126
1127        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1128            Some(Binding::Gesture(map)) => {
1129                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1130                assert_eq!(
1131                    map.get(&GestureDirection::Click),
1132                    Some(&crate::binding::default_gesture_binding(
1133                        GestureDirection::Click
1134                    )),
1135                    "a fresh gesture button must seed a Click from its default"
1136                );
1137            }
1138            other => panic!("expected Gesture, got {other:?}"),
1139        }
1140    }
1141
1142    #[test]
1143    fn gesture_owner_defaults_to_hidpp_button_yields_to_oshook_and_can_be_off() {
1144        let mut cfg = Config::default();
1145        // Default: the dedicated HID++ gesture button owns the gesture role even with no config.
1146        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1147
1148        // A dedicated HID++ gesture binding keeps it the owner.
1149        cfg.set_gesture_direction(
1150            "2b042",
1151            ButtonId::GestureButton,
1152            GestureDirection::Up,
1153            Action::MissionControl,
1154        );
1155        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1156
1157        // An explicit OS-hook gesture button takes the role over.
1158        cfg.set_binding(
1159            "2b042",
1160            ButtonId::Forward,
1161            Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1162        );
1163        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1164
1165        // Turning gestures off explicitly yields `None` (not the HID++ button default).
1166        let mut off = Config::default();
1167        off.disable_gestures("2b042");
1168        assert_eq!(off.gesture_owner("2b042"), None);
1169    }
1170
1171    #[test]
1172    fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1173        let mut cfg = Config::default();
1174        // Customize the dedicated HID++ gesture button's Up swipe; it is the (inferred) owner.
1175        cfg.set_gesture_direction(
1176            "2b042",
1177            ButtonId::GestureButton,
1178            GestureDirection::Up,
1179            Action::Copy,
1180        );
1181        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1182
1183        // Promote Back: the owner becomes Back explicitly; the HID++ gesture button keeps
1184        // its full gesture map (no destructive demotion).
1185        cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1186        cfg.set_gesture_owner("2b042", ButtonId::Back);
1187        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1188
1189        let bindings = cfg.bindings_for("2b042");
1190        // Back is a full five-direction gesture button: its prior single action
1191        // stays as Click, and the swipe arms are seeded from defaults.
1192        match bindings.get(&ButtonId::Back) {
1193            Some(Binding::Gesture(map)) => {
1194                assert_eq!(
1195                    map.get(&GestureDirection::Click),
1196                    Some(&Action::BrowserBack)
1197                );
1198                assert_eq!(
1199                    map.get(&GestureDirection::Up),
1200                    Some(&default_gesture_binding(GestureDirection::Up)),
1201                    "a promoted button gets full default arms"
1202                );
1203            }
1204            other => panic!("expected Back to be a gesture binding, got {other:?}"),
1205        }
1206        // The HID++ gesture button's customized map survived the switch intact.
1207        match bindings.get(&ButtonId::GestureButton) {
1208            Some(Binding::Gesture(map)) => {
1209                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1210            }
1211            other => panic!("expected the HID++ gesture button map preserved, got {other:?}"),
1212        }
1213
1214        // Switching back restores the user's customization, not defaults
1215        // (regression guard: owner-switch used to discard the swipe arms).
1216        cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1217        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1218        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1219            Some(Binding::Gesture(map)) => {
1220                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1221            }
1222            other => panic!("expected preserved gesture map, got {other:?}"),
1223        }
1224    }
1225
1226    #[test]
1227    fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1228        let mut cfg = Config::default();
1229        // The dedicated HID++ gesture button gets the full default direction map.
1230        cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1231        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1232            Some(Binding::Gesture(map)) => {
1233                for dir in GestureDirection::ALL {
1234                    assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1235                }
1236            }
1237            other => panic!("expected full default gesture map, got {other:?}"),
1238        }
1239
1240        // A fresh OS-hook button also gets all five directions, not just a Click:
1241        // its native action stays as Click, and the swipe arms are defaults — so
1242        // the GUI's shown defaults are exactly what the runtime dispatches.
1243        cfg.set_gesture_owner("2b042", ButtonId::Forward);
1244        match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1245            Some(Binding::Gesture(map)) => {
1246                assert_eq!(
1247                    map.get(&GestureDirection::Click),
1248                    Some(&default_binding(ButtonId::Forward))
1249                );
1250                for dir in [
1251                    GestureDirection::Up,
1252                    GestureDirection::Down,
1253                    GestureDirection::Left,
1254                    GestureDirection::Right,
1255                ] {
1256                    assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1257                }
1258            }
1259            other => panic!("expected full gesture map for Forward, got {other:?}"),
1260        }
1261    }
1262
1263    #[test]
1264    fn disable_gestures_turns_off_without_destroying_maps() {
1265        let mut cfg = Config::default();
1266        cfg.set_gesture_direction(
1267            "2b042",
1268            ButtonId::GestureButton,
1269            GestureDirection::Up,
1270            Action::Copy,
1271        );
1272        cfg.disable_gestures("2b042");
1273        // Off, but the HID++ gesture button's customized map is preserved (re-enabling
1274        // restores it rather than resurrecting a wiped default).
1275        assert_eq!(cfg.gesture_owner("2b042"), None);
1276        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1277            Some(Binding::Gesture(map)) => {
1278                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1279            }
1280            other => panic!("expected the gesture map preserved while off, got {other:?}"),
1281        }
1282    }
1283
1284    #[test]
1285    fn gesture_owner_field_roundtrips_as_a_scalar() {
1286        let mut cfg = Config::default();
1287        cfg.set_gesture_owner("2b042", ButtonId::Back); // explicit button
1288        cfg.disable_gestures("4082d"); // explicit off
1289
1290        let parsed = write_and_read(&cfg);
1291        assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1292        assert_eq!(parsed.gesture_owner("4082d"), None);
1293
1294        // The custom codec keeps it a bare TOML string (a nested table would risk
1295        // a value-after-table serialization error, since `bindings` is a table).
1296        let body = toml::to_string_pretty(&cfg).expect("serialize");
1297        assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1298        assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1299    }
1300
1301    #[test]
1302    fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1303        // A hand-edit typo in gesture_owner must NOT fail the whole-document parse
1304        // (which would revert every device's settings to defaults). It degrades
1305        // to "infer" while the rest of the device config survives.
1306        let toml = "\
1307schema_version = 2
1308
1309[devices.2b042]
1310gesture_owner = \"bogus\"
1311
1312[devices.2b042.bindings]
1313Back = \"Copy\"
1314";
1315        let dir = tempfile::tempdir().expect("tempdir");
1316        let path = dir.path().join("config.toml");
1317        fs::write(&path, toml).expect("write");
1318
1319        let cfg =
1320            Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1321        // The rest of the device config survived...
1322        assert_eq!(
1323            cfg.bindings_for("2b042").get(&ButtonId::Back),
1324            Some(&Binding::Single(Action::Copy))
1325        );
1326        // ...and the bad owner degraded to inference (HID++ button default here).
1327        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1328    }
1329}