Skip to main content

punch_kernel/
triggers.rs

1//! Event-driven trigger engine for the Punch Agent Combat System.
2//!
3//! The [`TriggerEngine`] manages triggers that automatically fire actions
4//! when conditions are met. Supports scheduled triggers (cron-like),
5//! keyword matching in messages, event pattern matching, and webhook triggers.
6//!
7//! Inspired by OpenFang's trigger system, adapted for Punch's combat metaphor.
8
9use std::time::Duration;
10
11use chrono::{DateTime, Utc};
12use dashmap::DashMap;
13use serde::{Deserialize, Serialize};
14use tracing::{debug, info};
15use uuid::Uuid;
16
17use punch_types::{FighterId, GorillaId, PunchEvent};
18
19// ---------------------------------------------------------------------------
20// TriggerId
21// ---------------------------------------------------------------------------
22
23/// Unique identifier for a trigger.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub struct TriggerId(pub Uuid);
26
27impl TriggerId {
28    pub fn new() -> Self {
29        Self(Uuid::new_v4())
30    }
31}
32
33impl Default for TriggerId {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl std::fmt::Display for TriggerId {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        write!(f, "{}", self.0)
42    }
43}
44
45// ---------------------------------------------------------------------------
46// Trigger types
47// ---------------------------------------------------------------------------
48
49/// What kind of condition activates a trigger.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case", tag = "type")]
52pub enum TriggerCondition {
53    /// Fire on a cron-like schedule (interval in seconds).
54    Schedule {
55        /// Interval in seconds between fires.
56        interval_secs: u64,
57    },
58    /// Fire when a message contains one of the specified keywords (case-insensitive).
59    Keyword {
60        /// Keywords to match against (any match triggers).
61        keywords: Vec<String>,
62    },
63    /// Fire when a specific [`PunchEvent`] variant occurs.
64    Event {
65        /// The event kind to match (e.g. "fighter_spawned", "gorilla_unleashed").
66        event_kind: String,
67    },
68    /// Fire when an HTTP webhook is received.
69    Webhook {
70        /// Optional secret for webhook validation.
71        secret: Option<String>,
72    },
73}
74
75/// What action to perform when a trigger fires.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case", tag = "action")]
78pub enum TriggerAction {
79    /// Spawn a fighter from a template name.
80    SpawnFighter { template_name: String },
81    /// Send a message to a specific fighter.
82    SendMessage {
83        fighter_id: FighterId,
84        message: String,
85    },
86    /// Execute a workflow by ID.
87    ExecuteWorkflow { workflow_id: String, input: String },
88    /// Trigger a single gorilla tick.
89    RunGorilla { gorilla_id: GorillaId },
90    /// Log a message (useful for testing and debugging).
91    Log { message: String },
92}
93
94/// A registered trigger definition.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Trigger {
97    /// Unique trigger ID.
98    pub id: TriggerId,
99    /// Human-readable name.
100    pub name: String,
101    /// The condition that activates this trigger.
102    pub condition: TriggerCondition,
103    /// The action to perform when triggered.
104    pub action: TriggerAction,
105    /// Whether this trigger is currently active.
106    pub enabled: bool,
107    /// When this trigger was created.
108    pub created_at: DateTime<Utc>,
109    /// How many times this trigger has fired.
110    pub fire_count: u64,
111    /// Maximum number of times this trigger can fire (0 = unlimited).
112    pub max_fires: u64,
113}
114
115/// Summary information about a trigger (for listing).
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct TriggerSummary {
118    /// Human-readable name.
119    pub name: String,
120    /// Description of the condition.
121    pub condition_type: String,
122    /// Whether active.
123    pub enabled: bool,
124    /// How many times fired.
125    pub fire_count: u64,
126    /// When created.
127    pub created_at: DateTime<Utc>,
128}
129
130// ---------------------------------------------------------------------------
131// TriggerEngine
132// ---------------------------------------------------------------------------
133
134/// The trigger engine manages event-to-action routing.
135pub struct TriggerEngine {
136    /// All registered triggers.
137    triggers: DashMap<TriggerId, Trigger>,
138}
139
140impl TriggerEngine {
141    /// Create a new trigger engine.
142    pub fn new() -> Self {
143        Self {
144            triggers: DashMap::new(),
145        }
146    }
147
148    /// Register a new trigger and return its ID.
149    pub fn register_trigger(&self, trigger: Trigger) -> TriggerId {
150        let id = trigger.id;
151        info!(trigger_id = %id, name = %trigger.name, "trigger registered");
152        self.triggers.insert(id, trigger);
153        id
154    }
155
156    /// Remove a trigger by ID.
157    pub fn remove_trigger(&self, id: &TriggerId) {
158        if let Some((_, trigger)) = self.triggers.remove(id) {
159            info!(trigger_id = %id, name = %trigger.name, "trigger removed");
160        }
161    }
162
163    /// List all triggers with summary information.
164    pub fn list_triggers(&self) -> Vec<(TriggerId, TriggerSummary)> {
165        self.triggers
166            .iter()
167            .map(|entry| {
168                let t = entry.value();
169                let condition_type = match &t.condition {
170                    TriggerCondition::Schedule { interval_secs } => {
171                        format!("schedule({}s)", interval_secs)
172                    }
173                    TriggerCondition::Keyword { keywords } => {
174                        format!("keyword({})", keywords.join(", "))
175                    }
176                    TriggerCondition::Event { event_kind } => {
177                        format!("event({})", event_kind)
178                    }
179                    TriggerCondition::Webhook { .. } => "webhook".to_string(),
180                };
181                (
182                    *entry.key(),
183                    TriggerSummary {
184                        name: t.name.clone(),
185                        condition_type,
186                        enabled: t.enabled,
187                        fire_count: t.fire_count,
188                        created_at: t.created_at,
189                    },
190                )
191            })
192            .collect()
193    }
194
195    /// Check if a message matches any keyword triggers.
196    ///
197    /// Returns the IDs of all matching triggers and increments their fire counts.
198    pub async fn check_keyword(&self, message: &str) -> Vec<TriggerId> {
199        let lower_message = message.to_lowercase();
200        let mut matched = Vec::new();
201
202        for mut entry in self.triggers.iter_mut() {
203            let trigger = entry.value_mut();
204            if !trigger.enabled {
205                continue;
206            }
207            if trigger.max_fires > 0 && trigger.fire_count >= trigger.max_fires {
208                trigger.enabled = false;
209                continue;
210            }
211
212            if let TriggerCondition::Keyword { keywords } = &trigger.condition {
213                let is_match = keywords
214                    .iter()
215                    .any(|kw| lower_message.contains(&kw.to_lowercase()));
216                if is_match {
217                    trigger.fire_count += 1;
218                    matched.push(trigger.id);
219                    debug!(
220                        trigger_id = %trigger.id,
221                        name = %trigger.name,
222                        fire_count = trigger.fire_count,
223                        "keyword trigger fired"
224                    );
225                }
226            }
227        }
228
229        matched
230    }
231
232    /// Check if a [`PunchEvent`] matches any event triggers.
233    ///
234    /// Returns the IDs of all matching triggers and increments their fire counts.
235    pub async fn check_event(&self, event: &PunchEvent) -> Vec<TriggerId> {
236        let event_kind = event_kind_string(event);
237        let mut matched = Vec::new();
238
239        for mut entry in self.triggers.iter_mut() {
240            let trigger = entry.value_mut();
241            if !trigger.enabled {
242                continue;
243            }
244            if trigger.max_fires > 0 && trigger.fire_count >= trigger.max_fires {
245                trigger.enabled = false;
246                continue;
247            }
248
249            if let TriggerCondition::Event {
250                event_kind: pattern,
251            } = &trigger.condition
252                && (pattern == "*" || pattern == &event_kind)
253            {
254                trigger.fire_count += 1;
255                matched.push(trigger.id);
256                debug!(
257                    trigger_id = %trigger.id,
258                    name = %trigger.name,
259                    event_kind = %event_kind,
260                    "event trigger fired"
261                );
262            }
263        }
264
265        matched
266    }
267
268    /// Get all schedule-type triggers with their intervals.
269    pub fn get_schedule_triggers(&self) -> Vec<(TriggerId, Duration)> {
270        self.triggers
271            .iter()
272            .filter_map(|entry| {
273                let t = entry.value();
274                if !t.enabled {
275                    return None;
276                }
277                if let TriggerCondition::Schedule { interval_secs } = &t.condition {
278                    Some((*entry.key(), Duration::from_secs(*interval_secs)))
279                } else {
280                    None
281                }
282            })
283            .collect()
284    }
285
286    /// Get a trigger by ID.
287    pub fn get_trigger(&self, id: &TriggerId) -> Option<Trigger> {
288        self.triggers.get(id).map(|t| t.clone())
289    }
290
291    /// Check if a webhook trigger exists and return its action.
292    pub fn check_webhook(&self, id: &TriggerId) -> Option<TriggerAction> {
293        let mut entry = self.triggers.get_mut(id)?;
294        let trigger = entry.value_mut();
295
296        if !trigger.enabled {
297            return None;
298        }
299        if trigger.max_fires > 0 && trigger.fire_count >= trigger.max_fires {
300            trigger.enabled = false;
301            return None;
302        }
303
304        if matches!(trigger.condition, TriggerCondition::Webhook { .. }) {
305            trigger.fire_count += 1;
306            debug!(
307                trigger_id = %trigger.id,
308                name = %trigger.name,
309                "webhook trigger fired"
310            );
311            Some(trigger.action.clone())
312        } else {
313            None
314        }
315    }
316}
317
318impl Default for TriggerEngine {
319    fn default() -> Self {
320        Self::new()
321    }
322}
323
324// ---------------------------------------------------------------------------
325// Helpers
326// ---------------------------------------------------------------------------
327
328/// Map a [`PunchEvent`] to a string kind for matching.
329fn event_kind_string(event: &PunchEvent) -> String {
330    match event {
331        PunchEvent::FighterSpawned { .. } => "fighter_spawned".to_string(),
332        PunchEvent::FighterMessage { .. } => "fighter_message".to_string(),
333        PunchEvent::GorillaUnleashed { .. } => "gorilla_unleashed".to_string(),
334        PunchEvent::GorillaPaused { .. } => "gorilla_paused".to_string(),
335        PunchEvent::ToolExecuted { .. } => "tool_executed".to_string(),
336        PunchEvent::BoutStarted { .. } => "bout_started".to_string(),
337        PunchEvent::BoutEnded { .. } => "bout_ended".to_string(),
338        PunchEvent::ComboTriggered { .. } => "combo_triggered".to_string(),
339        PunchEvent::TroopFormed { .. } => "troop_formed".to_string(),
340        PunchEvent::TroopDisbanded { .. } => "troop_disbanded".to_string(),
341        PunchEvent::Error { .. } => "error".to_string(),
342    }
343}
344
345// ---------------------------------------------------------------------------
346// Tests
347// ---------------------------------------------------------------------------
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use punch_types::FighterId;
353
354    fn make_keyword_trigger(keywords: Vec<&str>) -> Trigger {
355        Trigger {
356            id: TriggerId::new(),
357            name: "test-keyword".to_string(),
358            condition: TriggerCondition::Keyword {
359                keywords: keywords.into_iter().map(String::from).collect(),
360            },
361            action: TriggerAction::Log {
362                message: "keyword matched".to_string(),
363            },
364            enabled: true,
365            created_at: Utc::now(),
366            fire_count: 0,
367            max_fires: 0,
368        }
369    }
370
371    fn make_event_trigger(event_kind: &str) -> Trigger {
372        Trigger {
373            id: TriggerId::new(),
374            name: "test-event".to_string(),
375            condition: TriggerCondition::Event {
376                event_kind: event_kind.to_string(),
377            },
378            action: TriggerAction::Log {
379                message: "event matched".to_string(),
380            },
381            enabled: true,
382            created_at: Utc::now(),
383            fire_count: 0,
384            max_fires: 0,
385        }
386    }
387
388    fn make_schedule_trigger(interval_secs: u64) -> Trigger {
389        Trigger {
390            id: TriggerId::new(),
391            name: "test-schedule".to_string(),
392            condition: TriggerCondition::Schedule { interval_secs },
393            action: TriggerAction::Log {
394                message: "schedule fired".to_string(),
395            },
396            enabled: true,
397            created_at: Utc::now(),
398            fire_count: 0,
399            max_fires: 0,
400        }
401    }
402
403    #[tokio::test]
404    async fn test_keyword_trigger_matching() {
405        let engine = TriggerEngine::new();
406        let trigger = make_keyword_trigger(vec!["deploy", "release"]);
407        let id = engine.register_trigger(trigger);
408
409        // Should match.
410        let matches = engine.check_keyword("please deploy the app").await;
411        assert_eq!(matches.len(), 1);
412        assert_eq!(matches[0], id);
413
414        // Should match (case-insensitive).
415        let matches = engine.check_keyword("DEPLOY now!").await;
416        assert_eq!(matches.len(), 1);
417
418        // Should not match.
419        let matches = engine.check_keyword("hello world").await;
420        assert!(matches.is_empty());
421    }
422
423    #[tokio::test]
424    async fn test_keyword_trigger_multiple_keywords() {
425        let engine = TriggerEngine::new();
426        let trigger = make_keyword_trigger(vec!["help", "assist"]);
427        engine.register_trigger(trigger);
428
429        let matches = engine.check_keyword("I need help").await;
430        assert_eq!(matches.len(), 1);
431
432        let matches = engine.check_keyword("please assist me").await;
433        assert_eq!(matches.len(), 1);
434    }
435
436    #[tokio::test]
437    async fn test_event_trigger_firing() {
438        let engine = TriggerEngine::new();
439        let trigger = make_event_trigger("fighter_spawned");
440        let id = engine.register_trigger(trigger);
441
442        let event = PunchEvent::FighterSpawned {
443            fighter_id: FighterId::new(),
444            name: "test".to_string(),
445        };
446
447        let matches = engine.check_event(&event).await;
448        assert_eq!(matches.len(), 1);
449        assert_eq!(matches[0], id);
450
451        // Different event type should not match.
452        let event2 = PunchEvent::Error {
453            source: "test".to_string(),
454            message: "oops".to_string(),
455        };
456        let matches2 = engine.check_event(&event2).await;
457        assert!(matches2.is_empty());
458    }
459
460    #[tokio::test]
461    async fn test_event_trigger_wildcard() {
462        let engine = TriggerEngine::new();
463        let trigger = make_event_trigger("*");
464        engine.register_trigger(trigger);
465
466        let event = PunchEvent::Error {
467            source: "test".to_string(),
468            message: "anything".to_string(),
469        };
470        let matches = engine.check_event(&event).await;
471        assert_eq!(matches.len(), 1);
472    }
473
474    #[test]
475    fn test_schedule_trigger_listing() {
476        let engine = TriggerEngine::new();
477        let t1 = make_schedule_trigger(60);
478        let t2 = make_schedule_trigger(300);
479        engine.register_trigger(t1);
480        engine.register_trigger(t2);
481
482        // Also add a non-schedule trigger to verify it's excluded.
483        let t3 = make_keyword_trigger(vec!["hello"]);
484        engine.register_trigger(t3);
485
486        let schedules = engine.get_schedule_triggers();
487        assert_eq!(schedules.len(), 2);
488    }
489
490    #[test]
491    fn test_trigger_registration_and_removal() {
492        let engine = TriggerEngine::new();
493        let trigger = make_keyword_trigger(vec!["test"]);
494        let id = engine.register_trigger(trigger);
495
496        assert!(engine.get_trigger(&id).is_some());
497        assert_eq!(engine.list_triggers().len(), 1);
498
499        engine.remove_trigger(&id);
500        assert!(engine.get_trigger(&id).is_none());
501        assert_eq!(engine.list_triggers().len(), 0);
502    }
503
504    #[tokio::test]
505    async fn test_trigger_max_fires() {
506        let engine = TriggerEngine::new();
507        let mut trigger = make_keyword_trigger(vec!["fire"]);
508        trigger.max_fires = 2;
509        engine.register_trigger(trigger);
510
511        // First two should match.
512        assert_eq!(engine.check_keyword("fire").await.len(), 1);
513        assert_eq!(engine.check_keyword("fire").await.len(), 1);
514        // Third should not.
515        assert_eq!(engine.check_keyword("fire").await.len(), 0);
516    }
517
518    #[tokio::test]
519    async fn test_disabled_trigger_does_not_fire() {
520        let engine = TriggerEngine::new();
521        let mut trigger = make_keyword_trigger(vec!["test"]);
522        trigger.enabled = false;
523        engine.register_trigger(trigger);
524
525        let matches = engine.check_keyword("test message").await;
526        assert!(matches.is_empty());
527    }
528
529    #[test]
530    fn test_webhook_trigger() {
531        let engine = TriggerEngine::new();
532        let trigger = Trigger {
533            id: TriggerId::new(),
534            name: "webhook-test".to_string(),
535            condition: TriggerCondition::Webhook { secret: None },
536            action: TriggerAction::Log {
537                message: "webhook received".to_string(),
538            },
539            enabled: true,
540            created_at: Utc::now(),
541            fire_count: 0,
542            max_fires: 0,
543        };
544        let id = engine.register_trigger(trigger);
545
546        let action = engine.check_webhook(&id);
547        assert!(action.is_some());
548
549        // Non-existent ID should return None.
550        let fake_id = TriggerId::new();
551        assert!(engine.check_webhook(&fake_id).is_none());
552    }
553
554    #[test]
555    fn trigger_engine_default() {
556        let engine = TriggerEngine::default();
557        assert!(engine.list_triggers().is_empty());
558    }
559
560    #[test]
561    fn trigger_id_display() {
562        let id = TriggerId::new();
563        let s = format!("{}", id);
564        assert!(!s.is_empty());
565    }
566
567    #[test]
568    fn trigger_id_default() {
569        let id = TriggerId::default();
570        assert!(!id.0.is_nil());
571    }
572
573    #[test]
574    fn get_trigger_returns_correct_data() {
575        let engine = TriggerEngine::new();
576        let trigger = make_keyword_trigger(vec!["hello"]);
577        let id = engine.register_trigger(trigger);
578
579        let retrieved = engine.get_trigger(&id).unwrap();
580        assert_eq!(retrieved.name, "test-keyword");
581        assert!(retrieved.enabled);
582        assert_eq!(retrieved.fire_count, 0);
583    }
584
585    #[test]
586    fn get_trigger_nonexistent_returns_none() {
587        let engine = TriggerEngine::new();
588        let id = TriggerId::new();
589        assert!(engine.get_trigger(&id).is_none());
590    }
591
592    #[test]
593    fn remove_nonexistent_trigger_does_not_panic() {
594        let engine = TriggerEngine::new();
595        let id = TriggerId::new();
596        engine.remove_trigger(&id); // Should not panic.
597    }
598
599    #[tokio::test]
600    async fn keyword_trigger_fire_count_increments() {
601        let engine = TriggerEngine::new();
602        let trigger = make_keyword_trigger(vec!["count"]);
603        let id = engine.register_trigger(trigger);
604
605        engine.check_keyword("count me").await;
606        engine.check_keyword("count again").await;
607        engine.check_keyword("count three").await;
608
609        let t = engine.get_trigger(&id).unwrap();
610        assert_eq!(t.fire_count, 3);
611    }
612
613    #[tokio::test]
614    async fn event_trigger_fire_count_increments() {
615        let engine = TriggerEngine::new();
616        let trigger = make_event_trigger("error");
617        let id = engine.register_trigger(trigger);
618
619        let event = PunchEvent::Error {
620            source: "test".to_string(),
621            message: "oops".to_string(),
622        };
623        engine.check_event(&event).await;
624        engine.check_event(&event).await;
625
626        let t = engine.get_trigger(&id).unwrap();
627        assert_eq!(t.fire_count, 2);
628    }
629
630    #[test]
631    fn webhook_trigger_fire_count_increments() {
632        let engine = TriggerEngine::new();
633        let trigger = Trigger {
634            id: TriggerId::new(),
635            name: "webhook-count".to_string(),
636            condition: TriggerCondition::Webhook {
637                secret: Some("secret".to_string()),
638            },
639            action: TriggerAction::Log {
640                message: "fired".to_string(),
641            },
642            enabled: true,
643            created_at: Utc::now(),
644            fire_count: 0,
645            max_fires: 0,
646        };
647        let id = engine.register_trigger(trigger);
648
649        engine.check_webhook(&id);
650        engine.check_webhook(&id);
651
652        let t = engine.get_trigger(&id).unwrap();
653        assert_eq!(t.fire_count, 2);
654    }
655
656    #[test]
657    fn webhook_trigger_disabled_returns_none() {
658        let engine = TriggerEngine::new();
659        let trigger = Trigger {
660            id: TriggerId::new(),
661            name: "disabled-webhook".to_string(),
662            condition: TriggerCondition::Webhook { secret: None },
663            action: TriggerAction::Log {
664                message: "nope".to_string(),
665            },
666            enabled: false,
667            created_at: Utc::now(),
668            fire_count: 0,
669            max_fires: 0,
670        };
671        let id = trigger.id;
672        engine.register_trigger(trigger);
673
674        assert!(engine.check_webhook(&id).is_none());
675    }
676
677    #[test]
678    fn webhook_trigger_max_fires_reached() {
679        let engine = TriggerEngine::new();
680        let trigger = Trigger {
681            id: TriggerId::new(),
682            name: "limited-webhook".to_string(),
683            condition: TriggerCondition::Webhook { secret: None },
684            action: TriggerAction::Log {
685                message: "limited".to_string(),
686            },
687            enabled: true,
688            created_at: Utc::now(),
689            fire_count: 0,
690            max_fires: 1,
691        };
692        let id = engine.register_trigger(trigger);
693
694        assert!(engine.check_webhook(&id).is_some());
695        // Second should fail (max_fires=1 reached).
696        assert!(engine.check_webhook(&id).is_none());
697    }
698
699    #[test]
700    fn check_webhook_on_non_webhook_trigger_returns_none() {
701        let engine = TriggerEngine::new();
702        let trigger = make_keyword_trigger(vec!["test"]);
703        let id = engine.register_trigger(trigger);
704
705        assert!(engine.check_webhook(&id).is_none());
706    }
707
708    #[test]
709    fn disabled_schedule_trigger_excluded() {
710        let engine = TriggerEngine::new();
711        let mut trigger = make_schedule_trigger(60);
712        trigger.enabled = false;
713        engine.register_trigger(trigger);
714
715        let schedules = engine.get_schedule_triggers();
716        assert!(schedules.is_empty());
717    }
718
719    #[test]
720    fn list_triggers_returns_summaries() {
721        let engine = TriggerEngine::new();
722        let t1 = make_keyword_trigger(vec!["a", "b"]);
723        let t2 = make_event_trigger("fighter_spawned");
724        let t3 = make_schedule_trigger(120);
725        let t4 = Trigger {
726            id: TriggerId::new(),
727            name: "webhook".to_string(),
728            condition: TriggerCondition::Webhook { secret: None },
729            action: TriggerAction::Log {
730                message: "wh".to_string(),
731            },
732            enabled: true,
733            created_at: Utc::now(),
734            fire_count: 0,
735            max_fires: 0,
736        };
737
738        engine.register_trigger(t1);
739        engine.register_trigger(t2);
740        engine.register_trigger(t3);
741        engine.register_trigger(t4);
742
743        let summaries = engine.list_triggers();
744        assert_eq!(summaries.len(), 4);
745
746        // Check condition_type descriptions.
747        let types: Vec<String> = summaries
748            .iter()
749            .map(|(_, s)| s.condition_type.clone())
750            .collect();
751        assert!(types.iter().any(|t| t.contains("keyword")));
752        assert!(types.iter().any(|t| t.contains("event")));
753        assert!(types.iter().any(|t| t.contains("schedule")));
754        assert!(types.iter().any(|t| t == "webhook"));
755    }
756
757    #[tokio::test]
758    async fn multiple_keyword_triggers_fire_independently() {
759        let engine = TriggerEngine::new();
760        let t1 = make_keyword_trigger(vec!["alpha"]);
761        let t2 = make_keyword_trigger(vec!["beta"]);
762        let id1 = engine.register_trigger(t1);
763        let id2 = engine.register_trigger(t2);
764
765        // Only alpha matches.
766        let matches = engine.check_keyword("alpha is here").await;
767        assert_eq!(matches.len(), 1);
768        assert_eq!(matches[0], id1);
769
770        // Only beta matches.
771        let matches = engine.check_keyword("beta is here").await;
772        assert_eq!(matches.len(), 1);
773        assert_eq!(matches[0], id2);
774
775        // Both match.
776        let matches = engine.check_keyword("alpha and beta together").await;
777        assert_eq!(matches.len(), 2);
778    }
779}