Skip to main content

openlogi_core/config/
identity.rs

1//! Resolving a device's configuration key from its route and its own identity.
2//!
3//! A device's entry is keyed by what it *is* — its unit id or serial — so
4//! settings follow the device when it moves between a receiver and a cable.
5//! When the device is asleep its identity is unreadable and only the route is
6//! known, so the entry's `links` table doubles as a route index.
7
8#[cfg(test)]
9use crate::binding::{Action, ButtonId};
10use crate::config::{Config, DeviceConfig};
11#[cfg(test)]
12use crate::config::{LightSettings, Lighting, LinkConfig};
13use crate::device::Capabilities;
14use crate::device_order::{DeviceIdentity, DeviceStableId, PhysicalDeviceKey};
15#[cfg(test)]
16use crate::hid::Dpi;
17
18impl DeviceIdentity {
19    /// This identity as a transport-free configuration key, or `None` when the
20    /// device reported no identity worth keying on.
21    #[must_use]
22    pub fn config_key(&self) -> Option<String> {
23        self.is_physical().then(|| self.key())
24    }
25}
26
27/// The key a device's settings ultimately belong under: its own identity when
28/// it reported one, else the route-derived key that is the best available
29/// stand-in.
30///
31/// This is the *destination* of the schema-5 model, independent of what is on
32/// disk right now. [`Config::resolve_device_key`] may legitimately answer with
33/// a different, pre-upgrade key while the settings still live there; this is
34/// the key those settings are folded onto by [`Config::adopt_route`].
35#[must_use]
36pub fn canonical_device_key(
37    stable: &DeviceStableId,
38    identity: Option<&DeviceIdentity>,
39) -> Option<PhysicalDeviceKey> {
40    identity
41        .and_then(DeviceIdentity::config_key)
42        .and_then(|key| PhysicalDeviceKey::parse(&key))
43        .or_else(|| stable.physical_key())
44}
45
46impl Config {
47    /// Restore the route index's one-owner invariant after loading and schema
48    /// migration.
49    ///
50    /// A hand-edited file can put one route in several entries, and a key
51    /// migration can add a link that an existing entry already claims. Device
52    /// keys are stored in a [`BTreeMap`](std::collections::BTreeMap), so the
53    /// lexicographically first key keeps its complete
54    /// [`LinkConfig`](crate::config::LinkConfig) and later claims are removed.
55    /// This matches the owner an ambiguous index resolved
56    /// to before the repair, when [`Self::resolve_device_key`] used the first
57    /// matching map entry.
58    ///
59    /// Only the route index is repaired. Device entries are neither renamed
60    /// nor consumed, so [`Self::selected_device`] and
61    /// [`DeviceConfig::host_switch_targets`] keep referring to the same
62    /// physical configuration entries.
63    #[cfg(feature = "fs")]
64    pub(super) fn repair_duplicate_routes(&mut self) {
65        let mut owners = std::collections::BTreeMap::<String, String>::new();
66        for (device_key, device) in &mut self.devices {
67            device.links.retain(|route_key, _| {
68                let Some(owner) = owners.get(route_key) else {
69                    owners.insert(route_key.clone(), device_key.clone());
70                    return true;
71                };
72                tracing::warn!(
73                    %route_key,
74                    %owner,
75                    duplicate_owner = %device_key,
76                    "route is indexed by multiple devices; keeping the first owner"
77                );
78                false
79            });
80        }
81    }
82
83    /// The configuration key to *read and write* for a device reached by
84    /// `stable`, given whatever `identity` the current probe could read.
85    ///
86    /// Prefers the device's own identity — that is the whole point of the
87    /// schema-5 model — but only once the settings are actually there. A
88    /// `receiver:`/`raw:` entry is not renamed by the load-time migration
89    /// (nothing on disk says which device occupies a pairing slot), so
90    /// immediately after an upgrade every such device's settings still live
91    /// under its route-derived key while its identity key holds nothing.
92    /// Answering with the identity key there would silently apply defaults to
93    /// every receiver-paired device until the GUI next ran
94    /// [`Self::adopt_route`] — which happens only when the user opens the GUI,
95    /// and the agent runs unattended from login. So the pre-upgrade key wins
96    /// for exactly as long as it is the one holding the settings.
97    ///
98    /// "Holding the settings" is [`DeviceConfig::holds_settings`], not mere
99    /// existence: an entry carrying only the probed identity and the `links`
100    /// route index is metadata OpenLogi wrote itself, and must not out-rank a
101    /// legacy entry holding the user's actual bindings and DPI.
102    ///
103    /// Failing both, and for a device whose identity is unreadable (asleep),
104    /// the route is resolved through the persisted `links` index, then finally
105    /// through the route-derived key that every entry used before this
106    /// indirection existed.
107    #[must_use]
108    pub fn resolve_device_key(
109        &self,
110        stable: &DeviceStableId,
111        identity: Option<&DeviceIdentity>,
112    ) -> Option<PhysicalDeviceKey> {
113        if let Some(key) = identity
114            .and_then(DeviceIdentity::config_key)
115            .and_then(|key| PhysicalDeviceKey::parse(&key))
116        {
117            let holds_settings = |key: &str| {
118                self.devices
119                    .get(key)
120                    .is_some_and(DeviceConfig::holds_settings)
121            };
122            let legacy = stable
123                .physical_key()
124                .filter(|legacy| legacy.as_str() != key.as_str());
125            if let Some(legacy) = legacy
126                && !holds_settings(key.as_str())
127                && holds_settings(legacy.as_str())
128            {
129                return Some(legacy);
130            }
131            return Some(key);
132        }
133        let route = stable.route_key();
134        if let Some(key) = self
135            .devices
136            .iter()
137            .find(|(_, device)| device.links.contains_key(&route))
138            .map(|(key, _)| key.as_str())
139            .and_then(PhysicalDeviceKey::parse)
140        {
141            return Some(key);
142        }
143        stable.physical_key()
144    }
145
146    /// Record that the device keyed `canonical` was reached by `route_key`,
147    /// folding in any entry still keyed by that route.
148    ///
149    /// `capabilities` is what the device just measured on this route, and it
150    /// is what makes the per-link table a record of the hardware rather than
151    /// a leftover of migration: a G502 that answers `0x2121` over its
152    /// receiver and not over USB only differs in the config once both links
153    /// have been sighted. Stored on every online sighting, so a link whose
154    /// capabilities genuinely change stops reporting the old ones. `None`
155    /// leaves whatever the link already recorded — an unprobed sighting is
156    /// not evidence the capability went away.
157    ///
158    /// Returns whether anything actually changed — a route was newly
159    /// registered in the entry's `links` index, its measured capabilities
160    /// differ from what was recorded, a stale link pointing a re-paired
161    /// route at its previous device was removed, or a legacy entry
162    /// was folded in. Callers use this to decide whether the mutation needs
163    /// persisting; a `false` means the entry already recorded this exact
164    /// route and there is nothing new to write. Called on an online
165    /// sighting, where the device's identity is known and the route can
166    /// therefore be attributed to it with confidence.
167    ///
168    /// Consuming a legacy entry is a rename, and a device key lives in three
169    /// places — the `devices` map, [`Config::selected_device`], and every
170    /// entry of some keyboard's [`DeviceConfig::host_switch_targets`]. All
171    /// three are re-pointed here, exactly as
172    /// [`Config::migrate_transport_scoped_keys`] does for the keys it renames
173    /// at load; leaving either reference behind would drop the carousel
174    /// selection and silently unlink a host-switch target.
175    pub fn adopt_route(
176        &mut self,
177        canonical: &PhysicalDeviceKey,
178        route_key: &str,
179        capabilities: Option<Capabilities>,
180    ) -> bool {
181        let mut changed = false;
182        // A route names one device at a time. Re-pairing a different unit into
183        // a slot must move the index, not leave the slot pointing at both.
184        for (key, device) in &mut self.devices {
185            if key != canonical.as_str() && device.links.remove(route_key).is_some() {
186                changed = true;
187            }
188        }
189        let legacy = (route_key != canonical.as_str())
190            .then(|| self.devices.remove(route_key))
191            .flatten();
192        let device = self
193            .devices
194            .entry(canonical.as_str().to_string())
195            .or_default();
196        if !device.links.contains_key(route_key) {
197            changed = true;
198        }
199        let link = device.links.entry(route_key.to_string()).or_default();
200        // Only a probe that actually answered may overwrite the record, and
201        // only when it says something new — rewriting an identical value on
202        // every poll would persist the config on every tick.
203        if capabilities.is_some() && link.capabilities != capabilities {
204            link.capabilities = capabilities;
205            changed = true;
206        }
207        let Some(legacy) = legacy else {
208            return changed;
209        };
210        fold(device, legacy, route_key);
211        self.repoint_references(route_key, canonical.as_str());
212        true
213    }
214
215    /// Rewrite every reference to `old` — the carousel selection and any
216    /// host-switch target — to `new`, after `old`'s entry was consumed.
217    fn repoint_references(&mut self, old: &str, new: &str) {
218        if self.selected_device.as_deref() == Some(old) {
219            self.selected_device = Some(new.to_string());
220        }
221        for device in self.devices.values_mut() {
222            for target in &mut device.host_switch_targets {
223                if target == old {
224                    *target = new.to_string();
225                }
226            }
227        }
228    }
229}
230
231/// Merge a legacy route-keyed entry into its device's canonical entry.
232///
233/// A value the canonical side does not have is simply taken. A value both
234/// sides have and disagree on is not a conflict to resolve but a difference to
235/// preserve: the canonical value stays the device default and the legacy one
236/// becomes an override on its own link, when the field has an override slot
237/// to preserve it in; otherwise the canonical value wins and the disagreement
238/// is logged rather than silently discarded.
239///
240/// Routes `legacy` itself indexed come along, so folding is closed over the
241/// `links` map: a route the canonical entry already knows keeps the canonical
242/// link (its overrides are the ones expressed against the canonical
243/// defaults). At runtime that map is always empty — `links` did not exist
244/// before schema 5, so a route-keyed entry on disk cannot carry one — but
245/// [`Config::migrate_transport_scoped_keys`] folds two *renamed* entries that
246/// each already carry the route they were renamed out of.
247pub(super) fn fold(device: &mut DeviceConfig, mut legacy: DeviceConfig, route_key: &str) {
248    for (key, value) in std::mem::take(&mut legacy.links) {
249        device.links.entry(key).or_insert(value);
250    }
251    fold_maps(device, &mut legacy, route_key);
252
253    let link = device.links.entry(route_key.to_string()).or_default();
254    if let Some(capabilities) = legacy
255        .identity
256        .as_ref()
257        .map(|identity| identity.capabilities)
258    {
259        // Only where the link has no measurement of its own: the legacy
260        // entry's copy can be arbitrarily old, and the sighting that
261        // triggered this fold just measured the same route.
262        link.capabilities.get_or_insert(capabilities);
263    }
264
265    // Values with a per-link override slot: canonical stays the device
266    // default and a disagreeing legacy value survives as an override scoped
267    // to the route it was set on.
268    macro_rules! fold_field {
269        ($field:ident) => {
270            match (&device.$field, legacy.$field) {
271                (None, Some(value)) => device.$field = Some(value),
272                (Some(ours), Some(theirs)) if *ours != theirs => {
273                    link.overrides.$field = Some(theirs);
274                }
275                _ => {}
276            }
277        };
278    }
279    fold_field!(dpi);
280    fold_field!(scroll_resolution);
281    fold_field!(lighting);
282    fold_field!(smartshift);
283
284    // `invert_scroll` is a bare bool, so `false` is indistinguishable from
285    // never-set: only a legacy `true` is evidence the user chose anything.
286    // Recording an override for a legacy `false` would fire far more often
287    // wrongly (a device that simply never had the setting touched) than
288    // rightly.
289    if device.invert_scroll != legacy.invert_scroll && legacy.invert_scroll {
290        link.overrides.invert_scroll = Some(legacy.invert_scroll);
291    }
292
293    // Scalar values with no per-link override slot: take the legacy value
294    // when the canonical side is unset. A genuine disagreement keeps the
295    // canonical value — there is nowhere to park the legacy one — and is
296    // logged so the loss is visible instead of silent.
297    macro_rules! fold_option_field {
298        ($field:ident) => {
299            match (&device.$field, legacy.$field) {
300                (None, Some(value)) => device.$field = Some(value),
301                (Some(ours), Some(theirs)) if *ours != theirs => {
302                    tracing::warn!(
303                        %route_key,
304                        field = stringify!($field),
305                        "value differs between merged entries; keeping the canonical one"
306                    );
307                }
308                _ => {}
309            }
310        };
311    }
312    fold_option_field!(light);
313    fold_option_field!(camera_controls);
314    fold_option_field!(camera_profile);
315    fold_option_field!(thumbwheel_sensitivity);
316    fold_option_field!(fn_lock);
317    fold_option_field!(keep_os_layout);
318    fold_option_field!(report_rate);
319    // The user-assigned alias. Without this a legacy entry carrying a name
320    // folded into a canonical entry with none would drop it silently — the
321    // one field here a user typed by hand, so the loss is the most visible.
322    fold_option_field!(custom_name);
323
324    if device.identity.is_none() {
325        device.identity = legacy.identity.take();
326    }
327
328    // Collections with no natural per-item override: take the legacy value
329    // wholesale when the canonical side is empty (i.e. never configured).
330    // Merging two curated lists is not a safe default — a DPI cycle is
331    // ordered and a host-switch list is positional — so a populated
332    // canonical side wins, and the list it displaces is logged.
333    macro_rules! fold_if_empty {
334        ($field:ident) => {
335            if device.$field.is_empty() {
336                device.$field = legacy.$field;
337            } else if !legacy.$field.is_empty() && device.$field != legacy.$field {
338                tracing::warn!(
339                    %route_key,
340                    field = stringify!($field),
341                    "list differs between merged entries; keeping the canonical one"
342                );
343            }
344        };
345    }
346    fold_if_empty!(dpi_presets);
347    fold_if_empty!(host_switch_targets);
348
349    // `ActionRingConfig` has its own notion of "unset". Two configured rings
350    // cannot be merged — the slots are positional — so the canonical one wins
351    // and the displaced one is logged, as elsewhere.
352    if device.action_ring.is_default() {
353        device.action_ring = legacy.action_ring.clone();
354    } else if !legacy.action_ring.is_default() && device.action_ring != legacy.action_ring {
355        tracing::warn!(
356            %route_key,
357            field = "action_ring",
358            "ring differs between merged entries; keeping the canonical one"
359        );
360    }
361
362    // `enabled` defaults to `true` and is only ever persisted when `false`,
363    // so a legacy `false` is a deliberate "leave this device alone" choice
364    // that must not be lost under a canonical entry that never opted out.
365    if device.enabled && !legacy.enabled {
366        device.enabled = false;
367    }
368}
369
370/// The map-valued halves of [`fold`], split out to keep that function inside
371/// the workspace's line budget.
372///
373/// Maps merge key by key: a legacy entry is taken only where the canonical map
374/// has no entry for that key. A genuine conflict on a shared key keeps the
375/// canonical entry and is logged, not silently dropped.
376fn fold_maps(device: &mut DeviceConfig, legacy: &mut DeviceConfig, route_key: &str) {
377    macro_rules! fold_map_field {
378        ($field:ident) => {
379            for (key, value) in std::mem::take(&mut legacy.$field) {
380                if let Some(existing) = device.$field.get(&key) {
381                    if *existing != value {
382                        tracing::warn!(
383                            %route_key,
384                            field = stringify!($field),
385                            key = ?key,
386                            "entry differs between merged entries; keeping the canonical one"
387                        );
388                    }
389                } else {
390                    device.$field.insert(key, value);
391                }
392            }
393        };
394    }
395    fold_map_field!(bindings);
396    fold_map_field!(disabled_gestures);
397    fold_map_field!(per_app_bindings);
398    fold_map_field!(camera_profiles);
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use crate::device_order::{DeviceIdentity, DeviceStableId};
405
406    fn cabled() -> DeviceStableId {
407        DeviceStableId::Direct {
408            vendor_id: 0x046d,
409            product_id: 0xc08d,
410            identity: DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]),
411        }
412    }
413
414    fn on_receiver() -> DeviceStableId {
415        DeviceStableId::Bolt {
416            receiver_uid: "82839805".to_string(),
417            slot: 1,
418        }
419    }
420
421    #[test]
422    fn a_known_unit_keys_the_entry_directly() {
423        let config = Config::default();
424        let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
425        let key = config
426            .resolve_device_key(&cabled(), Some(&unit))
427            .expect("a non-zero unit is a physical identity");
428        assert_eq!(key.as_str(), "unit:6be9d300");
429    }
430
431    #[test]
432    fn the_same_unit_resolves_alike_on_either_route() {
433        // The whole point: one mouse, two routes, one entry.
434        let config = Config::default();
435        let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
436        assert_eq!(
437            config.resolve_device_key(&cabled(), Some(&unit)),
438            config.resolve_device_key(&on_receiver(), Some(&unit)),
439        );
440    }
441
442    fn litra() -> DeviceStableId {
443        DeviceStableId::RawHid {
444            vendor_id: 0x046d,
445            product_id: 0xc901,
446            usage_page: 0xff43,
447            usage_id: 0x0202,
448            identity: "serial:beam-1".to_string(),
449        }
450    }
451
452    #[test]
453    fn settings_still_under_the_pre_upgrade_key_are_read_from_it() {
454        // The upgrade path the agent lives through: the load-time migration
455        // deliberately leaves `receiver:` keys alone, and only the GUI folds
456        // them. Answering with `unit:6be9d300` here would hand the agent an
457        // empty entry, so every receiver-paired device would silently revert
458        // to default bindings and DPI until the user next opened the GUI.
459        let mut config = Config::default();
460        config.devices.insert(
461            "receiver:82839805:slot:1".to_string(),
462            DeviceConfig {
463                dpi: Some(Dpi::new(3200)),
464                ..DeviceConfig::default()
465            },
466        );
467
468        let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
469        let key = config
470            .resolve_device_key(&on_receiver(), Some(&unit))
471            .expect("resolves");
472        assert_eq!(key.as_str(), "receiver:82839805:slot:1");
473        assert_eq!(
474            config.devices[key.as_str()].dpi,
475            Some(Dpi::new(3200)),
476            "the settings are where the key says they are"
477        );
478    }
479
480    #[test]
481    fn a_standalone_raw_entry_keeps_its_light_settings() {
482        // A Litra has no migration at all: its key would move from
483        // `raw:…:serial:beam-1` to `serial:beam-1` the moment the identity
484        // won, orphaning the light settings the user configured.
485        let mut config = Config::default();
486        config.devices.insert(
487            "raw:046d:c901:ff43:0202:serial:beam-1".to_string(),
488            DeviceConfig {
489                light: Some(LightSettings::default()),
490                ..DeviceConfig::default()
491            },
492        );
493
494        let serial = DeviceIdentity::Serial("beam-1".to_string());
495        let key = config
496            .resolve_device_key(&litra(), Some(&serial))
497            .expect("resolves");
498        assert_eq!(key.as_str(), "raw:046d:c901:ff43:0202:serial:beam-1");
499    }
500
501    #[test]
502    fn a_canonical_entry_holding_only_metadata_does_not_shadow_the_legacy_one() {
503        // `persist_identities` and the `links` index write entries on their
504        // own. Neither is a user setting, so neither may out-rank the entry
505        // that actually holds the bindings.
506        let mut config = Config::default();
507        config.devices.insert(
508            "receiver:82839805:slot:1".to_string(),
509            DeviceConfig {
510                dpi: Some(Dpi::new(3200)),
511                ..DeviceConfig::default()
512            },
513        );
514        let mut bare = DeviceConfig::default();
515        bare.links.insert(
516            "receiver:82839805:slot:1".to_string(),
517            LinkConfig::default(),
518        );
519        config.devices.insert("unit:6be9d300".to_string(), bare);
520
521        let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
522        let key = config
523            .resolve_device_key(&on_receiver(), Some(&unit))
524            .expect("resolves");
525        assert_eq!(key.as_str(), "receiver:82839805:slot:1");
526    }
527
528    #[test]
529    fn a_canonical_entry_with_settings_wins_over_a_legacy_one() {
530        // Once adoption (or a plain first-run write) has put real settings
531        // under the identity key, that is the answer — the legacy entry is a
532        // leftover the next fold will consume.
533        let mut config = Config::default();
534        config.devices.insert(
535            "receiver:82839805:slot:1".to_string(),
536            DeviceConfig {
537                dpi: Some(Dpi::new(3200)),
538                ..DeviceConfig::default()
539            },
540        );
541        config.devices.insert(
542            "unit:6be9d300".to_string(),
543            DeviceConfig {
544                dpi: Some(Dpi::new(800)),
545                ..DeviceConfig::default()
546            },
547        );
548
549        let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
550        let key = config
551            .resolve_device_key(&on_receiver(), Some(&unit))
552            .expect("resolves");
553        assert_eq!(key.as_str(), "unit:6be9d300");
554    }
555
556    #[test]
557    fn the_route_key_is_the_legacy_entry_key_for_every_variant_but_direct() {
558        // `adopt_route` finds a legacy entry with `devices.remove(route_key)`,
559        // which only works because a Bolt/RawHid/Unknown route key *is* the
560        // key such an entry was written under. A Direct route key deliberately
561        // is not: its identity moved into the entry key, which is exactly why
562        // the load-time migration can rename direct entries and
563        // cannot rename the others. Three doc comments say so; this asserts it.
564        for stable in [on_receiver(), litra()] {
565            let physical = stable.physical_key().expect("physical");
566            assert_eq!(
567                stable.route_key(),
568                physical.as_str(),
569                "{stable:?}: the route key must find the legacy entry"
570            );
571        }
572        let unknown = DeviceStableId::Unknown {
573            slot: 2,
574            identity: DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]),
575        };
576        assert_eq!(
577            unknown.route_key(),
578            unknown.physical_key().expect("physical").as_str()
579        );
580
581        let direct = cabled();
582        assert_ne!(
583            direct.route_key(),
584            direct.physical_key().expect("physical").as_str(),
585            "a direct route key drops the identity that keys the entry"
586        );
587    }
588
589    #[test]
590    fn adoption_repoints_the_selection_and_any_host_switch_target() {
591        // Adoption is a rename, and a device key lives in three places. A
592        // stale `selected_device` silently jumps the carousel to the first
593        // device; a stale host-switch target silently drops out of the group.
594        let mut config = Config::default();
595        config.devices.insert(
596            "receiver:82839805:slot:1".to_string(),
597            DeviceConfig {
598                dpi: Some(Dpi::new(800)),
599                ..DeviceConfig::default()
600            },
601        );
602        config.devices.insert(
603            "receiver:82839805:slot:2".to_string(),
604            DeviceConfig {
605                host_switch_targets: vec!["receiver:82839805:slot:1".to_string()],
606                ..DeviceConfig::default()
607            },
608        );
609        config.selected_device = Some("receiver:82839805:slot:1".to_string());
610
611        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
612        assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
613
614        assert_eq!(config.selected_device.as_deref(), Some("unit:6be9d300"));
615        assert_eq!(
616            config.devices["receiver:82839805:slot:2"].host_switch_targets,
617            vec!["unit:6be9d300".to_string()],
618            "the keyboard still switches the mouse it was linked to"
619        );
620    }
621
622    #[test]
623    fn an_asleep_device_resolves_through_its_indexed_route() {
624        // Offline: no unit id was readable, only the receiver slot. The links
625        // table recorded on an earlier online sighting is the only way back
626        // to the entry.
627        let mut config = Config::default();
628        let mut device = DeviceConfig::default();
629        device.links.insert(
630            "receiver:82839805:slot:1".to_string(),
631            LinkConfig::default(),
632        );
633        config.devices.insert("unit:6be9d300".to_string(), device);
634
635        let key = config
636            .resolve_device_key(&on_receiver(), None)
637            .expect("the indexed route resolves");
638        assert_eq!(key.as_str(), "unit:6be9d300");
639    }
640
641    #[test]
642    fn an_unindexed_route_falls_back_to_todays_key() {
643        let config = Config::default();
644        let key = config
645            .resolve_device_key(&on_receiver(), None)
646            .expect("receiver keys are physical");
647        assert_eq!(key.as_str(), "receiver:82839805:slot:1");
648    }
649
650    #[test]
651    fn an_all_zero_unit_over_a_receiver_keeps_its_route_key() {
652        // Not every device reports a unit id. Such a device simply never
653        // correlates across transports rather than colliding on `unit:00000000`.
654        let config = Config::default();
655        let zero = DeviceIdentity::Unit([0; 4]);
656        let key = config
657            .resolve_device_key(&on_receiver(), Some(&zero))
658            .expect("falls back rather than failing");
659        assert_eq!(key.as_str(), "receiver:82839805:slot:1");
660    }
661
662    #[test]
663    fn a_direct_device_with_no_identity_stays_non_persistent() {
664        let config = Config::default();
665        let anonymous = DeviceStableId::Direct {
666            vendor_id: 0x046d,
667            product_id: 0xc08d,
668            identity: DeviceIdentity::Unit([0; 4]),
669        };
670        assert_eq!(config.resolve_device_key(&anonymous, None), None);
671    }
672
673    #[test]
674    fn folding_takes_a_custom_name_the_canonical_entry_lacks() {
675        // The alias is the one field in an entry the user typed by hand. A
676        // legacy entry carrying it must not lose it to a canonical entry that
677        // was created by, say, a first DPI write and never named.
678        let mut config = Config::default();
679        let legacy = DeviceConfig {
680            custom_name: Some("Desk mouse".to_string()),
681            ..DeviceConfig::default()
682        };
683        config
684            .devices
685            .insert("receiver:82839805:slot:1".to_string(), legacy);
686        config
687            .devices
688            .insert("unit:6be9d300".to_string(), DeviceConfig::default());
689
690        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
691        assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
692
693        assert_eq!(
694            config.devices["unit:6be9d300"].custom_name.as_deref(),
695            Some("Desk mouse"),
696            "the user's alias survives the fold"
697        );
698    }
699
700    #[test]
701    fn folding_keeps_the_canonical_custom_name_when_both_are_named() {
702        // Two names cannot be merged and there is no per-link slot for one, so
703        // the canonical entry wins. `fold` logs the loss rather than dropping
704        // it silently; this pins which of the two survives.
705        let mut config = Config::default();
706        let legacy = DeviceConfig {
707            custom_name: Some("Old name".to_string()),
708            ..DeviceConfig::default()
709        };
710        let canonical_entry = DeviceConfig {
711            custom_name: Some("Current name".to_string()),
712            ..DeviceConfig::default()
713        };
714        config
715            .devices
716            .insert("receiver:82839805:slot:1".to_string(), legacy);
717        config
718            .devices
719            .insert("unit:6be9d300".to_string(), canonical_entry);
720
721        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
722        assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
723
724        assert_eq!(
725            config.devices["unit:6be9d300"].custom_name.as_deref(),
726            Some("Current name"),
727            "the canonical alias wins a genuine conflict"
728        );
729    }
730
731    #[test]
732    fn adopting_a_route_folds_the_legacy_entry_in() {
733        // The lighting configured over the receiver has to survive onto the
734        // cable — that is the user-visible bug this whole change exists for.
735        let mut config = Config::default();
736        let legacy = DeviceConfig {
737            lighting: Some(Lighting::default()),
738            ..DeviceConfig::default()
739        };
740        config
741            .devices
742            .insert("receiver:82839805:slot:1".to_string(), legacy);
743        config
744            .devices
745            .insert("unit:6be9d300".to_string(), DeviceConfig::default());
746
747        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
748        assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
749
750        let device = &config.devices["unit:6be9d300"];
751        assert!(device.lighting.is_some(), "lighting moved to device level");
752        assert!(device.links.contains_key("receiver:82839805:slot:1"));
753        assert!(
754            !config.devices.contains_key("receiver:82839805:slot:1"),
755            "the legacy entry is consumed, not left behind"
756        );
757    }
758
759    #[test]
760    fn a_sighting_records_what_the_link_measured() {
761        // Without this the per-link table only ever gets capabilities from a
762        // migration fold, so the "supported on your other connection" notice
763        // could never appear for anyone who installed at schema 5.
764        let mut config = Config::default();
765        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
766        let wired = Capabilities {
767            hires_wheel: false,
768            ..Capabilities::default()
769        };
770        let wireless = Capabilities {
771            hires_wheel: true,
772            ..Capabilities::default()
773        };
774
775        assert!(config.adopt_route(&canonical, "direct:046d:c08d", Some(wired)));
776        assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", Some(wireless)));
777
778        let links = &config.devices["unit:6be9d300"].links;
779        assert_eq!(links["direct:046d:c08d"].capabilities, Some(wired));
780        assert_eq!(
781            links["receiver:82839805:slot:1"].capabilities,
782            Some(wireless)
783        );
784
785        // Re-sighting the same route with the same answer is not a change:
786        // reporting one would persist the config on every poll.
787        assert!(!config.adopt_route(&canonical, "direct:046d:c08d", Some(wired)));
788        // An unprobed sighting is not evidence the capability went away.
789        assert!(!config.adopt_route(&canonical, "direct:046d:c08d", None));
790        assert_eq!(
791            config.devices["unit:6be9d300"].links["direct:046d:c08d"].capabilities,
792            Some(wired)
793        );
794    }
795
796    #[test]
797    fn a_conflicting_value_becomes_a_per_link_override() {
798        // Both sides set a DPI and they disagree. Neither is wrong — the user
799        // set each one — so the legacy value survives as an override on its
800        // own link rather than being overwritten.
801        let mut config = Config::default();
802        let legacy = DeviceConfig {
803            dpi: Some(Dpi::new(800)),
804            ..DeviceConfig::default()
805        };
806        config
807            .devices
808            .insert("receiver:82839805:slot:1".to_string(), legacy);
809        let canonical_entry = DeviceConfig {
810            dpi: Some(Dpi::new(1600)),
811            ..DeviceConfig::default()
812        };
813        config
814            .devices
815            .insert("unit:6be9d300".to_string(), canonical_entry);
816
817        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
818        config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
819
820        let device = &config.devices["unit:6be9d300"];
821        assert_eq!(device.dpi, Some(Dpi::new(1600)), "canonical stays default");
822        assert_eq!(
823            device.links["receiver:82839805:slot:1"].overrides.dpi,
824            Some(Dpi::new(800)),
825            "the legacy value survives on its own link"
826        );
827    }
828
829    #[test]
830    fn adopting_an_unseen_route_registers_it_and_reports_a_change() {
831        // No legacy entry to fold in, but a new route in the links index is
832        // itself a change worth persisting — the return value means "did
833        // anything change", not "was a legacy entry folded".
834        let mut config = Config::default();
835        config
836            .devices
837            .insert("unit:6be9d300".to_string(), DeviceConfig::default());
838        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
839
840        assert!(config.adopt_route(&canonical, "direct:046d:c08d", None));
841        assert!(
842            config.devices["unit:6be9d300"]
843                .links
844                .contains_key("direct:046d:c08d")
845        );
846    }
847
848    #[test]
849    fn adopting_an_already_indexed_route_reports_no_change() {
850        // The route is already recorded and there is no legacy entry to
851        // fold — calling `adopt_route` again must be a safe no-op that
852        // reports nothing changed, so a caller polling every refresh does
853        // not persist and reload on every tick.
854        let mut config = Config::default();
855        let mut device = DeviceConfig::default();
856        device
857            .links
858            .insert("direct:046d:c08d".to_string(), LinkConfig::default());
859        config.devices.insert("unit:6be9d300".to_string(), device);
860        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
861
862        assert!(!config.adopt_route(&canonical, "direct:046d:c08d", None));
863    }
864
865    #[test]
866    fn a_re_paired_slot_points_at_the_new_unit() {
867        // Unpair mouse A from slot 1, pair mouse B into it. Today B inherits
868        // A's bindings because the key *is* the slot. It must not.
869        let mut config = Config::default();
870        let mut first = DeviceConfig::default();
871        first.links.insert(
872            "receiver:82839805:slot:1".to_string(),
873            LinkConfig::default(),
874        );
875        config.devices.insert("unit:aaaaaaaa".to_string(), first);
876        config
877            .devices
878            .insert("unit:bbbbbbbb".to_string(), DeviceConfig::default());
879
880        let second = PhysicalDeviceKey::parse("unit:bbbbbbbb").expect("valid");
881        config.adopt_route(&second, "receiver:82839805:slot:1", None);
882
883        assert!(
884            !config.devices["unit:aaaaaaaa"]
885                .links
886                .contains_key("receiver:82839805:slot:1"),
887            "the route no longer points at the previous unit"
888        );
889        assert!(
890            config.devices["unit:bbbbbbbb"]
891                .links
892                .contains_key("receiver:82839805:slot:1")
893        );
894    }
895
896    #[test]
897    fn per_app_bindings_merge_key_by_key() {
898        // A route-keyed entry could carry an app overlay the canonical side
899        // never bound — the fold must not silently drop it.
900        let mut config = Config::default();
901        let legacy = DeviceConfig {
902            per_app_bindings: std::collections::BTreeMap::from([(
903                "com.example.App".to_string(),
904                std::collections::BTreeMap::from([(ButtonId::MiddleClick, Action::Copy)]),
905            )]),
906            ..DeviceConfig::default()
907        };
908        config
909            .devices
910            .insert("receiver:82839805:slot:1".to_string(), legacy);
911        config
912            .devices
913            .insert("unit:6be9d300".to_string(), DeviceConfig::default());
914
915        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
916        config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
917
918        let device = &config.devices["unit:6be9d300"];
919        assert_eq!(
920            device.per_app_bindings.get("com.example.App"),
921            Some(&std::collections::BTreeMap::from([(
922                ButtonId::MiddleClick,
923                Action::Copy
924            )])),
925            "the app overlay survives adoption"
926        );
927    }
928
929    #[test]
930    fn light_is_taken_when_the_canonical_side_has_none() {
931        // `light` (standalone-light settings) has no per-link override slot,
932        // unlike `lighting` — it must still be taken rather than dropped.
933        let mut config = Config::default();
934        let legacy = DeviceConfig {
935            light: Some(LightSettings::default()),
936            ..DeviceConfig::default()
937        };
938        config
939            .devices
940            .insert("receiver:82839805:slot:1".to_string(), legacy);
941        config
942            .devices
943            .insert("unit:6be9d300".to_string(), DeviceConfig::default());
944
945        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
946        config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
947
948        assert_eq!(
949            config.devices["unit:6be9d300"].light,
950            Some(LightSettings::default()),
951            "the standalone-light setting is not silently discarded"
952        );
953    }
954
955    #[test]
956    fn a_legacy_disabled_flag_disables_the_canonical_entry() {
957        // `enabled` defaults to `true` and is only ever persisted as `false`,
958        // so a legacy `false` is a deliberate "leave this device alone"
959        // choice that adoption must not silently override.
960        let mut config = Config::default();
961        let legacy = DeviceConfig {
962            enabled: false,
963            ..DeviceConfig::default()
964        };
965        config
966            .devices
967            .insert("receiver:82839805:slot:1".to_string(), legacy);
968        config
969            .devices
970            .insert("unit:6be9d300".to_string(), DeviceConfig::default());
971
972        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
973        config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
974
975        assert!(
976            !config.devices["unit:6be9d300"].enabled,
977            "the legacy opt-out survives adoption"
978        );
979    }
980}