use std::sync::Arc;
use parking_lot::Mutex;
use crate::runtime::{JobOutcome, RecentJob};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct NotificationPrefs {
pub on_completion: bool,
pub on_failure: bool,
}
pub trait Notifier {
fn show(&self, title: &str, body: &str);
}
#[derive(Default)]
pub struct CapturingNotifier {
pub captured: Arc<Mutex<Vec<(String, String)>>>,
}
impl Notifier for CapturingNotifier {
fn show(&self, title: &str, body: &str) {
self.captured.lock().push((title.into(), body.into()));
}
}
const TRACE_TARGET: &str = "studio_worker::ui::notifier";
fn log_show_outcome(title: &str, result: Result<(), String>) {
match result {
Ok(()) => tracing::debug!(
target: TRACE_TARGET,
op = "show",
title = %title,
"desktop notification shown"
),
Err(e) => tracing::warn!(
target: TRACE_TARGET,
op = "show",
title = %title,
error = %e,
"desktop notification failed"
),
}
}
#[cfg(feature = "ui")]
pub struct DesktopNotifier;
#[cfg(feature = "ui")]
impl Notifier for DesktopNotifier {
fn show(&self, title: &str, body: &str) {
let result = notify_rust::Notification::new()
.summary(title)
.body(body)
.appname("studio-worker")
.show()
.map(|_| ())
.map_err(|e| e.to_string());
log_show_outcome(title, result);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NotifyDecision {
Skip,
Show { title: String, body: String },
}
pub fn decide(prefs: NotificationPrefs, recent: &RecentJob) -> NotifyDecision {
let allow = match &recent.outcome {
JobOutcome::Completed => prefs.on_completion,
JobOutcome::Failed { .. } => prefs.on_failure,
};
if !allow {
return NotifyDecision::Skip;
}
let title = match &recent.outcome {
JobOutcome::Completed => "studio-worker — job completed".into(),
JobOutcome::Failed { .. } => "studio-worker — job failed".into(),
};
let body = match &recent.outcome {
JobOutcome::Completed => format!("{} · {}", recent.kind.as_str(), recent.model),
JobOutcome::Failed { reason } => {
format!("{} · {} — {reason}", recent.kind.as_str(), recent.model)
}
};
NotifyDecision::Show { title, body }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::JobOutcome;
use crate::types::TaskKind;
use chrono::Utc;
fn completed_job() -> RecentJob {
let now = Utc::now();
RecentJob {
job_id: "j-1".into(),
kind: TaskKind::Image,
model: "synthetic".into(),
prompt: "a tree".into(),
outcome: JobOutcome::Completed,
started_at: now,
finished_at: now,
}
}
fn failed_job(reason: &str) -> RecentJob {
let mut j = completed_job();
j.outcome = JobOutcome::Failed {
reason: reason.into(),
};
j
}
#[test]
fn decide_skips_both_when_prefs_disabled() {
let prefs = NotificationPrefs::default();
assert_eq!(decide(prefs, &completed_job()), NotifyDecision::Skip);
assert_eq!(decide(prefs, &failed_job("x")), NotifyDecision::Skip);
}
#[test]
fn decide_emits_completion_when_toggle_on() {
let prefs = NotificationPrefs {
on_completion: true,
on_failure: false,
};
match decide(prefs, &completed_job()) {
NotifyDecision::Show { title, body } => {
assert!(title.contains("completed"));
assert!(body.contains("image"));
assert!(body.contains("synthetic"));
}
other => panic!("expected Show, got {other:?}"),
}
}
#[test]
fn decide_emits_failure_with_reason() {
let prefs = NotificationPrefs {
on_completion: false,
on_failure: true,
};
match decide(prefs, &failed_job("boom")) {
NotifyDecision::Show { title, body } => {
assert!(title.contains("failed"));
assert!(body.contains("boom"));
}
other => panic!("expected Show, got {other:?}"),
}
}
#[test]
fn capturing_notifier_records_calls() {
let n = CapturingNotifier::default();
n.show("t", "b");
n.show("t2", "b2");
let captured = n.captured.lock();
assert_eq!(captured.len(), 2);
assert_eq!(captured[1], ("t2".into(), "b2".into()));
}
#[test]
fn log_show_outcome_emits_debug_breadcrumb_on_success() {
let logs = crate::test_support::capture(|| {
log_show_outcome("studio-worker \u{2014} job completed", Ok(()));
});
assert!(logs.contains("DEBUG"), "expected DEBUG level, got: {logs}");
assert!(
logs.contains("studio_worker::ui::notifier"),
"expected notifier target, got: {logs}"
);
assert!(logs.contains("op=\"show\""), "expected op field: {logs}");
assert!(
logs.contains("desktop notification shown"),
"expected success message: {logs}"
);
}
#[test]
fn log_show_outcome_emits_warn_with_structured_error_on_failure() {
let logs = crate::test_support::capture(|| {
log_show_outcome(
"studio-worker \u{2014} job failed",
Err("no d-bus session".into()),
);
});
assert!(logs.contains("WARN"), "expected WARN level, got: {logs}");
assert!(
logs.contains("error=no d-bus session"),
"expected structured error field, got: {logs}"
);
assert!(
logs.contains("desktop notification failed"),
"expected failure message: {logs}"
);
}
}