use std::{
error::Error,
fmt::{Display, Formatter, Result as FmtResult},
};
use twilight_model::application::command::{Command, CommandOption, CommandType};
pub const CHOICES_LIMIT: usize = 25;
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_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_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::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::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::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::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,
DescriptionInvalid,
NameLengthInvalid,
NameCharacterInvalid {
character: char,
},
OptionDescriptionInvalid,
OptionNameLengthInvalid,
OptionNameCharacterInvalid {
character: char,
},
OptionsCountInvalid,
OptionsRequiredFirst {
index: usize,
},
PermissionsCountInvalid,
}
pub fn command(value: &Command) -> Result<(), CommandValidationError> {
let Command {
description,
description_localizations,
name,
name_localizations,
kind,
..
} = value;
self::description(description)?;
if let Some(description_localizations) = description_localizations {
for description in description_localizations.values() {
self::description(description)?;
}
}
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 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 option(option: &CommandOption) -> Result<(), CommandValidationError> {
let description_len = option.description.chars().count();
if description_len > OPTION_DESCRIPTION_LENGTH_MAX
&& description_len < OPTION_DESCRIPTION_LENGTH_MIN
{
return Err(CommandValidationError {
kind: CommandValidationErrorType::OptionDescriptionInvalid,
});
}
self::option_name(&option.name)
}
pub fn options(options: &[CommandOption]) -> Result<(), CommandValidationError> {
if options.len() > OPTIONS_LIMIT {
return Err(CommandValidationError {
kind: CommandValidationErrorType::OptionsCountInvalid,
});
}
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 {
#![allow(clippy::non_ascii_literal)]
use super::*;
use std::collections::HashMap;
use twilight_model::{application::command::CommandType, id::Id};
#[test]
fn command_length() {
let valid_command = Command {
application_id: Some(Id::new(1)),
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)),
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_command = Command {
description: "c".repeat(101),
name: "d".repeat(33),
..valid_command
};
assert!(command(&invalid_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());
}
}