use super::{ allow::{AllowCommand, ConsentDetails, ConsentTarget}, pay::{FinancialAccount, PaymentRequest}, }; use crate::models::consent::ConsentType; use ansi::{ansi, strip_special_characters}; use nom::{ branch::alt, bytes::complete::tag, bytes::complete::{take_till, take_till1, take_while, take_while1}, character::complete::{alpha1, char, one_of, space0, space1, u16, u64, u8}, combinator::{cut, eof, fail, map, opt, peek, recognize}, error::{context, VerboseError, VerboseErrorKind}, sequence::{pair, preceded, terminated}, IResult, }; pub fn parse_command_name(input: &str) -> (&str, &str) { fn parse(input: &str) -> IResult<&str, &str> { let (input, _) = space0(input)?; let (input, cmd) = alt(( recognize(one_of("-\"':.")), take_till1(|c| c == ' ' || c == '\t'), ))(input)?; let (input, _) = space0(input)?; Ok((input, cmd)) } match parse(input) { /* This parser only fails on empty / whitespace only strings. */ Err(_) => ("", ""), Ok((rest, command)) => (command, rest), } } pub fn parse_to_space(input: &str) -> (&str, &str) { fn parser(input: &str) -> IResult<&str, &str> { terminated(take_till(|c| c == ' ' || c == '\t'), alt((space1, eof)))(input) } match parser(input) { Err(_) => ("", ""), /* Impossible? */ Ok((rest, token)) => (token, rest), } } pub fn parse_offset(input: &str) -> (Option, &str) { fn parser(input: &str) -> IResult<&str, u8> { terminated(u8, char('.'))(input) } match parser(input) { Err(_) => (None, input), Ok((rest, result)) => (Some(result), rest), } } pub fn parse_count(input: &str) -> (Option, &str) { fn parser(input: &str) -> IResult<&str, u8> { terminated(u8, char(' '))(input) } match parser(input) { Err(_) => (None, input), Ok((rest, result)) => (Some(result), rest), } } pub fn parse_username(input: &str) -> Result<(&str, &str), &'static str> { const CATCHALL_ERROR: &'static str = "Must only contain alphanumeric characters or _"; fn parse_valid(input: &str) -> IResult<&str, (), VerboseError<&str>> { let (input, l1) = context("Must start with a letter", alpha1)(input)?; let (input, l2) = context( CATCHALL_ERROR, take_while(|c: char| c.is_alphanumeric() || c == '_'), )(input)?; if l1.len() + l2.len() > 20 { context( "Limit of 20 characters", fail::<&str, &str, VerboseError<&str>>, )(input)?; } Ok((input, ())) } match terminated(recognize(parse_valid), alt((space1, eof)))(input) { Ok((input, username)) => Ok((username, input)), Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => Err(e .errors .into_iter() .find_map(|k| match k.1 { VerboseErrorKind::Context(s) => Some(s), _ => None, }) .unwrap_or(CATCHALL_ERROR)), Err(_) => Err(CATCHALL_ERROR), } } pub fn parse_on_or_default<'l>(input: &'l str, default_on: &'l str) -> (&'l str, &'l str) { if let Some((a, b)) = input.split_once(" on ") { (a, b) } else { (input, default_on) } } pub fn parse_duration_mins<'l>(input: &'l str) -> Result<(u64, &'l str), String> { let (input, number) = match u16::<&'l str, ()>(input) { Err(_) => Err("Invalid number - duration should start with a number, e.g. 5 minutes")?, Ok(n) => n, }; let (tok, input) = match input.trim_start().split_once(" ") { None => (input, ""), Some(v) => v, }; Ok((match tok.to_lowercase().as_str() { "min" | "mins" | "minute" | "minutes" => number as u64, "h" | "hr" | "hrs" | "hour" | "hours" => (number as u64) * 60, "d" | "day" | "days" => (number as u64) * 60 * 24, "w" | "wk" | "wks" | "week" | "weeks" => (number as u64) * 60 * 24 * 7, _ => Err("Duration number needs to be followed by a valid unit - minutes, hours, days or weeks")? }, input)) } pub fn parse_allow<'l>(input: &'l str, is_explicit: bool) -> Result { let usage: &'static str = ansi!("Usage: allow action> from user> options> | allow action> against corp> by corp> options>. Try help allow for more."); let (consent_type_s, input) = match input.trim_start().split_once(" ") { None => Err(usage), Some(v) => Ok(v), }?; let consent_type = match ConsentType::from_str(&consent_type_s.trim().to_lowercase()) { None => Err(if is_explicit { "Invalid consent type - options are fight, medicine, gifts, visit and sex" } else { "Invalid consent type - options are fight, medicine, gifts and visit" }), Some(ct) => Ok(ct), }?; let (tok, mut input) = match input.trim_start().split_once(" ") { None => Err(usage), Some(v) => Ok(v), }?; let tok_trim = tok.trim_start().to_lowercase(); let consent_target = if tok_trim == "against" { if consent_type != ConsentType::Fight { Err("corps can only currently consent to fight, no other actions")? } else { let (my_corp_raw, new_input) = match input.trim_start().split_once(" ") { None => Err(usage), Some(v) => Ok(v), }?; let my_corp = my_corp_raw.trim_start(); let (tok, new_input) = match new_input.trim_start().split_once(" ") { None => Err(usage), Some(v) => Ok(v), }?; if tok.trim_start().to_lowercase() != "by" { Err(usage)?; } let (target_corp_raw, new_input) = match new_input.trim_start().split_once(" ") { None => (new_input.trim_start(), ""), Some(v) => v, }; input = new_input; ConsentTarget::CorpTarget { from_corp: my_corp, to_corp: target_corp_raw.trim_start(), } } } else if tok_trim == "from" { let (target_user_raw, new_input) = match input.trim_start().split_once(" ") { None => (input.trim_start(), ""), Some(v) => v, }; input = new_input; ConsentTarget::UserTarget { to_user: target_user_raw.trim_start(), } } else { Err(usage)? }; let mut consent_details = ConsentDetails::default_for(&consent_type); loop { input = input.trim_start(); if input == "" { break; } let (tok, new_input) = match input.split_once(" ") { None => (input, ""), Some(v) => v, }; match tok.to_lowercase().as_str() { "for" => { let (minutes, new_input) = parse_duration_mins(new_input)?; input = new_input; consent_details.duration_minutes = Some(minutes); } "until" => { let (tok, new_input) = match new_input.split_once(" ") { None => (new_input, ""), Some(v) => v, }; if tok.trim_start().to_lowercase() != "death" { Err("Option until needs to be followed with death - until death")? } consent_details.until_death = true; input = new_input; } "allow" => { let (tok, new_input) = match new_input.split_once(" ") { None => (new_input, ""), Some(v) => v, }; match tok.trim_start().to_lowercase().as_str() { "private" => { consent_details.allow_private = true; }, "pick" => { consent_details.allow_pick = true; }, "revoke" => { consent_details.freely_revoke = true; }, _ => Err("Option allow needs to be followed with private, pick or revoke - allow private | allow pick | allow revoke")? } input = new_input; } "disallow" => { let (tok, new_input) = match new_input.split_once(" ") { None => (new_input, ""), Some(v) => v, }; match tok.trim_start().to_lowercase().as_str() { "private" => { consent_details.allow_private = false; }, "pick" => { consent_details.allow_pick = false; }, _ => Err("Option disallow needs to be followed with private or pick - disallow private | disallow pick")? } input = new_input; } "in" => { let (tok, new_input) = match new_input.split_once(" ") { None => (new_input, ""), Some(v) => v, }; consent_details.only_in.push(tok); input = new_input; } _ => Err(format!( "I don't understand the option \"{}\"", strip_special_characters(tok) ))?, } } Ok(AllowCommand { consent_type: consent_type, consent_target: consent_target, consent_details: consent_details, }) } pub fn parse_payment_request( input: &str, my_account: &FinancialAccount, ) -> Result, &'static str> { let input = input.trim_start(); let account_parser = || { context( "Expected account to pay to/from to be username, corpname, or me", cut(terminated( alt(( map(tag::<&str, &str, VerboseError<&str>>("treasury"), |_| { FinancialAccount::Treasury }), map(tag("me"), |_| my_account.clone()), map( take_while1(|c: char| c.is_alphanumeric() || c == '_'), |n: &str| FinancialAccount::NamedAccount(n.to_owned()), ), )), peek(alt((eof, space1))), )), ) }; let res = pair( context( "No amount to pay found", preceded(opt(char('$')), terminated(u64, space1)), ), pair( opt(preceded( tag("from"), preceded(space1, terminated(account_parser(), space1)), )), terminated( context( "You need to specify who to pay with \"to\"", preceded(preceded(tag("to"), space1), account_parser()), ), eof, ), ), )(input); const CATCHALL_ERROR: &'static str = "Usage: \"pay $123 from account to account\". Leave off from to default to from you. Account can be \"me\" or a username or corpname"; match res { Ok((_, (amount, (from, to)))) => Ok(PaymentRequest { amount, from: from.unwrap_or_else(|| my_account.clone()), to, }), Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => Err(e .errors .into_iter() .find_map(|k| match k.1 { VerboseErrorKind::Context(s) => Some(s), _ => None, }) .unwrap_or(CATCHALL_ERROR)), Err(_) => Err(CATCHALL_ERROR), } } #[cfg(test)] mod tests { use super::*; #[test] fn it_parses_normal_command() { assert_eq!(parse_command_name("help"), ("help", "")); } #[test] fn it_parses_normal_command_with_arg() { assert_eq!( parse_command_name("help \t testing stuff"), ("help", "testing stuff") ); } #[test] fn it_parses_commands_with_leading_whitespace() { assert_eq!( parse_command_name(" \t \thelp \t testing stuff"), ("help", "testing stuff") ); } #[test] fn it_parses_empty_command_names() { assert_eq!(parse_command_name(""), ("", "")); assert_eq!(parse_command_name(" \t "), ("", "")); } #[test] fn it_parses_usernames() { assert_eq!(parse_username("Wizard123"), Ok(("Wizard123", ""))); } #[test] fn it_parses_usernames_with_further_args() { assert_eq!( parse_username("Wizard_123 with cat"), Ok(("Wizard_123", "with cat")) ); } #[test] fn it_parses_alpha_only_usernames() { assert_eq!(parse_username("W"), Ok(("W", ""))); } #[test] fn it_fails_on_empty_usernames() { assert_eq!(parse_username(""), Err("Must start with a letter")); } #[test] fn it_fails_on_usernames_with_invalid_start() { assert_eq!(parse_username("#hack"), Err("Must start with a letter")); } #[test] fn it_fails_on_usernames_with_underscore_start() { assert_eq!(parse_username("_hack"), Err("Must start with a letter")); } #[test] fn it_fails_on_usernames_with_number_start() { assert_eq!(parse_username("31337 #"), Err("Must start with a letter")); } #[test] fn it_fails_on_usernames_with_bad_characters() { assert_eq!( parse_username("Wizard!"), Err("Must only contain alphanumeric characters or _") ); } #[test] fn it_fails_on_long_usernames() { assert_eq!( parse_username("A23456789012345678901"), Err("Limit of 20 characters") ); } #[test] fn parse_to_space_splits_on_whitespace() { assert_eq!(parse_to_space("hello world"), ("hello", "world")); assert_eq!(parse_to_space("hello\tworld"), ("hello", "world")); assert_eq!(parse_to_space("hello world"), ("hello", "world")); } #[test] fn parse_to_space_supports_missing_rest() { assert_eq!(parse_to_space("hello"), ("hello", "")); assert_eq!(parse_to_space(""), ("", "")); } #[test] fn parse_offset_supports_no_offset() { assert_eq!(parse_offset("hello world"), (None, "hello world")) } #[test] fn parse_offset_supports_offset() { assert_eq!(parse_offset("2.hello world"), (Some(2), "hello world")) } #[test] fn parse_consent_works_default_options_user() { assert_eq!( super::parse_allow("medicine From Athorina", false), Ok(AllowCommand { consent_type: ConsentType::Medicine, consent_target: ConsentTarget::UserTarget { to_user: "Athorina" }, consent_details: ConsentDetails::default_for(&ConsentType::Medicine) }) ) } #[test] fn parse_consent_works_default_options_corp() { assert_eq!( super::parse_allow("Fight Against megacorp By supercorp", false), Ok(AllowCommand { consent_type: ConsentType::Fight, consent_target: ConsentTarget::CorpTarget { from_corp: "megacorp", to_corp: "supercorp" }, consent_details: ConsentDetails::default_for(&ConsentType::Fight) }) ) } #[test] fn parse_consent_handles_options() { assert_eq!(super::parse_allow("fighT fRom athorina For 2 hOurs unTil deAth allOw priVate Disallow pIck alLow revoKe iN here in pit", false), Ok(AllowCommand { consent_type: ConsentType::Fight, consent_target: ConsentTarget::UserTarget { to_user: "athorina" }, consent_details: ConsentDetails { duration_minutes: Some(120), until_death: true, allow_private: true, allow_pick: false, freely_revoke: true, only_in: vec!("here", "pit"), ..ConsentDetails::default_for(&ConsentType::Fight) } })) } #[test] fn parse_payment_request_happy_path_works() { assert_eq!( super::parse_payment_request( "$10 to mafia", &FinancialAccount::NamedAccount("namey".to_owned()) ), Ok(PaymentRequest { amount: 10, from: FinancialAccount::NamedAccount("namey".to_owned()), to: FinancialAccount::NamedAccount("mafia".to_owned()) }) ) } #[test] fn parse_payment_request_explicit_from_works() { assert_eq!( super::parse_payment_request( "10 from mafia to treasury", &FinancialAccount::NamedAccount("namey".to_owned()) ), Ok(PaymentRequest { amount: 10, from: FinancialAccount::NamedAccount("mafia".to_owned()), to: FinancialAccount::Treasury }) ) } #[test] fn parse_payment_wrong_start_err() { assert_eq!( super::parse_payment_request( "nonsense", &FinancialAccount::NamedAccount("namey".to_owned()) ), Err("No amount to pay found") ) } #[test] fn parse_payment_wrong_from_err() { assert_eq!( super::parse_payment_request( "10 from ! to me", &FinancialAccount::NamedAccount("namey".to_owned()) ), Err("Expected account to pay to/from to be username, corpname, or me") ) } #[test] fn parse_payment_wrong_to_err() { assert_eq!( super::parse_payment_request( "10 from me for fun", &FinancialAccount::NamedAccount("namey".to_owned()) ), Err("You need to specify who to pay with \"to\"") ) } #[test] fn parse_payment_trailing_junk_err() { assert_eq!( super::parse_payment_request( "10 from me to me for lolz", &FinancialAccount::NamedAccount("namey".to_owned()) ), Err("Usage: \"pay $123 from account to account\". Leave off from to default to from you. Account can be \"me\" or a username or corpname") ) } }