bloop_server_framework/
trigger.rs

1//! This module provides functionality to define, manage, and track triggers,
2//! which represent event-based activations identified by NFC tags (`NfcUid`).
3//!
4//! Triggers can be configured with different usage policies, such as single
5//! use, limited number of uses, or duration-based activation. The module
6//! supports both local (per client) and global triggers.
7//!
8//! # Key types
9//!
10//! - [`TriggerOccurrence`]: Specifies how often or for how long a trigger
11//!   remains active.
12//! - [`TriggerSpec`]: Defines the properties of a trigger including its type
13//!   and occurrence.
14//! - [`ActiveTrigger`]: Tracks usage and activation time for an active trigger
15//!   instance.
16//! - [`TriggerRegistry`]: Holds trigger specifications and active triggers,
17//!   providing methods to activate and check triggers per client.
18//!
19//! # Usage
20//!
21//! To use triggers, initialize a `TriggerRegistry` with your trigger
22//! specifications, then activate triggers upon NFC scans (`NfcUid`) and check
23//! their active status for clients.
24//!
25//! The registry automatically manages usage counts and expiration of active
26//! triggers.
27
28use crate::nfc_uid::NfcUid;
29use chrono::{DateTime, Utc};
30use serde::Deserialize;
31use std::collections::HashMap;
32use std::collections::hash_map::Entry;
33use std::time::Duration;
34
35/// Specifies how often or for how long a trigger should remain active.
36#[derive(Debug, Copy, Clone, Deserialize)]
37pub enum TriggerOccurrence {
38    /// The trigger can only be used once.
39    Once,
40    /// The trigger can be used a specified number of times.
41    Times(usize),
42    /// The trigger remains active for the specified duration.
43    Duration(Duration),
44}
45
46impl Default for TriggerOccurrence {
47    fn default() -> Self {
48        Self::Once
49    }
50}
51
52/// Defines the specification of a trigger, including its activation policy and type.
53#[derive(Debug, Copy, Clone, Deserialize)]
54pub struct TriggerSpec<T> {
55    /// Whether the trigger is global (affects all clients) or local (per client).
56    #[serde(default)]
57    pub global: bool,
58    /// How often or for how long this trigger can be active.
59    #[serde(default)]
60    pub occurrence: TriggerOccurrence,
61    /// The trigger identifier of type `T`.
62    pub trigger: T,
63}
64
65/// Represents an active trigger instance, tracking its usage and activation time.
66#[derive(Debug)]
67struct ActiveTrigger<T> {
68    /// The specification of the trigger being tracked.
69    spec: TriggerSpec<T>,
70    /// The time when the trigger was activated.
71    triggered_at: DateTime<Utc>,
72    /// The number of times this trigger has been used.
73    usages: usize,
74}
75
76impl<T> ActiveTrigger<T> {
77    /// Creates a new active trigger instance for a given spec and client ID.
78    ///
79    /// The trigger is considered activated at the current time.
80    fn new(spec: TriggerSpec<T>) -> Self {
81        Self {
82            spec,
83            triggered_at: Utc::now(),
84            usages: 0,
85        }
86    }
87}
88
89impl<T: PartialEq> ActiveTrigger<T> {
90    /// Checks whether the trigger is active with respect to the provided trigger value and
91    /// reference time.
92    fn check(&mut self, trigger: T, reference_time: DateTime<Utc>) -> (bool, bool) {
93        if trigger != self.spec.trigger {
94            // The trigger value doesn't match this active trigger's spec.
95            return (false, true);
96        }
97
98        match self.spec.occurrence {
99            TriggerOccurrence::Once => (true, false),
100            TriggerOccurrence::Times(times) => {
101                let active = self.usages < times;
102                self.usages += 1;
103                (active, self.usages < times)
104            }
105            TriggerOccurrence::Duration(duration) => {
106                let still_active = self.triggered_at + duration >= reference_time;
107                (still_active, still_active)
108            }
109        }
110    }
111}
112
113/// Registry that manages trigger specifications and tracks active triggers.
114#[derive(Debug)]
115pub struct TriggerRegistry<T> {
116    trigger_specs: HashMap<NfcUid, TriggerSpec<T>>,
117    active_local_triggers: HashMap<String, ActiveTrigger<T>>,
118    active_global_trigger: Option<ActiveTrigger<T>>,
119}
120
121impl<T> From<HashMap<NfcUid, TriggerSpec<T>>> for TriggerRegistry<T> {
122    fn from(specs: HashMap<NfcUid, TriggerSpec<T>>) -> Self {
123        Self::new(specs)
124    }
125}
126
127impl<T> TriggerRegistry<T> {
128    /// Creates a new [TriggerRegistry] from a set of trigger specifications.
129    pub fn new(specs: HashMap<NfcUid, TriggerSpec<T>>) -> Self {
130        Self {
131            trigger_specs: specs,
132            active_local_triggers: HashMap::new(),
133            active_global_trigger: None,
134        }
135    }
136}
137
138impl<T: PartialEq> TriggerRegistry<T> {
139    /// Checks if there is an active trigger for the given trigger and client at the specified time.
140    ///
141    /// This will update usage counts and remove triggers that should no longer be retained.
142    pub(crate) fn check_active_trigger(
143        &mut self,
144        trigger: T,
145        client_id: &str,
146        reference_time: DateTime<Utc>,
147    ) -> bool {
148        if let Entry::Occupied(mut active_trigger) =
149            self.active_local_triggers.entry(client_id.to_string())
150        {
151            let (active, retain) = active_trigger.get_mut().check(trigger, reference_time);
152
153            if !retain {
154                active_trigger.remove();
155            }
156
157            return active;
158        }
159
160        self.active_global_trigger
161            .take()
162            .is_some_and(|mut active_trigger| {
163                let (active, retain) = active_trigger.check(trigger, reference_time);
164
165                if retain {
166                    self.active_global_trigger = Some(active_trigger);
167                }
168
169                active
170            })
171    }
172}
173
174impl<T: Copy> TriggerRegistry<T> {
175    /// Attempts to activate a trigger based on the provided NFC UID and client ID.
176    ///
177    /// If the NFC UID is associated with a trigger spec, an active trigger is created and stored.
178    pub(crate) fn try_activate_trigger(&mut self, nfc_uid: NfcUid, client_id: &str) -> bool {
179        let Some(spec) = self.trigger_specs.get(&nfc_uid) else {
180            return false;
181        };
182
183        let active_trigger = ActiveTrigger::new(*spec);
184
185        if spec.global {
186            self.active_global_trigger = Some(active_trigger);
187        } else {
188            self.active_local_triggers
189                .insert(client_id.to_string(), active_trigger);
190        }
191
192        true
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use chrono::Utc;
200    use std::time::Duration;
201
202    #[test]
203    fn activate_and_check_once_trigger() {
204        let uid = NfcUid::default();
205        let mut registry: TriggerRegistry<u8> = HashMap::from_iter([(
206            uid,
207            TriggerSpec {
208                global: false,
209                occurrence: TriggerOccurrence::Once,
210                trigger: 42,
211            },
212        )])
213        .into();
214
215        assert!(registry.try_activate_trigger(uid, "client1"));
216
217        let now = Utc::now();
218        assert!(registry.check_active_trigger(42, "client1", now));
219        assert!(!registry.check_active_trigger(42, "client1", now));
220    }
221
222    #[test]
223    fn activate_and_check_times_trigger() {
224        let uid = NfcUid::default();
225        let mut registry: TriggerRegistry<u8> = HashMap::from_iter([(
226            uid,
227            TriggerSpec {
228                global: false,
229                occurrence: TriggerOccurrence::Times(2),
230                trigger: 42,
231            },
232        )])
233        .into();
234
235        assert!(registry.try_activate_trigger(uid, "client2"));
236
237        let now = Utc::now();
238        assert!(registry.check_active_trigger(42, "client2", now));
239        assert!(registry.check_active_trigger(42, "client2", now));
240        assert!(!registry.check_active_trigger(42, "client2", now));
241    }
242
243    #[test]
244    fn activate_and_check_duration_trigger() {
245        let uid = NfcUid::default();
246        let mut registry: TriggerRegistry<u8> = HashMap::from_iter([(
247            uid,
248            TriggerSpec {
249                global: false,
250                occurrence: TriggerOccurrence::Duration(Duration::from_secs(50)),
251                trigger: 42,
252            },
253        )])
254        .into();
255
256        assert!(registry.try_activate_trigger(uid, "client3"));
257
258        let now = Utc::now();
259        assert!(registry.check_active_trigger(42, "client3", now));
260
261        let later = now + Duration::from_secs(30);
262        assert!(registry.check_active_trigger(42, "client3", later));
263
264        let expired = now + Duration::from_secs(70);
265        assert!(!registry.check_active_trigger(42, "client3", expired));
266    }
267
268    #[test]
269    fn global_trigger_works_from_any_client() {
270        let uid = NfcUid::default();
271        let mut registry: TriggerRegistry<u8> = HashMap::from_iter([(
272            uid,
273            TriggerSpec {
274                global: true,
275                occurrence: TriggerOccurrence::Once,
276                trigger: 42,
277            },
278        )])
279        .into();
280
281        assert!(registry.try_activate_trigger(uid, "any_client"));
282
283        let now = Utc::now();
284        assert!(registry.check_active_trigger(42, "client1", now));
285        assert!(!registry.check_active_trigger(42, "client2", now));
286    }
287}