use std::process::Command;
use crate::domain::{BlockedKind, Task, TaskState};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Urgency {
Normal,
Critical,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Notification {
pub task_id: i64,
pub title: String,
pub body: String,
pub urgency: Urgency,
}
pub fn for_transition(task: &Task) -> Option<Notification> {
let (title, body, urgency) = match task.state {
TaskState::AwaitingReview => (
"Ready for review".to_string(),
task.title.clone(),
Urgency::Normal,
),
TaskState::Blocked => {
let title = match task.blocked_kind {
Some(BlockedKind::PermissionPrompt) => "Needs permission",
Some(BlockedKind::Question) => "Needs an answer",
Some(BlockedKind::Silence) => "Waiting for input",
None => "Blocked",
};
let body = match &task.blocked_reason {
Some(reason) => format!("{}: {}", task.title, reason),
None => task.title.clone(),
};
(title.to_string(), body, Urgency::Normal)
}
TaskState::Failed => {
let body = match &task.failure_reason {
Some(reason) => format!("{}: {}", task.title, reason),
None => task.title.clone(),
};
("Task failed".to_string(), body, Urgency::Critical)
}
TaskState::Queued
| TaskState::Running
| TaskState::Paused
| TaskState::Committed
| TaskState::Cancelled => {
return None;
}
};
Some(Notification {
task_id: task.id,
title: format!("marver — {title}"),
body,
urgency,
})
}
pub trait Notify {
fn send(&self, notification: &Notification) -> Result<(), String>;
}
#[derive(Debug, Clone, Default)]
pub struct SystemNotifier;
impl Notify for SystemNotifier {
#[cfg(target_os = "macos")]
fn send(&self, notification: &Notification) -> Result<(), String> {
run(
"osascript",
&[
"-e",
"on run argv",
"-e",
"display notification (item 1 of argv) with title (item 2 of argv)",
"-e",
"end run",
¬ification.body,
¬ification.title,
],
)
}
#[cfg(not(target_os = "macos"))]
fn send(&self, notification: &Notification) -> Result<(), String> {
let urgency = match notification.urgency {
Urgency::Normal => "normal",
Urgency::Critical => "critical",
};
run(
"notify-send",
&["-u", urgency, "--", ¬ification.title, ¬ification.body],
)
}
}
fn run(program: &str, args: &[&str]) -> Result<(), String> {
let output = Command::new(program)
.args(args)
.output()
.map_err(|err| format!("could not run {program}: {err}"))?;
if !output.status.success() {
return Err(format!(
"{program} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(())
}
#[derive(Debug, Clone, Default)]
pub struct SilentNotifier;
impl Notify for SilentNotifier {
fn send(&self, _: &Notification) -> Result<(), String> {
Ok(())
}
}
pub struct Notifier<N: Notify> {
backend: N,
enabled: bool,
}
impl<N: Notify> Notifier<N> {
pub fn new(backend: N) -> Self {
Self {
backend,
enabled: true,
}
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn announce(&self, task: &Task) -> Result<Option<Notification>, String> {
if !self.enabled {
return Ok(None);
}
let Some(notification) = for_transition(task) else {
return Ok(None);
};
self.backend.send(¬ification)?;
Ok(Some(notification))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{BlockedInfo, Store, Transition};
use chrono::{DateTime, Utc};
use std::cell::RefCell;
use std::path::Path;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).expect("valid timestamp")
}
#[derive(Default)]
struct Recorder {
sent: RefCell<Vec<Notification>>,
fail: bool,
}
impl Recorder {
fn failing() -> Self {
Self {
sent: RefCell::new(Vec::new()),
fail: true,
}
}
fn sent(&self) -> Vec<Notification> {
self.sent.borrow().clone()
}
}
impl Notify for Recorder {
fn send(&self, notification: &Notification) -> Result<(), String> {
self.sent.borrow_mut().push(notification.clone());
if self.fail {
return Err("no notification daemon".to_string());
}
Ok(())
}
}
fn task_in(state: TaskState) -> Task {
let mut store = Store::open_in_memory().unwrap();
let task = store
.create_task(
"Fix the auth flow",
"p",
Path::new("/tmp/tasks"),
&[],
at(0),
)
.unwrap();
let id = task.id;
if state != TaskState::Queued {
store
.transition(id, TaskState::Running, Transition::Plain, at(1))
.unwrap();
}
match state {
TaskState::Queued | TaskState::Running => {}
TaskState::Blocked => {
store
.transition(
id,
TaskState::Blocked,
Transition::Blocked(BlockedInfo::with_reason(
BlockedKind::PermissionPrompt,
"edit src/main.rs",
)),
at(2),
)
.unwrap();
}
TaskState::AwaitingReview => {
store
.transition(id, TaskState::AwaitingReview, Transition::Plain, at(2))
.unwrap();
}
TaskState::Committed => {
store
.transition(id, TaskState::AwaitingReview, Transition::Plain, at(2))
.unwrap();
store
.transition(id, TaskState::Committed, Transition::Plain, at(3))
.unwrap();
}
TaskState::Failed => {
store
.transition(
id,
TaskState::Failed,
Transition::Failed("the session died".to_string()),
at(2),
)
.unwrap();
}
TaskState::Cancelled => {
store
.transition(id, TaskState::Cancelled, Transition::Plain, at(2))
.unwrap();
}
TaskState::Paused => {
store
.transition(id, TaskState::Paused, Transition::Plain, at(2))
.unwrap();
}
}
store.get_task(id).unwrap()
}
#[test]
fn finishing_a_turn_asks_for_review() {
let n = for_transition(&task_in(TaskState::AwaitingReview)).unwrap();
assert_eq!(n.title, "marver — Ready for review");
assert_eq!(n.body, "Fix the auth flow");
assert_eq!(n.urgency, Urgency::Normal);
}
#[test]
fn a_permission_prompt_says_so_and_names_the_reason() {
let n = for_transition(&task_in(TaskState::Blocked)).unwrap();
assert_eq!(n.title, "marver — Needs permission");
assert_eq!(
n.body, "Fix the auth flow: edit src/main.rs",
"the reason is the whole point of the notification"
);
}
#[test]
fn each_blocked_kind_gets_its_own_wording() {
let mut task = task_in(TaskState::Blocked);
for (kind, expected) in [
(BlockedKind::PermissionPrompt, "marver — Needs permission"),
(BlockedKind::Question, "marver — Needs an answer"),
(BlockedKind::Silence, "marver — Waiting for input"),
] {
task.blocked_kind = Some(kind);
assert_eq!(for_transition(&task).unwrap().title, expected);
}
}
#[test]
fn a_failure_is_critical_and_explains_itself() {
let n = for_transition(&task_in(TaskState::Failed)).unwrap();
assert_eq!(n.title, "marver — Task failed");
assert_eq!(n.body, "Fix the auth flow: the session died");
assert_eq!(n.urgency, Urgency::Critical);
}
#[test]
fn states_the_user_caused_are_silent() {
for state in [
TaskState::Queued,
TaskState::Running,
TaskState::Committed,
TaskState::Cancelled,
] {
assert_eq!(
for_transition(&task_in(state)),
None,
"{state} should not notify"
);
}
}
#[test]
fn exactly_three_states_notify() {
let notifying: Vec<TaskState> = TaskState::ALL
.iter()
.copied()
.filter(|s| for_transition(&task_in(*s)).is_some())
.collect();
assert_eq!(
notifying,
[
TaskState::Blocked,
TaskState::AwaitingReview,
TaskState::Failed
]
);
}
#[test]
fn the_task_id_travels_with_the_notification() {
let task = task_in(TaskState::AwaitingReview);
assert_eq!(for_transition(&task).unwrap().task_id, task.id);
}
#[test]
fn a_blocked_task_without_a_reason_still_notifies() {
let mut task = task_in(TaskState::Blocked);
task.blocked_reason = None;
let n = for_transition(&task).unwrap();
assert_eq!(n.body, "Fix the auth flow");
}
#[test]
fn announce_delivers_only_notifiable_states() {
let recorder = Recorder::default();
let notifier = Notifier::new(recorder);
assert!(
notifier
.announce(&task_in(TaskState::AwaitingReview))
.unwrap()
.is_some()
);
assert!(
notifier
.announce(&task_in(TaskState::Running))
.unwrap()
.is_none()
);
}
#[test]
fn disabling_suppresses_everything() {
let notifier = Notifier::new(Recorder::default()).enabled(false);
assert!(
notifier
.announce(&task_in(TaskState::Failed))
.unwrap()
.is_none()
);
}
#[test]
fn a_backend_failure_is_reported_not_swallowed() {
let notifier = Notifier::new(Recorder::failing());
let result = notifier.announce(&task_in(TaskState::Failed));
assert!(result.is_err(), "the caller should be able to see this");
}
#[test]
fn the_silent_backend_accepts_everything() {
let notifier = Notifier::new(SilentNotifier);
assert!(
notifier
.announce(&task_in(TaskState::Failed))
.unwrap()
.is_some(),
"the policy still runs; only delivery is a no-op"
);
}
#[test]
#[ignore = "shows a desktop notification"]
fn sends_a_real_notification() {
let notification = Notification {
task_id: 1,
title: "marver — Ready for review".to_string(),
body: r#"TITLE GOES ABOVE, BODY BELOW — "quotes" and $(whoami)"#.to_string(),
urgency: Urgency::Normal,
};
SystemNotifier.send(¬ification).expect("should deliver");
}
#[test]
fn awkward_titles_are_carried_verbatim() {
let mut task = task_in(TaskState::AwaitingReview);
task.title = r#"fix "quotes" & $(whoami) '; rm -rf /"#.to_string();
let n = for_transition(&task).unwrap();
assert_eq!(n.body, r#"fix "quotes" & $(whoami) '; rm -rf /"#);
let notifier = Notifier::new(Recorder::default());
notifier.announce(&task).unwrap();
let delivered = notifier.backend.sent();
assert_eq!(delivered.len(), 1);
assert_eq!(
delivered[0].body, n.body,
"the backend must receive the text unmangled"
);
}
}