Skip to main content

orcs_event/
category.rs

1//! Event categories for subscription-based routing.
2//!
3//! Components subscribe to specific categories and only receive
4//! requests/events for those categories.
5//!
6//! # Built-in Categories
7//!
8//! | Category | Purpose | Example Operations |
9//! |----------|---------|-------------------|
10//! | `Lifecycle` | System events | init, shutdown |
11//! | `Hil` | Human-in-the-Loop | approve, reject, submit |
12//! | `Echo` | Echo (example) | echo |
13//!
14//! # Subscription Flow
15//!
16//! ```text
17//! Component::subscriptions() -> [Hil, Echo]
18//!     │
19//!     ▼
20//! EventBus::register(component, categories)
21//!     │
22//!     ▼
23//! Request { category: Hil, operation: "submit" }
24//!     │
25//!     ▼ (routed only to Hil subscribers)
26//! HilComponent::on_request()
27//! ```
28//!
29//! # Extension Categories
30//!
31//! For custom plugins, use `Extension`:
32//!
33//! ```
34//! use orcs_event::EventCategory;
35//!
36//! let custom = EventCategory::Extension {
37//!     namespace: "my-plugin".into(),
38//!     kind: "data".into(),
39//! };
40//! ```
41
42use serde::{Deserialize, Serialize};
43use std::collections::HashSet;
44
45/// A subscription entry with optional operation-level filtering.
46///
47/// Wraps an [`EventCategory`] with an optional set of accepted operations.
48/// This enables fine-grained subscription control: components can subscribe
49/// to a category and only receive events for specific operations.
50///
51/// # Examples
52///
53/// ```
54/// use orcs_event::{EventCategory, SubscriptionEntry};
55///
56/// // Accept all operations for Echo category
57/// let entry = SubscriptionEntry::all(EventCategory::Echo);
58/// assert!(entry.matches(&EventCategory::Echo, "any_op"));
59///
60/// // Accept only specific operations for Extension category
61/// let ext = EventCategory::extension("lua", "Extension");
62/// let entry = SubscriptionEntry::with_operations(
63///     ext.clone(),
64///     ["route_response".to_string()],
65/// );
66/// assert!(entry.matches(&ext, "route_response"));
67/// assert!(!entry.matches(&ext, "llm_response"));
68/// ```
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct SubscriptionEntry {
71    /// The event category to subscribe to.
72    pub category: EventCategory,
73    /// Optional set of accepted operations within this category.
74    /// `None` means all operations are accepted (wildcard).
75    pub operations: Option<HashSet<String>>,
76}
77
78impl SubscriptionEntry {
79    /// Create a subscription accepting all operations for a category.
80    #[must_use]
81    pub fn all(category: EventCategory) -> Self {
82        Self {
83            category,
84            operations: None,
85        }
86    }
87
88    /// Create a subscription accepting specific operations only.
89    #[must_use]
90    pub fn with_operations(
91        category: EventCategory,
92        operations: impl IntoIterator<Item = String>,
93    ) -> Self {
94        Self {
95            category,
96            operations: Some(operations.into_iter().collect()),
97        }
98    }
99
100    /// Check if an event matches this subscription entry.
101    ///
102    /// Returns `true` if:
103    /// - The category matches, AND
104    /// - Either `operations` is `None` (wildcard), or the operation is in the set.
105    #[must_use]
106    pub fn matches(&self, category: &EventCategory, operation: &str) -> bool {
107        if self.category != *category {
108            return false;
109        }
110        match &self.operations {
111            None => true,
112            Some(ops) => ops.contains(operation),
113        }
114    }
115
116    /// Returns the category of this entry.
117    #[must_use]
118    pub fn category(&self) -> &EventCategory {
119        &self.category
120    }
121}
122
123/// Event category for subscription-based routing.
124///
125/// Components declare which categories they subscribe to.
126/// The EventBus routes messages only to subscribers of the
127/// matching category.
128#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
129pub enum EventCategory {
130    /// System lifecycle events (init, shutdown, pause, resume).
131    ///
132    /// All components implicitly subscribe to this category.
133    Lifecycle,
134
135    /// Human-in-the-Loop approval requests.
136    ///
137    /// Operations: submit, status, list
138    Hil,
139
140    /// Echo component (example/test).
141    ///
142    /// Operations: echo, check
143    Echo,
144
145    /// User input from IOBridge.
146    ///
147    /// Components subscribe to this category to receive user messages
148    /// from the interactive console or other input sources.
149    ///
150    /// Payload should contain:
151    /// - `message`: The user's input text (String)
152    UserInput,
153
154    /// Output events for IO display.
155    ///
156    /// Components emit this category when they want to output
157    /// results to the user via IOBridge.
158    ///
159    /// Payload should contain:
160    /// - `message`: The message to display (String)
161    /// - `level`: Optional log level ("info", "warn", "error")
162    Output,
163
164    /// Extension category for plugins.
165    ///
166    /// Use this for custom components that don't fit built-in categories.
167    Extension {
168        /// Plugin/component namespace (e.g., "my-plugin").
169        namespace: String,
170        /// Event kind within the namespace (e.g., "data", "notification").
171        kind: String,
172    },
173}
174
175impl EventCategory {
176    /// Creates an Extension category.
177    ///
178    /// # Example
179    ///
180    /// ```
181    /// use orcs_event::EventCategory;
182    ///
183    /// let cat = EventCategory::extension("my-plugin", "data");
184    /// assert!(matches!(cat, EventCategory::Extension { .. }));
185    /// ```
186    #[must_use]
187    pub fn extension(namespace: impl Into<String>, kind: impl Into<String>) -> Self {
188        Self::Extension {
189            namespace: namespace.into(),
190            kind: kind.into(),
191        }
192    }
193
194    /// Returns `true` if this is the Lifecycle category.
195    #[must_use]
196    pub fn is_lifecycle(&self) -> bool {
197        matches!(self, Self::Lifecycle)
198    }
199
200    /// Returns `true` if this is the Hil category.
201    #[must_use]
202    pub fn is_hil(&self) -> bool {
203        matches!(self, Self::Hil)
204    }
205
206    /// Returns `true` if this is an Extension category.
207    #[must_use]
208    pub fn is_extension(&self) -> bool {
209        matches!(self, Self::Extension { .. })
210    }
211
212    /// Returns `true` if this is the Output category.
213    #[must_use]
214    pub fn is_output(&self) -> bool {
215        matches!(self, Self::Output)
216    }
217
218    /// Returns `true` if this is the UserInput category.
219    #[must_use]
220    pub fn is_user_input(&self) -> bool {
221        matches!(self, Self::UserInput)
222    }
223
224    /// Returns the display name of this category.
225    #[must_use]
226    pub fn name(&self) -> String {
227        match self {
228            Self::Lifecycle => "Lifecycle".to_string(),
229            Self::Hil => "Hil".to_string(),
230            Self::Echo => "Echo".to_string(),
231            Self::UserInput => "UserInput".to_string(),
232            Self::Output => "Output".to_string(),
233            Self::Extension { namespace, kind } => format!("{}:{}", namespace, kind),
234        }
235    }
236}
237
238impl std::fmt::Display for EventCategory {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        write!(f, "{}", self.name())
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn category_creation() {
250        let lifecycle = EventCategory::Lifecycle;
251        let hil = EventCategory::Hil;
252        let echo = EventCategory::Echo;
253
254        assert!(lifecycle.is_lifecycle());
255        assert!(hil.is_hil());
256        assert!(!echo.is_extension());
257    }
258
259    #[test]
260    fn category_extension() {
261        let ext = EventCategory::extension("my-plugin", "data");
262
263        assert!(ext.is_extension());
264        assert!(!ext.is_lifecycle());
265
266        if let EventCategory::Extension { namespace, kind } = ext {
267            assert_eq!(namespace, "my-plugin");
268            assert_eq!(kind, "data");
269        } else {
270            panic!("Expected Extension");
271        }
272    }
273
274    #[test]
275    fn category_display() {
276        assert_eq!(EventCategory::Lifecycle.to_string(), "Lifecycle");
277        assert_eq!(EventCategory::Hil.to_string(), "Hil");
278        assert_eq!(EventCategory::Echo.to_string(), "Echo");
279        assert_eq!(
280            EventCategory::extension("plugin", "event").to_string(),
281            "plugin:event"
282        );
283    }
284
285    #[test]
286    fn category_name() {
287        assert_eq!(EventCategory::Lifecycle.name(), "Lifecycle");
288        assert_eq!(EventCategory::extension("ns", "k").name(), "ns:k");
289    }
290
291    #[test]
292    fn category_equality() {
293        assert_eq!(EventCategory::Hil, EventCategory::Hil);
294        assert_ne!(EventCategory::Hil, EventCategory::Echo);
295
296        let ext1 = EventCategory::extension("a", "b");
297        let ext2 = EventCategory::extension("a", "b");
298        let ext3 = EventCategory::extension("a", "c");
299
300        assert_eq!(ext1, ext2);
301        assert_ne!(ext1, ext3);
302    }
303
304    #[test]
305    fn category_hash() {
306        use std::collections::HashSet;
307
308        let mut set = HashSet::new();
309        set.insert(EventCategory::Hil);
310        set.insert(EventCategory::Echo);
311        set.insert(EventCategory::Hil); // Duplicate
312
313        assert_eq!(set.len(), 2);
314        assert!(set.contains(&EventCategory::Hil));
315        assert!(set.contains(&EventCategory::Echo));
316    }
317
318    #[test]
319    fn category_serialize() {
320        let hil = EventCategory::Hil;
321        let json =
322            serde_json::to_string(&hil).expect("EventCategory::Hil should serialize to JSON");
323        assert!(json.contains("Hil"));
324
325        let ext = EventCategory::extension("ns", "kind");
326        let json =
327            serde_json::to_string(&ext).expect("EventCategory::Extension should serialize to JSON");
328        assert!(json.contains("Extension"));
329        assert!(json.contains("ns"));
330        assert!(json.contains("kind"));
331    }
332
333    #[test]
334    fn category_deserialize() {
335        let json = r#""Hil""#;
336        let cat: EventCategory = serde_json::from_str(json)
337            .expect("'Hil' JSON string should deserialize to EventCategory::Hil");
338        assert_eq!(cat, EventCategory::Hil);
339
340        let json = r#"{"Extension":{"namespace":"ns","kind":"k"}}"#;
341        let cat: EventCategory = serde_json::from_str(json)
342            .expect("Extension JSON should deserialize to EventCategory::Extension");
343        assert_eq!(cat, EventCategory::extension("ns", "k"));
344    }
345
346    // === SubscriptionEntry tests ===
347
348    #[test]
349    fn subscription_entry_all_matches_any_operation() {
350        let entry = SubscriptionEntry::all(EventCategory::Echo);
351        assert!(entry.matches(&EventCategory::Echo, "echo"));
352        assert!(entry.matches(&EventCategory::Echo, "anything"));
353        assert!(!entry.matches(&EventCategory::Hil, "echo"));
354    }
355
356    #[test]
357    fn subscription_entry_with_operations_filters() {
358        let ext = EventCategory::extension("lua", "Extension");
359        let entry = SubscriptionEntry::with_operations(
360            ext.clone(),
361            ["route_response".to_string(), "skill_update".to_string()],
362        );
363        assert!(entry.matches(&ext, "route_response"));
364        assert!(entry.matches(&ext, "skill_update"));
365        assert!(!entry.matches(&ext, "llm_response"));
366        assert!(!entry.matches(&EventCategory::Echo, "route_response"));
367    }
368
369    #[test]
370    fn subscription_entry_empty_operations_rejects_all() {
371        let entry =
372            SubscriptionEntry::with_operations(EventCategory::Echo, std::iter::empty::<String>());
373        assert!(!entry.matches(&EventCategory::Echo, "echo"));
374    }
375
376    #[test]
377    fn subscription_entry_category_accessor() {
378        let entry = SubscriptionEntry::all(EventCategory::Hil);
379        assert_eq!(entry.category(), &EventCategory::Hil);
380    }
381}