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 match note.level {
253 NotifyLevel::Error | NotifyLevel::Warn => eprintln!("{line}"),
254 NotifyLevel::Info | NotifyLevel::Success | NotifyLevel::Debug => println!("{line}"),
255 }
256 }
257}
258
259pub 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 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
303pub 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
358pub 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
377pub 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#[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: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: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: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 ($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: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 ($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 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); }
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}