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        if matches!(note.location, NotifyLocation::Log) {
253            eprintln!("{line}");
254            return;
255        }
256        match note.level {
257            NotifyLevel::Error | NotifyLevel::Warn => eprintln!("{line}"),
258            NotifyLevel::Info | NotifyLevel::Success | NotifyLevel::Debug => println!("{line}"),
259        }
260    }
261}
262
263/// Sink that collects notifications into a shared buffer for rendering
264/// as toasts in the boot animation. Does not print to stdout/stderr.
265pub struct ToastCollector {
266    notifications: Arc<std::sync::Mutex<Vec<Notification>>>,
267}
268
269impl ToastCollector {
270    pub fn new() -> (Self, Arc<std::sync::Mutex<Vec<Notification>>>) {
271        let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
272        (
273            Self {
274                notifications: buf.clone(),
275            },
276            buf,
277        )
278    }
279}
280
281impl NotifySink for ToastCollector {
282    fn emit(&self, note: &Notification) {
283        if let Ok(mut buf) = self.notifications.lock() {
284            // Apply dedupe: replace existing with same target+message pattern
285            if let Some(ref target) = note.target {
286                if let Some(prev) = buf
287                    .iter_mut()
288                    .find(|n| n.target.as_deref() == Some(target.as_str()))
289                {
290                    *prev = note.clone();
291                    return;
292                }
293            }
294            buf.push(note.clone());
295        }
296    }
297}
298
299impl Clone for ToastCollector {
300    fn clone(&self) -> Self {
301        Self {
302            notifications: self.notifications.clone(),
303        }
304    }
305}
306
307/// Sink that appends JSONL lines to a session log file.
308pub struct LogSink {
309    base_dir: std::path::PathBuf,
310    session_id: std::sync::RwLock<Option<String>>,
311}
312
313impl LogSink {
314    pub fn new(base_dir: std::path::PathBuf) -> Self {
315        Self {
316            base_dir,
317            session_id: std::sync::RwLock::new(None),
318        }
319    }
320
321    pub fn set_session_id(&self, sid: Option<String>) {
322        *self.session_id.write().unwrap() = sid;
323    }
324
325    fn log_path(&self) -> std::path::PathBuf {
326        let sid = self.session_id.read().unwrap();
327        if let Some(ref id) = *sid {
328            self.base_dir.join("sessions").join(id).join("notify.log")
329        } else {
330            let fallback = self.base_dir.join("logs");
331            let _ = std::fs::create_dir_all(&fallback);
332            fallback.join("daemon.log")
333        }
334    }
335}
336
337impl NotifySink for LogSink {
338    fn emit(&self, note: &Notification) {
339        use std::io::Write;
340        let path = self.log_path();
341        if let Some(parent) = path.parent() {
342            let _ = std::fs::create_dir_all(parent);
343        }
344        let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
345        let target = note.target.as_deref().unwrap_or("-");
346        let line = serde_json::json!({
347            "ts": ts,
348            "level": note.level.as_str(),
349            "target": target,
350            "msg": note.message,
351        });
352        if let Ok(mut f) = std::fs::OpenOptions::new()
353            .create(true)
354            .append(true)
355            .open(&path)
356        {
357            let _ = writeln!(f, "{line}");
358        }
359    }
360}
361
362/// Fan-out to multiple sinks.
363pub struct CompositeSink {
364    sinks: Vec<Arc<dyn NotifySink>>,
365}
366
367impl CompositeSink {
368    pub fn new(sinks: Vec<Arc<dyn NotifySink>>) -> Self {
369        Self { sinks }
370    }
371}
372
373impl NotifySink for CompositeSink {
374    fn emit(&self, note: &Notification) {
375        for s in &self.sinks {
376            s.emit(note);
377        }
378    }
379}
380
381// ── Convenience constructors for NotifyStack ─────────────────────────
382
383pub fn dedupe(key: impl Into<String>, window_ms: u64) -> NotifyStack {
384    NotifyStack::Dedupe {
385        key: key.into(),
386        window: Duration::from_millis(window_ms),
387    }
388}
389
390pub fn replace(key: impl Into<String>) -> NotifyStack {
391    NotifyStack::Replace { key: key.into() }
392}
393
394pub fn merge_count(key: impl Into<String>, window_ms: u64) -> NotifyStack {
395    NotifyStack::MergeCount {
396        key: key.into(),
397        window: Duration::from_millis(window_ms),
398    }
399}
400
401pub fn coalesce(key: impl Into<String>) -> NotifyStack {
402    NotifyStack::Coalesce { key: key.into() }
403}
404
405// ── notify! macro ──────────────────────────────────────────────────
406
407/// Emit a notification through the global notifier.
408///
409/// # Syntax
410///
411/// ```ignore
412/// notify!(level, "message {}", arg);
413/// notify!(level, location = Toast, "message");
414/// notify!(level, location = Status, stack = replace("key"), "message");
415/// notify!(debug, target: "auto_snapshot", "snapshot {}", name);
416/// ```
417#[macro_export]
418macro_rules! notify {
419    (@level error) => { $crate::notify::NotifyLevel::Error };
420    (@level warn) => { $crate::notify::NotifyLevel::Warn };
421    (@level info) => { $crate::notify::NotifyLevel::Info };
422    (@level success) => { $crate::notify::NotifyLevel::Success };
423    (@level debug) => { $crate::notify::NotifyLevel::Debug };
424
425    // Level + location + stack: notify!(level, location = Loc, stack = fn("key"), "msg", args...)
426    ($level:ident, location = $loc:ident, stack = $stack:ident ($($sk:expr),+), $msg:expr $(, $arg:expr)* $(,)?) => {{
427        $crate::notify::global().emit(
428            &$crate::notify::Notification::new(
429                $crate::notify!(@level $level),
430                format!($msg $(, $arg)*),
431            )
432            .with_location($crate::notify::NotifyLocation::$loc)
433            .with_stack($crate::notify::$stack($($sk),+)),
434        );
435    }};
436
437    // Level + location + lifecycle: notify!(level, location = Loc, lifecycle = Lc, "msg")
438    ($level:ident, location = $loc:ident, lifecycle = $lc:ident, $msg:expr $(, $arg:expr)* $(,)?) => {{
439        $crate::notify::global().emit(
440            &$crate::notify::Notification::new(
441                $crate::notify!(@level $level),
442                format!($msg $(, $arg)*),
443            )
444            .with_location($crate::notify::NotifyLocation::$loc)
445            .with_lifecycle($crate::notify::NotifyLifecycle::$lc),
446        );
447    }};
448
449    // Level + location: notify!(level, location = Loc, "message", args...)
450    ($level:ident, location = $loc:ident, $msg:expr $(, $arg:expr)* $(,)?) => {{
451        $crate::notify::global().emit(
452            &$crate::notify::Notification::new(
453                $crate::notify!(@level $level),
454                format!($msg $(, $arg)*),
455            )
456            .with_location($crate::notify::NotifyLocation::$loc),
457        );
458    }};
459
460    // Debug + target: notify!(debug, target: "target_name", "msg", args...)
461    ($level:ident, target: $target:expr, $msg:expr $(, $arg:expr)* $(,)?) => {{
462        $crate::notify::global().emit(
463            &$crate::notify::Notification::new(
464                $crate::notify!(@level $level),
465                format!($msg $(, $arg)*),
466            )
467            .with_target($target)
468            .with_location($crate::notify::NotifyLocation::Log),
469        );
470    }};
471
472    // Level only: notify!(level, "message", args...)
473    ($level:ident, $msg:expr $(, $arg:expr)* $(,)?) => {{
474        $crate::notify::global().emit(&$crate::notify::Notification::new(
475            $crate::notify!(@level $level),
476            format!($msg $(, $arg)*),
477        ));
478    }};
479
480    // Bare: notify!("message", args...)
481    ($msg:expr $(, $arg:expr)* $(,)?) => {{
482        $crate::notify::global().emit(&$crate::notify::Notification::new(
483            $crate::notify::NotifyLevel::Info,
484            format!($msg $(, $arg)*),
485        ));
486    }};
487}
488
489pub use notify;
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    #[test]
496    fn notification_builder_defaults() {
497        let n = Notification::new(NotifyLevel::Warn, "test message");
498        assert_eq!(n.level, NotifyLevel::Warn);
499        assert_eq!(n.location, NotifyLocation::Inline);
500        assert!(matches!(n.lifecycle, NotifyLifecycle::Persistent));
501        assert!(matches!(n.stack, NotifyStack::Append));
502        assert_eq!(n.message, "test message");
503    }
504
505    #[test]
506    fn toast_ttl_by_level() {
507        let n = Notification::new(NotifyLevel::Success, "ok").toast();
508        assert_eq!(n.location, NotifyLocation::Toast);
509        assert_eq!(n.lifecycle, NotifyLifecycle::Ttl(Duration::from_secs(2)));
510
511        let n = Notification::new(NotifyLevel::Error, "fail").toast();
512        assert_eq!(n.lifecycle, NotifyLifecycle::Ttl(Duration::from_secs(8)));
513    }
514
515    #[test]
516    fn stack_helpers() {
517        assert_eq!(
518            dedupe("my-key", 5000),
519            NotifyStack::Dedupe {
520                key: "my-key".into(),
521                window: Duration::from_millis(5000),
522            }
523        );
524        assert_eq!(replace("slot"), NotifyStack::Replace { key: "slot".into() });
525        assert_eq!(
526            merge_count("lag", 300),
527            NotifyStack::MergeCount {
528                key: "lag".into(),
529                window: Duration::from_millis(300),
530            }
531        );
532    }
533
534    #[test]
535    fn cli_sink_routes_by_level() {
536        let n = Notification::new(NotifyLevel::Error, "boom");
537        // CliSink sends Error to stderr — this test just ensures no panic.
538        let sink = CliSink;
539        sink.emit(&n);
540    }
541
542    #[test]
543    fn composite_fanout() {
544        let sink = CompositeSink::new(vec![Arc::new(NoopSink), Arc::new(CliSink)]);
545        let n = Notification::new(NotifyLevel::Info, "hello");
546        sink.emit(&n); // no panic
547    }
548
549    #[test]
550    fn level_as_str() {
551        assert_eq!(NotifyLevel::Error.as_str(), "ERROR");
552        assert_eq!(NotifyLevel::Success.as_str(), "SUCCESS");
553        assert_eq!(NotifyLevel::Debug.as_str(), "DEBUG");
554    }
555}