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