forked from blasthavers/blastmud
551 lines
18 KiB
Rust
551 lines
18 KiB
Rust
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<u8>, &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<u8>, &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) -> Result<AllowCommand, String> {
|
|
let usage: &'static str =
|
|
ansi!("Usage: allow <lt>action> from <lt>user> <lt>options> | allow <lt>action> against <lt>corp> by <lt>corp> <lt>options>. Try <bold>help allow<reset> 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("Invalid consent type - options are fight, medicine, gifts, visit and share"),
|
|
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<PaymentRequest<FinancialAccount>, &'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"),
|
|
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"),
|
|
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"),
|
|
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")
|
|
)
|
|
}
|
|
}
|