1use std::sync::Arc;
8
9use parking_lot::Mutex;
10
11use crate::runtime::{JobOutcome, RecentJob};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub struct NotificationPrefs {
19 pub on_completion: bool,
20 pub on_failure: bool,
21}
22
23pub trait Notifier {
24 fn show(&self, title: &str, body: &str);
25}
26
27#[derive(Default)]
28pub struct CapturingNotifier {
29 pub captured: Arc<Mutex<Vec<(String, String)>>>,
30}
31
32impl Notifier for CapturingNotifier {
33 fn show(&self, title: &str, body: &str) {
34 self.captured.lock().push((title.into(), body.into()));
35 }
36}
37
38const TRACE_TARGET: &str = "studio_worker::ui::notifier";
42
43fn log_show_outcome(title: &str, result: Result<(), String>) {
52 match result {
53 Ok(()) => tracing::debug!(
54 target: TRACE_TARGET,
55 op = "show",
56 title = %title,
57 "desktop notification shown"
58 ),
59 Err(e) => tracing::warn!(
60 target: TRACE_TARGET,
61 op = "show",
62 title = %title,
63 error = %e,
64 "desktop notification failed"
65 ),
66 }
67}
68
69#[cfg(feature = "ui")]
70pub struct DesktopNotifier;
71
72#[cfg(feature = "ui")]
73impl Notifier for DesktopNotifier {
74 fn show(&self, title: &str, body: &str) {
75 let result = notify_rust::Notification::new()
76 .summary(title)
77 .body(body)
78 .appname("studio-worker")
79 .show()
80 .map(|_| ())
81 .map_err(|e| e.to_string());
82 log_show_outcome(title, result);
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum NotifyDecision {
90 Skip,
91 Show { title: String, body: String },
92}
93
94pub fn decide(prefs: NotificationPrefs, recent: &RecentJob) -> NotifyDecision {
95 let allow = match &recent.outcome {
96 JobOutcome::Completed => prefs.on_completion,
97 JobOutcome::Failed { .. } => prefs.on_failure,
98 };
99 if !allow {
100 return NotifyDecision::Skip;
101 }
102 let title = match &recent.outcome {
103 JobOutcome::Completed => "studio-worker — job completed".into(),
104 JobOutcome::Failed { .. } => "studio-worker — job failed".into(),
105 };
106 let body = match &recent.outcome {
107 JobOutcome::Completed => format!("{} · {}", recent.kind.as_str(), recent.model),
108 JobOutcome::Failed { reason } => {
109 format!("{} · {} — {reason}", recent.kind.as_str(), recent.model)
110 }
111 };
112 NotifyDecision::Show { title, body }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::runtime::JobOutcome;
119 use crate::types::TaskKind;
120 use chrono::Utc;
121
122 fn completed_job() -> RecentJob {
123 let now = Utc::now();
124 RecentJob {
125 job_id: "j-1".into(),
126 kind: TaskKind::Image,
127 model: "synthetic".into(),
128 prompt: "a tree".into(),
129 outcome: JobOutcome::Completed,
130 started_at: now,
131 finished_at: now,
132 }
133 }
134
135 fn failed_job(reason: &str) -> RecentJob {
136 let mut j = completed_job();
137 j.outcome = JobOutcome::Failed {
138 reason: reason.into(),
139 };
140 j
141 }
142
143 #[test]
144 fn decide_skips_both_when_prefs_disabled() {
145 let prefs = NotificationPrefs::default();
146 assert_eq!(decide(prefs, &completed_job()), NotifyDecision::Skip);
147 assert_eq!(decide(prefs, &failed_job("x")), NotifyDecision::Skip);
148 }
149
150 #[test]
151 fn decide_emits_completion_when_toggle_on() {
152 let prefs = NotificationPrefs {
153 on_completion: true,
154 on_failure: false,
155 };
156 match decide(prefs, &completed_job()) {
157 NotifyDecision::Show { title, body } => {
158 assert!(title.contains("completed"));
159 assert!(body.contains("image"));
160 assert!(body.contains("synthetic"));
161 }
162 other => panic!("expected Show, got {other:?}"),
163 }
164 }
165
166 #[test]
167 fn decide_emits_failure_with_reason() {
168 let prefs = NotificationPrefs {
169 on_completion: false,
170 on_failure: true,
171 };
172 match decide(prefs, &failed_job("boom")) {
173 NotifyDecision::Show { title, body } => {
174 assert!(title.contains("failed"));
175 assert!(body.contains("boom"));
176 }
177 other => panic!("expected Show, got {other:?}"),
178 }
179 }
180
181 #[test]
182 fn capturing_notifier_records_calls() {
183 let n = CapturingNotifier::default();
184 n.show("t", "b");
185 n.show("t2", "b2");
186 let captured = n.captured.lock();
187 assert_eq!(captured.len(), 2);
188 assert_eq!(captured[1], ("t2".into(), "b2".into()));
189 }
190
191 #[test]
201 fn log_show_outcome_emits_debug_breadcrumb_on_success() {
202 let logs = crate::test_support::capture(|| {
203 log_show_outcome("studio-worker \u{2014} job completed", Ok(()));
204 });
205 assert!(logs.contains("DEBUG"), "expected DEBUG level, got: {logs}");
206 assert!(
207 logs.contains("studio_worker::ui::notifier"),
208 "expected notifier target, got: {logs}"
209 );
210 assert!(logs.contains("op=\"show\""), "expected op field: {logs}");
211 assert!(
212 logs.contains("desktop notification shown"),
213 "expected success message: {logs}"
214 );
215 }
216
217 #[test]
218 fn log_show_outcome_emits_warn_with_structured_error_on_failure() {
219 let logs = crate::test_support::capture(|| {
220 log_show_outcome(
221 "studio-worker \u{2014} job failed",
222 Err("no d-bus session".into()),
223 );
224 });
225 assert!(logs.contains("WARN"), "expected WARN level, got: {logs}");
226 assert!(
229 logs.contains("error=no d-bus session"),
230 "expected structured error field, got: {logs}"
231 );
232 assert!(
233 logs.contains("desktop notification failed"),
234 "expected failure message: {logs}"
235 );
236 }
237}