Skip to main content

bamboo_notification/
policy.rs

1//! Pure classification of agent events into candidate notifications.
2//!
3//! [`classify`] is the single source of truth for *which* events are
4//! notification-worthy, *how* they are categorised and prioritised, and *what*
5//! their dedup key is. It is intentionally side-effect free: it reads an
6//! [`AgentEvent`] and the user's [`NotificationPreferences`] and returns an
7//! optional [`ClassifiedNotification`]. Dedup timing and id/timestamp minting
8//! happen one layer up in [`crate::service`].
9
10use std::collections::hash_map::DefaultHasher;
11use std::hash::{Hash, Hasher};
12
13use bamboo_agent_core::AgentEvent;
14
15use crate::preferences::NotificationPreferences;
16
17/// Maximum body length for a clarification notification, in characters.
18const CLARIFICATION_BODY_MAX: usize = 120;
19
20/// Maximum body length for a background-task-completed notification, in characters.
21const BACKGROUND_TASK_BODY_MAX: usize = 120;
22
23/// Maximum body length for a run-failed notification, in characters.
24const RUN_FAILED_BODY_MAX: usize = 200;
25
26/// The kind of user-facing notification a classified event maps to.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum NotificationCategory {
29    /// A tool is requesting explicit user approval before running.
30    NeedsApproval,
31    /// The agent is asking the user a free-form clarifying question.
32    NeedsClarification,
33    /// Context usage has reached a critical level.
34    ContextCritical,
35    /// A background sub-agent task has completed.
36    SubagentCompleted,
37    /// A background shell/command (Bash `run_in_background`) has finished.
38    BackgroundTaskCompleted,
39    /// A run finished successfully (`AgentEvent::Complete`).
40    RunCompleted,
41    /// A run failed (`AgentEvent::Error`).
42    RunFailed,
43    /// A caller-supplied notification minted directly by the `notify` tool
44    /// rather than derived from a specific `AgentEvent` variant.
45    Custom,
46}
47
48impl NotificationCategory {
49    /// Returns the stable wire string for this category.
50    pub fn as_str(&self) -> &'static str {
51        match self {
52            NotificationCategory::NeedsApproval => "needs_approval",
53            NotificationCategory::NeedsClarification => "needs_clarification",
54            NotificationCategory::ContextCritical => "context_critical",
55            NotificationCategory::SubagentCompleted => "subagent_completed",
56            NotificationCategory::BackgroundTaskCompleted => "background_task_completed",
57            NotificationCategory::RunCompleted => "run_completed",
58            NotificationCategory::RunFailed => "run_failed",
59            NotificationCategory::Custom => "custom",
60        }
61    }
62}
63
64/// The urgency of a notification, surfaced to the client for sorting/styling.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum NotificationPriority {
67    /// Needs prompt user attention (e.g. the agent is blocked on input).
68    High,
69    /// Informational but worth surfacing.
70    Normal,
71    /// Low-signal; can be quietly delivered.
72    Low,
73}
74
75impl NotificationPriority {
76    /// Returns the stable wire string for this priority.
77    pub fn as_str(&self) -> &'static str {
78        match self {
79            NotificationPriority::High => "high",
80            NotificationPriority::Normal => "normal",
81            NotificationPriority::Low => "low",
82        }
83    }
84}
85
86/// A notification candidate produced by [`classify`].
87///
88/// This carries everything the policy decided; the service layer adds the
89/// volatile bits (unique id, creation timestamp) when materialising the
90/// outgoing [`AgentEvent::Notification`].
91#[derive(Debug, Clone, PartialEq)]
92pub struct ClassifiedNotification {
93    /// Category the event was classified into.
94    pub category: NotificationCategory,
95    /// Priority assigned to the notification.
96    pub priority: NotificationPriority,
97    /// Short title line.
98    pub title: String,
99    /// Body text.
100    pub body: String,
101    /// Stable key used to coalesce duplicate notifications within a window.
102    pub dedup_key: String,
103}
104
105/// Returns `true` when a clarification question is actually a permission prompt.
106///
107/// Permission prompts are produced by the permission gate / `request_permissions`
108/// tool and carry one of these markers in their question text; we route them to
109/// the `NeedsApproval` category rather than `NeedsClarification`.
110fn is_permission_prompt(question: &str) -> bool {
111    question.contains("Permission required") || question.contains("Permission Request")
112}
113
114/// Truncates `text` to at most `max` characters, appending an ellipsis when cut.
115///
116/// Operates on Unicode scalar values (chars), not bytes, so it never splits a
117/// multi-byte character.
118fn truncate(text: &str, max: usize) -> String {
119    if text.chars().count() <= max {
120        return text.to_string();
121    }
122    let mut out: String = text.chars().take(max).collect();
123    out.push('…');
124    out
125}
126
127/// Classifies an agent event into an optional notification candidate.
128///
129/// Returns `None` immediately when notifications are disabled, when the event is
130/// not notification-worthy, or when the specific per-category preference is off.
131/// The match uses a catch-all arm, so adding new [`AgentEvent`] variants never
132/// breaks this function.
133pub fn classify(
134    session_id: &str,
135    event: &AgentEvent,
136    prefs: &NotificationPreferences,
137) -> Option<ClassifiedNotification> {
138    if !prefs.enabled {
139        return None;
140    }
141
142    match event {
143        AgentEvent::NeedClarification {
144            question,
145            tool_call_id,
146            ..
147        } => {
148            let (category, title) = if is_permission_prompt(question) {
149                if !prefs.on_tool_approval {
150                    return None;
151                }
152                (NotificationCategory::NeedsApproval, "Approval needed")
153            } else {
154                if !prefs.on_clarification {
155                    return None;
156                }
157                (
158                    NotificationCategory::NeedsClarification,
159                    "Your input needed",
160                )
161            };
162            Some(ClassifiedNotification {
163                category,
164                priority: NotificationPriority::High,
165                title: title.to_string(),
166                body: truncate(question, CLARIFICATION_BODY_MAX),
167                dedup_key: format!(
168                    "clarification:{session_id}:{}",
169                    tool_call_id.as_deref().unwrap_or("")
170                ),
171            })
172        }
173
174        AgentEvent::ContextPressureNotification { level, message, .. } => {
175            if level != "critical" {
176                return None;
177            }
178            if !prefs.on_context_pressure {
179                return None;
180            }
181            Some(ClassifiedNotification {
182                category: NotificationCategory::ContextCritical,
183                priority: NotificationPriority::Normal,
184                title: "Context almost full".to_string(),
185                body: message.clone(),
186                dedup_key: format!("context:{session_id}"),
187            })
188        }
189
190        AgentEvent::SubAgentCompleted {
191            child_session_id,
192            status,
193            ..
194        } => {
195            if status != "completed" {
196                return None;
197            }
198            if !prefs.on_subagent_complete {
199                return None;
200            }
201            Some(ClassifiedNotification {
202                category: NotificationCategory::SubagentCompleted,
203                priority: NotificationPriority::Normal,
204                title: "Background task done".to_string(),
205                body: format!("A background task finished ({}).", child_session_id),
206                dedup_key: format!("subagent:{}", child_session_id),
207            })
208        }
209
210        AgentEvent::BashCompleted {
211            bash_id,
212            command,
213            exit_code,
214            status,
215        } => {
216            // A killed shell was terminated by the user (KillShell) — they already
217            // know; don't ping. Notify when a background command reaches a terminal
218            // state on its own ("completed" — any exit code — or an internal
219            // "error").
220            if status == "killed" {
221                return None;
222            }
223            if !prefs.on_background_task_complete {
224                return None;
225            }
226            let exit = match exit_code {
227                Some(code) => format!("exit {code}"),
228                None => "no exit code".to_string(),
229            };
230            Some(ClassifiedNotification {
231                category: NotificationCategory::BackgroundTaskCompleted,
232                priority: NotificationPriority::Normal,
233                title: "Background command finished".to_string(),
234                // Dedup by the shell id: the live `BashCompleted` fires once, but
235                // key on `bash_id` (not session) so distinct shells each notify.
236                body: truncate(
237                    &format!("{command} — {status}, {exit}"),
238                    BACKGROUND_TASK_BODY_MAX,
239                ),
240                dedup_key: format!("bash:{bash_id}"),
241            })
242        }
243
244        AgentEvent::Complete { usage } => {
245            if !prefs.on_run_complete {
246                return None;
247            }
248            Some(ClassifiedNotification {
249                category: NotificationCategory::RunCompleted,
250                priority: NotificationPriority::Normal,
251                title: "Run complete".to_string(),
252                body: format!(
253                    "Session {session_id} finished ({} tokens).",
254                    usage.total_tokens
255                ),
256                dedup_key: format!("run:{session_id}:done"),
257            })
258        }
259
260        AgentEvent::Error { message } => {
261            if !prefs.on_run_failed {
262                return None;
263            }
264            Some(ClassifiedNotification {
265                category: NotificationCategory::RunFailed,
266                priority: NotificationPriority::High,
267                title: "Run failed".to_string(),
268                body: truncate(message, RUN_FAILED_BODY_MAX),
269                dedup_key: format!("run:{session_id}:failed"),
270            })
271        }
272
273        _ => None,
274    }
275}
276
277/// Classifies a caller-supplied notification (from the `notify` tool) into a
278/// candidate notification.
279///
280/// Unlike [`classify`], this does not gate on a per-category preference —
281/// there is no dedicated preference for arbitrary agent-chosen content, only
282/// the master [`NotificationPreferences::enabled`] switch applies here. The
283/// dedup key hashes `title` + `body` so an identical repeated call within the
284/// dedup window is coalesced while distinct content from the same session
285/// still notifies. Kept in `policy` (not `service`) to stay pure and
286/// unit-testable without a service instance.
287pub fn classify_custom(
288    session_id: &str,
289    title: &str,
290    body: &str,
291    priority: NotificationPriority,
292    prefs: &NotificationPreferences,
293) -> Option<ClassifiedNotification> {
294    if !prefs.enabled {
295        return None;
296    }
297    let mut hasher = DefaultHasher::new();
298    title.hash(&mut hasher);
299    body.hash(&mut hasher);
300    let digest = hasher.finish();
301    Some(ClassifiedNotification {
302        category: NotificationCategory::Custom,
303        priority,
304        title: title.to_string(),
305        body: body.to_string(),
306        dedup_key: format!("custom:{session_id}:{digest:x}"),
307    })
308}
309
310/// Classifies a schedule-run completion/failure into a notification.
311///
312/// Unlike [`classify`], `title`/`body` are caller-supplied rather than
313/// derived from a raw `AgentEvent` — the schedule manager can name the
314/// schedule and quote the run's final assistant message, which the generic
315/// `AgentEvent::Complete`/`Error` path has no way to know. Everything else
316/// (category, priority, prefs gate, and — critically — the dedup key) is
317/// deliberately IDENTICAL to what [`classify`] derives for
318/// `AgentEvent::Complete`/`Error`: `"run:{session_id}:done"` /
319/// `"run:{session_id}:failed"`. A scheduled run's agent loop still emits that
320/// raw event, which the always-on notification relay independently
321/// classifies via [`classify`] — sharing the dedup key means whichever of the
322/// two sources reaches [`crate::service::NotificationService`] first wins the
323/// user-visible copy and the second is coalesced within
324/// [`crate::service::NotificationService::DEDUP_WINDOW`], so a scheduled run
325/// can never double-notify its owner. See
326/// `bamboo_server::schedule_app::manager`'s completion hook for the call
327/// site.
328pub fn classify_schedule_run(
329    session_id: &str,
330    success: bool,
331    title: String,
332    body: String,
333    prefs: &NotificationPreferences,
334) -> Option<ClassifiedNotification> {
335    if !prefs.enabled {
336        return None;
337    }
338    if success {
339        if !prefs.on_run_complete {
340            return None;
341        }
342        Some(ClassifiedNotification {
343            category: NotificationCategory::RunCompleted,
344            priority: NotificationPriority::Normal,
345            title,
346            body,
347            dedup_key: format!("run:{session_id}:done"),
348        })
349    } else {
350        if !prefs.on_run_failed {
351            return None;
352        }
353        Some(ClassifiedNotification {
354            category: NotificationCategory::RunFailed,
355            priority: NotificationPriority::High,
356            title,
357            body,
358            dedup_key: format!("run:{session_id}:failed"),
359        })
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    fn clarification(question: &str, tool_call_id: Option<&str>) -> AgentEvent {
368        AgentEvent::NeedClarification {
369            question: question.to_string(),
370            options: None,
371            tool_call_id: tool_call_id.map(str::to_string),
372            tool_name: None,
373            allow_custom: true,
374        }
375    }
376
377    fn context(level: &str) -> AgentEvent {
378        AgentEvent::ContextPressureNotification {
379            percent: 95.0,
380            level: level.to_string(),
381            message: "Context is nearly full.".to_string(),
382        }
383    }
384
385    fn subagent(status: &str) -> AgentEvent {
386        AgentEvent::SubAgentCompleted {
387            parent_session_id: "parent".to_string(),
388            child_session_id: "child-1".to_string(),
389            status: status.to_string(),
390            error: None,
391        }
392    }
393
394    fn bash(bash_id: &str, status: &str, exit_code: Option<i32>) -> AgentEvent {
395        AgentEvent::BashCompleted {
396            bash_id: bash_id.to_string(),
397            command: "npm run build".to_string(),
398            exit_code,
399            status: status.to_string(),
400        }
401    }
402
403    fn complete(total_tokens: u64) -> AgentEvent {
404        AgentEvent::Complete {
405            usage: bamboo_agent_core::TokenUsage {
406                prompt_tokens: total_tokens / 2,
407                completion_tokens: total_tokens - total_tokens / 2,
408                total_tokens,
409            },
410        }
411    }
412
413    fn error(message: &str) -> AgentEvent {
414        AgentEvent::Error {
415            message: message.to_string(),
416        }
417    }
418
419    #[test]
420    fn plain_clarification_is_needs_clarification() {
421        let prefs = NotificationPreferences::default();
422        let event = clarification("Which file should I edit?", Some("tc-1"));
423        let result = classify("sess", &event, &prefs).unwrap();
424        assert_eq!(result.category, NotificationCategory::NeedsClarification);
425        assert_eq!(result.priority, NotificationPriority::High);
426        assert_eq!(result.title, "Your input needed");
427        assert_eq!(result.dedup_key, "clarification:sess:tc-1");
428    }
429
430    #[test]
431    fn permission_prompt_is_needs_approval() {
432        let prefs = NotificationPreferences::default();
433        let event = clarification("**Permission required** to run `rm -rf`", Some("tc-2"));
434        let result = classify("sess", &event, &prefs).unwrap();
435        assert_eq!(result.category, NotificationCategory::NeedsApproval);
436        assert_eq!(result.title, "Approval needed");
437    }
438
439    #[test]
440    fn permission_request_marker_also_matches() {
441        let prefs = NotificationPreferences::default();
442        let event = clarification("Permission Request: write to disk", None);
443        let result = classify("sess", &event, &prefs).unwrap();
444        assert_eq!(result.category, NotificationCategory::NeedsApproval);
445        // No tool_call_id → empty trailing segment.
446        assert_eq!(result.dedup_key, "clarification:sess:");
447    }
448
449    #[test]
450    fn clarification_body_truncates_with_ellipsis() {
451        let prefs = NotificationPreferences::default();
452        let long = "x".repeat(200);
453        let event = clarification(&long, None);
454        let result = classify("sess", &event, &prefs).unwrap();
455        assert_eq!(result.body.chars().count(), CLARIFICATION_BODY_MAX + 1);
456        assert!(result.body.ends_with('…'));
457    }
458
459    #[test]
460    fn clarification_gated_by_on_clarification() {
461        let prefs = NotificationPreferences {
462            on_clarification: false,
463            ..NotificationPreferences::default()
464        };
465        let event = clarification("plain question", None);
466        assert!(classify("sess", &event, &prefs).is_none());
467    }
468
469    #[test]
470    fn approval_gated_by_on_tool_approval() {
471        let prefs = NotificationPreferences {
472            on_tool_approval: false,
473            ..NotificationPreferences::default()
474        };
475        let event = clarification("Permission required: do thing", None);
476        assert!(classify("sess", &event, &prefs).is_none());
477    }
478
479    #[test]
480    fn context_critical_fires() {
481        let prefs = NotificationPreferences::default();
482        let result = classify("sess", &context("critical"), &prefs).unwrap();
483        assert_eq!(result.category, NotificationCategory::ContextCritical);
484        assert_eq!(result.priority, NotificationPriority::Normal);
485        assert_eq!(result.title, "Context almost full");
486        assert_eq!(result.body, "Context is nearly full.");
487        assert_eq!(result.dedup_key, "context:sess");
488    }
489
490    #[test]
491    fn context_warning_is_none() {
492        let prefs = NotificationPreferences::default();
493        assert!(classify("sess", &context("warning"), &prefs).is_none());
494    }
495
496    #[test]
497    fn context_gated_by_on_context_pressure() {
498        let prefs = NotificationPreferences {
499            on_context_pressure: false,
500            ..NotificationPreferences::default()
501        };
502        assert!(classify("sess", &context("critical"), &prefs).is_none());
503    }
504
505    #[test]
506    fn subagent_completed_fires() {
507        let prefs = NotificationPreferences::default();
508        let result = classify("sess", &subagent("completed"), &prefs).unwrap();
509        assert_eq!(result.category, NotificationCategory::SubagentCompleted);
510        assert_eq!(result.priority, NotificationPriority::Normal);
511        assert_eq!(result.title, "Background task done");
512        assert_eq!(result.body, "A background task finished (child-1).");
513        assert_eq!(result.dedup_key, "subagent:child-1");
514    }
515
516    #[test]
517    fn subagent_error_is_none() {
518        let prefs = NotificationPreferences::default();
519        assert!(classify("sess", &subagent("error"), &prefs).is_none());
520    }
521
522    #[test]
523    fn subagent_gated_by_on_subagent_complete() {
524        let prefs = NotificationPreferences {
525            on_subagent_complete: false,
526            ..NotificationPreferences::default()
527        };
528        assert!(classify("sess", &subagent("completed"), &prefs).is_none());
529    }
530
531    #[test]
532    fn bash_completed_fires_and_dedups_by_bash_id() {
533        let prefs = NotificationPreferences::default();
534        let result = classify("sess", &bash("sh-9", "completed", Some(0)), &prefs).unwrap();
535        assert_eq!(
536            result.category,
537            NotificationCategory::BackgroundTaskCompleted
538        );
539        assert_eq!(result.priority, NotificationPriority::Normal);
540        assert_eq!(result.title, "Background command finished");
541        assert!(result.body.contains("npm run build"));
542        assert!(result.body.contains("exit 0"));
543        // Keyed on bash_id (not session), so distinct shells each notify.
544        assert_eq!(result.dedup_key, "bash:sh-9");
545    }
546
547    #[test]
548    fn bash_error_status_still_fires() {
549        let prefs = NotificationPreferences::default();
550        let result = classify("sess", &bash("sh-e", "error", None), &prefs).unwrap();
551        assert_eq!(
552            result.category,
553            NotificationCategory::BackgroundTaskCompleted
554        );
555        assert!(result.body.contains("no exit code"));
556    }
557
558    #[test]
559    fn bash_killed_is_none() {
560        // A user-killed shell must not ping (the user initiated the kill).
561        let prefs = NotificationPreferences::default();
562        assert!(classify("sess", &bash("sh-k", "killed", None), &prefs).is_none());
563    }
564
565    #[test]
566    fn bash_gated_by_on_background_task_complete() {
567        let prefs = NotificationPreferences {
568            on_background_task_complete: false,
569            ..NotificationPreferences::default()
570        };
571        assert!(classify("sess", &bash("sh-1", "completed", Some(0)), &prefs).is_none());
572    }
573
574    #[test]
575    fn master_switch_off_yields_none() {
576        let prefs = NotificationPreferences {
577            enabled: false,
578            ..NotificationPreferences::default()
579        };
580        assert!(classify("sess", &context("critical"), &prefs).is_none());
581        assert!(classify("sess", &subagent("completed"), &prefs).is_none());
582        assert!(classify("sess", &clarification("q", None), &prefs).is_none());
583        assert!(classify("sess", &bash("sh-1", "completed", Some(0)), &prefs).is_none());
584        assert!(classify("sess", &complete(100), &prefs).is_none());
585        assert!(classify("sess", &error("boom"), &prefs).is_none());
586        assert!(classify_custom("sess", "t", "b", NotificationPriority::Low, &prefs).is_none());
587    }
588
589    #[test]
590    fn unrelated_variant_is_none() {
591        let prefs = NotificationPreferences::default();
592        let event = AgentEvent::Token {
593            content: "hello".to_string(),
594        };
595        assert!(classify("sess", &event, &prefs).is_none());
596    }
597
598    #[test]
599    fn run_completed_fires_with_token_usage_in_body() {
600        let prefs = NotificationPreferences::default();
601        let result = classify("sess-1", &complete(1234), &prefs).unwrap();
602        assert_eq!(result.category, NotificationCategory::RunCompleted);
603        assert_eq!(result.priority, NotificationPriority::Normal);
604        assert_eq!(result.title, "Run complete");
605        assert!(result.body.contains("sess-1"));
606        assert!(result.body.contains("1234"));
607        assert_eq!(result.dedup_key, "run:sess-1:done");
608    }
609
610    #[test]
611    fn run_completed_gated_by_on_run_complete() {
612        let prefs = NotificationPreferences {
613            on_run_complete: false,
614            ..NotificationPreferences::default()
615        };
616        assert!(classify("sess", &complete(10), &prefs).is_none());
617    }
618
619    #[test]
620    fn run_failed_fires_with_truncated_message() {
621        let prefs = NotificationPreferences::default();
622        let long_message = "x".repeat(300);
623        let result = classify("sess-2", &error(&long_message), &prefs).unwrap();
624        assert_eq!(result.category, NotificationCategory::RunFailed);
625        assert_eq!(result.priority, NotificationPriority::High);
626        assert_eq!(result.title, "Run failed");
627        assert_eq!(result.body.chars().count(), RUN_FAILED_BODY_MAX + 1);
628        assert!(result.body.ends_with('…'));
629        assert_eq!(result.dedup_key, "run:sess-2:failed");
630    }
631
632    #[test]
633    fn run_failed_gated_by_on_run_failed() {
634        let prefs = NotificationPreferences {
635            on_run_failed: false,
636            ..NotificationPreferences::default()
637        };
638        assert!(classify("sess", &error("boom"), &prefs).is_none());
639    }
640
641    #[test]
642    fn classify_custom_mints_custom_category_with_hashed_dedup_key() {
643        let prefs = NotificationPreferences::default();
644        let result =
645            classify_custom("sess-3", "Title", "Body", NotificationPriority::Low, &prefs).unwrap();
646        assert_eq!(result.category, NotificationCategory::Custom);
647        assert_eq!(result.priority, NotificationPriority::Low);
648        assert_eq!(result.title, "Title");
649        assert_eq!(result.body, "Body");
650        assert!(result.dedup_key.starts_with("custom:sess-3:"));
651    }
652
653    #[test]
654    fn classify_custom_dedup_key_is_stable_for_identical_content() {
655        let prefs = NotificationPreferences::default();
656        let a =
657            classify_custom("sess", "Title", "Body", NotificationPriority::Low, &prefs).unwrap();
658        let b =
659            classify_custom("sess", "Title", "Body", NotificationPriority::Low, &prefs).unwrap();
660        assert_eq!(a.dedup_key, b.dedup_key);
661    }
662
663    #[test]
664    fn classify_custom_dedup_key_differs_for_different_content() {
665        let prefs = NotificationPreferences::default();
666        let a =
667            classify_custom("sess", "Title", "Body", NotificationPriority::Low, &prefs).unwrap();
668        let b = classify_custom(
669            "sess",
670            "Title",
671            "Different body",
672            NotificationPriority::Low,
673            &prefs,
674        )
675        .unwrap();
676        assert_ne!(a.dedup_key, b.dedup_key);
677    }
678
679    #[test]
680    fn classify_custom_ignores_per_category_prefs() {
681        // classify_custom must not be gated by any per-category flag — only
682        // the master `enabled` switch applies.
683        let prefs = NotificationPreferences {
684            on_clarification: false,
685            on_tool_approval: false,
686            on_context_pressure: false,
687            on_subagent_complete: false,
688            on_background_task_complete: false,
689            on_run_complete: false,
690            on_run_failed: false,
691            ..NotificationPreferences::default()
692        };
693        assert!(classify_custom("sess", "t", "b", NotificationPriority::Low, &prefs).is_some());
694    }
695
696    #[test]
697    fn classify_schedule_run_success_shares_dedup_key_with_classify_complete() {
698        let prefs = NotificationPreferences::default();
699        let scheduled = classify_schedule_run(
700            "sess-1",
701            true,
702            "Schedule 'nightly' completed".to_string(),
703            "All done.".to_string(),
704            &prefs,
705        )
706        .unwrap();
707        let raw = classify("sess-1", &complete(10), &prefs).unwrap();
708        assert_eq!(scheduled.dedup_key, raw.dedup_key);
709        assert_eq!(scheduled.category, NotificationCategory::RunCompleted);
710        assert_eq!(scheduled.priority, NotificationPriority::Normal);
711        assert_eq!(scheduled.title, "Schedule 'nightly' completed");
712        assert_eq!(scheduled.body, "All done.");
713    }
714
715    #[test]
716    fn classify_schedule_run_failure_shares_dedup_key_with_classify_error() {
717        let prefs = NotificationPreferences::default();
718        let scheduled = classify_schedule_run(
719            "sess-2",
720            false,
721            "Schedule 'nightly' failed".to_string(),
722            "boom".to_string(),
723            &prefs,
724        )
725        .unwrap();
726        let raw = classify("sess-2", &error("boom"), &prefs).unwrap();
727        assert_eq!(scheduled.dedup_key, raw.dedup_key);
728        assert_eq!(scheduled.category, NotificationCategory::RunFailed);
729        assert_eq!(scheduled.priority, NotificationPriority::High);
730    }
731
732    #[test]
733    fn classify_schedule_run_gated_by_on_run_complete_and_on_run_failed() {
734        let complete_off = NotificationPreferences {
735            on_run_complete: false,
736            ..NotificationPreferences::default()
737        };
738        assert!(classify_schedule_run(
739            "sess",
740            true,
741            "t".to_string(),
742            "b".to_string(),
743            &complete_off
744        )
745        .is_none());
746
747        let failed_off = NotificationPreferences {
748            on_run_failed: false,
749            ..NotificationPreferences::default()
750        };
751        assert!(classify_schedule_run(
752            "sess",
753            false,
754            "t".to_string(),
755            "b".to_string(),
756            &failed_off
757        )
758        .is_none());
759    }
760
761    #[test]
762    fn classify_schedule_run_master_switch_off_yields_none() {
763        let prefs = NotificationPreferences {
764            enabled: false,
765            ..NotificationPreferences::default()
766        };
767        assert!(
768            classify_schedule_run("sess", true, "t".to_string(), "b".to_string(), &prefs).is_none()
769        );
770        assert!(
771            classify_schedule_run("sess", false, "t".to_string(), "b".to_string(), &prefs)
772                .is_none()
773        );
774    }
775}