gardesk/garnotify / 6d67c7e

Browse files

feat(rules): add notification filtering and modification rules

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
6d67c7e600d4c6587e02fb0bbedaddab048eb734
Parents
939c9b8
Tree
34cfbcd

7 changed files

StatusFile+-
M Cargo.toml 1 0
M garnotify/Cargo.toml 1 0
M garnotify/src/config.rs 6 0
M garnotify/src/main.rs 1 0
A garnotify/src/rules/actions.rs 204 0
A garnotify/src/rules/matcher.rs 113 0
A garnotify/src/rules/mod.rs 209 0
Cargo.tomlmodified
@@ -48,6 +48,7 @@ thiserror = "1.0"
4848
 # Utilities
4949
 dirs = "5.0"
5050
 shellexpand = "3.0"
51
+regex = "1.10"
5152
 
5253
 # Icon loading
5354
 image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
garnotify/Cargo.tomlmodified
@@ -45,6 +45,7 @@ thiserror = { workspace = true }
4545
 # Utilities
4646
 dirs = { workspace = true }
4747
 shellexpand = { workspace = true }
48
+regex = { workspace = true }
4849
 
4950
 # Icon loading
5051
 image = { workspace = true }
garnotify/src/config.rsmodified
@@ -4,6 +4,8 @@ use anyhow::{Context, Result};
44
 use serde::{Deserialize, Serialize};
55
 use std::path::PathBuf;
66
 
7
+use crate::rules::Rule;
8
+
79
 /// Get the default configuration file path
810
 fn config_path() -> PathBuf {
911
     dirs::config_dir()
@@ -22,6 +24,9 @@ pub struct Config {
2224
     pub appearance: AppearanceConfig,
2325
     pub animation: AnimationConfig,
2426
     pub history: HistoryConfig,
27
+    /// Notification rules (use [[rules]] in TOML)
28
+    #[serde(rename = "rules", default)]
29
+    pub rules: Vec<Rule>,
2530
 }
2631
 
2732
 impl Default for Config {
@@ -33,6 +38,7 @@ impl Default for Config {
3338
             appearance: AppearanceConfig::default(),
3439
             animation: AnimationConfig::default(),
3540
             history: HistoryConfig::default(),
41
+            rules: Vec::new(),
3642
         }
3743
     }
3844
 }
garnotify/src/main.rsmodified
@@ -13,6 +13,7 @@ mod daemon;
1313
 mod dbus;
1414
 mod ipc;
1515
 mod notification;
16
+mod rules;
1617
 mod ui;
1718
 
1819
 /// garnotify - Notification daemon for gar desktop
garnotify/src/rules/actions.rsadded
@@ -0,0 +1,204 @@
1
+//! Rule actions for modifying notifications
2
+
3
+use serde::{Deserialize, Serialize};
4
+use tracing::debug;
5
+
6
+use crate::notification::{Notification, Urgency};
7
+
8
+/// Actions that can be performed on a matched notification
9
+#[derive(Debug, Clone, Serialize, Deserialize)]
10
+#[serde(tag = "type", rename_all = "snake_case")]
11
+pub enum RuleAction {
12
+    /// Suppress the notification (don't show it)
13
+    Suppress,
14
+
15
+    /// Set the urgency level
16
+    SetUrgency {
17
+        urgency: String, // "low", "normal", "critical"
18
+    },
19
+
20
+    /// Override the timeout (in milliseconds, 0 = use default, -1 = never expire)
21
+    SetTimeout { timeout: i32 },
22
+
23
+    /// Replace the summary text
24
+    SetSummary { summary: String },
25
+
26
+    /// Replace the body text
27
+    SetBody { body: String },
28
+
29
+    /// Append text to the body
30
+    AppendBody { text: String },
31
+
32
+    /// Prepend text to the summary
33
+    PrependSummary { text: String },
34
+
35
+    /// Set a custom sound (hint)
36
+    SetSound { sound: String },
37
+
38
+    /// Mark as transient (won't be saved to history)
39
+    SetTransient { transient: bool },
40
+
41
+    /// Execute a shell command (the notification data is available as env vars)
42
+    Exec { command: String },
43
+}
44
+
45
+impl RuleAction {
46
+    /// Apply this action to a notification
47
+    /// Returns None if the notification should be suppressed
48
+    pub fn apply(&self, notification: &mut Notification) -> Option<()> {
49
+        match self {
50
+            RuleAction::Suppress => {
51
+                debug!("Suppressing notification {}", notification.id);
52
+                return None;
53
+            }
54
+
55
+            RuleAction::SetUrgency { urgency } => {
56
+                let new_urgency = match urgency.to_lowercase().as_str() {
57
+                    "low" => Urgency::Low,
58
+                    "normal" => Urgency::Normal,
59
+                    "critical" => Urgency::Critical,
60
+                    _ => {
61
+                        debug!("Invalid urgency '{}', ignoring", urgency);
62
+                        return Some(());
63
+                    }
64
+                };
65
+                debug!(
66
+                    "Setting urgency for notification {} to {:?}",
67
+                    notification.id, new_urgency
68
+                );
69
+                notification.hints.urgency = new_urgency;
70
+            }
71
+
72
+            RuleAction::SetTimeout { timeout } => {
73
+                debug!(
74
+                    "Setting timeout for notification {} to {}ms",
75
+                    notification.id, timeout
76
+                );
77
+                notification.expire_timeout = *timeout;
78
+            }
79
+
80
+            RuleAction::SetSummary { summary } => {
81
+                debug!(
82
+                    "Setting summary for notification {} to '{}'",
83
+                    notification.id, summary
84
+                );
85
+                notification.summary = summary.clone();
86
+            }
87
+
88
+            RuleAction::SetBody { body } => {
89
+                debug!(
90
+                    "Setting body for notification {} to '{}'",
91
+                    notification.id, body
92
+                );
93
+                notification.body = body.clone();
94
+            }
95
+
96
+            RuleAction::AppendBody { text } => {
97
+                debug!("Appending to body of notification {}", notification.id);
98
+                notification.body.push_str(text);
99
+            }
100
+
101
+            RuleAction::PrependSummary { text } => {
102
+                debug!("Prepending to summary of notification {}", notification.id);
103
+                notification.summary = format!("{}{}", text, notification.summary);
104
+            }
105
+
106
+            RuleAction::SetSound { sound } => {
107
+                debug!(
108
+                    "Setting sound for notification {} to '{}'",
109
+                    notification.id, sound
110
+                );
111
+                notification.hints.sound_name = Some(sound.clone());
112
+            }
113
+
114
+            RuleAction::SetTransient { transient } => {
115
+                debug!(
116
+                    "Setting transient for notification {} to {}",
117
+                    notification.id, transient
118
+                );
119
+                notification.hints.transient = *transient;
120
+            }
121
+
122
+            RuleAction::Exec { command } => {
123
+                debug!(
124
+                    "Executing command for notification {}: {}",
125
+                    notification.id, command
126
+                );
127
+                // Execute command in background with notification data as env vars
128
+                let cmd = command.clone();
129
+                let app_name = notification.app_name.clone();
130
+                let summary = notification.summary.clone();
131
+                let body = notification.body.clone();
132
+                let id = notification.id;
133
+
134
+                std::thread::spawn(move || {
135
+                    let result = std::process::Command::new("sh")
136
+                        .arg("-c")
137
+                        .arg(&cmd)
138
+                        .env("NOTIFICATION_ID", id.to_string())
139
+                        .env("NOTIFICATION_APP", &app_name)
140
+                        .env("NOTIFICATION_SUMMARY", &summary)
141
+                        .env("NOTIFICATION_BODY", &body)
142
+                        .output();
143
+
144
+                    if let Err(e) = result {
145
+                        tracing::warn!("Failed to execute rule command '{}': {}", cmd, e);
146
+                    }
147
+                });
148
+            }
149
+        }
150
+
151
+        Some(())
152
+    }
153
+}
154
+
155
+#[cfg(test)]
156
+mod tests {
157
+    use super::*;
158
+
159
+    #[test]
160
+    fn test_suppress_action() {
161
+        let action = RuleAction::Suppress;
162
+        let mut notif = Notification::default();
163
+        assert!(action.apply(&mut notif).is_none());
164
+    }
165
+
166
+    #[test]
167
+    fn test_set_urgency() {
168
+        let action = RuleAction::SetUrgency {
169
+            urgency: "critical".to_string(),
170
+        };
171
+        let mut notif = Notification::default();
172
+        assert!(action.apply(&mut notif).is_some());
173
+        assert_eq!(notif.hints.urgency, Urgency::Critical);
174
+    }
175
+
176
+    #[test]
177
+    fn test_set_timeout() {
178
+        let action = RuleAction::SetTimeout { timeout: 10000 };
179
+        let mut notif = Notification::default();
180
+        action.apply(&mut notif);
181
+        assert_eq!(notif.expire_timeout, 10000);
182
+    }
183
+
184
+    #[test]
185
+    fn test_set_summary() {
186
+        let action = RuleAction::SetSummary {
187
+            summary: "New Summary".to_string(),
188
+        };
189
+        let mut notif = Notification::default();
190
+        action.apply(&mut notif);
191
+        assert_eq!(notif.summary, "New Summary");
192
+    }
193
+
194
+    #[test]
195
+    fn test_prepend_summary() {
196
+        let action = RuleAction::PrependSummary {
197
+            text: "[Important] ".to_string(),
198
+        };
199
+        let mut notif = Notification::default();
200
+        notif.summary = "Original".to_string();
201
+        action.apply(&mut notif);
202
+        assert_eq!(notif.summary, "[Important] Original");
203
+    }
204
+}
garnotify/src/rules/matcher.rsadded
@@ -0,0 +1,113 @@
1
+//! Pattern matching for notification rules
2
+//!
3
+//! Supports glob patterns (* and ?) and regex (prefixed with ~)
4
+
5
+use regex::Regex;
6
+
7
+/// Pattern matcher for rule conditions
8
+pub struct Matcher;
9
+
10
+impl Matcher {
11
+    /// Check if a pattern matches a string
12
+    ///
13
+    /// Pattern types:
14
+    /// - Simple string: exact match (case-insensitive)
15
+    /// - Glob pattern: * matches any chars, ? matches single char
16
+    /// - Regex: prefix with ~ (e.g., "~.*error.*")
17
+    pub fn matches(pattern: &str, text: &str) -> bool {
18
+        if pattern.is_empty() {
19
+            return true;
20
+        }
21
+
22
+        // Regex pattern (starts with ~)
23
+        if let Some(regex_pattern) = pattern.strip_prefix('~') {
24
+            return Self::matches_regex(regex_pattern, text);
25
+        }
26
+
27
+        // Glob pattern (contains * or ?)
28
+        if pattern.contains('*') || pattern.contains('?') {
29
+            return Self::matches_glob(pattern, text);
30
+        }
31
+
32
+        // Simple case-insensitive substring match
33
+        text.to_lowercase().contains(&pattern.to_lowercase())
34
+    }
35
+
36
+    /// Match using a regex pattern
37
+    fn matches_regex(pattern: &str, text: &str) -> bool {
38
+        match Regex::new(pattern) {
39
+            Ok(re) => re.is_match(text),
40
+            Err(_) => {
41
+                // Invalid regex, fall back to literal match
42
+                text.contains(pattern)
43
+            }
44
+        }
45
+    }
46
+
47
+    /// Match using a glob pattern
48
+    fn matches_glob(pattern: &str, text: &str) -> bool {
49
+        let pattern = pattern.to_lowercase();
50
+        let text = text.to_lowercase();
51
+
52
+        // Convert glob to regex
53
+        let mut regex_pattern = String::from("^");
54
+        for ch in pattern.chars() {
55
+            match ch {
56
+                '*' => regex_pattern.push_str(".*"),
57
+                '?' => regex_pattern.push('.'),
58
+                // Escape regex special characters
59
+                '.' | '+' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '$' | '|' | '\\' => {
60
+                    regex_pattern.push('\\');
61
+                    regex_pattern.push(ch);
62
+                }
63
+                _ => regex_pattern.push(ch),
64
+            }
65
+        }
66
+        regex_pattern.push('$');
67
+
68
+        match Regex::new(&regex_pattern) {
69
+            Ok(re) => re.is_match(&text),
70
+            Err(_) => false,
71
+        }
72
+    }
73
+}
74
+
75
+#[cfg(test)]
76
+mod tests {
77
+    use super::*;
78
+
79
+    #[test]
80
+    fn test_simple_match() {
81
+        assert!(Matcher::matches("firefox", "Firefox"));
82
+        assert!(Matcher::matches("Firefox", "Mozilla Firefox"));
83
+        assert!(!Matcher::matches("chrome", "Firefox"));
84
+    }
85
+
86
+    #[test]
87
+    fn test_glob_star() {
88
+        assert!(Matcher::matches("fire*", "Firefox"));
89
+        assert!(Matcher::matches("*fox", "Firefox"));
90
+        assert!(Matcher::matches("*ire*", "Firefox"));
91
+        assert!(!Matcher::matches("chrome*", "Firefox"));
92
+    }
93
+
94
+    #[test]
95
+    fn test_glob_question() {
96
+        assert!(Matcher::matches("Firefo?", "Firefox"));
97
+        assert!(Matcher::matches("?irefox", "Firefox"));
98
+        assert!(!Matcher::matches("Firefo?", "Firefoxx"));
99
+    }
100
+
101
+    #[test]
102
+    fn test_regex() {
103
+        assert!(Matcher::matches("~[Ff]irefox", "Firefox"));
104
+        assert!(Matcher::matches("~[Ff]irefox", "firefox"));
105
+        assert!(Matcher::matches("~.*error.*", "An error occurred"));
106
+        assert!(!Matcher::matches("~^Chrome$", "Firefox"));
107
+    }
108
+
109
+    #[test]
110
+    fn test_empty_pattern() {
111
+        assert!(Matcher::matches("", "anything"));
112
+    }
113
+}
garnotify/src/rules/mod.rsadded
@@ -0,0 +1,209 @@
1
+//! Notification rules system
2
+//!
3
+//! Allows filtering and modifying notifications based on pattern matching.
4
+
5
+mod actions;
6
+mod matcher;
7
+
8
+pub use actions::RuleAction;
9
+pub use matcher::Matcher;
10
+
11
+use serde::{Deserialize, Serialize};
12
+use tracing::{debug, info};
13
+
14
+use crate::notification::Notification;
15
+
16
+/// A notification rule that can match and modify notifications
17
+#[derive(Debug, Clone, Serialize, Deserialize)]
18
+pub struct Rule {
19
+    /// Rule name (for enable/disable)
20
+    pub name: String,
21
+    /// Whether this rule is enabled
22
+    #[serde(default = "default_enabled")]
23
+    pub enabled: bool,
24
+    /// Matcher conditions (all must match)
25
+    #[serde(default)]
26
+    pub match_app_name: Option<String>,
27
+    #[serde(default)]
28
+    pub match_summary: Option<String>,
29
+    #[serde(default)]
30
+    pub match_body: Option<String>,
31
+    #[serde(default)]
32
+    pub match_urgency: Option<String>,
33
+    /// Actions to perform when matched
34
+    #[serde(default)]
35
+    pub actions: Vec<RuleAction>,
36
+}
37
+
38
+fn default_enabled() -> bool {
39
+    true
40
+}
41
+
42
+impl Rule {
43
+    /// Check if this rule matches a notification
44
+    pub fn matches(&self, notification: &Notification) -> bool {
45
+        if !self.enabled {
46
+            return false;
47
+        }
48
+
49
+        // All specified conditions must match
50
+        if let Some(ref pattern) = self.match_app_name {
51
+            if !Matcher::matches(pattern, &notification.app_name) {
52
+                return false;
53
+            }
54
+        }
55
+
56
+        if let Some(ref pattern) = self.match_summary {
57
+            if !Matcher::matches(pattern, &notification.summary) {
58
+                return false;
59
+            }
60
+        }
61
+
62
+        if let Some(ref pattern) = self.match_body {
63
+            if !Matcher::matches(pattern, &notification.body) {
64
+                return false;
65
+            }
66
+        }
67
+
68
+        if let Some(ref pattern) = self.match_urgency {
69
+            let urgency_str = format!("{:?}", notification.hints.urgency).to_lowercase();
70
+            if !Matcher::matches(pattern, &urgency_str) {
71
+                return false;
72
+            }
73
+        }
74
+
75
+        true
76
+    }
77
+
78
+    /// Apply this rule's actions to a notification
79
+    /// Returns None if the notification should be suppressed
80
+    pub fn apply(&self, notification: &mut Notification) -> Option<()> {
81
+        for action in &self.actions {
82
+            match action.apply(notification) {
83
+                Some(()) => {}
84
+                None => {
85
+                    debug!("Rule '{}' suppressed notification {}", self.name, notification.id);
86
+                    return None;
87
+                }
88
+            }
89
+        }
90
+        Some(())
91
+    }
92
+}
93
+
94
+/// Rule engine that manages and applies rules
95
+#[derive(Debug, Default)]
96
+pub struct RuleEngine {
97
+    rules: Vec<Rule>,
98
+}
99
+
100
+impl RuleEngine {
101
+    /// Create a new rule engine
102
+    pub fn new() -> Self {
103
+        Self { rules: Vec::new() }
104
+    }
105
+
106
+    /// Create a rule engine with initial rules
107
+    pub fn with_rules(rules: Vec<Rule>) -> Self {
108
+        info!("Loaded {} notification rules", rules.len());
109
+        Self { rules }
110
+    }
111
+
112
+    /// Add a rule
113
+    pub fn add_rule(&mut self, rule: Rule) {
114
+        self.rules.push(rule);
115
+    }
116
+
117
+    /// Get all rules
118
+    pub fn rules(&self) -> &[Rule] {
119
+        &self.rules
120
+    }
121
+
122
+    /// Get mutable reference to rules
123
+    pub fn rules_mut(&mut self) -> &mut Vec<Rule> {
124
+        &mut self.rules
125
+    }
126
+
127
+    /// Enable a rule by name
128
+    pub fn enable_rule(&mut self, name: &str) -> bool {
129
+        for rule in &mut self.rules {
130
+            if rule.name == name {
131
+                rule.enabled = true;
132
+                info!("Enabled rule '{}'", name);
133
+                return true;
134
+            }
135
+        }
136
+        false
137
+    }
138
+
139
+    /// Disable a rule by name
140
+    pub fn disable_rule(&mut self, name: &str) -> bool {
141
+        for rule in &mut self.rules {
142
+            if rule.name == name {
143
+                rule.enabled = false;
144
+                info!("Disabled rule '{}'", name);
145
+                return true;
146
+            }
147
+        }
148
+        false
149
+    }
150
+
151
+    /// Process a notification through all rules
152
+    /// Returns None if the notification should be suppressed
153
+    pub fn process(&self, notification: &mut Notification) -> Option<()> {
154
+        for rule in &self.rules {
155
+            if rule.matches(notification) {
156
+                debug!(
157
+                    "Rule '{}' matched notification {} (app='{}', summary='{}')",
158
+                    rule.name, notification.id, notification.app_name, notification.summary
159
+                );
160
+                if rule.apply(notification).is_none() {
161
+                    return None;
162
+                }
163
+            }
164
+        }
165
+        Some(())
166
+    }
167
+}
168
+
169
+#[cfg(test)]
170
+mod tests {
171
+    use super::*;
172
+
173
+    #[test]
174
+    fn test_rule_matching() {
175
+        let rule = Rule {
176
+            name: "test".to_string(),
177
+            enabled: true,
178
+            match_app_name: Some("Firefox".to_string()),
179
+            match_summary: None,
180
+            match_body: None,
181
+            match_urgency: None,
182
+            actions: vec![],
183
+        };
184
+
185
+        let mut notif = Notification::default();
186
+        notif.app_name = "Firefox".to_string();
187
+        assert!(rule.matches(&notif));
188
+
189
+        notif.app_name = "Chrome".to_string();
190
+        assert!(!rule.matches(&notif));
191
+    }
192
+
193
+    #[test]
194
+    fn test_disabled_rule() {
195
+        let rule = Rule {
196
+            name: "test".to_string(),
197
+            enabled: false,
198
+            match_app_name: Some("Firefox".to_string()),
199
+            match_summary: None,
200
+            match_body: None,
201
+            match_urgency: None,
202
+            actions: vec![],
203
+        };
204
+
205
+        let mut notif = Notification::default();
206
+        notif.app_name = "Firefox".to_string();
207
+        assert!(!rule.matches(&notif));
208
+    }
209
+}