Skip to main content

retch_sysinfo/
input.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Keyboard and pointing-device detection.
5//!
6//! Linux reads `/proc/bus/input/devices`, a single file listing every evdev node with its
7//! handlers and capability bitmaps. Classification is deliberately **exclusive**: a device is
8//! reported as a keyboard or a mouse or neither, never both.
9//!
10//! ## Why a device can be unclassifiable
11//!
12//! A peripheral paired through a Logitech Unifying/Bolt receiver is presented by the kernel
13//! with a *merged* capability set — the receiver synthesizes one descriptor covering every
14//! device class it can carry. Measured on a Bolt receiver carrying an MX Keys keyboard and an
15//! MX Master 3 mouse, **every** kernel-visible signal is identical for the two:
16//!
17//! | source | MX Keys (keyboard) | MX Master 3 (mouse) |
18//! |---|---|---|
19//! | `/proc/bus/input/devices` handlers | `sysrq kbd leds mouse6 event26` | `sysrq kbd leds mouse5 event25` |
20//! | `capabilities/rel` | `0x1943` | `0x1943` |
21//! | `capabilities/key` (alphabet block) | present | present |
22//! | udev `ID_INPUT_*` | `POINTINGSTICK` | `POINTINGSTICK` |
23//! | USB HID `bInterfaceProtocol` | `00` | `00` |
24//! | HID report descriptor prologue | `05 01 09 06 a1 01` | `05 01 09 06 a1 01` |
25//!
26//! So capability inspection alone *cannot* separate them, and a classifier that guesses gets
27//! it wrong in both directions (fastfetch 2.66 reports the MX Master 3 as a keyboard and the
28//! MX Keys as a mouse on this hardware). The one place the truth survives is the HID++
29//! driver's battery `model_name` (`"MX Keys Wireless Keyboard"` / `"Wireless Mouse MX Master
30//! 3"`), so that is consulted as a tiebreak — and when it is absent too, the device is
31//! reported in **neither** field rather than asserted into the wrong one.
32
33/// What a device was classified as.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum DeviceKind {
36    /// A key-entry device: `kbd` handler plus the full alphabetic key block.
37    Keyboard,
38    /// A pointing device: exposes a `mouseN` handler.
39    Mouse,
40}
41
42/// One record from `/proc/bus/input/devices`.
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct InputDevice {
45    /// Device name from the `N: Name="…"` line.
46    pub name: String,
47    /// Handler tokens from the `H: Handlers=…` line (e.g. `kbd`, `mouse0`, `event3`).
48    pub handlers: Vec<String>,
49    /// Sysfs path from the `S: Sysfs=…` line, relative to `/sys`.
50    pub sysfs: String,
51    /// `B: KEY=…` bitmap, **low word first** (see [`parse_bitmap`]).
52    pub key_bits: Vec<u64>,
53}
54
55/// Linux key codes for the alphabetic block, from `include/uapi/linux/input-event-codes.h`.
56///
57/// A device claiming all of these is offering real text entry rather than the handful of
58/// consumer-control or power-button keys that also register a `kbd` handler.
59const ALPHA_KEYS: &[u32] = &[
60    16, 17, 18, 19, 20, 21, 22, 23, 24, 25, // Q W E R T Y U I O P
61    30, 31, 32, 33, 34, 35, 36, 37, 38, // A S D F G H J K L
62    44, 45, 46, 47, 48, 49, 50, // Z X C V B N M
63];
64
65/// Parses a `B: …=` capability bitmap into 64-bit words, **least-significant word first**.
66///
67/// The kernel prints these words most-significant first (the last word covers bits 0–63), so
68/// the order is reversed here. Getting this backwards silently inverts every capability test,
69/// which is why it is a separate, directly-tested function.
70pub fn parse_bitmap(value: &str) -> Vec<u64> {
71    let mut words: Vec<u64> = value
72        .split_whitespace()
73        .filter_map(|w| u64::from_str_radix(w, 16).ok())
74        .collect();
75    words.reverse();
76    words
77}
78
79/// Returns whether `bit` is set in a low-word-first bitmap.
80fn bit_set(bits: &[u64], bit: u32) -> bool {
81    let word = (bit / 64) as usize;
82    let offset = bit % 64;
83    bits.get(word).is_some_and(|w| (w >> offset) & 1 == 1)
84}
85
86/// Returns whether a key bitmap covers the whole alphabetic block ([`ALPHA_KEYS`]).
87pub fn has_alpha_block(key_bits: &[u64]) -> bool {
88    ALPHA_KEYS.iter().all(|&k| bit_set(key_bits, k))
89}
90
91/// Parses the whole of `/proc/bus/input/devices` into records.
92///
93/// Records are separated by blank lines; unrecognised lines are ignored, and a record with no
94/// name is dropped (it cannot be displayed anyway). Pure, so it is tested against a fixture
95/// rather than the host's real hardware.
96pub fn parse_input_devices(content: &str) -> Vec<InputDevice> {
97    let mut out = Vec::new();
98    let mut cur = InputDevice::default();
99
100    let flush = |cur: &mut InputDevice, out: &mut Vec<InputDevice>| {
101        if !cur.name.is_empty() {
102            out.push(std::mem::take(cur));
103        } else {
104            *cur = InputDevice::default();
105        }
106    };
107
108    for line in content.lines() {
109        let line = line.trim_end();
110        if line.is_empty() {
111            flush(&mut cur, &mut out);
112            continue;
113        }
114        if let Some(rest) = line.strip_prefix("N: Name=") {
115            cur.name = rest.trim().trim_matches('"').to_string();
116        } else if let Some(rest) = line.strip_prefix("H: Handlers=") {
117            cur.handlers = rest.split_whitespace().map(|s| s.to_string()).collect();
118        } else if let Some(rest) = line.strip_prefix("S: Sysfs=") {
119            cur.sysfs = rest.trim().to_string();
120        } else if let Some(rest) = line.strip_prefix("B: KEY=") {
121            cur.key_bits = parse_bitmap(rest);
122        }
123    }
124    flush(&mut cur, &mut out);
125    out
126}
127
128/// Classifies one device, consulting `model_name` only when the capabilities are ambiguous.
129///
130/// `model_name` receives the device's sysfs path and returns the HID++ battery model string
131/// when one exists. It is injected rather than read directly so this stays a pure function —
132/// the same parameterised-resolver pattern `display::parse_xrandr_displays_with` uses, and for
133/// the same reason: a test must not depend on what is plugged into the machine running it.
134///
135/// Returns `None` for a device that is neither (a power button, a consumer-control endpoint)
136/// **and** for one that claims both capabilities with no model string to break the tie.
137pub fn classify_device<F>(dev: &InputDevice, model_name: F) -> Option<DeviceKind>
138where
139    F: Fn(&str) -> Option<String>,
140{
141    let has_kbd = dev.handlers.iter().any(|h| h == "kbd") && has_alpha_block(&dev.key_bits);
142    let has_ptr = dev.handlers.iter().any(|h| h.starts_with("mouse"));
143
144    match (has_kbd, has_ptr) {
145        (true, false) => Some(DeviceKind::Keyboard),
146        (false, true) => Some(DeviceKind::Mouse),
147        (false, false) => None,
148        // Ambiguous: a merged HID++ endpoint. Physical devices are not both, so trust the
149        // manufacturer's own description when the driver exposes one, and omit otherwise.
150        (true, true) => {
151            let model = model_name(&dev.sysfs)?.to_lowercase();
152            if model.contains("keyboard") {
153                Some(DeviceKind::Keyboard)
154            } else if model.contains("mouse") || model.contains("trackball") {
155                Some(DeviceKind::Mouse)
156            } else {
157                None
158            }
159        }
160    }
161}
162
163/// Classifies a whole device list into `(keyboards, mice)`, de-duplicated by name.
164///
165/// De-duplication matters on real hardware: one physical peripheral routinely registers
166/// several evdev nodes under the same name (a receiver exposing separate endpoints), and
167/// listing it repeatedly is noise rather than information.
168pub fn classify_input_devices_with<F>(
169    devices: &[InputDevice],
170    model_name: F,
171) -> (Vec<String>, Vec<String>)
172where
173    F: Fn(&str) -> Option<String>,
174{
175    let mut keyboards: Vec<String> = Vec::new();
176    let mut mice: Vec<String> = Vec::new();
177
178    for dev in devices {
179        let target = match classify_device(dev, &model_name) {
180            Some(DeviceKind::Keyboard) => &mut keyboards,
181            Some(DeviceKind::Mouse) => &mut mice,
182            None => continue,
183        };
184        if !target.contains(&dev.name) {
185            target.push(dev.name.clone());
186        }
187    }
188    (keyboards, mice)
189}
190
191/// Reads a device's HID++ battery `model_name`, if the driver exposes one.
192///
193/// `sysfs` is the `S: Sysfs=` value, e.g. `/devices/…/0003:046D:408A.000F/input/input53`; the
194/// owning HID device is two levels up, and `hid-logitech-hidpp` hangs a `power_supply` node
195/// there whose `model_name` carries the manufacturer's description of the device.
196#[cfg(target_os = "linux")]
197fn hidpp_model_name(sysfs: &str) -> Option<String> {
198    use std::path::Path;
199    let hid_dir = Path::new("/sys")
200        .join(sysfs.trim_start_matches('/'))
201        .parent()?
202        .parent()?
203        .to_path_buf();
204    for entry in std::fs::read_dir(hid_dir.join("power_supply"))
205        .ok()?
206        .flatten()
207    {
208        if let Ok(model) = std::fs::read_to_string(entry.path().join("model_name")) {
209            let model = model.trim();
210            if !model.is_empty() {
211                return Some(model.to_string());
212            }
213        }
214    }
215    None
216}
217
218/// HID usage page 1, "Generic Desktop" — the page that carries keyboards and pointers.
219///
220/// Vendor-defined pages (`0xFF00` and up) dominate a real Mac's HID list: of the 27
221/// interfaces on the development machine, 20 sat on vendor pages carrying backlight,
222/// sensor and management endpoints. Filtering to page 1 is what separates input devices
223/// from everything else the HID stack exposes.
224pub const HID_PAGE_GENERIC_DESKTOP: i64 = 1;
225/// HID usage 6 on page 1 — Keyboard.
226pub const HID_USAGE_KEYBOARD: i64 = 6;
227/// HID usage 2 on page 1 — Mouse.
228pub const HID_USAGE_MOUSE: i64 = 2;
229
230/// Classifies macOS HID interfaces into `(keyboards, mice)`, de-duplicated by name.
231///
232/// **This is deliberately simpler than the Linux classifier, because macOS gives better
233/// data.** The v0.7.0 Linux finding still holds — on a Logitech unifying receiver no
234/// kernel-visible capability separates a keyboard from a mouse, and fastfetch gets it
235/// wrong in both directions there — but macOS does not present the problem in that form:
236/// it publishes **one `IOHIDDevice` per HID interface**, each with its own `PrimaryUsage`,
237/// so the role is stated rather than inferred. There is no ambiguous case to resolve and
238/// so no need for the HID++ `model_name` tiebreak or the "report neither" fallback.
239///
240/// A composite device therefore appears in **both** lists, and that is correct rather than
241/// a bug: `Apple Internal Keyboard / Trackpad` genuinely is a keyboard and a pointing
242/// device, and it publishes an interface for each. Verified against fastfetch on the same
243/// machine, which lists exactly the same two names under both fields.
244///
245/// Pure and injectable for the usual reason — the test feeds it a verbatim fixture from a
246/// real machine rather than consulting whatever is plugged into the one running it.
247pub fn classify_hid_interfaces(interfaces: &[(String, i64, i64)]) -> (Vec<String>, Vec<String>) {
248    let mut keyboards: Vec<String> = Vec::new();
249    let mut mice: Vec<String> = Vec::new();
250
251    for (name, page, usage) in interfaces {
252        if *page != HID_PAGE_GENERIC_DESKTOP {
253            continue;
254        }
255        let name = name.trim();
256        if name.is_empty() {
257            continue;
258        }
259        let target = match *usage {
260            HID_USAGE_KEYBOARD => &mut keyboards,
261            HID_USAGE_MOUSE => &mut mice,
262            _ => continue,
263        };
264        // One physical peripheral can publish several interfaces with the same role and
265        // the same product string; listing it repeatedly is noise, matching the Linux
266        // arm's de-duplication.
267        if !target.iter().any(|n| n == name) {
268            target.push(name.to_string());
269        }
270    }
271    (keyboards, mice)
272}
273
274/// Detects connected keyboards and pointing devices as `(keyboards, mice)`.
275///
276/// Linux reads `/proc/bus/input/devices` plus, only for ambiguous devices, a small sysfs
277/// lookup. macOS enumerates IOKit `IOHIDDevice` interfaces and reads their primary usage.
278/// Returns two empty vectors elsewhere, so both fields simply do not render. No subprocess
279/// and no elevation on either platform.
280pub fn detect_input_devices() -> (Vec<String>, Vec<String>) {
281    #[cfg(target_os = "linux")]
282    {
283        let Ok(content) = std::fs::read_to_string("/proc/bus/input/devices") else {
284            return (Vec::new(), Vec::new());
285        };
286        let devices = parse_input_devices(&content);
287        classify_input_devices_with(&devices, hidpp_model_name)
288    }
289    #[cfg(target_os = "macos")]
290    {
291        classify_hid_interfaces(&crate::macos_ffi::get_hid_interfaces())
292    }
293    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
294    {
295        (Vec::new(), Vec::new())
296    }
297}
298
299#[cfg(test)]
300mod macos_tests {
301    use super::*;
302
303    /// Verbatim `(Product, PrimaryUsagePage, PrimaryUsage)` triples read from an M3 Pro
304    /// MacBook Pro, trimmed to the interfaces that matter plus a representative sample of
305    /// the vendor-page noise. The real machine reported 27 interfaces, 20 of them on
306    /// vendor-defined pages.
307    ///
308    /// A fixture rather than a live enumeration for the #155/v0.6.2 reason: a test must not
309    /// depend on what is plugged into the machine running it.
310    fn fixture() -> Vec<(String, i64, i64)> {
311        [
312            // Vendor-defined pages (0xFF00 = 65280, 0xFF0C = 65292) — must all be ignored.
313            ("Apple Internal Keyboard / Trackpad", 65280, 3),
314            ("Apple Internal Keyboard / Trackpad", 65280, 13),
315            ("Keyboard Backlight", 65280, 15),
316            ("BTM", 65280, 72),
317            ("ktobias's Magic Keyboard", 65280, 75),
318            ("USB Receiver", 65280, 1),
319            // Consumer page (12) — a headset and a webcam, neither an input device here.
320            ("Headset", 12, 1),
321            ("Logitech BRIO", 12, 1),
322            // Generic Desktop (1): the ones that count.
323            ("Apple Internal Keyboard / Trackpad", 1, 6), // keyboard interface
324            ("Apple Internal Keyboard / Trackpad", 1, 2), // trackpad interface
325            ("ktobias's Magic Keyboard", 1, 6),
326            ("USB Receiver", 1, 2),
327            ("USB Receiver", 1, 6),
328        ]
329        .into_iter()
330        .map(|(n, p, u)| (n.to_string(), p, u))
331        .collect()
332    }
333
334    #[test]
335    fn test_classify_hid_interfaces_matches_the_real_machine() {
336        let (keyboards, mice) = classify_hid_interfaces(&fixture());
337        assert_eq!(
338            keyboards,
339            vec![
340                "Apple Internal Keyboard / Trackpad",
341                "ktobias's Magic Keyboard",
342                "USB Receiver",
343            ]
344        );
345        assert_eq!(
346            mice,
347            vec!["Apple Internal Keyboard / Trackpad", "USB Receiver"]
348        );
349    }
350
351    /// A composite device belongs in **both** lists, and this pins that as intended.
352    ///
353    /// The Linux arm deliberately reports such a device in *neither* list, because there a
354    /// merged receiver endpoint is genuinely ambiguous (v0.7.0). macOS is not the same
355    /// case: it publishes one interface per role, so `Apple Internal Keyboard / Trackpad`
356    /// appearing under both is the kernel stating a fact, not a guess.
357    #[test]
358    fn test_composite_device_is_listed_under_both_roles() {
359        let (keyboards, mice) = classify_hid_interfaces(&fixture());
360        let composite = "Apple Internal Keyboard / Trackpad";
361        assert!(keyboards.iter().any(|n| n == composite));
362        assert!(mice.iter().any(|n| n == composite));
363    }
364
365    /// Vendor pages carry backlight, sensor and management endpoints that share their
366    /// product name with a real input device, and none of them may be reported.
367    ///
368    /// **This assertion alone does NOT prove the page filter is load-bearing**, and that is
369    /// worth stating because the first version of this test was exactly that and could not
370    /// fail: every vendor-page entry in the fixture happens to carry a usage the *usage*
371    /// filter already rejects, so deleting the page check left the result unchanged. The
372    /// synthetic test below is what actually pins it.
373    #[test]
374    fn test_vendor_page_devices_are_not_reported() {
375        let (keyboards, mice) = classify_hid_interfaces(&fixture());
376        for list in [&keyboards, &mice] {
377            assert!(!list.iter().any(|n| n == "Keyboard Backlight"));
378            assert!(!list.iter().any(|n| n == "BTM"));
379            assert!(!list.iter().any(|n| n == "Headset"));
380            assert!(!list.iter().any(|n| n == "Logitech BRIO"));
381        }
382    }
383
384    /// Pins the page filter with a case only it can reject.
385    ///
386    /// **Synthetic, and labelled as such** — no interface on the development machine
387    /// paired a vendor page with usage 2 or 6. It is nonetheless the case that matters:
388    /// HID usage numbers are **page-relative**, so usage 6 on a vendor-defined page means
389    /// whatever that vendor decided, not "keyboard". Without the page check such an
390    /// interface is reported as input hardware.
391    ///
392    /// **Watched failing**: removing the page filter makes this report
393    /// `["Vendor Widget"]` for both fields.
394    #[test]
395    fn test_page_filter_rejects_a_vendor_usage_that_collides_with_keyboard() {
396        let interfaces = vec![
397            ("Vendor Widget".to_string(), 65280, HID_USAGE_KEYBOARD),
398            ("Vendor Widget".to_string(), 65280, HID_USAGE_MOUSE),
399            (
400                "Real Keyboard".to_string(),
401                HID_PAGE_GENERIC_DESKTOP,
402                HID_USAGE_KEYBOARD,
403            ),
404        ];
405        let (keyboards, mice) = classify_hid_interfaces(&interfaces);
406        assert_eq!(keyboards, vec!["Real Keyboard"]);
407        assert!(mice.is_empty());
408    }
409
410    #[test]
411    fn test_names_are_deduplicated_and_blanks_dropped() {
412        let interfaces = vec![
413            ("Dup".to_string(), 1, 6),
414            ("Dup".to_string(), 1, 6),
415            ("  ".to_string(), 1, 6),
416            ("Trimmed  ".to_string(), 1, 2),
417            ("Trimmed".to_string(), 1, 2),
418        ];
419        let (keyboards, mice) = classify_hid_interfaces(&interfaces);
420        assert_eq!(keyboards, vec!["Dup"]);
421        assert_eq!(mice, vec!["Trimmed"]);
422    }
423
424    /// Usages other than Keyboard(6)/Mouse(2) on page 1 — Pointer(1), Joystick(4),
425    /// Gamepad(5), Keypad(7) — are not reported by either field. Gamepads have their own
426    /// field, and reporting a joystick as a mouse would be wrong.
427    #[test]
428    fn test_other_generic_desktop_usages_are_not_input_devices() {
429        let interfaces = vec![
430            ("Joystick".to_string(), 1, 4),
431            ("Gamepad".to_string(), 1, 5),
432            ("Pointer".to_string(), 1, 1),
433            ("Keypad".to_string(), 1, 7),
434        ];
435        let (keyboards, mice) = classify_hid_interfaces(&interfaces);
436        assert!(keyboards.is_empty());
437        assert!(mice.is_empty());
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    /// Verbatim excerpt of `/proc/bus/input/devices` from a Fedora 44 laptop with a Bolt
446    /// receiver, trimmed to the interesting records. Covers every classification branch:
447    /// a plain keyboard, a plain mouse, a touchpad, a `kbd`-handler non-keyboard, and two
448    /// merged HID++ endpoints that are indistinguishable by capability alone.
449    const FIXTURE: &str = r#"I: Bus=0019 Vendor=0000 Product=0001 Version=0000
450N: Name="Power Button"
451P: Phys=PNP0C0C/button/input0
452S: Sysfs=/devices/platform/PNP0C0C:00/input/input1
453U: Uniq=
454H: Handlers=kbd event1
455B: PROP=0
456B: EV=3
457B: KEY=8000 10000000000000 0
458
459I: Bus=0011 Vendor=0001 Product=0001 Version=ab83
460N: Name="AT Translated Set 2 keyboard"
461P: Phys=isa0060/serio0/input0
462S: Sysfs=/devices/platform/i8042/serio0/input/input3
463U: Uniq=
464H: Handlers=sysrq kbd leds event3
465B: PROP=0
466B: EV=120013
467B: KEY=2000000000000000 0 40000 0 0 0 0 11100f02902007 f780307cfb10f001 feffffdfffcfffff fffffffffffffffe
468
469I: Bus=0011 Vendor=0002 Product=0001 Version=0000
470N: Name="PS/2 Generic Mouse"
471P: Phys=isa0060/serio1/input0
472S: Sysfs=/devices/platform/i8042/serio1/input/input5
473U: Uniq=
474H: Handlers=mouse0 event4
475B: PROP=0
476B: EV=7
477B: KEY=70000 0 0 0 0
478
479I: Bus=0018 Vendor=06cb Product=cf06 Version=0100
480N: Name="VEN_06CB:00 06CB:CF06 Touchpad"
481P: Phys=i2c-VEN_06CB:00
482S: Sysfs=/devices/pci0000:00/0000:00:15.0/i2c_designware.0/i2c-1/i2c-VEN_06CB:00/0018:06CB:CF06.0001/input/input8
483U: Uniq=
484H: Handlers=mouse2 event6
485B: PROP=5
486B: EV=1b
487B: KEY=e520 10000 0 0 0 0
488
489I: Bus=0003 Vendor=046d Product=408a Version=0111
490N: Name="Logitech MX Keys"
491P: Phys=usb-0000:00:14.0-2/input2:1
492S: Sysfs=/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.4/0003:046D:C52B.000D/0003:046D:408A.000F/input/input53
493U: Uniq=
494H: Handlers=sysrq kbd leds mouse6 event26
495B: PROP=0
496B: EV=12001f
497B: KEY=3f00733fff 0 0 483ffff17aff32d bfd4444600000000 ffff0001 130ff38b17d007 ffff7bfad941dfff ffbeffdfffefffff fffffffffffffffe
498
499I: Bus=0003 Vendor=046d Product=4082 Version=0111
500N: Name="Logitech MX Master 3"
501P: Phys=usb-0000:00:14.0-2/input2:2
502S: Sysfs=/devices/pci0000:00/0000:00:14.0/usb3/3-2/3-2.4/0003:046D:C52B.000D/0003:046D:4082.0011/input/input51
503U: Uniq=
504H: Handlers=sysrq kbd leds mouse5 event25
505B: PROP=0
506B: EV=12001f
507B: KEY=3f00733fff 0 0 483ffff17aff32d bfd4444600000000 ffff0001 130ff38b17d007 ffff7bfad9415fff ffbeffdfffefffff fffffffffffffffe
508"#;
509
510    /// Resolver standing in for the real HID++ lookup, keyed on the fixture's sysfs paths.
511    fn fixture_model_name(sysfs: &str) -> Option<String> {
512        if sysfs.contains("408A") {
513            Some("MX Keys Wireless Keyboard".to_string())
514        } else if sysfs.contains("4082") {
515            Some("Wireless Mouse MX Master 3".to_string())
516        } else {
517            None
518        }
519    }
520
521    #[test]
522    fn test_parse_bitmap_is_low_word_first() {
523        // The kernel prints the highest word first, so the LAST printed word holds bits 0-63.
524        assert_eq!(parse_bitmap("2 1"), vec![1, 2]);
525        assert_eq!(parse_bitmap("ff"), vec![0xff]);
526        assert_eq!(parse_bitmap(""), Vec::<u64>::new());
527        // Junk words are skipped rather than panicking.
528        assert_eq!(parse_bitmap("zz 3"), vec![3]);
529    }
530
531    #[test]
532    fn test_bit_set_across_word_boundary() {
533        let bits = parse_bitmap("1 8000000000000000");
534        assert!(bit_set(&bits, 63), "bit 63 is the top of the low word");
535        assert!(bit_set(&bits, 64), "bit 64 is the bottom of the high word");
536        assert!(!bit_set(&bits, 62));
537        assert!(!bit_set(&bits, 65));
538        // Out-of-range reads are false, never a panic.
539        assert!(!bit_set(&bits, 4096));
540    }
541
542    #[test]
543    fn test_has_alpha_block() {
544        let devices = parse_input_devices(FIXTURE);
545        let by_name = |n: &str| devices.iter().find(|d| d.name == n).unwrap().clone();
546        assert!(has_alpha_block(
547            &by_name("AT Translated Set 2 keyboard").key_bits
548        ));
549        assert!(
550            !has_alpha_block(&by_name("Power Button").key_bits),
551            "a power button registers `kbd` but offers no text entry"
552        );
553        assert!(!has_alpha_block(&by_name("PS/2 Generic Mouse").key_bits));
554    }
555
556    #[test]
557    fn test_parse_input_devices_fields() {
558        let devices = parse_input_devices(FIXTURE);
559        assert_eq!(
560            devices.len(),
561            6,
562            "one record per blank-line-separated block"
563        );
564        let kb = devices
565            .iter()
566            .find(|d| d.name == "AT Translated Set 2 keyboard")
567            .unwrap();
568        assert_eq!(kb.handlers, vec!["sysrq", "kbd", "leds", "event3"]);
569        assert_eq!(kb.sysfs, "/devices/platform/i8042/serio0/input/input3");
570        assert_eq!(*kb.key_bits.first().unwrap(), 0xfffffffffffffffe);
571    }
572
573    #[test]
574    fn test_classify_unambiguous_devices() {
575        let devices = parse_input_devices(FIXTURE);
576        let find = |n: &str| devices.iter().find(|d| d.name == n).unwrap();
577
578        assert_eq!(
579            classify_device(find("AT Translated Set 2 keyboard"), fixture_model_name),
580            Some(DeviceKind::Keyboard)
581        );
582        assert_eq!(
583            classify_device(find("PS/2 Generic Mouse"), fixture_model_name),
584            Some(DeviceKind::Mouse)
585        );
586        assert_eq!(
587            classify_device(find("VEN_06CB:00 06CB:CF06 Touchpad"), fixture_model_name),
588            Some(DeviceKind::Mouse),
589            "a touchpad is a pointing device"
590        );
591        assert_eq!(
592            classify_device(find("Power Button"), fixture_model_name),
593            None,
594            "`kbd` handler without the alphabet block is neither"
595        );
596    }
597
598    #[test]
599    fn test_merged_hidpp_endpoints_use_the_model_name_tiebreak() {
600        let devices = parse_input_devices(FIXTURE);
601        let find = |n: &str| devices.iter().find(|d| d.name == n).unwrap();
602
603        // These two records are identical in every capability field; only the model name
604        // separates them. This is the case fastfetch gets wrong in both directions.
605        let keys = find("Logitech MX Keys");
606        let master = find("Logitech MX Master 3");
607        assert_eq!(keys.key_bits.len(), master.key_bits.len());
608        assert!(has_alpha_block(&keys.key_bits) && has_alpha_block(&master.key_bits));
609
610        assert_eq!(
611            classify_device(keys, fixture_model_name),
612            Some(DeviceKind::Keyboard)
613        );
614        assert_eq!(
615            classify_device(master, fixture_model_name),
616            Some(DeviceKind::Mouse)
617        );
618    }
619
620    #[test]
621    fn test_ambiguous_device_without_model_name_is_omitted() {
622        let devices = parse_input_devices(FIXTURE);
623        let keys = devices
624            .iter()
625            .find(|d| d.name == "Logitech MX Keys")
626            .unwrap();
627        // A paired-but-idle device exposes no battery node, so no model name resolves. It must
628        // be reported in neither field rather than guessed into one.
629        assert_eq!(classify_device(keys, |_| None), None);
630    }
631
632    #[test]
633    fn test_classify_input_devices_with_end_to_end() {
634        let devices = parse_input_devices(FIXTURE);
635        let (keyboards, mice) = classify_input_devices_with(&devices, fixture_model_name);
636        assert_eq!(
637            keyboards,
638            vec!["AT Translated Set 2 keyboard", "Logitech MX Keys"]
639        );
640        assert_eq!(
641            mice,
642            vec![
643                "PS/2 Generic Mouse",
644                "VEN_06CB:00 06CB:CF06 Touchpad",
645                "Logitech MX Master 3"
646            ]
647        );
648    }
649
650    #[test]
651    fn test_duplicate_names_are_collapsed() {
652        // One physical peripheral commonly registers several endpoints under one name.
653        let doubled = format!("{}\n{}", FIXTURE, FIXTURE);
654        let devices = parse_input_devices(&doubled);
655        let (keyboards, mice) = classify_input_devices_with(&devices, fixture_model_name);
656        assert_eq!(keyboards.len(), 2, "names must not repeat");
657        assert_eq!(mice.len(), 3);
658    }
659
660    #[test]
661    fn test_empty_and_malformed_input() {
662        assert!(parse_input_devices("").is_empty());
663        assert!(parse_input_devices("garbage\nlines\nwith no records").is_empty());
664        // A record with handlers but no name is unusable and is dropped.
665        assert!(parse_input_devices("H: Handlers=kbd event0\nB: EV=3\n").is_empty());
666        let (k, m) = classify_input_devices_with(&[], fixture_model_name);
667        assert!(k.is_empty() && m.is_empty());
668    }
669}