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        }
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    // -----------------------------------------------------------------
192    // log_show_outcome — the structured breadcrumb the real
193    // `DesktopNotifier` emits.  Without these, a desktop notification
194    // that silently fails (no D-Bus session on a headless box) or one
195    // that fired correctly leaves no operator-visible trail, and the
196    // logging diverges from the rest of the worker's `target` / `op` /
197    // `error` convention.
198    // -----------------------------------------------------------------
199
200    #[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        // `error = %e` renders via Display (no quotes), matching the
227        // worker's `error = %e` logging convention.
228        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}