Skip to main content

studio_worker/ui/
notifier.rs

1//! OS-native desktop notifications gated on per-event toggles.
2//!
3//! `Notifier` is a trait so tests can substitute a `CapturingNotifier`
4//! and assert what would have been shown without invoking the real
5//! `notify-rust` D-Bus / NSUserNotification / WinRT path.
6
7use std::sync::Arc;
8
9use parking_lot::Mutex;
10
11use crate::runtime::{JobOutcome, RecentJob};
12
13/// Per-event desktop-notification toggles.  Surfaced in the Config
14/// tab and held on the `App` for the current session only.  They are
15/// not part of the persisted `Config`, so they reset to off on each
16/// restart.
17#[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
38/// Tracing target for desktop-notification events.  Stable so
39/// operators can filter with
40/// `RUST_LOG=studio_worker::ui::notifier=debug`.
41const TRACE_TARGET: &str = "studio_worker::ui::notifier";
42
43/// Emit a structured breadcrumb for a desktop-notification attempt.
44/// Pulled out of [`DesktopNotifier::show`] so both branches are
45/// observable AND unit-testable without a real D-Bus / WinRT /
46/// NSUserNotification round-trip.  Success logs at `debug` so an
47/// operator can confirm the notifier actually fired; failure logs at
48/// `warn` with a structured `error` field, matching the rest of the
49/// worker's logging convention (the old inline call swallowed the
50/// success case entirely and string-interpolated the error).
51fn 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/// Decision the gate makes for a single recent-job entry.  Pure data
87/// so we can assert on it without running a real notifier.
88#[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            source: crate::runtime::JobSource::Studio,
133        }
134    }
135
136    fn failed_job(reason: &str) -> RecentJob {
137        let mut j = completed_job();
138        j.outcome = JobOutcome::Failed {
139            reason: reason.into(),
140        };
141        j
142    }
143
144    #[test]
145    fn decide_skips_both_when_prefs_disabled() {
146        let prefs = NotificationPrefs::default();
147        assert_eq!(decide(prefs, &completed_job()), NotifyDecision::Skip);
148        assert_eq!(decide(prefs, &failed_job("x")), NotifyDecision::Skip);
149    }
150
151    #[test]
152    fn decide_emits_completion_when_toggle_on() {
153        let prefs = NotificationPrefs {
154            on_completion: true,
155            on_failure: false,
156        };
157        match decide(prefs, &completed_job()) {
158            NotifyDecision::Show { title, body } => {
159                assert!(title.contains("completed"));
160                assert!(body.contains("image"));
161                assert!(body.contains("synthetic"));
162            }
163            other => panic!("expected Show, got {other:?}"),
164        }
165    }
166
167    #[test]
168    fn decide_emits_failure_with_reason() {
169        let prefs = NotificationPrefs {
170            on_completion: false,
171            on_failure: true,
172        };
173        match decide(prefs, &failed_job("boom")) {
174            NotifyDecision::Show { title, body } => {
175                assert!(title.contains("failed"));
176                assert!(body.contains("boom"));
177            }
178            other => panic!("expected Show, got {other:?}"),
179        }
180    }
181
182    #[test]
183    fn capturing_notifier_records_calls() {
184        let n = CapturingNotifier::default();
185        n.show("t", "b");
186        n.show("t2", "b2");
187        let captured = n.captured.lock();
188        assert_eq!(captured.len(), 2);
189        assert_eq!(captured[1], ("t2".into(), "b2".into()));
190    }
191
192    // -----------------------------------------------------------------
193    // log_show_outcome — the structured breadcrumb the real
194    // `DesktopNotifier` emits.  Without these, a desktop notification
195    // that silently fails (no D-Bus session on a headless box) or one
196    // that fired correctly leaves no operator-visible trail, and the
197    // logging diverges from the rest of the worker's `target` / `op` /
198    // `error` convention.
199    // -----------------------------------------------------------------
200
201    #[test]
202    fn log_show_outcome_emits_debug_breadcrumb_on_success() {
203        let logs = crate::test_support::capture(|| {
204            log_show_outcome("studio-worker \u{2014} job completed", Ok(()));
205        });
206        assert!(logs.contains("DEBUG"), "expected DEBUG level, got: {logs}");
207        assert!(
208            logs.contains("studio_worker::ui::notifier"),
209            "expected notifier target, got: {logs}"
210        );
211        assert!(logs.contains("op=\"show\""), "expected op field: {logs}");
212        assert!(
213            logs.contains("desktop notification shown"),
214            "expected success message: {logs}"
215        );
216    }
217
218    #[test]
219    fn log_show_outcome_emits_warn_with_structured_error_on_failure() {
220        let logs = crate::test_support::capture(|| {
221            log_show_outcome(
222                "studio-worker \u{2014} job failed",
223                Err("no d-bus session".into()),
224            );
225        });
226        assert!(logs.contains("WARN"), "expected WARN level, got: {logs}");
227        // `error = %e` renders via Display (no quotes), matching the
228        // worker's `error = %e` logging convention.
229        assert!(
230            logs.contains("error=no d-bus session"),
231            "expected structured error field, got: {logs}"
232        );
233        assert!(
234            logs.contains("desktop notification failed"),
235            "expected failure message: {logs}"
236        );
237    }
238}