use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::errors::{CarDesktopError, Result};
use crate::models::WindowHandle;
pub const DESTRUCTIVE_WORDS: &[&str] = &[
"delete", "quit", "remove", "discard", "drop", "erase", "send", "publish", "submit", "buy",
"pay", "confirm",
];
pub fn destructive_word_in(label: &str) -> Option<&'static str> {
let lowered = label.to_ascii_lowercase();
for word in DESTRUCTIVE_WORDS {
if contains_whole_word(&lowered, word) {
return Some(word);
}
}
None
}
fn contains_whole_word(haystack: &str, needle: &str) -> bool {
if haystack.is_empty() || needle.is_empty() || needle.len() > haystack.len() {
return false;
}
let bytes = haystack.as_bytes();
let n_bytes = needle.as_bytes();
let mut i = 0;
while i + n_bytes.len() <= bytes.len() {
if &bytes[i..i + n_bytes.len()] == n_bytes {
let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
let after_idx = i + n_bytes.len();
let after_ok = after_idx == bytes.len() || !bytes[after_idx].is_ascii_alphanumeric();
if before_ok && after_ok {
return true;
}
}
i += 1;
}
false
}
#[derive(Debug)]
pub struct PerWindowRateLimiter {
state: Mutex<HashMap<WindowHandle, Bucket>>,
}
#[derive(Debug)]
struct Bucket {
tokens: f64,
last_refill: Instant,
}
const BUCKET_CAPACITY: f64 = 8.0;
const REFILL_PER_SEC: f64 = 8.0;
impl Default for PerWindowRateLimiter {
fn default() -> Self {
Self::new()
}
}
impl PerWindowRateLimiter {
pub fn new() -> Self {
Self {
state: Mutex::new(HashMap::new()),
}
}
pub fn acquire(&self, window: WindowHandle) -> Result<()> {
let mut guard = self
.state
.lock()
.expect("PerWindowRateLimiter mutex poisoned");
let now = Instant::now();
let bucket = guard.entry(window).or_insert(Bucket {
tokens: BUCKET_CAPACITY,
last_refill: now,
});
let elapsed = now.saturating_duration_since(bucket.last_refill);
bucket.tokens =
(bucket.tokens + elapsed.as_secs_f64() * REFILL_PER_SEC).min(BUCKET_CAPACITY);
bucket.last_refill = now;
if bucket.tokens < 1.0 {
return Err(CarDesktopError::RateLimited);
}
bucket.tokens -= 1.0;
Ok(())
}
}
pub const POST_EVENT_SETTLE: Duration = Duration::from_millis(8);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn destructive_flags_obvious_hits() {
assert_eq!(destructive_word_in("Delete"), Some("delete"));
assert_eq!(destructive_word_in("Send Now"), Some("send"));
assert_eq!(destructive_word_in("Pay $50"), Some("pay"));
assert_eq!(destructive_word_in("Publish post"), Some("publish"));
assert_eq!(destructive_word_in("CONFIRM"), Some("confirm"));
}
#[test]
fn destructive_ignores_innocuous_labels() {
assert_eq!(destructive_word_in("Save"), None);
assert_eq!(destructive_word_in("Open"), None);
assert_eq!(destructive_word_in("Apply"), None);
assert_eq!(destructive_word_in(""), None);
}
#[test]
fn whole_word_rejects_substring_collisions() {
assert_eq!(destructive_word_in("Undelete"), None);
assert_eq!(destructive_word_in("submitted"), None);
assert_eq!(destructive_word_in("Presend"), None);
assert_eq!(destructive_word_in("Pays"), None);
}
#[test]
fn whole_word_handles_punctuation_boundaries() {
assert!(destructive_word_in("Delete?").is_some());
assert!(destructive_word_in(" delete ").is_some());
assert!(destructive_word_in("Click to delete this item").is_some());
assert!(destructive_word_in("send/publish").is_some());
}
#[test]
fn rate_limiter_allows_bucket_capacity() {
let lim = PerWindowRateLimiter::new();
let w = WindowHandle::new(1, 1);
for _ in 0..8 {
lim.acquire(w).expect("first 8 events must fit in bucket");
}
}
#[test]
fn rate_limiter_rejects_overflow() {
let lim = PerWindowRateLimiter::new();
let w = WindowHandle::new(1, 1);
for _ in 0..8 {
lim.acquire(w).unwrap();
}
assert!(matches!(lim.acquire(w), Err(CarDesktopError::RateLimited)));
}
#[test]
fn rate_limiter_is_per_window() {
let lim = PerWindowRateLimiter::new();
let w1 = WindowHandle::new(1, 1);
let w2 = WindowHandle::new(1, 2);
for _ in 0..8 {
lim.acquire(w1).unwrap();
}
lim.acquire(w2).unwrap();
}
#[test]
fn rate_limiter_refills_after_delay() {
let lim = PerWindowRateLimiter::new();
let w = WindowHandle::new(1, 1);
for _ in 0..8 {
lim.acquire(w).unwrap();
}
std::thread::sleep(std::time::Duration::from_millis(200));
lim.acquire(w).expect("refill must allow one more event");
}
}