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 [`default_gesture_binding`] so every
683    /// gesture button exposes the same full five-direction set.
684    pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
685        self.devices
686            .entry(device_key.to_string())
687            .or_default()
688            .gesture_owner = Some(GestureOwner::Button(button));
689        self.ensure_gesture_binding(device_key, button)
690            .fill_gesture_defaults();
691    }
692
693    /// Turn gestures off for `device_key`, recording the explicit "off" choice.
694    /// Every button keeps its gesture map intact (nothing is destroyed), so
695    /// re-selecting a gesture owner later restores its directions exactly.
696    pub fn disable_gestures(&mut self, device_key: &str) {
697        self.devices
698            .entry(device_key.to_string())
699            .or_default()
700            .gesture_owner = Some(GestureOwner::Off);
701    }
702
703    /// Resolve the effective binding map for `device_key`, overlaying the
704    /// per-app entry for `bundle_id` (if any) on top of the global per-device
705    /// `bindings`. A per-app override replaces the whole button with a
706    /// [`Binding::Single`]; everything else falls through.
707    ///
708    /// Returns an empty map when the device has no recorded bindings yet.
709    /// Callers (the GUI / hook) layer their own defaults on top.
710    #[must_use]
711    pub fn effective_bindings(
712        &self,
713        device_key: &str,
714        bundle_id: Option<&str>,
715    ) -> BTreeMap<ButtonId, Binding> {
716        let Some(device) = self.devices.get(device_key) else {
717            return BTreeMap::new();
718        };
719        let mut out = device.bindings.clone();
720        if let Some(bid) = bundle_id
721            && let Some(overlay) = device.per_app_bindings.get(bid)
722        {
723            for (k, v) in overlay {
724                out.insert(*k, Binding::Single(v.clone()));
725            }
726        }
727        out
728    }
729
730    /// Records a per-app override. Creates the device + app entries as
731    /// needed; passing an action of `None` removes the override and prunes
732    /// the empty app map.
733    pub fn set_per_app_binding(
734        &mut self,
735        device_key: &str,
736        bundle_id: &str,
737        button: ButtonId,
738        action: Option<Action>,
739    ) {
740        let entry = self
741            .devices
742            .entry(device_key.to_string())
743            .or_default()
744            .per_app_bindings
745            .entry(bundle_id.to_string())
746            .or_default();
747        match action {
748            Some(a) => {
749                entry.insert(button, a);
750            }
751            None => {
752                entry.remove(&button);
753            }
754        }
755        if let Some(d) = self.devices.get_mut(device_key) {
756            d.per_app_bindings.retain(|_, m| !m.is_empty());
757        }
758    }
759
760    /// HID++ config key of the carousel-selected device, if any.
761    #[must_use]
762    pub fn selected_device(&self) -> Option<&str> {
763        self.selected_device.as_deref()
764    }
765
766    /// Update the carousel-selected device. Pass `None` to clear the
767    /// selection (e.g. when the previously-selected device disappears).
768    pub fn set_selected_device(&mut self, key: Option<String>) {
769        self.selected_device = key;
770    }
771
772    /// The ordered DPI preset list for `device_key`, or an empty `Vec` if the
773    /// device has none configured yet.
774    #[must_use]
775    pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
776        self.devices
777            .get(device_key)
778            .map(|d| d.dpi_presets.clone())
779            .unwrap_or_default()
780    }
781
782    /// Replace the DPI preset list for `device_key`. Pass an empty `Vec` to
783    /// clear (the device block is kept; the field is just omitted on save
784    /// thanks to `skip_serializing_if`).
785    pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
786        self.devices
787            .entry(device_key.to_string())
788            .or_default()
789            .dpi_presets = presets;
790    }
791
792    /// The last-known [`DeviceIdentity`] for `device_key`, or `None` if the
793    /// device has never been seen online (or was configured before identities
794    /// were recorded).
795    #[must_use]
796    pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
797        self.devices
798            .get(device_key)
799            .and_then(|d| d.identity.as_ref())
800    }
801
802    /// Record (or refresh) the identity captured for `device_key` while it was
803    /// online, creating the device entry if needed.
804    pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
805        self.devices
806            .entry(device_key.to_string())
807            .or_default()
808            .identity = Some(identity);
809    }
810
811    /// Iterate every device we've recorded an identity for, as
812    /// `(config_key, identity)`. Used to seed offline placeholder cards so a
813    /// known device stays visible (with its panels) before any live probe.
814    pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
815        self.devices
816            .iter()
817            .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
818    }
819
820    /// The lighting config for `device_key`, or `None` if unset.
821    #[must_use]
822    pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
823        self.devices
824            .get(device_key)
825            .and_then(|d| d.lighting.clone())
826    }
827
828    /// Replace the lighting config for `device_key`.
829    pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
830        self.devices
831            .entry(device_key.to_string())
832            .or_default()
833            .lighting = Some(lighting);
834    }
835
836    /// The committed sensor DPI for `device_key`, or `None` if never set.
837    #[must_use]
838    pub fn dpi(&self, device_key: &str) -> Option<u32> {
839        self.devices.get(device_key).and_then(|d| d.dpi)
840    }
841
842    /// Record the committed sensor DPI for `device_key`, so the agent can
843    /// re-apply it when the device reconnects (#189).
844    pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
845        self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
846    }
847
848    /// The SmartShift wheel config for `device_key`, or `None` if never set.
849    #[must_use]
850    pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
851        self.devices.get(device_key).and_then(|d| d.smartshift)
852    }
853
854    /// Record the SmartShift wheel config for `device_key`, so the agent can
855    /// re-apply it when the device reconnects (#189).
856    pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
857        self.devices
858            .entry(device_key.to_string())
859            .or_default()
860            .smartshift = Some(smartshift);
861    }
862}
863
864fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
865    let tmp = path.with_extension("toml.tmp");
866    {
867        #[cfg(unix)]
868        {
869            use std::os::unix::fs::OpenOptionsExt;
870            let mut f = fs::OpenOptions::new()
871                .write(true)
872                .create(true)
873                .truncate(true)
874                .mode(0o600)
875                .open(&tmp)?;
876            io::Write::write_all(&mut f, bytes)?;
877            f.sync_all()?;
878        }
879        #[cfg(not(unix))]
880        {
881            let mut f = fs::OpenOptions::new()
882                .write(true)
883                .create(true)
884                .truncate(true)
885                .open(&tmp)?;
886            io::Write::write_all(&mut f, bytes)?;
887            f.sync_all()?;
888        }
889    }
890    fs::rename(&tmp, path)
891}
892
893#[cfg(test)]
894#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
895mod tests {
896    use super::*;
897    use crate::binding::{default_binding, default_gesture_binding};
898
899    fn write_and_read(config: &Config) -> Config {
900        let dir = tempfile::tempdir().expect("tempdir");
901        let path = dir.path().join("config.toml");
902        config.save_to_path(&path).expect("save");
903        Config::load_from_path(&path).expect("load")
904    }
905
906    #[test]
907    fn missing_file_yields_default() {
908        let dir = tempfile::tempdir().expect("tempdir");
909        let path = dir.path().join("nonexistent.toml");
910        let cfg = Config::load_from_path(&path).expect("load");
911        assert_eq!(cfg.schema_version, SCHEMA_VERSION);
912        assert!(cfg.devices.is_empty());
913    }
914
915    #[test]
916    fn lighting_roundtrips_per_device() {
917        let mut cfg = Config::default();
918        cfg.set_lighting(
919            "g513",
920            Lighting {
921                enabled: true,
922                color: "00aabb".to_string(),
923                brightness: 75,
924            },
925        );
926        let restored = write_and_read(&cfg);
927        assert_eq!(
928            restored.lighting("g513"),
929            Some(Lighting {
930                enabled: true,
931                color: "00aabb".to_string(),
932                brightness: 75,
933            })
934        );
935        assert_eq!(restored.lighting("absent"), None);
936    }
937
938    #[test]
939    fn dpi_roundtrips_per_device() {
940        let mut cfg = Config::default();
941        cfg.set_dpi("2b042", 1600);
942        let restored = write_and_read(&cfg);
943        assert_eq!(restored.dpi("2b042"), Some(1600));
944        assert_eq!(restored.dpi("absent"), None);
945    }
946
947    #[test]
948    fn smartshift_roundtrips_per_device() {
949        let mut cfg = Config::default();
950        cfg.set_smartshift(
951            "2b042",
952            SmartShift {
953                mode: WheelMode::Ratchet,
954                auto_disengage: 16,
955                tunable_torque: 30,
956            },
957        );
958        let restored = write_and_read(&cfg);
959        assert_eq!(
960            restored.smartshift("2b042"),
961            Some(SmartShift {
962                mode: WheelMode::Ratchet,
963                auto_disengage: 16,
964                tunable_torque: 30,
965            })
966        );
967        assert_eq!(restored.smartshift("absent"), None);
968    }
969
970    #[test]
971    fn bindings_roundtrip_per_device() {
972        let mut cfg = Config::default();
973        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
974        cfg.set_binding(
975            "2b042",
976            ButtonId::DpiToggle,
977            Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
978                modifiers: crate::binding::KeyCombo::MOD_CMD,
979                key_code: 0x23, // kVK_ANSI_P
980                display: "⌘P".into(),
981            })),
982        );
983        cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
984
985        let parsed = write_and_read(&cfg);
986
987        // Per-device isolation.
988        let a = parsed.bindings_for("2b042");
989        assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
990        assert_eq!(
991            a.get(&ButtonId::DpiToggle),
992            Some(&Binding::Single(Action::CustomShortcut(
993                crate::binding::KeyCombo {
994                    modifiers: crate::binding::KeyCombo::MOD_CMD,
995                    key_code: 0x23,
996                    display: "⌘P".into(),
997                }
998            )))
999        );
1000
1001        let b = parsed.bindings_for("4082d");
1002        assert_eq!(
1003            b.get(&ButtonId::Back),
1004            Some(&Binding::Single(Action::Paste))
1005        );
1006        assert_eq!(b.len(), 1, "device b should only see its own bindings");
1007
1008        // Unknown device returns empty map without panic.
1009        assert!(parsed.bindings_for("deadbeef").is_empty());
1010    }
1011
1012    #[test]
1013    fn human_readable_toml_layout() {
1014        let mut cfg = Config::default();
1015        cfg.set_binding(
1016            "2b042",
1017            ButtonId::Back,
1018            Binding::Single(Action::BrowserBack),
1019        );
1020        let body = toml::to_string_pretty(&cfg).expect("serialize");
1021
1022        // The model id only contains [A-Za-z0-9_], so TOML emits it as a
1023        // bare-word table key (no surrounding quotes). The test asserts the
1024        // observable structure rather than locking in a specific quoting.
1025        assert!(body.contains("schema_version = 2"), "got: {body}");
1026        assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1027        // A `Single` binding serializes byte-identically to the pre-v2 bare
1028        // `Action`, so the leaf line is unchanged.
1029        assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
1030    }
1031
1032    #[test]
1033    fn dpi_presets_roundtrip_per_device() {
1034        let mut cfg = Config::default();
1035        cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
1036        cfg.set_dpi_presets("4082d", vec![400, 1600]);
1037
1038        let parsed = write_and_read(&cfg);
1039
1040        assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
1041        assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
1042        assert!(parsed.dpi_presets("unknown").is_empty());
1043    }
1044
1045    #[test]
1046    fn empty_dpi_presets_skip_serialization() {
1047        let mut cfg = Config::default();
1048        // Add a binding so the device block exists.
1049        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1050        cfg.set_dpi_presets("2b042", vec![800]);
1051        cfg.set_dpi_presets("2b042", vec![]); // clear
1052
1053        let body = toml::to_string_pretty(&cfg).expect("serialize");
1054        assert!(
1055            !body.contains("dpi_presets"),
1056            "empty dpi_presets should be omitted: {body}"
1057        );
1058    }
1059
1060    #[test]
1061    fn device_identity_roundtrips_and_is_iterable() {
1062        use crate::device::{Capabilities, DeviceKind};
1063
1064        let mut cfg = Config::default();
1065        let mouse = DeviceIdentity {
1066            display_name: "MX Master 3S".to_string(),
1067            kind: DeviceKind::Mouse,
1068            capabilities: Capabilities {
1069                buttons: true,
1070                pointer: true,
1071                lighting: false,
1072            },
1073        };
1074        cfg.set_device_identity("2b034", mouse.clone());
1075        // Recording an identity must not disturb unrelated per-device state.
1076        cfg.set_binding(
1077            "2b034",
1078            ButtonId::Back,
1079            Binding::Single(Action::BrowserBack),
1080        );
1081
1082        let parsed = write_and_read(&cfg);
1083        assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
1084        assert_eq!(parsed.device_identity("absent"), None);
1085        assert_eq!(
1086            parsed.bindings_for("2b034").get(&ButtonId::Back),
1087            Some(&Binding::Single(Action::BrowserBack)),
1088            "identity must coexist with bindings on the same device block"
1089        );
1090        assert_eq!(
1091            parsed.known_identities().collect::<Vec<_>>(),
1092            vec![("2b034", &mouse)]
1093        );
1094    }
1095
1096    #[test]
1097    fn selected_device_roundtrips() {
1098        let mut cfg = Config::default();
1099        assert_eq!(cfg.selected_device(), None);
1100        cfg.set_selected_device(Some("2b042".into()));
1101        let parsed = write_and_read(&cfg);
1102        assert_eq!(parsed.selected_device(), Some("2b042"));
1103    }
1104
1105    #[test]
1106    fn per_app_overlay_takes_precedence() {
1107        let mut cfg = Config::default();
1108        cfg.set_binding(
1109            "2b042",
1110            ButtonId::Back,
1111            Binding::Single(Action::BrowserBack),
1112        );
1113        cfg.set_binding(
1114            "2b042",
1115            ButtonId::Forward,
1116            Binding::Single(Action::BrowserForward),
1117        );
1118        cfg.set_per_app_binding(
1119            "2b042",
1120            "com.microsoft.VSCode",
1121            ButtonId::Back,
1122            Some(Action::Undo),
1123        );
1124
1125        // Global: both buttons are browser nav.
1126        let global = cfg.effective_bindings("2b042", None);
1127        assert_eq!(
1128            global.get(&ButtonId::Back),
1129            Some(&Binding::Single(Action::BrowserBack))
1130        );
1131        assert_eq!(
1132            global.get(&ButtonId::Forward),
1133            Some(&Binding::Single(Action::BrowserForward))
1134        );
1135
1136        // VSCode: Back overridden (wrapped as Single), Forward inherits.
1137        let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
1138        assert_eq!(
1139            vscode.get(&ButtonId::Back),
1140            Some(&Binding::Single(Action::Undo))
1141        );
1142        assert_eq!(
1143            vscode.get(&ButtonId::Forward),
1144            Some(&Binding::Single(Action::BrowserForward))
1145        );
1146
1147        // Unrelated app falls through.
1148        let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
1149        assert_eq!(
1150            other.get(&ButtonId::Back),
1151            Some(&Binding::Single(Action::BrowserBack))
1152        );
1153    }
1154
1155    #[test]
1156    fn per_app_binding_removal_prunes_empty_app() {
1157        let mut cfg = Config::default();
1158        cfg.set_per_app_binding(
1159            "2b042",
1160            "com.example.App",
1161            ButtonId::Back,
1162            Some(Action::Copy),
1163        );
1164        cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
1165        assert!(
1166            cfg.devices["2b042"].per_app_bindings.is_empty(),
1167            "removing last override should prune the app entry"
1168        );
1169    }
1170
1171    #[test]
1172    fn app_settings_default_omits_block() {
1173        let cfg = Config::default();
1174        let body = toml::to_string_pretty(&cfg).expect("serialize");
1175        assert!(
1176            !body.contains("app_settings"),
1177            "default app_settings should be omitted: {body}"
1178        );
1179    }
1180
1181    #[test]
1182    fn app_settings_launch_at_login_roundtrips() {
1183        let mut cfg = Config::default();
1184        cfg.app_settings.launch_at_login = true;
1185        let parsed = write_and_read(&cfg);
1186        assert!(parsed.app_settings.launch_at_login);
1187    }
1188
1189    #[test]
1190    fn cleared_selected_device_omits_field() {
1191        let mut cfg = Config::default();
1192        cfg.set_selected_device(Some("2b042".into()));
1193        cfg.set_selected_device(None);
1194        let body = toml::to_string_pretty(&cfg).expect("serialize");
1195        assert!(
1196            !body.contains("selected_device"),
1197            "cleared selection should not appear: {body}"
1198        );
1199    }
1200
1201    #[test]
1202    fn empty_device_block_is_skipped_in_output() {
1203        // Inserting then clearing should not leave a [devices."x"] header
1204        // with no bindings under it (skip_serializing_if on bindings).
1205        let mut cfg = Config::default();
1206        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1207        cfg.devices
1208            .get_mut("2b042")
1209            .expect("entry")
1210            .bindings
1211            .clear();
1212        let body = toml::to_string_pretty(&cfg).expect("serialize");
1213        assert!(
1214            !body.contains("Back"),
1215            "cleared bindings should not appear: {body}"
1216        );
1217    }
1218
1219    #[test]
1220    fn migrates_v1_button_and_gesture_bindings() {
1221        // A pre-v2 file: split button_bindings + a flat gesture_bindings map.
1222        let v1 = "\
1223schema_version = 1
1224
1225[devices.2b042.button_bindings]
1226Back = \"BrowserBack\"
1227
1228[devices.2b042.gesture_bindings]
1229Up = \"Copy\"
1230Click = \"Paste\"
1231";
1232        let dir = tempfile::tempdir().expect("tempdir");
1233        let path = dir.path().join("config.toml");
1234        fs::write(&path, v1).expect("write");
1235
1236        // v1 still loads (version <= current) and folds into the merged map.
1237        let cfg = Config::load_from_path(&path).expect("load v1");
1238        let bindings = cfg.bindings_for("2b042");
1239        assert_eq!(
1240            bindings.get(&ButtonId::Back),
1241            Some(&Binding::Single(Action::BrowserBack))
1242        );
1243        let mut gesture = BTreeMap::new();
1244        gesture.insert(GestureDirection::Up, Action::Copy);
1245        gesture.insert(GestureDirection::Click, Action::Paste);
1246        assert_eq!(
1247            bindings.get(&ButtonId::GestureButton),
1248            Some(&Binding::Gesture(gesture))
1249        );
1250
1251        // Saving self-heals to the v2 shape: stamped version + merged table,
1252        // legacy field names gone.
1253        let body = toml::to_string_pretty(&cfg).expect("serialize");
1254        assert!(body.contains("schema_version = 2"), "got: {body}");
1255        assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1256        assert!(!body.contains("button_bindings"), "got: {body}");
1257        assert!(!body.contains("gesture_bindings"), "got: {body}");
1258    }
1259
1260    #[test]
1261    fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1262        // The data-loss guard: when a legacy single button_bindings[GestureButton]
1263        // entry coexists with a gesture_bindings map (reachable via hand-edited
1264        // or very old configs), the gesture map must survive — not be shadowed by
1265        // the single entry. Mirrors the pre-v2 "gesture entries win" rule.
1266        let v1 = "\
1267schema_version = 1
1268
1269[devices.2b042.button_bindings]
1270GestureButton = \"MissionControl\"
1271
1272[devices.2b042.gesture_bindings]
1273Up = \"Copy\"
1274Down = \"Paste\"
1275";
1276        let dir = tempfile::tempdir().expect("tempdir");
1277        let path = dir.path().join("config.toml");
1278        fs::write(&path, v1).expect("write");
1279
1280        let cfg = Config::load_from_path(&path).expect("load v1");
1281        let mut gesture = BTreeMap::new();
1282        gesture.insert(GestureDirection::Up, Action::Copy);
1283        gesture.insert(GestureDirection::Down, Action::Paste);
1284        assert_eq!(
1285            cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1286            Some(&Binding::Gesture(gesture)),
1287            "gesture map must win over the legacy single GestureButton entry"
1288        );
1289    }
1290
1291    #[test]
1292    fn migration_drops_vestigial_lone_gesture_button_single() {
1293        // A v1 file with only `button_bindings[GestureButton]` and no
1294        // `gesture_bindings` (the pre-gesture-picker shape). That entry never
1295        // dispatched in v1 — the gesture button's plain press routes through the
1296        // gesture `Click` slot, not the per-button map — so migrating it to a
1297        // `Binding::Single` would leave an unreachable entry the GUI hides and the
1298        // runtime ignores. It must be dropped, not shadow the gesture path.
1299        let v1 = "\
1300schema_version = 1
1301
1302[devices.2b042.button_bindings]
1303GestureButton = \"MissionControl\"
1304Back = \"BrowserBack\"
1305";
1306        let dir = tempfile::tempdir().expect("tempdir");
1307        let path = dir.path().join("config.toml");
1308        fs::write(&path, v1).expect("write");
1309
1310        let bindings = Config::load_from_path(&path)
1311            .expect("load v1")
1312            .bindings_for("2b042");
1313        // An ordinary button still migrates to a `Single`...
1314        assert_eq!(
1315            bindings.get(&ButtonId::Back),
1316            Some(&Binding::Single(Action::BrowserBack))
1317        );
1318        // ...but the vestigial gesture-button single is gone, leaving the button
1319        // to fall back to its canonical default rather than an unreachable entry.
1320        assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1321    }
1322
1323    #[test]
1324    fn rejects_newer_schema_version_but_accepts_v1() {
1325        // A future version is rejected loudly; the current and older versions
1326        // load (older ones migrate through the shim).
1327        let dir = tempfile::tempdir().expect("tempdir");
1328        let path = dir.path().join("config.toml");
1329        fs::write(&path, "schema_version = 99\n").expect("write");
1330        assert!(matches!(
1331            Config::load_from_path(&path).expect_err("v99 should fail"),
1332            ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1333        ));
1334
1335        fs::write(&path, "schema_version = 1\n").expect("write");
1336        assert!(
1337            Config::load_from_path(&path).is_ok(),
1338            "v1 should still load"
1339        );
1340    }
1341
1342    #[test]
1343    fn set_gesture_direction_upgrades_single_to_gesture() {
1344        let mut cfg = Config::default();
1345        // Start from a Single binding, then bind a swipe direction.
1346        cfg.set_binding(
1347            "2b042",
1348            ButtonId::Back,
1349            Binding::Single(Action::BrowserBack),
1350        );
1351        cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1352
1353        match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1354            Some(Binding::Gesture(map)) => {
1355                // The prior single action is preserved as the Click entry.
1356                assert_eq!(
1357                    map.get(&GestureDirection::Click),
1358                    Some(&Action::BrowserBack)
1359                );
1360                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1361            }
1362            other => panic!("expected Gesture after upgrade, got {other:?}"),
1363        }
1364    }
1365
1366    #[test]
1367    fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1368        // Binding one direction on a never-configured gesture button must still
1369        // persist a `Click`, so the click projection is the canonical default
1370        // rather than `Action::None` (which reads as a no-op press).
1371        let mut cfg = Config::default();
1372        cfg.set_gesture_direction(
1373            "2b042",
1374            ButtonId::GestureButton,
1375            GestureDirection::Up,
1376            Action::Copy,
1377        );
1378
1379        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1380            Some(Binding::Gesture(map)) => {
1381                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1382                assert_eq!(
1383                    map.get(&GestureDirection::Click),
1384                    Some(&crate::binding::default_gesture_binding(
1385                        GestureDirection::Click
1386                    )),
1387                    "a fresh gesture button must seed a Click from its default"
1388                );
1389            }
1390            other => panic!("expected Gesture, got {other:?}"),
1391        }
1392    }
1393
1394    #[test]
1395    fn gesture_owner_defaults_to_thumb_pad_yields_to_oshook_and_can_be_off() {
1396        let mut cfg = Config::default();
1397        // Default: the thumb pad owns the gesture role even with no config.
1398        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1399
1400        // A thumb-pad gesture binding keeps it the owner.
1401        cfg.set_gesture_direction(
1402            "2b042",
1403            ButtonId::GestureButton,
1404            GestureDirection::Up,
1405            Action::MissionControl,
1406        );
1407        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1408
1409        // An explicit OS-hook gesture button takes the role over.
1410        cfg.set_binding(
1411            "2b042",
1412            ButtonId::Forward,
1413            Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1414        );
1415        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1416
1417        // Turning gestures off explicitly yields `None` (not the thumb-pad default).
1418        let mut off = Config::default();
1419        off.disable_gestures("2b042");
1420        assert_eq!(off.gesture_owner("2b042"), None);
1421    }
1422
1423    #[test]
1424    fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1425        let mut cfg = Config::default();
1426        // Customize the thumb pad's Up swipe; it is the (inferred) owner.
1427        cfg.set_gesture_direction(
1428            "2b042",
1429            ButtonId::GestureButton,
1430            GestureDirection::Up,
1431            Action::Copy,
1432        );
1433        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1434
1435        // Promote Back: the owner becomes Back explicitly; the thumb pad keeps
1436        // its full gesture map (no destructive demotion).
1437        cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1438        cfg.set_gesture_owner("2b042", ButtonId::Back);
1439        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1440
1441        let bindings = cfg.bindings_for("2b042");
1442        // Back is a full five-direction gesture button: its prior single action
1443        // stays as Click, and the swipe arms are seeded from defaults.
1444        match bindings.get(&ButtonId::Back) {
1445            Some(Binding::Gesture(map)) => {
1446                assert_eq!(
1447                    map.get(&GestureDirection::Click),
1448                    Some(&Action::BrowserBack)
1449                );
1450                assert_eq!(
1451                    map.get(&GestureDirection::Up),
1452                    Some(&default_gesture_binding(GestureDirection::Up)),
1453                    "a promoted button gets full default arms"
1454                );
1455            }
1456            other => panic!("expected Back to be a gesture binding, got {other:?}"),
1457        }
1458        // The thumb pad's customized map survived the switch intact.
1459        match bindings.get(&ButtonId::GestureButton) {
1460            Some(Binding::Gesture(map)) => {
1461                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1462            }
1463            other => panic!("expected the thumb pad map preserved, got {other:?}"),
1464        }
1465
1466        // Switching back restores the user's customization, not defaults
1467        // (regression guard: owner-switch used to discard the swipe arms).
1468        cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1469        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1470        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1471            Some(Binding::Gesture(map)) => {
1472                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1473            }
1474            other => panic!("expected preserved gesture map, got {other:?}"),
1475        }
1476    }
1477
1478    #[test]
1479    fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1480        let mut cfg = Config::default();
1481        // The dedicated thumb pad gets the full default direction map.
1482        cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1483        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1484            Some(Binding::Gesture(map)) => {
1485                for dir in GestureDirection::ALL {
1486                    assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1487                }
1488            }
1489            other => panic!("expected full default gesture map, got {other:?}"),
1490        }
1491
1492        // A fresh OS-hook button also gets all five directions, not just a Click:
1493        // its native action stays as Click, and the swipe arms are defaults — so
1494        // the GUI's shown defaults are exactly what the runtime dispatches.
1495        cfg.set_gesture_owner("2b042", ButtonId::Forward);
1496        match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1497            Some(Binding::Gesture(map)) => {
1498                assert_eq!(
1499                    map.get(&GestureDirection::Click),
1500                    Some(&default_binding(ButtonId::Forward))
1501                );
1502                for dir in [
1503                    GestureDirection::Up,
1504                    GestureDirection::Down,
1505                    GestureDirection::Left,
1506                    GestureDirection::Right,
1507                ] {
1508                    assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1509                }
1510            }
1511            other => panic!("expected full gesture map for Forward, got {other:?}"),
1512        }
1513    }
1514
1515    #[test]
1516    fn disable_gestures_turns_off_without_destroying_maps() {
1517        let mut cfg = Config::default();
1518        cfg.set_gesture_direction(
1519            "2b042",
1520            ButtonId::GestureButton,
1521            GestureDirection::Up,
1522            Action::Copy,
1523        );
1524        cfg.disable_gestures("2b042");
1525        // Off, but the thumb pad's customized map is preserved (re-enabling
1526        // restores it rather than resurrecting a wiped default).
1527        assert_eq!(cfg.gesture_owner("2b042"), None);
1528        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1529            Some(Binding::Gesture(map)) => {
1530                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1531            }
1532            other => panic!("expected the gesture map preserved while off, got {other:?}"),
1533        }
1534    }
1535
1536    #[test]
1537    fn gesture_owner_field_roundtrips_as_a_scalar() {
1538        let mut cfg = Config::default();
1539        cfg.set_gesture_owner("2b042", ButtonId::Back); // explicit button
1540        cfg.disable_gestures("4082d"); // explicit off
1541
1542        let parsed = write_and_read(&cfg);
1543        assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1544        assert_eq!(parsed.gesture_owner("4082d"), None);
1545
1546        // The custom codec keeps it a bare TOML string (a nested table would risk
1547        // a value-after-table serialization error, since `bindings` is a table).
1548        let body = toml::to_string_pretty(&cfg).expect("serialize");
1549        assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1550        assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1551    }
1552
1553    #[test]
1554    fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1555        // A hand-edit typo in gesture_owner must NOT fail the whole-document parse
1556        // (which would revert every device's settings to defaults). It degrades
1557        // to "infer" while the rest of the device config survives.
1558        let toml = "\
1559schema_version = 2
1560
1561[devices.2b042]
1562gesture_owner = \"bogus\"
1563
1564[devices.2b042.bindings]
1565Back = \"Copy\"
1566";
1567        let dir = tempfile::tempdir().expect("tempdir");
1568        let path = dir.path().join("config.toml");
1569        fs::write(&path, toml).expect("write");
1570
1571        let cfg =
1572            Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1573        // The rest of the device config survived...
1574        assert_eq!(
1575            cfg.bindings_for("2b042").get(&ButtonId::Back),
1576            Some(&Binding::Single(Action::Copy))
1577        );
1578        // ...and the bad owner degraded to inference (thumb-pad default here).
1579        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1580    }
1581}