Skip to main content

openlogi_core/
config.rs

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