use std::collections::HashMap;
use std::time::{Duration, Instant};
pub struct EventDebouncer {
last_events: HashMap<String, Instant>,
global_debounce: Duration,
max_events_per_minute: u32,
event_count: u32,
window_start: Instant,
}
impl EventDebouncer {
pub fn new(global_debounce_ms: u64, max_events_per_minute: u32) -> Self {
Self {
last_events: HashMap::new(),
global_debounce: Duration::from_millis(global_debounce_ms),
max_events_per_minute,
event_count: 0,
window_start: Instant::now(),
}
}
pub fn should_process(&mut self, key: &str, per_rule_debounce_ms: u64) -> bool {
let now = Instant::now();
if now.duration_since(self.window_start) >= Duration::from_secs(60) {
self.event_count = 0;
self.window_start = now;
}
if self.event_count >= self.max_events_per_minute {
tracing::warn!(
"Rate limit reached ({} events/minute), suppressing event for {key}",
self.max_events_per_minute
);
return false;
}
let debounce = Duration::from_millis(per_rule_debounce_ms).max(self.global_debounce);
if let Some(last) = self.last_events.get(key)
&& now.duration_since(*last) < debounce
{
return false;
}
self.last_events.insert(key.to_string(), now);
self.event_count += 1;
true
}
pub fn event_count(&self) -> u32 {
self.event_count
}
pub fn cleanup(&mut self, max_age: Duration) {
let now = Instant::now();
self.last_events
.retain(|_, last| now.duration_since(*last) < max_age);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_event_always_passes() {
let mut d = EventDebouncer::new(1000, 60);
assert!(d.should_process("file.txt", 1000));
}
#[test]
fn rapid_events_are_debounced() {
let mut d = EventDebouncer::new(5000, 60); assert!(d.should_process("file.txt", 5000));
assert!(!d.should_process("file.txt", 5000));
}
#[test]
fn different_keys_are_independent() {
let mut d = EventDebouncer::new(5000, 60);
assert!(d.should_process("a.txt", 5000));
assert!(d.should_process("b.txt", 5000)); assert!(!d.should_process("a.txt", 5000)); }
#[test]
fn rate_limit_enforced() {
let mut d = EventDebouncer::new(0, 2); assert!(d.should_process("a", 0));
assert!(d.should_process("b", 0));
assert!(!d.should_process("c", 0)); }
#[test]
fn cleanup_removes_stale_entries() {
let mut d = EventDebouncer::new(0, 60);
d.should_process("old", 0);
d.cleanup(Duration::from_secs(0));
assert!(d.last_events.is_empty());
}
}