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