Skip to main content

tess/
or.rs

1//! The OR dimension of filtering: `--or-filter` / `--or-grep` grouped by
2//! `--or-group`. A record passes the OR clause when *every* non-empty group
3//! has *at least one* matching condition (OR within a group, AND across
4//! groups). Required `--filter`/`--grep` are unchanged and AND'd on top.
5//!
6//! Each OR-filter compiles to a single-spec `CompiledFilter` and each OR-grep
7//! to a single-pattern `GrepPredicate`, so the existing field/regex machinery
8//! (including case policy and records-mode evaluation) is reused verbatim.
9
10use crate::filter::{CompiledFilter, FilterMatch, FilterSpec};
11use crate::format::LogFormat;
12use crate::grep::GrepPredicate;
13use crate::viewport::CaseMode;
14
15/// Name of the implicit group that holds OR-conditions given without an
16/// explicit `--or-group`.
17pub const DEFAULT_GROUP: &str = "default";
18
19/// Raw, ungrouped OR-conditions collected from argv or config, before
20/// compilation. Groups are kept in first-seen order.
21#[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    /// True when there are no OR-conditions at all (the OR clause is then
59    /// vacuously satisfied and callers can skip compilation).
60    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    /// True when any group carries a field-filter condition, which requires a
67    /// `--format` to resolve named captures.
68    pub fn has_filters(&self) -> bool {
69        self.groups.iter().any(|g| !g.filters.is_empty())
70    }
71}
72
73/// One compiled OR-group. Matches when ANY contained condition matches.
74#[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    /// OR-filters use `evaluate_record` (dotall + multi-line) so captures span
89    /// the whole record. OR-greps reuse `GrepPredicate::matches`, which scans
90    /// the full record bytes — a literal pattern on any continuation line still
91    /// matches — but is compiled single-line, so a `.` won't cross a newline.
92    /// This is identical to how the required `--grep` behaves on records.
93    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/// The compiled OR dimension. Empty (`is_active() == false`) means "no OR
102/// constraint": both `matches_*` return true via `all` over an empty list.
103#[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    /// Compile raw specs against an optional `format` (required only when any
122    /// OR-filter is present) and the active `case_mode`. Empty groups are
123    /// dropped so they impose no constraint.
124    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
150/// Walk an already-`expand_argv`'d argv and collect OR-conditions grouped by
151/// the most recent `--or-group` marker (default group before any marker).
152/// Handles `--flag value` and `--flag=value`. Does not strip tokens — clap
153/// still parses them for `--help` and error reporting; this is the source of
154/// truth for grouping because clap's `Vec` collection drops ordering.
155pub 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        // Resolve this flag's value: inline (`--flag=value`) or the next token.
166        // `--or-grep --foo` treats `--foo` as the pattern, matching clap's
167        // own two-token parse; clap owns validation, we only track ordering.
168        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(&current, v);
190                }
191            }
192            "--or-grep" => {
193                if let Some(v) = value {
194                    raw.add_grep(&current, 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        // A dangling `--or-group` (no following value) leaves the current group
320        // unchanged and must not panic. (clap rejects this in production, so the
321        // function never actually sees it, but be robust regardless.)
322        let raw = extract_from_argv(&argv(&["tess", "--or-grep", "x", "--or-group"]));
323        assert!(!raw.is_empty());
324    }
325}