Skip to main content

ralph/notification/
config.rs

1//! Notification configuration resolution.
2//!
3//! Responsibilities:
4//! - Define CLI override and runtime configuration types for notifications.
5//! - Merge config-file values with CLI overrides into a resolved runtime config.
6//!
7//! Does NOT handle:
8//! - Notification display or sound playback.
9//! - UI activity detection beyond suppression flags.
10//!
11//! Invariants:
12//! - Runtime config defaults remain explicit and stable.
13//! - CLI override precedence is preserved over stored configuration.
14
15/// CLI overrides for notification settings.
16/// Fields are `Option<bool>` to distinguish "not set" from explicit false.
17#[derive(Debug, Clone, Default)]
18pub struct NotificationOverrides {
19    /// Override notify_on_complete from CLI.
20    pub notify_on_complete: Option<bool>,
21    /// Override notify_on_fail from CLI.
22    pub notify_on_fail: Option<bool>,
23    /// Override sound_enabled from CLI.
24    pub notify_sound: Option<bool>,
25}
26
27/// Configuration for desktop notifications.
28#[derive(Debug, Clone, Default)]
29pub struct NotificationConfig {
30    /// Enable desktop notifications on task completion (legacy field).
31    pub enabled: bool,
32    /// Enable desktop notifications on task completion.
33    pub notify_on_complete: bool,
34    /// Enable desktop notifications on task failure.
35    pub notify_on_fail: bool,
36    /// Enable desktop notifications when loop mode completes.
37    pub notify_on_loop_complete: bool,
38    /// Enable desktop notifications when watch mode adds new tasks from comments.
39    pub notify_on_watch_new_tasks: bool,
40    /// Suppress notifications when a foreground UI client is active.
41    pub suppress_when_active: bool,
42    /// Enable sound alerts with notifications.
43    pub sound_enabled: bool,
44    /// Custom sound file path (platform-specific format).
45    /// If not set, uses platform default sounds.
46    pub sound_path: Option<String>,
47    /// Notification timeout in milliseconds (default: 8000).
48    pub timeout_ms: u32,
49}
50
51impl NotificationConfig {
52    /// Create a new config with sensible defaults.
53    pub fn new() -> Self {
54        Self {
55            enabled: true,
56            notify_on_complete: true,
57            notify_on_fail: true,
58            notify_on_loop_complete: true,
59            notify_on_watch_new_tasks: true,
60            suppress_when_active: true,
61            sound_enabled: false,
62            sound_path: None,
63            timeout_ms: 8000,
64        }
65    }
66
67    /// Check if notifications should be suppressed based on UI state.
68    pub fn should_suppress(&self, ui_active: bool) -> bool {
69        if !self.enabled {
70            return true;
71        }
72        ui_active && self.suppress_when_active
73    }
74}
75
76/// Build a runtime NotificationConfig from config and CLI overrides.
77///
78/// Precedence: CLI overrides > config values > defaults.
79pub fn build_notification_config(
80    config: &crate::contracts::NotificationConfig,
81    overrides: &NotificationOverrides,
82) -> NotificationConfig {
83    let notify_on_complete = overrides
84        .notify_on_complete
85        .or(config.notify_on_complete)
86        .unwrap_or(true);
87    let notify_on_fail = overrides
88        .notify_on_fail
89        .or(config.notify_on_fail)
90        .unwrap_or(true);
91    let notify_on_loop_complete = config.notify_on_loop_complete.unwrap_or(true);
92    let notify_on_watch_new_tasks = config.notify_on_watch_new_tasks.unwrap_or(true);
93
94    NotificationConfig {
95        enabled: notify_on_complete
96            || notify_on_fail
97            || notify_on_loop_complete
98            || notify_on_watch_new_tasks,
99        notify_on_complete,
100        notify_on_fail,
101        notify_on_loop_complete,
102        notify_on_watch_new_tasks,
103        suppress_when_active: config.suppress_when_active.unwrap_or(true),
104        sound_enabled: overrides
105            .notify_sound
106            .or(config.sound_enabled)
107            .unwrap_or(false),
108        sound_path: config.sound_path.clone(),
109        timeout_ms: config.timeout_ms.unwrap_or(8000),
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn build_notification_config_uses_defaults() {
119        let config = crate::contracts::NotificationConfig::default();
120        let overrides = NotificationOverrides::default();
121        let result = build_notification_config(&config, &overrides);
122
123        assert!(result.enabled);
124        assert!(result.notify_on_complete);
125        assert!(result.notify_on_fail);
126        assert!(result.notify_on_loop_complete);
127        assert!(result.notify_on_watch_new_tasks);
128        assert!(result.suppress_when_active);
129        assert!(!result.sound_enabled);
130        assert!(result.sound_path.is_none());
131        assert_eq!(result.timeout_ms, 8000);
132    }
133
134    #[test]
135    fn build_notification_config_overrides_take_precedence() {
136        let config = crate::contracts::NotificationConfig {
137            notify_on_complete: Some(false),
138            notify_on_fail: Some(false),
139            sound_enabled: Some(false),
140            ..Default::default()
141        };
142        let overrides = NotificationOverrides {
143            notify_on_complete: Some(true),
144            notify_on_fail: Some(true),
145            notify_sound: Some(true),
146        };
147        let result = build_notification_config(&config, &overrides);
148
149        assert!(result.notify_on_complete);
150        assert!(result.notify_on_fail);
151        assert!(result.sound_enabled);
152    }
153
154    #[test]
155    fn build_notification_config_config_used_when_no_override() {
156        let config = crate::contracts::NotificationConfig {
157            notify_on_complete: Some(false),
158            notify_on_fail: Some(true),
159            suppress_when_active: Some(false),
160            timeout_ms: Some(5000),
161            sound_path: Some("/path/to/sound.wav".to_string()),
162            ..Default::default()
163        };
164        let overrides = NotificationOverrides::default();
165        let result = build_notification_config(&config, &overrides);
166
167        assert!(!result.notify_on_complete);
168        assert!(result.notify_on_fail);
169        assert!(!result.suppress_when_active);
170        assert_eq!(result.timeout_ms, 5000);
171        assert_eq!(result.sound_path, Some("/path/to/sound.wav".to_string()));
172    }
173
174    #[test]
175    fn build_notification_config_enabled_computed_correctly() {
176        let config = crate::contracts::NotificationConfig {
177            notify_on_complete: Some(false),
178            notify_on_fail: Some(false),
179            notify_on_loop_complete: Some(false),
180            notify_on_watch_new_tasks: Some(false),
181            ..Default::default()
182        };
183        let overrides = NotificationOverrides::default();
184        let result = build_notification_config(&config, &overrides);
185        assert!(!result.enabled);
186
187        let config = crate::contracts::NotificationConfig {
188            notify_on_complete: Some(true),
189            notify_on_fail: Some(false),
190            notify_on_loop_complete: Some(false),
191            ..Default::default()
192        };
193        let result = build_notification_config(&config, &overrides);
194        assert!(result.enabled);
195
196        let config = crate::contracts::NotificationConfig {
197            notify_on_complete: Some(false),
198            notify_on_fail: Some(false),
199            notify_on_loop_complete: Some(false),
200            notify_on_watch_new_tasks: Some(true),
201            ..Default::default()
202        };
203        let result = build_notification_config(&config, &overrides);
204        assert!(result.enabled);
205        assert!(result.notify_on_watch_new_tasks);
206    }
207}