use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum NotifyLevel {
Error,
Warn,
Info,
Success,
Debug,
}
impl NotifyLevel {
pub fn as_str(&self) -> &'static str {
match self {
Self::Error => "ERROR",
Self::Warn => "WARN",
Self::Info => "INFO",
Self::Success => "SUCCESS",
Self::Debug => "DEBUG",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum NotifyLocation {
Inline,
Toast,
Status,
Modal,
Stdout,
Stderr,
Log,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NotifyLifecycle {
Persistent,
Ttl(Duration),
Dismissible,
UntilReplaced,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum NotifyStack {
Append,
Replace { key: String },
Dedupe { key: String, window: Duration },
MergeCount { key: String, window: Duration },
Coalesce { key: String },
}
#[derive(Debug, Clone)]
pub struct Notification {
pub level: NotifyLevel,
pub location: NotifyLocation,
pub lifecycle: NotifyLifecycle,
pub stack: NotifyStack,
pub message: String,
pub target: Option<String>,
pub created_at: Instant,
}
impl Notification {
pub fn new(level: NotifyLevel, message: impl Into<String>) -> Self {
Self {
level,
location: NotifyLocation::Inline,
lifecycle: NotifyLifecycle::Persistent,
stack: NotifyStack::Append,
message: message.into(),
target: None,
created_at: Instant::now(),
}
}
pub fn with_location(mut self, location: NotifyLocation) -> Self {
self.location = location;
self
}
pub fn with_lifecycle(mut self, lifecycle: NotifyLifecycle) -> Self {
self.lifecycle = lifecycle;
self
}
pub fn with_stack(mut self, stack: NotifyStack) -> Self {
self.stack = stack;
self
}
pub fn with_target(mut self, target: impl Into<String>) -> Self {
self.target = Some(target.into());
self
}
pub fn toast(self) -> Self {
let ttl = match self.level {
NotifyLevel::Success => Duration::from_secs(2),
NotifyLevel::Info => Duration::from_secs(3),
NotifyLevel::Warn => Duration::from_secs(5),
NotifyLevel::Error => Duration::from_secs(8),
NotifyLevel::Debug => Duration::from_secs(2),
};
self.with_location(NotifyLocation::Toast)
.with_lifecycle(NotifyLifecycle::Ttl(ttl))
}
}
pub trait NotifySink: Send + Sync {
fn emit(&self, note: &Notification);
}
static GLOBAL_SINK: RwLock<Option<Arc<dyn NotifySink>>> = RwLock::new(None);
static LOG_SINK: RwLock<Option<Arc<LogSink>>> = RwLock::new(None);
pub fn install(sink: Arc<dyn NotifySink>) {
let log = LOG_SINK.read().unwrap().clone();
let composite: Arc<dyn NotifySink> = match log {
Some(ls) => Arc::new(CompositeSink::new(vec![sink, ls])),
None => sink,
};
*GLOBAL_SINK.write().unwrap() = Some(composite);
}
pub fn install_log(ls: Arc<LogSink>) {
*LOG_SINK.write().unwrap() = Some(ls.clone());
let existing = GLOBAL_SINK.write().unwrap().take();
let user_sink: Arc<dyn NotifySink> = existing.unwrap_or_else(|| Arc::new(NoopSink));
*GLOBAL_SINK.write().unwrap() = Some(Arc::new(CompositeSink::new(vec![user_sink, ls])));
}
pub fn log_sink() -> Option<Arc<LogSink>> {
LOG_SINK.read().unwrap().clone()
}
pub fn global() -> Arc<dyn NotifySink> {
GLOBAL_SINK
.read()
.unwrap()
.clone()
.unwrap_or_else(|| Arc::new(NoopSink))
}
pub struct ScopedSink {
prev: Option<Arc<dyn NotifySink>>,
}
impl ScopedSink {
pub fn tui() -> Self {
let mut sink = GLOBAL_SINK.write().unwrap();
let prev = sink.take();
let log = LOG_SINK.read().unwrap().clone();
let replacement: Arc<dyn NotifySink> = match log {
Some(ls) => Arc::new(CompositeSink::new(vec![Arc::new(NoopSink), ls])),
None => Arc::new(NoopSink),
};
*sink = Some(replacement);
Self { prev }
}
pub fn replace_with(user: Arc<dyn NotifySink>) -> Self {
let mut sink = GLOBAL_SINK.write().unwrap();
let prev = sink.take();
let log = LOG_SINK.read().unwrap().clone();
let replacement: Arc<dyn NotifySink> = match log {
Some(ls) => Arc::new(CompositeSink::new(vec![user, ls])),
None => user,
};
*sink = Some(replacement);
Self { prev }
}
}
impl Drop for ScopedSink {
fn drop(&mut self) {
let mut sink = GLOBAL_SINK.write().unwrap();
*sink = self.prev.take();
}
}
pub struct NoopSink;
impl NotifySink for NoopSink {
fn emit(&self, _note: &Notification) {}
}
pub struct CliSink;
impl NotifySink for CliSink {
fn emit(&self, note: &Notification) {
let prefix = format!(
"[atman] {}",
if let Some(ref t) = note.target {
format!("[{}] ", t)
} else {
String::new()
}
);
let line = format!("{}{}", prefix, note.message);
if matches!(note.location, NotifyLocation::Log) {
eprintln!("{line}");
return;
}
match note.level {
NotifyLevel::Error | NotifyLevel::Warn => eprintln!("{line}"),
NotifyLevel::Info | NotifyLevel::Success | NotifyLevel::Debug => println!("{line}"),
}
}
}
pub struct ToastCollector {
notifications: Arc<std::sync::Mutex<Vec<Notification>>>,
}
impl ToastCollector {
pub fn new() -> (Self, Arc<std::sync::Mutex<Vec<Notification>>>) {
let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
(
Self {
notifications: buf.clone(),
},
buf,
)
}
}
impl NotifySink for ToastCollector {
fn emit(&self, note: &Notification) {
if let Ok(mut buf) = self.notifications.lock() {
if let Some(ref target) = note.target {
if let Some(prev) = buf
.iter_mut()
.find(|n| n.target.as_deref() == Some(target.as_str()))
{
*prev = note.clone();
return;
}
}
buf.push(note.clone());
}
}
}
impl Clone for ToastCollector {
fn clone(&self) -> Self {
Self {
notifications: self.notifications.clone(),
}
}
}
pub struct LogSink {
base_dir: std::path::PathBuf,
session_id: std::sync::RwLock<Option<String>>,
}
impl LogSink {
pub fn new(base_dir: std::path::PathBuf) -> Self {
Self {
base_dir,
session_id: std::sync::RwLock::new(None),
}
}
pub fn set_session_id(&self, sid: Option<String>) {
*self.session_id.write().unwrap() = sid;
}
fn log_path(&self) -> std::path::PathBuf {
let sid = self.session_id.read().unwrap();
if let Some(ref id) = *sid {
self.base_dir.join("sessions").join(id).join("notify.log")
} else {
let fallback = self.base_dir.join("logs");
let _ = std::fs::create_dir_all(&fallback);
fallback.join("daemon.log")
}
}
}
impl NotifySink for LogSink {
fn emit(&self, note: &Notification) {
use std::io::Write;
let path = self.log_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let target = note.target.as_deref().unwrap_or("-");
let line = serde_json::json!({
"ts": ts,
"level": note.level.as_str(),
"target": target,
"msg": note.message,
});
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = writeln!(f, "{line}");
}
}
}
pub struct CompositeSink {
sinks: Vec<Arc<dyn NotifySink>>,
}
impl CompositeSink {
pub fn new(sinks: Vec<Arc<dyn NotifySink>>) -> Self {
Self { sinks }
}
}
impl NotifySink for CompositeSink {
fn emit(&self, note: &Notification) {
for s in &self.sinks {
s.emit(note);
}
}
}
pub fn dedupe(key: impl Into<String>, window_ms: u64) -> NotifyStack {
NotifyStack::Dedupe {
key: key.into(),
window: Duration::from_millis(window_ms),
}
}
pub fn replace(key: impl Into<String>) -> NotifyStack {
NotifyStack::Replace { key: key.into() }
}
pub fn merge_count(key: impl Into<String>, window_ms: u64) -> NotifyStack {
NotifyStack::MergeCount {
key: key.into(),
window: Duration::from_millis(window_ms),
}
}
pub fn coalesce(key: impl Into<String>) -> NotifyStack {
NotifyStack::Coalesce { key: key.into() }
}
#[macro_export]
macro_rules! notify {
(@level error) => { $crate::notify::NotifyLevel::Error };
(@level warn) => { $crate::notify::NotifyLevel::Warn };
(@level info) => { $crate::notify::NotifyLevel::Info };
(@level success) => { $crate::notify::NotifyLevel::Success };
(@level debug) => { $crate::notify::NotifyLevel::Debug };
($level:ident, location = $loc:ident, stack = $stack:ident ($($sk:expr),+), $msg:expr $(, $arg:expr)* $(,)?) => {{
$crate::notify::global().emit(
&$crate::notify::Notification::new(
$crate::notify!(@level $level),
format!($msg $(, $arg)*),
)
.with_location($crate::notify::NotifyLocation::$loc)
.with_stack($crate::notify::$stack($($sk),+)),
);
}};
($level:ident, location = $loc:ident, lifecycle = $lc:ident, $msg:expr $(, $arg:expr)* $(,)?) => {{
$crate::notify::global().emit(
&$crate::notify::Notification::new(
$crate::notify!(@level $level),
format!($msg $(, $arg)*),
)
.with_location($crate::notify::NotifyLocation::$loc)
.with_lifecycle($crate::notify::NotifyLifecycle::$lc),
);
}};
($level:ident, location = $loc:ident, $msg:expr $(, $arg:expr)* $(,)?) => {{
$crate::notify::global().emit(
&$crate::notify::Notification::new(
$crate::notify!(@level $level),
format!($msg $(, $arg)*),
)
.with_location($crate::notify::NotifyLocation::$loc),
);
}};
($level:ident, target: $target:expr, $msg:expr $(, $arg:expr)* $(,)?) => {{
$crate::notify::global().emit(
&$crate::notify::Notification::new(
$crate::notify!(@level $level),
format!($msg $(, $arg)*),
)
.with_target($target)
.with_location($crate::notify::NotifyLocation::Log),
);
}};
($level:ident, $msg:expr $(, $arg:expr)* $(,)?) => {{
$crate::notify::global().emit(&$crate::notify::Notification::new(
$crate::notify!(@level $level),
format!($msg $(, $arg)*),
));
}};
($msg:expr $(, $arg:expr)* $(,)?) => {{
$crate::notify::global().emit(&$crate::notify::Notification::new(
$crate::notify::NotifyLevel::Info,
format!($msg $(, $arg)*),
));
}};
}
pub use notify;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notification_builder_defaults() {
let n = Notification::new(NotifyLevel::Warn, "test message");
assert_eq!(n.level, NotifyLevel::Warn);
assert_eq!(n.location, NotifyLocation::Inline);
assert!(matches!(n.lifecycle, NotifyLifecycle::Persistent));
assert!(matches!(n.stack, NotifyStack::Append));
assert_eq!(n.message, "test message");
}
#[test]
fn toast_ttl_by_level() {
let n = Notification::new(NotifyLevel::Success, "ok").toast();
assert_eq!(n.location, NotifyLocation::Toast);
assert_eq!(n.lifecycle, NotifyLifecycle::Ttl(Duration::from_secs(2)));
let n = Notification::new(NotifyLevel::Error, "fail").toast();
assert_eq!(n.lifecycle, NotifyLifecycle::Ttl(Duration::from_secs(8)));
}
#[test]
fn stack_helpers() {
assert_eq!(
dedupe("my-key", 5000),
NotifyStack::Dedupe {
key: "my-key".into(),
window: Duration::from_millis(5000),
}
);
assert_eq!(replace("slot"), NotifyStack::Replace { key: "slot".into() });
assert_eq!(
merge_count("lag", 300),
NotifyStack::MergeCount {
key: "lag".into(),
window: Duration::from_millis(300),
}
);
}
#[test]
fn cli_sink_routes_by_level() {
let n = Notification::new(NotifyLevel::Error, "boom");
let sink = CliSink;
sink.emit(&n);
}
#[test]
fn composite_fanout() {
let sink = CompositeSink::new(vec![Arc::new(NoopSink), Arc::new(CliSink)]);
let n = Notification::new(NotifyLevel::Info, "hello");
sink.emit(&n); }
#[test]
fn level_as_str() {
assert_eq!(NotifyLevel::Error.as_str(), "ERROR");
assert_eq!(NotifyLevel::Success.as_str(), "SUCCESS");
assert_eq!(NotifyLevel::Debug.as_str(), "DEBUG");
}
}