Skip to main content

log_io/
filter.rs

1//! Per-target severity filtering.
2//!
3//! A [`Filter`] is a list of [`FilterRule`]s plus a default threshold.
4//! For each record, the first rule whose target prefix matches is
5//! consulted; if none match, the default threshold applies. Matching
6//! is prefix-based on `target`, so a rule of `"app::auth"` matches
7//! records whose target starts with `app::auth` (and is either equal
8//! or followed by `::`).
9
10use crate::level::Level;
11
12/// One rule in a [`Filter`].
13///
14/// A rule pairs a target prefix with a minimum severity. A record is
15/// admitted by a rule when its level is `>=` the rule's level.
16#[derive(Debug, Clone)]
17pub struct FilterRule {
18    /// Target prefix the rule applies to.
19    pub target: String,
20    /// Minimum severity allowed through.
21    pub level: Level,
22}
23
24impl FilterRule {
25    /// Build a rule from a target prefix and a minimum severity.
26    pub fn new(target: impl Into<String>, level: Level) -> Self {
27        Self {
28            target: target.into(),
29            level,
30        }
31    }
32}
33
34/// A target-aware severity gate.
35#[derive(Debug, Clone)]
36pub struct Filter {
37    default_level: Level,
38    rules: Vec<FilterRule>,
39}
40
41impl Filter {
42    /// New filter with a default minimum severity and no overrides.
43    pub fn new(default_level: Level) -> Self {
44        Self {
45            default_level,
46            rules: Vec::new(),
47        }
48    }
49
50    /// Add a per-target override. Rules are checked in insertion order;
51    /// the longest, most specific prefix should be added first.
52    pub fn with_rule(mut self, target: impl Into<String>, level: Level) -> Self {
53        self.rules.push(FilterRule::new(target, level));
54        self
55    }
56
57    /// The configured default severity.
58    pub fn default_level(&self) -> Level {
59        self.default_level
60    }
61
62    /// The configured rules in insertion order.
63    pub fn rules(&self) -> &[FilterRule] {
64        &self.rules
65    }
66
67    /// Decide whether a record at `level`/`target` should be admitted.
68    ///
69    /// Returns `true` when the level is at least as severe as the rule
70    /// matched (or the default, if no rule matches).
71    pub fn is_enabled(&self, target: &str, level: Level) -> bool {
72        level.is_enabled_at(self.threshold_for(target))
73    }
74
75    /// Return the configured threshold for a target.
76    pub fn threshold_for(&self, target: &str) -> Level {
77        for rule in &self.rules {
78            if target_matches(target, &rule.target) {
79                return rule.level;
80            }
81        }
82        self.default_level
83    }
84
85    /// Parse a directive string like `info,app::auth=debug,hyper=warn`.
86    ///
87    /// Whitespace is trimmed. Unknown levels return an error.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`ParseFilterError`] if any segment fails to parse as
92    /// `Level` or `target=Level`.
93    pub fn parse(directive: &str) -> core::result::Result<Self, ParseFilterError> {
94        let mut default_level = Level::Off;
95        let mut rules = Vec::new();
96        let mut saw_default = false;
97
98        for segment in directive.split(',') {
99            let segment = segment.trim();
100            if segment.is_empty() {
101                continue;
102            }
103            if let Some((target, level)) = segment.split_once('=') {
104                let level: Level = level.trim().parse().map_err(|_| ParseFilterError)?;
105                rules.push(FilterRule::new(target.trim(), level));
106            } else {
107                let level: Level = segment.parse().map_err(|_| ParseFilterError)?;
108                default_level = level;
109                saw_default = true;
110            }
111        }
112
113        if !saw_default && !rules.is_empty() {
114            default_level = Level::Off;
115        }
116        Ok(Self {
117            default_level,
118            rules,
119        })
120    }
121}
122
123/// Error returned by [`Filter::parse`] when a directive segment is
124/// malformed.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct ParseFilterError;
127
128impl core::fmt::Display for ParseFilterError {
129    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
130        f.write_str("invalid filter directive")
131    }
132}
133
134impl std::error::Error for ParseFilterError {}
135
136fn target_matches(target: &str, prefix: &str) -> bool {
137    if target == prefix {
138        return true;
139    }
140    if target.len() > prefix.len() && target.starts_with(prefix) {
141        let rest = &target.as_bytes()[prefix.len()..];
142        // Accept either `::` (Rust module separator) or `.` (dot path).
143        return rest.starts_with(b"::") || rest.starts_with(b".");
144    }
145    false
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn default_applies_when_no_rules_match() {
154        let f = Filter::new(Level::Warn);
155        assert!(f.is_enabled("anything", Level::Warn));
156        assert!(!f.is_enabled("anything", Level::Info));
157    }
158
159    #[test]
160    fn rule_overrides_default_for_matching_prefix() {
161        let f = Filter::new(Level::Warn).with_rule("app::auth", Level::Debug);
162        assert!(f.is_enabled("app::auth", Level::Debug));
163        assert!(f.is_enabled("app::auth::login", Level::Debug));
164        assert!(!f.is_enabled("app::auth", Level::Trace));
165        // Unrelated target still hits the default.
166        assert!(!f.is_enabled("hyper", Level::Info));
167    }
168
169    #[test]
170    fn prefix_match_respects_module_boundary() {
171        let f = Filter::new(Level::Off).with_rule("app", Level::Info);
172        assert!(f.is_enabled("app", Level::Info));
173        assert!(f.is_enabled("app::sub", Level::Info));
174        // "application" must not match the "app" rule.
175        assert!(!f.is_enabled("application", Level::Info));
176    }
177
178    #[test]
179    fn parse_simple_directive() {
180        let f = Filter::parse("info").unwrap();
181        assert_eq!(f.default_level(), Level::Info);
182        assert!(f.rules().is_empty());
183    }
184
185    #[test]
186    fn parse_with_overrides() {
187        let f = Filter::parse("info, app::auth = debug ,hyper=warn").unwrap();
188        assert_eq!(f.default_level(), Level::Info);
189        assert_eq!(f.rules().len(), 2);
190        assert!(f.is_enabled("hyper", Level::Warn));
191        assert!(!f.is_enabled("hyper", Level::Info));
192        assert!(f.is_enabled("app::auth", Level::Debug));
193    }
194
195    #[test]
196    fn parse_rejects_garbage() {
197        assert!(Filter::parse("nope").is_err());
198        assert!(Filter::parse("hyper=verbose").is_err());
199    }
200
201    #[test]
202    fn rules_without_default_disables_unmatched() {
203        let f = Filter::parse("hyper=warn").unwrap();
204        assert_eq!(f.default_level(), Level::Off);
205        assert!(f.is_enabled("hyper", Level::Warn));
206        assert!(!f.is_enabled("other", Level::Error));
207    }
208}