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::Unknown => "unknown",
415    }
416}
417
418fn connection_label(connection: ConnectionKind) -> &'static str {
419    match connection {
420        ConnectionKind::BoltReceiver => "Logi Bolt receiver",
421        ConnectionKind::UnifyingReceiver => "Logi Unifying receiver",
422        ConnectionKind::BluetoothDirect => "Bluetooth (direct)",
423        ConnectionKind::Wired => "Wired (USB)",
424        ConnectionKind::Unknown => "unknown",
425    }
426}
427
428fn battery_label(battery: Option<&BatteryInfo>) -> String {
429    match battery {
430        Some(b) => format!(
431            "{}% ({}, {})",
432            b.percentage,
433            battery_status_label(b.status),
434            battery_level_label(b.level),
435        ),
436        None => "n/a".to_string(),
437    }
438}
439
440fn battery_status_label(status: BatteryStatus) -> &'static str {
441    match status {
442        BatteryStatus::Discharging => "discharging",
443        BatteryStatus::Charging => "charging",
444        BatteryStatus::ChargingSlow => "charging (slow)",
445        BatteryStatus::Full => "full",
446        BatteryStatus::Error => "error",
447        BatteryStatus::Unknown => "unknown",
448    }
449}
450
451fn battery_level_label(level: crate::device::BatteryLevel) -> &'static str {
452    use crate::device::BatteryLevel;
453    match level {
454        BatteryLevel::Critical => "critical",
455        BatteryLevel::Low => "low",
456        BatteryLevel::Good => "good",
457        BatteryLevel::Full => "full",
458        BatteryLevel::Unknown => "unknown",
459    }
460}
461
462fn transports_label(t: DeviceTransports) -> String {
463    let mut parts = Vec::new();
464    if t.usb {
465        parts.push("USB");
466    }
467    if t.equad {
468        parts.push("eQuad");
469    }
470    if t.btle {
471        parts.push("BTLE");
472    }
473    if t.bluetooth {
474        parts.push("Bluetooth");
475    }
476    if parts.is_empty() {
477        "none".to_string()
478    } else {
479        parts.join(", ")
480    }
481}
482
483fn yes_no(value: bool) -> &'static str {
484    if value { "yes" } else { "no" }
485}
486
487fn granted(value: bool) -> &'static str {
488    if value { "granted" } else { "denied" }
489}
490
491fn opt_state(value: Option<bool>, yes: &'static str, no: &'static str) -> &'static str {
492    match value {
493        Some(true) => yes,
494        Some(false) => no,
495        None => "unknown",
496    }
497}
498
499fn opt_num<T: std::fmt::Display>(value: Option<T>) -> String {
500    value.map_or_else(|| "—".to_string(), |v| v.to_string())
501}
502
503#[cfg(test)]
504#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
505mod tests {
506    use super::{
507        AppInfo, AssetInfo, AssetSource, ConnectionKind, DeviceDiag, DiagnosticsReport,
508        InventoryState, ReceiverDiag, RenderState,
509    };
510    use crate::device::{
511        BatteryInfo, BatteryLevel, BatteryStatus, Capabilities, DeviceKind, DeviceTransports,
512    };
513
514    fn app() -> AppInfo {
515        AppInfo {
516            gui_version: "0.6.6".to_string(),
517            build_profile: "release".to_string(),
518            agent_version: Some("0.6.6".to_string()),
519            protocol_gui: 1,
520            protocol_agent: Some(1),
521            inventory: Some(InventoryState::Ready),
522            os: "macos".to_string(),
523            os_version: Some("15.5".to_string()),
524            arch: "arm64".to_string(),
525            system_locale: Some("en-US".to_string()),
526            ui_language: None,
527            accessibility_granted: true,
528            hook_installed: Some(true),
529            launch_at_login: Some(true),
530            show_in_menu_bar: Some(true),
531            check_for_updates: Some(false),
532            thumbwheel_sensitivity: Some(0),
533            config_schema_version: Some(2),
534            configured_device_count: Some(3),
535            running_from_bundle: true,
536        }
537    }
538
539    fn assets() -> AssetInfo {
540        AssetInfo {
541            source: AssetSource::Bundle,
542            index_loaded: true,
543            index_entries: Some(142),
544            user_cache_present: true,
545            cache_path: "~/.local/share/openlogi/assets".to_string(),
546            bundle_present: true,
547        }
548    }
549
550    fn sample() -> DiagnosticsReport {
551        DiagnosticsReport {
552            app: app(),
553            assets: assets(),
554            receivers: vec![ReceiverDiag {
555                name: "Logi Bolt".to_string(),
556                vendor_id: 0x046d,
557                product_id: 0xc548,
558            }],
559            devices: vec![
560                DeviceDiag {
561                    display_name: "MX Keys".to_string(),
562                    kind: DeviceKind::Keyboard,
563                    codename: Some("MX Keys".to_string()),
564                    connection: ConnectionKind::BoltReceiver,
565                    online: true,
566                    battery: Some(BatteryInfo {
567                        percentage: 80,
568                        level: BatteryLevel::Good,
569                        status: BatteryStatus::Discharging,
570                    }),
571                    capabilities: Some(Capabilities::default()),
572                    dpi: None,
573                    config_key: "2b35a".to_string(),
574                    wpid: Some(0x4093),
575                    model_ids: Some([0xb35a, 0, 0]),
576                    extended_model_id: Some(0x02),
577                    transports: Some(DeviceTransports {
578                        equad: true,
579                        ..DeviceTransports::default()
580                    }),
581                    render: RenderState::Silhouette,
582                    slot: 2,
583                },
584                DeviceDiag {
585                    display_name: "MX Master 3S".to_string(),
586                    kind: DeviceKind::Mouse,
587                    codename: Some("MX Master 3S".to_string()),
588                    connection: ConnectionKind::Wired,
589                    online: false,
590                    battery: None,
591                    capabilities: Some(Capabilities {
592                        buttons: true,
593                        pointer: true,
594                        lighting: false,
595                        scroll_inversion: false,
596                        hires_wheel: true,
597                    }),
598                    dpi: Some("1600 dpi (range 200–8000, 5 steps)".to_string()),
599                    config_key: "4082d".to_string(),
600                    wpid: Some(0x4082),
601                    model_ids: Some([0x082d, 0, 0]),
602                    extended_model_id: Some(0x04),
603                    transports: Some(DeviceTransports {
604                        usb: true,
605                        ..DeviceTransports::default()
606                    }),
607                    render: RenderState::Resolved("mx_master_3s".to_string()),
608                    slot: 1,
609                },
610            ],
611        }
612    }
613
614    #[test]
615    fn renders_header_and_sections() {
616        let md = sample().to_markdown();
617        assert!(md.starts_with("### OpenLogi Diagnostics"));
618        assert!(md.contains("**App**"));
619        assert!(md.contains("**Assets**"));
620        assert!(md.contains("**Devices (2)**"));
621        assert!(md.contains("**Receivers (1)**"));
622        assert!(md.contains("- Logi Bolt (VID 046d / PID c548)"));
623        assert!(md.contains("- OpenLogi (GUI): v0.6.6 (release)"));
624        assert!(md.contains("- Agent: v0.6.6 (connected)"));
625        assert!(md.contains("- IPC protocol: GUI 1 / agent 1"));
626        assert!(md.contains("- Inventory: ready"));
627        assert!(md.contains("- OS: macOS 15.5 (arm64)"));
628        assert!(
629            md.contains("- Source: app bundle · Index: loaded (142 models) · User cache: present")
630        );
631        assert!(md.contains("- Config: schema 2 · 3 configured device(s) · thumbwheel 0"));
632    }
633
634    #[test]
635    fn renders_device_detail() {
636        let md = sample().to_markdown();
637        assert!(md.contains("- MX Keys — keyboard (codename: MX Keys)"));
638        assert!(md.contains(
639            "Connection: Logi Bolt receiver · Online: yes · Battery: 80% (discharging, good)"
640        ));
641        assert!(md.contains("Capabilities: buttons=no, pointer=no, lighting=no"));
642        assert!(md.contains("Model: 2b35a (wpid: 4093, model-ids: b35a/0000/0000, ext-model: 02)"));
643        assert!(md.contains("Transports: eQuad"));
644        assert!(md.contains("Render: ⚠️ none (silhouette) · Slot 2"));
645        assert!(md.contains("- MX Master 3S — mouse"));
646        assert!(md.contains("DPI: 1600 dpi (range 200–8000, 5 steps)"));
647        assert!(md.contains("Transports: USB"));
648        assert!(md.contains("Render: mx_master_3s · Slot 1"));
649        assert!(md.contains("Battery: n/a"));
650    }
651
652    #[test]
653    fn flags_version_and_protocol_mismatch() {
654        let mut report = sample();
655        report.app.agent_version = Some("0.6.5".to_string());
656        report.app.protocol_agent = Some(2);
657        let md = report.to_markdown();
658        assert!(md.contains("v0.6.5 (connected) ⚠️ version mismatch with GUI"));
659        assert!(md.contains("GUI 1 / agent 2 ⚠️ mismatch"));
660    }
661
662    #[test]
663    fn omits_unique_identifiers_and_footer() {
664        let md = sample().to_markdown();
665        assert!(!md.contains("Serial"));
666        assert!(!md.to_lowercase().contains("unit id"));
667        assert!(!md.contains("omitted by design"));
668    }
669
670    #[test]
671    fn direct_slot_renders_as_direct() {
672        let mut report = sample();
673        report.devices[0].slot = 0xFF;
674        let md = report.to_markdown();
675        assert!(md.contains("· direct"));
676        assert!(!md.contains("Slot 255"));
677    }
678
679    #[test]
680    fn unprobed_capabilities_render_not_probed() {
681        let mut report = sample();
682        report.devices[0].capabilities = None;
683        let md = report.to_markdown();
684        assert!(md.contains("  - Capabilities: not probed"));
685    }
686
687    #[test]
688    fn empty_inventory_still_renders() {
689        let report = DiagnosticsReport {
690            app: app(),
691            assets: assets(),
692            receivers: Vec::new(),
693            devices: Vec::new(),
694        };
695        let md = report.to_markdown();
696        assert!(md.contains("**Devices (0)**"));
697        assert!(md.contains("- No devices detected."));
698    }
699
700    #[test]
701    fn unreachable_agent_renders_unknowns() {
702        let mut report = sample();
703        report.app.agent_version = None;
704        report.app.protocol_agent = None;
705        report.app.inventory = None;
706        report.app.hook_installed = None;
707        report.app.launch_at_login = None;
708        let md = report.to_markdown();
709        assert!(md.contains("- Agent: not connected"));
710        assert!(md.contains("GUI 1 / agent —"));
711        assert!(md.contains("- Inventory: —"));
712        assert!(md.contains("Input hook: unknown"));
713    }
714
715    #[test]
716    fn incomplete_enumeration_is_flagged() {
717        let mut report = sample();
718        report.app.inventory = Some(InventoryState::Scanning);
719        assert!(
720            report
721                .to_markdown()
722                .contains("- Inventory: scanning (first enumeration in progress)")
723        );
724        report.app.inventory = Some(InventoryState::Unavailable);
725        assert!(
726            report
727                .to_markdown()
728                .contains("- Inventory: ⚠️ unavailable (enumeration failed — see agent log)")
729        );
730    }
731}