#![feature(trait_alias)] use std::collections::HashMap; use std::fmt; use std::fs::{self, File}; use std::io::{BufReader, Read, Seek}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use anyhow::{anyhow, Context, Error, Result}; use chrono::{DateTime, Duration, TimeZone, Utc}; use colored::Colorize; use log::debug; use regex::Regex; use rustyline::Editor; use structopt::StructOpt; use walkdir::{DirEntry, WalkDir}; use evtclib::raw::parser::PartialEvtc; use evtclib::{Encounter, EventKind, Log, Outcome}; mod fexpr; mod filters; use filters::{log::LogFilter, Inclusion}; mod guilds; mod logger; mod output; use output::sorting::Sorting; mod paths; mod playerclass; use playerclass::PlayerClass; /// Application name, as it should be used in configuration directory paths. const APP_NAME: &str = "raidgrep"; /// Process exit code for when everything went right. const RETCODE_SUCCESS: i32 = 0; /// Process exit code for when no results are found. const RETCODE_NO_RESULTS: i32 = 1; /// Process exit code for when an error occurred. const RETCODE_ERROR: i32 = 2; /// A program that allows you to search through all your evtc logs for specific people. /// /// raidgrep supports different predicates that determine whether a log is included or not. /// Predicates start with a - and optionally take an argument. Predicates can be combined with /// "and", "or" and "not", and predicates that operate on single players (instead of logs) have to /// be within an "all(player: ...)" or "any(player: ...)" construct. /// /// PREDICATES: /// -character REGEX True if the character name matches the regex. /// -account REGEX True if the account name matches the regex. /// -name REGEX True if either character or account name match. /// -class CLASSES True if the player has one of the listed classes. /// /// -success Only include successful logs. /// -wipe Only include failed logs. /// -outcome OUTCOMES Only include logs with the given outcomes. /// -weekday WEEKDAYS Only include logs from the given weekdays. /// -before DATE Only include logs from before the given date. /// -after DATE Only include logs from after the given date. /// -boss BOSSES Only include logs from the given bosses. /// -cm Only include logs with challenge mote enabled. /// -player REGEX Shorthand to check if any player in the log has the given name. /// -include Always evaluates to including the log. /// -exclude Always evaluates to excluding the log. /// /// BOSS NAMES: /// The following names can be used with the -boss filter: /// vg, gorseval, sabetha, slothasor, matthias, kc, xera, cairn, /// mo, samarog, deimos, desmina, dhuum, ca, largos, qadim, /// adina, sabir, qadimp, skorvald, artsariiv, arkk, mama, siax, /// ensolyss, icebrood, fraenir, kodans, boneskinner, whisper. /// Names can also be comma separated. #[derive(StructOpt, Debug)] #[structopt(verbatim_doc_comment)] pub struct Opt { /// Path to the folder with logs. #[structopt(short = "d", long = "dir", default_value = ".", parse(from_os_str))] path: PathBuf, /// Check the given file only. #[structopt( short = "c", long = "check", value_name = "file", conflicts_with_all = &["path", "repl"], )] check: Option, /// Only show the name of matching files. #[structopt(short = "l", long = "files-with-matches")] file_name_only: bool, /// Only output the number of matching logs. #[structopt( short = "n", long = "count", conflicts_with_all = &["sorting", "file-name-only"], )] count: bool, /// Disable colored output. #[structopt(long = "no-color")] no_color: bool, /// Sort the output. /// /// Valid sorting fields are date, boss, cm and outcome. Prefix the field with ~ to sort in /// descending order. #[structopt(short = "s", long = "sort")] sorting: Option, /// Print more debugging information to stderr. #[structopt(long = "debug")] debug: bool, /// Load guild information from the API. /// /// Loading guild information requires network access and slows down the program considerably, /// so this is disabled by default. #[structopt(long = "guilds")] guilds: bool, /// Run the REPL. /// /// The REPL will allow you to keep entering queries which are being searched by raidgrep, /// until you manually exit with Crtl+C. #[structopt(long)] repl: bool, /// The filter expression, see PREDICATES for more information. expression: Vec, } impl Opt { fn build_filter(&self) -> Result> { // As a shortcut, we allow only the regular expression to be given, to retain the behaviour // before the filter changes. // Special characters that when present will prevent the filter to be interpreted as a // regex. This is to ensure that errors are properly reported on invalid filterlines // instead of being swallowed because the filter was taken as a (valid) regex: const SPECIAL_CHARS: &[char] = &['-', '(', ')', ':', '<', '>', '=']; if self.expression.len() == 1 { let line = &self.expression[0]; let maybe_filter = build_filter(line); if maybe_filter.is_err() && !line.contains(SPECIAL_CHARS) { let maybe_regex = Regex::new(line); if let Ok(rgx) = maybe_regex { let filter = filters::player::any( filters::player::account(rgx.clone()) | filters::player::character(rgx), ); return Ok(filter); } } return Ok(maybe_filter?); } let expr_string = fexpr::requote(&self.expression); Ok(build_filter(&expr_string)?) } } /// A log that matches the search criteria. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct LogResult { /// The path to the log file. log_file: PathBuf, /// The time of the recording. time: DateTime, /// The duration of the fight. duration: Duration, /// The encounter. encounter: Option, /// A vector of all participating players. players: Vec, /// The outcome of the fight. outcome: FightOutcome, /// Whether the fight had the Challenge Mote turned on. is_cm: bool, } /// A player. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Player { /// Account name of the player. account_name: String, /// Character name of the player. character_name: String, /// Profession or elite specialization. profession: PlayerClass, /// Subsquad that the player was in. subgroup: u8, /// Guild ID, ready for API consumption. guild_id: Option, } impl PartialOrd for Player { fn partial_cmp(&self, other: &Player) -> Option { Some(self.cmp(other)) } } impl Ord for Player { fn cmp(&self, other: &Self) -> std::cmp::Ordering { (self.subgroup, &self.account_name, &self.character_name).cmp(&( other.subgroup, &other.account_name, &other.character_name, )) } } /// Outcome of the fight. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum FightOutcome { Success, Wipe, } impl From for FightOutcome { fn from(outcome: Outcome) -> Self { match outcome { Outcome::Success => FightOutcome::Success, Outcome::Failure => FightOutcome::Wipe, } } } impl FromStr for FightOutcome { type Err = &'static str; fn from_str(s: &str) -> Result { match s { "success" | "kill" => Ok(FightOutcome::Success), "wipe" | "fail" => Ok(FightOutcome::Wipe), _ => Err("Must be success or wipe"), } } } /// A stripped version of [`LogResult`][LogResult] that is available early in the parsing process. /// /// This can be used by filters to filter out logs early, before they will be fully parsed. #[derive(Debug, Clone)] pub struct EarlyLogResult { /// The path to the log file. log_file: PathBuf, /// The partially parsed evtc. evtc: PartialEvtc, } enum ZipWrapper { Raw(Option), Zipped(zip::ZipArchive), } impl ZipWrapper { pub fn raw(input: R) -> Self { ZipWrapper::Raw(Some(input)) } pub fn zipped(input: R) -> Self { ZipWrapper::Zipped(zip::ZipArchive::new(input).unwrap()) } pub fn get_stream<'a>(&'a mut self) -> Box<(dyn Read + 'a)> { match *self { ZipWrapper::Raw(ref mut o) => Box::new(o.take().unwrap()), ZipWrapper::Zipped(ref mut z) => Box::new(z.by_index(0).unwrap()), } } } #[derive(Clone, Debug)] struct InputError { line: String, location: usize, msg: String, } impl fmt::Display for InputError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let prefix = "Input:"; writeln!(f, "{} {}", prefix.yellow(), self.line)?; let prefix_len = prefix.len() + self.location; writeln!(f, "{}{}", " ".repeat(prefix_len), " ^-".red())?; write!(f, "{}: {}", "Error".red(), self.msg)?; Ok(()) } } impl std::error::Error for InputError {} /// A flag that indicates whether the current search should be interrupted because the user pressed /// Crtl-C. /// /// This flag can be set to `true` by signal handlers in order to exit the search as early as /// possible. Note that the search won't be exited immediately, as any logs currently in the /// process of being parsed will finish to do so. /// /// The flag is automatically reset to `false` by the repl. static INTERRUPTED: AtomicBool = AtomicBool::new(false); fn main() { let result = run(); match result { Ok(retcode) => std::process::exit(retcode), Err(err) => { display_error(&err); std::process::exit(RETCODE_ERROR); } } } fn display_error(err: &Error) { if let Some(err) = err.downcast_ref::() { eprintln!("{}", err); } else { eprintln!("{}: {}", "Error".red(), err); } } fn run() -> Result { let opt = Opt::from_args(); if opt.no_color { colored::control::set_override(false); } if opt.debug { logger::initialize(log::Level::Debug); } else { logger::initialize(log::Level::Info); } if opt.guilds { guilds::prepare_cache(); } let retcode; if !opt.repl { if single(&opt)? { retcode = RETCODE_SUCCESS; } else { retcode = RETCODE_NO_RESULTS; } } else { repl(&opt)?; retcode = RETCODE_SUCCESS; } if opt.guilds { guilds::save_cache(); } Ok(retcode) } fn single(opt: &Opt) -> Result { let filter = opt.build_filter()?; if let Some(ref path) = opt.check { let is_zip = path .file_name() .and_then(|f| f.to_str()) .map(is_zip_file_name) .unwrap_or(false); search_file(path, is_zip, &*filter).map(|r| r.is_some()) } else { grep(&opt, &*filter) } } fn repl(opt: &Opt) -> Result<()> { ctrlc::set_handler(|| INTERRUPTED.store(true, Ordering::Relaxed)) .expect("Could not set interrupt hanlder"); let mut rl = Editor::<()>::new(); let history_path = paths::history_path(); if history_path.is_none() { debug!("Could not determine the history path"); } maybe_load_history(&mut rl, history_path.as_ref().map(|r| r as &Path)); loop { let line = rl.readline("Query> ")?; rl.add_history_entry(&line); maybe_save_history(&rl, history_path.as_ref().map(|r| r as &Path)); let parsed = build_filter(&line); INTERRUPTED.store(false, Ordering::Relaxed); match parsed { Ok(filter) => grep(&opt, &*filter).map(|_| ())?, Err(err) => display_error(&err), } } } fn maybe_load_history(rl: &mut Editor<()>, path: Option<&Path>) { if let Some(path) = path { debug!("Loading history from {:?}", path); if let Err(e) = rl.load_history(path) { debug!("Loading the history failed: {}", e); } } } fn maybe_save_history(rl: &Editor<()>, path: Option<&Path>) { let run = |path: &Path| -> Result<()> { debug!("Saving history to {:?}", path); let parent = path .parent() .ok_or_else(|| anyhow!("Path does not have a parent"))?; fs::create_dir_all(parent).context("Could not create directory")?; rl.save_history(path)?; Ok(()) }; if let Some(path) = path { if let Err(e) = run(path) { debug!("Saving the history failed: {}", e); } } } /// Check if the given entry represents a log file, based on the file name. fn is_log_file(entry: &DirEntry) -> bool { entry .file_name() .to_str() .map(|n| n.ends_with(".evtc") || n.ends_with(".evtc.zip") || n.ends_with(".zevtc")) .unwrap_or(false) } /// Small wrapper around `fexpr::parse_logfilter` to convert the returned `Err` to be `'static'. fn build_filter(expr_string: &str) -> Result> { if expr_string.trim().is_empty() { return Err(anyhow!("Expected a filter to be given")); } Ok( fexpr::parse_logfilter(expr_string).map_err(|error| InputError { line: expr_string.to_string(), location: fexpr::location(&error), msg: error.to_string(), })?, ) } /// Run the grep search with the given options. /// /// This function returns `false` if no log has been found, and `true` if at least one log matched /// the filter. fn grep(opt: &Opt, filter: &dyn LogFilter) -> Result { let pipeline = output::build_pipeline(opt); let pipeline_ref = &pipeline; let found_something = &AtomicBool::new(false); let result: Result<()> = rayon::scope(|s| { let walker = WalkDir::new(&opt.path); for entry in walker { let entry = entry?; s.spawn(move |_| { // Check first if we should even still continue if INTERRUPTED.load(Ordering::Relaxed) { return; } if is_log_file(&entry) { let search = search_entry(&entry, filter); match search { Ok(None) => (), Ok(Some(result)) => { found_something.store(true, Ordering::Relaxed); pipeline_ref.push_item(result); } Err(err) => { debug!("Runtime error while scanning {:?}: {}", entry.path(), err); } } } }); } Ok(()) }); result?; pipeline.finish(); Ok(found_something.load(Ordering::Relaxed)) } fn is_zip_file_name(file_name: &str) -> bool { file_name.ends_with(".zip") || file_name.ends_with(".zevtc") } /// Search the given directory entry. /// /// This forwards to [`search_file`][search_file] with the right parameters, and as such has the /// same return possibilities. fn search_entry(entry: &DirEntry, filter: &dyn LogFilter) -> Result> { let is_zip = entry .file_name() .to_str() .map(is_zip_file_name) .unwrap_or(false); search_file(entry.path(), is_zip, filter) } /// Search the given single log.. /// /// 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_file(path: &Path, is_zip: bool, filter: &dyn LogFilter) -> Result> { let file_stream = BufReader::new(File::open(path)?); let mut wrapper = if is_zip { ZipWrapper::zipped(file_stream) } else { ZipWrapper::raw(file_stream) }; let mut stream = wrapper.get_stream(); let partial = evtclib::raw::parser::parse_partial_file(&mut stream)?; let early_log = EarlyLogResult { log_file: path.to_owned(), evtc: partial, }; let early_ok = filter.filter_early(&early_log); let partial = early_log.evtc; if early_ok == Inclusion::Exclude { return Ok(None); } let raw = evtclib::raw::parser::finish_parsing(partial, &mut stream)?; let parsed = evtclib::process(&raw).ok(); let log = if let Some(e) = parsed { e } else { debug!("log file cannot be parsed: {:?}", path); return Ok(None); }; let info = extract_info(path, &log); let take_log = filter.filter(&info); if take_log { Ok(Some(info)) } else { Ok(None) } } /// Extract human-readable information from the given log file. fn extract_info(path: &Path, log: &Log) -> LogResult { let encounter = log.encounter(); if encounter.is_none() { debug!( "log file has unknown boss: {:?} (id: {:#x})", path, log.encounter_id() ); } let guild_ids = get_guild_mapping(log); let mut players = log .players() .map(|p| Player { account_name: p.account_name().to_owned(), character_name: p.character_name().to_owned(), profession: (p.profession(), p.elite()).into(), subgroup: p.subgroup(), guild_id: guild_ids.get(&p.addr()).cloned(), }) .collect::>(); players.sort(); LogResult { log_file: path.to_path_buf(), time: Utc.timestamp(i64::from(log.local_end_timestamp().unwrap_or(0)), 0), duration: get_fight_duration(log), encounter, players, outcome: get_fight_outcome(log), is_cm: log.is_cm(), } } /// Get a mapping of agent IDs to guild API strings. fn get_guild_mapping(log: &Log) -> HashMap { log.events() .iter() .filter_map(|event| { if let EventKind::Guild { source_agent_addr, api_guild_id, .. } = event.kind() { api_guild_id .as_ref() .map(|api_id| (*source_agent_addr, api_id.clone())) } else { None } }) .collect() } /// Get the outcome of the fight. fn get_fight_outcome(log: &Log) -> FightOutcome { log.analyzer() .and_then(|a| a.outcome()) .map(Into::into) .unwrap_or(FightOutcome::Wipe) } /// Get the duration of the fight. fn get_fight_duration(log: &Log) -> Duration { Duration::milliseconds(log.span() as i64) }