Skip to main content

bamboo_notification/
preferences.rs

1//! User preferences gating which agent events become notifications.
2//!
3//! Each flag is an opt-out (defaulting to `true`) so that a freshly created
4//! preferences file — or one written by an older client that omits a key —
5//! still notifies on everything. Preferences are persisted as pretty JSON.
6
7use std::path::Path;
8
9use serde::{Deserialize, Serialize};
10
11fn default_true() -> bool {
12    true
13}
14
15/// Per-user notification opt-in flags.
16///
17/// `enabled` is the master switch; when `false`, no notifications are produced
18/// regardless of the per-category flags. Each per-category flag gates a single
19/// notification [`category`](crate::NotificationCategory).
20///
21/// Every field defaults to `true` (via `serde(default = ...)`), so missing keys
22/// in a partial JSON document are treated as opted-in.
23///
24/// # Whole-struct-PUT caveat
25///
26/// The server's `PUT /api/v1/notifications/preferences` endpoint deserializes
27/// this struct directly and replaces the persisted value wholesale — it is
28/// not a per-field patch. This has always meant that a client holding a
29/// stale in-memory copy (fetched before some field existed, or simply never
30/// refreshed) will silently reset any field it doesn't know about back to its
31/// `serde(default)` on its next save, clobbering whatever the user
32/// previously chose for it. This applies equally to every field below,
33/// including the two most recently added (`on_run_complete`,
34/// `on_run_failed`); frontends must always PUT back a full, freshly-fetched
35/// copy rather than a hand-built partial one.
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct NotificationPreferences {
38    /// Master switch. When `false`, nothing is ever notified.
39    #[serde(default = "default_true")]
40    pub enabled: bool,
41    /// Notify when the agent needs free-form clarification from the user.
42    #[serde(default = "default_true")]
43    pub on_clarification: bool,
44    /// Notify when a tool requires explicit user approval.
45    #[serde(default = "default_true")]
46    pub on_tool_approval: bool,
47    /// Notify when context usage reaches a critical level.
48    #[serde(default = "default_true")]
49    pub on_context_pressure: bool,
50    /// Notify when a background sub-agent task completes.
51    #[serde(default = "default_true")]
52    pub on_subagent_complete: bool,
53    /// Notify when a background shell/command (Bash `run_in_background`) finishes.
54    #[serde(default = "default_true")]
55    pub on_background_task_complete: bool,
56    /// Notify when a run finishes successfully (`AgentEvent::Complete`).
57    #[serde(default = "default_true")]
58    pub on_run_complete: bool,
59    /// Notify when a run fails (`AgentEvent::Error`).
60    #[serde(default = "default_true")]
61    pub on_run_failed: bool,
62}
63
64impl Default for NotificationPreferences {
65    fn default() -> Self {
66        Self {
67            enabled: true,
68            on_clarification: true,
69            on_tool_approval: true,
70            on_context_pressure: true,
71            on_subagent_complete: true,
72            on_background_task_complete: true,
73            on_run_complete: true,
74            on_run_failed: true,
75        }
76    }
77}
78
79impl NotificationPreferences {
80    /// Loads preferences from `path`.
81    ///
82    /// Returns [`Default`] when the file is missing (a fresh install) so the
83    /// caller never has to special-case first run. A file that exists but fails
84    /// to parse is logged via [`tracing::warn`] and also falls back to
85    /// [`Default`], so a corrupt file never disables notifications silently.
86    pub fn load(path: &Path) -> Self {
87        let contents = match std::fs::read_to_string(path) {
88            Ok(contents) => contents,
89            Err(_) => return Self::default(),
90        };
91        match serde_json::from_str(&contents) {
92            Ok(prefs) => prefs,
93            Err(err) => {
94                tracing::warn!(
95                    path = %path.display(),
96                    error = %err,
97                    "failed to parse notification preferences; using defaults"
98                );
99                Self::default()
100            }
101        }
102    }
103
104    /// Persists preferences to `path` as pretty JSON.
105    ///
106    /// Creates the parent directory if it does not yet exist.
107    pub fn save(&self, path: &Path) -> std::io::Result<()> {
108        if let Some(parent) = path.parent() {
109            if !parent.as_os_str().is_empty() {
110                std::fs::create_dir_all(parent)?;
111            }
112        }
113        let json = serde_json::to_string_pretty(self)
114            .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
115        std::fs::write(path, json)
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn default_is_all_true() {
125        let prefs = NotificationPreferences::default();
126        assert!(prefs.enabled);
127        assert!(prefs.on_clarification);
128        assert!(prefs.on_tool_approval);
129        assert!(prefs.on_context_pressure);
130        assert!(prefs.on_subagent_complete);
131        assert!(prefs.on_background_task_complete);
132        assert!(prefs.on_run_complete);
133        assert!(prefs.on_run_failed);
134    }
135
136    #[test]
137    fn save_load_roundtrip() {
138        let dir = tempfile::tempdir().unwrap();
139        let path = dir.path().join("nested").join("prefs.json");
140        let prefs = NotificationPreferences {
141            enabled: true,
142            on_clarification: false,
143            on_tool_approval: true,
144            on_context_pressure: false,
145            on_subagent_complete: true,
146            on_background_task_complete: false,
147            on_run_complete: false,
148            on_run_failed: true,
149        };
150        prefs.save(&path).unwrap();
151        let loaded = NotificationPreferences::load(&path);
152        assert_eq!(prefs, loaded);
153    }
154
155    #[test]
156    fn missing_file_yields_default() {
157        let dir = tempfile::tempdir().unwrap();
158        let path = dir.path().join("does-not-exist.json");
159        assert_eq!(
160            NotificationPreferences::load(&path),
161            NotificationPreferences::default()
162        );
163    }
164
165    #[test]
166    fn partial_json_fills_missing_with_true() {
167        let dir = tempfile::tempdir().unwrap();
168        let path = dir.path().join("partial.json");
169        std::fs::write(&path, r#"{"on_clarification": false}"#).unwrap();
170        let loaded = NotificationPreferences::load(&path);
171        assert!(!loaded.on_clarification);
172        // Every other field defaults to true.
173        assert!(loaded.enabled);
174        assert!(loaded.on_tool_approval);
175        assert!(loaded.on_context_pressure);
176        assert!(loaded.on_subagent_complete);
177        assert!(loaded.on_background_task_complete);
178        assert!(loaded.on_run_complete);
179        assert!(loaded.on_run_failed);
180    }
181
182    #[test]
183    fn new_keys_missing_from_an_older_clients_json_default_to_true() {
184        // Simulates a preferences file written by a client that predates
185        // on_run_complete/on_run_failed (the whole-struct-PUT caveat above).
186        let dir = tempfile::tempdir().unwrap();
187        let path = dir.path().join("old-client.json");
188        std::fs::write(
189            &path,
190            r#"{
191                "enabled": true,
192                "on_clarification": true,
193                "on_tool_approval": true,
194                "on_context_pressure": true,
195                "on_subagent_complete": true,
196                "on_background_task_complete": true
197            }"#,
198        )
199        .unwrap();
200        let loaded = NotificationPreferences::load(&path);
201        assert!(loaded.on_run_complete);
202        assert!(loaded.on_run_failed);
203    }
204}