diff --git a/Cargo.lock b/Cargo.lock index 70567d78..71c0b45a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,7 +182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6798148dccfbff0fae41c7574d2fa8f1ef3492fba0face179de5d8d447d67b05" dependencies = [ "memchr", - "regex-automata 0.3.6", + "regex-automata 0.3.7", "serde", ] @@ -1427,14 +1427,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.9.3" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81bc1d4caf89fac26a70747fe603c130093b53c773888797a6329091246d651a" +checksum = "12de2eff854e5fa4b1295edd650e227e9d8fb0c9e90b12e7f36d6a6811791a29" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.3.6", - "regex-syntax 0.7.4", + "regex-automata 0.3.7", + "regex-syntax 0.7.5", ] [[package]] @@ -1448,13 +1448,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed1ceff11a1dddaee50c9dc8e4938bd106e9d89ae372f192311e7da498e3b69" +checksum = "49530408a136e16e5b486e883fbb6ba058e8e4e8ae6621a77b048b314336e629" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.7.4", + "regex-syntax 0.7.5", ] [[package]] @@ -1465,9 +1465,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" [[package]] name = "rustc-demangle" @@ -1750,6 +1750,7 @@ dependencies = [ "backoff", "itertools", "miette", + "regex", "serde", "serde_json", "tokio", diff --git a/test-harness/Cargo.toml b/test-harness/Cargo.toml index 2b520dc1..7c0cefc2 100644 --- a/test-harness/Cargo.toml +++ b/test-harness/Cargo.toml @@ -9,6 +9,7 @@ publish = false backoff = { version = "0.4.0", default-features = false } itertools = "0.11.0" miette = { version = "5.9.0", features = ["fancy"] } +regex = "1.9.4" serde = { version = "1.0.186", features = ["derive"] } serde_json = "1.0.105" tokio = { version = "1.28.2", features = ["full", "tracing"] } diff --git a/test-harness/src/lib.rs b/test-harness/src/lib.rs index ce7d22b0..3a355475 100644 --- a/test-harness/src/lib.rs +++ b/test-harness/src/lib.rs @@ -3,4 +3,8 @@ pub use tracing_json::Event; mod tracing_reader; +mod matcher; +pub use matcher::IntoMatcher; +pub use matcher::Matcher; + pub mod fs; diff --git a/test-harness/src/matcher.rs b/test-harness/src/matcher.rs new file mode 100644 index 00000000..9899aa77 --- /dev/null +++ b/test-harness/src/matcher.rs @@ -0,0 +1,167 @@ +use miette::IntoDiagnostic; +use regex::Regex; + +use crate::Event; + +/// An [`Event`] matcher. +pub struct Matcher { + message: Regex, + target: Option, + spans: Vec, +} + +impl Matcher { + /// Construct a query for events with messages matching the given regex. + pub fn message(message_regex: &str) -> miette::Result { + let message = Regex::new(message_regex).into_diagnostic()?; + Ok(Self { + message, + target: None, + spans: Vec::new(), + }) + } + + /// Construct a query for new span events, denoted by a `new` message. + pub fn span_new() -> Self { + // This regex will never fail to parse. + Self::message("new").unwrap() + } + + /// Construct a query for span close events, denoted by a `close` message. + pub fn span_close() -> Self { + // This regex will never fail to parse. + Self::message("close").unwrap() + } + + /// Require that matching events be in a span with the given name. + /// + /// Note that this will overwrite any previously-set spans. + pub fn in_span(mut self, span: &str) -> Self { + self.spans.clear(); + self.spans.push(span.to_owned()); + self + } + + /// Require that matching events be in spans with the given names. + /// + /// Spans are listed from the inside out; that is, a call to `in_spans(["a", "b", "c"])` will + /// require that events be emitted from a span `a` directly nested in a span + /// `b` directly nested in a span `c`. + /// + /// Note that this will overwrite any previously-set spans. + pub fn in_spans(mut self, spans: impl IntoIterator>) -> Self { + self.spans = spans.into_iter().map(|s| s.as_ref().to_owned()).collect(); + self + } + + /// Require that matching events be emitted from the given module as recorded by the event's + /// `target` field. + /// + /// Note that this requires the module name to match exactly; child modules will not be + /// matched. + pub fn from_module(mut self, module: &str) -> Self { + self.target = Some(module.to_owned()); + self + } + + /// Determines if this query matches the given event. + pub fn matches(&self, event: &Event) -> bool { + if !self.message.is_match(&event.message) { + return false; + } + + if !self.spans.is_empty() { + let mut spans = event.spans(); + for expected_name in &self.spans { + match spans.next() { + Some(actual_span) => { + if &actual_span.name != expected_name { + return false; + } + } + None => { + // We expected another span but the event doesn't have one. + return false; + } + } + } + } + + if let Some(target) = &self.target { + if target != &event.target { + return false; + } + } + + true + } +} + +/// A type that can be converted into a `Matcher` and used for searching log events. +pub trait IntoMatcher { + /// Convert the object into a `Matcher`. + fn into_matcher(self) -> miette::Result; +} + +impl IntoMatcher for Matcher { + fn into_matcher(self) -> miette::Result { + Ok(self) + } +} + +impl IntoMatcher for &str { + fn into_matcher(self) -> miette::Result { + Matcher::message(self) + } +} + +#[cfg(test)] +mod tests { + use tracing::Level; + + use crate::tracing_json::Span; + + use super::*; + + #[test] + fn test_matcher_message() { + let matcher = r"ghci started in \d+\.\d+s".into_matcher().unwrap(); + assert!(matcher.matches(&Event { + timestamp: "2023-08-25T22:14:30.067641Z".to_owned(), + level: Level::INFO, + message: "ghci started in 2.44s".to_owned(), + fields: Default::default(), + target: "ghcid_ng::ghci".to_owned(), + span: Some(Span { + name: "ghci".to_owned(), + rest: Default::default() + }), + spans: vec![Span { + name: "ghci".to_owned(), + rest: Default::default() + },] + })); + } + + #[test] + fn test_matcher_spans_and_target() { + let matcher = Matcher::span_close() + .from_module("ghcid_ng::ghci") + .in_spans(["reload", "on_action"]); + assert!(matcher.matches(&Event { + timestamp: "2023-08-25T22:14:30.993920Z".to_owned(), + level: Level::DEBUG, + message: "close".to_owned(), + fields: Default::default(), + target: "ghcid_ng::ghci".to_owned(), + span: Some(Span { + name: "reload".to_owned(), + rest: Default::default() + }), + spans: vec![Span { + name: "on_action".to_owned(), + rest: Default::default() + },] + })); + } +}