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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct NotificationPreferences {
25    /// Master switch. When `false`, nothing is ever notified.
26    #[serde(default = "default_true")]
27    pub enabled: bool,
28    /// Notify when the agent needs free-form clarification from the user.
29    #[serde(default = "default_true")]
30    pub on_clarification: bool,
31    /// Notify when a tool requires explicit user approval.
32    #[serde(default = "default_true")]
33    pub on_tool_approval: bool,
34    /// Notify when context usage reaches a critical level.
35    #[serde(default = "default_true")]
36    pub on_context_pressure: bool,
37    /// Notify when a background sub-agent task completes.
38    #[serde(default = "default_true")]
39    pub on_subagent_complete: bool,
40}
41
42impl Default for NotificationPreferences {
43    fn default() -> Self {
44        Self {
45            enabled: true,
46            on_clarification: true,
47            on_tool_approval: true,
48            on_context_pressure: true,
49            on_subagent_complete: true,
50        }
51    }
52}
53
54impl NotificationPreferences {
55    /// Loads preferences from `path`.
56    ///
57    /// Returns [`Default`] when the file is missing (a fresh install) so the
58    /// caller never has to special-case first run. A file that exists but fails
59    /// to parse is logged via [`tracing::warn`] and also falls back to
60    /// [`Default`], so a corrupt file never disables notifications silently.
61    pub fn load(path: &Path) -> Self {
62        let contents = match std::fs::read_to_string(path) {
63            Ok(contents) => contents,
64            Err(_) => return Self::default(),
65        };
66        match serde_json::from_str(&contents) {
67            Ok(prefs) => prefs,
68            Err(err) => {
69                tracing::warn!(
70                    path = %path.display(),
71                    error = %err,
72                    "failed to parse notification preferences; using defaults"
73                );
74                Self::default()
75            }
76        }
77    }
78
79    /// Persists preferences to `path` as pretty JSON.
80    ///
81    /// Creates the parent directory if it does not yet exist.
82    pub fn save(&self, path: &Path) -> std::io::Result<()> {
83        if let Some(parent) = path.parent() {
84            if !parent.as_os_str().is_empty() {
85                std::fs::create_dir_all(parent)?;
86            }
87        }
88        let json = serde_json::to_string_pretty(self)
89            .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
90        std::fs::write(path, json)
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn default_is_all_true() {
100        let prefs = NotificationPreferences::default();
101        assert!(prefs.enabled);
102        assert!(prefs.on_clarification);
103        assert!(prefs.on_tool_approval);
104        assert!(prefs.on_context_pressure);
105        assert!(prefs.on_subagent_complete);
106    }
107
108    #[test]
109    fn save_load_roundtrip() {
110        let dir = tempfile::tempdir().unwrap();
111        let path = dir.path().join("nested").join("prefs.json");
112        let prefs = NotificationPreferences {
113            enabled: true,
114            on_clarification: false,
115            on_tool_approval: true,
116            on_context_pressure: false,
117            on_subagent_complete: true,
118        };
119        prefs.save(&path).unwrap();
120        let loaded = NotificationPreferences::load(&path);
121        assert_eq!(prefs, loaded);
122    }
123
124    #[test]
125    fn missing_file_yields_default() {
126        let dir = tempfile::tempdir().unwrap();
127        let path = dir.path().join("does-not-exist.json");
128        assert_eq!(
129            NotificationPreferences::load(&path),
130            NotificationPreferences::default()
131        );
132    }
133
134    #[test]
135    fn partial_json_fills_missing_with_true() {
136        let dir = tempfile::tempdir().unwrap();
137        let path = dir.path().join("partial.json");
138        std::fs::write(&path, r#"{"on_clarification": false}"#).unwrap();
139        let loaded = NotificationPreferences::load(&path);
140        assert!(!loaded.on_clarification);
141        // Every other field defaults to true.
142        assert!(loaded.enabled);
143        assert!(loaded.on_tool_approval);
144        assert!(loaded.on_context_pressure);
145        assert!(loaded.on_subagent_complete);
146    }
147}