1use std::collections::VecDeque;
24use std::time::{Duration, SystemTime};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
35#[non_exhaustive]
36pub enum Severity {
37 Info,
39 Warn,
41 Error,
43}
44
45impl Severity {
46 pub fn default_ttl(self) -> Duration {
48 match self {
49 Severity::Info => Duration::from_secs(2),
50 Severity::Warn => Duration::from_secs(4),
51 Severity::Error => Duration::from_secs(6),
52 }
53 }
54
55 pub fn label(self) -> &'static str {
57 match self {
58 Severity::Info => "INFO",
59 Severity::Warn => "WARN",
60 Severity::Error => "ERROR",
61 }
62 }
63}
64
65#[derive(Debug, Clone)]
71#[non_exhaustive]
72pub struct Holler {
73 pub id: u64,
75 pub ts: SystemTime,
77 pub severity: Severity,
79 pub body: String,
81 pub ttl: Duration,
83 pub count: u32,
86 pub dismissed: bool,
88}
89
90impl Holler {
91 pub fn is_expired(&self, now: SystemTime) -> bool {
103 if self.dismissed {
104 return true;
105 }
106 now.duration_since(self.ts)
107 .map(|elapsed| elapsed >= self.ttl)
108 .unwrap_or(false)
109 }
110
111 pub fn is_fading(&self, now: SystemTime) -> bool {
114 if self.dismissed {
115 return true;
116 }
117 let Ok(elapsed) = now.duration_since(self.ts) else {
118 return false;
119 };
120 if elapsed >= self.ttl {
121 return true;
122 }
123 self.ttl.saturating_sub(elapsed) < Duration::from_millis(500)
124 }
125
126 pub fn display_body(&self) -> String {
128 if self.count > 1 {
129 format!("{} (\u{d7}{})", self.body, self.count)
130 } else {
131 self.body.clone()
132 }
133 }
134}
135
136pub const DEFAULT_HISTORY_CAP: usize = 200;
140
141#[non_exhaustive]
158pub struct HollerBus {
159 pub history: VecDeque<Holler>,
161 pub next_id: u64,
163}
164
165impl Default for HollerBus {
166 fn default() -> Self {
167 Self::new()
168 }
169}
170
171impl HollerBus {
172 pub fn new() -> Self {
174 Self {
175 history: VecDeque::with_capacity(DEFAULT_HISTORY_CAP),
176 next_id: 0,
177 }
178 }
179
180 pub fn push(&mut self, severity: Severity, body: impl Into<String>) -> u64 {
196 let body: String = body.into();
197 let ttl = severity.default_ttl();
198
199 if let Some(last) = self.history.back_mut()
201 && last.body == body
202 && last.severity == severity
203 {
204 last.count = last.count.saturating_add(1);
205 last.ts = SystemTime::now();
207 last.dismissed = false;
208 return last.id;
209 }
210
211 let id = self.next_id;
212 self.next_id += 1;
213
214 let entry = Holler {
215 id,
216 ts: SystemTime::now(),
217 severity,
218 body,
219 ttl,
220 count: 1,
221 dismissed: false,
222 };
223
224 if self.history.len() >= DEFAULT_HISTORY_CAP {
225 self.history.pop_front();
226 }
227 self.history.push_back(entry);
228 id
229 }
230
231 pub fn info(&mut self, body: impl Into<String>) -> u64 {
233 self.push(Severity::Info, body)
234 }
235
236 pub fn warn(&mut self, body: impl Into<String>) -> u64 {
238 self.push(Severity::Warn, body)
239 }
240
241 pub fn error(&mut self, body: impl Into<String>) -> u64 {
243 self.push(Severity::Error, body)
244 }
245
246 pub fn active(&self, now: SystemTime) -> impl Iterator<Item = &Holler> {
250 self.history.iter().filter(move |h| !h.is_expired(now))
251 }
252
253 pub fn history(&self) -> impl Iterator<Item = &Holler> {
255 self.history.iter()
256 }
257
258 pub fn dismiss(&mut self, id: u64) {
261 if let Some(h) = self.history.iter_mut().find(|h| h.id == id) {
262 h.dismissed = true;
263 }
264 }
265
266 pub fn clear_active(&mut self) {
268 let now = SystemTime::now();
269 for h in &mut self.history {
270 if !h.is_expired(now) {
271 h.dismissed = true;
272 }
273 }
274 }
275
276 pub fn last_body(&self) -> Option<&str> {
279 self.history.back().map(|h| h.body.as_str())
280 }
281
282 pub fn last_body_or_empty(&self) -> &str {
285 self.last_body().unwrap_or("")
286 }
287}
288
289#[cfg(test)]
292mod tests {
293 use super::*;
294
295 fn now() -> SystemTime {
296 SystemTime::now()
297 }
298
299 #[test]
300 fn info_warn_error_default_ttl() {
301 assert_eq!(Severity::Info.default_ttl(), Duration::from_secs(2));
302 assert_eq!(Severity::Warn.default_ttl(), Duration::from_secs(4));
303 assert_eq!(Severity::Error.default_ttl(), Duration::from_secs(6));
304 }
305
306 #[test]
307 fn push_returns_incrementing_ids() {
308 let mut bus = HollerBus::new();
309 let a = bus.info("a");
310 let b = bus.info("b");
311 let c = bus.info("c");
312 assert!(a < b && b < c);
313 }
314
315 #[test]
316 fn active_returns_non_expired_entries() {
317 let mut bus = HollerBus::new();
318 bus.info("hello");
319 assert_eq!(bus.active(now()).count(), 1);
320 }
321
322 #[test]
323 fn active_excludes_expired_entries() {
324 let mut bus = HollerBus::new();
325 let entry = Holler {
327 id: 0,
328 ts: SystemTime::UNIX_EPOCH, severity: Severity::Info,
330 body: "old".into(),
331 ttl: Duration::from_secs(1),
332 count: 1,
333 dismissed: false,
334 };
335 bus.history.push_back(entry);
336 bus.next_id = 1;
337 assert_eq!(bus.active(now()).count(), 0);
338 }
339
340 #[test]
341 fn dismiss_removes_from_active() {
342 let mut bus = HollerBus::new();
343 let id = bus.info("test");
344 assert_eq!(bus.active(now()).count(), 1);
345 bus.dismiss(id);
346 assert_eq!(bus.active(now()).count(), 0);
347 }
348
349 #[test]
350 fn dismiss_unknown_id_is_noop() {
351 let mut bus = HollerBus::new();
352 bus.info("ok");
353 bus.dismiss(999); assert_eq!(bus.active(now()).count(), 1);
355 }
356
357 #[test]
358 fn clear_active_dismisses_all() {
359 let mut bus = HollerBus::new();
360 bus.info("a");
361 bus.warn("b");
362 bus.error("c");
363 assert_eq!(bus.active(now()).count(), 3);
364 bus.clear_active();
365 assert_eq!(bus.active(now()).count(), 0);
366 }
367
368 #[test]
369 fn history_returns_all_entries() {
370 let mut bus = HollerBus::new();
371 bus.info("x");
372 bus.warn("y");
373 assert_eq!(bus.history().count(), 2);
375 }
376
377 #[test]
378 fn duplicate_consecutive_collapses_count() {
379 let mut bus = HollerBus::new();
380 let id1 = bus.info("same message");
381 let id2 = bus.info("same message");
382 let id3 = bus.info("same message");
383 assert_eq!(id1, id2);
384 assert_eq!(id2, id3);
385 assert_eq!(bus.history().count(), 1);
386 assert_eq!(bus.history().next().unwrap().count, 3);
387 }
388
389 #[test]
390 fn different_body_does_not_collapse() {
391 let mut bus = HollerBus::new();
392 bus.info("a");
393 bus.info("b");
394 assert_eq!(bus.history().count(), 2);
395 }
396
397 #[test]
398 fn different_severity_same_body_does_not_collapse() {
399 let mut bus = HollerBus::new();
400 bus.info("msg");
401 bus.warn("msg");
402 assert_eq!(bus.history().count(), 2);
403 }
404
405 #[test]
406 fn history_cap_evicts_oldest() {
407 let mut bus = HollerBus::new();
408 for i in 0..DEFAULT_HISTORY_CAP + 10 {
409 bus.info(format!("msg {i}"));
410 }
411 assert_eq!(bus.history().count(), DEFAULT_HISTORY_CAP);
412 let last = bus.history().last().unwrap();
414 assert!(last.body.ends_with(&format!("{}", DEFAULT_HISTORY_CAP + 9)));
415 }
416
417 #[test]
418 fn display_body_shows_count_badge() {
419 let mut bus = HollerBus::new();
420 bus.info("dup");
421 bus.info("dup");
422 let entry = bus.history().next().unwrap();
423 assert_eq!(entry.count, 2);
424 assert!(
425 entry.display_body().contains("(×2)"),
426 "got: {}",
427 entry.display_body()
428 );
429 }
430
431 #[test]
432 fn display_body_no_badge_when_count_one() {
433 let mut bus = HollerBus::new();
434 bus.info("single");
435 let entry = bus.history().next().unwrap();
436 assert_eq!(entry.display_body(), "single");
437 }
438
439 #[test]
440 fn is_fading_false_when_fresh() {
441 let mut bus = HollerBus::new();
442 bus.info("fresh");
443 let h = bus.history().next().unwrap();
444 assert!(!h.is_fading(now()));
445 }
446
447 #[test]
448 fn is_expired_false_when_fresh() {
449 let mut bus = HollerBus::new();
450 bus.info("fresh");
451 let h = bus.history().next().unwrap();
452 assert!(!h.is_expired(now()));
453 }
454
455 #[test]
456 fn is_expired_true_when_dismissed() {
457 let mut bus = HollerBus::new();
458 let id = bus.info("x");
459 bus.dismiss(id);
460 let h = bus.history().next().unwrap();
461 assert!(h.is_expired(now()));
462 }
463
464 #[test]
465 fn severity_labels() {
466 assert_eq!(Severity::Info.label(), "INFO");
467 assert_eq!(Severity::Warn.label(), "WARN");
468 assert_eq!(Severity::Error.label(), "ERROR");
469 }
470
471 #[test]
472 fn last_body_or_empty_empty_bus() {
473 let bus = HollerBus::new();
474 assert_eq!(bus.last_body_or_empty(), "");
475 }
476
477 #[test]
478 fn last_body_returns_most_recent() {
479 let mut bus = HollerBus::new();
480 bus.info("first");
481 bus.info("second");
482 assert_eq!(bus.last_body(), Some("second"));
483 }
484
485 #[test]
486 fn default_constructs_empty_bus() {
487 let bus = HollerBus::default();
488 assert_eq!(bus.history().count(), 0);
489 assert_eq!(bus.active(now()).count(), 0);
490 }
491}