#![feature(trait_alias)] use std::collections::HashMap; use std::fmt; use std::fs::File; use std::io::{BufReader, Read, Seek}; use std::path::PathBuf; use std::str::FromStr; use anyhow::{anyhow, Error, Result}; use chrono::{DateTime, TimeZone, Utc, Weekday}; use colored::Colorize; use itertools::Itertools; use log::debug; use regex::Regex; use rustyline::Editor; use structopt::StructOpt; use walkdir::{DirEntry, WalkDir}; use evtclib::{EliteSpec, EventKind, Log, Profession}; mod fexpr; mod filters; use filters::{log::LogFilter, Inclusion}; mod guilds; mod logger; mod output; /// 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. /// /// -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. /// -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, /// Only show the name of matching files. #[structopt(short = "l", long = "files-with-matches")] file_name_only: bool, /// Disable colored output. #[structopt(long = "no-color")] no_color: bool, /// 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, } /// A flag indicating which fields should be searched. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum SearchField { /// Only search the account name. Account, /// Only search the character name. Character, /// Only search the guild name or tag. Guild, } impl FromStr for SearchField { type Err = &'static str; fn from_str(s: &str) -> Result { match s { "account" => Ok(SearchField::Account), "character" => Ok(SearchField::Character), "guild" => Ok(SearchField::Guild), _ => Err("Must be account or character"), } } } /// A log that matches the search criteria. #[derive(Debug, Clone)] pub struct LogResult { /// The path to the log file. log_file: PathBuf, /// The time of the recording. time: DateTime, /// The numeric ID of the boss. boss_id: u16, /// The name of the boss. boss_name: String, /// A vector of all participating players. players: Vec, /// The outcome of the fight. outcome: FightOutcome, } /// A player. #[derive(Debug, Clone)] pub struct Player { /// Account name of the player. account_name: String, /// Character name of the player. character_name: String, /// Profession (or elite specialization) as english name. profession: String, /// Subsquad that the player was in. subgroup: u8, /// Guild ID, ready for API consumption. guild_id: Option, } /// Outcome of the fight. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum FightOutcome { Success, 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"), } } } 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 {} fn main() { let result = run(); if let Err(err) = result { display_error(&err); } } 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(); } if !opt.repl { single(&opt)?; } else { repl(&opt)?; } if opt.guilds { guilds::save_cache(); } Ok(()) } fn single(opt: &Opt) -> Result<()> { // As a shortcut, we allow only the regular expression to be given, to retain the behaviour // before the filter changes. if opt.expression.len() == 1 { let line = &opt.expression[0]; let maybe_filter = build_filter(line); if maybe_filter.is_err() && !line.starts_with('-') { 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 grep(opt, &*filter); } } return grep(opt, &*maybe_filter?); } let expr_string = opt .expression .iter() .map(|part| { if part.contains(' ') { format!(r#""{}""#, part) } else { part.into() } }) .join(" "); let filter = build_filter(&expr_string)?; grep(&opt, &*filter)?; Ok(()) } fn repl(opt: &Opt) -> Result<()> { let mut rl = Editor::<()>::new(); loop { let line = rl.readline("Query> ")?; rl.add_history_entry(&line); let parsed = build_filter(&line); match parsed { Ok(filter) => grep(&opt, &*filter)?, Err(err) => display_error(&err), } } } /// 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. fn grep(opt: &Opt, filter: &dyn LogFilter) -> Result<()> { let pipeline = &output::build_pipeline(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, filter); match search { Ok(None) => (), Ok(Some(result)) => pipeline.push_item(&result), Err(err) => { debug!("Runtime error while scanning {:?}: {}", entry.path(), err); } } } }); } Ok(()) }) } /// 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_log(entry: &DirEntry, filter: &dyn LogFilter) -> Result> { let file_stream = BufReader::new(File::open(entry.path())?); let is_zip = entry .file_name() .to_str() .map(|n| n.ends_with(".zip") || n.ends_with(".zevtc")) .unwrap_or(false); 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_ok = filter.filter_early(&partial); 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: {:?}", entry.path()); return Ok(None); }; let info = extract_info(entry, &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(entry: &DirEntry, log: &Log) -> LogResult { let boss_name = get_encounter_name(log) .unwrap_or_else(|| { debug!( "log file has unknown boss: {:?} (id: {:#x})", entry.path(), log.encounter_id() ); "unknown" }) .into(); 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: get_profession_name(p.profession(), p.elite()).into(), subgroup: p.subgroup(), guild_id: guild_ids.get(&p.addr()).cloned(), }) .collect::>(); players.sort_by_key(|p| p.subgroup); LogResult { log_file: entry.path().to_path_buf(), time: Utc.timestamp(i64::from(log.local_end_timestamp().unwrap_or(0)), 0), boss_id: log.encounter_id(), boss_name, players, outcome: get_fight_outcome(log), } } /// 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, ref 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 { if log.was_rewarded() { FightOutcome::Success } else { FightOutcome::Wipe } } /// Get the (english) name for the given encounter fn get_encounter_name(log: &Log) -> Option<&'static str> { use evtclib::Boss; Some(match log.encounter()? { Boss::ValeGuardian => "Vale Guardian", Boss::Gorseval => "Gorseval", Boss::Sabetha => "Sabetha", Boss::Slothasor => "Slothasor", Boss::Matthias => "Matthias", Boss::KeepConstruct => "Keep Construct", Boss::Xera => "Xera", Boss::Cairn => "Cairn", Boss::MursaatOverseer => "Mursaat Overseer", Boss::Samarog => "Samarog", Boss::Deimos => "Deimos", Boss::SoullessHorror => "Desmina", Boss::Dhuum => "Dhuum", Boss::ConjuredAmalgamate => "Conjured Amalgamate", Boss::LargosTwins => "Largos Twins", Boss::Qadim => "Qadim", Boss::CardinalAdina => "Cardinal Adina", Boss::CardinalSabir => "Cardinal Sabir", Boss::QadimThePeerless => "Qadim The Peerless", Boss::Skorvald => "Skorvald", Boss::Artsariiv => "Artsariiv", Boss::Arkk => "Arkk", Boss::MAMA => "MAMA", Boss::Siax => "Siax the Corrupted", Boss::Ensolyss => "Ensolyss of the Endless Torment", Boss::IcebroodConstruct => "Icebrood Construct", Boss::VoiceOfTheFallen => "Super Kodan Brothers", Boss::FraenirOfJormag => "Fraenir of Jormag", Boss::Boneskinner => "Boneskinner", Boss::WhisperOfJormag => "Whisper of Jormag", }) } /// Get the (english) name for the given profession/elite specialization. fn get_profession_name(profession: Profession, elite: Option) -> &'static str { use EliteSpec::*; use Profession::*; if let Some(elite) = elite { match elite { Dragonhunter => "Dragonhunter", Firebrand => "Firebrand", Berserker => "Berserker", Spellbreaker => "Spellbreaker", Herald => "Herald", Renegade => "Renegade", Scrapper => "Scrapper", Holosmith => "Holosmith", Druid => "Druid", Soulbeast => "Soulbeast", Daredevil => "Daredevil", Deadeye => "Deadeye", Tempest => "Tempest", Weaver => "Weaver", Chronomancer => "Chronomancer", Mirage => "Mirage", Reaper => "Reaper", Scourge => "Scourge", } } else { match profession { Guardian => "Guardian", Warrior => "Warrior", Revenant => "Revenant", Engineer => "Engineer", Ranger => "Ranger", Thief => "Thief", Elementalist => "Elementalist", Mesmer => "Mesmer", Necromancer => "Necromancer", } } }