traverse_runtime/events/
catalog.rs1use std::{collections::HashMap, sync::Mutex};
6
7use serde::{Deserialize, Serialize};
8
9use super::types::{EventError, LifecycleStatus};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct EventCatalogEntry {
16 pub event_type: String,
18 pub owner: String,
20 pub version: String,
22 pub lifecycle_status: LifecycleStatus,
24 pub consumer_count: usize,
26}
27
28pub struct EventCatalog {
30 entries: Mutex<HashMap<String, EventCatalogEntry>>,
31}
32
33impl std::fmt::Debug for EventCatalog {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 f.debug_struct("EventCatalog").finish_non_exhaustive()
36 }
37}
38
39impl EventCatalog {
40 #[must_use]
42 pub fn new() -> Self {
43 Self {
44 entries: Mutex::new(HashMap::new()),
45 }
46 }
47
48 pub fn register(&self, entry: EventCatalogEntry) -> Result<(), EventError> {
54 let mut map = self
55 .entries
56 .lock()
57 .map_err(|_| EventError::LifecycleViolation("catalog lock poisoned".to_owned()))?;
58 if map.contains_key(&entry.event_type) {
59 return Err(EventError::LifecycleViolation(format!(
60 "event type '{}' is already registered",
61 entry.event_type
62 )));
63 }
64 map.insert(entry.event_type.clone(), entry);
65 Ok(())
66 }
67
68 #[must_use]
70 pub fn list(&self) -> Vec<EventCatalogEntry> {
71 self.entries
72 .lock()
73 .map(|map| map.values().cloned().collect())
74 .unwrap_or_default()
75 }
76
77 #[must_use]
79 pub fn get(&self, event_type: &str) -> Option<EventCatalogEntry> {
80 self.entries
81 .lock()
82 .ok()
83 .and_then(|map| map.get(event_type).cloned())
84 }
85
86 pub fn increment_consumer_count(&self, event_type: &str) {
88 if let Some(entry) = self
89 .entries
90 .lock()
91 .ok()
92 .as_mut()
93 .and_then(|map| map.get_mut(event_type))
94 {
95 entry.consumer_count = entry.consumer_count.saturating_add(1);
96 }
97 }
98}
99
100impl Default for EventCatalog {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 #![allow(clippy::expect_used)]
109 #![allow(clippy::panic)]
110 #![allow(clippy::unwrap_used)]
111
112 use super::*;
113
114 fn active_entry(event_type: &str) -> EventCatalogEntry {
115 EventCatalogEntry {
116 event_type: event_type.to_string(),
117 owner: "cap.test".to_string(),
118 version: "1.0.0".to_string(),
119 lifecycle_status: LifecycleStatus::Active,
120 consumer_count: 0,
121 }
122 }
123
124 #[test]
125 fn catalog_debug_impl_is_accessible() {
126 let catalog = EventCatalog::new();
127 let rendered = format!("{catalog:?}");
128 assert!(rendered.contains("EventCatalog"));
129 }
130
131 #[test]
132 fn duplicate_registration_returns_lifecycle_violation() {
133 let catalog = EventCatalog::new();
134 catalog
135 .register(active_entry("dev.traverse.dup"))
136 .expect("register must succeed");
137 let err = catalog
138 .register(active_entry("dev.traverse.dup"))
139 .expect_err("duplicate must fail");
140 assert!(matches!(err, EventError::LifecycleViolation(_)));
141 }
142
143 #[test]
144 fn list_returns_empty_when_lock_is_poisoned() {
145 let catalog = EventCatalog::new();
146 catalog
147 .register(active_entry("dev.traverse.poison"))
148 .expect("register must succeed");
149
150 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
151 let _guard = catalog.entries.lock().unwrap();
152 panic!("poison");
153 }));
154
155 let entries = catalog.list();
156 assert!(
157 entries.is_empty(),
158 "poisoned lock must result in default empty list"
159 );
160 }
161
162 #[test]
163 fn default_catalog_is_empty() {
164 let catalog = EventCatalog::default();
165 assert!(catalog.list().is_empty());
166 }
167}