use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
#[cfg(test)]
use std::sync::atomic::{AtomicUsize, Ordering};
#[cfg(test)]
use std::sync::Arc;
use crate::config::schema::NotificationsConfig;
use crate::error::{Error, Result};
pub trait Notifier: Send + Sync {
fn notify_capture(&self, summary: &str) -> Result<()>;
fn notify_complete(&self, log_path: &Path) -> Result<()>;
}
pub struct SystemNotifier {
cfg: NotificationsConfig,
}
impl SystemNotifier {
pub fn new(cfg: NotificationsConfig) -> Self {
Self { cfg }
}
}
impl Notifier for SystemNotifier {
fn notify_capture(&self, summary: &str) -> Result<()> {
if !self.cfg.enabled || !self.cfg.on_capture {
return Ok(());
}
send("textlog: captured", summary, self.cfg.sound)
}
fn notify_complete(&self, log_path: &Path) -> Result<()> {
if !self.cfg.enabled || !self.cfg.on_complete {
return Ok(());
}
let body = format!("Saved to {}", log_path.display());
send("textlog: written", &body, self.cfg.sound)
}
}
#[cfg(test)]
#[derive(Debug, Default)]
pub struct CountingNotifier {
pub captured: AtomicUsize,
pub completed: AtomicUsize,
pub last_capture_summary: std::sync::Mutex<Option<String>>,
pub last_complete_path: std::sync::Mutex<Option<PathBuf>>,
}
#[cfg(test)]
impl CountingNotifier {
pub fn new() -> Self {
Self::default()
}
pub fn into_arc(self) -> Arc<dyn Notifier> {
Arc::new(self)
}
pub fn captured(&self) -> usize {
self.captured.load(Ordering::SeqCst)
}
pub fn completed(&self) -> usize {
self.completed.load(Ordering::SeqCst)
}
}
#[cfg(test)]
impl Notifier for CountingNotifier {
fn notify_capture(&self, summary: &str) -> Result<()> {
self.captured.fetch_add(1, Ordering::SeqCst);
*self.last_capture_summary.lock().unwrap() = Some(summary.to_string());
Ok(())
}
fn notify_complete(&self, log_path: &Path) -> Result<()> {
self.completed.fetch_add(1, Ordering::SeqCst);
*self.last_complete_path.lock().unwrap() = Some(log_path.to_path_buf());
Ok(())
}
}
fn send(summary: &str, body: &str, sound: bool) -> Result<()> {
let mut n = notify_rust::Notification::new();
n.summary(summary).body(body).appname("textlog");
if sound {
n.sound_name("default");
}
n.show()
.map(|_handle| ())
.map_err(|e| Error::Notification(format!("notify-rust dispatch failed: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(enabled: bool, on_capture: bool, on_complete: bool) -> NotificationsConfig {
NotificationsConfig {
enabled,
on_capture,
on_complete,
copy_log_path_on_complete: false,
sound: false,
}
}
#[test]
fn system_notifier_skips_when_disabled() {
let n = SystemNotifier::new(cfg(false, true, true));
n.notify_capture("ignored").unwrap();
n.notify_complete(Path::new("/tmp/x.md")).unwrap();
}
#[test]
fn system_notifier_skips_capture_when_on_capture_false() {
let n = SystemNotifier::new(cfg(true, false, false));
n.notify_capture("nothing").unwrap();
}
#[test]
fn system_notifier_skips_complete_when_on_complete_false() {
let n = SystemNotifier::new(cfg(true, false, false));
n.notify_complete(Path::new("/tmp/x.md")).unwrap();
}
#[test]
fn counting_notifier_records_calls() {
let c = CountingNotifier::new();
c.notify_capture("first").unwrap();
c.notify_capture("second").unwrap();
c.notify_complete(Path::new("/tmp/log.md")).unwrap();
assert_eq!(c.captured(), 2);
assert_eq!(c.completed(), 1);
assert_eq!(
c.last_capture_summary.lock().unwrap().as_deref(),
Some("second")
);
assert_eq!(
c.last_complete_path.lock().unwrap().as_deref(),
Some(Path::new("/tmp/log.md"))
);
}
#[test]
fn counting_notifier_can_be_used_via_trait() {
let c: Arc<dyn Notifier> = CountingNotifier::new().into_arc();
c.notify_capture("trait dispatch").unwrap();
c.notify_complete(Path::new("/tmp/x.md")).unwrap();
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "fires a real macOS notification; run with --ignored"]
fn system_notifier_dispatches_real_notification() {
let n = SystemNotifier::new(cfg(true, true, true));
n.notify_capture("textlog test capture").unwrap();
n.notify_complete(Path::new("/tmp/test.md")).unwrap();
}
}