Skip to main content

openlogi_core/
config.rs

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