diff options
author | Daniel <kingdread@gmx.de> | 2020-04-15 15:01:10 +0200 |
---|---|---|
committer | Daniel <kingdread@gmx.de> | 2020-04-15 15:01:10 +0200 |
commit | 7881ba85ff40f3d22237ef903c7241b56aa9c185 (patch) | |
tree | 975ad07930052191766f21515729c2880bbbc2e2 | |
parent | 3c429432382dfad6d4ac97349c96e4a4eb292089 (diff) | |
download | raidgrep-7881ba85ff40f3d22237ef903c7241b56aa9c185.tar.gz raidgrep-7881ba85ff40f3d22237ef903c7241b56aa9c185.tar.bz2 raidgrep-7881ba85ff40f3d22237ef903c7241b56aa9c185.zip |
new filter pipeline
This is the groundwork for introducing more complex filter queries like
`find` has. Filters can be arbitrarily combined with and/or/not and
support an "early filter" mode.
So far, the filters have been translated pretty mechanically to match
the current command line arguments, so now new syntax has been
introduced.
The NameFilter is not yet in its final version. The goal is to support
something like PlayerAll/PlayerExists and have a PlayerFilter that works
on single players instead of the complete log, but that might introduce
some code duplication as we then need a PlayerFilterAnd, PlayerFilterOr,
...
Some digging has to be done into whether we can reduce that duplication
without devolving into madness or resorting to macros. Maybe some
type-level generic hackery could be done? Maybe an enum instead of
dynamic traits should be used, at least for the base functions?
-rw-r--r-- | src/csl.rs | 34 | ||||
-rw-r--r-- | src/filters.rs | 396 | ||||
-rw-r--r-- | src/main.rs | 43 |
3 files changed, 397 insertions, 76 deletions
@@ -1,14 +1,14 @@ use std::collections::HashSet; +use std::fmt; use std::hash::Hash; use std::str::FromStr; -use std::fmt; -use super::{SearchField, FightOutcome}; +use super::{FightOutcome, SearchField}; use chrono::Weekday; use evtclib::statistics::gamedata::Boss; pub trait Variants: Copy { - type Output: Iterator<Item=Self>; + type Output: Iterator<Item = Self>; fn variants() -> Self::Output; } @@ -79,18 +79,21 @@ impl<E: fmt::Display> fmt::Display for ParseError<E> { } impl<T> FromStr for CommaSeparatedList<T> - where T: FromStr + Variants + Hash + Eq + fmt::Debug +where + T: FromStr + Variants + Hash + Eq + fmt::Debug, { type Err = ParseError<T::Err>; fn from_str(input: &str) -> Result<Self, Self::Err> { if input == "*" { - Ok(CommaSeparatedList { values: T::variants().collect() }) + Ok(CommaSeparatedList { + values: T::variants().collect(), + }) } else if input.starts_with(NEGATOR) { let no_csl = CommaSeparatedList::from_str(&input[1..])?; let all_values = T::variants().collect::<HashSet<_>>(); Ok(CommaSeparatedList { - values: all_values.difference(&no_csl.values).cloned().collect() + values: all_values.difference(&no_csl.values).cloned().collect(), }) } else { let parts = input.split(DELIMITER); @@ -104,9 +107,26 @@ impl<T> FromStr for CommaSeparatedList<T> } impl<T> CommaSeparatedList<T> - where T: Hash + Eq + fmt::Debug +where + T: Hash + Eq + fmt::Debug, { pub fn contains(&self, value: &T) -> bool { self.values.contains(value) } + + pub fn values(&self) -> &HashSet<T> { + &self.values + } +} + +// We allow implicit hasher because then it's a zero-cost conversion, as we're just unwrapping the +// values. +#[allow(clippy::implicit_hasher)] +impl<T> From<CommaSeparatedList<T>> for HashSet<T> +where + T: Hash + Eq + fmt::Debug, +{ + fn from(csl: CommaSeparatedList<T>) -> Self { + csl.values + } } diff --git a/src/filters.rs b/src/filters.rs index cdd8f36..7da19ec 100644 --- a/src/filters.rs +++ b/src/filters.rs @@ -1,79 +1,361 @@ +#![allow(clippy::new_ret_no_self)] +use std::collections::HashSet; +use std::ops; + use evtclib::raw::parser::PartialEvtc; use evtclib::statistics::gamedata::Boss; use evtclib::{Agent, AgentName}; -use num_traits::FromPrimitive; - -use super::{guilds, LogResult, Opt, SearchField}; - -use chrono::Datelike; - -/// Do filtering based on the character or account name. -pub fn filter_name(evtc: &PartialEvtc, opt: &Opt) -> bool { - for player in &evtc.agents { - let fancy = Agent::from_raw(player); - if let Ok(AgentName::Player { - ref account_name, - ref character_name, - .. - }) = fancy.as_ref().map(Agent::name) - { - if (opt.field.contains(&SearchField::Account) && opt.expression.is_match(account_name)) - || (opt.field.contains(&SearchField::Character) - && opt.expression.is_match(character_name)) +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; + +use regex::Regex; + +use super::{guilds, FightOutcome, LogResult, SearchField, Weekday}; + +use chrono::{Datelike, NaiveDateTime}; + +/// Early filtering result. +/// +/// This implements a [three-valued logic](https://en.wikipedia.org/wiki/Three-valued_logic), +/// similar to SQL's `NULL`. +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[repr(i8)] +pub enum Inclusion { + /// The log should be included. + Include = 1, + /// The state is yet unknown. + Unknown = 0, + /// The log should be excluded. + Exclude = -1, +} + +impl ops::Not for Inclusion { + type Output = Self; + + fn not(self) -> Self::Output { + Inclusion::from_i8(-(self as i8)).unwrap() + } +} + +impl ops::BitAnd<Inclusion> for Inclusion { + type Output = Self; + + fn bitand(self, rhs: Inclusion) -> Self::Output { + Inclusion::from_i8((self as i8).min(rhs as i8)).unwrap() + } +} + +impl ops::BitOr<Inclusion> for Inclusion { + type Output = Self; + + fn bitor(self, rhs: Inclusion) -> Self::Output { + Inclusion::from_i8((self as i8).max(rhs as i8)).unwrap() + } +} + +impl From<bool> for Inclusion { + fn from(data: bool) -> Self { + if data { + Inclusion::Include + } else { + Inclusion::Exclude + } + } +} + +/// The main filter trait. +/// +/// Filters are usually handled as a `Box<dyn Filter>`. +pub trait Filter: Send + Sync { + /// Determine early (before processing all events) whether the log stands a chance to be + /// included. + /// + /// Note that you can return [Inclusion::Unkown] if this filter cannot determine yet a definite + /// answer. + fn filter_early(&self, _: &PartialEvtc) -> Inclusion { + Inclusion::Unknown + } + + /// Return whether the log should be included, according to this filter. + fn filter(&self, log: &LogResult) -> bool; +} + +#[derive(Debug, Clone, Copy)] +pub struct Const(pub bool); + +impl Const { + pub fn new(output: bool) -> Box<dyn Filter> { + Box::new(Const(output)) + } +} + +impl Filter for Const { + fn filter_early(&self, _: &PartialEvtc) -> Inclusion { + self.0.into() + } + + fn filter(&self, _: &LogResult) -> bool { + self.0 + } +} + +struct AndFilter(Box<dyn Filter>, Box<dyn Filter>); + +impl Filter for AndFilter { + fn filter_early(&self, partial_evtc: &PartialEvtc) -> Inclusion { + let lhs = self.0.filter_early(partial_evtc); + // Short circuit behaviour + if lhs == Inclusion::Exclude { + Inclusion::Exclude + } else { + lhs & self.1.filter_early(partial_evtc) + } + } + + fn filter(&self, log: &LogResult) -> bool { + self.0.filter(log) && self.1.filter(log) + } +} + +impl ops::BitAnd<Box<dyn Filter>> for Box<dyn Filter> { + type Output = Box<dyn Filter>; + + fn bitand(self, rhs: Box<dyn Filter>) -> Self::Output { + Box::new(AndFilter(self, rhs)) + } +} + +struct OrFilter(Box<dyn Filter>, Box<dyn Filter>); + +impl Filter for OrFilter { + fn filter_early(&self, partial_evtc: &PartialEvtc) -> Inclusion { + let lhs = self.0.filter_early(partial_evtc); + // Short circuit behaviour + if lhs == Inclusion::Include { + Inclusion::Include + } else { + lhs | self.1.filter_early(partial_evtc) + } + } + + fn filter(&self, log: &LogResult) -> bool { + self.0.filter(log) || self.1.filter(log) + } +} + +impl ops::BitOr<Box<dyn Filter>> for Box<dyn Filter> { + type Output = Box<dyn Filter>; + + fn bitor(self, rhs: Box<dyn Filter>) -> Self::Output { + Box::new(OrFilter(self, rhs)) + } +} + +struct NotFilter(Box<dyn Filter>); + +impl Filter for NotFilter { + fn filter_early(&self, partial_evtc: &PartialEvtc) -> Inclusion { + !self.0.filter_early(partial_evtc) + } + + fn filter(&self, log: &LogResult) -> bool { + !self.0.filter(log) + } +} + +impl ops::Not for Box<dyn Filter> { + type Output = Box<dyn Filter>; + + fn not(self) -> Self::Output { + Box::new(NotFilter(self)) + } +} + +// From here onwards, we have the specific filters + +/// Filter that filters according to the name. +/// +/// The given SearchField determines in which field something should be searched. +#[derive(Debug, Clone)] +pub struct NameFilter(SearchField, Regex); + +impl NameFilter { + pub fn new(field: SearchField, regex: Regex) -> Box<dyn Filter> { + Box::new(NameFilter(field, regex)) + } +} + +impl Filter for NameFilter { + fn filter_early(&self, partial_evtc: &PartialEvtc) -> Inclusion { + if self.0 == SearchField::Guild { + return Inclusion::Unknown; + } + + for player in &partial_evtc.agents { + let fancy = Agent::from_raw(player); + if let Ok(AgentName::Player { + ref account_name, + ref character_name, + .. + }) = fancy.as_ref().map(Agent::name) { - return true; + let field = match self.0 { + SearchField::Account => account_name, + SearchField::Character => character_name, + _ => unreachable!("We already checked for Guild earlier"), + }; + if self.1.is_match(field) { + return Inclusion::Include; + } + } + } + Inclusion::Exclude + } + + fn filter(&self, log: &LogResult) -> bool { + for player in &log.players { + match self.0 { + SearchField::Account if self.1.is_match(&player.account_name) => return true, + SearchField::Character if self.1.is_match(&player.character_name) => return true, + SearchField::Guild => { + let guild_ok = player + .guild_id + .as_ref() + .and_then(|id| guilds::lookup(id)) + .map(|guild| self.1.is_match(guild.tag()) || self.1.is_match(guild.name())) + .unwrap_or(false); + if guild_ok { + return true; + } + } + _ => (), } } + false + } +} + +#[derive(Debug, Clone)] +pub struct BossFilter(HashSet<Boss>); + +impl BossFilter { + pub fn new(bosses: HashSet<Boss>) -> Box<dyn Filter> { + Box::new(BossFilter(bosses)) + } +} + +impl Filter for BossFilter { + fn filter_early(&self, partial_evtc: &PartialEvtc) -> Inclusion { + let boss = Boss::from_u16(partial_evtc.header.combat_id); + boss.map(|b| self.0.contains(&b).into()) + .unwrap_or(Inclusion::Include) + } + + fn filter(&self, log: &LogResult) -> bool { + let boss = Boss::from_u16(log.boss_id); + boss.map(|b| self.0.contains(&b)).unwrap_or(false) } - // Don't throw away the log yet if we are searching for guilds - opt.field.contains(&SearchField::Guild) } -/// Do filtering based on the boss ID. -pub fn filter_boss(evtc: &PartialEvtc, opt: &Opt) -> bool { - let boss = Boss::from_u16(evtc.header.combat_id); - boss.map(|b| opt.bosses.contains(&b)).unwrap_or(true) +#[derive(Debug, Clone)] +pub struct OutcomeFilter(HashSet<FightOutcome>); + +impl OutcomeFilter { + pub fn new(outcomes: HashSet<FightOutcome>) -> Box<dyn Filter> { + Box::new(OutcomeFilter(outcomes)) + } } -/// Do filtering based on the fight outcome. -pub fn filter_outcome(result: &LogResult, opt: &Opt) -> bool { - opt.outcome.contains(&result.outcome) +impl Filter for OutcomeFilter { + fn filter(&self, log: &LogResult) -> bool { + self.0.contains(&log.outcome) + } } -/// Do filtering based on the weekday of the fight. -pub fn filter_weekday(result: &LogResult, opt: &Opt) -> bool { - opt.weekdays.contains(&result.time.weekday()) +#[derive(Debug, Clone)] +pub struct WeekdayFilter(HashSet<Weekday>); + +impl WeekdayFilter { + pub fn new(weekdays: HashSet<Weekday>) -> Box<dyn Filter> { + Box::new(WeekdayFilter(weekdays)) + } } -/// Do filtering based on encounter time. -pub fn filter_time(result: &LogResult, opt: &Opt) -> bool { - let after_ok = match opt.after { - Some(time) => time <= result.time, - None => true, - }; - let before_ok = match opt.before { - Some(time) => time >= result.time, - None => true, - }; +impl Filter for WeekdayFilter { + fn filter(&self, log: &LogResult) -> bool { + self.0.contains(&log.time.weekday()) + } +} - after_ok && before_ok +#[derive(Debug, Clone)] +pub struct TimeFilter(Option<NaiveDateTime>, Option<NaiveDateTime>); + +impl TimeFilter { + pub fn new(after: Option<NaiveDateTime>, before: Option<NaiveDateTime>) -> Box<dyn Filter> { + Box::new(TimeFilter(after, before)) + } } -/// Do filtering based on the guilds. -pub fn filter_guilds(result: &LogResult, opt: &Opt) -> bool { - if !opt.guilds { - return true; +impl Filter for TimeFilter { + fn filter(&self, log: &LogResult) -> bool { + let after_ok = match self.0 { + Some(time) => time <= log.time, + None => true, + }; + let before_ok = match self.1 { + Some(time) => time >= log.time, + None => true, + }; + + after_ok && before_ok } - if !opt.field.contains(&SearchField::Guild) { - return true; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_inclusion_not() { + use Inclusion::*; + + assert_eq!(!Exclude, Include); + assert_eq!(!Include, Exclude); + assert_eq!(!Unknown, Unknown); + } + + #[test] + fn test_inclusion_and() { + use Inclusion::*; + + assert_eq!(Exclude & Exclude, Exclude); + assert_eq!(Exclude & Unknown, Exclude); + assert_eq!(Exclude & Include, Exclude); + + assert_eq!(Unknown & Exclude, Exclude); + assert_eq!(Unknown & Unknown, Unknown); + assert_eq!(Unknown & Include, Unknown); + + assert_eq!(Include & Exclude, Exclude); + assert_eq!(Include & Unknown, Unknown); + assert_eq!(Include & Include, Include); + } + + #[test] + fn test_inclusion_or() { + use Inclusion::*; + + assert_eq!(Exclude | Exclude, Exclude); + assert_eq!(Exclude | Unknown, Unknown); + assert_eq!(Exclude | Include, Include); + + assert_eq!(Unknown | Exclude, Unknown); + assert_eq!(Unknown | Unknown, Unknown); + assert_eq!(Unknown | Include, Include); + + assert_eq!(Include | Exclude, Include); + assert_eq!(Include | Unknown, Include); + assert_eq!(Include | Include, Include); } - result.players.iter().any(|player| { - let guild = player.guild_id.as_ref().and_then(|id| guilds::lookup(id)); - if let Some(guild) = guild { - opt.expression.is_match(guild.tag()) || opt.expression.is_match(guild.name()) - } else { - false - } - }) } diff --git a/src/main.rs b/src/main.rs index 6b67875..41df732 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,15 +6,16 @@ use std::str::FromStr; use anyhow::{anyhow, Result}; use chrono::{Duration, NaiveDateTime, Weekday}; +use log::debug; use num_traits::cast::FromPrimitive; use regex::Regex; use structopt::StructOpt; use walkdir::{DirEntry, WalkDir}; -use log::debug; use evtclib::{AgentKind, AgentName, EventKind, Log}; mod filters; +use filters::{Filter, Inclusion}; mod guilds; mod logger; mod output; @@ -32,7 +33,6 @@ macro_rules! unwrap { }; } - /// A program that allows you to search through all your evtc logs for specific /// people. #[derive(StructOpt, Debug)] @@ -106,7 +106,7 @@ pub struct Opt { /// A flag indicating which fields should be searched. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -enum SearchField { +pub enum SearchField { /// Only search the account name. Account, /// Only search the character name. @@ -135,6 +135,8 @@ pub struct LogResult { log_file: PathBuf, /// The time of the recording. time: NaiveDateTime, + /// The numeric ID of the boss. + boss_id: u16, /// The name of the boss. boss_name: String, /// A vector of all participating players. @@ -258,16 +260,36 @@ fn is_log_file(entry: &DirEntry) -> bool { .unwrap_or(false) } +fn build_filter(opt: &Opt) -> Box<dyn Filter> { + let mut filter = filters::Const::new(false); + for field in opt.field.values() { + filter = filter | filters::NameFilter::new(*field, opt.expression.clone()); + } + + if opt.invert { + filter = !filter; + } + + filter = filter + & filters::BossFilter::new(opt.bosses.values().clone()) + & filters::OutcomeFilter::new(opt.outcome.values().clone()) + & filters::WeekdayFilter::new(opt.weekdays.values().clone()) + & filters::TimeFilter::new(opt.after, opt.before); + + filter +} + /// Run the grep search with the given options. fn grep(opt: &Opt) -> Result<()> { let pipeline = &output::build_pipeline(opt); + let filter: &dyn Filter = &*build_filter(opt); rayon::scope(|s| { let walker = WalkDir::new(&opt.path); for entry in walker { let entry = entry?; s.spawn(move |_| { if is_log_file(&entry) { - let search = search_log(&entry, opt); + let search = search_log(&entry, filter); match search { Ok(None) => (), Ok(Some(result)) => pipeline.push_item(&result), @@ -287,7 +309,7 @@ fn grep(opt: &Opt) -> Result<()> { /// If the log matches, returns `Ok(Some(..))`. /// If the log doesn't match, returns `Ok(None)`. /// If there was a fatal error, returns `Err(..)`. -fn search_log(entry: &DirEntry, opt: &Opt) -> Result<Option<LogResult>> { +fn search_log(entry: &DirEntry, filter: &dyn Filter) -> Result<Option<LogResult>> { let file_stream = BufReader::new(File::open(entry.path())?); let is_zip = entry .file_name() @@ -302,10 +324,9 @@ fn search_log(entry: &DirEntry, opt: &Opt) -> Result<Option<LogResult>> { let mut stream = wrapper.get_stream(); let partial = evtclib::raw::parser::parse_partial_file(&mut stream)?; - let early_ok = - filters::filter_name(&partial, opt) != opt.invert && filters::filter_boss(&partial, opt); + let early_ok = filter.filter_early(&partial); - if !early_ok { + if early_ok == Inclusion::Exclude { return Ok(None); } @@ -320,10 +341,7 @@ fn search_log(entry: &DirEntry, opt: &Opt) -> Result<Option<LogResult>> { let info = extract_info(entry, &log); - let take_log = filters::filter_outcome(&info, opt) - && filters::filter_weekday(&info, opt) - && filters::filter_time(&info, opt) - && filters::filter_guilds(&info, opt); + let take_log = filter.filter(&info); if take_log { Ok(Some(info)) @@ -372,6 +390,7 @@ fn extract_info(entry: &DirEntry, log: &Log) -> LogResult { LogResult { log_file: entry.path().to_path_buf(), time: NaiveDateTime::from_timestamp(i64::from(get_start_timestamp(log)), 0), + boss_id: log.boss_id(), boss_name, players, outcome: get_fight_outcome(log), |