1use 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#[derive(Debug, Copy, Clone, Deserialize)]
37pub enum TriggerOccurrence {
38 Once,
40 Times(usize),
42 Duration(Duration),
44}
45
46impl Default for TriggerOccurrence {
47 fn default() -> Self {
48 Self::Once
49 }
50}
51
52#[derive(Debug, Copy, Clone, Deserialize)]
54pub struct TriggerSpec<T> {
55 #[serde(default)]
57 pub global: bool,
58 #[serde(default)]
60 pub occurrence: TriggerOccurrence,
61 pub trigger: T,
63}
64
65#[derive(Debug)]
67struct ActiveTrigger<T> {
68 spec: TriggerSpec<T>,
70 triggered_at: DateTime<Utc>,
72 usages: usize,
74}
75
76impl<T> ActiveTrigger<T> {
77 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 fn check(&mut self, trigger: T, reference_time: DateTime<Utc>) -> (bool, bool) {
93 if trigger != self.spec.trigger {
94 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#[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 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 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 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}