use shep_core::config::{LevelMatcher, LevelRule};
pub use shep_core::config::LineLevel as Level;
const LEVEL_KEYS: [&str; 3] = ["level", "lvl", "severity"];
#[must_use]
pub fn level_of(line: &str) -> Option<Level> {
line.split_whitespace().take(4).find_map(|word| {
match level_word(word)
.trim_matches(|c: char| !c.is_ascii_alphabetic() && !c.is_ascii_digit())
.to_ascii_lowercase()
.as_str()
{
"trace" => Some(Level::Trace),
"debug" => Some(Level::Debug),
"info" => Some(Level::Info),
"warn" | "warning" => Some(Level::Warn),
"error" | "fatal" => Some(Level::Error),
_ => None,
}
})
}
fn level_word(word: &str) -> &str {
match word.split_once('=') {
Some((key, value)) if LEVEL_KEYS.iter().any(|k| k.eq_ignore_ascii_case(key)) => value,
_ => word,
}
}
#[derive(Debug)]
pub struct Classifier(Option<LevelMatcher>);
impl Classifier {
#[must_use]
pub fn new(rules: &[LevelRule]) -> Self {
if rules.is_empty() {
return Self(None);
}
Self(LevelMatcher::compile(rules).ok())
}
#[must_use]
pub fn level_of(&self, line: &str) -> Option<Level> {
match &self.0 {
Some(matcher) => matcher.level_of(line),
None => level_of(line),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_leading_level_word_is_found_whatever_its_case() {
assert_eq!(level_of("WARN pool exhausted"), Some(Level::Warn));
assert_eq!(level_of("warn pool exhausted"), Some(Level::Warn));
assert_eq!(level_of("Error: connection refused"), Some(Level::Error));
}
#[test]
fn a_level_after_a_timestamp_is_still_found() {
assert_eq!(
level_of("2026-09-08T11:02:03Z INFO listening on 8080"),
Some(Level::Info)
);
}
#[test]
fn a_line_with_no_level_word_has_no_level() {
assert_eq!(level_of("listening on 8080"), None);
assert_eq!(level_of(""), None);
}
#[test]
fn a_level_name_inside_a_longer_word_is_not_a_level() {
assert_eq!(level_of("information about the pool"), None);
assert_eq!(level_of("warnings are disabled"), None);
}
#[test]
fn the_long_forms_of_warn_and_error_are_recognised() {
assert_eq!(level_of("a warning was logged"), Some(Level::Warn));
assert_eq!(level_of("a fatal crash occurred"), Some(Level::Error));
}
#[test]
fn the_lowest_two_levels_are_recognised() {
assert_eq!(level_of("TRACE entering the pool"), Some(Level::Trace));
assert_eq!(level_of("debug pool has 4 idle"), Some(Level::Debug));
}
#[test]
fn a_digit_touching_the_word_blocks_the_match() {
assert_eq!(level_of("/error404 requested"), None);
assert_eq!(level_of("info2 and counting"), None);
assert_eq!(level_of("GET /info2 200"), None);
}
#[test]
fn surrounding_punctuation_still_trims() {
assert_eq!(level_of("[WARN] pool exhausted"), Some(Level::Warn));
assert_eq!(level_of("Error: connection refused"), Some(Level::Error));
}
#[test]
fn the_scan_window_is_exactly_four_words() {
assert_eq!(level_of("a b c warn"), Some(Level::Warn));
assert_eq!(level_of("a b c d warn"), None);
}
#[test]
fn a_logfmt_line_announces_its_level() {
assert_eq!(
level_of(r#"time=2026-09-08T11:02:03.000Z level=ERROR msg="connection refused""#),
Some(Level::Error)
);
assert_eq!(
level_of("ts=2026-09-08T11:02:03Z caller=main.go:42 level=info msg=listening"),
Some(Level::Info)
);
}
#[test]
fn every_accepted_key_is_read() {
assert_eq!(level_of("level=warn pool exhausted"), Some(Level::Warn));
assert_eq!(level_of("lvl=warn pool exhausted"), Some(Level::Warn));
assert_eq!(level_of("severity=warn pool exhausted"), Some(Level::Warn));
}
#[test]
fn a_key_matches_whatever_its_case() {
assert_eq!(
level_of("LEVEL=Error connection refused"),
Some(Level::Error)
);
assert_eq!(level_of("Lvl=FATAL connection refused"), Some(Level::Error));
}
#[test]
fn a_pair_value_follows_the_bare_word_rules() {
assert_eq!(level_of(r#"level="error" msg=x"#), Some(Level::Error));
assert_eq!(level_of("level=error404 msg=x"), None);
assert_eq!(level_of("level= msg=x"), None);
}
#[test]
fn a_key_outside_the_set_announces_nothing() {
assert_eq!(level_of("l=error connection refused"), None);
assert_eq!(level_of("loglevel=error connection refused"), None);
assert_eq!(level_of("status=error connection refused"), None);
}
#[test]
fn a_query_string_is_not_a_level_pair() {
assert_eq!(level_of("GET /search?level=error 200"), None);
}
#[test]
fn no_declared_rules_falls_back_to_the_built_in_reading() {
let classifier = Classifier::new(&[]);
assert_eq!(
classifier.level_of("WARN pool exhausted"),
Some(Level::Warn)
);
assert_eq!(classifier.level_of("listening on 8080"), None);
}
#[test]
fn a_declared_rule_reads_a_line_the_built_in_reading_cannot() {
let line = r#"{"severity":"ERROR","msg":"boom"}"#;
assert_eq!(level_of(line), None);
let classifier = Classifier::new(&[LevelRule {
pattern: r#""severity":"ERROR""#.to_string(),
level: Level::Error,
}]);
assert_eq!(classifier.level_of(line), Some(Level::Error));
}
#[test]
fn a_declared_rule_set_replaces_the_built_in_reading() {
let classifier = Classifier::new(&[LevelRule {
pattern: "^E/".to_string(),
level: Level::Error,
}]);
assert_eq!(level_of("WARN pool exhausted"), Some(Level::Warn));
assert_eq!(classifier.level_of("WARN pool exhausted"), None);
assert_eq!(classifier.level_of("E/tag boom"), Some(Level::Error));
}
#[test]
fn a_rule_set_that_will_not_compile_falls_back_to_the_built_in_reading() {
let classifier = Classifier::new(&[LevelRule {
pattern: "[unclosed".to_string(),
level: Level::Error,
}]);
assert_eq!(
classifier.level_of("WARN pool exhausted"),
Some(Level::Warn)
);
}
#[test]
fn levels_order_from_trace_up_to_error() {
assert!(Level::Trace < Level::Debug);
assert!(Level::Debug < Level::Info);
assert!(Level::Info < Level::Warn);
assert!(Level::Warn < Level::Error);
}
}