Skip to main content

atman_runtime/
notify.rs

1//! Unified notification system for atman.
2//!
3//! ```ignore
4//! use atman_runtime::notify::{self, NotifyLocation, NotifyStack};
5//!
6//! notify!(info, "copied {} chars to clipboard", n);
7//! notify!(warn, location = Inline, stack = dedupe("key", 30_000), "index unavailable");
8//! notify!(success, location = Toast, "todo marked done");
9//! notify!(debug, target: "auto_snapshot", "{} @ {}", name, rev);
10//! ```
11
12use serde::{Deserialize, Serialize};
13use std::sync::{Arc, RwLock};
14use std::time::{Duration, Instant};
15
16// ── Level ──────────────────────────────────────────────────────────
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub enum NotifyLevel {
20    Error,
21    Warn,
22    Info,
23    Success,
24    Debug,
25}
26
27impl NotifyLevel {
28    pub fn as_str(&self) -> &'static str {
29        match self {
30            Self::Error => "ERROR",
31            Self::Warn => "WARN",
32            Self::Info => "INFO",
33            Self::Success => "SUCCESS",
34            Self::Debug => "DEBUG",
35        }
36    }
37}
38
39// ── Location ───────────────────────────────────────────────────────
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub enum NotifyLocation {
43    /// Inline in the transcript / event stream; persists in scrollback.
44    Inline,
45    /// Top-right corner toast; auto-dismisses.
46    Toast,
47    /// Status bar single slot; overwrites on same key.
48    Status,
49    /// Modal dialog; requires user dismissal.
50    Modal,
51    /// Non-TUI stdout.
52    Stdout,
53    /// Non-TUI stderr.
54    Stderr,
55    /// Log only; never shown to the user.
56    Log,
57}
58
59// ── Lifecycle ───────────────────────────────────────────────────────
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62pub enum NotifyLifecycle {
63    /// Persist in transcript / output items.
64    Persistent,
65    /// Auto-dismiss after a duration.
66    Ttl(Duration),
67    /// User can dismiss manually (Esc / click).
68    Dismissible,
69    /// Overwrite the previous notification with the same key.
70    UntilReplaced,
71}
72
73// ── Stack strategy ─────────────────────────────────────────────────
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub enum NotifyStack {
77    /// Always append a new entry.
78    Append,
79    /// Replace the previous notification with the same key.
80    Replace { key: String },
81    /// Suppress duplicates of the same key within a time window.
82    Dedupe { key: String, window: Duration },
83    /// Merge into a single entry with a count; useful for bursty errors.
84    MergeCount { key: String, window: Duration },
85    /// Keep only the latest message for a given key.
86    Coalesce { key: String },
87}
88
89// ── Notification ───────────────────────────────────────────────────
90
91#[derive(Debug, Clone)]
92pub struct Notification {
93    pub level: NotifyLevel,
94    pub location: NotifyLocation,
95    pub lifecycle: NotifyLifecycle,
96    pub stack: NotifyStack,
97    pub message: String,
98    pub target: Option<String>,
99    pub created_at: Instant,
100}
101
102impl Notification {
103    pub fn new(level: NotifyLevel, message: impl Into<String>) -> Self {
104        Self {
105            level,
106            location: NotifyLocation::Inline,
107            lifecycle: NotifyLifecycle::Persistent,
108            stack: NotifyStack::Append,
109            message: message.into(),
110            target: None,
111            created_at: Instant::now(),
112        }
113    }
114
115    pub fn with_location(mut self, location: NotifyLocation) -> Self {
116        self.location = location;
117        self
118    }
119
120    pub fn with_lifecycle(mut self, lifecycle: NotifyLifecycle) -> Self {
121        self.lifecycle = lifecycle;
122        self
123    }
124
125    pub fn with_stack(mut self, stack: NotifyStack) -> Self {
126        self.stack = stack;
127        self
128    }
129
130    pub fn with_target(mut self, target: impl Into<String>) -> Self {
131        self.target = Some(target.into());
132        self
133    }
134
135    /// Builder helper: make this a toast with default TTL.
136    pub fn toast(self) -> Self {
137        let ttl = match self.level {
138            NotifyLevel::Success => Duration::from_secs(2),
139            NotifyLevel::Info => Duration::from_secs(3),
140            NotifyLevel::Warn => Duration::from_secs(5),
141            NotifyLevel::Error => Duration::from_secs(8),
142            NotifyLevel::Debug => Duration::from_secs(2),
143        };
144        self.with_location(NotifyLocation::Toast)
145            .with_lifecycle(NotifyLifecycle::Ttl(ttl))
146    }
147}
148
149// ── Sink trait ─────────────────────────────────────────────────────
150
151pub trait NotifySink: Send + Sync {
152    fn emit(&self, note: &Notification);
153}
154
155// ── Global notifier ────────────────────────────────────────────────
156
157static GLOBAL_SINK: RwLock<Option<Arc<dyn NotifySink>>> = RwLock::new(None);
158
159/// Separate log sink — always active, survives ScopedSink swaps.
160static LOG_SINK: RwLock<Option<Arc<LogSink>>> = RwLock::new(None);
161
162pub fn install(sink: Arc<dyn NotifySink>) {
163    let log = LOG_SINK.read().unwrap().clone();
164    let composite: Arc<dyn NotifySink> = match log {
165        Some(ls) => Arc::new(CompositeSink::new(vec![sink, ls])),
166        None => sink,
167    };
168    *GLOBAL_SINK.write().unwrap() = Some(composite);
169}
170
171pub fn install_log(ls: Arc<LogSink>) {
172    *LOG_SINK.write().unwrap() = Some(ls.clone());
173    // Re-install to wrap existing user sink with the new log sink.
174    let existing = GLOBAL_SINK.write().unwrap().take();
175    let user_sink: Arc<dyn NotifySink> = existing.unwrap_or_else(|| Arc::new(NoopSink));
176    *GLOBAL_SINK.write().unwrap() = Some(Arc::new(CompositeSink::new(vec![user_sink, ls])));
177}
178
179pub fn log_sink() -> Option<Arc<LogSink>> {
180    LOG_SINK.read().unwrap().clone()
181}
182
183pub fn global() -> Arc<dyn NotifySink> {
184    GLOBAL_SINK
185        .read()
186        .unwrap()
187        .clone()
188        .unwrap_or_else(|| Arc::new(NoopSink))
189}
190
191/// RAII guard: installs a sink and restores the previous one on drop.
192pub struct ScopedSink {
193    prev: Option<Arc<dyn NotifySink>>,
194}
195
196impl ScopedSink {
197    /// Install NoopSink for TUI mode — prevents stderr from leaking into raw terminal.
198    pub fn tui() -> Self {
199        let mut sink = GLOBAL_SINK.write().unwrap();
200        let prev = sink.take();
201        // Replace CLI sink with Noop but keep LogSink active.
202        let log = LOG_SINK.read().unwrap().clone();
203        let replacement: Arc<dyn NotifySink> = match log {
204            Some(ls) => Arc::new(CompositeSink::new(vec![Arc::new(NoopSink), ls])),
205            None => Arc::new(NoopSink),
206        };
207        *sink = Some(replacement);
208        Self { prev }
209    }
210
211    /// Install any sink (wrapping LogSink) and restore the previous on drop.
212    pub fn replace_with(user: Arc<dyn NotifySink>) -> Self {
213        let mut sink = GLOBAL_SINK.write().unwrap();
214        let prev = sink.take();
215        let log = LOG_SINK.read().unwrap().clone();
216        let replacement: Arc<dyn NotifySink> = match log {
217            Some(ls) => Arc::new(CompositeSink::new(vec![user, ls])),
218            None => user,
219        };
220        *sink = Some(replacement);
221        Self { prev }
222    }
223}
224
225impl Drop for ScopedSink {
226    fn drop(&mut self) {
227        let mut sink = GLOBAL_SINK.write().unwrap();
228        *sink = self.prev.take();
229    }
230}
231
232// ── Built-in sinks ─────────────────────────────────────────────────
233
234pub struct NoopSink;
235impl NotifySink for NoopSink {
236    fn emit(&self, _note: &Notification) {}
237}
238
239/// Sink that prints to stdout/stderr based on level.
240pub struct CliSink;
241impl NotifySink for CliSink {
242    fn emit(&self, note: &Notification) {
243        let prefix = format!(
244            "[atman] {}",
245            if let Some(ref t) = note.target {
246                format!("[{}] ", t)
247            } else {
248                String::new()
249            }
250        );
251        let line = format!("{}{}", prefix, note.message);
252        match note.level {
253            NotifyLevel::Error | NotifyLevel::Warn => eprintln!("{line}"),
254            NotifyLevel::Info | NotifyLevel::Success | NotifyLevel::Debug => println!("{line}"),
255        }
256    }
257}
258
259/// Sink that collects notifications into a shared buffer for rendering
260/// as toasts in the boot animation. Does not print to stdout/stderr.
261pub struct ToastCollector {
262    notifications: Arc<std::sync::Mutex<Vec<Notification>>>,
263}
264
265impl ToastCollector {
266    pub fn new() -> (Self, Arc<std::sync::Mutex<Vec<Notification>>>) {
267        let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
268        (
269            Self {
270                notifications: buf.clone(),
271            },
272            buf,
273        )
274    }
275}
276
277impl NotifySink for ToastCollector {
278    fn emit(&self, note: &Notification) {
279        if let Ok(mut buf) = self.notifications.lock() {
280            // Apply dedupe: replace existing with same target+message pattern
281            if let Some(ref target) = note.target {
282                if let Some(prev) = buf
283                    .iter_mut()
284                    .find(|n| n.target.as_deref() == Some(target.as_str()))
285                {
286                    *prev = note.clone();
287                    return;
288                }
289            }
290            buf.push(note.clone());
291        }
292    }
293}
294
295impl Clone for ToastCollector {
296    fn clone(&self) -> Self {
297        Self {
298            notifications: self.notifications.clone(),
299        }
300    }
301}
302
303/// Sink that appends JSONL lines to a session log file.
304pub struct LogSink {
305    base_dir: std::path::PathBuf,
306    session_id: std::sync::RwLock<Option<String>>,
307}
308
309impl LogSink {
310    pub fn new(base_dir: std::path::PathBuf) -> Self {
311        Self {
312            base_dir,
313            session_id: std::sync::RwLock::new(None),
314        }
315    }
316
317    pub fn set_session_id(&self, sid: Option<String>) {
318        *self.session_id.write().unwrap() = sid;
319    }
320
321    fn log_path(&self) -> std::path::PathBuf {
322        let sid = self.session_id.read().unwrap();
323        if let Some(ref id) = *sid {
324            self.base_dir.join("sessions").join(id).join("notify.log")
325        } else {
326            let fallback = self.base_dir.join("logs");
327            let _ = std::fs::create_dir_all(&fallback);
328            fallback.join("daemon.log")
329        }
330    }
331}
332
333impl NotifySink for LogSink {
334    fn emit(&self, note: &Notification) {
335        use std::io::Write;
336        let path = self.log_path();
337        if let Some(parent) = path.parent() {
338            let _ = std::fs::create_dir_all(parent);
339        }
340        let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
341        let target = note.target.as_deref().unwrap_or("-");
342        let line = serde_json::json!({
343            "ts": ts,
344            "level": note.level.as_str(),
345            "target": target,
346            "msg": note.message,
347        });
348        if let Ok(mut f) = std::fs::OpenOptions::new()
349            .create(true)
350            .append(true)
351            .open(&path)
352        {
353            let _ = writeln!(f, "{line}");
354        }
355    }
356}
357
358/// Fan-out to multiple sinks.
359pub struct CompositeSink {
360    sinks: Vec<Arc<dyn NotifySink>>,
361}
362
363impl CompositeSink {
364    pub fn new(sinks: Vec<Arc<dyn NotifySink>>) -> Self {
365        Self { sinks }
366    }
367}
368
369impl NotifySink for CompositeSink {
370    fn emit(&self, note: &Notification) {
371        for s in &self.sinks {
372            s.emit(note);
373        }
374    }
375}
376
377// ── Convenience constructors for NotifyStack ─────────────────────────
378
379pub fn dedupe(key: impl Into<String>, window_ms: u64) -> NotifyStack {
380    NotifyStack::Dedupe {
381        key: key.into(),
382        window: Duration::from_millis(window_ms),
383    }
384}
385
386pub fn replace(key: impl Into<String>) -> NotifyStack {
387    NotifyStack::Replace { key: key.into() }
388}
389
390pub fn merge_count(key: impl Into<String>, window_ms: u64) -> NotifyStack {
391    NotifyStack::MergeCount {
392        key: key.into(),
393        window: Duration::from_millis(window_ms),
394    }
395}
396
397pub fn coalesce(key: impl Into<String>) -> NotifyStack {
398    NotifyStack::Coalesce { key: key.into() }
399}
400
401// ── notify! macro ──────────────────────────────────────────────────
402
403/// Emit a notification through the global notifier.
404///
405/// # Syntax
406///
407/// ```ignore
408/// notify!(level, "message {}", arg);
409/// notify!(level, location = Toast, "message");
410/// notify!(level, location = Status, stack = replace("key"), "message");
411/// notify!(debug, target: "auto_snapshot", "snapshot {}", name);
412/// ```
413#[macro_export]
414macro_rules! notify {
415    (@level error) => { $crate::notify::NotifyLevel::Error };
416    (@level warn) => { $crate::notify::NotifyLevel::Warn };
417    (@level info) => { $crate::notify::NotifyLevel::Info };
418    (@level success) => { $crate::notify::NotifyLevel::Success };
419    (@level debug) => { $crate::notify::NotifyLevel::Debug };
420
421    // Level + location + stack: notify!(level, location = Loc, stack = fn("key"), "msg", args...)
422    ($level:ident, location = $loc:ident, stack = $stack:ident ($($sk:expr),+), $msg:expr $(, $arg:expr)* $(,)?) => {{
423        $crate::notify::global().emit(
424            &$crate::notify::Notification::new(
425                $crate::notify!(@level $level),
426                format!($msg $(, $arg)*),
427            )
428            .with_location($crate::notify::NotifyLocation::$loc)
429            .with_stack($crate::notify::$stack($($sk),+)),
430        );
431    }};
432
433    // Level + location + lifecycle: notify!(level, location = Loc, lifecycle = Lc, "msg")
434    ($level:ident, location = $loc:ident, lifecycle = $lc:ident, $msg:expr $(, $arg:expr)* $(,)?) => {{
435        $crate::notify::global().emit(
436            &$crate::notify::Notification::new(
437                $crate::notify!(@level $level),
438                format!($msg $(, $arg)*),
439            )
440            .with_location($crate::notify::NotifyLocation::$loc)
441            .with_lifecycle($crate::notify::NotifyLifecycle::$lc),
442        );
443    }};
444
445    // Level + location: notify!(level, location = Loc, "message", args...)
446    ($level:ident, location = $loc:ident, $msg:expr $(, $arg:expr)* $(,)?) => {{
447        $crate::notify::global().emit(
448            &$crate::notify::Notification::new(
449                $crate::notify!(@level $level),
450                format!($msg $(, $arg)*),
451            )
452            .with_location($crate::notify::NotifyLocation::$loc),
453        );
454    }};
455
456    // Debug + target: notify!(debug, target: "target_name", "msg", args...)
457    ($level:ident, target: $target:expr, $msg:expr $(, $arg:expr)* $(,)?) => {{
458        $crate::notify::global().emit(
459            &$crate::notify::Notification::new(
460                $crate::notify!(@level $level),
461                format!($msg $(, $arg)*),
462            )
463            .with_target($target)
464            .with_location($crate::notify::NotifyLocation::Log),
465        );
466    }};
467
468    // Level only: notify!(level, "message", args...)
469    ($level:ident, $msg:expr $(, $arg:expr)* $(,)?) => {{
470        $crate::notify::global().emit(&$crate::notify::Notification::new(
471            $crate::notify!(@level $level),
472            format!($msg $(, $arg)*),
473        ));
474    }};
475
476    // Bare: notify!("message", args...)
477    ($msg:expr $(, $arg:expr)* $(,)?) => {{
478        $crate::notify::global().emit(&$crate::notify::Notification::new(
479            $crate::notify::NotifyLevel::Info,
480            format!($msg $(, $arg)*),
481        ));
482    }};
483}
484
485pub use notify;
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn notification_builder_defaults() {
493        let n = Notification::new(NotifyLevel::Warn, "test message");
494        assert_eq!(n.level, NotifyLevel::Warn);
495        assert_eq!(n.location, NotifyLocation::Inline);
496        assert!(matches!(n.lifecycle, NotifyLifecycle::Persistent));
497        assert!(matches!(n.stack, NotifyStack::Append));
498        assert_eq!(n.message, "test message");
499    }
500
501    #[test]
502    fn toast_ttl_by_level() {
503        let n = Notification::new(NotifyLevel::Success, "ok").toast();
504        assert_eq!(n.location, NotifyLocation::Toast);
505        assert_eq!(n.lifecycle, NotifyLifecycle::Ttl(Duration::from_secs(2)));
506
507        let n = Notification::new(NotifyLevel::Error, "fail").toast();
508        assert_eq!(n.lifecycle, NotifyLifecycle::Ttl(Duration::from_secs(8)));
509    }
510
511    #[test]
512    fn stack_helpers() {
513        assert_eq!(
514            dedupe("my-key", 5000),
515            NotifyStack::Dedupe {
516                key: "my-key".into(),
517                window: Duration::from_millis(5000),
518            }
519        );
520        assert_eq!(replace("slot"), NotifyStack::Replace { key: "slot".into() });
521        assert_eq!(
522            merge_count("lag", 300),
523            NotifyStack::MergeCount {
524                key: "lag".into(),
525                window: Duration::from_millis(300),
526            }
527        );
528    }
529
530    #[test]
531    fn cli_sink_routes_by_level() {
532        let n = Notification::new(NotifyLevel::Error, "boom");
533        // CliSink sends Error to stderr — this test just ensures no panic.
534        let sink = CliSink;
535        sink.emit(&n);
536    }
537
538    #[test]
539    fn composite_fanout() {
540        let sink = CompositeSink::new(vec![Arc::new(NoopSink), Arc::new(CliSink)]);
541        let n = Notification::new(NotifyLevel::Info, "hello");
542        sink.emit(&n); // no panic
543    }
544
545    #[test]
546    fn level_as_str() {
547        assert_eq!(NotifyLevel::Error.as_str(), "ERROR");
548        assert_eq!(NotifyLevel::Success.as_str(), "SUCCESS");
549        assert_eq!(NotifyLevel::Debug.as_str(), "DEBUG");
550    }
551}