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, ScrollResolution, 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    /// The configured wheel resolution for `device_key`, or `None` when
545    /// OpenLogi should leave the device's current resolution unchanged.
546    #[must_use]
547    pub fn scroll_resolution(&self, device_key: &str) -> Option<ScrollResolution> {
548        self.devices
549            .get(device_key)
550            .and_then(|device| device.scroll_resolution)
551    }
552
553    /// Set the wheel resolution OpenLogi should restore for `device_key`.
554    /// Passing `None` returns the device to its unmanaged default state.
555    pub fn set_scroll_resolution(
556        &mut self,
557        device_key: &str,
558        resolution: Option<ScrollResolution>,
559    ) {
560        self.devices
561            .entry(device_key.to_string())
562            .or_default()
563            .scroll_resolution = resolution;
564    }
565}
566
567/// Write `bytes` to `path` atomically via a randomized temp file + rename,
568/// with the directory fsync the old hand-rolled writer lacked.
569fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
570    #[cfg_attr(
571        not(unix),
572        expect(unused_mut, reason = "only the unix path mutates the options")
573    )]
574    let mut options = AtomicWriteFile::options();
575    #[cfg(unix)]
576    {
577        use atomic_write_file::unix::OpenOptionsExt as _;
578        use std::os::unix::fs::OpenOptionsExt as _;
579        // Force 0600 on every save, matching the previous writer.
580        options.preserve_mode(false).mode(0o600);
581    }
582    let mut file = options.open(path)?;
583    io::Write::write_all(&mut file, bytes)?;
584    file.commit()
585}
586
587#[cfg(test)]
588#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
589mod tests {
590    use std::assert_matches;
591
592    use super::*;
593    use crate::binding::{default_binding, default_gesture_binding};
594
595    fn write_and_read(config: &Config) -> Config {
596        let dir = tempfile::tempdir().expect("tempdir");
597        let path = dir.path().join("config.toml");
598        config.save_to_path(&path).expect("save");
599        Config::load_from_path(&path).expect("load")
600    }
601
602    #[test]
603    fn missing_file_yields_default() {
604        let dir = tempfile::tempdir().expect("tempdir");
605        let path = dir.path().join("nonexistent.toml");
606        let cfg = Config::load_from_path(&path).expect("load");
607        assert_eq!(cfg.schema_version, SCHEMA_VERSION);
608        assert!(cfg.devices.is_empty());
609    }
610
611    #[test]
612    fn lighting_roundtrips_per_device() {
613        let mut cfg = Config::default();
614        cfg.set_lighting(
615            "g513",
616            Lighting {
617                enabled: true,
618                color: "00aabb".parse().expect("valid hex"),
619                brightness: 75,
620            },
621        );
622        let restored = write_and_read(&cfg);
623        assert_eq!(
624            restored.lighting("g513"),
625            Some(Lighting {
626                enabled: true,
627                color: "00aabb".parse().expect("valid hex"),
628                brightness: 75,
629            })
630        );
631        assert_eq!(restored.lighting("absent"), None);
632    }
633
634    #[test]
635    fn unparseable_lighting_color_falls_back_to_white() {
636        let cfg: Config = toml::from_str(
637            r#"
638                schema_version = 3
639                [devices.g513.lighting]
640                enabled = true
641                color = "red"
642                brightness = 50
643            "#,
644        )
645        .expect("config with a bad color still loads");
646        assert_eq!(
647            cfg.lighting("g513").map(|l| l.color),
648            Some(crate::color::Rgb::WHITE)
649        );
650    }
651
652    #[test]
653    fn hash_prefixed_lighting_color_migrates_to_canonical_hex() {
654        let dir = tempfile::tempdir().expect("tempdir");
655        let path = dir.path().join("config.toml");
656        fs::write(
657            &path,
658            r##"
659                schema_version = 3
660                [devices.g513.lighting]
661                enabled = true
662                color = "#ff0000"
663                brightness = 50
664            "##,
665        )
666        .expect("write config");
667
668        let cfg = Config::load_from_path(&path).expect("load hash-prefixed color");
669        assert_eq!(
670            cfg.lighting("g513").map(|lighting| lighting.color),
671            Some(crate::color::Rgb::new(0xff, 0x00, 0x00))
672        );
673
674        cfg.save_to_path(&path).expect("save canonical color");
675        let saved = fs::read_to_string(path).expect("read saved config");
676        assert!(saved.contains("color = \"ff0000\""));
677        assert!(!saved.contains("color = \"#"));
678    }
679
680    #[test]
681    fn dpi_roundtrips_per_device() {
682        let mut cfg = Config::default();
683        cfg.set_dpi("2b042", 1600);
684        let restored = write_and_read(&cfg);
685        assert_eq!(restored.dpi("2b042"), Some(1600));
686        assert_eq!(restored.dpi("absent"), None);
687    }
688
689    #[test]
690    fn smartshift_roundtrips_per_device() {
691        let mut cfg = Config::default();
692        cfg.set_smartshift(
693            "2b042",
694            SmartShift {
695                mode: WheelMode::Ratchet,
696                auto_disengage: 16,
697                tunable_torque: 30,
698            },
699        );
700        let restored = write_and_read(&cfg);
701        assert_eq!(
702            restored.smartshift("2b042"),
703            Some(SmartShift {
704                mode: WheelMode::Ratchet,
705                auto_disengage: 16,
706                tunable_torque: 30,
707            })
708        );
709        assert_eq!(restored.smartshift("absent"), None);
710    }
711
712    #[test]
713    fn invert_scroll_roundtrips_per_device() {
714        let mut cfg = Config::default();
715        // Default is the native direction for any device, present or not.
716        assert!(!cfg.invert_scroll("2b042"));
717        cfg.set_invert_scroll("2b042", true);
718        let restored = write_and_read(&cfg);
719        assert!(restored.invert_scroll("2b042"));
720        assert!(!restored.invert_scroll("absent"));
721    }
722
723    #[test]
724    fn default_invert_scroll_is_omitted_from_toml() {
725        // A device block with only the default (false) invert_scroll must not
726        // emit the field — `skip_serializing_if` keeps configs clean.
727        let mut cfg = Config::default();
728        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
729        cfg.set_invert_scroll("2b042", false);
730        let body = toml::to_string_pretty(&cfg).expect("serialize");
731        assert!(
732            !body.contains("invert_scroll"),
733            "default invert_scroll should be omitted: {body}"
734        );
735    }
736
737    #[test]
738    fn scroll_resolution_roundtrips_all_three_states() {
739        let mut cfg = Config::default();
740        assert_eq!(cfg.scroll_resolution("mouse"), None);
741
742        cfg.set_scroll_resolution("mouse", Some(ScrollResolution::Low));
743        let low = write_and_read(&cfg);
744        assert_eq!(low.scroll_resolution("mouse"), Some(ScrollResolution::Low));
745
746        cfg.set_scroll_resolution("mouse", Some(ScrollResolution::High));
747        let high = write_and_read(&cfg);
748        assert_eq!(
749            high.scroll_resolution("mouse"),
750            Some(ScrollResolution::High)
751        );
752
753        cfg.set_scroll_resolution("mouse", None);
754        let unmanaged = write_and_read(&cfg);
755        assert_eq!(unmanaged.scroll_resolution("mouse"), None);
756    }
757
758    #[test]
759    fn unset_scroll_resolution_is_omitted_from_toml() {
760        let mut cfg = Config::default();
761        cfg.set_binding("mouse", ButtonId::Back, Binding::Single(Action::Copy));
762        cfg.set_scroll_resolution("mouse", Some(ScrollResolution::Low));
763        cfg.set_scroll_resolution("mouse", None);
764
765        let body = toml::to_string_pretty(&cfg).expect("serialize");
766        assert!(
767            !body.contains("scroll_resolution"),
768            "unset scroll resolution should be omitted: {body}"
769        );
770    }
771
772    #[test]
773    fn config_without_scroll_resolution_loads_as_unmanaged() {
774        let dir = tempfile::tempdir().expect("tempdir");
775        let path = dir.path().join("config.toml");
776        fs::write(
777            &path,
778            r"
779                schema_version = 3
780                [devices.mouse]
781                invert_scroll = true
782            ",
783        )
784        .expect("write config");
785
786        let cfg = Config::load_from_path(&path).expect("load existing config");
787        assert_eq!(cfg.scroll_resolution("mouse"), None);
788        assert!(cfg.invert_scroll("mouse"));
789    }
790
791    #[test]
792    fn bindings_roundtrip_per_device() {
793        let mut cfg = Config::default();
794        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
795        cfg.set_binding(
796            "2b042",
797            ButtonId::DpiToggle,
798            Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
799                modifiers: crate::binding::KeyCombo::MOD_CMD,
800                key_code: 0x23, // kVK_ANSI_P
801                display: "⌘P".into(),
802            })),
803        );
804        cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
805
806        let parsed = write_and_read(&cfg);
807
808        // Per-device isolation.
809        let a = parsed.bindings_for("2b042");
810        assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
811        assert_eq!(
812            a.get(&ButtonId::DpiToggle),
813            Some(&Binding::Single(Action::CustomShortcut(
814                crate::binding::KeyCombo {
815                    modifiers: crate::binding::KeyCombo::MOD_CMD,
816                    key_code: 0x23,
817                    display: "⌘P".into(),
818                }
819            )))
820        );
821
822        let b = parsed.bindings_for("4082d");
823        assert_eq!(
824            b.get(&ButtonId::Back),
825            Some(&Binding::Single(Action::Paste))
826        );
827        assert_eq!(b.len(), 1, "device b should only see its own bindings");
828
829        // Unknown device returns empty map without panic.
830        assert!(parsed.bindings_for("deadbeef").is_empty());
831    }
832
833    #[test]
834    fn human_readable_toml_layout() {
835        let mut cfg = Config::default();
836        cfg.set_binding(
837            "2b042",
838            ButtonId::Back,
839            Binding::Single(Action::BrowserBack),
840        );
841        let body = toml::to_string_pretty(&cfg).expect("serialize");
842
843        // The key only contains [A-Za-z0-9_], so TOML emits it as a bare-word
844        // table key (no surrounding quotes). The test asserts the observable
845        // structure rather than locking in a specific quoting.
846        assert!(body.contains("schema_version = 3"), "got: {body}");
847        assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
848        // A `Single` binding serializes byte-identically to the pre-v2 bare
849        // `Action`, so the leaf line is unchanged.
850        assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
851    }
852
853    #[test]
854    fn dpi_presets_roundtrip_per_device() {
855        let mut cfg = Config::default();
856        cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
857        cfg.set_dpi_presets("4082d", vec![400, 1600]);
858
859        let parsed = write_and_read(&cfg);
860
861        assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
862        assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
863        assert!(parsed.dpi_presets("unknown").is_empty());
864    }
865
866    #[test]
867    fn empty_dpi_presets_skip_serialization() {
868        let mut cfg = Config::default();
869        // Add a binding so the device block exists.
870        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
871        cfg.set_dpi_presets("2b042", vec![800]);
872        cfg.set_dpi_presets("2b042", vec![]); // clear
873
874        let body = toml::to_string_pretty(&cfg).expect("serialize");
875        assert!(
876            !body.contains("dpi_presets"),
877            "empty dpi_presets should be omitted: {body}"
878        );
879    }
880
881    #[test]
882    fn device_identity_roundtrips_and_is_iterable() {
883        use crate::device::{Capabilities, DeviceKind};
884
885        let mut cfg = Config::default();
886        let mouse = DeviceIdentity {
887            display_name: "MX Master 3S".to_string(),
888            model_info: None,
889            codename: None,
890            kind: DeviceKind::Mouse,
891            capabilities: Capabilities {
892                buttons: true,
893                pointer: true,
894                lighting: false,
895                scroll_inversion: false,
896                hires_wheel: true,
897            },
898        };
899        cfg.set_device_identity("2b034", mouse.clone());
900        // Recording an identity must not disturb unrelated per-device state.
901        cfg.set_binding(
902            "2b034",
903            ButtonId::Back,
904            Binding::Single(Action::BrowserBack),
905        );
906
907        let parsed = write_and_read(&cfg);
908        assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
909        assert_eq!(parsed.device_identity("absent"), None);
910        assert_eq!(
911            parsed.bindings_for("2b034").get(&ButtonId::Back),
912            Some(&Binding::Single(Action::BrowserBack)),
913            "identity must coexist with bindings on the same device block"
914        );
915        assert_eq!(
916            parsed.known_identities().collect::<Vec<_>>(),
917            vec![("2b034", &mouse)]
918        );
919    }
920
921    #[test]
922    fn selected_device_roundtrips() {
923        let mut cfg = Config::default();
924        assert_eq!(cfg.selected_device(), None);
925        cfg.set_selected_device(Some("2b042".into()));
926        let parsed = write_and_read(&cfg);
927        assert_eq!(parsed.selected_device(), Some("2b042"));
928    }
929
930    #[test]
931    fn per_app_overlay_takes_precedence() {
932        let mut cfg = Config::default();
933        cfg.set_binding(
934            "2b042",
935            ButtonId::Back,
936            Binding::Single(Action::BrowserBack),
937        );
938        cfg.set_binding(
939            "2b042",
940            ButtonId::Forward,
941            Binding::Single(Action::BrowserForward),
942        );
943        cfg.set_per_app_binding(
944            "2b042",
945            "com.microsoft.VSCode",
946            ButtonId::Back,
947            Some(Action::Undo),
948        );
949
950        // Global: both buttons are browser nav.
951        let global = cfg.effective_bindings("2b042", None);
952        assert_eq!(
953            global.get(&ButtonId::Back),
954            Some(&Binding::Single(Action::BrowserBack))
955        );
956        assert_eq!(
957            global.get(&ButtonId::Forward),
958            Some(&Binding::Single(Action::BrowserForward))
959        );
960
961        // VSCode: Back overridden (wrapped as Single), Forward inherits.
962        let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
963        assert_eq!(
964            vscode.get(&ButtonId::Back),
965            Some(&Binding::Single(Action::Undo))
966        );
967        assert_eq!(
968            vscode.get(&ButtonId::Forward),
969            Some(&Binding::Single(Action::BrowserForward))
970        );
971
972        // Unrelated app falls through.
973        let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
974        assert_eq!(
975            other.get(&ButtonId::Back),
976            Some(&Binding::Single(Action::BrowserBack))
977        );
978    }
979
980    #[test]
981    fn per_app_binding_removal_prunes_empty_app() {
982        let mut cfg = Config::default();
983        cfg.set_per_app_binding(
984            "2b042",
985            "com.example.App",
986            ButtonId::Back,
987            Some(Action::Copy),
988        );
989        cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
990        assert!(
991            cfg.devices["2b042"].per_app_bindings.is_empty(),
992            "removing last override should prune the app entry"
993        );
994    }
995
996    #[test]
997    fn app_settings_default_omits_block() {
998        let cfg = Config::default();
999        let body = toml::to_string_pretty(&cfg).expect("serialize");
1000        assert!(
1001            !body.contains("app_settings"),
1002            "default app_settings should be omitted: {body}"
1003        );
1004    }
1005
1006    #[test]
1007    fn app_settings_launch_at_login_roundtrips() {
1008        let mut cfg = Config::default();
1009        cfg.app_settings.launch_at_login = true;
1010        let parsed = write_and_read(&cfg);
1011        assert!(parsed.app_settings.launch_at_login);
1012    }
1013
1014    #[test]
1015    fn cleared_selected_device_omits_field() {
1016        let mut cfg = Config::default();
1017        cfg.set_selected_device(Some("2b042".into()));
1018        cfg.set_selected_device(None);
1019        let body = toml::to_string_pretty(&cfg).expect("serialize");
1020        assert!(
1021            !body.contains("selected_device"),
1022            "cleared selection should not appear: {body}"
1023        );
1024    }
1025
1026    #[test]
1027    fn empty_device_block_is_skipped_in_output() {
1028        // Inserting then clearing should not leave a [devices."x"] header
1029        // with no bindings under it (skip_serializing_if on bindings).
1030        let mut cfg = Config::default();
1031        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1032        cfg.devices
1033            .get_mut("2b042")
1034            .expect("entry")
1035            .bindings
1036            .clear();
1037        let body = toml::to_string_pretty(&cfg).expect("serialize");
1038        assert!(
1039            !body.contains("Back"),
1040            "cleared bindings should not appear: {body}"
1041        );
1042    }
1043
1044    #[test]
1045    fn migrates_v1_button_and_gesture_bindings() {
1046        // A pre-v2 file: split button_bindings + a flat gesture_bindings map.
1047        let v1 = "\
1048schema_version = 1
1049
1050[devices.2b042.button_bindings]
1051Back = \"BrowserBack\"
1052
1053[devices.2b042.gesture_bindings]
1054Up = \"Copy\"
1055Click = \"Paste\"
1056";
1057        let dir = tempfile::tempdir().expect("tempdir");
1058        let path = dir.path().join("config.toml");
1059        fs::write(&path, v1).expect("write");
1060
1061        // v1 still loads (version <= current) and folds into the merged map.
1062        let cfg = Config::load_from_path(&path).expect("load v1");
1063        let bindings = cfg.bindings_for("2b042");
1064        assert_eq!(
1065            bindings.get(&ButtonId::Back),
1066            Some(&Binding::Single(Action::BrowserBack))
1067        );
1068        let mut gesture = BTreeMap::new();
1069        gesture.insert(GestureDirection::Up, Action::Copy);
1070        gesture.insert(GestureDirection::Click, Action::Paste);
1071        assert_eq!(
1072            bindings.get(&ButtonId::GestureButton),
1073            Some(&Binding::Gesture(gesture))
1074        );
1075
1076        // Saving self-heals to the current shape: stamped version + merged table,
1077        // legacy field names gone.
1078        let body = toml::to_string_pretty(&cfg).expect("serialize");
1079        assert!(body.contains("schema_version = 3"), "got: {body}");
1080        assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1081        assert!(!body.contains("button_bindings"), "got: {body}");
1082        assert!(!body.contains("gesture_bindings"), "got: {body}");
1083    }
1084
1085    #[test]
1086    fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1087        // The data-loss guard: when a legacy single button_bindings[GestureButton]
1088        // entry coexists with a gesture_bindings map (reachable via hand-edited
1089        // or very old configs), the gesture map must survive — not be shadowed by
1090        // the single entry. Mirrors the pre-v2 "gesture entries win" rule.
1091        let v1 = "\
1092schema_version = 1
1093
1094[devices.2b042.button_bindings]
1095GestureButton = \"MissionControl\"
1096
1097[devices.2b042.gesture_bindings]
1098Up = \"Copy\"
1099Down = \"Paste\"
1100";
1101        let dir = tempfile::tempdir().expect("tempdir");
1102        let path = dir.path().join("config.toml");
1103        fs::write(&path, v1).expect("write");
1104
1105        let cfg = Config::load_from_path(&path).expect("load v1");
1106        let mut gesture = BTreeMap::new();
1107        gesture.insert(GestureDirection::Up, Action::Copy);
1108        gesture.insert(GestureDirection::Down, Action::Paste);
1109        assert_eq!(
1110            cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1111            Some(&Binding::Gesture(gesture)),
1112            "gesture map must win over the legacy single GestureButton entry"
1113        );
1114    }
1115
1116    #[test]
1117    fn migration_drops_vestigial_lone_gesture_button_single() {
1118        // A v1 file with only `button_bindings[GestureButton]` and no
1119        // `gesture_bindings` (the pre-gesture-picker shape). That entry never
1120        // dispatched in v1 — the gesture button's plain press routes through the
1121        // gesture `Click` slot, not the per-button map — so migrating it to a
1122        // `Binding::Single` would leave an unreachable entry the GUI hides and the
1123        // runtime ignores. It must be dropped, not shadow the gesture path.
1124        let v1 = "\
1125schema_version = 1
1126
1127[devices.2b042.button_bindings]
1128GestureButton = \"MissionControl\"
1129Back = \"BrowserBack\"
1130";
1131        let dir = tempfile::tempdir().expect("tempdir");
1132        let path = dir.path().join("config.toml");
1133        fs::write(&path, v1).expect("write");
1134
1135        let bindings = Config::load_from_path(&path)
1136            .expect("load v1")
1137            .bindings_for("2b042");
1138        // An ordinary button still migrates to a `Single`...
1139        assert_eq!(
1140            bindings.get(&ButtonId::Back),
1141            Some(&Binding::Single(Action::BrowserBack))
1142        );
1143        // ...but the vestigial gesture-button single is gone, leaving the button
1144        // to fall back to its canonical default rather than an unreachable entry.
1145        assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1146    }
1147
1148    #[test]
1149    fn rejects_newer_schema_version_but_accepts_v1() {
1150        // A future version is rejected loudly; the current and older versions
1151        // load (older ones migrate through the shim).
1152        let dir = tempfile::tempdir().expect("tempdir");
1153        let path = dir.path().join("config.toml");
1154        fs::write(&path, "schema_version = 99\n").expect("write");
1155        assert_matches!(
1156            Config::load_from_path(&path).expect_err("v99 should fail"),
1157            ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1158        );
1159
1160        fs::write(&path, "schema_version = 1\n").expect("write");
1161        assert!(
1162            Config::load_from_path(&path).is_ok(),
1163            "v1 should still load"
1164        );
1165    }
1166
1167    #[test]
1168    fn set_gesture_direction_upgrades_single_to_gesture() {
1169        let mut cfg = Config::default();
1170        // Start from a Single binding, then bind a swipe direction.
1171        cfg.set_binding(
1172            "2b042",
1173            ButtonId::Back,
1174            Binding::Single(Action::BrowserBack),
1175        );
1176        cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1177
1178        match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1179            Some(Binding::Gesture(map)) => {
1180                // The prior single action is preserved as the Click entry.
1181                assert_eq!(
1182                    map.get(&GestureDirection::Click),
1183                    Some(&Action::BrowserBack)
1184                );
1185                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1186            }
1187            other => panic!("expected Gesture after upgrade, got {other:?}"),
1188        }
1189    }
1190
1191    #[test]
1192    fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1193        // Binding one direction on a never-configured gesture button must still
1194        // persist a `Click`, so the click projection is the canonical default
1195        // rather than `Action::None` (which reads as a no-op press).
1196        let mut cfg = Config::default();
1197        cfg.set_gesture_direction(
1198            "2b042",
1199            ButtonId::GestureButton,
1200            GestureDirection::Up,
1201            Action::Copy,
1202        );
1203
1204        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1205            Some(Binding::Gesture(map)) => {
1206                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1207                assert_eq!(
1208                    map.get(&GestureDirection::Click),
1209                    Some(&crate::binding::default_gesture_binding(
1210                        GestureDirection::Click
1211                    )),
1212                    "a fresh gesture button must seed a Click from its default"
1213                );
1214            }
1215            other => panic!("expected Gesture, got {other:?}"),
1216        }
1217    }
1218
1219    #[test]
1220    fn gesture_owner_defaults_to_hidpp_button_yields_to_oshook_and_can_be_off() {
1221        let mut cfg = Config::default();
1222        // Default: the dedicated HID++ gesture button owns the gesture role even with no config.
1223        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1224
1225        // A dedicated HID++ gesture binding keeps it the owner.
1226        cfg.set_gesture_direction(
1227            "2b042",
1228            ButtonId::GestureButton,
1229            GestureDirection::Up,
1230            Action::MissionControl,
1231        );
1232        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1233
1234        // An explicit OS-hook gesture button takes the role over.
1235        cfg.set_binding(
1236            "2b042",
1237            ButtonId::Forward,
1238            Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1239        );
1240        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1241
1242        // Turning gestures off explicitly yields `None` (not the HID++ button default).
1243        let mut off = Config::default();
1244        off.disable_gestures("2b042");
1245        assert_eq!(off.gesture_owner("2b042"), None);
1246    }
1247
1248    #[test]
1249    fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1250        let mut cfg = Config::default();
1251        // Customize the dedicated HID++ gesture button's Up swipe; it is the (inferred) owner.
1252        cfg.set_gesture_direction(
1253            "2b042",
1254            ButtonId::GestureButton,
1255            GestureDirection::Up,
1256            Action::Copy,
1257        );
1258        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1259
1260        // Promote Back: the owner becomes Back explicitly; the HID++ gesture button keeps
1261        // its full gesture map (no destructive demotion).
1262        cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1263        cfg.set_gesture_owner("2b042", ButtonId::Back);
1264        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1265
1266        let bindings = cfg.bindings_for("2b042");
1267        // Back is a full five-direction gesture button: its prior single action
1268        // stays as Click, and the swipe arms are seeded from defaults.
1269        match bindings.get(&ButtonId::Back) {
1270            Some(Binding::Gesture(map)) => {
1271                assert_eq!(
1272                    map.get(&GestureDirection::Click),
1273                    Some(&Action::BrowserBack)
1274                );
1275                assert_eq!(
1276                    map.get(&GestureDirection::Up),
1277                    Some(&default_gesture_binding(GestureDirection::Up)),
1278                    "a promoted button gets full default arms"
1279                );
1280            }
1281            other => panic!("expected Back to be a gesture binding, got {other:?}"),
1282        }
1283        // The HID++ gesture button's customized map survived the switch intact.
1284        match bindings.get(&ButtonId::GestureButton) {
1285            Some(Binding::Gesture(map)) => {
1286                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1287            }
1288            other => panic!("expected the HID++ gesture button map preserved, got {other:?}"),
1289        }
1290
1291        // Switching back restores the user's customization, not defaults
1292        // (regression guard: owner-switch used to discard the swipe arms).
1293        cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1294        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1295        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1296            Some(Binding::Gesture(map)) => {
1297                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1298            }
1299            other => panic!("expected preserved gesture map, got {other:?}"),
1300        }
1301    }
1302
1303    #[test]
1304    fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1305        let mut cfg = Config::default();
1306        // The dedicated HID++ gesture button gets the full default direction map.
1307        cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1308        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1309            Some(Binding::Gesture(map)) => {
1310                for dir in GestureDirection::ALL {
1311                    assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1312                }
1313            }
1314            other => panic!("expected full default gesture map, got {other:?}"),
1315        }
1316
1317        // A fresh OS-hook button also gets all five directions, not just a Click:
1318        // its native action stays as Click, and the swipe arms are defaults — so
1319        // the GUI's shown defaults are exactly what the runtime dispatches.
1320        cfg.set_gesture_owner("2b042", ButtonId::Forward);
1321        match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1322            Some(Binding::Gesture(map)) => {
1323                assert_eq!(
1324                    map.get(&GestureDirection::Click),
1325                    Some(&default_binding(ButtonId::Forward))
1326                );
1327                for dir in [
1328                    GestureDirection::Up,
1329                    GestureDirection::Down,
1330                    GestureDirection::Left,
1331                    GestureDirection::Right,
1332                ] {
1333                    assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1334                }
1335            }
1336            other => panic!("expected full gesture map for Forward, got {other:?}"),
1337        }
1338    }
1339
1340    #[test]
1341    fn disable_gestures_turns_off_without_destroying_maps() {
1342        let mut cfg = Config::default();
1343        cfg.set_gesture_direction(
1344            "2b042",
1345            ButtonId::GestureButton,
1346            GestureDirection::Up,
1347            Action::Copy,
1348        );
1349        cfg.disable_gestures("2b042");
1350        // Off, but the HID++ gesture button's customized map is preserved (re-enabling
1351        // restores it rather than resurrecting a wiped default).
1352        assert_eq!(cfg.gesture_owner("2b042"), None);
1353        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1354            Some(Binding::Gesture(map)) => {
1355                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1356            }
1357            other => panic!("expected the gesture map preserved while off, got {other:?}"),
1358        }
1359    }
1360
1361    #[test]
1362    fn gesture_owner_field_roundtrips_as_a_scalar() {
1363        let mut cfg = Config::default();
1364        cfg.set_gesture_owner("2b042", ButtonId::Back); // explicit button
1365        cfg.disable_gestures("4082d"); // explicit off
1366
1367        let parsed = write_and_read(&cfg);
1368        assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1369        assert_eq!(parsed.gesture_owner("4082d"), None);
1370
1371        // The custom codec keeps it a bare TOML string (a nested table would risk
1372        // a value-after-table serialization error, since `bindings` is a table).
1373        let body = toml::to_string_pretty(&cfg).expect("serialize");
1374        assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1375        assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1376    }
1377
1378    #[test]
1379    fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1380        // A hand-edit typo in gesture_owner must NOT fail the whole-document parse
1381        // (which would revert every device's settings to defaults). It degrades
1382        // to "infer" while the rest of the device config survives.
1383        let toml = "\
1384schema_version = 2
1385
1386[devices.2b042]
1387gesture_owner = \"bogus\"
1388
1389[devices.2b042.bindings]
1390Back = \"Copy\"
1391";
1392        let dir = tempfile::tempdir().expect("tempdir");
1393        let path = dir.path().join("config.toml");
1394        fs::write(&path, toml).expect("write");
1395
1396        let cfg =
1397            Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1398        // The rest of the device config survived...
1399        assert_eq!(
1400            cfg.bindings_for("2b042").get(&ButtonId::Back),
1401            Some(&Binding::Single(Action::Copy))
1402        );
1403        // ...and the bad owner degraded to inference (HID++ button default here).
1404        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1405    }
1406}