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    // The user-assigned alias. Without this a legacy entry carrying a name
318    // folded into a canonical entry with none would drop it silently — the
319    // one field here a user typed by hand, so the loss is the most visible.
320    fold_option_field!(custom_name);
321
322    if device.identity.is_none() {
323        device.identity = legacy.identity.take();
324    }
325
326    // Collections with no natural per-item override: take the legacy value
327    // wholesale when the canonical side is empty (i.e. never configured).
328    // Merging two curated lists is not a safe default — a DPI cycle is
329    // ordered and a host-switch list is positional — so a populated
330    // canonical side wins, and the list it displaces is logged.
331    macro_rules! fold_if_empty {
332        ($field:ident) => {
333            if device.$field.is_empty() {
334                device.$field = legacy.$field;
335            } else if !legacy.$field.is_empty() && device.$field != legacy.$field {
336                tracing::warn!(
337                    %route_key,
338                    field = stringify!($field),
339                    "list differs between merged entries; keeping the canonical one"
340                );
341            }
342        };
343    }
344    fold_if_empty!(dpi_presets);
345    fold_if_empty!(host_switch_targets);
346
347    // `ActionRingConfig` has its own notion of "unset". Two configured rings
348    // cannot be merged — the slots are positional — so the canonical one wins
349    // and the displaced one is logged, as elsewhere.
350    if device.action_ring.is_default() {
351        device.action_ring = legacy.action_ring.clone();
352    } else if !legacy.action_ring.is_default() && device.action_ring != legacy.action_ring {
353        tracing::warn!(
354            %route_key,
355            field = "action_ring",
356            "ring differs between merged entries; keeping the canonical one"
357        );
358    }
359
360    // `enabled` defaults to `true` and is only ever persisted when `false`,
361    // so a legacy `false` is a deliberate "leave this device alone" choice
362    // that must not be lost under a canonical entry that never opted out.
363    if device.enabled && !legacy.enabled {
364        device.enabled = false;
365    }
366}
367
368/// The map-valued halves of [`fold`], split out to keep that function inside
369/// the workspace's line budget.
370///
371/// Maps merge key by key: a legacy entry is taken only where the canonical map
372/// has no entry for that key. A genuine conflict on a shared key keeps the
373/// canonical entry and is logged, not silently dropped.
374fn fold_maps(device: &mut DeviceConfig, legacy: &mut DeviceConfig, route_key: &str) {
375    macro_rules! fold_map_field {
376        ($field:ident) => {
377            for (key, value) in std::mem::take(&mut legacy.$field) {
378                if let Some(existing) = device.$field.get(&key) {
379                    if *existing != value {
380                        tracing::warn!(
381                            %route_key,
382                            field = stringify!($field),
383                            key = ?key,
384                            "entry differs between merged entries; keeping the canonical one"
385                        );
386                    }
387                } else {
388                    device.$field.insert(key, value);
389                }
390            }
391        };
392    }
393    fold_map_field!(bindings);
394    fold_map_field!(disabled_gestures);
395    fold_map_field!(per_app_bindings);
396    fold_map_field!(camera_profiles);
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::device_order::{DeviceIdentity, DeviceStableId};
403
404    fn cabled() -> DeviceStableId {
405        DeviceStableId::Direct {
406            vendor_id: 0x046d,
407            product_id: 0xc08d,
408            identity: DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]),
409        }
410    }
411
412    fn on_receiver() -> DeviceStableId {
413        DeviceStableId::Bolt {
414            receiver_uid: "82839805".to_string(),
415            slot: 1,
416        }
417    }
418
419    #[test]
420    fn a_known_unit_keys_the_entry_directly() {
421        let config = Config::default();
422        let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
423        let key = config
424            .resolve_device_key(&cabled(), Some(&unit))
425            .expect("a non-zero unit is a physical identity");
426        assert_eq!(key.as_str(), "unit:6be9d300");
427    }
428
429    #[test]
430    fn the_same_unit_resolves_alike_on_either_route() {
431        // The whole point: one mouse, two routes, one entry.
432        let config = Config::default();
433        let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
434        assert_eq!(
435            config.resolve_device_key(&cabled(), Some(&unit)),
436            config.resolve_device_key(&on_receiver(), Some(&unit)),
437        );
438    }
439
440    fn litra() -> DeviceStableId {
441        DeviceStableId::RawHid {
442            vendor_id: 0x046d,
443            product_id: 0xc901,
444            usage_page: 0xff43,
445            usage_id: 0x0202,
446            identity: "serial:beam-1".to_string(),
447        }
448    }
449
450    #[test]
451    fn settings_still_under_the_pre_upgrade_key_are_read_from_it() {
452        // The upgrade path the agent lives through: the load-time migration
453        // deliberately leaves `receiver:` keys alone, and only the GUI folds
454        // them. Answering with `unit:6be9d300` here would hand the agent an
455        // empty entry, so every receiver-paired device would silently revert
456        // to default bindings and DPI until the user next opened the GUI.
457        let mut config = Config::default();
458        config.devices.insert(
459            "receiver:82839805:slot:1".to_string(),
460            DeviceConfig {
461                dpi: Some(Dpi::new(3200)),
462                ..DeviceConfig::default()
463            },
464        );
465
466        let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
467        let key = config
468            .resolve_device_key(&on_receiver(), Some(&unit))
469            .expect("resolves");
470        assert_eq!(key.as_str(), "receiver:82839805:slot:1");
471        assert_eq!(
472            config.devices[key.as_str()].dpi,
473            Some(Dpi::new(3200)),
474            "the settings are where the key says they are"
475        );
476    }
477
478    #[test]
479    fn a_standalone_raw_entry_keeps_its_light_settings() {
480        // A Litra has no migration at all: its key would move from
481        // `raw:…:serial:beam-1` to `serial:beam-1` the moment the identity
482        // won, orphaning the light settings the user configured.
483        let mut config = Config::default();
484        config.devices.insert(
485            "raw:046d:c901:ff43:0202:serial:beam-1".to_string(),
486            DeviceConfig {
487                light: Some(LightSettings::default()),
488                ..DeviceConfig::default()
489            },
490        );
491
492        let serial = DeviceIdentity::Serial("beam-1".to_string());
493        let key = config
494            .resolve_device_key(&litra(), Some(&serial))
495            .expect("resolves");
496        assert_eq!(key.as_str(), "raw:046d:c901:ff43:0202:serial:beam-1");
497    }
498
499    #[test]
500    fn a_canonical_entry_holding_only_metadata_does_not_shadow_the_legacy_one() {
501        // `persist_identities` and the `links` index write entries on their
502        // own. Neither is a user setting, so neither may out-rank the entry
503        // that actually holds the bindings.
504        let mut config = Config::default();
505        config.devices.insert(
506            "receiver:82839805:slot:1".to_string(),
507            DeviceConfig {
508                dpi: Some(Dpi::new(3200)),
509                ..DeviceConfig::default()
510            },
511        );
512        let mut bare = DeviceConfig::default();
513        bare.links.insert(
514            "receiver:82839805:slot:1".to_string(),
515            LinkConfig::default(),
516        );
517        config.devices.insert("unit:6be9d300".to_string(), bare);
518
519        let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
520        let key = config
521            .resolve_device_key(&on_receiver(), Some(&unit))
522            .expect("resolves");
523        assert_eq!(key.as_str(), "receiver:82839805:slot:1");
524    }
525
526    #[test]
527    fn a_canonical_entry_with_settings_wins_over_a_legacy_one() {
528        // Once adoption (or a plain first-run write) has put real settings
529        // under the identity key, that is the answer — the legacy entry is a
530        // leftover the next fold will consume.
531        let mut config = Config::default();
532        config.devices.insert(
533            "receiver:82839805:slot:1".to_string(),
534            DeviceConfig {
535                dpi: Some(Dpi::new(3200)),
536                ..DeviceConfig::default()
537            },
538        );
539        config.devices.insert(
540            "unit:6be9d300".to_string(),
541            DeviceConfig {
542                dpi: Some(Dpi::new(800)),
543                ..DeviceConfig::default()
544            },
545        );
546
547        let unit = DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]);
548        let key = config
549            .resolve_device_key(&on_receiver(), Some(&unit))
550            .expect("resolves");
551        assert_eq!(key.as_str(), "unit:6be9d300");
552    }
553
554    #[test]
555    fn the_route_key_is_the_legacy_entry_key_for_every_variant_but_direct() {
556        // `adopt_route` finds a legacy entry with `devices.remove(route_key)`,
557        // which only works because a Bolt/RawHid/Unknown route key *is* the
558        // key such an entry was written under. A Direct route key deliberately
559        // is not: its identity moved into the entry key, which is exactly why
560        // the load-time migration can rename direct entries and
561        // cannot rename the others. Three doc comments say so; this asserts it.
562        for stable in [on_receiver(), litra()] {
563            let physical = stable.physical_key().expect("physical");
564            assert_eq!(
565                stable.route_key(),
566                physical.as_str(),
567                "{stable:?}: the route key must find the legacy entry"
568            );
569        }
570        let unknown = DeviceStableId::Unknown {
571            slot: 2,
572            identity: DeviceIdentity::Unit([0x6b, 0xe9, 0xd3, 0x00]),
573        };
574        assert_eq!(
575            unknown.route_key(),
576            unknown.physical_key().expect("physical").as_str()
577        );
578
579        let direct = cabled();
580        assert_ne!(
581            direct.route_key(),
582            direct.physical_key().expect("physical").as_str(),
583            "a direct route key drops the identity that keys the entry"
584        );
585    }
586
587    #[test]
588    fn adoption_repoints_the_selection_and_any_host_switch_target() {
589        // Adoption is a rename, and a device key lives in three places. A
590        // stale `selected_device` silently jumps the carousel to the first
591        // device; a stale host-switch target silently drops out of the group.
592        let mut config = Config::default();
593        config.devices.insert(
594            "receiver:82839805:slot:1".to_string(),
595            DeviceConfig {
596                dpi: Some(Dpi::new(800)),
597                ..DeviceConfig::default()
598            },
599        );
600        config.devices.insert(
601            "receiver:82839805:slot:2".to_string(),
602            DeviceConfig {
603                host_switch_targets: vec!["receiver:82839805:slot:1".to_string()],
604                ..DeviceConfig::default()
605            },
606        );
607        config.selected_device = Some("receiver:82839805:slot:1".to_string());
608
609        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
610        assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
611
612        assert_eq!(config.selected_device.as_deref(), Some("unit:6be9d300"));
613        assert_eq!(
614            config.devices["receiver:82839805:slot:2"].host_switch_targets,
615            vec!["unit:6be9d300".to_string()],
616            "the keyboard still switches the mouse it was linked to"
617        );
618    }
619
620    #[test]
621    fn an_asleep_device_resolves_through_its_indexed_route() {
622        // Offline: no unit id was readable, only the receiver slot. The links
623        // table recorded on an earlier online sighting is the only way back
624        // to the entry.
625        let mut config = Config::default();
626        let mut device = DeviceConfig::default();
627        device.links.insert(
628            "receiver:82839805:slot:1".to_string(),
629            LinkConfig::default(),
630        );
631        config.devices.insert("unit:6be9d300".to_string(), device);
632
633        let key = config
634            .resolve_device_key(&on_receiver(), None)
635            .expect("the indexed route resolves");
636        assert_eq!(key.as_str(), "unit:6be9d300");
637    }
638
639    #[test]
640    fn an_unindexed_route_falls_back_to_todays_key() {
641        let config = Config::default();
642        let key = config
643            .resolve_device_key(&on_receiver(), None)
644            .expect("receiver keys are physical");
645        assert_eq!(key.as_str(), "receiver:82839805:slot:1");
646    }
647
648    #[test]
649    fn an_all_zero_unit_over_a_receiver_keeps_its_route_key() {
650        // Not every device reports a unit id. Such a device simply never
651        // correlates across transports rather than colliding on `unit:00000000`.
652        let config = Config::default();
653        let zero = DeviceIdentity::Unit([0; 4]);
654        let key = config
655            .resolve_device_key(&on_receiver(), Some(&zero))
656            .expect("falls back rather than failing");
657        assert_eq!(key.as_str(), "receiver:82839805:slot:1");
658    }
659
660    #[test]
661    fn a_direct_device_with_no_identity_stays_non_persistent() {
662        let config = Config::default();
663        let anonymous = DeviceStableId::Direct {
664            vendor_id: 0x046d,
665            product_id: 0xc08d,
666            identity: DeviceIdentity::Unit([0; 4]),
667        };
668        assert_eq!(config.resolve_device_key(&anonymous, None), None);
669    }
670
671    #[test]
672    fn folding_takes_a_custom_name_the_canonical_entry_lacks() {
673        // The alias is the one field in an entry the user typed by hand. A
674        // legacy entry carrying it must not lose it to a canonical entry that
675        // was created by, say, a first DPI write and never named.
676        let mut config = Config::default();
677        let legacy = DeviceConfig {
678            custom_name: Some("Desk mouse".to_string()),
679            ..DeviceConfig::default()
680        };
681        config
682            .devices
683            .insert("receiver:82839805:slot:1".to_string(), legacy);
684        config
685            .devices
686            .insert("unit:6be9d300".to_string(), DeviceConfig::default());
687
688        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
689        assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
690
691        assert_eq!(
692            config.devices["unit:6be9d300"].custom_name.as_deref(),
693            Some("Desk mouse"),
694            "the user's alias survives the fold"
695        );
696    }
697
698    #[test]
699    fn folding_keeps_the_canonical_custom_name_when_both_are_named() {
700        // Two names cannot be merged and there is no per-link slot for one, so
701        // the canonical entry wins. `fold` logs the loss rather than dropping
702        // it silently; this pins which of the two survives.
703        let mut config = Config::default();
704        let legacy = DeviceConfig {
705            custom_name: Some("Old name".to_string()),
706            ..DeviceConfig::default()
707        };
708        let canonical_entry = DeviceConfig {
709            custom_name: Some("Current name".to_string()),
710            ..DeviceConfig::default()
711        };
712        config
713            .devices
714            .insert("receiver:82839805:slot:1".to_string(), legacy);
715        config
716            .devices
717            .insert("unit:6be9d300".to_string(), canonical_entry);
718
719        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
720        assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
721
722        assert_eq!(
723            config.devices["unit:6be9d300"].custom_name.as_deref(),
724            Some("Current name"),
725            "the canonical alias wins a genuine conflict"
726        );
727    }
728
729    #[test]
730    fn adopting_a_route_folds_the_legacy_entry_in() {
731        // The lighting configured over the receiver has to survive onto the
732        // cable — that is the user-visible bug this whole change exists for.
733        let mut config = Config::default();
734        let legacy = DeviceConfig {
735            lighting: Some(Lighting::default()),
736            ..DeviceConfig::default()
737        };
738        config
739            .devices
740            .insert("receiver:82839805:slot:1".to_string(), legacy);
741        config
742            .devices
743            .insert("unit:6be9d300".to_string(), DeviceConfig::default());
744
745        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
746        assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", None));
747
748        let device = &config.devices["unit:6be9d300"];
749        assert!(device.lighting.is_some(), "lighting moved to device level");
750        assert!(device.links.contains_key("receiver:82839805:slot:1"));
751        assert!(
752            !config.devices.contains_key("receiver:82839805:slot:1"),
753            "the legacy entry is consumed, not left behind"
754        );
755    }
756
757    #[test]
758    fn a_sighting_records_what_the_link_measured() {
759        // Without this the per-link table only ever gets capabilities from a
760        // migration fold, so the "supported on your other connection" notice
761        // could never appear for anyone who installed at schema 5.
762        let mut config = Config::default();
763        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
764        let wired = Capabilities {
765            hires_wheel: false,
766            ..Capabilities::default()
767        };
768        let wireless = Capabilities {
769            hires_wheel: true,
770            ..Capabilities::default()
771        };
772
773        assert!(config.adopt_route(&canonical, "direct:046d:c08d", Some(wired)));
774        assert!(config.adopt_route(&canonical, "receiver:82839805:slot:1", Some(wireless)));
775
776        let links = &config.devices["unit:6be9d300"].links;
777        assert_eq!(links["direct:046d:c08d"].capabilities, Some(wired));
778        assert_eq!(
779            links["receiver:82839805:slot:1"].capabilities,
780            Some(wireless)
781        );
782
783        // Re-sighting the same route with the same answer is not a change:
784        // reporting one would persist the config on every poll.
785        assert!(!config.adopt_route(&canonical, "direct:046d:c08d", Some(wired)));
786        // An unprobed sighting is not evidence the capability went away.
787        assert!(!config.adopt_route(&canonical, "direct:046d:c08d", None));
788        assert_eq!(
789            config.devices["unit:6be9d300"].links["direct:046d:c08d"].capabilities,
790            Some(wired)
791        );
792    }
793
794    #[test]
795    fn a_conflicting_value_becomes_a_per_link_override() {
796        // Both sides set a DPI and they disagree. Neither is wrong — the user
797        // set each one — so the legacy value survives as an override on its
798        // own link rather than being overwritten.
799        let mut config = Config::default();
800        let legacy = DeviceConfig {
801            dpi: Some(Dpi::new(800)),
802            ..DeviceConfig::default()
803        };
804        config
805            .devices
806            .insert("receiver:82839805:slot:1".to_string(), legacy);
807        let canonical_entry = DeviceConfig {
808            dpi: Some(Dpi::new(1600)),
809            ..DeviceConfig::default()
810        };
811        config
812            .devices
813            .insert("unit:6be9d300".to_string(), canonical_entry);
814
815        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
816        config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
817
818        let device = &config.devices["unit:6be9d300"];
819        assert_eq!(device.dpi, Some(Dpi::new(1600)), "canonical stays default");
820        assert_eq!(
821            device.links["receiver:82839805:slot:1"].overrides.dpi,
822            Some(Dpi::new(800)),
823            "the legacy value survives on its own link"
824        );
825    }
826
827    #[test]
828    fn adopting_an_unseen_route_registers_it_and_reports_a_change() {
829        // No legacy entry to fold in, but a new route in the links index is
830        // itself a change worth persisting — the return value means "did
831        // anything change", not "was a legacy entry folded".
832        let mut config = Config::default();
833        config
834            .devices
835            .insert("unit:6be9d300".to_string(), DeviceConfig::default());
836        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
837
838        assert!(config.adopt_route(&canonical, "direct:046d:c08d", None));
839        assert!(
840            config.devices["unit:6be9d300"]
841                .links
842                .contains_key("direct:046d:c08d")
843        );
844    }
845
846    #[test]
847    fn adopting_an_already_indexed_route_reports_no_change() {
848        // The route is already recorded and there is no legacy entry to
849        // fold — calling `adopt_route` again must be a safe no-op that
850        // reports nothing changed, so a caller polling every refresh does
851        // not persist and reload on every tick.
852        let mut config = Config::default();
853        let mut device = DeviceConfig::default();
854        device
855            .links
856            .insert("direct:046d:c08d".to_string(), LinkConfig::default());
857        config.devices.insert("unit:6be9d300".to_string(), device);
858        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
859
860        assert!(!config.adopt_route(&canonical, "direct:046d:c08d", None));
861    }
862
863    #[test]
864    fn a_re_paired_slot_points_at_the_new_unit() {
865        // Unpair mouse A from slot 1, pair mouse B into it. Today B inherits
866        // A's bindings because the key *is* the slot. It must not.
867        let mut config = Config::default();
868        let mut first = DeviceConfig::default();
869        first.links.insert(
870            "receiver:82839805:slot:1".to_string(),
871            LinkConfig::default(),
872        );
873        config.devices.insert("unit:aaaaaaaa".to_string(), first);
874        config
875            .devices
876            .insert("unit:bbbbbbbb".to_string(), DeviceConfig::default());
877
878        let second = PhysicalDeviceKey::parse("unit:bbbbbbbb").expect("valid");
879        config.adopt_route(&second, "receiver:82839805:slot:1", None);
880
881        assert!(
882            !config.devices["unit:aaaaaaaa"]
883                .links
884                .contains_key("receiver:82839805:slot:1"),
885            "the route no longer points at the previous unit"
886        );
887        assert!(
888            config.devices["unit:bbbbbbbb"]
889                .links
890                .contains_key("receiver:82839805:slot:1")
891        );
892    }
893
894    #[test]
895    fn per_app_bindings_merge_key_by_key() {
896        // A route-keyed entry could carry an app overlay the canonical side
897        // never bound — the fold must not silently drop it.
898        let mut config = Config::default();
899        let legacy = DeviceConfig {
900            per_app_bindings: std::collections::BTreeMap::from([(
901                "com.example.App".to_string(),
902                std::collections::BTreeMap::from([(ButtonId::MiddleClick, Action::Copy)]),
903            )]),
904            ..DeviceConfig::default()
905        };
906        config
907            .devices
908            .insert("receiver:82839805:slot:1".to_string(), legacy);
909        config
910            .devices
911            .insert("unit:6be9d300".to_string(), DeviceConfig::default());
912
913        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
914        config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
915
916        let device = &config.devices["unit:6be9d300"];
917        assert_eq!(
918            device.per_app_bindings.get("com.example.App"),
919            Some(&std::collections::BTreeMap::from([(
920                ButtonId::MiddleClick,
921                Action::Copy
922            )])),
923            "the app overlay survives adoption"
924        );
925    }
926
927    #[test]
928    fn light_is_taken_when_the_canonical_side_has_none() {
929        // `light` (standalone-light settings) has no per-link override slot,
930        // unlike `lighting` — it must still be taken rather than dropped.
931        let mut config = Config::default();
932        let legacy = DeviceConfig {
933            light: Some(LightSettings::default()),
934            ..DeviceConfig::default()
935        };
936        config
937            .devices
938            .insert("receiver:82839805:slot:1".to_string(), legacy);
939        config
940            .devices
941            .insert("unit:6be9d300".to_string(), DeviceConfig::default());
942
943        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
944        config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
945
946        assert_eq!(
947            config.devices["unit:6be9d300"].light,
948            Some(LightSettings::default()),
949            "the standalone-light setting is not silently discarded"
950        );
951    }
952
953    #[test]
954    fn a_legacy_disabled_flag_disables_the_canonical_entry() {
955        // `enabled` defaults to `true` and is only ever persisted as `false`,
956        // so a legacy `false` is a deliberate "leave this device alone"
957        // choice that adoption must not silently override.
958        let mut config = Config::default();
959        let legacy = DeviceConfig {
960            enabled: false,
961            ..DeviceConfig::default()
962        };
963        config
964            .devices
965            .insert("receiver:82839805:slot:1".to_string(), legacy);
966        config
967            .devices
968            .insert("unit:6be9d300".to_string(), DeviceConfig::default());
969
970        let canonical = PhysicalDeviceKey::parse("unit:6be9d300").expect("valid");
971        config.adopt_route(&canonical, "receiver:82839805:slot:1", None);
972
973        assert!(
974            !config.devices["unit:6be9d300"].enabled,
975            "the legacy opt-out survives adoption"
976        );
977    }
978}