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