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