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