mod api; mod bt; mod cache; mod output; mod render; mod useropts; use anyhow::{Context, Result}; use clap::{App, Arg, ArgMatches}; use thiserror::Error; use api::{Api, Profession, Skill}; use bt::{BuildTemplate, ExtraData, Legend, TraitChoice, Traitline, CODE_REVENANT}; use render::RenderError; /// The name of this application. /// /// This is used for example in the cache path. const APP_NAME: &str = "kondou"; /// Return value indicating that a requested resource could not be found. #[derive(Debug, Clone, Error)] enum NotFound { /// Used when the requested profession can not be found. /// /// The argument is the requested profession. #[error("The profession '{0}' could not be found")] Profession(String), /// Used when a skill given by its ID could not be found. /// /// The argument is the requested skill id. #[error("The skill with ID {0} could not be found")] SkillId(u32), /// Used when a skill given by its name could not be found. /// /// The argument is the requested skill name. #[error("The skill named '{0}' could not be found")] SkillName(String), /// Used when a specialization could not be found. /// /// The argument is the requested specialization. #[error("The specialization named '{0}' could not be found")] Specialization(String), } /// A trait for containers that only contain a single item. trait SingleContainer { /// Extract the single element by consuming the container. fn single(self) -> T; } impl SingleContainer for Vec { fn single(self) -> T { assert_eq!(self.len(), 1, "this container must have exactly 1 element."); self.into_iter().next().unwrap() } } /// Find the profession by the given name. fn find_profession(api: &mut Api, name: &str) -> Result { let profession_ids = api.get_profession_ids()?; let lower_name = name.to_lowercase(); let profession_id = profession_ids .iter() .find(|id| id.to_lowercase() == lower_name) .ok_or_else(|| NotFound::Profession(name.to_owned()))? .clone(); Ok(api.get_professions(&[profession_id])?.single()) } /// Resolve a skill. /// /// `text` can either be a skill name, in which case all skills of the profession will be searched. /// Alternatively, it can also be a numeric ID, in which case it will be requested directly. fn resolve_skill(api: &mut Api, profession: &Profession, text: &str) -> Result { // Try it as an ID first let numeric = text.parse::(); if let Ok(num_id) = numeric { let exists = profession.skills.iter().any(|s| s.id == num_id); if exists { return Ok(api.get_skills(&[num_id])?.single()); } else { return Err(NotFound::SkillId(num_id).into()); } } // Check all names of the profession skills let all_ids = profession.skills.iter().map(|s| s.id).collect::>(); let all_skills = api.get_skills(&all_ids)?; let lower_text = text.to_lowercase(); all_skills .into_iter() .find(|s| s.name.to_lowercase().contains(&lower_text)) .ok_or_else(|| NotFound::SkillName(text.to_owned()).into()) } /// Resolve a traitline. /// /// `text` must be in the `"name:choice1:choice2:choice3"` format. fn resolve_traitline(api: &mut Api, profession: &Profession, text: &str) -> Result { let parts = text.split(':').collect::>(); assert_eq!( parts.len(), 4, "invalid text format passed to resolve_traitline" ); let name = parts[0]; let lower_name = name.to_lowercase(); let spec = api .get_specializations(&profession.specializations)? .into_iter() .find(|s| s.name.to_lowercase() == lower_name) .ok_or_else(|| NotFound::Specialization(name.to_owned()))?; let mut choices = [TraitChoice::None; 3]; for (i, text_choice) in parts.iter().skip(1).enumerate() { let choice = text_choice .parse() .expect("Argument validation failed us, there is an invalid value here"); choices[i] = choice; } Ok((spec, choices)) } /// Create the build template by manually combining the given skills/traitlines from the CLI. fn run_searching(api: &mut Api, matches: &ArgMatches) -> Result { let requested_profession = matches .value_of("profession") .expect("clap handles missing argument"); let profession = find_profession(api, requested_profession)?; let legends = matches .values_of("legend") .map(Iterator::collect::>) .unwrap_or_default() .into_iter() .map(str::parse) .map(Result::unwrap) .collect::>(); let extra_data = if profession.code == CODE_REVENANT { let mut array_legends = [Legend::None; 4]; for (i, l) in legends.iter().enumerate() { array_legends[i] = *l; } ExtraData::Legends(array_legends) } else { ExtraData::None }; let skills = if profession.code != CODE_REVENANT { matches .values_of("skill") .map(Iterator::collect::>) .unwrap_or_default() .into_iter() .map(|s| resolve_skill(api, &profession, s)) .collect::, _>>()? } else if let Some(l) = legends.first() { let l = api.get_legends(&[l.api_id().unwrap()])?.single(); let mut result = Vec::new(); for skill_id in (&[l.heal]).iter().chain(&l.utilities).chain(&[l.elite]) { let skill = api.get_skills(&[*skill_id])?.single(); result.push(skill); } result } else { Vec::new() }; let traitlines = matches .values_of("traitline") .map(Iterator::collect::>) .unwrap_or_default() .into_iter() .map(|t| resolve_traitline(api, &profession, t)) .collect::, _>>()?; assert!(skills.len() <= bt::SKILL_COUNT, "got too many skills"); assert!( traitlines.len() <= bt::TRAITLINE_COUNT, "got too many traitlines" ); let build = BuildTemplate::new(profession, &skills, &traitlines, extra_data) .expect("BuildTemplate could not be constructed"); Ok(build) } /// Create the build template by parsing a chat link. fn run_chatlink(api: &mut Api, matches: &ArgMatches) -> Result { let link = matches.value_of("chatlink").unwrap(); Ok(BuildTemplate::from_chatlink(api, link)?) } /// Make sure a traitline is in the `"traitline:choice1:choice2:choice3"` format. fn validate_traitline_format(input: String) -> Result<(), String> { let parts = input.split(':').collect::>(); if parts.len() != 4 { return Err("traitline format is line:trait_1:trait_2:trait_3".to_owned()); } for part in parts.iter().skip(1) { let parsed = part.parse::(); if parsed.is_err() { return Err(format!( "{} is not a valid trait. Use top, middle or bottom.", part )); } } Ok(()) } /// Make sure a legend is valid. fn validate_legend(input: String) -> Result<(), String> { input .parse::() .map(|_| ()) .map_err(|_| "invalid legend name".to_owned()) } fn run() -> Result<()> { let matches = App::new(APP_NAME) .version("0.1") .author("Peter Parker IV") .about("Renders Guild Wars 2 skills and traits.") .arg( Arg::with_name("profession") .help("Selects which profession to use.") .required_unless("chatlink"), ) .arg( Arg::with_name("skill") .help("Selects a skill based on either the name or the ID.") .takes_value(true) .number_of_values(1) .long("skill") .short("s") .multiple(true) .max_values(bt::SKILL_COUNT as u64), ) .arg( Arg::with_name("traitline") .help("Selects a traitline.") .takes_value(true) .number_of_values(1) .long("traitline") .short("t") .multiple(true) .validator(validate_traitline_format) .max_values(bt::TRAITLINE_COUNT as u64), ) .arg( Arg::with_name("legend") .help("Selects a revenant legend.") .takes_value(true) .number_of_values(1) .long("legend") .short("l") .multiple(true) .max_values(bt::LEGEND_COUNT as u64) .validator(validate_legend), ) .arg( Arg::with_name("chatlink") .help("Specifies a chat link to parse.") .short("c") .long("chatlink") .takes_value(true) .conflicts_with_all(&["profession", "skill"]), ) .arg( Arg::with_name("quiet") .help("Surpress console output except for the chat code.") .short("q") .long("quiet") .takes_value(false), ) .arg( Arg::with_name("outfile") .help("Specifies the output filename") .short("o") .long("outfile") .default_value("buildtemplate.png") .takes_value(true), ) .arg( Arg::with_name("no-cache") .help("Disables the cache") .long("no-cache") .takes_value(false), ) .arg( Arg::with_name("config") .help("Specifies the render option file.") .long("config") .takes_value(true), ) .get_matches(); let mut api = if matches.is_present("no-cache") { Api::new(cache::NoopCache::new()) } else { Api::new(cache::FileCache::new()) }; let build = if matches.is_present("chatlink") { run_chatlink(&mut api, &matches)? } else { run_searching(&mut api, &matches)? }; if !matches.is_present("quiet") { output::show_build_template(&build)?; } else { println!("{}", build.chatlink()); } let render_options = if let Some(config_path) = matches.value_of("config") { useropts::load_file(config_path)? } else { Default::default() }; let mut renderer = render::Renderer::new(&mut api, render_options); match renderer.render_buildtemplate(&build) { Ok(img) => { let filename = matches.value_of("outfile").unwrap(); img.save(filename)?; if !matches.is_present("quiet") { println!("Image saved in {}", filename); } } Err(RenderError::EmptyBuild) => (), Err(err) => { return Err(err).context("Image could not be rendered"); } } Ok(()) } fn main() { let result = run(); if let Err(e) = result { output::show_error(e).expect("Error while displaying error"); std::process::exit(1); } }