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