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,
26 UnifyingReceiver,
27 BluetoothDirect,
28 Wired,
29 Unknown,
30}
31
32#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum InventoryState {
45 Scanning,
47 Ready,
49 Unavailable,
51}
52
53#[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#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct DeviceDiag {
64 pub display_name: String,
65 pub kind: DeviceKind,
66 pub codename: Option<String>,
68 pub connection: ConnectionKind,
69 pub online: bool,
70 pub battery: Option<BatteryInfo>,
71 pub capabilities: Option<Capabilities>,
73 pub dpi: Option<String>,
75 pub config_key: String,
77 pub wpid: Option<u16>,
78 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct AppInfo {
89 pub gui_version: String,
90 pub build_profile: String,
92 pub agent_version: Option<String>,
94 pub protocol_gui: u32,
95 pub protocol_agent: Option<u32>,
96 pub inventory: Option<InventoryState>,
99 pub os: String,
101 pub os_version: Option<String>,
102 pub arch: String,
103 pub system_locale: Option<String>,
104 pub ui_language: Option<String>,
106 pub accessibility_granted: bool,
107 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct AssetInfo {
121 pub source: AssetSource,
122 pub index_loaded: bool,
123 pub index_entries: Option<usize>,
125 pub user_cache_present: bool,
126 pub cache_path: String,
128 pub bundle_present: bool,
129}
130
131#[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 #[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 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}