Skip to main content

traverse_runtime/events/
catalog.rs

1//! Event catalog — registry of known event types with ECCA governance metadata.
2//!
3//! Governed by spec 026-event-broker.
4
5use std::{collections::HashMap, sync::Mutex};
6
7use serde::{Deserialize, Serialize};
8
9use super::types::{EventError, LifecycleStatus};
10
11/// A single entry in the event catalog.
12///
13/// Intentionally contains no `data` field — the catalog tracks metadata only.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct EventCatalogEntry {
16    /// Reverse-DNS event type identifier.
17    pub event_type: String,
18    /// Capability ID that owns this event type.
19    pub owner: String,
20    /// Contract version for this event type.
21    pub version: String,
22    /// Current lifecycle status.
23    pub lifecycle_status: LifecycleStatus,
24    /// Number of active subscribers.
25    pub consumer_count: usize,
26}
27
28/// Thread-safe registry of event types.
29pub 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    /// Create an empty catalog.
41    #[must_use]
42    pub fn new() -> Self {
43        Self {
44            entries: Mutex::new(HashMap::new()),
45        }
46    }
47
48    /// Register a new event type. Returns an error if the type is already registered.
49    ///
50    /// # Errors
51    ///
52    /// Returns `EventError::LifecycleViolation` if `event_type` is already registered.
53    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    /// Return a snapshot of all entries.
69    #[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    /// Look up a single entry by event type.
78    #[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    /// Atomically increment the subscriber count for an event type.
87    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}