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    /// Invert this device's scroll-wheel direction relative to the OS setting
375    /// (issue #126): on, a wheel tick scrolls the opposite way, so a user who
376    /// keeps macOS "natural scrolling" for the trackpad can have a traditional
377    /// "reverse" wheel on the mouse. Vertical only; the agent applies it in the
378    /// OS hook and leaves continuous trackpad scrolling untouched. `false`
379    /// (default) is the native direction, and is omitted from `config.toml`.
380    #[serde(default, skip_serializing_if = "is_false")]
381    pub invert_scroll: bool,
382}
383
384/// `skip_serializing_if` helper for plain `bool` fields whose default is
385/// `false`: keeps an unset toggle out of `config.toml` entirely.
386#[allow(
387    clippy::trivially_copy_pass_by_ref,
388    reason = "serde's skip_serializing_if requires a fn(&T) -> bool signature"
389)]
390fn is_false(b: &bool) -> bool {
391    !*b
392}
393
394/// Deserialize-only shim that folds the pre-v2 `button_bindings` +
395/// `gesture_bindings` fields into [`DeviceConfig::bindings`]. Never serialized
396/// (only [`DeviceConfig`] is), so reading a legacy file and saving rewrites it
397/// in the v2 shape.
398#[derive(Deserialize)]
399struct RawDeviceConfig {
400    /// Explicit gesture owner (v2.1+). Absent on older configs → `None` → the
401    /// owner is inferred in [`Config::gesture_owner`]. A present-but-invalid
402    /// value is tolerated as `None` (infer), not a parse error — see
403    /// [`deserialize_gesture_owner`].
404    #[serde(default, deserialize_with = "deserialize_gesture_owner")]
405    gesture_owner: Option<GestureOwner>,
406    #[serde(default)]
407    identity: Option<DeviceIdentity>,
408    /// v2 shape — present on already-migrated files; wins on any key collision.
409    #[serde(default)]
410    bindings: BTreeMap<ButtonId, Binding>,
411    /// Legacy v1 per-button single bindings.
412    #[serde(default)]
413    button_bindings: BTreeMap<ButtonId, Action>,
414    /// Legacy v1 flat gesture map (implicitly the gesture button's directions).
415    #[serde(default)]
416    gesture_bindings: BTreeMap<GestureDirection, Action>,
417    #[serde(default)]
418    per_app_bindings: BTreeMap<String, BTreeMap<ButtonId, Action>>,
419    #[serde(default)]
420    dpi_presets: Vec<u32>,
421    #[serde(default)]
422    dpi: Option<u32>,
423    #[serde(default)]
424    lighting: Option<Lighting>,
425    #[serde(default)]
426    smartshift: Option<SmartShift>,
427    #[serde(default)]
428    invert_scroll: bool,
429}
430
431impl From<RawDeviceConfig> for DeviceConfig {
432    fn from(raw: RawDeviceConfig) -> Self {
433        let mut bindings = raw.bindings; // the v2 map wins on every key.
434
435        // Re-home the legacy flat gesture map under `GestureButton`. This MUST
436        // happen before folding `button_bindings`, so a legacy single
437        // `button_bindings[GestureButton]` entry coexisting with a
438        // `gesture_bindings` map cannot claim the slot first and silently drop
439        // the whole direction map (the pre-v2 rule was "gesture entries win").
440        if !raw.gesture_bindings.is_empty() {
441            bindings
442                .entry(ButtonId::GestureButton)
443                .or_insert_with(|| Binding::Gesture(raw.gesture_bindings));
444        }
445        for (button, action) in raw.button_bindings {
446            // A legacy `button_bindings[GestureButton]` is vestigial and must not
447            // become a `Binding::Single`: the gesture button never dispatched
448            // through the per-button map (it is not an OS-hook button, and its
449            // plain press routes through the gesture `Click` slot — see
450            // agent-core `bindings_for`). A `Single` here would be unreachable —
451            // the GUI hides it and the runtime ignores it — while folding it into
452            // `Click` would resurrect a dead binding as a behavior change. Drop
453            // it: the gesture map (re-homed above) already owns this button, and
454            // an absent entry falls back to the canonical default, exactly as
455            // pre-v2.
456            if button == ButtonId::GestureButton {
457                continue;
458            }
459            bindings.entry(button).or_insert(Binding::Single(action));
460        }
461
462        DeviceConfig {
463            gesture_owner: raw.gesture_owner,
464            identity: raw.identity,
465            bindings,
466            per_app_bindings: raw.per_app_bindings,
467            dpi_presets: raw.dpi_presets,
468            dpi: raw.dpi,
469            lighting: raw.lighting,
470            smartshift: raw.smartshift,
471            invert_scroll: raw.invert_scroll,
472        }
473    }
474}
475
476#[derive(Debug, Error)]
477pub enum ConfigError {
478    #[error("could not resolve config path")]
479    Path(#[from] PathsError),
480    #[error("could not read config at {path}")]
481    Read {
482        path: PathBuf,
483        #[source]
484        source: io::Error,
485    },
486    #[error("could not parse config at {path}")]
487    Parse {
488        path: PathBuf,
489        #[source]
490        source: toml::de::Error,
491    },
492    #[error("could not write config at {path}")]
493    Write {
494        path: PathBuf,
495        #[source]
496        source: io::Error,
497    },
498    #[error("could not serialize config")]
499    Serialize(#[from] toml::ser::Error),
500    #[error("config at {path} has unsupported schema_version {found}")]
501    UnsupportedSchemaVersion { path: PathBuf, found: u32 },
502}
503
504#[allow(
505    clippy::result_large_err,
506    reason = "Config I/O keeps rich parse/write context and is not a hot path"
507)]
508impl Config {
509    /// Loads the config from the default user path, returning
510    /// [`Config::default`] if the file does not exist yet.
511    pub fn load_or_default() -> Result<Self, ConfigError> {
512        Self::load_from_path(&paths::config_path()?)
513    }
514
515    /// Same as [`Self::load_or_default`] but reads from `path`. Used by tests
516    /// to avoid touching the real user config.
517    pub fn load_from_path(path: &Path) -> Result<Self, ConfigError> {
518        match fs::read_to_string(path) {
519            Ok(text) => {
520                let mut config: Self =
521                    toml::from_str(&text).map_err(|source| ConfigError::Parse {
522                        path: path.to_path_buf(),
523                        source,
524                    })?;
525                // Accept any version up to the current one: older files migrate
526                // through the per-device [`RawDeviceConfig`] shim and self-heal on
527                // the next save. Only a *newer* file is rejected — loudly, so a
528                // downgraded binary refuses to load (and silently wipe) a config
529                // it can't represent.
530                if config.schema_version > SCHEMA_VERSION {
531                    return Err(ConfigError::UnsupportedSchemaVersion {
532                        path: path.to_path_buf(),
533                        found: config.schema_version,
534                    });
535                }
536                // Stamp the in-memory doc to the current version so a re-save
537                // writes the migrated v2 shape (the device shim already folded
538                // the legacy fields during deserialize).
539                config.schema_version = SCHEMA_VERSION;
540                Ok(config)
541            }
542            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
543            Err(source) => Err(ConfigError::Read {
544                path: path.to_path_buf(),
545                source,
546            }),
547        }
548    }
549
550    /// Writes the config atomically to the default user path: serialize to a
551    /// sibling temp file, then rename over the target. On Unix the temp file
552    /// is created with mode 0600.
553    pub fn save_atomic(&self) -> Result<(), ConfigError> {
554        self.save_to_path(&paths::config_path()?)
555    }
556
557    /// Same as [`Self::save_atomic`] but writes to `path`. Used by tests.
558    pub fn save_to_path(&self, path: &Path) -> Result<(), ConfigError> {
559        if let Some(parent) = path.parent() {
560            fs::create_dir_all(parent).map_err(|source| ConfigError::Write {
561                path: path.to_path_buf(),
562                source,
563            })?;
564        }
565        let body = toml::to_string_pretty(self)?;
566        write_atomic(path, body.as_bytes()).map_err(|source| ConfigError::Write {
567            path: path.to_path_buf(),
568            source,
569        })
570    }
571
572    /// Returns the bindings stored for `device_key`, or an empty map if the
573    /// device has no committed bindings yet.
574    #[must_use]
575    pub fn bindings_for(&self, device_key: &str) -> BTreeMap<ButtonId, Binding> {
576        self.devices
577            .get(device_key)
578            .map(|d| d.bindings.clone())
579            .unwrap_or_default()
580    }
581
582    /// Records `binding` for `button` on `device_key`, creating the device
583    /// entry if needed. Replaces the whole binding (use
584    /// [`Self::set_gesture_direction`] to edit one direction of a gesture
585    /// binding in place).
586    pub fn set_binding(&mut self, device_key: &str, button: ButtonId, binding: Binding) {
587        self.devices
588            .entry(device_key.to_string())
589            .or_default()
590            .bindings
591            .insert(button, binding);
592    }
593
594    /// Returns the gesture sub-bindings for `device_key`'s gesture button, or an
595    /// empty map if it isn't in gesture mode. Derived from the unified
596    /// [`DeviceConfig::bindings`]; kept as a convenience for the agent-side
597    /// per-direction adapter.
598    #[must_use]
599    pub fn gesture_bindings_for(&self, device_key: &str) -> BTreeMap<GestureDirection, Action> {
600        match self
601            .devices
602            .get(device_key)
603            .and_then(|d| d.bindings.get(&ButtonId::GestureButton))
604        {
605            Some(Binding::Gesture(map)) => map.clone(),
606            _ => BTreeMap::new(),
607        }
608    }
609
610    /// Records `action` for one `direction` of `button`'s gesture binding,
611    /// creating the device entry if needed.
612    ///
613    /// A button with no binding yet is seeded from its canonical
614    /// [`default_binding_for`] — for [`ButtonId::GestureButton`] that is the full
615    /// default direction map (including a [`GestureDirection::Click`]), so the
616    /// merged map never persists a gesture binding whose click projection is a
617    /// no-op. A prior [`Binding::Single`] is upgraded to [`Binding::Gesture`],
618    /// preserving its action as the `Click` entry.
619    pub fn set_gesture_direction(
620        &mut self,
621        device_key: &str,
622        button: ButtonId,
623        direction: GestureDirection,
624        action: Action,
625    ) {
626        if let Binding::Gesture(map) = self.ensure_gesture_binding(device_key, button) {
627            map.insert(direction, action);
628        }
629    }
630
631    /// Ensure `button` on `device_key` is a [`Binding::Gesture`], creating the
632    /// device + a default binding if needed and upgrading a [`Binding::Single`]
633    /// in place (its action kept as the [`GestureDirection::Click`]). Returns the
634    /// entry so the caller can finish it — seed every direction
635    /// ([`Binding::fill_gesture_defaults`]) or set just one. Shared by
636    /// [`Self::set_gesture_owner`] and [`Self::set_gesture_direction`] so the two
637    /// promote a button into gesture mode identically.
638    fn ensure_gesture_binding(&mut self, device_key: &str, button: ButtonId) -> &mut Binding {
639        let entry = self
640            .devices
641            .entry(device_key.to_string())
642            .or_default()
643            .bindings
644            .entry(button)
645            .or_insert_with(|| default_binding_for(button));
646        entry.upgrade_to_gesture();
647        entry
648    }
649
650    /// The button that owns `device_key`'s single gesture role, or `None` when
651    /// gestures are turned off.
652    ///
653    /// Resolved from the explicit [`DeviceConfig::gesture_owner`] when present;
654    /// otherwise inferred (see `Self::infer_gesture_owner`) for configs
655    /// predating the field and freshly-migrated pre-v2 files. The dedicated thumb
656    /// pad ([`ButtonId::GestureButton`]) owns the role by default. At most one
657    /// button gestures per device.
658    #[must_use]
659    pub fn gesture_owner(&self, device_key: &str) -> Option<ButtonId> {
660        let Some(device) = self.devices.get(device_key) else {
661            // No config yet → the thumb pad is the default gesture owner.
662            return Some(ButtonId::GestureButton);
663        };
664        match device.gesture_owner {
665            Some(GestureOwner::Off) => None,
666            Some(GestureOwner::Button(id)) => Some(id),
667            None => Self::infer_gesture_owner(&device.bindings),
668        }
669    }
670
671    /// Infer the gesture owner for a config predating the explicit
672    /// [`DeviceConfig::gesture_owner`] field, from the shape of `bindings` — the
673    /// pre-field behavior, so old/migrated configs keep working until the first
674    /// explicit owner change stamps the field.
675    fn infer_gesture_owner(bindings: &BTreeMap<ButtonId, Binding>) -> Option<ButtonId> {
676        // An OS-hook button left in gesture mode took the role over.
677        if let Some((id, _)) = bindings
678            .iter()
679            .find(|(id, b)| **id != ButtonId::GestureButton && b.is_gesture())
680        {
681            return Some(*id);
682        }
683        // A thumb pad explicitly demoted to a single action means gestures off.
684        if matches!(
685            bindings.get(&ButtonId::GestureButton),
686            Some(Binding::Single(_))
687        ) {
688            return None;
689        }
690        // Default: the thumb pad owns the gesture role.
691        Some(ButtonId::GestureButton)
692    }
693
694    /// Make `button` the device's sole gesture button.
695    ///
696    /// Records `button` as the explicit [`gesture_owner`](Self::gesture_owner), so
697    /// the one-gesture-button-per-device lock is a data-model fact rather than a
698    /// destructive demotion of the others — every other gesture-capable button
699    /// keeps its own gesture map intact, ready to restore if re-chosen, and is
700    /// simply not dispatched while it isn't the owner. `button` is given a full
701    /// [`Binding::Gesture`] map: a prior [`Binding::Single`] is kept as the
702    /// [`GestureDirection::Click`] action, any existing swipe arms are preserved,
703    /// and unbound directions are seeded from
704    /// [`default_gesture_binding`](crate::binding::default_gesture_binding) so every
705    /// gesture button exposes the same full five-direction set.
706    pub fn set_gesture_owner(&mut self, device_key: &str, button: ButtonId) {
707        self.devices
708            .entry(device_key.to_string())
709            .or_default()
710            .gesture_owner = Some(GestureOwner::Button(button));
711        self.ensure_gesture_binding(device_key, button)
712            .fill_gesture_defaults();
713    }
714
715    /// Turn gestures off for `device_key`, recording the explicit "off" choice.
716    /// Every button keeps its gesture map intact (nothing is destroyed), so
717    /// re-selecting a gesture owner later restores its directions exactly.
718    pub fn disable_gestures(&mut self, device_key: &str) {
719        self.devices
720            .entry(device_key.to_string())
721            .or_default()
722            .gesture_owner = Some(GestureOwner::Off);
723    }
724
725    /// Resolve the effective binding map for `device_key`, overlaying the
726    /// per-app entry for `bundle_id` (if any) on top of the global per-device
727    /// `bindings`. A per-app override replaces the whole button with a
728    /// [`Binding::Single`]; everything else falls through.
729    ///
730    /// Returns an empty map when the device has no recorded bindings yet.
731    /// Callers (the GUI / hook) layer their own defaults on top.
732    #[must_use]
733    pub fn effective_bindings(
734        &self,
735        device_key: &str,
736        bundle_id: Option<&str>,
737    ) -> BTreeMap<ButtonId, Binding> {
738        let Some(device) = self.devices.get(device_key) else {
739            return BTreeMap::new();
740        };
741        let mut out = device.bindings.clone();
742        if let Some(bid) = bundle_id
743            && let Some(overlay) = device.per_app_bindings.get(bid)
744        {
745            for (k, v) in overlay {
746                out.insert(*k, Binding::Single(v.clone()));
747            }
748        }
749        out
750    }
751
752    /// Records a per-app override. Creates the device + app entries as
753    /// needed; passing an action of `None` removes the override and prunes
754    /// the empty app map.
755    pub fn set_per_app_binding(
756        &mut self,
757        device_key: &str,
758        bundle_id: &str,
759        button: ButtonId,
760        action: Option<Action>,
761    ) {
762        let entry = self
763            .devices
764            .entry(device_key.to_string())
765            .or_default()
766            .per_app_bindings
767            .entry(bundle_id.to_string())
768            .or_default();
769        match action {
770            Some(a) => {
771                entry.insert(button, a);
772            }
773            None => {
774                entry.remove(&button);
775            }
776        }
777        if let Some(d) = self.devices.get_mut(device_key) {
778            d.per_app_bindings.retain(|_, m| !m.is_empty());
779        }
780    }
781
782    /// HID++ config key of the carousel-selected device, if any.
783    #[must_use]
784    pub fn selected_device(&self) -> Option<&str> {
785        self.selected_device.as_deref()
786    }
787
788    /// Update the carousel-selected device. Pass `None` to clear the
789    /// selection (e.g. when the previously-selected device disappears).
790    pub fn set_selected_device(&mut self, key: Option<String>) {
791        self.selected_device = key;
792    }
793
794    /// The ordered DPI preset list for `device_key`, or an empty `Vec` if the
795    /// device has none configured yet.
796    #[must_use]
797    pub fn dpi_presets(&self, device_key: &str) -> Vec<u32> {
798        self.devices
799            .get(device_key)
800            .map(|d| d.dpi_presets.clone())
801            .unwrap_or_default()
802    }
803
804    /// Replace the DPI preset list for `device_key`. Pass an empty `Vec` to
805    /// clear (the device block is kept; the field is just omitted on save
806    /// thanks to `skip_serializing_if`).
807    pub fn set_dpi_presets(&mut self, device_key: &str, presets: Vec<u32>) {
808        self.devices
809            .entry(device_key.to_string())
810            .or_default()
811            .dpi_presets = presets;
812    }
813
814    /// The last-known [`DeviceIdentity`] for `device_key`, or `None` if the
815    /// device has never been seen online (or was configured before identities
816    /// were recorded).
817    #[must_use]
818    pub fn device_identity(&self, device_key: &str) -> Option<&DeviceIdentity> {
819        self.devices
820            .get(device_key)
821            .and_then(|d| d.identity.as_ref())
822    }
823
824    /// Record (or refresh) the identity captured for `device_key` while it was
825    /// online, creating the device entry if needed.
826    pub fn set_device_identity(&mut self, device_key: &str, identity: DeviceIdentity) {
827        self.devices
828            .entry(device_key.to_string())
829            .or_default()
830            .identity = Some(identity);
831    }
832
833    /// Iterate every device we've recorded an identity for, as
834    /// `(config_key, identity)`. Used to seed offline placeholder cards so a
835    /// known device stays visible (with its panels) before any live probe.
836    pub fn known_identities(&self) -> impl Iterator<Item = (&str, &DeviceIdentity)> {
837        self.devices
838            .iter()
839            .filter_map(|(k, d)| d.identity.as_ref().map(|i| (k.as_str(), i)))
840    }
841
842    /// The lighting config for `device_key`, or `None` if unset.
843    #[must_use]
844    pub fn lighting(&self, device_key: &str) -> Option<Lighting> {
845        self.devices
846            .get(device_key)
847            .and_then(|d| d.lighting.clone())
848    }
849
850    /// Replace the lighting config for `device_key`.
851    pub fn set_lighting(&mut self, device_key: &str, lighting: Lighting) {
852        self.devices
853            .entry(device_key.to_string())
854            .or_default()
855            .lighting = Some(lighting);
856    }
857
858    /// The committed sensor DPI for `device_key`, or `None` if never set.
859    #[must_use]
860    pub fn dpi(&self, device_key: &str) -> Option<u32> {
861        self.devices.get(device_key).and_then(|d| d.dpi)
862    }
863
864    /// Record the committed sensor DPI for `device_key`, so the agent can
865    /// re-apply it when the device reconnects (#189).
866    pub fn set_dpi(&mut self, device_key: &str, dpi: u32) {
867        self.devices.entry(device_key.to_string()).or_default().dpi = Some(dpi);
868    }
869
870    /// The SmartShift wheel config for `device_key`, or `None` if never set.
871    #[must_use]
872    pub fn smartshift(&self, device_key: &str) -> Option<SmartShift> {
873        self.devices.get(device_key).and_then(|d| d.smartshift)
874    }
875
876    /// Record the SmartShift wheel config for `device_key`, so the agent can
877    /// re-apply it when the device reconnects (#189).
878    pub fn set_smartshift(&mut self, device_key: &str, smartshift: SmartShift) {
879        self.devices
880            .entry(device_key.to_string())
881            .or_default()
882            .smartshift = Some(smartshift);
883    }
884
885    /// Whether `device_key`'s scroll wheel is inverted (issue #126). `false`
886    /// (the native direction) for an unconfigured or absent device.
887    #[must_use]
888    pub fn invert_scroll(&self, device_key: &str) -> bool {
889        self.devices
890            .get(device_key)
891            .is_some_and(|d| d.invert_scroll)
892    }
893
894    /// Set whether `device_key`'s scroll wheel is inverted. The agent reads this
895    /// on the next `ReloadConfig` and applies it in the OS hook.
896    pub fn set_invert_scroll(&mut self, device_key: &str, invert: bool) {
897        self.devices
898            .entry(device_key.to_string())
899            .or_default()
900            .invert_scroll = invert;
901    }
902}
903
904fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
905    let tmp = path.with_extension("toml.tmp");
906    {
907        #[cfg(unix)]
908        {
909            use std::os::unix::fs::OpenOptionsExt;
910            let mut f = fs::OpenOptions::new()
911                .write(true)
912                .create(true)
913                .truncate(true)
914                .mode(0o600)
915                .open(&tmp)?;
916            io::Write::write_all(&mut f, bytes)?;
917            f.sync_all()?;
918        }
919        #[cfg(not(unix))]
920        {
921            let mut f = fs::OpenOptions::new()
922                .write(true)
923                .create(true)
924                .truncate(true)
925                .open(&tmp)?;
926            io::Write::write_all(&mut f, bytes)?;
927            f.sync_all()?;
928        }
929    }
930    fs::rename(&tmp, path)
931}
932
933#[cfg(test)]
934#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
935mod tests {
936    use super::*;
937    use crate::binding::{default_binding, default_gesture_binding};
938
939    fn write_and_read(config: &Config) -> Config {
940        let dir = tempfile::tempdir().expect("tempdir");
941        let path = dir.path().join("config.toml");
942        config.save_to_path(&path).expect("save");
943        Config::load_from_path(&path).expect("load")
944    }
945
946    #[test]
947    fn missing_file_yields_default() {
948        let dir = tempfile::tempdir().expect("tempdir");
949        let path = dir.path().join("nonexistent.toml");
950        let cfg = Config::load_from_path(&path).expect("load");
951        assert_eq!(cfg.schema_version, SCHEMA_VERSION);
952        assert!(cfg.devices.is_empty());
953    }
954
955    #[test]
956    fn lighting_roundtrips_per_device() {
957        let mut cfg = Config::default();
958        cfg.set_lighting(
959            "g513",
960            Lighting {
961                enabled: true,
962                color: "00aabb".to_string(),
963                brightness: 75,
964            },
965        );
966        let restored = write_and_read(&cfg);
967        assert_eq!(
968            restored.lighting("g513"),
969            Some(Lighting {
970                enabled: true,
971                color: "00aabb".to_string(),
972                brightness: 75,
973            })
974        );
975        assert_eq!(restored.lighting("absent"), None);
976    }
977
978    #[test]
979    fn dpi_roundtrips_per_device() {
980        let mut cfg = Config::default();
981        cfg.set_dpi("2b042", 1600);
982        let restored = write_and_read(&cfg);
983        assert_eq!(restored.dpi("2b042"), Some(1600));
984        assert_eq!(restored.dpi("absent"), None);
985    }
986
987    #[test]
988    fn smartshift_roundtrips_per_device() {
989        let mut cfg = Config::default();
990        cfg.set_smartshift(
991            "2b042",
992            SmartShift {
993                mode: WheelMode::Ratchet,
994                auto_disengage: 16,
995                tunable_torque: 30,
996            },
997        );
998        let restored = write_and_read(&cfg);
999        assert_eq!(
1000            restored.smartshift("2b042"),
1001            Some(SmartShift {
1002                mode: WheelMode::Ratchet,
1003                auto_disengage: 16,
1004                tunable_torque: 30,
1005            })
1006        );
1007        assert_eq!(restored.smartshift("absent"), None);
1008    }
1009
1010    #[test]
1011    fn invert_scroll_roundtrips_per_device() {
1012        let mut cfg = Config::default();
1013        // Default is the native direction for any device, present or not.
1014        assert!(!cfg.invert_scroll("2b042"));
1015        cfg.set_invert_scroll("2b042", true);
1016        let restored = write_and_read(&cfg);
1017        assert!(restored.invert_scroll("2b042"));
1018        assert!(!restored.invert_scroll("absent"));
1019    }
1020
1021    #[test]
1022    fn default_invert_scroll_is_omitted_from_toml() {
1023        // A device block with only the default (false) invert_scroll must not
1024        // emit the field — `skip_serializing_if` keeps configs clean.
1025        let mut cfg = Config::default();
1026        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1027        cfg.set_invert_scroll("2b042", false);
1028        let body = toml::to_string_pretty(&cfg).expect("serialize");
1029        assert!(
1030            !body.contains("invert_scroll"),
1031            "default invert_scroll should be omitted: {body}"
1032        );
1033    }
1034
1035    #[test]
1036    fn bindings_roundtrip_per_device() {
1037        let mut cfg = Config::default();
1038        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1039        cfg.set_binding(
1040            "2b042",
1041            ButtonId::DpiToggle,
1042            Binding::Single(Action::CustomShortcut(crate::binding::KeyCombo {
1043                modifiers: crate::binding::KeyCombo::MOD_CMD,
1044                key_code: 0x23, // kVK_ANSI_P
1045                display: "⌘P".into(),
1046            })),
1047        );
1048        cfg.set_binding("4082d", ButtonId::Back, Binding::Single(Action::Paste));
1049
1050        let parsed = write_and_read(&cfg);
1051
1052        // Per-device isolation.
1053        let a = parsed.bindings_for("2b042");
1054        assert_eq!(a.get(&ButtonId::Back), Some(&Binding::Single(Action::Copy)));
1055        assert_eq!(
1056            a.get(&ButtonId::DpiToggle),
1057            Some(&Binding::Single(Action::CustomShortcut(
1058                crate::binding::KeyCombo {
1059                    modifiers: crate::binding::KeyCombo::MOD_CMD,
1060                    key_code: 0x23,
1061                    display: "⌘P".into(),
1062                }
1063            )))
1064        );
1065
1066        let b = parsed.bindings_for("4082d");
1067        assert_eq!(
1068            b.get(&ButtonId::Back),
1069            Some(&Binding::Single(Action::Paste))
1070        );
1071        assert_eq!(b.len(), 1, "device b should only see its own bindings");
1072
1073        // Unknown device returns empty map without panic.
1074        assert!(parsed.bindings_for("deadbeef").is_empty());
1075    }
1076
1077    #[test]
1078    fn human_readable_toml_layout() {
1079        let mut cfg = Config::default();
1080        cfg.set_binding(
1081            "2b042",
1082            ButtonId::Back,
1083            Binding::Single(Action::BrowserBack),
1084        );
1085        let body = toml::to_string_pretty(&cfg).expect("serialize");
1086
1087        // The model id only contains [A-Za-z0-9_], so TOML emits it as a
1088        // bare-word table key (no surrounding quotes). The test asserts the
1089        // observable structure rather than locking in a specific quoting.
1090        assert!(body.contains("schema_version = 2"), "got: {body}");
1091        assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1092        // A `Single` binding serializes byte-identically to the pre-v2 bare
1093        // `Action`, so the leaf line is unchanged.
1094        assert!(body.contains("Back = \"BrowserBack\""), "got: {body}");
1095    }
1096
1097    #[test]
1098    fn dpi_presets_roundtrip_per_device() {
1099        let mut cfg = Config::default();
1100        cfg.set_dpi_presets("2b042", vec![800, 1600, 3200]);
1101        cfg.set_dpi_presets("4082d", vec![400, 1600]);
1102
1103        let parsed = write_and_read(&cfg);
1104
1105        assert_eq!(parsed.dpi_presets("2b042"), vec![800, 1600, 3200]);
1106        assert_eq!(parsed.dpi_presets("4082d"), vec![400, 1600]);
1107        assert!(parsed.dpi_presets("unknown").is_empty());
1108    }
1109
1110    #[test]
1111    fn empty_dpi_presets_skip_serialization() {
1112        let mut cfg = Config::default();
1113        // Add a binding so the device block exists.
1114        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1115        cfg.set_dpi_presets("2b042", vec![800]);
1116        cfg.set_dpi_presets("2b042", vec![]); // clear
1117
1118        let body = toml::to_string_pretty(&cfg).expect("serialize");
1119        assert!(
1120            !body.contains("dpi_presets"),
1121            "empty dpi_presets should be omitted: {body}"
1122        );
1123    }
1124
1125    #[test]
1126    fn device_identity_roundtrips_and_is_iterable() {
1127        use crate::device::{Capabilities, DeviceKind};
1128
1129        let mut cfg = Config::default();
1130        let mouse = DeviceIdentity {
1131            display_name: "MX Master 3S".to_string(),
1132            kind: DeviceKind::Mouse,
1133            capabilities: Capabilities {
1134                buttons: true,
1135                pointer: true,
1136                lighting: false,
1137            },
1138        };
1139        cfg.set_device_identity("2b034", mouse.clone());
1140        // Recording an identity must not disturb unrelated per-device state.
1141        cfg.set_binding(
1142            "2b034",
1143            ButtonId::Back,
1144            Binding::Single(Action::BrowserBack),
1145        );
1146
1147        let parsed = write_and_read(&cfg);
1148        assert_eq!(parsed.device_identity("2b034"), Some(&mouse));
1149        assert_eq!(parsed.device_identity("absent"), None);
1150        assert_eq!(
1151            parsed.bindings_for("2b034").get(&ButtonId::Back),
1152            Some(&Binding::Single(Action::BrowserBack)),
1153            "identity must coexist with bindings on the same device block"
1154        );
1155        assert_eq!(
1156            parsed.known_identities().collect::<Vec<_>>(),
1157            vec![("2b034", &mouse)]
1158        );
1159    }
1160
1161    #[test]
1162    fn selected_device_roundtrips() {
1163        let mut cfg = Config::default();
1164        assert_eq!(cfg.selected_device(), None);
1165        cfg.set_selected_device(Some("2b042".into()));
1166        let parsed = write_and_read(&cfg);
1167        assert_eq!(parsed.selected_device(), Some("2b042"));
1168    }
1169
1170    #[test]
1171    fn per_app_overlay_takes_precedence() {
1172        let mut cfg = Config::default();
1173        cfg.set_binding(
1174            "2b042",
1175            ButtonId::Back,
1176            Binding::Single(Action::BrowserBack),
1177        );
1178        cfg.set_binding(
1179            "2b042",
1180            ButtonId::Forward,
1181            Binding::Single(Action::BrowserForward),
1182        );
1183        cfg.set_per_app_binding(
1184            "2b042",
1185            "com.microsoft.VSCode",
1186            ButtonId::Back,
1187            Some(Action::Undo),
1188        );
1189
1190        // Global: both buttons are browser nav.
1191        let global = cfg.effective_bindings("2b042", None);
1192        assert_eq!(
1193            global.get(&ButtonId::Back),
1194            Some(&Binding::Single(Action::BrowserBack))
1195        );
1196        assert_eq!(
1197            global.get(&ButtonId::Forward),
1198            Some(&Binding::Single(Action::BrowserForward))
1199        );
1200
1201        // VSCode: Back overridden (wrapped as Single), Forward inherits.
1202        let vscode = cfg.effective_bindings("2b042", Some("com.microsoft.VSCode"));
1203        assert_eq!(
1204            vscode.get(&ButtonId::Back),
1205            Some(&Binding::Single(Action::Undo))
1206        );
1207        assert_eq!(
1208            vscode.get(&ButtonId::Forward),
1209            Some(&Binding::Single(Action::BrowserForward))
1210        );
1211
1212        // Unrelated app falls through.
1213        let other = cfg.effective_bindings("2b042", Some("com.apple.Safari"));
1214        assert_eq!(
1215            other.get(&ButtonId::Back),
1216            Some(&Binding::Single(Action::BrowserBack))
1217        );
1218    }
1219
1220    #[test]
1221    fn per_app_binding_removal_prunes_empty_app() {
1222        let mut cfg = Config::default();
1223        cfg.set_per_app_binding(
1224            "2b042",
1225            "com.example.App",
1226            ButtonId::Back,
1227            Some(Action::Copy),
1228        );
1229        cfg.set_per_app_binding("2b042", "com.example.App", ButtonId::Back, None);
1230        assert!(
1231            cfg.devices["2b042"].per_app_bindings.is_empty(),
1232            "removing last override should prune the app entry"
1233        );
1234    }
1235
1236    #[test]
1237    fn app_settings_default_omits_block() {
1238        let cfg = Config::default();
1239        let body = toml::to_string_pretty(&cfg).expect("serialize");
1240        assert!(
1241            !body.contains("app_settings"),
1242            "default app_settings should be omitted: {body}"
1243        );
1244    }
1245
1246    #[test]
1247    fn app_settings_launch_at_login_roundtrips() {
1248        let mut cfg = Config::default();
1249        cfg.app_settings.launch_at_login = true;
1250        let parsed = write_and_read(&cfg);
1251        assert!(parsed.app_settings.launch_at_login);
1252    }
1253
1254    #[test]
1255    fn cleared_selected_device_omits_field() {
1256        let mut cfg = Config::default();
1257        cfg.set_selected_device(Some("2b042".into()));
1258        cfg.set_selected_device(None);
1259        let body = toml::to_string_pretty(&cfg).expect("serialize");
1260        assert!(
1261            !body.contains("selected_device"),
1262            "cleared selection should not appear: {body}"
1263        );
1264    }
1265
1266    #[test]
1267    fn empty_device_block_is_skipped_in_output() {
1268        // Inserting then clearing should not leave a [devices."x"] header
1269        // with no bindings under it (skip_serializing_if on bindings).
1270        let mut cfg = Config::default();
1271        cfg.set_binding("2b042", ButtonId::Back, Binding::Single(Action::Copy));
1272        cfg.devices
1273            .get_mut("2b042")
1274            .expect("entry")
1275            .bindings
1276            .clear();
1277        let body = toml::to_string_pretty(&cfg).expect("serialize");
1278        assert!(
1279            !body.contains("Back"),
1280            "cleared bindings should not appear: {body}"
1281        );
1282    }
1283
1284    #[test]
1285    fn migrates_v1_button_and_gesture_bindings() {
1286        // A pre-v2 file: split button_bindings + a flat gesture_bindings map.
1287        let v1 = "\
1288schema_version = 1
1289
1290[devices.2b042.button_bindings]
1291Back = \"BrowserBack\"
1292
1293[devices.2b042.gesture_bindings]
1294Up = \"Copy\"
1295Click = \"Paste\"
1296";
1297        let dir = tempfile::tempdir().expect("tempdir");
1298        let path = dir.path().join("config.toml");
1299        fs::write(&path, v1).expect("write");
1300
1301        // v1 still loads (version <= current) and folds into the merged map.
1302        let cfg = Config::load_from_path(&path).expect("load v1");
1303        let bindings = cfg.bindings_for("2b042");
1304        assert_eq!(
1305            bindings.get(&ButtonId::Back),
1306            Some(&Binding::Single(Action::BrowserBack))
1307        );
1308        let mut gesture = BTreeMap::new();
1309        gesture.insert(GestureDirection::Up, Action::Copy);
1310        gesture.insert(GestureDirection::Click, Action::Paste);
1311        assert_eq!(
1312            bindings.get(&ButtonId::GestureButton),
1313            Some(&Binding::Gesture(gesture))
1314        );
1315
1316        // Saving self-heals to the v2 shape: stamped version + merged table,
1317        // legacy field names gone.
1318        let body = toml::to_string_pretty(&cfg).expect("serialize");
1319        assert!(body.contains("schema_version = 2"), "got: {body}");
1320        assert!(body.contains("[devices.2b042.bindings]"), "got: {body}");
1321        assert!(!body.contains("button_bindings"), "got: {body}");
1322        assert!(!body.contains("gesture_bindings"), "got: {body}");
1323    }
1324
1325    #[test]
1326    fn migration_gesture_map_wins_over_legacy_single_gesture_button_entry() {
1327        // The data-loss guard: when a legacy single button_bindings[GestureButton]
1328        // entry coexists with a gesture_bindings map (reachable via hand-edited
1329        // or very old configs), the gesture map must survive — not be shadowed by
1330        // the single entry. Mirrors the pre-v2 "gesture entries win" rule.
1331        let v1 = "\
1332schema_version = 1
1333
1334[devices.2b042.button_bindings]
1335GestureButton = \"MissionControl\"
1336
1337[devices.2b042.gesture_bindings]
1338Up = \"Copy\"
1339Down = \"Paste\"
1340";
1341        let dir = tempfile::tempdir().expect("tempdir");
1342        let path = dir.path().join("config.toml");
1343        fs::write(&path, v1).expect("write");
1344
1345        let cfg = Config::load_from_path(&path).expect("load v1");
1346        let mut gesture = BTreeMap::new();
1347        gesture.insert(GestureDirection::Up, Action::Copy);
1348        gesture.insert(GestureDirection::Down, Action::Paste);
1349        assert_eq!(
1350            cfg.bindings_for("2b042").get(&ButtonId::GestureButton),
1351            Some(&Binding::Gesture(gesture)),
1352            "gesture map must win over the legacy single GestureButton entry"
1353        );
1354    }
1355
1356    #[test]
1357    fn migration_drops_vestigial_lone_gesture_button_single() {
1358        // A v1 file with only `button_bindings[GestureButton]` and no
1359        // `gesture_bindings` (the pre-gesture-picker shape). That entry never
1360        // dispatched in v1 — the gesture button's plain press routes through the
1361        // gesture `Click` slot, not the per-button map — so migrating it to a
1362        // `Binding::Single` would leave an unreachable entry the GUI hides and the
1363        // runtime ignores. It must be dropped, not shadow the gesture path.
1364        let v1 = "\
1365schema_version = 1
1366
1367[devices.2b042.button_bindings]
1368GestureButton = \"MissionControl\"
1369Back = \"BrowserBack\"
1370";
1371        let dir = tempfile::tempdir().expect("tempdir");
1372        let path = dir.path().join("config.toml");
1373        fs::write(&path, v1).expect("write");
1374
1375        let bindings = Config::load_from_path(&path)
1376            .expect("load v1")
1377            .bindings_for("2b042");
1378        // An ordinary button still migrates to a `Single`...
1379        assert_eq!(
1380            bindings.get(&ButtonId::Back),
1381            Some(&Binding::Single(Action::BrowserBack))
1382        );
1383        // ...but the vestigial gesture-button single is gone, leaving the button
1384        // to fall back to its canonical default rather than an unreachable entry.
1385        assert_eq!(bindings.get(&ButtonId::GestureButton), None);
1386    }
1387
1388    #[test]
1389    fn rejects_newer_schema_version_but_accepts_v1() {
1390        // A future version is rejected loudly; the current and older versions
1391        // load (older ones migrate through the shim).
1392        let dir = tempfile::tempdir().expect("tempdir");
1393        let path = dir.path().join("config.toml");
1394        fs::write(&path, "schema_version = 99\n").expect("write");
1395        assert!(matches!(
1396            Config::load_from_path(&path).expect_err("v99 should fail"),
1397            ConfigError::UnsupportedSchemaVersion { found: 99, .. }
1398        ));
1399
1400        fs::write(&path, "schema_version = 1\n").expect("write");
1401        assert!(
1402            Config::load_from_path(&path).is_ok(),
1403            "v1 should still load"
1404        );
1405    }
1406
1407    #[test]
1408    fn set_gesture_direction_upgrades_single_to_gesture() {
1409        let mut cfg = Config::default();
1410        // Start from a Single binding, then bind a swipe direction.
1411        cfg.set_binding(
1412            "2b042",
1413            ButtonId::Back,
1414            Binding::Single(Action::BrowserBack),
1415        );
1416        cfg.set_gesture_direction("2b042", ButtonId::Back, GestureDirection::Up, Action::Copy);
1417
1418        match cfg.bindings_for("2b042").get(&ButtonId::Back) {
1419            Some(Binding::Gesture(map)) => {
1420                // The prior single action is preserved as the Click entry.
1421                assert_eq!(
1422                    map.get(&GestureDirection::Click),
1423                    Some(&Action::BrowserBack)
1424                );
1425                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1426            }
1427            other => panic!("expected Gesture after upgrade, got {other:?}"),
1428        }
1429    }
1430
1431    #[test]
1432    fn set_gesture_direction_on_fresh_gesture_button_seeds_click() {
1433        // Binding one direction on a never-configured gesture button must still
1434        // persist a `Click`, so the click projection is the canonical default
1435        // rather than `Action::None` (which reads as a no-op press).
1436        let mut cfg = Config::default();
1437        cfg.set_gesture_direction(
1438            "2b042",
1439            ButtonId::GestureButton,
1440            GestureDirection::Up,
1441            Action::Copy,
1442        );
1443
1444        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1445            Some(Binding::Gesture(map)) => {
1446                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1447                assert_eq!(
1448                    map.get(&GestureDirection::Click),
1449                    Some(&crate::binding::default_gesture_binding(
1450                        GestureDirection::Click
1451                    )),
1452                    "a fresh gesture button must seed a Click from its default"
1453                );
1454            }
1455            other => panic!("expected Gesture, got {other:?}"),
1456        }
1457    }
1458
1459    #[test]
1460    fn gesture_owner_defaults_to_thumb_pad_yields_to_oshook_and_can_be_off() {
1461        let mut cfg = Config::default();
1462        // Default: the thumb pad owns the gesture role even with no config.
1463        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1464
1465        // A thumb-pad gesture binding keeps it the owner.
1466        cfg.set_gesture_direction(
1467            "2b042",
1468            ButtonId::GestureButton,
1469            GestureDirection::Up,
1470            Action::MissionControl,
1471        );
1472        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1473
1474        // An explicit OS-hook gesture button takes the role over.
1475        cfg.set_binding(
1476            "2b042",
1477            ButtonId::Forward,
1478            Binding::Gesture(BTreeMap::from([(GestureDirection::Up, Action::Copy)])),
1479        );
1480        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Forward));
1481
1482        // Turning gestures off explicitly yields `None` (not the thumb-pad default).
1483        let mut off = Config::default();
1484        off.disable_gestures("2b042");
1485        assert_eq!(off.gesture_owner("2b042"), None);
1486    }
1487
1488    #[test]
1489    fn set_gesture_owner_records_owner_without_destroying_other_maps() {
1490        let mut cfg = Config::default();
1491        // Customize the thumb pad's Up swipe; it is the (inferred) owner.
1492        cfg.set_gesture_direction(
1493            "2b042",
1494            ButtonId::GestureButton,
1495            GestureDirection::Up,
1496            Action::Copy,
1497        );
1498        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1499
1500        // Promote Back: the owner becomes Back explicitly; the thumb pad keeps
1501        // its full gesture map (no destructive demotion).
1502        cfg.set_binding("2b042", ButtonId::Back, Action::BrowserBack.into());
1503        cfg.set_gesture_owner("2b042", ButtonId::Back);
1504        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::Back));
1505
1506        let bindings = cfg.bindings_for("2b042");
1507        // Back is a full five-direction gesture button: its prior single action
1508        // stays as Click, and the swipe arms are seeded from defaults.
1509        match bindings.get(&ButtonId::Back) {
1510            Some(Binding::Gesture(map)) => {
1511                assert_eq!(
1512                    map.get(&GestureDirection::Click),
1513                    Some(&Action::BrowserBack)
1514                );
1515                assert_eq!(
1516                    map.get(&GestureDirection::Up),
1517                    Some(&default_gesture_binding(GestureDirection::Up)),
1518                    "a promoted button gets full default arms"
1519                );
1520            }
1521            other => panic!("expected Back to be a gesture binding, got {other:?}"),
1522        }
1523        // The thumb pad's customized map survived the switch intact.
1524        match bindings.get(&ButtonId::GestureButton) {
1525            Some(Binding::Gesture(map)) => {
1526                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1527            }
1528            other => panic!("expected the thumb pad map preserved, got {other:?}"),
1529        }
1530
1531        // Switching back restores the user's customization, not defaults
1532        // (regression guard: owner-switch used to discard the swipe arms).
1533        cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1534        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1535        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1536            Some(Binding::Gesture(map)) => {
1537                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1538            }
1539            other => panic!("expected preserved gesture map, got {other:?}"),
1540        }
1541    }
1542
1543    #[test]
1544    fn set_gesture_owner_seeds_a_fresh_button_with_full_directions() {
1545        let mut cfg = Config::default();
1546        // The dedicated thumb pad gets the full default direction map.
1547        cfg.set_gesture_owner("2b042", ButtonId::GestureButton);
1548        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1549            Some(Binding::Gesture(map)) => {
1550                for dir in GestureDirection::ALL {
1551                    assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1552                }
1553            }
1554            other => panic!("expected full default gesture map, got {other:?}"),
1555        }
1556
1557        // A fresh OS-hook button also gets all five directions, not just a Click:
1558        // its native action stays as Click, and the swipe arms are defaults — so
1559        // the GUI's shown defaults are exactly what the runtime dispatches.
1560        cfg.set_gesture_owner("2b042", ButtonId::Forward);
1561        match cfg.bindings_for("2b042").get(&ButtonId::Forward) {
1562            Some(Binding::Gesture(map)) => {
1563                assert_eq!(
1564                    map.get(&GestureDirection::Click),
1565                    Some(&default_binding(ButtonId::Forward))
1566                );
1567                for dir in [
1568                    GestureDirection::Up,
1569                    GestureDirection::Down,
1570                    GestureDirection::Left,
1571                    GestureDirection::Right,
1572                ] {
1573                    assert_eq!(map.get(&dir), Some(&default_gesture_binding(dir)));
1574                }
1575            }
1576            other => panic!("expected full gesture map for Forward, got {other:?}"),
1577        }
1578    }
1579
1580    #[test]
1581    fn disable_gestures_turns_off_without_destroying_maps() {
1582        let mut cfg = Config::default();
1583        cfg.set_gesture_direction(
1584            "2b042",
1585            ButtonId::GestureButton,
1586            GestureDirection::Up,
1587            Action::Copy,
1588        );
1589        cfg.disable_gestures("2b042");
1590        // Off, but the thumb pad's customized map is preserved (re-enabling
1591        // restores it rather than resurrecting a wiped default).
1592        assert_eq!(cfg.gesture_owner("2b042"), None);
1593        match cfg.bindings_for("2b042").get(&ButtonId::GestureButton) {
1594            Some(Binding::Gesture(map)) => {
1595                assert_eq!(map.get(&GestureDirection::Up), Some(&Action::Copy));
1596            }
1597            other => panic!("expected the gesture map preserved while off, got {other:?}"),
1598        }
1599    }
1600
1601    #[test]
1602    fn gesture_owner_field_roundtrips_as_a_scalar() {
1603        let mut cfg = Config::default();
1604        cfg.set_gesture_owner("2b042", ButtonId::Back); // explicit button
1605        cfg.disable_gestures("4082d"); // explicit off
1606
1607        let parsed = write_and_read(&cfg);
1608        assert_eq!(parsed.gesture_owner("2b042"), Some(ButtonId::Back));
1609        assert_eq!(parsed.gesture_owner("4082d"), None);
1610
1611        // The custom codec keeps it a bare TOML string (a nested table would risk
1612        // a value-after-table serialization error, since `bindings` is a table).
1613        let body = toml::to_string_pretty(&cfg).expect("serialize");
1614        assert!(body.contains("gesture_owner = \"Back\""), "got: {body}");
1615        assert!(body.contains("gesture_owner = \"Off\""), "got: {body}");
1616    }
1617
1618    #[test]
1619    fn invalid_gesture_owner_string_is_tolerated_not_fatal() {
1620        // A hand-edit typo in gesture_owner must NOT fail the whole-document parse
1621        // (which would revert every device's settings to defaults). It degrades
1622        // to "infer" while the rest of the device config survives.
1623        let toml = "\
1624schema_version = 2
1625
1626[devices.2b042]
1627gesture_owner = \"bogus\"
1628
1629[devices.2b042.bindings]
1630Back = \"Copy\"
1631";
1632        let dir = tempfile::tempdir().expect("tempdir");
1633        let path = dir.path().join("config.toml");
1634        fs::write(&path, toml).expect("write");
1635
1636        let cfg =
1637            Config::load_from_path(&path).expect("an invalid gesture_owner must not fail the load");
1638        // The rest of the device config survived...
1639        assert_eq!(
1640            cfg.bindings_for("2b042").get(&ButtonId::Back),
1641            Some(&Binding::Single(Action::Copy))
1642        );
1643        // ...and the bad owner degraded to inference (thumb-pad default here).
1644        assert_eq!(cfg.gesture_owner("2b042"), Some(ButtonId::GestureButton));
1645    }
1646}