watch.rs
1 //! Shared watch infrastructure for monitoring AT-SPI events. 2 //! 3 //! This module provides a unified system for watching accessibility objects and 4 //! receiving notifications when events occur on those objects or their children. 5 6 use std::{ 7 sync::{Arc, OnceLock}, 8 time::Instant, 9 }; 10 11 use atspi::{ 12 AccessibilityConnection, State, events::Event, events::object::Property, 13 object_ref::ObjectRefOwned, proxy::accessible::ObjectRefExt, 14 }; 15 use chrono::Local; 16 use futures::StreamExt; 17 use tokio::{ 18 sync::{Mutex, mpsc}, 19 task::{self, JoinHandle}, 20 }; 21 22 use crate::{error::Result, path::strip_atspi_prefix}; 23 24 /// Capacity for broadcast channels (required by `tokio::sync::broadcast`). 25 /// Unbounded mpsc channels are used elsewhere; broadcast requires a fixed capacity. 26 /// If receivers lag behind by more than this many events, they receive a Lagged error. 27 pub const BROADCAST_CHANNEL_CAPACITY: usize = 256; 28 29 /// Global watchlist 30 static WATCHLIST: OnceLock<Arc<Mutex<Vec<WatchHandle>>>> = OnceLock::new(); 31 32 /// Get the global watchlist, initializing it if necessary 33 fn get_watchlist() -> &'static Arc<Mutex<Vec<WatchHandle>>> { 34 WATCHLIST.get_or_init(Default::default) 35 } 36 37 /// A handle representing an active watch on an accessibility object. 38 #[derive(Clone)] 39 struct WatchHandle { 40 /// The object reference for the watched object. 41 object_ref: ObjectRefOwned, 42 /// Cached reference path for display (e.g., "firefox-1.74") 43 reference: String, 44 /// Timestamp of the last event received for this watch. 45 last_event: Arc<tokio::sync::Mutex<Option<Instant>>>, 46 } 47 48 impl WatchHandle { 49 /// Check if an event object matches this watch or is a child of it. 50 /// 51 /// IMPORTANT: This function makes D-Bus calls and must NOT be called from 52 /// the event collector task. Only call from the matcher task. 53 async fn matches( 54 &self, 55 event_obj: &ObjectRefOwned, 56 connection: &AccessibilityConnection, 57 ) -> Result<bool> { 58 // Exact match 59 if event_obj == &self.object_ref { 60 return Ok(true); 61 } 62 63 // Optimization: if watching an app root, any event from the same bus matches. 64 if self.object_ref.path_as_str() == "/org/a11y/atspi/accessible/root" 65 && event_obj.name_as_str() == self.object_ref.name_as_str() 66 { 67 return Ok(true); 68 } 69 70 // For events from a different app, walk up the parent chain to check ancestry. 71 let mut current = event_obj.clone(); 72 73 loop { 74 let proxy = current 75 .into_accessible_proxy(connection.connection()) 76 .await?; 77 let parent = proxy.parent().await?; 78 79 // Check if we've reached the root or a null object 80 if parent.name_as_str().unwrap_or("").is_empty() 81 && parent.path_as_str() == "/org/a11y/atspi/null" 82 { 83 // Reached null, no match found 84 return Ok(false); 85 } 86 87 // Check if the parent matches the watched object 88 if parent == self.object_ref { 89 return Ok(true); 90 } 91 92 // Continue up the tree 93 current = parent; 94 } 95 } 96 } 97 98 /// Start background tasks that monitor AT-SPI events and call the handler 99 /// when events match watches in the watchlist. 100 /// 101 /// This uses a two-task architecture to avoid D-Bus deadlocks: 102 /// 1. **Event collector**: Reads from `event_stream()`, forwards to channel (never blocks on D-Bus) 103 /// 2. **Matcher**: Reads from channel, does parent traversal, calls handler 104 /// (safe to do D-Bus calls) 105 /// 106 /// # Arguments 107 /// * `connection` - The AT-SPI connection 108 /// * `handler` - Closure called with `(event, reference_path)` when an event matches 109 /// 110 /// # Returns 111 /// A join handle for the matcher task (the one that calls the handler) 112 /// 113 /// Also calls `defunct_handler` when an object becomes defunct. 114 /// Use this to clean up resources (e.g., inode mappings). 115 pub fn spawn_watch_task_with_defunct<F, D>( 116 connection: &AccessibilityConnection, 117 handler: F, 118 defunct_handler: D, 119 ) -> JoinHandle<()> 120 where 121 F: Fn(&Event, &str) + Send + Sync + 'static, 122 D: Fn(&ObjectRefOwned) + Send + Sync + 'static, 123 { 124 let (tx, rx) = mpsc::unbounded_channel::<Event>(); 125 126 // Spawn the event collector task 127 spawn_event_collector(connection, tx); 128 129 // Spawn and return the matcher task 130 spawn_matcher_task(connection, rx, handler, defunct_handler) 131 } 132 133 /// Event collector task: reads from `event_stream` and forwards to channel. 134 /// 135 /// This task NEVER makes D-Bus method calls to avoid deadlocking the event stream. 136 fn spawn_event_collector( 137 connection: &AccessibilityConnection, 138 tx: mpsc::UnboundedSender<Event>, 139 ) -> JoinHandle<()> { 140 let connection = connection.clone(); 141 task::spawn(async move { 142 let mut events = connection.event_stream(); 143 144 tracing::debug!("Event collector started"); 145 146 while let Some(event) = events.next().await { 147 let Ok(event) = event else { continue }; 148 149 // Send to matcher task (unbounded, so only fails if receiver dropped) 150 if tx.send(event).is_err() { 151 tracing::debug!("Matcher task gone, stopping collector"); 152 break; 153 } 154 } 155 156 tracing::debug!("Event collector ended"); 157 }) 158 } 159 160 /// Matcher task: reads events from channel and does matching. 161 /// 162 /// This task is safe to make D-Bus calls because it's decoupled from the event stream. 163 fn spawn_matcher_task<F, D>( 164 connection: &AccessibilityConnection, 165 mut rx: mpsc::UnboundedReceiver<Event>, 166 handler: F, 167 defunct_handler: D, 168 ) -> JoinHandle<()> 169 where 170 F: Fn(&Event, &str) + Send + Sync + 'static, 171 D: Fn(&ObjectRefOwned) + Send + Sync + 'static, 172 { 173 let watchlist = get_watchlist().clone(); 174 let connection = connection.clone(); 175 task::spawn(async move { 176 tracing::debug!("Matcher task started"); 177 178 while let Some(event) = rx.recv().await { 179 // Handle defunct state changes 180 if let Event::Object(atspi::ObjectEvents::StateChanged(ref ev)) = event 181 && ev.state == State::Defunct 182 { 183 remove_defunct_watch(&ev.item, &connection).await; 184 defunct_handler(&ev.item); 185 } 186 187 // Extract the object reference from the event 188 let event_obj = event_item(&event); 189 190 let watches = watchlist.lock().await.clone(); 191 192 // Skip if no watches 193 if watches.is_empty() { 194 continue; 195 } 196 197 // Find the first watch that matches this event 198 for handle in &watches { 199 // Ok(false): no match, continue to next watch 200 // Err(_): errors during parent traversal are normal for transient objects 201 if let Ok(true) = handle.matches(event_obj, &connection).await { 202 handler(&event, &handle.reference); 203 *handle.last_event.lock().await = Some(Instant::now()); 204 break; 205 } 206 } 207 } 208 209 tracing::debug!("Matcher task ended"); 210 }) 211 } 212 213 /// Add a watch using a pre-computed reference string. 214 /// 215 /// Use this when the reference is already known (e.g., from FUSE path traversal) 216 /// to avoid D-Bus calls. 217 pub async fn add_watch_with_ref(obj_ref: ObjectRefOwned, reference: String) { 218 let handle = WatchHandle { 219 object_ref: obj_ref, 220 reference, 221 last_event: Arc::new(tokio::sync::Mutex::new(None)), 222 }; 223 get_watchlist().lock().await.push(handle); 224 } 225 226 /// Remove a watch by object reference. 227 /// 228 /// Use this when you have the object reference but not a proxy. 229 pub async fn remove_watch_by_ref(obj_ref: &ObjectRefOwned) -> Option<String> { 230 let mut guard = get_watchlist().lock().await; 231 if let Some(pos) = guard.iter().position(|h| &h.object_ref == obj_ref) { 232 let handle = guard.remove(pos); 233 Some(handle.reference) 234 } else { 235 None 236 } 237 } 238 239 /// Remove a watch by its index in the watchlist. 240 /// 241 /// Returns the removed watch handle if the index was valid. 242 async fn remove_watch_by_index(index: usize) -> Option<WatchHandle> { 243 let mut guard = get_watchlist().lock().await; 244 if index < guard.len() { 245 Some(guard.remove(index)) 246 } else { 247 None 248 } 249 } 250 251 /// Remove a watch for a defunct object. 252 async fn remove_defunct_watch(defunct_obj: &ObjectRefOwned, connection: &AccessibilityConnection) { 253 let index = { 254 let guard = get_watchlist().lock().await; 255 guard 256 .iter() 257 .position(|handle| &handle.object_ref == defunct_obj) 258 }; 259 260 if let Some(index) = index 261 && let Some(handle) = remove_watch_by_index(index).await 262 { 263 // Try to create a proxy to get the reference path for logging 264 if let Ok(proxy) = handle 265 .object_ref 266 .into_accessible_proxy(connection.connection()) 267 .await 268 && let Ok(ref_path) = crate::path::generate_reference(&proxy).await 269 { 270 tracing::error!("Removing defunct watch: {}", ref_path); 271 } 272 } 273 } 274 275 /// Extract the `ObjectRef` from an event. 276 #[allow(clippy::too_many_lines)] 277 fn event_item(event: &Event) -> &ObjectRefOwned { 278 match event { 279 Event::Object(obj) => { 280 use atspi::ObjectEvents; 281 match obj { 282 ObjectEvents::PropertyChange(e) => &e.item, 283 ObjectEvents::BoundsChanged(e) => &e.item, 284 ObjectEvents::LinkSelected(e) => &e.item, 285 ObjectEvents::StateChanged(e) => &e.item, 286 ObjectEvents::ChildrenChanged(e) => &e.item, 287 ObjectEvents::VisibleDataChanged(e) => &e.item, 288 ObjectEvents::SelectionChanged(e) => &e.item, 289 ObjectEvents::ModelChanged(e) => &e.item, 290 ObjectEvents::ActiveDescendantChanged(e) => &e.item, 291 ObjectEvents::Announcement(e) => &e.item, 292 ObjectEvents::AttributesChanged(e) => &e.item, 293 ObjectEvents::RowInserted(e) => &e.item, 294 ObjectEvents::RowReordered(e) => &e.item, 295 ObjectEvents::RowDeleted(e) => &e.item, 296 ObjectEvents::ColumnInserted(e) => &e.item, 297 ObjectEvents::ColumnReordered(e) => &e.item, 298 ObjectEvents::ColumnDeleted(e) => &e.item, 299 ObjectEvents::TextBoundsChanged(e) => &e.item, 300 ObjectEvents::TextSelectionChanged(e) => &e.item, 301 ObjectEvents::TextChanged(e) => &e.item, 302 ObjectEvents::TextAttributesChanged(e) => &e.item, 303 ObjectEvents::TextCaretMoved(e) => &e.item, 304 } 305 } 306 Event::Focus(focus) => { 307 use atspi::FocusEvents; 308 match focus { 309 FocusEvents::Focus(e) => &e.item, 310 } 311 } 312 Event::Window(win) => { 313 use atspi::WindowEvents; 314 match win { 315 WindowEvents::PropertyChange(e) => &e.item, 316 WindowEvents::Activate(e) => &e.item, 317 WindowEvents::Close(e) => &e.item, 318 WindowEvents::Create(e) => &e.item, 319 WindowEvents::Deactivate(e) => &e.item, 320 WindowEvents::DesktopCreate(e) => &e.item, 321 WindowEvents::DesktopDestroy(e) => &e.item, 322 WindowEvents::Destroy(e) => &e.item, 323 WindowEvents::Lower(e) => &e.item, 324 WindowEvents::Maximize(e) => &e.item, 325 WindowEvents::Minimize(e) => &e.item, 326 WindowEvents::Move(e) => &e.item, 327 WindowEvents::Raise(e) => &e.item, 328 WindowEvents::Reparent(e) => &e.item, 329 WindowEvents::Resize(e) => &e.item, 330 WindowEvents::Restore(e) => &e.item, 331 WindowEvents::Restyle(e) => &e.item, 332 WindowEvents::Shade(e) => &e.item, 333 WindowEvents::UUshade(e) => &e.item, 334 } 335 } 336 Event::Document(doc) => { 337 use atspi::DocumentEvents; 338 match doc { 339 DocumentEvents::LoadComplete(e) => &e.item, 340 DocumentEvents::Reload(e) => &e.item, 341 DocumentEvents::LoadStopped(e) => &e.item, 342 DocumentEvents::ContentChanged(e) => &e.item, 343 DocumentEvents::AttributesChanged(e) => &e.item, 344 DocumentEvents::PageChanged(e) => &e.item, 345 } 346 } 347 Event::Terminal(term) => { 348 use atspi::TerminalEvents; 349 match term { 350 TerminalEvents::LineChanged(e) => &e.item, 351 TerminalEvents::ColumnCountChanged(e) => &e.item, 352 TerminalEvents::LineCountChanged(e) => &e.item, 353 TerminalEvents::ApplicationChanged(e) => &e.item, 354 TerminalEvents::CharWidthChanged(e) => &e.item, 355 } 356 } 357 Event::Mouse(mouse) => { 358 use atspi::MouseEvents; 359 match mouse { 360 MouseEvents::Abs(e) => &e.item, 361 MouseEvents::Rel(e) => &e.item, 362 MouseEvents::Button(e) => &e.item, 363 } 364 } 365 Event::Keyboard(kb) => { 366 use atspi::KeyboardEvents; 367 match kb { 368 KeyboardEvents::Modifiers(e) => &e.item, 369 } 370 } 371 Event::Cache(cache) => { 372 use atspi::CacheEvents; 373 match cache { 374 CacheEvents::Add(e) => &e.item, 375 CacheEvents::LegacyAdd(e) => &e.item, 376 CacheEvents::Remove(e) => &e.item, 377 } 378 } 379 _ => unreachable!("Unhandled event type - update event_item(): {event:?}"), 380 } 381 } 382 383 /// Canonical event representation used for both JSON serialization and display. 384 /// 385 /// This type provides a unified representation of AT-SPI events that can be: 386 /// - Serialized to JSON for FUSE filesystem streaming 387 /// - Displayed in human-readable format via the `Display` trait 388 #[derive(serde::Serialize)] 389 pub struct EventInfo { 390 pub timestamp: String, 391 pub event_type: String, 392 pub source: String, 393 #[serde(skip_serializing_if = "Option::is_none")] 394 pub details: Option<serde_json::Value>, 395 } 396 397 impl From<&Event> for EventInfo { 398 /// Create an `EventInfo` from an AT-SPI event. 399 #[allow(clippy::too_many_lines)] 400 fn from(event: &Event) -> Self { 401 let timestamp = Local::now().format("%Y-%m-%dT%H:%M:%S%.3f").to_string(); 402 let item = event_item(event); 403 let source = format!( 404 "{}:{}", 405 item.name_as_str().unwrap_or(""), 406 strip_atspi_prefix(item.path_as_str()) 407 ); 408 409 let (event_type, details) = match event { 410 Event::Object(obj_event) => { 411 use atspi::ObjectEvents; 412 match obj_event { 413 ObjectEvents::StateChanged(e) => ( 414 "StateChanged".to_string(), 415 Some(serde_json::json!({ 416 "state": e.state.to_string(), 417 "enabled": e.enabled 418 })), 419 ), 420 ObjectEvents::TextChanged(e) => ( 421 "TextChanged".to_string(), 422 Some(serde_json::json!({ 423 "operation": format!("{:?}", e.operation), 424 "start_pos": e.start_pos, 425 "length": e.length, 426 "text": e.text 427 })), 428 ), 429 ObjectEvents::PropertyChange(e) => { 430 let value = match &e.value { 431 Property::Name(s) => serde_json::json!({"type": "name", "value": s}), 432 Property::Description(s) => { 433 serde_json::json!({"type": "description", "value": s}) 434 } 435 Property::Role(r) => { 436 serde_json::json!({"type": "role", "value": r.to_string()}) 437 } 438 Property::Parent(p) => serde_json::json!({ 439 "type": "parent", 440 "value": format!("{}:{}", p.name_as_str().unwrap_or(""), strip_atspi_prefix(p.path_as_str())) 441 }), 442 Property::TableCaption(s) => { 443 serde_json::json!({"type": "table_caption", "value": s}) 444 } 445 Property::TableColumnDescription(s) => { 446 serde_json::json!({"type": "table_column_description", "value": s}) 447 } 448 Property::TableColumnHeader(s) => { 449 serde_json::json!({"type": "table_column_header", "value": s}) 450 } 451 Property::TableRowDescription(s) => { 452 serde_json::json!({"type": "table_row_description", "value": s}) 453 } 454 Property::TableRowHeader(s) => { 455 serde_json::json!({"type": "table_row_header", "value": s}) 456 } 457 Property::TableSummary(s) => { 458 serde_json::json!({"type": "table_summary", "value": s}) 459 } 460 Property::HelpText(s) => { 461 serde_json::json!({"type": "help_text", "value": s}) 462 } 463 Property::Other((name, val)) => { 464 serde_json::json!({"type": "other", "name": name, "value": format!("{:?}", val)}) 465 } 466 _ => { 467 serde_json::json!({"type": "unknown", "value": format!("{:?}", e.value)}) 468 } 469 }; 470 ( 471 "PropertyChange".to_string(), 472 Some(serde_json::json!({ 473 "property": e.property, 474 "value": value 475 })), 476 ) 477 } 478 ObjectEvents::TextCaretMoved(e) => ( 479 "TextCaretMoved".to_string(), 480 Some(serde_json::json!({ 481 "position": e.position 482 })), 483 ), 484 ObjectEvents::ChildrenChanged(e) => ( 485 "ChildrenChanged".to_string(), 486 Some(serde_json::json!({ 487 "operation": format!("{:?}", e.operation), 488 "index": e.index_in_parent, 489 "child": format!("{}:{}", e.child.name_as_str().unwrap_or(""), strip_atspi_prefix(e.child.path_as_str())) 490 })), 491 ), 492 ObjectEvents::Announcement(e) => ( 493 "Announcement".to_string(), 494 Some(serde_json::json!({ 495 "text": e.text, 496 "live": format!("{:?}", e.live) 497 })), 498 ), 499 ObjectEvents::BoundsChanged(_) => ("BoundsChanged".to_string(), None), 500 ObjectEvents::SelectionChanged(_) => ("SelectionChanged".to_string(), None), 501 ObjectEvents::ActiveDescendantChanged(e) => ( 502 "ActiveDescendantChanged".to_string(), 503 Some(serde_json::json!({ 504 "descendant": format!( 505 "{}:{}", 506 e.descendant.name_as_str().unwrap_or(""), 507 strip_atspi_prefix(e.descendant.path_as_str()) 508 ) 509 })), 510 ), 511 ObjectEvents::LinkSelected(_) => ("LinkSelected".to_string(), None), 512 ObjectEvents::VisibleDataChanged(_) => ("VisibleDataChanged".to_string(), None), 513 ObjectEvents::ModelChanged(_) => ("ModelChanged".to_string(), None), 514 ObjectEvents::AttributesChanged(_) => ("AttributesChanged".to_string(), None), 515 ObjectEvents::RowInserted(_) => ("RowInserted".to_string(), None), 516 ObjectEvents::RowReordered(_) => ("RowReordered".to_string(), None), 517 ObjectEvents::RowDeleted(_) => ("RowDeleted".to_string(), None), 518 ObjectEvents::ColumnInserted(_) => ("ColumnInserted".to_string(), None), 519 ObjectEvents::ColumnReordered(_) => ("ColumnReordered".to_string(), None), 520 ObjectEvents::ColumnDeleted(_) => ("ColumnDeleted".to_string(), None), 521 ObjectEvents::TextBoundsChanged(_) => ("TextBoundsChanged".to_string(), None), 522 ObjectEvents::TextSelectionChanged(_) => { 523 ("TextSelectionChanged".to_string(), None) 524 } 525 ObjectEvents::TextAttributesChanged(_) => { 526 ("TextAttributesChanged".to_string(), None) 527 } 528 } 529 } 530 Event::Focus(_) => ("Focus".to_string(), None), 531 Event::Window(win) => { 532 use atspi::WindowEvents; 533 let name = match win { 534 WindowEvents::PropertyChange(e) => { 535 return Self { 536 timestamp, 537 event_type: "Window::PropertyChange".to_string(), 538 source, 539 details: Some(serde_json::json!({ 540 "property": e.property 541 })), 542 }; 543 } 544 WindowEvents::Minimize(_) => "Window::Minimize", 545 WindowEvents::Maximize(_) => "Window::Maximize", 546 WindowEvents::Restore(_) => "Window::Restore", 547 WindowEvents::Close(_) => "Window::Close", 548 WindowEvents::Create(_) => "Window::Create", 549 WindowEvents::Reparent(_) => "Window::Reparent", 550 WindowEvents::DesktopCreate(_) => "Window::DesktopCreate", 551 WindowEvents::DesktopDestroy(_) => "Window::DesktopDestroy", 552 WindowEvents::Destroy(_) => "Window::Destroy", 553 WindowEvents::Activate(_) => "Window::Activate", 554 WindowEvents::Deactivate(_) => "Window::Deactivate", 555 WindowEvents::Raise(_) => "Window::Raise", 556 WindowEvents::Lower(_) => "Window::Lower", 557 WindowEvents::Move(_) => "Window::Move", 558 WindowEvents::Resize(_) => "Window::Resize", 559 WindowEvents::Shade(_) => "Window::Shade", 560 WindowEvents::UUshade(_) => "Window::Unshade", 561 WindowEvents::Restyle(_) => "Window::Restyle", 562 }; 563 (name.to_string(), None) 564 } 565 Event::Document(doc) => { 566 use atspi::DocumentEvents; 567 let name = match doc { 568 DocumentEvents::LoadComplete(_) => "Document::LoadComplete", 569 DocumentEvents::Reload(_) => "Document::Reload", 570 DocumentEvents::LoadStopped(_) => "Document::LoadStopped", 571 DocumentEvents::ContentChanged(_) => "Document::ContentChanged", 572 DocumentEvents::AttributesChanged(_) => "Document::AttributesChanged", 573 DocumentEvents::PageChanged(_) => "Document::PageChanged", 574 }; 575 (name.to_string(), None) 576 } 577 Event::Terminal(term) => { 578 use atspi::TerminalEvents; 579 let name = match term { 580 TerminalEvents::LineChanged(_) => "Terminal::LineChanged", 581 TerminalEvents::ColumnCountChanged(_) => "Terminal::ColumnCountChanged", 582 TerminalEvents::LineCountChanged(_) => "Terminal::LineCountChanged", 583 TerminalEvents::ApplicationChanged(_) => "Terminal::ApplicationChanged", 584 TerminalEvents::CharWidthChanged(_) => "Terminal::CharWidthChanged", 585 }; 586 (name.to_string(), None) 587 } 588 Event::Mouse(mouse) => { 589 use atspi::MouseEvents; 590 match mouse { 591 MouseEvents::Abs(e) => ( 592 "Mouse::Abs".to_string(), 593 Some(serde_json::json!({"x": e.x, "y": e.y})), 594 ), 595 MouseEvents::Rel(e) => ( 596 "Mouse::Rel".to_string(), 597 Some(serde_json::json!({"x": e.x, "y": e.y})), 598 ), 599 MouseEvents::Button(e) => ( 600 "Mouse::Button".to_string(), 601 Some(serde_json::json!({ 602 "detail": e.detail, 603 "x": e.mouse_x, 604 "y": e.mouse_y 605 })), 606 ), 607 } 608 } 609 Event::Keyboard(kb) => { 610 use atspi::KeyboardEvents; 611 match kb { 612 KeyboardEvents::Modifiers(e) => ( 613 "Keyboard::Modifiers".to_string(), 614 Some(serde_json::json!({ 615 "previous": e.previous_modifiers, 616 "current": e.current_modifiers 617 })), 618 ), 619 } 620 } 621 Event::Cache(cache) => { 622 use atspi::CacheEvents; 623 let name = match cache { 624 CacheEvents::Add(_) => "Cache::Add", 625 CacheEvents::LegacyAdd(_) => "Cache::LegacyAdd", 626 CacheEvents::Remove(_) => "Cache::Remove", 627 }; 628 (name.to_string(), None) 629 } 630 _ => ("Unknown".to_string(), None), 631 }; 632 633 Self { 634 timestamp, 635 event_type, 636 source, 637 details, 638 } 639 } 640 } 641 642 /// Format a JSON value for human-readable display. 643 fn format_json_value(key: &str, value: &serde_json::Value) -> String { 644 match value { 645 serde_json::Value::String(s) => format!("{key}=\"{s}\""), 646 serde_json::Value::Number(n) => format!("{key}={n}"), 647 serde_json::Value::Bool(b) => format!("{key}={b}"), 648 _ => format!("{key}={value}"), 649 } 650 } 651 652 impl std::fmt::Display for EventInfo { 653 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 654 // Format: [timestamp] [source] event_type: key=value, key=value 655 write!( 656 f, 657 "[{}] [{}] {}", 658 self.timestamp, self.source, self.event_type 659 )?; 660 661 if let Some(serde_json::Value::Object(map)) = &self.details { 662 let parts: Vec<String> = map.iter().map(|(k, v)| format_json_value(k, v)).collect(); 663 if !parts.is_empty() { 664 write!(f, ": {}", parts.join(", "))?; 665 } 666 } 667 Ok(()) 668 } 669 }