Skip to main content

openlogi_core/
diagnostics.rs

1//! Privacy-filtered diagnostics report for support tickets — model-level only, no unique identifiers by construction.
2
3use std::fmt::Write as _;
4
5use serde::{Deserialize, Serialize};
6
7use crate::device::{BatteryInfo, BatteryStatus, Capabilities, DeviceKind, DeviceTransports};
8
9/// Where the resolver found the bundled device renders.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum AssetSource {
13    /// Read-only assets shipped inside the macOS `.app` bundle (release builds).
14    Bundle,
15    /// The per-user cache populated by the background asset sync.
16    UserCache,
17    /// Neither tier was found — devices fall back to the synthetic silhouette.
18    None,
19}
20
21/// How a device reaches the host, refined past the raw HID++ route via announced transports.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum ConnectionKind {
25    /// Paired through a Logi Bolt receiver.
26    BoltReceiver,
27    /// Paired through a legacy Unifying receiver.
28    UnifyingReceiver,
29    /// Connected directly over Bluetooth — no receiver involved.
30    BluetoothDirect,
31    /// Connected over a USB cable.
32    Wired,
33    /// The route could not be classified from the announced transports.
34    Unknown,
35}
36
37/// Whether a curated render resolved, or the device fell back to the silhouette.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case", tag = "state", content = "depot")]
40pub enum RenderState {
41    /// A curated render resolved; carries the depot name (e.g.
42    /// `"mx_master_3s"`).
43    Resolved(String),
44    /// No depot matched — the UI draws the synthetic silhouette instead.
45    Silhouette,
46}
47
48/// Agent-side device-enumeration health — explains an empty or stale device
49/// section (e.g. a report copied while the agent is still scanning).
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum InventoryState {
53    /// The first enumeration hasn't completed yet — the device set is unknown.
54    Scanning,
55    /// Enumeration completed; the device section is authoritative.
56    Ready,
57    /// Enumeration failed and is no longer retried; details in the agent log.
58    Unavailable,
59}
60
61/// A receiver, by model only — never its `unique_id`.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct ReceiverDiag {
64    /// Receiver product string — model-level, carries no per-unit identity.
65    pub name: String,
66    /// USB vendor ID (`0x046d` for Logitech).
67    pub vendor_id: u16,
68    /// USB product ID distinguishing the receiver model.
69    pub product_id: u16,
70}
71
72/// One paired device, model-level only.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct DeviceDiag {
75    /// The name the GUI shows for this device.
76    pub display_name: String,
77    /// Classified device kind — an identity guess, not a capability claim.
78    pub kind: DeviceKind,
79    /// Firmware codename (e.g. `"MX Master 3S"`), when known.
80    pub codename: Option<String>,
81    /// How the device reaches the host.
82    pub connection: ConnectionKind,
83    /// Whether the device was reachable when the report was generated.
84    pub online: bool,
85    /// Battery snapshot, `None` when offline or unreported.
86    pub battery: Option<BatteryInfo>,
87    /// Measured HID++ capabilities, or `None` if never probed since the agent started.
88    pub capabilities: Option<Capabilities>,
89    /// Human DPI summary (current + supported range), or `None` when not queried.
90    pub dpi: Option<String>,
91    /// Model identifier (e.g. `"2b35a"`) — a per-model key, not user-identifying.
92    pub config_key: String,
93    /// Wireless PID from the receiver's pairing table, when paired via a
94    /// receiver.
95    pub wpid: Option<u16>,
96    /// Per-transport PID array from HID++ DeviceInformation (0x0003).
97    pub model_ids: Option<[u16; 3]>,
98    /// Extended-model byte pairing with [`Self::model_ids`] to form the
99    /// registry `modelId`.
100    pub extended_model_id: Option<u8>,
101    /// Transports announced by the firmware, when the device was probed.
102    pub transports: Option<DeviceTransports>,
103    /// Whether a curated render resolved, or the silhouette fallback drew.
104    pub render: RenderState,
105    /// Receiver slot, or `0xFF` for direct connections (rendered as
106    /// "direct").
107    pub slot: u8,
108}
109
110/// App, agent, and host environment.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct AppInfo {
113    /// Version of the GUI process writing the report.
114    pub gui_version: String,
115    /// `"debug"` or `"release"`.
116    pub build_profile: String,
117    /// `None` when the agent is unreachable (not yet connected / restarting).
118    pub agent_version: Option<String>,
119    /// IPC protocol version compiled into the GUI.
120    pub protocol_gui: u32,
121    /// IPC protocol version the agent reported, `None` when unreachable.
122    /// A mismatch with [`Self::protocol_gui`] is flagged in the rendered
123    /// report.
124    pub protocol_agent: Option<u32>,
125    /// Enumeration health behind the device section, `None` when the agent
126    /// status is unavailable.
127    pub inventory: Option<InventoryState>,
128    /// Raw `std::env::consts::OS` (`"macos"` / `"linux"` / `"windows"`).
129    pub os: String,
130    /// OS version string, when the platform exposes one.
131    pub os_version: Option<String>,
132    /// Host CPU architecture (e.g. `"arm64"`).
133    pub arch: String,
134    /// OS-reported locale, `None` when detection failed.
135    pub system_locale: Option<String>,
136    /// Explicit UI-language override, or `None` for "follow system".
137    pub ui_language: Option<String>,
138    /// Input-monitoring/Accessibility permission state — macOS gates the
139    /// input hook on it. `None` when the agent is unreachable: the permission
140    /// belongs to the agent process, so with the agent down its state is
141    /// unknown, not denied — a support ticket claiming "denied" here sent
142    /// triage down the wrong path.
143    pub accessibility_granted: Option<bool>,
144    /// `None` when the agent status is unavailable.
145    pub hook_installed: Option<bool>,
146    /// Launch-at-login setting, `None` when unknown.
147    pub launch_at_login: Option<bool>,
148    /// Menu-bar/tray icon setting, `None` when unknown.
149    pub show_in_menu_bar: Option<bool>,
150    /// Automatic update-check setting, `None` when unknown.
151    pub check_for_updates: Option<bool>,
152    /// Thumbwheel sensitivity setting, `None` when unknown.
153    pub thumbwheel_sensitivity: Option<i32>,
154    /// `schema_version` of the loaded `config.toml`, when one loaded.
155    pub config_schema_version: Option<u32>,
156    /// Number of device entries in the config, when known.
157    pub configured_device_count: Option<usize>,
158    /// `true` for an installed app bundle, `false` for a source/dev build.
159    pub running_from_bundle: bool,
160}
161
162/// Asset-cache state behind device renders.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct AssetInfo {
165    /// Which tier the render resolver is serving from.
166    pub source: AssetSource,
167    /// Whether a registry `index.json` parsed successfully.
168    pub index_loaded: bool,
169    /// Number of device models in the loaded index, when known.
170    pub index_entries: Option<usize>,
171    /// Whether the per-user asset cache directory exists.
172    pub user_cache_present: bool,
173    /// Cache directory with the home prefix redacted to `~`.
174    pub cache_path: String,
175    /// Whether the assets shipped inside the app bundle were found.
176    pub bundle_present: bool,
177}
178
179/// The whole report. Render with [`Self::to_markdown`] for the clipboard.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct DiagnosticsReport {
182    /// App, agent, and host environment section.
183    pub app: AppInfo,
184    /// Asset-cache state behind device renders.
185    pub assets: AssetInfo,
186    /// Model-level receiver list (may be empty for direct-only setups).
187    pub receivers: Vec<ReceiverDiag>,
188    /// Per-device detail, one entry per enumerated device.
189    pub devices: Vec<DeviceDiag>,
190}
191
192impl DiagnosticsReport {
193    /// Render the report as the Markdown blob copied to the clipboard.
194    #[must_use]
195    pub fn to_markdown(&self) -> String {
196        let mut out = String::new();
197        let _ = writeln!(out, "### OpenLogi Diagnostics\n");
198        self.write_app(&mut out);
199        self.write_assets(&mut out);
200        self.write_devices(&mut out);
201        out.truncate(out.trim_end().len());
202        out
203    }
204
205    fn write_app(&self, out: &mut String) {
206        let a = &self.app;
207        let _ = writeln!(out, "**App**");
208        let _ = writeln!(
209            out,
210            "- OpenLogi (GUI): v{} ({})",
211            a.gui_version, a.build_profile
212        );
213        let agent = match &a.agent_version {
214            Some(v) if *v == a.gui_version => format!("v{v} (connected)"),
215            Some(v) => format!("v{v} (connected) ⚠️ version mismatch with GUI"),
216            None => "not connected".to_string(),
217        };
218        let _ = writeln!(out, "- Agent: {agent}");
219        let proto = match a.protocol_agent {
220            Some(p) if p == a.protocol_gui => format!("GUI {} / agent {p}", a.protocol_gui),
221            Some(p) => format!("GUI {} / agent {p} ⚠️ mismatch", a.protocol_gui),
222            None => format!("GUI {} / agent —", a.protocol_gui),
223        };
224        let _ = writeln!(out, "- IPC protocol: {proto}");
225        let inventory = match a.inventory {
226            Some(InventoryState::Ready) => "ready",
227            Some(InventoryState::Scanning) => "scanning (first enumeration in progress)",
228            Some(InventoryState::Unavailable) => {
229                "⚠️ unavailable (enumeration failed — see agent log)"
230            }
231            None => "—",
232        };
233        let _ = writeln!(out, "- Inventory: {inventory}");
234        let os = match &a.os_version {
235            Some(v) => format!("{} {} ({})", os_label(&a.os), v, a.arch),
236            None => format!("{} ({})", os_label(&a.os), a.arch),
237        };
238        let _ = writeln!(out, "- OS: {os}");
239        let locale = a.system_locale.as_deref().unwrap_or("unknown");
240        let ui = a.ui_language.as_deref().unwrap_or("follow system");
241        let _ = writeln!(out, "- Locale: {locale} (UI: {ui})");
242        let _ = writeln!(
243            out,
244            "- Accessibility: {} · Input hook: {}",
245            opt_state(a.accessibility_granted, "granted", "denied"),
246            opt_state(a.hook_installed, "installed", "not installed"),
247        );
248        let _ = writeln!(
249            out,
250            "- Launch at login: {} · Menu bar: {} · Update check: {}",
251            opt_state(a.launch_at_login, "yes", "no"),
252            opt_state(a.show_in_menu_bar, "yes", "no"),
253            opt_state(a.check_for_updates, "on", "off"),
254        );
255        let source = if a.running_from_bundle {
256            "app bundle (release)"
257        } else {
258            "source build (dev)"
259        };
260        let _ = writeln!(out, "- Running from: {source}");
261        let _ = writeln!(
262            out,
263            "- Config: schema {} · {} configured device(s) · thumbwheel {}\n",
264            opt_num(a.config_schema_version),
265            opt_num(a.configured_device_count),
266            opt_num(a.thumbwheel_sensitivity),
267        );
268    }
269
270    fn write_assets(&self, out: &mut String) {
271        let s = &self.assets;
272        let _ = writeln!(out, "**Assets**");
273        let index = match (s.index_loaded, s.index_entries) {
274            (true, Some(n)) => format!("loaded ({n} models)"),
275            (true, None) => "loaded".to_string(),
276            (false, _) => "not loaded".to_string(),
277        };
278        let _ = writeln!(
279            out,
280            "- Source: {} · Index: {index} · User cache: {}",
281            asset_source_label(s.source),
282            if s.user_cache_present {
283                "present"
284            } else {
285                "absent"
286            },
287        );
288        let _ = writeln!(
289            out,
290            "- Cache path: {} · Bundle assets: {}\n",
291            s.cache_path,
292            if s.bundle_present {
293                "present"
294            } else {
295                "absent"
296            },
297        );
298    }
299
300    fn write_devices(&self, out: &mut String) {
301        let _ = writeln!(out, "**Devices ({})**", self.devices.len());
302        if self.devices.is_empty() {
303            let _ = writeln!(out, "- No devices detected.");
304        }
305        for d in &self.devices {
306            let codename = d
307                .codename
308                .as_deref()
309                .map(|c| format!(" (codename: {c})"))
310                .unwrap_or_default();
311            let _ = writeln!(
312                out,
313                "- {} — {}{codename}",
314                d.display_name,
315                kind_label(d.kind)
316            );
317            let _ = writeln!(
318                out,
319                "  - Connection: {} · Online: {} · Battery: {}",
320                connection_label(d.connection),
321                yes_no(d.online),
322                battery_label(d.battery.as_ref()),
323            );
324            let caps = match d.capabilities {
325                Some(c) => format!(
326                    "buttons={}, pointer={}, lighting={}",
327                    yes_no(c.buttons),
328                    yes_no(c.pointer),
329                    yes_no(c.lighting),
330                ),
331                None => "not probed".to_string(),
332            };
333            let _ = writeln!(out, "  - Capabilities: {caps}");
334            if let Some(dpi) = &d.dpi {
335                let _ = writeln!(out, "  - DPI: {dpi}");
336            }
337            let _ = writeln!(out, "  - Model: {}{}", d.config_key, model_detail(d));
338            if let Some(t) = d.transports {
339                let _ = writeln!(out, "  - Transports: {}", transports_label(t));
340            }
341            let render = match &d.render {
342                RenderState::Resolved(depot) => depot.clone(),
343                RenderState::Silhouette => "⚠️ none (silhouette)".to_string(),
344            };
345            let _ = writeln!(out, "  - Render: {render} · {}", slot_label(d.slot));
346        }
347        if !self.receivers.is_empty() {
348            let _ = writeln!(out, "\n**Receivers ({})**", self.receivers.len());
349            for r in &self.receivers {
350                let _ = writeln!(
351                    out,
352                    "- {} (VID {:04x} / PID {:04x})",
353                    r.name, r.vendor_id, r.product_id
354                );
355            }
356        }
357    }
358}
359
360fn model_detail(d: &DeviceDiag) -> String {
361    let mut parts = Vec::new();
362    if let Some(wpid) = d.wpid {
363        parts.push(format!("wpid: {wpid:04x}"));
364    }
365    if let Some([a, b, c]) = d.model_ids {
366        parts.push(format!("model-ids: {a:04x}/{b:04x}/{c:04x}"));
367    }
368    if let Some(ext) = d.extended_model_id {
369        parts.push(format!("ext-model: {ext:02x}"));
370    }
371    if parts.is_empty() {
372        String::new()
373    } else {
374        format!(" ({})", parts.join(", "))
375    }
376}
377
378fn slot_label(slot: u8) -> String {
379    // 0xFF is the HID++ direct-device index (USB cable / Bluetooth, no receiver).
380    if slot == 0xFF {
381        "direct".to_string()
382    } else {
383        format!("Slot {slot}")
384    }
385}
386
387fn os_label(os: &str) -> &str {
388    match os {
389        "macos" => "macOS",
390        "linux" => "Linux",
391        "windows" => "Windows",
392        other => other,
393    }
394}
395
396fn asset_source_label(source: AssetSource) -> &'static str {
397    match source {
398        AssetSource::Bundle => "app bundle",
399        AssetSource::UserCache => "user cache",
400        AssetSource::None => "none",
401    }
402}
403
404fn kind_label(kind: DeviceKind) -> &'static str {
405    match kind {
406        DeviceKind::Mouse => "mouse",
407        DeviceKind::Keyboard => "keyboard",
408        DeviceKind::Numpad => "numpad",
409        DeviceKind::Presenter => "presenter",
410        DeviceKind::Remote => "remote",
411        DeviceKind::Trackball => "trackball",
412        DeviceKind::Touchpad => "touchpad",
413        DeviceKind::Tablet => "tablet",
414        DeviceKind::Gamepad => "gamepad",
415        DeviceKind::Joystick => "joystick",
416        DeviceKind::Headset => "headset",
417        DeviceKind::Camera => "camera",
418        DeviceKind::Unknown => "unknown",
419        DeviceKind::Light => "light",
420    }
421}
422
423fn connection_label(connection: ConnectionKind) -> &'static str {
424    match connection {
425        ConnectionKind::BoltReceiver => "Logi Bolt receiver",
426        ConnectionKind::UnifyingReceiver => "Logi Unifying receiver",
427        ConnectionKind::BluetoothDirect => "Bluetooth (direct)",
428        ConnectionKind::Wired => "Wired (USB)",
429        ConnectionKind::Unknown => "unknown",
430    }
431}
432
433fn battery_label(battery: Option<&BatteryInfo>) -> String {
434    match battery {
435        Some(b) => {
436            let status = battery_status_label(b.status);
437            let level = battery_level_label(b.level);
438            match b.percentage {
439                Some(percentage) => format!("{percentage}% ({status}, {level})"),
440                None => format!("no percentage ({status}, {level})"),
441            }
442        }
443        None => "n/a".to_string(),
444    }
445}
446
447fn battery_status_label(status: BatteryStatus) -> &'static str {
448    match status {
449        BatteryStatus::Discharging => "discharging",
450        BatteryStatus::Charging => "charging",
451        BatteryStatus::ChargingSlow => "charging (slow)",
452        BatteryStatus::Full => "full",
453        BatteryStatus::Error => "error",
454        BatteryStatus::Unknown => "unknown",
455    }
456}
457
458fn battery_level_label(level: crate::device::BatteryLevel) -> &'static str {
459    use crate::device::BatteryLevel;
460    match level {
461        BatteryLevel::Critical => "critical",
462        BatteryLevel::Low => "low",
463        BatteryLevel::Good => "good",
464        BatteryLevel::Full => "full",
465        BatteryLevel::Unknown => "unknown",
466    }
467}
468
469fn transports_label(t: DeviceTransports) -> String {
470    let mut parts = Vec::new();
471    if t.usb {
472        parts.push("USB");
473    }
474    if t.equad {
475        parts.push("eQuad");
476    }
477    if t.btle {
478        parts.push("BTLE");
479    }
480    if t.bluetooth {
481        parts.push("Bluetooth");
482    }
483    if parts.is_empty() {
484        "none".to_string()
485    } else {
486        parts.join(", ")
487    }
488}
489
490fn yes_no(value: bool) -> &'static str {
491    if value { "yes" } else { "no" }
492}
493
494fn opt_state(value: Option<bool>, yes: &'static str, no: &'static str) -> &'static str {
495    match value {
496        Some(true) => yes,
497        Some(false) => no,
498        None => "unknown",
499    }
500}
501
502fn opt_num<T: std::fmt::Display>(value: Option<T>) -> String {
503    value.map_or_else(|| "—".to_string(), |v| v.to_string())
504}
505
506#[cfg(test)]
507mod tests {
508    use super::{
509        AppInfo, AssetInfo, AssetSource, ConnectionKind, DeviceDiag, DiagnosticsReport,
510        InventoryState, ReceiverDiag, RenderState,
511    };
512    use crate::device::{
513        BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceKind, DeviceTransports,
514    };
515
516    fn app() -> AppInfo {
517        AppInfo {
518            gui_version: "0.6.6".to_string(),
519            build_profile: "release".to_string(),
520            agent_version: Some("0.6.6".to_string()),
521            protocol_gui: 1,
522            protocol_agent: Some(1),
523            inventory: Some(InventoryState::Ready),
524            os: "macos".to_string(),
525            os_version: Some("15.5".to_string()),
526            arch: "arm64".to_string(),
527            system_locale: Some("en-US".to_string()),
528            ui_language: None,
529            accessibility_granted: Some(true),
530            hook_installed: Some(true),
531            launch_at_login: Some(true),
532            show_in_menu_bar: Some(true),
533            check_for_updates: Some(false),
534            thumbwheel_sensitivity: Some(0),
535            config_schema_version: Some(2),
536            configured_device_count: Some(3),
537            running_from_bundle: true,
538        }
539    }
540
541    fn assets() -> AssetInfo {
542        AssetInfo {
543            source: AssetSource::Bundle,
544            index_loaded: true,
545            index_entries: Some(142),
546            user_cache_present: true,
547            cache_path: "~/.local/share/openlogi/assets".to_string(),
548            bundle_present: true,
549        }
550    }
551
552    fn sample() -> DiagnosticsReport {
553        DiagnosticsReport {
554            app: app(),
555            assets: assets(),
556            receivers: vec![ReceiverDiag {
557                name: "Logi Bolt".to_string(),
558                vendor_id: 0x046d,
559                product_id: 0xc548,
560            }],
561            devices: vec![
562                DeviceDiag {
563                    display_name: "MX Keys".to_string(),
564                    kind: DeviceKind::Keyboard,
565                    codename: Some("MX Keys".to_string()),
566                    connection: ConnectionKind::BoltReceiver,
567                    online: true,
568                    battery: Some(BatteryInfo {
569                        percentage: Some(80),
570                        level: BatteryLevel::Good,
571                        status: BatteryStatus::Discharging,
572                    }),
573                    capabilities: Some(Capabilities::default()),
574                    dpi: None,
575                    config_key: "2b35a".to_string(),
576                    wpid: Some(0x4093),
577                    model_ids: Some([0xb35a, 0, 0]),
578                    extended_model_id: Some(0x02),
579                    transports: Some(DeviceTransports {
580                        equad: true,
581                        ..DeviceTransports::default()
582                    }),
583                    render: RenderState::Silhouette,
584                    slot: 2,
585                },
586                DeviceDiag {
587                    display_name: "MX Master 3S".to_string(),
588                    kind: DeviceKind::Mouse,
589                    codename: Some("MX Master 3S".to_string()),
590                    connection: ConnectionKind::Wired,
591                    online: false,
592                    battery: None,
593                    capabilities: Some(Capabilities {
594                        buttons: true,
595                        pointer: true,
596                        lighting: false,
597                        scroll_inversion: false,
598                        hires_wheel: true,
599                        thumbwheel: false,
600                        haptic_feedback: false,
601                        haptic_panel: false,
602                        dpi_gestures: false,
603                        fn_lock: false,
604                        os_layout: false,
605                        report_rate: false,
606                        backlight: false,
607                        easy_switch: false,
608                    }),
609                    dpi: Some("1600 dpi (range 200–8000, 5 steps)".to_string()),
610                    config_key: "4082d".to_string(),
611                    wpid: Some(0x4082),
612                    model_ids: Some([0x082d, 0, 0]),
613                    extended_model_id: Some(0x04),
614                    transports: Some(DeviceTransports {
615                        usb: true,
616                        ..DeviceTransports::default()
617                    }),
618                    render: RenderState::Resolved("mx_master_3s".to_string()),
619                    slot: 1,
620                },
621            ],
622        }
623    }
624
625    #[test]
626    fn renders_header_and_sections() {
627        let md = sample().to_markdown();
628        assert!(md.starts_with("### OpenLogi Diagnostics"));
629        assert!(md.contains("**App**"));
630        assert!(md.contains("**Assets**"));
631        assert!(md.contains("**Devices (2)**"));
632        assert!(md.contains("**Receivers (1)**"));
633        assert!(md.contains("- Logi Bolt (VID 046d / PID c548)"));
634        assert!(md.contains("- OpenLogi (GUI): v0.6.6 (release)"));
635        assert!(md.contains("- Agent: v0.6.6 (connected)"));
636        assert!(md.contains("- IPC protocol: GUI 1 / agent 1"));
637        assert!(md.contains("- Inventory: ready"));
638        assert!(md.contains("- OS: macOS 15.5 (arm64)"));
639        assert!(
640            md.contains("- Source: app bundle · Index: loaded (142 models) · User cache: present")
641        );
642        assert!(md.contains("- Config: schema 2 · 3 configured device(s) · thumbwheel 0"));
643    }
644
645    #[test]
646    fn renders_device_detail() {
647        let md = sample().to_markdown();
648        assert!(md.contains("- MX Keys — keyboard (codename: MX Keys)"));
649        assert!(md.contains(
650            "Connection: Logi Bolt receiver · Online: yes · Battery: 80% (discharging, good)"
651        ));
652        assert!(md.contains("Capabilities: buttons=no, pointer=no, lighting=no"));
653        assert!(md.contains("Model: 2b35a (wpid: 4093, model-ids: b35a/0000/0000, ext-model: 02)"));
654        assert!(md.contains("Transports: eQuad"));
655        assert!(md.contains("Render: ⚠️ none (silhouette) · Slot 2"));
656        assert!(md.contains("- MX Master 3S — mouse"));
657        assert!(md.contains("DPI: 1600 dpi (range 200–8000, 5 steps)"));
658        assert!(md.contains("Transports: USB"));
659        assert!(md.contains("Render: mx_master_3s · Slot 1"));
660        assert!(md.contains("Battery: n/a"));
661    }
662
663    #[test]
664    fn a_level_only_battery_does_not_render_as_zero_percent() {
665        let mut report = sample();
666        report.devices[0].battery = Some(BatteryInfo {
667            percentage: None,
668            level: BatteryLevel::Good,
669            status: BatteryStatus::Discharging,
670        });
671        let md = report.to_markdown();
672        assert!(md.contains("Battery: no percentage (discharging, good)"));
673        assert!(!md.contains("Battery: 0%"));
674    }
675
676    #[test]
677    fn flags_version_and_protocol_mismatch() {
678        let mut report = sample();
679        report.app.agent_version = Some("0.6.5".to_string());
680        report.app.protocol_agent = Some(2);
681        let md = report.to_markdown();
682        assert!(md.contains("v0.6.5 (connected) ⚠️ version mismatch with GUI"));
683        assert!(md.contains("GUI 1 / agent 2 ⚠️ mismatch"));
684    }
685
686    #[test]
687    fn omits_unique_identifiers_and_footer() {
688        let md = sample().to_markdown();
689        assert!(!md.contains("Serial"));
690        assert!(!md.to_lowercase().contains("unit id"));
691        assert!(!md.contains("omitted by design"));
692    }
693
694    #[test]
695    fn direct_slot_renders_as_direct() {
696        let mut report = sample();
697        report.devices[0].slot = 0xFF;
698        let md = report.to_markdown();
699        assert!(md.contains("· direct"));
700        assert!(!md.contains("Slot 255"));
701    }
702
703    #[test]
704    fn unprobed_capabilities_render_not_probed() {
705        let mut report = sample();
706        report.devices[0].capabilities = None;
707        let md = report.to_markdown();
708        assert!(md.contains("  - Capabilities: not probed"));
709    }
710
711    #[test]
712    fn empty_inventory_still_renders() {
713        let report = DiagnosticsReport {
714            app: app(),
715            assets: assets(),
716            receivers: Vec::new(),
717            devices: Vec::new(),
718        };
719        let md = report.to_markdown();
720        assert!(md.contains("**Devices (0)**"));
721        assert!(md.contains("- No devices detected."));
722    }
723
724    #[test]
725    fn unreachable_agent_renders_unknowns() {
726        let mut report = sample();
727        report.app.agent_version = None;
728        report.app.protocol_agent = None;
729        report.app.inventory = None;
730        report.app.accessibility_granted = None;
731        report.app.hook_installed = None;
732        report.app.launch_at_login = None;
733        let md = report.to_markdown();
734        assert!(md.contains("- Agent: not connected"));
735        assert!(md.contains("GUI 1 / agent —"));
736        assert!(md.contains("- Inventory: —"));
737        assert!(
738            md.contains("Accessibility: unknown"),
739            "an unreachable agent must not claim the permission was denied"
740        );
741        assert!(md.contains("Input hook: unknown"));
742    }
743
744    #[test]
745    fn incomplete_enumeration_is_flagged() {
746        let mut report = sample();
747        report.app.inventory = Some(InventoryState::Scanning);
748        assert!(
749            report
750                .to_markdown()
751                .contains("- Inventory: scanning (first enumeration in progress)")
752        );
753        report.app.inventory = Some(InventoryState::Unavailable);
754        assert!(
755            report
756                .to_markdown()
757                .contains("- Inventory: ⚠️ unavailable (enumeration failed — see agent log)")
758        );
759    }
760}