Skip to main content

this/events/
matcher.rs

1//! EventMatcher — compiles a TriggerConfig into an executable matcher
2//!
3//! The EventMatcher is created from a `TriggerConfig` and provides a
4//! `matches(&FrameworkEvent) -> bool` method that determines whether an
5//! incoming event should trigger a flow.
6//!
7//! # Supported event kinds
8//!
9//! - `link.created` — matches `FrameworkEvent::Link(LinkEvent::Created { .. })`
10//! - `link.deleted` — matches `FrameworkEvent::Link(LinkEvent::Deleted { .. })`
11//! - `entity.created` — matches `FrameworkEvent::Entity(EntityEvent::Created { .. })`
12//! - `entity.updated` — matches `FrameworkEvent::Entity(EntityEvent::Updated { .. })`
13//! - `entity.deleted` — matches `FrameworkEvent::Entity(EntityEvent::Deleted { .. })`
14//!
15//! # Filters
16//!
17//! - `link_type` — Only match link events with this link type (e.g., "follows", "likes")
18//! - `entity_type` — Only match entity events with this entity type (e.g., "user", "post")
19//!
20//! When a filter is `None`, it acts as a wildcard (matches any value).
21
22use crate::config::events::TriggerConfig;
23use crate::core::events::{EntityEvent, FrameworkEvent, LinkEvent};
24
25/// Compiled event kind for fast matching (avoids string comparisons at runtime)
26#[derive(Debug, Clone, PartialEq)]
27enum EventKind {
28    LinkCreated,
29    LinkDeleted,
30    EntityCreated,
31    EntityUpdated,
32    EntityDeleted,
33}
34
35/// Compiled event matcher
36///
37/// Created from a `TriggerConfig`, provides zero-allocation matching
38/// via enum dispatch instead of string comparisons.
39#[derive(Debug, Clone)]
40pub struct EventMatcher {
41    /// The compiled event kind to match
42    kind: EventKind,
43
44    /// Optional link type filter (only for link events)
45    link_type: Option<String>,
46
47    /// Optional entity type filter (only for entity events)
48    entity_type: Option<String>,
49}
50
51/// Error returned when a TriggerConfig has an invalid `kind` string
52#[derive(Debug, thiserror::Error)]
53#[error(
54    "unknown event kind: '{kind}'. Expected one of: link.created, link.deleted, entity.created, entity.updated, entity.deleted"
55)]
56pub struct UnknownEventKind {
57    pub kind: String,
58}
59
60impl EventMatcher {
61    /// Compile a TriggerConfig into an EventMatcher
62    ///
63    /// Returns an error if the `kind` string is not recognized.
64    ///
65    /// # Examples
66    ///
67    /// ```
68    /// use this::config::events::TriggerConfig;
69    /// use this::events::matcher::EventMatcher;
70    ///
71    /// let config = TriggerConfig {
72    ///     kind: "link.created".to_string(),
73    ///     link_type: Some("follows".to_string()),
74    ///     entity_type: None,
75    /// };
76    /// let matcher = EventMatcher::compile(&config).unwrap();
77    /// ```
78    pub fn compile(config: &TriggerConfig) -> Result<Self, UnknownEventKind> {
79        let kind = match config.kind.as_str() {
80            "link.created" => EventKind::LinkCreated,
81            "link.deleted" => EventKind::LinkDeleted,
82            "entity.created" => EventKind::EntityCreated,
83            "entity.updated" => EventKind::EntityUpdated,
84            "entity.deleted" => EventKind::EntityDeleted,
85            _ => {
86                return Err(UnknownEventKind {
87                    kind: config.kind.clone(),
88                });
89            }
90        };
91
92        Ok(Self {
93            kind,
94            link_type: config.link_type.clone(),
95            entity_type: config.entity_type.clone(),
96        })
97    }
98
99    /// Check whether a framework event matches this matcher
100    ///
101    /// Returns `true` if:
102    /// 1. The event kind matches (e.g., link.created)
103    /// 2. Any type filters match (link_type or entity_type)
104    ///
105    /// When a filter is `None`, it's treated as a wildcard (always matches).
106    pub fn matches(&self, event: &FrameworkEvent) -> bool {
107        match event {
108            FrameworkEvent::Link(link_event) => self.matches_link(link_event),
109            FrameworkEvent::Entity(entity_event) => self.matches_entity(entity_event),
110        }
111    }
112
113    /// Match against a link event
114    fn matches_link(&self, event: &LinkEvent) -> bool {
115        let (kind_matches, event_link_type) = match event {
116            LinkEvent::Created { link_type, .. } => {
117                (self.kind == EventKind::LinkCreated, link_type)
118            }
119            LinkEvent::Deleted { link_type, .. } => {
120                (self.kind == EventKind::LinkDeleted, link_type)
121            }
122        };
123
124        if !kind_matches {
125            return false;
126        }
127
128        // Apply link_type filter (None = wildcard)
129        match &self.link_type {
130            Some(expected) => expected == event_link_type,
131            None => true,
132        }
133    }
134
135    /// Match against an entity event
136    fn matches_entity(&self, event: &EntityEvent) -> bool {
137        let (kind_matches, event_entity_type) = match event {
138            EntityEvent::Created { entity_type, .. } => {
139                (self.kind == EventKind::EntityCreated, entity_type)
140            }
141            EntityEvent::Updated { entity_type, .. } => {
142                (self.kind == EventKind::EntityUpdated, entity_type)
143            }
144            EntityEvent::Deleted { entity_type, .. } => {
145                (self.kind == EventKind::EntityDeleted, entity_type)
146            }
147        };
148
149        if !kind_matches {
150            return false;
151        }
152
153        // Apply entity_type filter (None = wildcard)
154        match &self.entity_type {
155            Some(expected) => expected == event_entity_type,
156            None => true,
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use serde_json::json;
165    use uuid::Uuid;
166
167    // ── Helper constructors ──────────────────────────────────────────
168
169    fn link_created(link_type: &str) -> FrameworkEvent {
170        FrameworkEvent::Link(LinkEvent::Created {
171            link_type: link_type.to_string(),
172            link_id: Uuid::new_v4(),
173            source_id: Uuid::new_v4(),
174            target_id: Uuid::new_v4(),
175            metadata: None,
176        })
177    }
178
179    fn link_deleted(link_type: &str) -> FrameworkEvent {
180        FrameworkEvent::Link(LinkEvent::Deleted {
181            link_type: link_type.to_string(),
182            link_id: Uuid::new_v4(),
183            source_id: Uuid::new_v4(),
184            target_id: Uuid::new_v4(),
185        })
186    }
187
188    fn entity_created(entity_type: &str) -> FrameworkEvent {
189        FrameworkEvent::Entity(EntityEvent::Created {
190            entity_type: entity_type.to_string(),
191            entity_id: Uuid::new_v4(),
192            data: json!({"name": "test"}),
193        })
194    }
195
196    fn entity_updated(entity_type: &str) -> FrameworkEvent {
197        FrameworkEvent::Entity(EntityEvent::Updated {
198            entity_type: entity_type.to_string(),
199            entity_id: Uuid::new_v4(),
200            data: json!({"name": "updated"}),
201        })
202    }
203
204    fn entity_deleted(entity_type: &str) -> FrameworkEvent {
205        FrameworkEvent::Entity(EntityEvent::Deleted {
206            entity_type: entity_type.to_string(),
207            entity_id: Uuid::new_v4(),
208        })
209    }
210
211    fn trigger(kind: &str, link_type: Option<&str>, entity_type: Option<&str>) -> TriggerConfig {
212        TriggerConfig {
213            kind: kind.to_string(),
214            link_type: link_type.map(String::from),
215            entity_type: entity_type.map(String::from),
216        }
217    }
218
219    // ── link.created tests ───────────────────────────────────────────
220
221    #[test]
222    fn test_link_created_wildcard() {
223        let m = EventMatcher::compile(&trigger("link.created", None, None)).unwrap();
224        assert!(m.matches(&link_created("follows")));
225        assert!(m.matches(&link_created("likes")));
226        assert!(m.matches(&link_created("blocks")));
227        // Should NOT match other kinds
228        assert!(!m.matches(&link_deleted("follows")));
229        assert!(!m.matches(&entity_created("user")));
230    }
231
232    #[test]
233    fn test_link_created_with_type_filter() {
234        let m = EventMatcher::compile(&trigger("link.created", Some("follows"), None)).unwrap();
235        assert!(m.matches(&link_created("follows")));
236        assert!(!m.matches(&link_created("likes")));
237        assert!(!m.matches(&link_created("blocks")));
238    }
239
240    // ── link.deleted tests ───────────────────────────────────────────
241
242    #[test]
243    fn test_link_deleted_wildcard() {
244        let m = EventMatcher::compile(&trigger("link.deleted", None, None)).unwrap();
245        assert!(m.matches(&link_deleted("follows")));
246        assert!(m.matches(&link_deleted("likes")));
247        assert!(!m.matches(&link_created("follows")));
248        assert!(!m.matches(&entity_deleted("user")));
249    }
250
251    #[test]
252    fn test_link_deleted_with_type_filter() {
253        let m = EventMatcher::compile(&trigger("link.deleted", Some("likes"), None)).unwrap();
254        assert!(m.matches(&link_deleted("likes")));
255        assert!(!m.matches(&link_deleted("follows")));
256    }
257
258    // ── entity.created tests ─────────────────────────────────────────
259
260    #[test]
261    fn test_entity_created_wildcard() {
262        let m = EventMatcher::compile(&trigger("entity.created", None, None)).unwrap();
263        assert!(m.matches(&entity_created("user")));
264        assert!(m.matches(&entity_created("post")));
265        assert!(!m.matches(&entity_updated("user")));
266        assert!(!m.matches(&link_created("follows")));
267    }
268
269    #[test]
270    fn test_entity_created_with_type_filter() {
271        let m = EventMatcher::compile(&trigger("entity.created", None, Some("capture"))).unwrap();
272        assert!(m.matches(&entity_created("capture")));
273        assert!(!m.matches(&entity_created("user")));
274        assert!(!m.matches(&entity_created("post")));
275    }
276
277    // ── entity.updated tests ─────────────────────────────────────────
278
279    #[test]
280    fn test_entity_updated_wildcard() {
281        let m = EventMatcher::compile(&trigger("entity.updated", None, None)).unwrap();
282        assert!(m.matches(&entity_updated("user")));
283        assert!(m.matches(&entity_updated("post")));
284        assert!(!m.matches(&entity_created("user")));
285        assert!(!m.matches(&entity_deleted("user")));
286    }
287
288    #[test]
289    fn test_entity_updated_with_type_filter() {
290        let m = EventMatcher::compile(&trigger("entity.updated", None, Some("user"))).unwrap();
291        assert!(m.matches(&entity_updated("user")));
292        assert!(!m.matches(&entity_updated("post")));
293    }
294
295    // ── entity.deleted tests ─────────────────────────────────────────
296
297    #[test]
298    fn test_entity_deleted_wildcard() {
299        let m = EventMatcher::compile(&trigger("entity.deleted", None, None)).unwrap();
300        assert!(m.matches(&entity_deleted("user")));
301        assert!(m.matches(&entity_deleted("post")));
302        assert!(!m.matches(&entity_created("user")));
303        assert!(!m.matches(&entity_updated("user")));
304    }
305
306    #[test]
307    fn test_entity_deleted_with_type_filter() {
308        let m = EventMatcher::compile(&trigger("entity.deleted", None, Some("post"))).unwrap();
309        assert!(m.matches(&entity_deleted("post")));
310        assert!(!m.matches(&entity_deleted("user")));
311    }
312
313    // ── Error cases ──────────────────────────────────────────────────
314
315    #[test]
316    fn test_unknown_kind_returns_error() {
317        let result = EventMatcher::compile(&trigger("link.updated", None, None));
318        assert!(result.is_err());
319        let err = result.unwrap_err();
320        assert!(err.to_string().contains("link.updated"));
321    }
322
323    #[test]
324    fn test_invalid_kind_returns_error() {
325        let result = EventMatcher::compile(&trigger("banana", None, None));
326        assert!(result.is_err());
327    }
328
329    // ── Cross-kind non-matching ──────────────────────────────────────
330
331    #[test]
332    fn test_link_matcher_never_matches_entity_events() {
333        let m = EventMatcher::compile(&trigger("link.created", None, None)).unwrap();
334        assert!(!m.matches(&entity_created("user")));
335        assert!(!m.matches(&entity_updated("user")));
336        assert!(!m.matches(&entity_deleted("user")));
337    }
338
339    #[test]
340    fn test_entity_matcher_never_matches_link_events() {
341        let m = EventMatcher::compile(&trigger("entity.created", None, None)).unwrap();
342        assert!(!m.matches(&link_created("follows")));
343        assert!(!m.matches(&link_deleted("follows")));
344    }
345
346    // ── Filter combinations ──────────────────────────────────────────
347
348    #[test]
349    fn test_link_type_filter_ignored_for_entity_matcher() {
350        // Even if link_type is set, it doesn't affect entity matching
351        let m = EventMatcher::compile(&trigger("entity.created", Some("follows"), Some("user")))
352            .unwrap();
353        // entity_type filter applies, link_type is irrelevant
354        assert!(m.matches(&entity_created("user")));
355        assert!(!m.matches(&entity_created("post")));
356    }
357
358    #[test]
359    fn test_entity_type_filter_ignored_for_link_matcher() {
360        // Even if entity_type is set, it doesn't affect link matching
361        let m =
362            EventMatcher::compile(&trigger("link.created", Some("follows"), Some("user"))).unwrap();
363        // link_type filter applies, entity_type is irrelevant
364        assert!(m.matches(&link_created("follows")));
365        assert!(!m.matches(&link_created("likes")));
366    }
367}