Skip to main content

hdds_logger/
filter.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2025-2026 naskel.com
3
4//! Log filtering by level, participant, and topic.
5
6use serde::{Deserialize, Serialize};
7
8/// Log severity levels (compatible with ROS 2 rcl_interfaces/Log).
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
10#[repr(u8)]
11#[derive(Default)]
12pub enum LogLevel {
13    /// Unset/unknown level.
14    Unset = 0,
15    /// Debug messages for development.
16    Debug = 10,
17    /// Informational messages.
18    #[default]
19    Info = 20,
20    /// Warning messages.
21    Warn = 30,
22    /// Error messages.
23    Error = 40,
24    /// Fatal/critical errors.
25    Fatal = 50,
26}
27
28impl LogLevel {
29    /// Get level name as string.
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            Self::Unset => "UNSET",
33            Self::Debug => "DEBUG",
34            Self::Info => "INFO",
35            Self::Warn => "WARN",
36            Self::Error => "ERROR",
37            Self::Fatal => "FATAL",
38        }
39    }
40
41    /// Parse level from string (case-insensitive).
42    pub fn parse(s: &str) -> Option<Self> {
43        match s.to_uppercase().as_str() {
44            "UNSET" => Some(Self::Unset),
45            "DEBUG" => Some(Self::Debug),
46            "INFO" => Some(Self::Info),
47            "WARN" | "WARNING" => Some(Self::Warn),
48            "ERROR" | "ERR" => Some(Self::Error),
49            "FATAL" | "CRITICAL" => Some(Self::Fatal),
50            _ => None,
51        }
52    }
53
54    /// Get numeric value for syslog priority calculation.
55    pub fn syslog_severity(&self) -> u8 {
56        match self {
57            Self::Unset => 7, // Debug
58            Self::Debug => 7, // Debug
59            Self::Info => 6,  // Informational
60            Self::Warn => 4,  // Warning
61            Self::Error => 3, // Error
62            Self::Fatal => 2, // Critical
63        }
64    }
65}
66
67impl std::fmt::Display for LogLevel {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        write!(f, "{}", self.as_str())
70    }
71}
72
73/// Log filter configuration.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct LogFilter {
76    /// Minimum log level to include.
77    pub min_level: LogLevel,
78    /// Participant GUID pattern (glob-style, e.g., "01.0f.*").
79    pub participant_pattern: Option<String>,
80    /// Topic name pattern (glob-style, e.g., "rt/*").
81    pub topic_pattern: Option<String>,
82    /// Node name pattern (for ROS 2 logs).
83    pub node_pattern: Option<String>,
84    /// Message content pattern (regex).
85    pub message_pattern: Option<String>,
86}
87
88impl Default for LogFilter {
89    fn default() -> Self {
90        Self {
91            min_level: LogLevel::Info,
92            participant_pattern: None,
93            topic_pattern: None,
94            node_pattern: None,
95            message_pattern: None,
96        }
97    }
98}
99
100impl LogFilter {
101    /// Create a filter that accepts all logs.
102    pub fn all() -> Self {
103        Self {
104            min_level: LogLevel::Unset,
105            ..Default::default()
106        }
107    }
108
109    /// Create a filter for a minimum level.
110    pub fn min_level(level: LogLevel) -> Self {
111        Self {
112            min_level: level,
113            ..Default::default()
114        }
115    }
116
117    /// Check if a log entry passes this filter.
118    pub fn matches(&self, entry: &super::LogEntry) -> bool {
119        // Level check
120        if entry.level < self.min_level {
121            return false;
122        }
123
124        // Participant pattern check
125        if let Some(ref pattern) = self.participant_pattern {
126            if !glob_match(pattern, &entry.participant_id) {
127                return false;
128            }
129        }
130
131        // Topic pattern check
132        if let Some(ref pattern) = self.topic_pattern {
133            if let Some(ref topic) = entry.topic {
134                if !glob_match(pattern, topic) {
135                    return false;
136                }
137            }
138        }
139
140        // Node pattern check
141        if let Some(ref pattern) = self.node_pattern {
142            if let Some(ref node) = entry.node_name {
143                if !glob_match(pattern, node) {
144                    return false;
145                }
146            }
147        }
148
149        // Message pattern check (simple contains for now)
150        if let Some(ref pattern) = self.message_pattern {
151            if !entry.message.contains(pattern) {
152                return false;
153            }
154        }
155
156        true
157    }
158}
159
160/// Simple glob-style pattern matching.
161/// Supports: * (any chars), ? (single char)
162fn glob_match(pattern: &str, text: &str) -> bool {
163    let mut pattern_chars = pattern.chars().peekable();
164    let mut text_chars = text.chars().peekable();
165
166    while let Some(p) = pattern_chars.next() {
167        match p {
168            '*' => {
169                // Skip consecutive stars
170                while pattern_chars.peek() == Some(&'*') {
171                    pattern_chars.next();
172                }
173
174                // If star is at end, match everything
175                if pattern_chars.peek().is_none() {
176                    return true;
177                }
178
179                // Try matching from each position
180                let remaining_pattern: String = pattern_chars.collect();
181                while text_chars.peek().is_some() {
182                    let remaining_text: String = text_chars.clone().collect();
183                    if glob_match(&remaining_pattern, &remaining_text) {
184                        return true;
185                    }
186                    text_chars.next();
187                }
188                // Also try with empty remaining text
189                return glob_match(&remaining_pattern, "");
190            }
191            '?' => {
192                // Must match exactly one character
193                if text_chars.next().is_none() {
194                    return false;
195                }
196            }
197            c => {
198                // Must match exact character
199                if text_chars.next() != Some(c) {
200                    return false;
201                }
202            }
203        }
204    }
205
206    // Pattern exhausted - text must also be exhausted
207    text_chars.peek().is_none()
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn test_log_level_ordering() {
216        assert!(LogLevel::Debug < LogLevel::Info);
217        assert!(LogLevel::Info < LogLevel::Warn);
218        assert!(LogLevel::Warn < LogLevel::Error);
219        assert!(LogLevel::Error < LogLevel::Fatal);
220    }
221
222    #[test]
223    fn test_log_level_from_str() {
224        assert_eq!(LogLevel::parse("debug"), Some(LogLevel::Debug));
225        assert_eq!(LogLevel::parse("INFO"), Some(LogLevel::Info));
226        assert_eq!(LogLevel::parse("Warning"), Some(LogLevel::Warn));
227        assert_eq!(LogLevel::parse("ERR"), Some(LogLevel::Error));
228        assert_eq!(LogLevel::parse("invalid"), None);
229    }
230
231    #[test]
232    fn test_glob_match_exact() {
233        assert!(glob_match("hello", "hello"));
234        assert!(!glob_match("hello", "world"));
235        assert!(!glob_match("hello", "hello!"));
236    }
237
238    #[test]
239    fn test_glob_match_star() {
240        assert!(glob_match("*", "anything"));
241        assert!(glob_match("*", ""));
242        assert!(glob_match("hello*", "hello"));
243        assert!(glob_match("hello*", "hello world"));
244        assert!(glob_match("*world", "hello world"));
245        assert!(glob_match("hello*world", "hello big world"));
246        assert!(!glob_match("hello*world", "hello big moon"));
247    }
248
249    #[test]
250    fn test_glob_match_question() {
251        assert!(glob_match("h?llo", "hello"));
252        assert!(glob_match("h?llo", "hallo"));
253        assert!(!glob_match("h?llo", "hllo"));
254        assert!(!glob_match("h?llo", "heello"));
255    }
256
257    #[test]
258    fn test_glob_match_combined() {
259        assert!(glob_match("rt/*", "rt/rosout"));
260        assert!(glob_match("rt/*", "rt/topic/nested"));
261        assert!(glob_match("*/rosout", "rt/rosout"));
262        assert!(glob_match("rt/ros?ut", "rt/rosout"));
263    }
264
265    #[test]
266    fn test_filter_level() {
267        use super::super::LogEntry;
268
269        let filter = LogFilter::min_level(LogLevel::Warn);
270
271        let debug_entry = LogEntry {
272            level: LogLevel::Debug,
273            message: "test".to_string(),
274            participant_id: "test".to_string(),
275            ..Default::default()
276        };
277
278        let warn_entry = LogEntry {
279            level: LogLevel::Warn,
280            message: "test".to_string(),
281            participant_id: "test".to_string(),
282            ..Default::default()
283        };
284
285        assert!(!filter.matches(&debug_entry));
286        assert!(filter.matches(&warn_entry));
287    }
288}