1use serde::{Deserialize, Serialize};
13use std::sync::{Arc, RwLock};
14use std::time::{Duration, Instant};
15
16#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub enum NotifyLocation {
43 Inline,
45 Toast,
47 Status,
49 Modal,
51 Stdout,
53 Stderr,
55 Log,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62pub enum NotifyLifecycle {
63 Persistent,
65 Ttl(Duration),
67 Dismissible,
69 UntilReplaced,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub enum NotifyStack {
77 Append,
79 Replace { key: String },
81 Dedupe { key: String, window: Duration },
83 MergeCount { key: String, window: Duration },
85 Coalesce { key: String },
87}
88
89#[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 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
149pub trait NotifySink: Send + Sync {
152 fn emit(&self, note: &Notification);
153}
154
155static GLOBAL_SINK: RwLock<Option<Arc<dyn NotifySink>>> = RwLock::new(None);
158
159static 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 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
191pub struct ScopedSink {
193 prev: Option<Arc<dyn NotifySink>>,
194}
195
196impl ScopedSink {
197 pub fn tui() -> Self {
199 let mut sink = GLOBAL_SINK.write().unwrap();
200 let prev = sink.take();
201 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 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
232pub struct NoopSink;
235impl NotifySink for NoopSink {
236 fn emit(&self, _note: &Notification) {}
237}
238
239pub 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
263pub 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 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
307pub 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
362pub 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
381pub 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#[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: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: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: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 ($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: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 ($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 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); }
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}