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.
140    pub accessibility_granted: bool,
141    /// `None` when the agent status is unavailable.
142    pub hook_installed: Option<bool>,
143    /// Launch-at-login setting, `None` when unknown.
144    pub launch_at_login: Option<bool>,
145    /// Menu-bar/tray icon setting, `None` when unknown.
146    pub show_in_menu_bar: Option<bool>,
147    /// Automatic update-check setting, `None` when unknown.
148    pub check_for_updates: Option<bool>,
149    /// Thumbwheel sensitivity setting, `None` when unknown.
150    pub thumbwheel_sensitivity: Option<i32>,
151    /// `schema_version` of the loaded `config.toml`, when one loaded.
152    pub config_schema_version: Option<u32>,
153    /// Number of device entries in the config, when known.
154    pub configured_device_count: Option<usize>,
155    /// `true` for an installed app bundle, `false` for a source/dev build.
156    pub running_from_bundle: bool,
157}
158
159/// Asset-cache state behind device renders.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct AssetInfo {
162    /// Which tier the render resolver is serving from.
163    pub source: AssetSource,
164    /// Whether a registry `index.json` parsed successfully.
165    pub index_loaded: bool,
166    /// Number of device models in the loaded index, when known.
167    pub index_entries: Option<usize>,
168    /// Whether the per-user asset cache directory exists.
169    pub user_cache_present: bool,
170    /// Cache directory with the home prefix redacted to `~`.
171    pub cache_path: String,
172    /// Whether the assets shipped inside the app bundle were found.
173    pub bundle_present: bool,
174}
175
176/// The whole report. Render with [`Self::to_markdown`] for the clipboard.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct DiagnosticsReport {
179    /// App, agent, and host environment section.
180    pub app: AppInfo,
181    /// Asset-cache state behind device renders.
182    pub assets: AssetInfo,
183    /// Model-level receiver list (may be empty for direct-only setups).
184    pub receivers: Vec<ReceiverDiag>,
185    /// Per-device detail, one entry per enumerated device.
186    pub devices: Vec<DeviceDiag>,
187}
188
189impl DiagnosticsReport {
190    /// Render the report as the Markdown blob copied to the clipboard.
191    #[must_use]
192    pub fn to_markdown(&self) -> String {
193        let mut out = String::new();
194        let _ = writeln!(out, "### OpenLogi Diagnostics\n");
195        self.write_app(&mut out);
196        self.write_assets(&mut out);
197        self.write_devices(&mut out);
198        out.truncate(out.trim_end().len());
199        out
200    }
201
202    fn write_app(&self, out: &mut String) {
203        let a = &self.app;
204        let _ = writeln!(out, "**App**");
205        let _ = writeln!(
206            out,
207            "- OpenLogi (GUI): v{} ({})",
208            a.gui_version, a.build_profile
209        );
210        let agent = match &a.agent_version {
211            Some(v) if *v == a.gui_version => format!("v{v} (connected)"),
212            Some(v) => format!("v{v} (connected) ⚠️ version mismatch with GUI"),
213            None => "not connected".to_string(),
214        };
215        let _ = writeln!(out, "- Agent: {agent}");
216        let proto = match a.protocol_agent {
217            Some(p) if p == a.protocol_gui => format!("GUI {} / agent {p}", a.protocol_gui),
218            Some(p) => format!("GUI {} / agent {p} ⚠️ mismatch", a.protocol_gui),
219            None => format!("GUI {} / agent —", a.protocol_gui),
220        };
221        let _ = writeln!(out, "- IPC protocol: {proto}");
222        let inventory = match a.inventory {
223            Some(InventoryState::Ready) => "ready",
224            Some(InventoryState::Scanning) => "scanning (first enumeration in progress)",
225            Some(InventoryState::Unavailable) => {
226                "⚠️ unavailable (enumeration failed — see agent log)"
227            }
228            None => "—",
229        };
230        let _ = writeln!(out, "- Inventory: {inventory}");
231        let os = match &a.os_version {
232            Some(v) => format!("{} {} ({})", os_label(&a.os), v, a.arch),
233            None => format!("{} ({})", os_label(&a.os), a.arch),
234        };
235        let _ = writeln!(out, "- OS: {os}");
236        let locale = a.system_locale.as_deref().unwrap_or("unknown");
237        let ui = a.ui_language.as_deref().unwrap_or("follow system");
238        let _ = writeln!(out, "- Locale: {locale} (UI: {ui})");
239        let _ = writeln!(
240            out,
241            "- Accessibility: {} · Input hook: {}",
242            granted(a.accessibility_granted),
243            opt_state(a.hook_installed, "installed", "not installed"),
244        );
245        let _ = writeln!(
246            out,
247            "- Launch at login: {} · Menu bar: {} · Update check: {}",
248            opt_state(a.launch_at_login, "yes", "no"),
249            opt_state(a.show_in_menu_bar, "yes", "no"),
250            opt_state(a.check_for_updates, "on", "off"),
251        );
252        let source = if a.running_from_bundle {
253            "app bundle (release)"
254        } else {
255            "source build (dev)"
256        };
257        let _ = writeln!(out, "- Running from: {source}");
258        let _ = writeln!(
259            out,
260            "- Config: schema {} · {} configured device(s) · thumbwheel {}\n",
261            opt_num(a.config_schema_version),
262            opt_num(a.configured_device_count),
263            opt_num(a.thumbwheel_sensitivity),
264        );
265    }
266
267    fn write_assets(&self, out: &mut String) {
268        let s = &self.assets;
269        let _ = writeln!(out, "**Assets**");
270        let index = match (s.index_loaded, s.index_entries) {
271            (true, Some(n)) => format!("loaded ({n} models)"),
272            (true, None) => "loaded".to_string(),
273            (false, _) => "not loaded".to_string(),
274        };
275        let _ = writeln!(
276            out,
277            "- Source: {} · Index: {index} · User cache: {}",
278            asset_source_label(s.source),
279            if s.user_cache_present {
280                "present"
281            } else {
282                "absent"
283            },
284        );
285        let _ = writeln!(
286            out,
287            "- Cache path: {} · Bundle assets: {}\n",
288            s.cache_path,
289            if s.bundle_present {
290                "present"
291            } else {
292                "absent"
293            },
294        );
295    }
296
297    fn write_devices(&self, out: &mut String) {
298        let _ = writeln!(out, "**Devices ({})**", self.devices.len());
299        if self.devices.is_empty() {
300            let _ = writeln!(out, "- No devices detected.");
301        }
302        for d in &self.devices {
303            let codename = d
304                .codename
305                .as_deref()
306                .map(|c| format!(" (codename: {c})"))
307                .unwrap_or_default();
308            let _ = writeln!(
309                out,
310                "- {} — {}{codename}",
311                d.display_name,
312                kind_label(d.kind)
313            );
314            let _ = writeln!(
315                out,
316                "  - Connection: {} · Online: {} · Battery: {}",
317                connection_label(d.connection),
318                yes_no(d.online),
319                battery_label(d.battery.as_ref()),
320            );
321            let caps = match d.capabilities {
322                Some(c) => format!(
323                    "buttons={}, pointer={}, lighting={}",
324                    yes_no(c.buttons),
325                    yes_no(c.pointer),
326                    yes_no(c.lighting),
327                ),
328                None => "not probed".to_string(),
329            };
330            let _ = writeln!(out, "  - Capabilities: {caps}");
331            if let Some(dpi) = &d.dpi {
332                let _ = writeln!(out, "  - DPI: {dpi}");
333            }
334            let _ = writeln!(out, "  - Model: {}{}", d.config_key, model_detail(d));
335            if let Some(t) = d.transports {
336                let _ = writeln!(out, "  - Transports: {}", transports_label(t));
337            }
338            let render = match &d.render {
339                RenderState::Resolved(depot) => depot.clone(),
340                RenderState::Silhouette => "⚠️ none (silhouette)".to_string(),
341            };
342            let _ = writeln!(out, "  - Render: {render} · {}", slot_label(d.slot));
343        }
344        if !self.receivers.is_empty() {
345            let _ = writeln!(out, "\n**Receivers ({})**", self.receivers.len());
346            for r in &self.receivers {
347                let _ = writeln!(
348                    out,
349                    "- {} (VID {:04x} / PID {:04x})",
350                    r.name, r.vendor_id, r.product_id
351                );
352            }
353        }
354    }
355}
356
357fn model_detail(d: &DeviceDiag) -> String {
358    let mut parts = Vec::new();
359    if let Some(wpid) = d.wpid {
360        parts.push(format!("wpid: {wpid:04x}"));
361    }
362    if let Some([a, b, c]) = d.model_ids {
363        parts.push(format!("model-ids: {a:04x}/{b:04x}/{c:04x}"));
364    }
365    if let Some(ext) = d.extended_model_id {
366        parts.push(format!("ext-model: {ext:02x}"));
367    }
368    if parts.is_empty() {
369        String::new()
370    } else {
371        format!(" ({})", parts.join(", "))
372    }
373}
374
375fn slot_label(slot: u8) -> String {
376    // 0xFF is the HID++ direct-device index (USB cable / Bluetooth, no receiver).
377    if slot == 0xFF {
378        "direct".to_string()
379    } else {
380        format!("Slot {slot}")
381    }
382}
383
384fn os_label(os: &str) -> &str {
385    match os {
386        "macos" => "macOS",
387        "linux" => "Linux",
388        "windows" => "Windows",
389        other => other,
390    }
391}
392
393fn asset_source_label(source: AssetSource) -> &'static str {
394    match source {
395        AssetSource::Bundle => "app bundle",
396        AssetSource::UserCache => "user cache",
397        AssetSource::None => "none",
398    }
399}
400
401fn kind_label(kind: DeviceKind) -> &'static str {
402    match kind {
403        DeviceKind::Mouse => "mouse",
404        DeviceKind::Keyboard => "keyboard",
405        DeviceKind::Numpad => "numpad",
406        DeviceKind::Presenter => "presenter",
407        DeviceKind::Remote => "remote",
408        DeviceKind::Trackball => "trackball",
409        DeviceKind::Touchpad => "touchpad",
410        DeviceKind::Tablet => "tablet",
411        DeviceKind::Gamepad => "gamepad",
412        DeviceKind::Joystick => "joystick",
413        DeviceKind::Headset => "headset",
414        DeviceKind::Camera => "camera",
415        DeviceKind::Unknown => "unknown",
416        DeviceKind::Light => "light",
417    }
418}
419
420fn connection_label(connection: ConnectionKind) -> &'static str {
421    match connection {
422        ConnectionKind::BoltReceiver => "Logi Bolt receiver",
423        ConnectionKind::UnifyingReceiver => "Logi Unifying receiver",
424        ConnectionKind::BluetoothDirect => "Bluetooth (direct)",
425        ConnectionKind::Wired => "Wired (USB)",
426        ConnectionKind::Unknown => "unknown",
427    }
428}
429
430fn battery_label(battery: Option<&BatteryInfo>) -> String {
431    match battery {
432        Some(b) => format!(
433            "{}% ({}, {})",
434            b.percentage,
435            battery_status_label(b.status),
436            battery_level_label(b.level),
437        ),
438        None => "n/a".to_string(),
439    }
440}
441
442fn battery_status_label(status: BatteryStatus) -> &'static str {
443    match status {
444        BatteryStatus::Discharging => "discharging",
445        BatteryStatus::Charging => "charging",
446        BatteryStatus::ChargingSlow => "charging (slow)",
447        BatteryStatus::Full => "full",
448        BatteryStatus::Error => "error",
449        BatteryStatus::Unknown => "unknown",
450    }
451}
452
453fn battery_level_label(level: crate::device::BatteryLevel) -> &'static str {
454    use crate::device::BatteryLevel;
455    match level {
456        BatteryLevel::Critical => "critical",
457        BatteryLevel::Low => "low",
458        BatteryLevel::Good => "good",
459        BatteryLevel::Full => "full",
460        BatteryLevel::Unknown => "unknown",
461    }
462}
463
464fn transports_label(t: DeviceTransports) -> String {
465    let mut parts = Vec::new();
466    if t.usb {
467        parts.push("USB");
468    }
469    if t.equad {
470        parts.push("eQuad");
471    }
472    if t.btle {
473        parts.push("BTLE");
474    }
475    if t.bluetooth {
476        parts.push("Bluetooth");
477    }
478    if parts.is_empty() {
479        "none".to_string()
480    } else {
481        parts.join(", ")
482    }
483}
484
485fn yes_no(value: bool) -> &'static str {
486    if value { "yes" } else { "no" }
487}
488
489fn granted(value: bool) -> &'static str {
490    if value { "granted" } else { "denied" }
491}
492
493fn opt_state(value: Option<bool>, yes: &'static str, no: &'static str) -> &'static str {
494    match value {
495        Some(true) => yes,
496        Some(false) => no,
497        None => "unknown",
498    }
499}
500
501fn opt_num<T: std::fmt::Display>(value: Option<T>) -> String {
502    value.map_or_else(|| "—".to_string(), |v| v.to_string())
503}
504
505#[cfg(test)]
506#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
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: 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: 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                    }),
600                    dpi: Some("1600 dpi (range 200–8000, 5 steps)".to_string()),
601                    config_key: "4082d".to_string(),
602                    wpid: Some(0x4082),
603                    model_ids: Some([0x082d, 0, 0]),
604                    extended_model_id: Some(0x04),
605                    transports: Some(DeviceTransports {
606                        usb: true,
607                        ..DeviceTransports::default()
608                    }),
609                    render: RenderState::Resolved("mx_master_3s".to_string()),
610                    slot: 1,
611                },
612            ],
613        }
614    }
615
616    #[test]
617    fn renders_header_and_sections() {
618        let md = sample().to_markdown();
619        assert!(md.starts_with("### OpenLogi Diagnostics"));
620        assert!(md.contains("**App**"));
621        assert!(md.contains("**Assets**"));
622        assert!(md.contains("**Devices (2)**"));
623        assert!(md.contains("**Receivers (1)**"));
624        assert!(md.contains("- Logi Bolt (VID 046d / PID c548)"));
625        assert!(md.contains("- OpenLogi (GUI): v0.6.6 (release)"));
626        assert!(md.contains("- Agent: v0.6.6 (connected)"));
627        assert!(md.contains("- IPC protocol: GUI 1 / agent 1"));
628        assert!(md.contains("- Inventory: ready"));
629        assert!(md.contains("- OS: macOS 15.5 (arm64)"));
630        assert!(
631            md.contains("- Source: app bundle · Index: loaded (142 models) · User cache: present")
632        );
633        assert!(md.contains("- Config: schema 2 · 3 configured device(s) · thumbwheel 0"));
634    }
635
636    #[test]
637    fn renders_device_detail() {
638        let md = sample().to_markdown();
639        assert!(md.contains("- MX Keys — keyboard (codename: MX Keys)"));
640        assert!(md.contains(
641            "Connection: Logi Bolt receiver · Online: yes · Battery: 80% (discharging, good)"
642        ));
643        assert!(md.contains("Capabilities: buttons=no, pointer=no, lighting=no"));
644        assert!(md.contains("Model: 2b35a (wpid: 4093, model-ids: b35a/0000/0000, ext-model: 02)"));
645        assert!(md.contains("Transports: eQuad"));
646        assert!(md.contains("Render: ⚠️ none (silhouette) · Slot 2"));
647        assert!(md.contains("- MX Master 3S — mouse"));
648        assert!(md.contains("DPI: 1600 dpi (range 200–8000, 5 steps)"));
649        assert!(md.contains("Transports: USB"));
650        assert!(md.contains("Render: mx_master_3s · Slot 1"));
651        assert!(md.contains("Battery: n/a"));
652    }
653
654    #[test]
655    fn flags_version_and_protocol_mismatch() {
656        let mut report = sample();
657        report.app.agent_version = Some("0.6.5".to_string());
658        report.app.protocol_agent = Some(2);
659        let md = report.to_markdown();
660        assert!(md.contains("v0.6.5 (connected) ⚠️ version mismatch with GUI"));
661        assert!(md.contains("GUI 1 / agent 2 ⚠️ mismatch"));
662    }
663
664    #[test]
665    fn omits_unique_identifiers_and_footer() {
666        let md = sample().to_markdown();
667        assert!(!md.contains("Serial"));
668        assert!(!md.to_lowercase().contains("unit id"));
669        assert!(!md.contains("omitted by design"));
670    }
671
672    #[test]
673    fn direct_slot_renders_as_direct() {
674        let mut report = sample();
675        report.devices[0].slot = 0xFF;
676        let md = report.to_markdown();
677        assert!(md.contains("· direct"));
678        assert!(!md.contains("Slot 255"));
679    }
680
681    #[test]
682    fn unprobed_capabilities_render_not_probed() {
683        let mut report = sample();
684        report.devices[0].capabilities = None;
685        let md = report.to_markdown();
686        assert!(md.contains("  - Capabilities: not probed"));
687    }
688
689    #[test]
690    fn empty_inventory_still_renders() {
691        let report = DiagnosticsReport {
692            app: app(),
693            assets: assets(),
694            receivers: Vec::new(),
695            devices: Vec::new(),
696        };
697        let md = report.to_markdown();
698        assert!(md.contains("**Devices (0)**"));
699        assert!(md.contains("- No devices detected."));
700    }
701
702    #[test]
703    fn unreachable_agent_renders_unknowns() {
704        let mut report = sample();
705        report.app.agent_version = None;
706        report.app.protocol_agent = None;
707        report.app.inventory = None;
708        report.app.hook_installed = None;
709        report.app.launch_at_login = None;
710        let md = report.to_markdown();
711        assert!(md.contains("- Agent: not connected"));
712        assert!(md.contains("GUI 1 / agent —"));
713        assert!(md.contains("- Inventory: —"));
714        assert!(md.contains("Input hook: unknown"));
715    }
716
717    #[test]
718    fn incomplete_enumeration_is_flagged() {
719        let mut report = sample();
720        report.app.inventory = Some(InventoryState::Scanning);
721        assert!(
722            report
723                .to_markdown()
724                .contains("- Inventory: scanning (first enumeration in progress)")
725        );
726        report.app.inventory = Some(InventoryState::Unavailable);
727        assert!(
728            report
729                .to_markdown()
730                .contains("- Inventory: ⚠️ unavailable (enumeration failed — see agent log)")
731        );
732    }
733}