use core::fmt;
use serde::{Deserialize, Serialize};
const PATTERN_SIZE_LIMIT: usize = 1 << 20;
const MAX_LEVEL_RULES: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum LineLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
impl fmt::Display for LineLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Trace => "trace",
Self::Debug => "debug",
Self::Info => "info",
Self::Warn => "warn",
Self::Error => "error",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
pub struct LevelRule {
pub pattern: String,
pub level: LineLevel,
}
#[derive(Debug)]
pub struct LevelMatcher {
rules: Vec<(regex::Regex, LineLevel)>,
}
impl LevelMatcher {
pub fn compile(rules: &[LevelRule]) -> Result<Self, LevelRuleError> {
if rules.len() > MAX_LEVEL_RULES {
return Err(LevelRuleError::TooManyRules {
count: rules.len(),
limit: MAX_LEVEL_RULES,
});
}
let mut compiled = Vec::with_capacity(rules.len());
for rule in rules {
if rule.pattern.is_empty() {
return Err(LevelRuleError::EmptyPattern { level: rule.level });
}
let regex = regex::RegexBuilder::new(&rule.pattern)
.size_limit(PATTERN_SIZE_LIMIT)
.build()
.map_err(|err| LevelRuleError::BadPattern {
pattern: rule.pattern.clone(),
reason: err.to_string(),
})?;
compiled.push((regex, rule.level));
}
Ok(Self { rules: compiled })
}
#[must_use]
pub fn level_of(&self, line: &str) -> Option<LineLevel> {
self.rules
.iter()
.find(|(regex, _)| regex.is_match(line))
.map(|(_, level)| *level)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum LevelRuleError {
EmptyPattern {
level: LineLevel,
},
TooManyRules {
count: usize,
limit: usize,
},
BadPattern {
pattern: String,
reason: String,
},
}
impl fmt::Display for LevelRuleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyPattern { level } => write!(
f,
"the `{level}` level rule has an empty pattern, which would match every line"
),
Self::TooManyRules { count, limit } => write!(
f,
"{count} level rules, over the {limit} one app may declare: each is a compiled \
regex a client rebuilds on every redraw"
),
Self::BadPattern { pattern, reason } => {
write!(f, "invalid level rule pattern `{pattern}`: {reason}")
}
}
}
}
impl core::error::Error for LevelRuleError {}
#[cfg(test)]
mod tests {
#[test]
fn a_list_longer_than_the_ceiling_is_refused() {
let rule = |n: usize| LevelRule {
pattern: format!("^{n} "),
level: LineLevel::Info,
};
let at_ceiling: Vec<LevelRule> = (0..MAX_LEVEL_RULES).map(rule).collect();
assert!(
LevelMatcher::compile(&at_ceiling).is_ok(),
"the ceiling itself is allowed"
);
let over: Vec<LevelRule> = (0..=MAX_LEVEL_RULES).map(rule).collect();
let err = LevelMatcher::compile(&over).expect_err("one past it is refused");
assert_eq!(
err,
LevelRuleError::TooManyRules {
count: MAX_LEVEL_RULES + 1,
limit: MAX_LEVEL_RULES,
}
);
let text = err.to_string();
assert!(text.contains("65"), "{text}");
assert!(text.contains("64"), "{text}");
}
use super::*;
#[test]
fn levels_order_from_trace_up_to_error() {
assert!(LineLevel::Trace < LineLevel::Debug);
assert!(LineLevel::Debug < LineLevel::Info);
assert!(LineLevel::Info < LineLevel::Warn);
assert!(LineLevel::Warn < LineLevel::Error);
}
#[test]
fn a_level_serializes_as_its_lowercase_name() {
let rule = LevelRule {
pattern: "x".to_string(),
level: LineLevel::Warn,
};
assert_eq!(
serde_json::to_string(&rule).unwrap(),
r#"{"pattern":"x","level":"warn"}"#
);
}
#[test]
fn a_rule_claims_the_lines_its_pattern_matches() {
let matcher = LevelMatcher::compile(&[LevelRule {
pattern: r#""severity":"ERROR""#.to_string(),
level: LineLevel::Error,
}])
.unwrap();
assert_eq!(
matcher.level_of(r#"{"severity":"ERROR","msg":"boom"}"#),
Some(LineLevel::Error)
);
assert_eq!(matcher.level_of(r#"{"severity":"INFO"}"#), None);
}
#[test]
fn the_first_matching_rule_wins() {
let rules = |first, second| {
vec![
LevelRule {
pattern: "boom".to_string(),
level: first,
},
LevelRule {
pattern: "b".to_string(),
level: second,
},
]
};
let matcher = LevelMatcher::compile(&rules(LineLevel::Error, LineLevel::Debug)).unwrap();
assert_eq!(matcher.level_of("boom"), Some(LineLevel::Error));
let reversed = LevelMatcher::compile(&rules(LineLevel::Debug, LineLevel::Error)).unwrap();
assert_eq!(reversed.level_of("boom"), Some(LineLevel::Debug));
}
#[test]
fn a_line_no_rule_matches_has_no_level() {
let matcher = LevelMatcher::compile(&[LevelRule {
pattern: "^ERROR".to_string(),
level: LineLevel::Error,
}])
.unwrap();
assert_eq!(matcher.level_of("listening on 8080"), None);
}
#[test]
fn no_rules_compiles_to_a_matcher_that_classifies_nothing() {
let matcher = LevelMatcher::compile(&[]).unwrap();
assert_eq!(matcher.level_of("ERROR everything is on fire"), None);
}
#[test]
fn a_pattern_matches_case_exactly_unless_it_asks_not_to() {
let exact = LevelMatcher::compile(&[LevelRule {
pattern: "^ERROR".to_string(),
level: LineLevel::Error,
}])
.unwrap();
assert_eq!(exact.level_of("error: nope"), None);
let folded = LevelMatcher::compile(&[LevelRule {
pattern: "(?i)^error".to_string(),
level: LineLevel::Error,
}])
.unwrap();
assert_eq!(folded.level_of("error: nope"), Some(LineLevel::Error));
}
#[test]
fn an_empty_pattern_is_refused() {
let err = LevelMatcher::compile(&[LevelRule {
pattern: String::new(),
level: LineLevel::Warn,
}])
.unwrap_err();
assert_eq!(
err,
LevelRuleError::EmptyPattern {
level: LineLevel::Warn
}
);
assert!(err.to_string().contains("warn"), "{err}");
}
#[test]
fn a_pattern_regex_refuses_is_refused() {
let err = LevelMatcher::compile(&[LevelRule {
pattern: "[unclosed".to_string(),
level: LineLevel::Error,
}])
.unwrap_err();
assert!(
matches!(&err, LevelRuleError::BadPattern { pattern, .. } if pattern == "[unclosed"),
"{err:?}"
);
}
#[test]
fn a_pattern_over_the_size_bound_is_refused() {
let err = LevelMatcher::compile(&[LevelRule {
pattern: format!("(a{}){{10000}}", "|b".repeat(100_000)),
level: LineLevel::Error,
}])
.unwrap_err();
assert!(matches!(err, LevelRuleError::BadPattern { .. }), "{err:?}");
}
}