1use crate::filter::{CompiledFilter, FilterMatch, FilterSpec};
11use crate::format::LogFormat;
12use crate::grep::GrepPredicate;
13use crate::viewport::CaseMode;
14
15pub const DEFAULT_GROUP: &str = "default";
18
19#[derive(Debug, Default, Clone)]
22pub struct OrSpecRaw {
23 groups: Vec<RawGroup>,
24}
25
26#[derive(Debug, Clone)]
27struct RawGroup {
28 name: String,
29 filters: Vec<String>,
30 greps: Vec<String>,
31}
32
33impl OrSpecRaw {
34 pub fn new() -> Self {
35 Self::default()
36 }
37
38 fn group_mut(&mut self, name: &str) -> &mut RawGroup {
39 if let Some(i) = self.groups.iter().position(|g| g.name == name) {
40 return &mut self.groups[i];
41 }
42 self.groups.push(RawGroup {
43 name: name.to_string(),
44 filters: Vec::new(),
45 greps: Vec::new(),
46 });
47 self.groups.last_mut().unwrap()
48 }
49
50 pub fn add_filter(&mut self, group: &str, spec: String) {
51 self.group_mut(group).filters.push(spec);
52 }
53
54 pub fn add_grep(&mut self, group: &str, pattern: String) {
55 self.group_mut(group).greps.push(pattern);
56 }
57
58 pub fn is_empty(&self) -> bool {
61 self.groups
62 .iter()
63 .all(|g| g.filters.is_empty() && g.greps.is_empty())
64 }
65
66 pub fn has_filters(&self) -> bool {
69 self.groups.iter().any(|g| !g.filters.is_empty())
70 }
71}
72
73#[derive(Debug)]
75struct OrGroup {
76 filters: Vec<CompiledFilter>,
77 greps: Vec<GrepPredicate>,
78}
79
80impl OrGroup {
81 fn matches_line(&self, line: &[u8]) -> bool {
82 self.filters
83 .iter()
84 .any(|f| matches!(f.evaluate(line), FilterMatch::Matched))
85 || self.greps.iter().any(|g| g.matches(line))
86 }
87
88 fn matches_record(&self, record: &[u8]) -> bool {
94 self.filters
95 .iter()
96 .any(|f| matches!(f.evaluate_record(record), FilterMatch::Matched))
97 || self.greps.iter().any(|g| g.matches(record))
98 }
99}
100
101#[derive(Debug, Default)]
104pub struct OrGroups {
105 groups: Vec<OrGroup>,
106}
107
108impl OrGroups {
109 pub fn is_active(&self) -> bool {
110 !self.groups.is_empty()
111 }
112
113 pub fn matches_line(&self, line: &[u8]) -> bool {
114 self.groups.iter().all(|g| g.matches_line(line))
115 }
116
117 pub fn matches_record(&self, record: &[u8]) -> bool {
118 self.groups.iter().all(|g| g.matches_record(record))
119 }
120
121 pub fn compile(
125 raw: &OrSpecRaw,
126 format: Option<&LogFormat>,
127 case_mode: CaseMode,
128 ) -> Result<Self, String> {
129 let mut groups = Vec::new();
130 for rg in &raw.groups {
131 if rg.filters.is_empty() && rg.greps.is_empty() {
132 continue;
133 }
134 let mut filters = Vec::with_capacity(rg.filters.len());
135 for spec_str in &rg.filters {
136 let fmt = format.ok_or_else(|| "--or-filter requires --format".to_string())?;
137 let spec = FilterSpec::parse(spec_str)?;
138 filters.push(CompiledFilter::compile(fmt, vec![spec], case_mode)?);
139 }
140 let mut greps = Vec::with_capacity(rg.greps.len());
141 for pat in &rg.greps {
142 greps.push(GrepPredicate::compile(std::slice::from_ref(pat), case_mode)?);
143 }
144 groups.push(OrGroup { filters, greps });
145 }
146 Ok(Self { groups })
147 }
148}
149
150pub fn extract_from_argv(argv: &[String]) -> OrSpecRaw {
156 let mut raw = OrSpecRaw::new();
157 let mut current = DEFAULT_GROUP.to_string();
158 let mut i = 0;
159 while i < argv.len() {
160 let arg = &argv[i];
161 let (flag, inline): (&str, Option<String>) = match arg.split_once('=') {
162 Some((f, v)) if f.starts_with("--") => (f, Some(v.to_string())),
163 _ => (arg.as_str(), None),
164 };
165 let value: Option<String> = if inline.is_some() {
169 inline
170 } else if matches!(flag, "--or-group" | "--or-filter" | "--or-grep") {
171 match argv.get(i + 1) {
172 Some(v) => {
173 i += 1;
174 Some(v.clone())
175 }
176 None => None,
177 }
178 } else {
179 None
180 };
181 match flag {
182 "--or-group" => {
183 if let Some(v) = value {
184 current = v;
185 }
186 }
187 "--or-filter" => {
188 if let Some(v) = value {
189 raw.add_filter(¤t, v);
190 }
191 }
192 "--or-grep" => {
193 if let Some(v) = value {
194 raw.add_grep(¤t, v);
195 }
196 }
197 _ => {}
198 }
199 i += 1;
200 }
201 raw
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn fmt() -> LogFormat {
209 LogFormat::compile("app", r"^(?P<lvl>\w+) (?P<msg>.+)$").unwrap()
210 }
211
212 #[test]
213 fn empty_spec_is_inactive_and_matches_everything() {
214 let raw = OrSpecRaw::new();
215 let og = OrGroups::compile(&raw, None, CaseMode::Sensitive).unwrap();
216 assert!(!og.is_active());
217 assert!(og.matches_line(b"anything"));
218 }
219
220 #[test]
221 fn default_group_is_a_single_or_pool() {
222 let mut raw = OrSpecRaw::new();
223 raw.add_grep(DEFAULT_GROUP, "failed".into());
224 raw.add_grep(DEFAULT_GROUP, "invalid".into());
225 let og = OrGroups::compile(&raw, None, CaseMode::Sensitive).unwrap();
226 assert!(og.is_active());
227 assert!(og.matches_line(b"login failed"));
228 assert!(og.matches_line(b"invalid user"));
229 assert!(!og.matches_line(b"all good"));
230 }
231
232 #[test]
233 fn groups_are_anded_conditions_within_group_ored() {
234 let mut raw = OrSpecRaw::new();
235 raw.add_grep("a", "failed".into());
236 raw.add_grep("a", "denied".into());
237 raw.add_grep("b", "ssh".into());
238 let og = OrGroups::compile(&raw, None, CaseMode::Sensitive).unwrap();
239 assert!(og.matches_line(b"ssh login failed"));
240 assert!(og.matches_line(b"ssh access denied"));
241 assert!(!og.matches_line(b"login failed"));
242 assert!(!og.matches_line(b"ssh login ok"));
243 }
244
245 #[test]
246 fn or_filter_and_or_grep_share_a_group() {
247 let mut raw = OrSpecRaw::new();
248 raw.add_filter(DEFAULT_GROUP, "lvl=ERROR".into());
249 raw.add_grep(DEFAULT_GROUP, "panic".into());
250 let og = OrGroups::compile(&raw, Some(&fmt()), CaseMode::Sensitive).unwrap();
251 assert!(og.matches_line(b"ERROR disk full"));
252 assert!(og.matches_line(b"INFO panic trace"));
253 assert!(!og.matches_line(b"INFO ok"));
254 }
255
256 #[test]
257 fn or_filter_without_format_errors() {
258 let mut raw = OrSpecRaw::new();
259 raw.add_filter(DEFAULT_GROUP, "lvl=ERROR".into());
260 let err = OrGroups::compile(&raw, None, CaseMode::Sensitive).unwrap_err();
261 assert!(err.contains("requires --format"), "{err}");
262 }
263
264 #[test]
265 fn has_filters_detects_field_conditions() {
266 let mut raw = OrSpecRaw::new();
267 raw.add_grep(DEFAULT_GROUP, "x".into());
268 assert!(!raw.has_filters());
269 raw.add_filter("g", "lvl=ERROR".into());
270 assert!(raw.has_filters());
271 }
272
273 fn argv(parts: &[&str]) -> Vec<String> {
274 parts.iter().map(|s| s.to_string()).collect()
275 }
276
277 #[test]
278 fn extract_unlabeled_go_to_default() {
279 let raw = extract_from_argv(&argv(&[
280 "tess", "--or-grep", "failed", "--or-filter", "lvl=ERROR",
281 ]));
282 let og = OrGroups::compile(&raw, Some(&fmt()), CaseMode::Sensitive).unwrap();
283 assert!(og.matches_line(b"INFO failed"));
284 assert!(og.matches_line(b"ERROR x"));
285 assert!(!og.matches_line(b"INFO ok"));
286 }
287
288 #[test]
289 fn extract_or_group_marker_scopes_following_conditions() {
290 let raw = extract_from_argv(&argv(&[
291 "tess",
292 "--or-grep", "failed",
293 "--or-group", "svc",
294 "--or-grep", "ssh",
295 ]));
296 let og = OrGroups::compile(&raw, None, CaseMode::Sensitive).unwrap();
297 assert!(og.matches_line(b"ssh failed"));
298 assert!(!og.matches_line(b"ssh ok"));
299 assert!(!og.matches_line(b"http failed"));
300 }
301
302 #[test]
303 fn extract_handles_attached_equals_form() {
304 let raw = extract_from_argv(&argv(&[
305 "tess", "--or-group=svc", "--or-grep=ssh", "--or-filter=lvl=ERROR",
306 ]));
307 let og = OrGroups::compile(&raw, Some(&fmt()), CaseMode::Sensitive).unwrap();
308 assert!(og.matches_line(b"ssh ERROR"));
309 }
310
311 #[test]
312 fn extract_ignores_non_or_flags() {
313 let raw = extract_from_argv(&argv(&["tess", "--follow", "-N", "file.log"]));
314 assert!(raw.is_empty());
315 }
316
317 #[test]
318 fn extract_or_group_at_eof_does_not_panic() {
319 let raw = extract_from_argv(&argv(&["tess", "--or-grep", "x", "--or-group"]));
323 assert!(!raw.is_empty());
324 }
325}