1use std::fmt::Write as _;
4
5use serde::{Deserialize, Serialize};
6
7use crate::device::{BatteryInfo, BatteryStatus, Capabilities, DeviceKind, DeviceTransports};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum AssetSource {
13 Bundle,
15 UserCache,
17 None,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum ConnectionKind {
25 BoltReceiver,
27 UnifyingReceiver,
29 BluetoothDirect,
31 Wired,
33 Unknown,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case", tag = "state", content = "depot")]
40pub enum RenderState {
41 Resolved(String),
44 Silhouette,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum InventoryState {
53 Scanning,
55 Ready,
57 Unavailable,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct ReceiverDiag {
64 pub name: String,
66 pub vendor_id: u16,
68 pub product_id: u16,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct DeviceDiag {
75 pub display_name: String,
77 pub kind: DeviceKind,
79 pub codename: Option<String>,
81 pub connection: ConnectionKind,
83 pub online: bool,
85 pub battery: Option<BatteryInfo>,
87 pub capabilities: Option<Capabilities>,
89 pub dpi: Option<String>,
91 pub config_key: String,
93 pub wpid: Option<u16>,
96 pub model_ids: Option<[u16; 3]>,
98 pub extended_model_id: Option<u8>,
101 pub transports: Option<DeviceTransports>,
103 pub render: RenderState,
105 pub slot: u8,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct AppInfo {
113 pub gui_version: String,
115 pub build_profile: String,
117 pub agent_version: Option<String>,
119 pub protocol_gui: u32,
121 pub protocol_agent: Option<u32>,
125 pub inventory: Option<InventoryState>,
128 pub os: String,
130 pub os_version: Option<String>,
132 pub arch: String,
134 pub system_locale: Option<String>,
136 pub ui_language: Option<String>,
138 pub accessibility_granted: bool,
141 pub hook_installed: Option<bool>,
143 pub launch_at_login: Option<bool>,
145 pub show_in_menu_bar: Option<bool>,
147 pub check_for_updates: Option<bool>,
149 pub thumbwheel_sensitivity: Option<i32>,
151 pub config_schema_version: Option<u32>,
153 pub configured_device_count: Option<usize>,
155 pub running_from_bundle: bool,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct AssetInfo {
162 pub source: AssetSource,
164 pub index_loaded: bool,
166 pub index_entries: Option<usize>,
168 pub user_cache_present: bool,
170 pub cache_path: String,
172 pub bundle_present: bool,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct DiagnosticsReport {
179 pub app: AppInfo,
181 pub assets: AssetInfo,
183 pub receivers: Vec<ReceiverDiag>,
185 pub devices: Vec<DeviceDiag>,
187}
188
189impl DiagnosticsReport {
190 #[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 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 thumbwheel: false,
600 }),
601 dpi: Some("1600 dpi (range 200–8000, 5 steps)".to_string()),
602 config_key: "4082d".to_string(),
603 wpid: Some(0x4082),
604 model_ids: Some([0x082d, 0, 0]),
605 extended_model_id: Some(0x04),
606 transports: Some(DeviceTransports {
607 usb: true,
608 ..DeviceTransports::default()
609 }),
610 render: RenderState::Resolved("mx_master_3s".to_string()),
611 slot: 1,
612 },
613 ],
614 }
615 }
616
617 #[test]
618 fn renders_header_and_sections() {
619 let md = sample().to_markdown();
620 assert!(md.starts_with("### OpenLogi Diagnostics"));
621 assert!(md.contains("**App**"));
622 assert!(md.contains("**Assets**"));
623 assert!(md.contains("**Devices (2)**"));
624 assert!(md.contains("**Receivers (1)**"));
625 assert!(md.contains("- Logi Bolt (VID 046d / PID c548)"));
626 assert!(md.contains("- OpenLogi (GUI): v0.6.6 (release)"));
627 assert!(md.contains("- Agent: v0.6.6 (connected)"));
628 assert!(md.contains("- IPC protocol: GUI 1 / agent 1"));
629 assert!(md.contains("- Inventory: ready"));
630 assert!(md.contains("- OS: macOS 15.5 (arm64)"));
631 assert!(
632 md.contains("- Source: app bundle · Index: loaded (142 models) · User cache: present")
633 );
634 assert!(md.contains("- Config: schema 2 · 3 configured device(s) · thumbwheel 0"));
635 }
636
637 #[test]
638 fn renders_device_detail() {
639 let md = sample().to_markdown();
640 assert!(md.contains("- MX Keys — keyboard (codename: MX Keys)"));
641 assert!(md.contains(
642 "Connection: Logi Bolt receiver · Online: yes · Battery: 80% (discharging, good)"
643 ));
644 assert!(md.contains("Capabilities: buttons=no, pointer=no, lighting=no"));
645 assert!(md.contains("Model: 2b35a (wpid: 4093, model-ids: b35a/0000/0000, ext-model: 02)"));
646 assert!(md.contains("Transports: eQuad"));
647 assert!(md.contains("Render: ⚠️ none (silhouette) · Slot 2"));
648 assert!(md.contains("- MX Master 3S — mouse"));
649 assert!(md.contains("DPI: 1600 dpi (range 200–8000, 5 steps)"));
650 assert!(md.contains("Transports: USB"));
651 assert!(md.contains("Render: mx_master_3s · Slot 1"));
652 assert!(md.contains("Battery: n/a"));
653 }
654
655 #[test]
656 fn flags_version_and_protocol_mismatch() {
657 let mut report = sample();
658 report.app.agent_version = Some("0.6.5".to_string());
659 report.app.protocol_agent = Some(2);
660 let md = report.to_markdown();
661 assert!(md.contains("v0.6.5 (connected) ⚠️ version mismatch with GUI"));
662 assert!(md.contains("GUI 1 / agent 2 ⚠️ mismatch"));
663 }
664
665 #[test]
666 fn omits_unique_identifiers_and_footer() {
667 let md = sample().to_markdown();
668 assert!(!md.contains("Serial"));
669 assert!(!md.to_lowercase().contains("unit id"));
670 assert!(!md.contains("omitted by design"));
671 }
672
673 #[test]
674 fn direct_slot_renders_as_direct() {
675 let mut report = sample();
676 report.devices[0].slot = 0xFF;
677 let md = report.to_markdown();
678 assert!(md.contains("· direct"));
679 assert!(!md.contains("Slot 255"));
680 }
681
682 #[test]
683 fn unprobed_capabilities_render_not_probed() {
684 let mut report = sample();
685 report.devices[0].capabilities = None;
686 let md = report.to_markdown();
687 assert!(md.contains(" - Capabilities: not probed"));
688 }
689
690 #[test]
691 fn empty_inventory_still_renders() {
692 let report = DiagnosticsReport {
693 app: app(),
694 assets: assets(),
695 receivers: Vec::new(),
696 devices: Vec::new(),
697 };
698 let md = report.to_markdown();
699 assert!(md.contains("**Devices (0)**"));
700 assert!(md.contains("- No devices detected."));
701 }
702
703 #[test]
704 fn unreachable_agent_renders_unknowns() {
705 let mut report = sample();
706 report.app.agent_version = None;
707 report.app.protocol_agent = None;
708 report.app.inventory = None;
709 report.app.hook_installed = None;
710 report.app.launch_at_login = None;
711 let md = report.to_markdown();
712 assert!(md.contains("- Agent: not connected"));
713 assert!(md.contains("GUI 1 / agent —"));
714 assert!(md.contains("- Inventory: —"));
715 assert!(md.contains("Input hook: unknown"));
716 }
717
718 #[test]
719 fn incomplete_enumeration_is_flagged() {
720 let mut report = sample();
721 report.app.inventory = Some(InventoryState::Scanning);
722 assert!(
723 report
724 .to_markdown()
725 .contains("- Inventory: scanning (first enumeration in progress)")
726 );
727 report.app.inventory = Some(InventoryState::Unavailable);
728 assert!(
729 report
730 .to_markdown()
731 .contains("- Inventory: ⚠️ unavailable (enumeration failed — see agent log)")
732 );
733 }
734}