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::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}