use std::{
collections::{HashMap, HashSet},
error::Error,
fmt::{Display, Formatter, Result as FmtResult},
};
use twilight_model::application::command::{
Command, CommandOption, CommandOptionChoice, CommandOptionChoiceValue, CommandOptionType,
CommandType,
};
pub const CHOICES_LIMIT: usize = 25;
pub const COMMAND_TOTAL_LENGTH: usize = 4000;
pub const DESCRIPTION_LENGTH_MAX: usize = 100;
pub const DESCRIPTION_LENGTH_MIN: usize = 1;
pub const NAME_LENGTH_MAX: usize = 32;
pub const NAME_LENGTH_MIN: usize = 1;
pub const OPTIONS_LIMIT: usize = 25;
pub const OPTION_CHOICE_NAME_LENGTH_MAX: usize = 100;
pub const OPTION_CHOICE_NAME_LENGTH_MIN: usize = 1;
pub const OPTION_CHOICE_STRING_VALUE_LENGTH_MAX: usize = 100;
pub const OPTION_CHOICE_STRING_VALUE_LENGTH_MIN: usize = 1;
pub const OPTION_DESCRIPTION_LENGTH_MAX: usize = 100;
pub const OPTION_DESCRIPTION_LENGTH_MIN: usize = 1;
pub const OPTION_NAME_LENGTH_MAX: usize = 32;
pub const OPTION_NAME_LENGTH_MIN: usize = 1;
pub const GUILD_COMMAND_LIMIT: usize = 100;
pub const GUILD_COMMAND_PERMISSION_LIMIT: usize = 10;
#[derive(Debug)]
pub struct CommandValidationError {
kind: CommandValidationErrorType,
}
impl CommandValidationError {
pub const COMMAND_COUNT_INVALID: CommandValidationError = CommandValidationError {
kind: CommandValidationErrorType::CountInvalid,
};
#[must_use = "retrieving the type has no effect if left unused"]
pub const fn kind(&self) -> &CommandValidationErrorType {
&self.kind
}
#[allow(clippy::unused_self)]
#[must_use = "consuming the error and retrieving the source has no effect if left unused"]
pub fn into_source(self) -> Option<Box<dyn Error + Send + Sync>> {
None
}
#[must_use = "consuming the error into its parts has no effect if left unused"]
pub fn into_parts(
self,
) -> (
CommandValidationErrorType,
Option<Box<dyn Error + Send + Sync>>,
) {
(self.kind, None)
}
#[must_use = "creating an error has no effect if left unused"]
pub const fn option_name_not_unique(option_index: usize) -> Self {
Self {
kind: CommandValidationErrorType::OptionNameNotUnique { option_index },
}
}
#[must_use = "creating an error has no effect if left unused"]
pub const fn option_required_first(index: usize) -> Self {
Self {
kind: CommandValidationErrorType::OptionsRequiredFirst { index },
}
}
}
impl Display for CommandValidationError {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
match &self.kind {
CommandValidationErrorType::CountInvalid => {
f.write_str("more than ")?;
Display::fmt(&GUILD_COMMAND_LIMIT, f)?;
f.write_str(" commands were set")
}
CommandValidationErrorType::CommandTooLarge { characters } => {
f.write_str("the combined total length of the command is ")?;
Display::fmt(characters, f)?;
f.write_str(" characters long, but the max is ")?;
Display::fmt(&COMMAND_TOTAL_LENGTH, f)
}
CommandValidationErrorType::DescriptionInvalid => {
f.write_str("command description must be between ")?;
Display::fmt(&DESCRIPTION_LENGTH_MIN, f)?;
f.write_str(" and ")?;
Display::fmt(&DESCRIPTION_LENGTH_MAX, f)?;
f.write_str(" characters")
}
CommandValidationErrorType::DescriptionNotAllowed => f.write_str(
"command description must be a empty string on message and user commands",
),
CommandValidationErrorType::NameLengthInvalid => {
f.write_str("command name must be between ")?;
Display::fmt(&NAME_LENGTH_MIN, f)?;
f.write_str(" and ")?;
Display::fmt(&NAME_LENGTH_MAX, f)
}
CommandValidationErrorType::NameCharacterInvalid { character } => {
f.write_str(
"command name must only contain lowercase alphanumeric characters, found `",
)?;
Display::fmt(character, f)?;
f.write_str("`")
}
CommandValidationErrorType::OptionDescriptionInvalid => {
f.write_str("command option description must be between ")?;
Display::fmt(&OPTION_DESCRIPTION_LENGTH_MIN, f)?;
f.write_str(" and ")?;
Display::fmt(&OPTION_DESCRIPTION_LENGTH_MAX, f)?;
f.write_str(" characters")
}
CommandValidationErrorType::OptionNameNotUnique { option_index } => {
f.write_str("command option at index ")?;
Display::fmt(option_index, f)?;
f.write_str(" has the same name as another option")
}
CommandValidationErrorType::OptionNameLengthInvalid => {
f.write_str("command option name must be between ")?;
Display::fmt(&OPTION_NAME_LENGTH_MIN, f)?;
f.write_str(" and ")?;
Display::fmt(&OPTION_NAME_LENGTH_MAX, f)
}
CommandValidationErrorType::OptionNameCharacterInvalid { character } => {
f.write_str("command option name must only contain lowercase alphanumeric characters, found `")?;
Display::fmt(character, f)?;
f.write_str("`")
}
CommandValidationErrorType::OptionChoiceNameLengthInvalid => {
f.write_str("command option choice name must be between ")?;
Display::fmt(&OPTION_CHOICE_NAME_LENGTH_MIN, f)?;
f.write_str(" and ")?;
Display::fmt(&OPTION_CHOICE_NAME_LENGTH_MAX, f)?;
f.write_str(" characters")
}
CommandValidationErrorType::OptionChoiceStringValueLengthInvalid => {
f.write_str("command option choice string value must be between ")?;
Display::fmt(&OPTION_CHOICE_STRING_VALUE_LENGTH_MIN, f)?;
f.write_str(" and ")?;
Display::fmt(&OPTION_CHOICE_STRING_VALUE_LENGTH_MAX, f)?;
f.write_str(" characters")
}
CommandValidationErrorType::OptionsCountInvalid => {
f.write_str("more than ")?;
Display::fmt(&OPTIONS_LIMIT, f)?;
f.write_str(" options were set")
}
CommandValidationErrorType::OptionsRequiredFirst { .. } => {
f.write_str("optional command options must be added after required")
}
CommandValidationErrorType::PermissionsCountInvalid => {
f.write_str("more than ")?;
Display::fmt(&GUILD_COMMAND_PERMISSION_LIMIT, f)?;
f.write_str(" permission overwrites were set")
}
}
}
}
impl Error for CommandValidationError {}
#[derive(Debug)]
#[non_exhaustive]
pub enum CommandValidationErrorType {
CountInvalid,
CommandTooLarge {
characters: usize,
},
DescriptionInvalid,
DescriptionNotAllowed,
NameLengthInvalid,
NameCharacterInvalid {
character: char,
},
OptionDescriptionInvalid,
OptionNameLengthInvalid,
OptionNameNotUnique {
option_index: usize,
},
OptionNameCharacterInvalid {
character: char,
},
OptionChoiceNameLengthInvalid,
OptionChoiceStringValueLengthInvalid,
OptionsCountInvalid,
OptionsRequiredFirst {
index: usize,
},
PermissionsCountInvalid,
}
pub fn command(value: &Command) -> Result<(), CommandValidationError> {
let characters = self::command_characters(value);
if characters > COMMAND_TOTAL_LENGTH {
return Err(CommandValidationError {
kind: CommandValidationErrorType::CommandTooLarge { characters },
});
}
let Command {
description,
description_localizations,
name,
name_localizations,
kind,
..
} = value;
if *kind == CommandType::ChatInput {
self::description(description)?;
if let Some(description_localizations) = description_localizations {
for description in description_localizations.values() {
self::description(description)?;
}
}
} else if !description.is_empty() {
return Err(CommandValidationError {
kind: CommandValidationErrorType::DescriptionNotAllowed,
});
}
if let Some(name_localizations) = name_localizations {
for name in name_localizations.values() {
match kind {
CommandType::ChatInput => self::chat_input_name(name)?,
CommandType::User | CommandType::Message => {
self::name(name)?;
}
CommandType::Unknown(_) => (),
_ => unimplemented!(),
}
}
}
match kind {
CommandType::ChatInput => self::chat_input_name(name),
CommandType::User | CommandType::Message => self::name(name),
CommandType::Unknown(_) => Ok(()),
_ => unimplemented!(),
}
}
pub fn command_characters(command: &Command) -> usize {
let mut characters =
longest_localization_characters(&command.name, command.name_localizations.as_ref())
+ longest_localization_characters(
&command.description,
command.description_localizations.as_ref(),
);
for option in &command.options {
characters += option_characters(option);
}
characters
}
pub fn option_characters(option: &CommandOption) -> usize {
let mut characters = 0;
characters += longest_localization_characters(&option.name, option.name_localizations.as_ref());
characters += longest_localization_characters(
&option.description,
option.description_localizations.as_ref(),
);
match option.kind {
CommandOptionType::String => {
if let Some(choices) = option.choices.as_ref() {
for choice in choices {
if let CommandOptionChoiceValue::String(string_choice) = &choice.value {
characters += longest_localization_characters(
&choice.name,
choice.name_localizations.as_ref(),
) + string_choice.len();
}
}
}
}
CommandOptionType::SubCommandGroup | CommandOptionType::SubCommand => {
if let Some(options) = option.options.as_ref() {
for option in options {
characters += option_characters(option);
}
}
}
_ => {}
}
characters
}
fn longest_localization_characters(
default: &str,
localizations: Option<&HashMap<String, String>>,
) -> usize {
let mut characters = default.len();
if let Some(localizations) = localizations {
for localization in localizations.values() {
if localization.len() > characters {
characters = localization.len();
}
}
}
characters
}
pub fn description(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
let len = value.as_ref().chars().count();
if (DESCRIPTION_LENGTH_MIN..=DESCRIPTION_LENGTH_MAX).contains(&len) {
Ok(())
} else {
Err(CommandValidationError {
kind: CommandValidationErrorType::DescriptionInvalid,
})
}
}
pub fn name(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
let len = value.as_ref().chars().count();
if (NAME_LENGTH_MIN..=NAME_LENGTH_MAX).contains(&len) {
Ok(())
} else {
Err(CommandValidationError {
kind: CommandValidationErrorType::NameLengthInvalid,
})
}
}
pub fn chat_input_name(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
self::name(&value)?;
self::name_characters(value)?;
Ok(())
}
pub fn option_name(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
let len = value.as_ref().chars().count();
if !(OPTION_NAME_LENGTH_MIN..=OPTION_NAME_LENGTH_MAX).contains(&len) {
return Err(CommandValidationError {
kind: CommandValidationErrorType::NameLengthInvalid,
});
}
self::name_characters(value)?;
Ok(())
}
fn name_characters(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
let chars = value.as_ref().chars();
for char in chars {
if !char.is_alphanumeric() && char != '_' && char != '-' {
return Err(CommandValidationError {
kind: CommandValidationErrorType::NameCharacterInvalid { character: char },
});
}
if char.to_lowercase().next() != Some(char) {
return Err(CommandValidationError {
kind: CommandValidationErrorType::NameCharacterInvalid { character: char },
});
}
}
Ok(())
}
pub fn choice_name(name: &str) -> Result<(), CommandValidationError> {
let len = name.chars().count();
if (OPTION_CHOICE_NAME_LENGTH_MIN..=OPTION_CHOICE_NAME_LENGTH_MAX).contains(&len) {
Ok(())
} else {
Err(CommandValidationError {
kind: CommandValidationErrorType::OptionChoiceNameLengthInvalid,
})
}
}
pub fn choice(choice: &CommandOptionChoice) -> Result<(), CommandValidationError> {
self::choice_name(&choice.name)?;
if let CommandOptionChoiceValue::String(value) = &choice.value {
let value_len = value.chars().count();
if !(OPTION_CHOICE_STRING_VALUE_LENGTH_MIN..=OPTION_CHOICE_STRING_VALUE_LENGTH_MAX)
.contains(&value_len)
{
return Err(CommandValidationError {
kind: CommandValidationErrorType::OptionChoiceStringValueLengthInvalid,
});
}
}
if let Some(name_localizations) = &choice.name_localizations {
name_localizations
.values()
.try_for_each(|name| self::choice_name(name))?;
}
Ok(())
}
pub fn option(option: &CommandOption) -> Result<(), CommandValidationError> {
let description_len = option.description.chars().count();
if !(OPTION_DESCRIPTION_LENGTH_MIN..=OPTION_DESCRIPTION_LENGTH_MAX).contains(&description_len) {
return Err(CommandValidationError {
kind: CommandValidationErrorType::OptionDescriptionInvalid,
});
}
if let Some(choices) = &option.choices {
choices.iter().try_for_each(self::choice)?;
}
self::option_name(&option.name)
}
pub fn options(options: &[CommandOption]) -> Result<(), CommandValidationError> {
if options.len() > OPTIONS_LIMIT {
return Err(CommandValidationError {
kind: CommandValidationErrorType::OptionsCountInvalid,
});
}
let mut names = HashSet::with_capacity(options.len());
for (option_index, option) in options.iter().enumerate() {
if !names.insert(&option.name) {
return Err(CommandValidationError::option_name_not_unique(option_index));
}
}
options
.iter()
.zip(options.iter().skip(1))
.enumerate()
.try_for_each(|(index, (first, second))| {
if !first.required.unwrap_or_default() && second.required.unwrap_or_default() {
Err(CommandValidationError::option_required_first(index))
} else {
Ok(())
}
})?;
options.iter().try_for_each(|option| {
if let Some(options) = &option.options {
self::options(options)
} else {
self::option(option)
}
})?;
Ok(())
}
pub const fn guild_permissions(count: usize) -> Result<(), CommandValidationError> {
if count <= GUILD_COMMAND_PERMISSION_LIMIT {
Ok(())
} else {
Err(CommandValidationError {
kind: CommandValidationErrorType::PermissionsCountInvalid,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use twilight_model::id::Id;
#[test]
fn choice_name_limit() {
let valid_choice = CommandOptionChoice {
name: "a".repeat(100),
name_localizations: None,
value: CommandOptionChoiceValue::String("a".to_string()),
};
assert!(choice(&valid_choice).is_ok());
let invalid_choice = CommandOptionChoice {
name: "a".repeat(101),
name_localizations: None,
value: CommandOptionChoiceValue::String("b".to_string()),
};
assert!(choice(&invalid_choice).is_err());
let invalid_choice = CommandOptionChoice {
name: String::new(),
name_localizations: None,
value: CommandOptionChoiceValue::String("c".to_string()),
};
assert!(choice(&invalid_choice).is_err());
}
#[test]
fn choice_name_localizations() {
let mut name_localizations = HashMap::new();
name_localizations.insert("en-US".to_string(), "a".repeat(100));
let valid_choice = CommandOptionChoice {
name: "a".to_string(),
name_localizations: Some(name_localizations),
value: CommandOptionChoiceValue::String("a".to_string()),
};
assert!(choice(&valid_choice).is_ok());
let mut name_localizations = HashMap::new();
name_localizations.insert("en-US".to_string(), "a".repeat(101));
let invalid_choice = CommandOptionChoice {
name: "a".to_string(),
name_localizations: Some(name_localizations),
value: CommandOptionChoiceValue::String("b".to_string()),
};
assert!(choice(&invalid_choice).is_err());
let mut name_localizations = HashMap::new();
name_localizations.insert("en-US".to_string(), String::new());
let invalid_choice = CommandOptionChoice {
name: "a".to_string(),
name_localizations: Some(name_localizations),
value: CommandOptionChoiceValue::String("c".to_string()),
};
assert!(choice(&invalid_choice).is_err());
let mut name_localizations = HashMap::new();
name_localizations.insert("en-US".to_string(), String::from("a"));
name_localizations.insert("en-GB".to_string(), "a".repeat(101));
name_localizations.insert("es-ES".to_string(), "a".repeat(100));
let invalid_choice = CommandOptionChoice {
name: "a".to_string(),
name_localizations: Some(name_localizations),
value: CommandOptionChoiceValue::String("c".to_string()),
};
assert!(choice(&invalid_choice).is_err());
}
#[test]
fn choice_string_value() {
let valid_choice = CommandOptionChoice {
name: "a".to_string(),
name_localizations: None,
value: CommandOptionChoiceValue::String("a".to_string()),
};
assert!(choice(&valid_choice).is_ok());
let invalid_choice = CommandOptionChoice {
name: "b".to_string(),
name_localizations: None,
value: CommandOptionChoiceValue::String("b".repeat(101)),
};
assert!(choice(&invalid_choice).is_err());
let invalid_choice = CommandOptionChoice {
name: "c".to_string(),
name_localizations: None,
value: CommandOptionChoiceValue::String(String::new()),
};
assert!(choice(&invalid_choice).is_err());
}
#[test]
#[allow(deprecated)]
fn command_length() {
let valid_command = Command {
application_id: Some(Id::new(1)),
contexts: None,
default_member_permissions: None,
dm_permission: None,
description: "a".repeat(100),
description_localizations: Some(HashMap::from([(
"en-US".to_string(),
"a".repeat(100),
)])),
guild_id: Some(Id::new(2)),
id: Some(Id::new(3)),
integration_types: None,
kind: CommandType::ChatInput,
name: "b".repeat(32),
name_localizations: Some(HashMap::from([("en-US".to_string(), "b".repeat(32))])),
nsfw: None,
options: Vec::new(),
version: Id::new(4),
};
assert!(command(&valid_command).is_ok());
let invalid_message_command = Command {
description: "c".repeat(101),
name: "d".repeat(33),
..valid_command.clone()
};
assert!(command(&invalid_message_command).is_err());
let valid_context_menu_command = Command {
description: String::new(),
kind: CommandType::Message,
..valid_command.clone()
};
assert!(command(&valid_context_menu_command).is_ok());
let invalid_context_menu_command = Command {
description: "example description".to_string(),
kind: CommandType::Message,
..valid_command
};
assert!(command(&invalid_context_menu_command).is_err());
}
#[test]
fn name_allowed_characters() {
assert!(name_characters("hello-command").is_ok()); assert!(name_characters("Hello").is_err()); assert!(name_characters("hello!").is_err());
assert!(name_characters("здрасти").is_ok()); assert!(name_characters("Здрасти").is_err()); assert!(name_characters("здрасти!").is_err());
assert!(name_characters("你好").is_ok()); assert!(name_characters("你好。").is_err()); }
#[test]
fn guild_permissions_count() {
assert!(guild_permissions(0).is_ok());
assert!(guild_permissions(1).is_ok());
assert!(guild_permissions(10).is_ok());
assert!(guild_permissions(11).is_err());
}
#[test]
#[allow(deprecated)]
fn command_combined_limit() {
let mut command = Command {
application_id: Some(Id::new(1)),
default_member_permissions: None,
dm_permission: None,
description: "a".repeat(10),
description_localizations: Some(HashMap::from([(
"en-US".to_string(),
"a".repeat(100),
)])),
guild_id: Some(Id::new(2)),
id: Some(Id::new(3)),
kind: CommandType::ChatInput,
name: "b".repeat(10),
name_localizations: Some(HashMap::from([("en-US".to_string(), "b".repeat(32))])),
nsfw: None,
options: Vec::from([CommandOption {
autocomplete: None,
channel_types: None,
choices: None,
description: "a".repeat(10),
description_localizations: Some(HashMap::from([(
"en-US".to_string(),
"a".repeat(100),
)])),
kind: CommandOptionType::SubCommandGroup,
max_length: None,
max_value: None,
min_length: None,
min_value: None,
name: "b".repeat(10),
name_localizations: Some(HashMap::from([("en-US".to_string(), "b".repeat(32))])),
options: Some(Vec::from([CommandOption {
autocomplete: None,
channel_types: None,
choices: None,
description: "a".repeat(100),
description_localizations: Some(HashMap::from([(
"en-US".to_string(),
"a".repeat(10),
)])),
kind: CommandOptionType::SubCommand,
max_length: None,
max_value: None,
min_length: None,
min_value: None,
name: "b".repeat(32),
name_localizations: Some(HashMap::from([(
"en-US".to_string(),
"b".repeat(10),
)])),
options: Some(Vec::from([CommandOption {
autocomplete: Some(false),
channel_types: None,
choices: Some(Vec::from([CommandOptionChoice {
name: "b".repeat(32),
name_localizations: Some(HashMap::from([(
"en-US".to_string(),
"b".repeat(10),
)])),
value: CommandOptionChoiceValue::String("c".repeat(100)),
}])),
description: "a".repeat(100),
description_localizations: Some(HashMap::from([(
"en-US".to_string(),
"a".repeat(10),
)])),
kind: CommandOptionType::String,
max_length: None,
max_value: None,
min_length: None,
min_value: None,
name: "b".repeat(32),
name_localizations: Some(HashMap::from([(
"en-US".to_string(),
"b".repeat(10),
)])),
options: None,
required: Some(false),
}])),
required: None,
}])),
required: None,
}]),
version: Id::new(4),
contexts: None,
integration_types: None,
};
assert_eq!(command_characters(&command), 660);
assert!(super::command(&command).is_ok());
command.description = "a".repeat(3441);
assert_eq!(command_characters(&command), 4001);
assert!(matches!(
super::command(&command).unwrap_err().kind(),
CommandValidationErrorType::CommandTooLarge { characters: 4001 }
));
}
#[test]
fn option_name_uniqueness() {
let option = CommandOption {
autocomplete: None,
channel_types: None,
choices: None,
description: "a description".to_owned(),
description_localizations: None,
kind: CommandOptionType::String,
max_length: None,
max_value: None,
min_length: None,
min_value: None,
name: "name".to_owned(),
name_localizations: None,
options: None,
required: None,
};
let mut options = Vec::from([option.clone()]);
assert!(super::options(&options).is_ok());
options.push(option);
assert!(matches!(super::options(&options).unwrap_err().kind(),
CommandValidationErrorType::OptionNameNotUnique { option_index } if *option_index == 1));
}
#[test]
fn option_description_length() {
let base = CommandOption {
autocomplete: None,
channel_types: None,
choices: None,
description: String::new(),
description_localizations: None,
kind: CommandOptionType::Boolean,
max_length: None,
max_value: None,
min_length: None,
min_value: None,
name: "testcommand".to_string(),
name_localizations: None,
options: None,
required: None,
};
let toolong = CommandOption {
description: "e".repeat(OPTION_DESCRIPTION_LENGTH_MAX + 1),
..base.clone()
};
let tooshort = CommandOption {
description: "e".repeat(OPTION_DESCRIPTION_LENGTH_MIN - 1),
..base.clone()
};
let maxlen = CommandOption {
description: "e".repeat(OPTION_DESCRIPTION_LENGTH_MAX),
..base.clone()
};
#[allow(clippy::repeat_once)]
let minlen = CommandOption {
description: "e".repeat(OPTION_DESCRIPTION_LENGTH_MIN),
..base
};
assert!(option(&toolong).is_err());
assert!(option(&tooshort).is_err());
assert!(option(&maxlen).is_ok());
assert!(option(&minlen).is_ok());
}
}