use crate::filter::{CompiledFilter, FilterMatch, FilterSpec};
use crate::format::LogFormat;
use crate::grep::GrepPredicate;
use crate::viewport::CaseMode;
pub const DEFAULT_GROUP: &str = "default";
#[derive(Debug, Default, Clone)]
pub struct OrSpecRaw {
groups: Vec<RawGroup>,
}
#[derive(Debug, Clone)]
struct RawGroup {
name: String,
filters: Vec<String>,
greps: Vec<String>,
}
impl OrSpecRaw {
pub fn new() -> Self {
Self::default()
}
fn group_mut(&mut self, name: &str) -> &mut RawGroup {
if let Some(i) = self.groups.iter().position(|g| g.name == name) {
return &mut self.groups[i];
}
self.groups.push(RawGroup {
name: name.to_string(),
filters: Vec::new(),
greps: Vec::new(),
});
self.groups.last_mut().unwrap()
}
pub fn add_filter(&mut self, group: &str, spec: String) {
self.group_mut(group).filters.push(spec);
}
pub fn add_grep(&mut self, group: &str, pattern: String) {
self.group_mut(group).greps.push(pattern);
}
pub fn is_empty(&self) -> bool {
self.groups
.iter()
.all(|g| g.filters.is_empty() && g.greps.is_empty())
}
pub fn has_filters(&self) -> bool {
self.groups.iter().any(|g| !g.filters.is_empty())
}
}
#[derive(Debug)]
struct OrGroup {
filters: Vec<CompiledFilter>,
greps: Vec<GrepPredicate>,
}
impl OrGroup {
fn matches_line(&self, line: &[u8]) -> bool {
self.filters
.iter()
.any(|f| matches!(f.evaluate(line), FilterMatch::Matched))
|| self.greps.iter().any(|g| g.matches(line))
}
fn matches_record(&self, record: &[u8]) -> bool {
self.filters
.iter()
.any(|f| matches!(f.evaluate_record(record), FilterMatch::Matched))
|| self.greps.iter().any(|g| g.matches(record))
}
}
#[derive(Debug, Default)]
pub struct OrGroups {
groups: Vec<OrGroup>,
}
impl OrGroups {
pub fn is_active(&self) -> bool {
!self.groups.is_empty()
}
pub fn matches_line(&self, line: &[u8]) -> bool {
self.groups.iter().all(|g| g.matches_line(line))
}
pub fn matches_record(&self, record: &[u8]) -> bool {
self.groups.iter().all(|g| g.matches_record(record))
}
pub fn compile(
raw: &OrSpecRaw,
format: Option<&LogFormat>,
case_mode: CaseMode,
) -> Result<Self, String> {
let mut groups = Vec::new();
for rg in &raw.groups {
if rg.filters.is_empty() && rg.greps.is_empty() {
continue;
}
let mut filters = Vec::with_capacity(rg.filters.len());
for spec_str in &rg.filters {
let fmt = format.ok_or_else(|| "--or-filter requires --format".to_string())?;
let spec = FilterSpec::parse(spec_str)?;
filters.push(CompiledFilter::compile(fmt, vec![spec], case_mode)?);
}
let mut greps = Vec::with_capacity(rg.greps.len());
for pat in &rg.greps {
greps.push(GrepPredicate::compile(std::slice::from_ref(pat), case_mode)?);
}
groups.push(OrGroup { filters, greps });
}
Ok(Self { groups })
}
}
pub fn extract_from_argv(argv: &[String]) -> OrSpecRaw {
let mut raw = OrSpecRaw::new();
let mut current = DEFAULT_GROUP.to_string();
let mut i = 0;
while i < argv.len() {
let arg = &argv[i];
let (flag, inline): (&str, Option<String>) = match arg.split_once('=') {
Some((f, v)) if f.starts_with("--") => (f, Some(v.to_string())),
_ => (arg.as_str(), None),
};
let value: Option<String> = if inline.is_some() {
inline
} else if matches!(flag, "--or-group" | "--or-filter" | "--or-grep") {
match argv.get(i + 1) {
Some(v) => {
i += 1;
Some(v.clone())
}
None => None,
}
} else {
None
};
match flag {
"--or-group" => {
if let Some(v) = value {
current = v;
}
}
"--or-filter" => {
if let Some(v) = value {
raw.add_filter(¤t, v);
}
}
"--or-grep" => {
if let Some(v) = value {
raw.add_grep(¤t, v);
}
}
_ => {}
}
i += 1;
}
raw
}
#[cfg(test)]
mod tests {
use super::*;
fn fmt() -> LogFormat {
LogFormat::compile("app", r"^(?P<lvl>\w+) (?P<msg>.+)$").unwrap()
}
#[test]
fn empty_spec_is_inactive_and_matches_everything() {
let raw = OrSpecRaw::new();
let og = OrGroups::compile(&raw, None, CaseMode::Sensitive).unwrap();
assert!(!og.is_active());
assert!(og.matches_line(b"anything"));
}
#[test]
fn default_group_is_a_single_or_pool() {
let mut raw = OrSpecRaw::new();
raw.add_grep(DEFAULT_GROUP, "failed".into());
raw.add_grep(DEFAULT_GROUP, "invalid".into());
let og = OrGroups::compile(&raw, None, CaseMode::Sensitive).unwrap();
assert!(og.is_active());
assert!(og.matches_line(b"login failed"));
assert!(og.matches_line(b"invalid user"));
assert!(!og.matches_line(b"all good"));
}
#[test]
fn groups_are_anded_conditions_within_group_ored() {
let mut raw = OrSpecRaw::new();
raw.add_grep("a", "failed".into());
raw.add_grep("a", "denied".into());
raw.add_grep("b", "ssh".into());
let og = OrGroups::compile(&raw, None, CaseMode::Sensitive).unwrap();
assert!(og.matches_line(b"ssh login failed"));
assert!(og.matches_line(b"ssh access denied"));
assert!(!og.matches_line(b"login failed"));
assert!(!og.matches_line(b"ssh login ok"));
}
#[test]
fn or_filter_and_or_grep_share_a_group() {
let mut raw = OrSpecRaw::new();
raw.add_filter(DEFAULT_GROUP, "lvl=ERROR".into());
raw.add_grep(DEFAULT_GROUP, "panic".into());
let og = OrGroups::compile(&raw, Some(&fmt()), CaseMode::Sensitive).unwrap();
assert!(og.matches_line(b"ERROR disk full"));
assert!(og.matches_line(b"INFO panic trace"));
assert!(!og.matches_line(b"INFO ok"));
}
#[test]
fn or_filter_without_format_errors() {
let mut raw = OrSpecRaw::new();
raw.add_filter(DEFAULT_GROUP, "lvl=ERROR".into());
let err = OrGroups::compile(&raw, None, CaseMode::Sensitive).unwrap_err();
assert!(err.contains("requires --format"), "{err}");
}
#[test]
fn has_filters_detects_field_conditions() {
let mut raw = OrSpecRaw::new();
raw.add_grep(DEFAULT_GROUP, "x".into());
assert!(!raw.has_filters());
raw.add_filter("g", "lvl=ERROR".into());
assert!(raw.has_filters());
}
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
#[test]
fn extract_unlabeled_go_to_default() {
let raw = extract_from_argv(&argv(&[
"tess", "--or-grep", "failed", "--or-filter", "lvl=ERROR",
]));
let og = OrGroups::compile(&raw, Some(&fmt()), CaseMode::Sensitive).unwrap();
assert!(og.matches_line(b"INFO failed"));
assert!(og.matches_line(b"ERROR x"));
assert!(!og.matches_line(b"INFO ok"));
}
#[test]
fn extract_or_group_marker_scopes_following_conditions() {
let raw = extract_from_argv(&argv(&[
"tess",
"--or-grep", "failed",
"--or-group", "svc",
"--or-grep", "ssh",
]));
let og = OrGroups::compile(&raw, None, CaseMode::Sensitive).unwrap();
assert!(og.matches_line(b"ssh failed"));
assert!(!og.matches_line(b"ssh ok"));
assert!(!og.matches_line(b"http failed"));
}
#[test]
fn extract_handles_attached_equals_form() {
let raw = extract_from_argv(&argv(&[
"tess", "--or-group=svc", "--or-grep=ssh", "--or-filter=lvl=ERROR",
]));
let og = OrGroups::compile(&raw, Some(&fmt()), CaseMode::Sensitive).unwrap();
assert!(og.matches_line(b"ssh ERROR"));
}
#[test]
fn extract_ignores_non_or_flags() {
let raw = extract_from_argv(&argv(&["tess", "--follow", "-N", "file.log"]));
assert!(raw.is_empty());
}
#[test]
fn extract_or_group_at_eof_does_not_panic() {
let raw = extract_from_argv(&argv(&["tess", "--or-grep", "x", "--or-group"]));
assert!(!raw.is_empty());
}
}