1use crate::glob::glob_match;
2use crate::model::Entry;
3
4#[derive(Debug)]
5enum Cmp {
6 Ge,
7 Le,
8 Gt,
9 Lt,
10 Eq,
11}
12
13#[derive(Debug)]
14enum Clause {
15 Host(String),
16 Method(String),
17 Path(String),
18 Status(Cmp, i64),
19 Time(Cmp, f64),
20 Has(String),
21}
22
23#[derive(Debug)]
24pub struct Filter {
25 clauses: Vec<Clause>,
26}
27
28impl Filter {
29 pub fn parse(exprs: &[String]) -> Result<Filter, String> {
31 let mut clauses = Vec::new();
32 for raw in exprs {
33 for token in raw.split_whitespace() {
34 clauses.push(parse_clause(token)?);
35 }
36 }
37 Ok(Filter { clauses })
38 }
39
40 pub fn matches(&self, e: &Entry) -> bool {
41 self.clauses.iter().all(|c| clause_matches(c, e))
42 }
43}
44
45fn parse_clause(token: &str) -> Result<Clause, String> {
46 let (key, val) = token
47 .split_once(':')
48 .ok_or_else(|| format!("invalid filter clause: {token}"))?;
49 match key {
50 "host" => Ok(Clause::Host(val.to_string())),
51 "method" => Ok(Clause::Method(val.to_ascii_uppercase())),
52 "path" => Ok(Clause::Path(val.to_string())),
53 "status" => {
54 let (cmp, n) = parse_cmp_int(val)?;
55 Ok(Clause::Status(cmp, n))
56 }
57 "time" => {
58 let v = val.trim_end_matches("ms");
59 let (cmp, n) = parse_cmp_float(v)?;
60 Ok(Clause::Time(cmp, n))
61 }
62 "has" => Ok(Clause::Has(val.to_ascii_lowercase())),
63 other => Err(format!("unknown filter key: {other}")),
64 }
65}
66
67fn parse_cmp_int(s: &str) -> Result<(Cmp, i64), String> {
68 let (cmp, rest) = split_cmp(s);
69 let n = rest
70 .parse::<i64>()
71 .map_err(|_| format!("invalid number: {rest}"))?;
72 Ok((cmp, n))
73}
74
75fn parse_cmp_float(s: &str) -> Result<(Cmp, f64), String> {
76 let (cmp, rest) = split_cmp(s);
77 let n = rest
78 .parse::<f64>()
79 .map_err(|_| format!("invalid number: {rest}"))?;
80 Ok((cmp, n))
81}
82
83fn split_cmp(s: &str) -> (Cmp, &str) {
84 if let Some(rest) = s.strip_prefix(">=") {
85 (Cmp::Ge, rest)
86 } else if let Some(rest) = s.strip_prefix("<=") {
87 (Cmp::Le, rest)
88 } else if let Some(rest) = s.strip_prefix('>') {
89 (Cmp::Gt, rest)
90 } else if let Some(rest) = s.strip_prefix('<') {
91 (Cmp::Lt, rest)
92 } else if let Some(rest) = s.strip_prefix('=') {
93 (Cmp::Eq, rest)
94 } else {
95 (Cmp::Eq, s)
96 }
97}
98
99fn cmp_i(cmp: &Cmp, a: i64, b: i64) -> bool {
100 match cmp {
101 Cmp::Ge => a >= b,
102 Cmp::Le => a <= b,
103 Cmp::Gt => a > b,
104 Cmp::Lt => a < b,
105 Cmp::Eq => a == b,
106 }
107}
108
109fn cmp_f(cmp: &Cmp, a: f64, b: f64) -> bool {
110 match cmp {
111 Cmp::Ge => a >= b,
112 Cmp::Le => a <= b,
113 Cmp::Gt => a > b,
114 Cmp::Lt => a < b,
115 Cmp::Eq => a == b,
116 }
117}
118
119fn clause_matches(c: &Clause, e: &Entry) -> bool {
120 match c {
121 Clause::Host(h) => glob_match(h, &e.host),
122 Clause::Method(m) => e.method.eq_ignore_ascii_case(m),
123 Clause::Path(p) => glob_match(p, &e.path),
124 Clause::Status(cmp, n) => cmp_i(cmp, e.status, *n),
125 Clause::Time(cmp, n) => cmp_f(cmp, e.duration_ms, *n),
126 Clause::Has(field) => has_field(field, e),
127 }
128}
129
130fn has_field(field: &str, e: &Entry) -> bool {
131 if let Some(name) = field.strip_prefix("req.header.") {
133 return e
134 .req_headers
135 .iter()
136 .any(|(n, _)| n.eq_ignore_ascii_case(name));
137 }
138 if let Some(name) = field.strip_prefix("resp.header.") {
139 return e
140 .resp_headers
141 .iter()
142 .any(|(n, _)| n.eq_ignore_ascii_case(name));
143 }
144 match field {
145 "req.body" => e.req_body.as_ref().is_some_and(|b| !b.is_empty()),
146 "resp.body" => e.resp_body.as_ref().is_some_and(|b| !b.is_empty()),
147 _ => false,
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::Filter;
154 use crate::classify::ResourceType;
155 use crate::model::{Entry, Phases, Sizes};
156
157 fn entry(host: &str, status: i64, method: &str, path: &str, dur: f64) -> Entry {
158 Entry {
159 id: "e000000".into(),
160 index: 0,
161 started_offset_ms: 0.0,
162 duration_ms: dur,
163 method: method.into(),
164 url: format!("https://{host}{path}"),
165 host: host.into(),
166 path: path.into(),
167 norm_path: path.into(),
168 query: vec![],
169 status,
170 status_text: String::new(),
171 resource_type: ResourceType::Api,
172 content_type: None,
173 req_headers: vec![("authorization".into(), "x".into())],
174 resp_headers: vec![],
175 req_body: None,
176 resp_body: None,
177 timings: Phases::default(),
178 sizes: Sizes::default(),
179 server_ip: None,
180 http_version: "HTTP/2".into(),
181 redirect_url: None,
182 correlation: vec![],
183 }
184 }
185
186 #[test]
187 fn matches_host_and_status() {
188 let f = Filter::parse(&["host:api.foo.com".into(), "status:>=400".into()]).unwrap();
189 assert!(f.matches(&entry("api.foo.com", 500, "GET", "/x", 10.0)));
190 assert!(!f.matches(&entry("api.foo.com", 200, "GET", "/x", 10.0)));
191 assert!(!f.matches(&entry("other.com", 500, "GET", "/x", 10.0)));
192 }
193
194 #[test]
195 fn matches_method_and_path_glob_and_time() {
196 let f = Filter::parse(&[
197 "method:POST".into(),
198 "path:*login*".into(),
199 "time:>5ms".into(),
200 ])
201 .unwrap();
202 assert!(f.matches(&entry("h", 200, "POST", "/v1/login/start", 10.0)));
203 assert!(!f.matches(&entry("h", 200, "POST", "/v1/login/start", 1.0)));
204 assert!(!f.matches(&entry("h", 200, "GET", "/v1/login/start", 10.0)));
205 }
206
207 #[test]
208 fn matches_has_header() {
209 let f = Filter::parse(&["has:req.header.authorization".into()]).unwrap();
210 assert!(f.matches(&entry("h", 200, "GET", "/x", 1.0)));
211 }
212
213 #[test]
214 fn empty_filter_matches_all() {
215 let f = Filter::parse(&[]).unwrap();
216 assert!(f.matches(&entry("h", 200, "GET", "/x", 1.0)));
217 }
218}