Skip to main content

log_io/
filter.rs

1//! Per-target severity filtering.
2//!
3//! A [`Filter`] is a default threshold plus a set of per-target
4//! [`FilterRule`]s. For each record the **most specific** matching
5//! rule wins: the rule with the longest target that is a prefix of the
6//! record's target at a module boundary. If no rule matches, the
7//! default applies. This matches the semantics of `RUST_LOG` in
8//! `env_logger` and `tracing-subscriber`, so directive order does not
9//! matter.
10//!
11//! A rule for `app::db` matches the targets `app::db`, `app::db::pool`
12//! and `app::db.pool` (both `::` and `.` count as boundaries), but not
13//! `app::dbx`.
14
15use crate::level::Level;
16
17/// One rule in a [`Filter`]: records whose target is (or is nested
18/// under) `target` are admitted when their level is at least `level`.
19///
20/// # Examples
21///
22/// ```
23/// use log_io::{FilterRule, Level};
24///
25/// let rule = FilterRule::new("app::db", Level::Debug);
26/// assert_eq!(rule.target, "app::db");
27/// ```
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct FilterRule {
30    /// Target prefix the rule applies to.
31    pub target: String,
32    /// Minimum severity allowed through.
33    pub level: Level,
34}
35
36impl FilterRule {
37    /// Build a rule from a target prefix and a minimum severity.
38    pub fn new(target: impl Into<String>, level: Level) -> Self {
39        Self {
40            target: target.into(),
41            level,
42        }
43    }
44}
45
46/// A target-aware severity gate.
47///
48/// # Examples
49///
50/// ```
51/// use log_io::{Filter, Level};
52///
53/// // Order does not matter: the most specific rule always wins.
54/// let f = Filter::parse("info, app::db=trace, app=warn")?;
55/// assert!(f.is_enabled("app::db::pool", Level::Trace));
56/// assert!(!f.is_enabled("app::http", Level::Info));
57/// assert!(f.is_enabled("hyper", Level::Info));
58/// # Ok::<(), log_io::ParseFilterError>(())
59/// ```
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Filter {
62    default_level: Level,
63    /// Sorted by descending target length, so the first match is the
64    /// most specific. Targets are unique.
65    rules: Vec<FilterRule>,
66    /// The lowest threshold anywhere in the filter; anything below it
67    /// is rejected without scanning the rules.
68    min_level: Level,
69}
70
71impl Filter {
72    /// New filter with a default minimum severity and no overrides.
73    #[must_use]
74    pub fn new(default_level: Level) -> Self {
75        Self {
76            default_level,
77            rules: Vec::new(),
78            min_level: default_level,
79        }
80    }
81
82    /// Add (or replace) the rule for `target`.
83    ///
84    /// Rules may be added in any order; matching always picks the most
85    /// specific one. Adding a second rule for the same target replaces
86    /// the first. Unlike [`Filter::parse`], the target is not validated:
87    /// an empty target matches only records whose target is empty
88    /// (which is what [`crate::Logger::log`] uses unless a default
89    /// target is configured).
90    #[must_use]
91    pub fn with_rule(mut self, target: impl Into<String>, level: Level) -> Self {
92        self.insert(FilterRule::new(target, level));
93        self
94    }
95
96    fn insert(&mut self, rule: FilterRule) {
97        if let Some(existing) = self.rules.iter_mut().find(|r| r.target == rule.target) {
98            existing.level = rule.level;
99        } else {
100            // Keep the vector sorted by descending length; equal-length
101            // targets cannot both match one record, so their relative
102            // order is irrelevant.
103            let at = self
104                .rules
105                .partition_point(|r| r.target.len() >= rule.target.len());
106            self.rules.insert(at, rule);
107        }
108        self.recompute_min();
109    }
110
111    fn recompute_min(&mut self) {
112        self.min_level = self
113            .rules
114            .iter()
115            .map(|r| r.level)
116            .fold(self.default_level, core::cmp::min);
117    }
118
119    /// The default severity, applied when no rule matches.
120    #[must_use]
121    pub fn default_level(&self) -> Level {
122        self.default_level
123    }
124
125    /// Replace the default severity, keeping every rule.
126    #[must_use]
127    pub fn with_default_level(mut self, level: Level) -> Self {
128        self.default_level = level;
129        self.recompute_min();
130        self
131    }
132
133    /// The configured rules, most specific (longest target) first.
134    #[must_use]
135    pub fn rules(&self) -> &[FilterRule] {
136        &self.rules
137    }
138
139    /// The lowest severity this filter can admit for any target.
140    /// Records below it are rejected without examining their target.
141    #[must_use]
142    pub fn min_level(&self) -> Level {
143        self.min_level
144    }
145
146    /// Decide whether a record at `level` for `target` is admitted.
147    #[inline]
148    #[must_use]
149    pub fn is_enabled(&self, target: &str, level: Level) -> bool {
150        if level < self.min_level || level == Level::Off {
151            return false;
152        }
153        level.is_enabled_at(self.threshold_for(target))
154    }
155
156    /// The threshold that applies to `target`: the level of the most
157    /// specific matching rule, or the default.
158    #[must_use]
159    pub fn threshold_for(&self, target: &str) -> Level {
160        self.rules
161            .iter()
162            .find(|rule| target_matches(target, &rule.target))
163            .map_or(self.default_level, |rule| rule.level)
164    }
165
166    /// Parse a directive string such as `info,app::auth=debug,hyper=off`.
167    ///
168    /// The grammar is a comma-separated list of segments. Each segment
169    /// is either a bare level (sets the default) or `target=level` (adds
170    /// a rule). Whitespace around segments, targets and levels is
171    /// ignored, and empty segments (`a=info,,b=warn`) are skipped. Level
172    /// names are case-insensitive and accept the aliases `warning` and
173    /// `err`. When a level or a target appears more than once, the last
174    /// occurrence wins. If no bare level is given, the default is
175    /// [`Level::Off`]: only the listed targets are logged.
176    ///
177    /// Differences from `env_logger`: a bare word must be a level (a
178    /// bare target such as `my_crate` is rejected rather than meaning
179    /// "everything for my_crate", so a misspelt level is reported
180    /// instead of silently becoming a target), and the `/regex` message
181    /// filter is not supported.
182    ///
183    /// # Errors
184    ///
185    /// Returns [`ParseFilterError`] identifying the offending segment
186    /// when the input has no segment (empty, blank, or only commas such
187    /// as `","`), a level is unknown, a target is
188    /// empty (`=debug`), a target contains whitespace, or a target ends
189    /// with a separator (`app::=debug`, `app.=debug`).
190    ///
191    /// # Examples
192    ///
193    /// ```
194    /// use log_io::{Filter, Level, ParseFilterErrorKind};
195    ///
196    /// let f = Filter::parse("warn,app=debug")?;
197    /// assert_eq!(f.default_level(), Level::Warn);
198    ///
199    /// let err = Filter::parse("info,app=loud").unwrap_err();
200    /// assert_eq!(err.kind(), ParseFilterErrorKind::InvalidLevel);
201    /// assert_eq!(err.segment(), "app=loud");
202    /// # Ok::<(), log_io::ParseFilterError>(())
203    /// ```
204    pub fn parse(directive: &str) -> Result<Self, ParseFilterError> {
205        let mut filter = Self::new(Level::Off);
206        let mut segments = 0_usize;
207        for raw in directive.split(',') {
208            let segment = raw.trim();
209            if segment.is_empty() {
210                continue;
211            }
212            segments += 1;
213            match segment.split_once('=') {
214                Some((target, level)) => {
215                    let target = target.trim();
216                    validate_target(target, segment)?;
217                    let level: Level = level.trim().parse().map_err(|_| {
218                        ParseFilterError::new(ParseFilterErrorKind::InvalidLevel, segment)
219                    })?;
220                    filter.insert(FilterRule::new(target, level));
221                }
222                None => {
223                    let level: Level = segment.parse().map_err(|_| {
224                        ParseFilterError::new(ParseFilterErrorKind::InvalidLevel, segment)
225                    })?;
226                    filter.default_level = level;
227                }
228            }
229        }
230        if segments == 0 {
231            // `""`, `"  "`, `","`: nothing was asked for. Treating it as
232            // "default Off, no rules" would silence all logging.
233            return Err(ParseFilterError::new(
234                ParseFilterErrorKind::Empty,
235                directive,
236            ));
237        }
238        filter.recompute_min();
239        Ok(filter)
240    }
241}
242
243impl core::str::FromStr for Filter {
244    type Err = ParseFilterError;
245
246    fn from_str(s: &str) -> Result<Self, Self::Err> {
247        Self::parse(s)
248    }
249}
250
251fn validate_target(target: &str, segment: &str) -> Result<(), ParseFilterError> {
252    let kind = if target.is_empty() {
253        ParseFilterErrorKind::EmptyTarget
254    } else if target.ends_with(':') || target.ends_with('.') {
255        ParseFilterErrorKind::TrailingSeparator
256    } else if target.chars().any(char::is_whitespace) {
257        ParseFilterErrorKind::InvalidTarget
258    } else {
259        return Ok(());
260    };
261    Err(ParseFilterError::new(kind, segment))
262}
263
264/// What was wrong with a directive passed to [`Filter::parse`].
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266#[non_exhaustive]
267pub enum ParseFilterErrorKind {
268    /// The directive was empty or contained only whitespace and commas.
269    Empty,
270    /// A level name was not recognized.
271    InvalidLevel,
272    /// A `target=level` segment had an empty target.
273    EmptyTarget,
274    /// A target ended with `::`, `:` or `.`, so it could never match.
275    TrailingSeparator,
276    /// A target contained whitespace.
277    InvalidTarget,
278    /// The directive came from an environment variable whose value is
279    /// not valid Unicode.
280    NotUnicode,
281}
282
283/// Error returned by [`Filter::parse`].
284///
285/// Carries the kind of problem and the offending directive segment so
286/// the message can point at the exact mistake.
287#[derive(Debug, Clone, PartialEq, Eq)]
288pub struct ParseFilterError {
289    kind: ParseFilterErrorKind,
290    segment: String,
291}
292
293impl ParseFilterError {
294    fn new(kind: ParseFilterErrorKind, segment: &str) -> Self {
295        Self {
296            kind,
297            segment: segment.to_owned(),
298        }
299    }
300
301    pub(crate) fn not_unicode(raw: &std::ffi::OsStr) -> Self {
302        Self {
303            kind: ParseFilterErrorKind::NotUnicode,
304            segment: raw.to_string_lossy().into_owned(),
305        }
306    }
307
308    /// What was wrong.
309    #[must_use]
310    pub fn kind(&self) -> ParseFilterErrorKind {
311        self.kind
312    }
313
314    /// The offending segment (the whole directive for
315    /// [`ParseFilterErrorKind::Empty`], and a lossy rendering of the
316    /// value for [`ParseFilterErrorKind::NotUnicode`]).
317    #[must_use]
318    pub fn segment(&self) -> &str {
319        &self.segment
320    }
321}
322
323impl core::fmt::Display for ParseFilterError {
324    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
325        let what = match self.kind {
326            ParseFilterErrorKind::Empty => "directive is empty",
327            ParseFilterErrorKind::InvalidLevel => {
328                "unknown level (expected off, trace, debug, info, warn or error)"
329            }
330            ParseFilterErrorKind::EmptyTarget => "target is empty",
331            ParseFilterErrorKind::TrailingSeparator => "target ends with a separator",
332            ParseFilterErrorKind::InvalidTarget => "target contains whitespace",
333            ParseFilterErrorKind::NotUnicode => "value is not valid Unicode",
334        };
335        write!(f, "invalid filter directive {:?}: {what}", self.segment)
336    }
337}
338
339impl std::error::Error for ParseFilterError {}
340
341/// `true` when `target` equals `prefix` or continues it after a `::`
342/// or `.` boundary.
343#[inline]
344fn target_matches(target: &str, prefix: &str) -> bool {
345    match target.strip_prefix(prefix) {
346        Some("") => true,
347        Some(rest) => !prefix.is_empty() && (rest.starts_with("::") || rest.starts_with('.')),
348        None => false,
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn test_filter_default_applies_when_no_rules_match() {
358        let f = Filter::new(Level::Warn);
359        assert!(f.is_enabled("anything", Level::Warn));
360        assert!(!f.is_enabled("anything", Level::Info));
361    }
362
363    #[test]
364    fn test_filter_rule_overrides_default_for_matching_prefix() {
365        let f = Filter::new(Level::Warn).with_rule("app::auth", Level::Debug);
366        assert!(f.is_enabled("app::auth", Level::Debug));
367        assert!(f.is_enabled("app::auth::login", Level::Debug));
368        assert!(!f.is_enabled("app::auth", Level::Trace));
369        assert!(!f.is_enabled("hyper", Level::Info));
370    }
371
372    #[test]
373    fn test_filter_prefix_match_respects_module_boundary() {
374        let f = Filter::new(Level::Off).with_rule("app", Level::Info);
375        assert!(f.is_enabled("app", Level::Info));
376        assert!(f.is_enabled("app::sub", Level::Info));
377        assert!(f.is_enabled("app.sub", Level::Info));
378        assert!(!f.is_enabled("application", Level::Info));
379        assert!(!f.is_enabled("ap", Level::Info));
380        assert!(!f.is_enabled("app:", Level::Info));
381        assert!(!f.is_enabled("app:x", Level::Info));
382    }
383
384    #[test]
385    fn test_filter_most_specific_rule_wins_regardless_of_order() {
386        // The audit's R03 reproduction.
387        let f = Filter::parse("info,app=warn,app::db=trace").unwrap();
388        assert!(f.is_enabled("app::db", Level::Trace));
389        assert!(f.is_enabled("app::db::pool", Level::Trace));
390        assert!(!f.is_enabled("app::http", Level::Info));
391        assert!(f.is_enabled("other", Level::Info));
392        let reversed = Filter::parse("app::db=trace,app=warn,info").unwrap();
393        assert_eq!(f, reversed);
394        let built = Filter::new(Level::Info)
395            .with_rule("app", Level::Warn)
396            .with_rule("app::db", Level::Trace);
397        assert_eq!(built.threshold_for("app::db::x"), Level::Trace);
398    }
399
400    #[test]
401    fn test_filter_same_target_last_wins() {
402        let f = Filter::parse("app=error,app=debug").unwrap();
403        assert_eq!(f.rules().len(), 1);
404        assert_eq!(f.threshold_for("app"), Level::Debug);
405        let f = Filter::new(Level::Info)
406            .with_rule("x", Level::Error)
407            .with_rule("x", Level::Trace);
408        assert_eq!(f.rules().len(), 1);
409        assert_eq!(f.threshold_for("x"), Level::Trace);
410        let f = Filter::parse("info,warn").unwrap();
411        assert_eq!(f.default_level(), Level::Warn);
412    }
413
414    #[test]
415    fn test_filter_rules_are_ordered_most_specific_first() {
416        let f = Filter::parse("a=info,a::b::c=debug,a::b=warn").unwrap();
417        let targets: Vec<&str> = f.rules().iter().map(|r| r.target.as_str()).collect();
418        assert_eq!(targets, ["a::b::c", "a::b", "a"]);
419    }
420
421    #[test]
422    fn test_filter_min_level_fast_path() {
423        let f = Filter::new(Level::Warn).with_rule("loud", Level::Trace);
424        assert_eq!(f.min_level(), Level::Trace);
425        assert!(f.is_enabled("loud", Level::Trace));
426        let quiet = Filter::new(Level::Error);
427        assert_eq!(quiet.min_level(), Level::Error);
428        assert!(!quiet.is_enabled("x", Level::Warn));
429        let raised = f.with_default_level(Level::Debug);
430        assert_eq!(raised.default_level(), Level::Debug);
431        assert_eq!(raised.min_level(), Level::Trace);
432    }
433
434    #[test]
435    fn test_filter_never_admits_off_level_records() {
436        let f = Filter::new(Level::Trace);
437        assert!(!f.is_enabled("x", Level::Off));
438    }
439
440    #[test]
441    fn test_filter_empty_target_rule_matches_only_empty_target() {
442        let f = Filter::new(Level::Off).with_rule("", Level::Info);
443        assert!(f.is_enabled("", Level::Info));
444        assert!(!f.is_enabled("app", Level::Info));
445        assert!(!f.is_enabled("::app", Level::Info));
446    }
447
448    #[test]
449    fn test_parse_simple_directive() {
450        let f = Filter::parse("info").unwrap();
451        assert_eq!(f.default_level(), Level::Info);
452        assert!(f.rules().is_empty());
453        let f: Filter = "DEBUG".parse().unwrap();
454        assert_eq!(f.default_level(), Level::Debug);
455    }
456
457    #[test]
458    fn test_parse_with_overrides_and_whitespace() {
459        let f = Filter::parse(" info, app::auth = debug ,hyper=warn,, ").unwrap();
460        assert_eq!(f.default_level(), Level::Info);
461        assert_eq!(f.rules().len(), 2);
462        assert!(f.is_enabled("hyper", Level::Warn));
463        assert!(!f.is_enabled("hyper", Level::Info));
464        assert!(f.is_enabled("app::auth", Level::Debug));
465    }
466
467    #[test]
468    fn test_parse_rules_without_default_disable_unmatched() {
469        let f = Filter::parse("hyper=warn").unwrap();
470        assert_eq!(f.default_level(), Level::Off);
471        assert!(f.is_enabled("hyper", Level::Warn));
472        assert!(!f.is_enabled("other", Level::Error));
473    }
474
475    #[test]
476    fn test_parse_rejects_degenerate_input_with_typed_errors() {
477        let cases = [
478            ("", ParseFilterErrorKind::Empty, ""),
479            ("  ", ParseFilterErrorKind::Empty, "  "),
480            ("nope", ParseFilterErrorKind::InvalidLevel, "nope"),
481            (
482                "info,hyper=verbose",
483                ParseFilterErrorKind::InvalidLevel,
484                "hyper=verbose",
485            ),
486            ("=debug", ParseFilterErrorKind::EmptyTarget, "=debug"),
487            (
488                "app::=debug",
489                ParseFilterErrorKind::TrailingSeparator,
490                "app::=debug",
491            ),
492            (
493                "app.=debug",
494                ParseFilterErrorKind::TrailingSeparator,
495                "app.=debug",
496            ),
497            (
498                "my app=debug",
499                ParseFilterErrorKind::InvalidTarget,
500                "my app=debug",
501            ),
502            ("a=b=c", ParseFilterErrorKind::InvalidLevel, "a=b=c"),
503        ];
504        for (input, kind, segment) in cases {
505            let err = Filter::parse(input).unwrap_err();
506            assert_eq!(err.kind(), kind, "{input:?}");
507            assert_eq!(err.segment(), segment, "{input:?}");
508        }
509    }
510
511    #[test]
512    fn test_parse_filter_error_display_names_segment_and_cause() {
513        let err = Filter::parse("x=loud").unwrap_err();
514        assert_eq!(
515            err.to_string(),
516            "invalid filter directive \"x=loud\": unknown level (expected off, trace, debug, info, warn or error)"
517        );
518        for (input, needle) in [
519            ("", "empty"),
520            ("=x", "target is empty"),
521            ("a::=info", "separator"),
522            ("a b=info", "whitespace"),
523        ] {
524            let msg = Filter::parse(input).unwrap_err().to_string();
525            assert!(msg.contains(needle), "{input:?}: {msg}");
526        }
527    }
528
529    #[test]
530    fn test_target_matches_boundaries() {
531        assert!(target_matches("a", "a"));
532        assert!(target_matches("a::b", "a"));
533        assert!(target_matches("a.b", "a"));
534        assert!(!target_matches("ab", "a"));
535        assert!(!target_matches("a:b", "a"));
536        assert!(!target_matches("b", "a"));
537        assert!(!target_matches("a", "a::b"));
538        assert!(target_matches("", ""));
539        assert!(!target_matches("::a", ""));
540    }
541}