Skip to main content

hjkl_holler/
lib.rs

1//! Renderer-agnostic notification bus: severity-tagged toasts with a
2//! ring-buffer history and auto-dismiss TTLs.
3//!
4//! No TUI or renderer types are referenced here — the ratatui adapter lives
5//! in `hjkl-holler-tui`.
6//!
7//! # Quick start
8//!
9//! ```rust
10//! use hjkl_holler::{HollerBus, Severity};
11//! use std::time::SystemTime;
12//!
13//! let mut bus = HollerBus::new();
14//! bus.info("file saved");
15//! bus.warn("trailing whitespace");
16//! bus.error("E45: readonly option is set");
17//!
18//! let now = SystemTime::now();
19//! let active: Vec<_> = bus.active(now).collect();
20//! assert_eq!(active.len(), 3);
21//! ```
22
23use std::collections::VecDeque;
24use std::time::{Duration, SystemTime};
25
26// ── Severity ──────────────────────────────────────────────────────────────────
27
28/// Toast severity level.
29///
30/// Controls the TTL (how long the toast stays in the active stack) and the
31/// border colour used by the TUI renderer.
32///
33/// `#[non_exhaustive]` — new variants may be added in minor releases.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
35#[non_exhaustive]
36pub enum Severity {
37    /// Neutral acknowledgement or echo. TTL 2 s.
38    Info,
39    /// Non-fatal warning. TTL 4 s.
40    Warn,
41    /// Hard error or failure. TTL 6 s.
42    Error,
43}
44
45impl Severity {
46    /// Default TTL for this severity level.
47    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    /// Short uppercase label for display.
56    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// ── Holler ────────────────────────────────────────────────────────────────────
66
67/// A single notification entry.
68///
69/// `#[non_exhaustive]` — new fields may be added in minor releases.
70#[derive(Debug, Clone)]
71#[non_exhaustive]
72pub struct Holler {
73    /// Monotonically increasing identifier within one `HollerBus` instance.
74    pub id: u64,
75    /// Wall-clock time when this notification was pushed.
76    pub ts: SystemTime,
77    /// Severity level.
78    pub severity: Severity,
79    /// Notification body text.
80    pub body: String,
81    /// How long this notification stays in the active (visible) stack.
82    pub ttl: Duration,
83    /// How many times a duplicate consecutive message was suppressed.
84    /// `1` means this is the original (no duplicates collapsed into it yet).
85    pub count: u32,
86    /// Whether the user explicitly dismissed this entry before its TTL expired.
87    pub dismissed: bool,
88}
89
90impl Holler {
91    /// Returns `true` when this notification has expired or been dismissed.
92    ///
93    /// ```rust
94    /// use hjkl_holler::HollerBus;
95    /// use std::time::SystemTime;
96    ///
97    /// let mut bus = HollerBus::new();
98    /// bus.info("test");
99    /// let h = bus.history().next().unwrap();
100    /// assert!(!h.is_expired(SystemTime::now()));
101    /// ```
102    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    /// Returns `true` when this notification is within the last 500 ms of its
112    /// TTL — used by the renderer to apply a soft-fade (`Modifier::DIM`).
113    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    /// Formatted body including duplicate-count badge when > 1.
127    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
136// ── HollerBus ─────────────────────────────────────────────────────────────────
137
138/// Maximum number of entries retained in the history ring.
139pub const DEFAULT_HISTORY_CAP: usize = 200;
140
141/// Notification bus: push messages in, query active/history out.
142///
143/// `#[non_exhaustive]` — new fields may be added in minor releases.
144///
145/// # Example
146///
147/// ```rust
148/// use hjkl_holler::HollerBus;
149/// use std::time::SystemTime;
150///
151/// let mut bus = HollerBus::new();
152/// let id = bus.info("saved");
153/// assert_eq!(bus.active(SystemTime::now()).count(), 1);
154/// bus.dismiss(id);
155/// assert_eq!(bus.active(SystemTime::now()).count(), 0);
156/// ```
157#[non_exhaustive]
158pub struct HollerBus {
159    /// Ring-buffer of all pushed notifications (capped at [`DEFAULT_HISTORY_CAP`]).
160    pub history: VecDeque<Holler>,
161    /// Counter for the next notification id.
162    pub next_id: u64,
163}
164
165impl Default for HollerBus {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171impl HollerBus {
172    /// Create a new empty bus with the default history capacity.
173    pub fn new() -> Self {
174        Self {
175            history: VecDeque::with_capacity(DEFAULT_HISTORY_CAP),
176            next_id: 0,
177        }
178    }
179
180    /// Push a notification with an explicit severity and body.
181    ///
182    /// Consecutive duplicate bodies (same body as the most recent entry) are
183    /// collapsed: the existing entry's count is incremented instead of
184    /// inserting a new one. Returns the id of the affected entry.
185    ///
186    /// ```rust
187    /// use hjkl_holler::{HollerBus, Severity};
188    ///
189    /// let mut bus = HollerBus::new();
190    /// let id1 = bus.push(Severity::Info, "same");
191    /// let id2 = bus.push(Severity::Info, "same");
192    /// assert_eq!(id1, id2, "duplicate collapses into first entry");
193    /// assert_eq!(bus.history().next().unwrap().count, 2);
194    /// ```
195    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        // Throttle: collapse consecutive duplicate body + severity pairs.
200        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            // Reset ts and dismissed so the toast reappears.
206            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    /// Push an `Info` notification (TTL 2 s).
232    pub fn info(&mut self, body: impl Into<String>) -> u64 {
233        self.push(Severity::Info, body)
234    }
235
236    /// Push a `Warn` notification (TTL 4 s).
237    pub fn warn(&mut self, body: impl Into<String>) -> u64 {
238        self.push(Severity::Warn, body)
239    }
240
241    /// Push an `Error` notification (TTL 6 s).
242    pub fn error(&mut self, body: impl Into<String>) -> u64 {
243        self.push(Severity::Error, body)
244    }
245
246    /// Iterator over notifications whose TTL has not yet expired, in
247    /// push order (oldest first). Passes `now` to each entry's
248    /// [`Holler::is_expired`] check so callers control the clock.
249    pub fn active(&self, now: SystemTime) -> impl Iterator<Item = &Holler> {
250        self.history.iter().filter(move |h| !h.is_expired(now))
251    }
252
253    /// Iterator over all entries in the history ring, oldest first.
254    pub fn history(&self) -> impl Iterator<Item = &Holler> {
255        self.history.iter()
256    }
257
258    /// Explicitly dismiss the notification with the given id.
259    /// No-op if the id is not found.
260    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    /// Dismiss all currently-active (non-expired, non-dismissed) notifications.
267    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    /// Return the body of the most recent notification, or `None` if the
277    /// history is empty. Used in tests to mirror the old `status_message` checks.
278    pub fn last_body(&self) -> Option<&str> {
279        self.history.back().map(|h| h.body.as_str())
280    }
281
282    /// Return the body of the most recent notification, or `""`.
283    /// Convenience for test assertions.
284    pub fn last_body_or_empty(&self) -> &str {
285        self.last_body().unwrap_or("")
286    }
287}
288
289// ── Tests ─────────────────────────────────────────────────────────────────────
290
291#[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        // Manually insert a past-TTL entry.
326        let entry = Holler {
327            id: 0,
328            ts: SystemTime::UNIX_EPOCH, // far in the past
329            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); // no such id
354        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        // history includes all, not just active
374        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        // Oldest entries were evicted; most recent should be last.
413        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}