Skip to main content

twilight_validate/
command.rs

1//! Constants, error types, and functions for validating [`Command`]s.
2
3use std::{
4    collections::{HashMap, HashSet},
5    error::Error,
6    fmt::{Display, Formatter, Result as FmtResult},
7};
8use twilight_model::application::command::{
9    Command, CommandOption, CommandOptionChoice, CommandOptionChoiceValue, CommandOptionType,
10    CommandType,
11};
12
13/// Maximum number of choices an option can have.
14pub const CHOICES_LIMIT: usize = 25;
15
16/// The maximum combined command length in codepoints.
17pub const COMMAND_TOTAL_LENGTH: usize = 4000;
18
19/// Maximum length of a command's description.
20pub const DESCRIPTION_LENGTH_MAX: usize = 100;
21
22/// Minimum length of a command's description.
23pub const DESCRIPTION_LENGTH_MIN: usize = 1;
24
25/// Maximum length of a command's name.
26pub const NAME_LENGTH_MAX: usize = 32;
27
28/// Minimum length of a command's name.
29pub const NAME_LENGTH_MIN: usize = 1;
30
31/// Maximum amount of options a command may have.
32pub const OPTIONS_LIMIT: usize = 25;
33
34/// Maximum length of an option choice name.
35pub const OPTION_CHOICE_NAME_LENGTH_MAX: usize = 100;
36
37/// Minimum length of an option choice name.
38pub const OPTION_CHOICE_NAME_LENGTH_MIN: usize = 1;
39
40/// Maximum length of an option choice string value.
41pub const OPTION_CHOICE_STRING_VALUE_LENGTH_MAX: usize = 100;
42
43/// Minimum length of an option choice string value.
44pub const OPTION_CHOICE_STRING_VALUE_LENGTH_MIN: usize = 1;
45
46/// Maximum length of a command's description.
47pub const OPTION_DESCRIPTION_LENGTH_MAX: usize = 100;
48
49/// Minimum length of a command's description.
50pub const OPTION_DESCRIPTION_LENGTH_MIN: usize = 1;
51
52/// Maximum length of a command's name.
53pub const OPTION_NAME_LENGTH_MAX: usize = 32;
54
55/// Minimum length of a command's name.
56pub const OPTION_NAME_LENGTH_MIN: usize = 1;
57
58/// Maximum number of commands an application may have in an individual
59/// guild.
60pub const GUILD_COMMAND_LIMIT: usize = 100;
61
62/// Maximum number of permission overwrites an application may have in an
63/// individual guild command.
64pub const GUILD_COMMAND_PERMISSION_LIMIT: usize = 10;
65
66/// Error created when a [`Command`] is invalid.
67#[derive(Debug)]
68pub struct CommandValidationError {
69    /// Type of error that occurred.
70    kind: CommandValidationErrorType,
71}
72
73impl CommandValidationError {
74    /// Constant instance of a [`CommandValidationError`] with type
75    /// [`CountInvalid`].
76    ///
77    /// [`CountInvalid`]: CommandValidationErrorType::CountInvalid
78    pub const COMMAND_COUNT_INVALID: CommandValidationError = CommandValidationError {
79        kind: CommandValidationErrorType::CountInvalid,
80    };
81
82    /// Immutable reference to the type of error that occurred.
83    #[must_use = "retrieving the type has no effect if left unused"]
84    pub const fn kind(&self) -> &CommandValidationErrorType {
85        &self.kind
86    }
87
88    /// Consume the error, returning the source error if there is any.
89    #[allow(clippy::unused_self)]
90    #[must_use = "consuming the error and retrieving the source has no effect if left unused"]
91    pub fn into_source(self) -> Option<Box<dyn Error + Send + Sync>> {
92        None
93    }
94
95    /// Consume the error, returning the owned error type and the source error.
96    #[must_use = "consuming the error into its parts has no effect if left unused"]
97    pub fn into_parts(
98        self,
99    ) -> (
100        CommandValidationErrorType,
101        Option<Box<dyn Error + Send + Sync>>,
102    ) {
103        (self.kind, None)
104    }
105
106    /// Create an error of type [`OptionNameNotUnique`] with a provided index of
107    /// the duplicated option name.
108    ///
109    /// [`OptionNameNotUnique`]: CommandValidationErrorType::OptionNameNotUnique
110    #[must_use = "creating an error has no effect if left unused"]
111    pub const fn option_name_not_unique(option_index: usize) -> Self {
112        Self {
113            kind: CommandValidationErrorType::OptionNameNotUnique { option_index },
114        }
115    }
116
117    /// Create an error of type [`OptionsRequiredFirst`] with a provided index.
118    ///
119    /// [`OptionsRequiredFirst`]: CommandValidationErrorType::OptionsRequiredFirst
120    #[must_use = "creating an error has no effect if left unused"]
121    pub const fn option_required_first(index: usize) -> Self {
122        Self {
123            kind: CommandValidationErrorType::OptionsRequiredFirst { index },
124        }
125    }
126}
127
128impl Display for CommandValidationError {
129    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
130        match &self.kind {
131            CommandValidationErrorType::CountInvalid => {
132                f.write_str("more than ")?;
133                Display::fmt(&GUILD_COMMAND_LIMIT, f)?;
134
135                f.write_str(" commands were set")
136            }
137            CommandValidationErrorType::CommandTooLarge { characters } => {
138                f.write_str("the combined total length of the command is ")?;
139                Display::fmt(characters, f)?;
140                f.write_str(" characters long, but the max is ")?;
141
142                Display::fmt(&COMMAND_TOTAL_LENGTH, f)
143            }
144            CommandValidationErrorType::DescriptionInvalid => {
145                f.write_str("command description must be between ")?;
146                Display::fmt(&DESCRIPTION_LENGTH_MIN, f)?;
147                f.write_str(" and ")?;
148                Display::fmt(&DESCRIPTION_LENGTH_MAX, f)?;
149
150                f.write_str(" characters")
151            }
152            CommandValidationErrorType::DescriptionNotAllowed => f.write_str(
153                "command description must be a empty string on message and user commands",
154            ),
155            CommandValidationErrorType::NameLengthInvalid => {
156                f.write_str("command name must be between ")?;
157                Display::fmt(&NAME_LENGTH_MIN, f)?;
158                f.write_str(" and ")?;
159
160                Display::fmt(&NAME_LENGTH_MAX, f)
161            }
162            CommandValidationErrorType::NameCharacterInvalid { character } => {
163                f.write_str(
164                    "command name must only contain lowercase alphanumeric characters, found `",
165                )?;
166                Display::fmt(character, f)?;
167
168                f.write_str("`")
169            }
170            CommandValidationErrorType::OptionDescriptionInvalid => {
171                f.write_str("command option description must be between ")?;
172                Display::fmt(&OPTION_DESCRIPTION_LENGTH_MIN, f)?;
173                f.write_str(" and ")?;
174                Display::fmt(&OPTION_DESCRIPTION_LENGTH_MAX, f)?;
175
176                f.write_str(" characters")
177            }
178            CommandValidationErrorType::OptionNameNotUnique { option_index } => {
179                f.write_str("command option at index ")?;
180                Display::fmt(option_index, f)?;
181
182                f.write_str(" has the same name as another option")
183            }
184            CommandValidationErrorType::OptionNameLengthInvalid => {
185                f.write_str("command option name must be between ")?;
186                Display::fmt(&OPTION_NAME_LENGTH_MIN, f)?;
187                f.write_str(" and ")?;
188
189                Display::fmt(&OPTION_NAME_LENGTH_MAX, f)
190            }
191            CommandValidationErrorType::OptionNameCharacterInvalid { character } => {
192                f.write_str("command option name must only contain lowercase alphanumeric characters, found `")?;
193                Display::fmt(character, f)?;
194
195                f.write_str("`")
196            }
197            CommandValidationErrorType::OptionChoiceNameLengthInvalid => {
198                f.write_str("command option choice name must be between ")?;
199                Display::fmt(&OPTION_CHOICE_NAME_LENGTH_MIN, f)?;
200                f.write_str(" and ")?;
201                Display::fmt(&OPTION_CHOICE_NAME_LENGTH_MAX, f)?;
202
203                f.write_str(" characters")
204            }
205            CommandValidationErrorType::OptionChoiceStringValueLengthInvalid => {
206                f.write_str("command option choice string value must be between ")?;
207                Display::fmt(&OPTION_CHOICE_STRING_VALUE_LENGTH_MIN, f)?;
208                f.write_str(" and ")?;
209                Display::fmt(&OPTION_CHOICE_STRING_VALUE_LENGTH_MAX, f)?;
210
211                f.write_str(" characters")
212            }
213            CommandValidationErrorType::OptionsCountInvalid => {
214                f.write_str("more than ")?;
215                Display::fmt(&OPTIONS_LIMIT, f)?;
216
217                f.write_str(" options were set")
218            }
219            CommandValidationErrorType::OptionsRequiredFirst { .. } => {
220                f.write_str("optional command options must be added after required")
221            }
222            CommandValidationErrorType::PermissionsCountInvalid => {
223                f.write_str("more than ")?;
224                Display::fmt(&GUILD_COMMAND_PERMISSION_LIMIT, f)?;
225
226                f.write_str(" permission overwrites were set")
227            }
228        }
229    }
230}
231
232impl Error for CommandValidationError {}
233
234/// Type of [`CommandValidationError`] that occurred.
235#[derive(Debug)]
236#[non_exhaustive]
237pub enum CommandValidationErrorType {
238    /// Too many commands have been provided.
239    ///
240    /// The maximum number of commands is defined by
241    /// [`GUILD_COMMAND_LIMIT`].
242    CountInvalid,
243    /// Combined values of the command are larger than
244    /// [`COMMAND_TOTAL_LENGTH`].
245    ///
246    /// This includes name or the longest name localization,
247    /// description or the longest description localization
248    /// of the command and its options and the choice names
249    /// or the longest name localization and the choice value
250    /// if it is a string choice.
251    CommandTooLarge {
252        /// Provided number of codepoints.
253        characters: usize,
254    },
255    /// Command description is invalid.
256    DescriptionInvalid,
257    /// Command description must be a empty string.
258    DescriptionNotAllowed,
259    /// Command name length is invalid.
260    NameLengthInvalid,
261    /// Command name contain an invalid character.
262    NameCharacterInvalid {
263        /// Invalid character.
264        character: char,
265    },
266    /// Command option description is invalid.
267    OptionDescriptionInvalid,
268    /// Command option name length is invalid.
269    OptionNameLengthInvalid,
270    /// Command option name is non-unique.
271    OptionNameNotUnique {
272        /// Index of the option that has a duplicated name.
273        option_index: usize,
274    },
275    /// Command option name contain an invalid character.
276    OptionNameCharacterInvalid {
277        /// Invalid character.
278        character: char,
279    },
280    /// Command option choice name length is invalid.
281    OptionChoiceNameLengthInvalid,
282    /// String command option choice value length is invalid.
283    OptionChoiceStringValueLengthInvalid,
284    /// Command options count invalid.
285    OptionsCountInvalid,
286    /// Required command options have to be passed before optional ones.
287    OptionsRequiredFirst {
288        /// Index of the option that failed validation.
289        index: usize,
290    },
291    /// More than 10 permission overwrites were set.
292    PermissionsCountInvalid,
293}
294
295/// Validate a [`Command`].
296///
297/// # Errors
298///
299/// Returns an error of type [`DescriptionInvalid`] if the description is
300/// invalid.
301///
302/// Returns an error of type [`NameLengthInvalid`] or [`NameCharacterInvalid`]
303/// if the name is invalid.
304///
305/// [`DescriptionInvalid`]: CommandValidationErrorType::DescriptionInvalid
306/// [`NameLengthInvalid`]: CommandValidationErrorType::NameLengthInvalid
307/// [`NameCharacterInvalid`]: CommandValidationErrorType::NameCharacterInvalid
308pub fn command(value: &Command) -> Result<(), CommandValidationError> {
309    let characters = self::command_characters(value);
310
311    if characters > COMMAND_TOTAL_LENGTH {
312        return Err(CommandValidationError {
313            kind: CommandValidationErrorType::CommandTooLarge { characters },
314        });
315    }
316
317    let Command {
318        description,
319        description_localizations,
320        name,
321        name_localizations,
322        kind,
323        ..
324    } = value;
325
326    if *kind == CommandType::ChatInput {
327        self::description(description)?;
328        if let Some(description_localizations) = description_localizations {
329            for description in description_localizations.values() {
330                self::description(description)?;
331            }
332        }
333    } else if !description.is_empty() {
334        return Err(CommandValidationError {
335            kind: CommandValidationErrorType::DescriptionNotAllowed,
336        });
337    }
338
339    if let Some(name_localizations) = name_localizations {
340        for name in name_localizations.values() {
341            match kind {
342                CommandType::ChatInput => self::chat_input_name(name)?,
343                CommandType::User | CommandType::Message => {
344                    self::name(name)?;
345                }
346                CommandType::Unknown(_) => (),
347                _ => unimplemented!(),
348            }
349        }
350    }
351
352    match kind {
353        CommandType::ChatInput => self::chat_input_name(name),
354        CommandType::User | CommandType::Message => self::name(name),
355        CommandType::Unknown(_) => Ok(()),
356        _ => unimplemented!(),
357    }
358}
359
360/// Calculate the total character count of a command.
361pub fn command_characters(command: &Command) -> usize {
362    let mut characters =
363        longest_localization_characters(&command.name, command.name_localizations.as_ref())
364            + longest_localization_characters(
365                &command.description,
366                command.description_localizations.as_ref(),
367            );
368
369    for option in &command.options {
370        characters += option_characters(option);
371    }
372
373    characters
374}
375
376/// Calculate the total character count of a command option.
377pub fn option_characters(option: &CommandOption) -> usize {
378    let mut characters = 0;
379
380    characters += longest_localization_characters(&option.name, option.name_localizations.as_ref());
381    characters += longest_localization_characters(
382        &option.description,
383        option.description_localizations.as_ref(),
384    );
385
386    match option.kind {
387        CommandOptionType::String => {
388            if let Some(choices) = option.choices.as_ref() {
389                for choice in choices {
390                    if let CommandOptionChoiceValue::String(string_choice) = &choice.value {
391                        characters += longest_localization_characters(
392                            &choice.name,
393                            choice.name_localizations.as_ref(),
394                        ) + string_choice.chars().count();
395                    }
396                }
397            }
398        }
399        CommandOptionType::SubCommandGroup | CommandOptionType::SubCommand => {
400            if let Some(options) = option.options.as_ref() {
401                for option in options {
402                    characters += option_characters(option);
403                }
404            }
405        }
406        _ => {}
407    }
408
409    characters
410}
411
412/// Calculate the characters for the longest name/description.
413///
414/// Discord only counts the longest localization to the character
415/// limit. If the default value is longer than any of the
416/// localizations, the length of the default value will be used
417/// instead.
418fn longest_localization_characters(
419    default: &str,
420    localizations: Option<&HashMap<String, String>>,
421) -> usize {
422    let mut characters = default.chars().count();
423
424    if let Some(localizations) = localizations {
425        for localization in localizations.values() {
426            let len = localization.chars().count();
427            if len > characters {
428                characters = len;
429            }
430        }
431    }
432
433    characters
434}
435
436/// Validate the description of a [`Command`].
437///
438/// The length of the description must be more than [`DESCRIPTION_LENGTH_MIN`]
439/// and less than or equal to [`DESCRIPTION_LENGTH_MAX`].
440///
441/// # Errors
442///
443/// Returns an error of type [`DescriptionInvalid`] if the description is
444/// invalid.
445///
446/// [`DescriptionInvalid`]: CommandValidationErrorType::DescriptionInvalid
447pub fn description(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
448    let len = value.as_ref().chars().count();
449
450    // https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-structure
451    if (DESCRIPTION_LENGTH_MIN..=DESCRIPTION_LENGTH_MAX).contains(&len) {
452        Ok(())
453    } else {
454        Err(CommandValidationError {
455            kind: CommandValidationErrorType::DescriptionInvalid,
456        })
457    }
458}
459
460/// Validate the name of a [`User`] or [`Message`] command.
461///
462/// The length of the name must be more than [`NAME_LENGTH_MIN`] and less than
463/// or equal to [`NAME_LENGTH_MAX`].
464///
465/// Use [`chat_input_name`] to validate name of a [`ChatInput`] command.
466///
467/// # Errors
468///
469/// Returns an error of type [`NameLengthInvalid`] if the name is invalid.
470///
471/// [`User`]: CommandType::User
472/// [`Message`]: CommandType::Message
473/// [`ChatInput`]: CommandType::ChatInput
474/// [`NameLengthInvalid`]: CommandValidationErrorType::NameLengthInvalid
475pub fn name(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
476    let len = value.as_ref().chars().count();
477
478    // https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-structure
479    if (NAME_LENGTH_MIN..=NAME_LENGTH_MAX).contains(&len) {
480        Ok(())
481    } else {
482        Err(CommandValidationError {
483            kind: CommandValidationErrorType::NameLengthInvalid,
484        })
485    }
486}
487
488/// Validate the name of a [`ChatInput`] command.
489///
490/// The length of the name must be more than [`NAME_LENGTH_MIN`] and less than
491/// or equal to [`NAME_LENGTH_MAX`]. It can only contain alphanumeric characters
492/// and lowercase variants must be used where possible. Special characters `-`
493/// and `_` are allowed.
494///
495/// # Errors
496///
497/// Returns an error of type [`NameLengthInvalid`] if the length is invalid.
498///
499/// Returns an error of type [`NameCharacterInvalid`] if the name contains a
500/// non-alphanumeric character or an uppercase character for which a lowercase
501/// variant exists.
502///
503/// [`ChatInput`]: CommandType::ChatInput
504/// [`NameLengthInvalid`]: CommandValidationErrorType::NameLengthInvalid
505/// [`NameCharacterInvalid`]: CommandValidationErrorType::NameCharacterInvalid
506pub fn chat_input_name(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
507    self::name(&value)?;
508
509    self::name_characters(value)?;
510
511    Ok(())
512}
513
514/// Validate the name of a [`CommandOption`].
515///
516/// The length of the name must be more than [`NAME_LENGTH_MIN`] and less than
517/// or equal to [`NAME_LENGTH_MAX`]. It can only contain alphanumeric characters
518/// and lowercase variants must be used where possible. Special characters `-`
519/// and `_` are allowed.
520///
521/// # Errors
522///
523/// Returns an error of type [`NameLengthInvalid`] if the length is invalid.
524///
525/// Returns an error of type [`NameCharacterInvalid`] if the name contains a
526/// non-alphanumeric character or an uppercase character for which a lowercase
527/// variant exists.
528///
529/// [`NameLengthInvalid`]: CommandValidationErrorType::NameLengthInvalid
530/// [`NameCharacterInvalid`]: CommandValidationErrorType::NameCharacterInvalid
531pub fn option_name(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
532    let len = value.as_ref().chars().count();
533
534    if !(OPTION_NAME_LENGTH_MIN..=OPTION_NAME_LENGTH_MAX).contains(&len) {
535        return Err(CommandValidationError {
536            kind: CommandValidationErrorType::NameLengthInvalid,
537        });
538    }
539
540    self::name_characters(value)?;
541
542    Ok(())
543}
544
545/// Validate the characters of a [`ChatInput`] command name or a
546/// [`CommandOption`] name.
547///
548/// The name can only contain alphanumeric characters and lowercase variants
549/// must be used where possible. Special characters `-` and `_` are allowed.
550///
551/// # Errors
552///
553/// Returns an error of type [`NameCharacterInvalid`] if the name contains a
554/// non-alphanumeric character or an uppercase character for which a lowercase
555/// variant exists.
556///
557/// [`ChatInput`]: CommandType::ChatInput
558/// [`NameCharacterInvalid`]: CommandValidationErrorType::NameCharacterInvalid
559fn name_characters(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
560    let chars = value.as_ref().chars();
561
562    for char in chars {
563        if !char.is_alphanumeric() && char != '_' && char != '-' {
564            return Err(CommandValidationError {
565                kind: CommandValidationErrorType::NameCharacterInvalid { character: char },
566            });
567        }
568
569        if char.to_lowercase().next() != Some(char) {
570            return Err(CommandValidationError {
571                kind: CommandValidationErrorType::NameCharacterInvalid { character: char },
572            });
573        }
574    }
575
576    Ok(())
577}
578
579/// Validate a single name localization in a [`CommandOptionChoice`].
580///
581/// # Errors
582///
583/// Returns an error of type [`OptionChoiceNameLengthInvalid`] if the name is
584/// less than [`OPTION_CHOICE_NAME_LENGTH_MIN`] or more than [`OPTION_CHOICE_NAME_LENGTH_MAX`].
585///
586/// [`OptionChoiceNameLengthInvalid`]: CommandValidationErrorType::OptionChoiceNameLengthInvalid
587pub fn choice_name(name: &str) -> Result<(), CommandValidationError> {
588    let len = name.chars().count();
589
590    if (OPTION_CHOICE_NAME_LENGTH_MIN..=OPTION_CHOICE_NAME_LENGTH_MAX).contains(&len) {
591        Ok(())
592    } else {
593        Err(CommandValidationError {
594            kind: CommandValidationErrorType::OptionChoiceNameLengthInvalid,
595        })
596    }
597}
598
599/// Validate a single [`CommandOptionChoice`].
600///
601/// # Errors
602///
603/// Returns an error of type [`OptionChoiceNameLengthInvalid`] if the name is
604/// less than [`OPTION_CHOICE_NAME_LENGTH_MIN`] or more than [`OPTION_CHOICE_NAME_LENGTH_MAX`].
605///
606/// [`OptionChoiceNameLengthInvalid`]: CommandValidationErrorType::OptionChoiceNameLengthInvalid
607pub fn choice(choice: &CommandOptionChoice) -> Result<(), CommandValidationError> {
608    self::choice_name(&choice.name)?;
609
610    if let CommandOptionChoiceValue::String(value) = &choice.value {
611        let value_len = value.chars().count();
612
613        if !(OPTION_CHOICE_STRING_VALUE_LENGTH_MIN..=OPTION_CHOICE_STRING_VALUE_LENGTH_MAX)
614            .contains(&value_len)
615        {
616            return Err(CommandValidationError {
617                kind: CommandValidationErrorType::OptionChoiceStringValueLengthInvalid,
618            });
619        }
620    }
621
622    if let Some(name_localizations) = &choice.name_localizations {
623        name_localizations
624            .values()
625            .try_for_each(|name| self::choice_name(name))?;
626    }
627
628    Ok(())
629}
630
631/// Validate a single [`CommandOption`].
632///
633/// # Errors
634///
635/// Returns an error of type [`OptionDescriptionInvalid`] if the description is
636/// invalid.
637///
638/// Returns an error of type [`OptionNameLengthInvalid`] or [`OptionNameCharacterInvalid`]
639/// if the name is invalid.
640///
641/// [`OptionDescriptionInvalid`]: CommandValidationErrorType::OptionDescriptionInvalid
642/// [`OptionNameLengthInvalid`]: CommandValidationErrorType::OptionNameLengthInvalid
643/// [`OptionNameCharacterInvalid`]: CommandValidationErrorType::OptionNameCharacterInvalid
644pub fn option(option: &CommandOption) -> Result<(), CommandValidationError> {
645    let description_len = option.description.chars().count();
646    if !(OPTION_DESCRIPTION_LENGTH_MIN..=OPTION_DESCRIPTION_LENGTH_MAX).contains(&description_len) {
647        return Err(CommandValidationError {
648            kind: CommandValidationErrorType::OptionDescriptionInvalid,
649        });
650    }
651
652    if let Some(choices) = &option.choices {
653        choices.iter().try_for_each(self::choice)?;
654    }
655
656    self::option_name(&option.name)
657}
658
659/// Validate a list of command options for count, order, and internal validity.
660///
661/// # Errors
662///
663/// Returns an error of type [`OptionsRequiredFirst`] if a required option is
664/// listed before an optional option.
665///
666/// Returns an error of type [`OptionsCountInvalid`] if the list of options or
667/// any sub-list of options is too long.
668///
669/// [`OptionsRequiredFirst`]: CommandValidationErrorType::OptionsRequiredFirst
670/// [`OptionsCountInvalid`]: CommandValidationErrorType::OptionsCountInvalid
671pub fn options(options: &[CommandOption]) -> Result<(), CommandValidationError> {
672    // https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure
673    if options.len() > OPTIONS_LIMIT {
674        return Err(CommandValidationError {
675            kind: CommandValidationErrorType::OptionsCountInvalid,
676        });
677    }
678
679    let mut names = HashSet::with_capacity(options.len());
680
681    for (option_index, option) in options.iter().enumerate() {
682        if !names.insert(&option.name) {
683            return Err(CommandValidationError::option_name_not_unique(option_index));
684        }
685    }
686
687    // Validate that there are no required options listed after optional ones.
688    options
689        .iter()
690        .zip(options.iter().skip(1))
691        .enumerate()
692        .try_for_each(|(index, (first, second))| {
693            if !first.required.unwrap_or_default() && second.required.unwrap_or_default() {
694                Err(CommandValidationError::option_required_first(index))
695            } else {
696                Ok(())
697            }
698        })?;
699
700    // Validate that each option is correct.
701    options.iter().try_for_each(|option| {
702        if let Some(options) = &option.options {
703            self::options(options)
704        } else {
705            self::option(option)
706        }
707    })?;
708
709    Ok(())
710}
711
712/// Validate the number of guild command permission overwrites.
713///
714/// The maximum number of commands allowed in a guild is defined by
715/// [`GUILD_COMMAND_PERMISSION_LIMIT`].
716///
717/// # Errors
718///
719/// Returns an error of type [`PermissionsCountInvalid`] if the permissions are
720/// invalid.
721///
722/// [`PermissionsCountInvalid`]: CommandValidationErrorType::PermissionsCountInvalid
723pub const fn guild_permissions(count: usize) -> Result<(), CommandValidationError> {
724    // https://discord.com/developers/docs/interactions/application-commands#registering-a-command
725    if count <= GUILD_COMMAND_PERMISSION_LIMIT {
726        Ok(())
727    } else {
728        Err(CommandValidationError {
729            kind: CommandValidationErrorType::PermissionsCountInvalid,
730        })
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use twilight_model::id::Id;
738
739    #[test]
740    fn choice_name_limit() {
741        let valid_choice = CommandOptionChoice {
742            name: "a".repeat(100),
743            name_localizations: None,
744            value: CommandOptionChoiceValue::String("a".to_string()),
745        };
746
747        assert!(choice(&valid_choice).is_ok());
748
749        let invalid_choice = CommandOptionChoice {
750            name: "a".repeat(101),
751            name_localizations: None,
752            value: CommandOptionChoiceValue::String("b".to_string()),
753        };
754
755        assert!(choice(&invalid_choice).is_err());
756
757        let invalid_choice = CommandOptionChoice {
758            name: String::new(),
759            name_localizations: None,
760            value: CommandOptionChoiceValue::String("c".to_string()),
761        };
762
763        assert!(choice(&invalid_choice).is_err());
764    }
765
766    #[test]
767    fn choice_name_localizations() {
768        let mut name_localizations = HashMap::new();
769        name_localizations.insert("en-US".to_string(), "a".repeat(100));
770
771        let valid_choice = CommandOptionChoice {
772            name: "a".to_string(),
773            name_localizations: Some(name_localizations),
774            value: CommandOptionChoiceValue::String("a".to_string()),
775        };
776
777        assert!(choice(&valid_choice).is_ok());
778
779        let mut name_localizations = HashMap::new();
780        name_localizations.insert("en-US".to_string(), "a".repeat(101));
781
782        let invalid_choice = CommandOptionChoice {
783            name: "a".to_string(),
784            name_localizations: Some(name_localizations),
785            value: CommandOptionChoiceValue::String("b".to_string()),
786        };
787
788        assert!(choice(&invalid_choice).is_err());
789
790        let mut name_localizations = HashMap::new();
791        name_localizations.insert("en-US".to_string(), String::new());
792
793        let invalid_choice = CommandOptionChoice {
794            name: "a".to_string(),
795            name_localizations: Some(name_localizations),
796            value: CommandOptionChoiceValue::String("c".to_string()),
797        };
798
799        assert!(choice(&invalid_choice).is_err());
800
801        let mut name_localizations = HashMap::new();
802        name_localizations.insert("en-US".to_string(), String::from("a"));
803        name_localizations.insert("en-GB".to_string(), "a".repeat(101));
804        name_localizations.insert("es-ES".to_string(), "a".repeat(100));
805
806        let invalid_choice = CommandOptionChoice {
807            name: "a".to_string(),
808            name_localizations: Some(name_localizations),
809            value: CommandOptionChoiceValue::String("c".to_string()),
810        };
811
812        assert!(choice(&invalid_choice).is_err());
813    }
814
815    #[test]
816    fn choice_string_value() {
817        let valid_choice = CommandOptionChoice {
818            name: "a".to_string(),
819            name_localizations: None,
820            value: CommandOptionChoiceValue::String("a".to_string()),
821        };
822
823        assert!(choice(&valid_choice).is_ok());
824
825        let invalid_choice = CommandOptionChoice {
826            name: "b".to_string(),
827            name_localizations: None,
828            value: CommandOptionChoiceValue::String("b".repeat(101)),
829        };
830
831        assert!(choice(&invalid_choice).is_err());
832
833        let invalid_choice = CommandOptionChoice {
834            name: "c".to_string(),
835            name_localizations: None,
836            value: CommandOptionChoiceValue::String(String::new()),
837        };
838
839        assert!(choice(&invalid_choice).is_err());
840    }
841
842    // This tests [`description`] and [`name`] by proxy.
843    #[test]
844    #[allow(deprecated)]
845    fn command_length() {
846        let valid_command = Command {
847            application_id: Some(Id::new(1)),
848            contexts: None,
849            default_member_permissions: None,
850            dm_permission: None,
851            description: "a".repeat(100),
852            description_localizations: Some(HashMap::from([(
853                "en-US".to_string(),
854                "a".repeat(100),
855            )])),
856            guild_id: Some(Id::new(2)),
857            id: Some(Id::new(3)),
858            integration_types: None,
859            kind: CommandType::ChatInput,
860            name: "b".repeat(32),
861            name_localizations: Some(HashMap::from([("en-US".to_string(), "b".repeat(32))])),
862            nsfw: None,
863            options: Vec::new(),
864            version: Id::new(4),
865        };
866
867        assert!(command(&valid_command).is_ok());
868
869        let invalid_message_command = Command {
870            description: "c".repeat(101),
871            name: "d".repeat(33),
872            ..valid_command.clone()
873        };
874        assert!(command(&invalid_message_command).is_err());
875
876        let valid_context_menu_command = Command {
877            description: String::new(),
878            kind: CommandType::Message,
879            ..valid_command.clone()
880        };
881
882        assert!(command(&valid_context_menu_command).is_ok());
883
884        let invalid_context_menu_command = Command {
885            description: "example description".to_string(),
886            kind: CommandType::Message,
887            ..valid_command
888        };
889
890        assert!(command(&invalid_context_menu_command).is_err());
891    }
892
893    #[test]
894    fn name_allowed_characters() {
895        assert!(name_characters("hello-command").is_ok()); // Latin language
896        assert!(name_characters("Hello").is_err()); // Latin language with uppercase
897        assert!(name_characters("hello!").is_err()); // Latin language with non-alphanumeric
898
899        assert!(name_characters("здрасти").is_ok()); // Russian
900        assert!(name_characters("Здрасти").is_err()); // Russian with uppercase
901        assert!(name_characters("здрасти!").is_err()); // Russian with non-alphanumeric
902
903        assert!(name_characters("你好").is_ok()); // Chinese (no upper and lowercase variants)
904        assert!(name_characters("你好。").is_err()); // Chinese with non-alphanumeric
905    }
906
907    #[test]
908    fn guild_permissions_count() {
909        assert!(guild_permissions(0).is_ok());
910        assert!(guild_permissions(1).is_ok());
911        assert!(guild_permissions(10).is_ok());
912
913        assert!(guild_permissions(11).is_err());
914    }
915
916    #[test]
917    #[allow(deprecated)]
918    fn command_combined_limit() {
919        let mut command = Command {
920            application_id: Some(Id::new(1)),
921            default_member_permissions: None,
922            dm_permission: None,
923            description: "a".repeat(10),
924            description_localizations: Some(HashMap::from([(
925                "en-US".to_string(),
926                "a".repeat(100),
927            )])),
928            guild_id: Some(Id::new(2)),
929            id: Some(Id::new(3)),
930            kind: CommandType::ChatInput,
931            name: "b".repeat(10),
932            name_localizations: Some(HashMap::from([("en-US".to_string(), "b".repeat(32))])),
933            nsfw: None,
934            options: Vec::from([CommandOption {
935                autocomplete: None,
936                channel_types: None,
937                choices: None,
938                description: "a".repeat(10),
939                description_localizations: Some(HashMap::from([(
940                    "en-US".to_string(),
941                    "a".repeat(100),
942                )])),
943                kind: CommandOptionType::SubCommandGroup,
944                max_length: None,
945                max_value: None,
946                min_length: None,
947                min_value: None,
948                name: "b".repeat(10),
949                name_localizations: Some(HashMap::from([("en-US".to_string(), "b".repeat(32))])),
950                options: Some(Vec::from([CommandOption {
951                    autocomplete: None,
952                    channel_types: None,
953                    choices: None,
954                    description: "a".repeat(100),
955                    description_localizations: Some(HashMap::from([(
956                        "en-US".to_string(),
957                        "a".repeat(10),
958                    )])),
959                    kind: CommandOptionType::SubCommand,
960                    max_length: None,
961                    max_value: None,
962                    min_length: None,
963                    min_value: None,
964                    name: "b".repeat(32),
965                    name_localizations: Some(HashMap::from([(
966                        "en-US".to_string(),
967                        "b".repeat(10),
968                    )])),
969                    options: Some(Vec::from([CommandOption {
970                        autocomplete: Some(false),
971                        channel_types: None,
972                        choices: Some(Vec::from([CommandOptionChoice {
973                            name: "b".repeat(32),
974                            name_localizations: Some(HashMap::from([(
975                                "en-US".to_string(),
976                                "b".repeat(10),
977                            )])),
978                            value: CommandOptionChoiceValue::String("c".repeat(100)),
979                        }])),
980                        description: "a".repeat(100),
981                        description_localizations: Some(HashMap::from([(
982                            "en-US".to_string(),
983                            "a".repeat(10),
984                        )])),
985                        kind: CommandOptionType::String,
986                        max_length: None,
987                        max_value: None,
988                        min_length: None,
989                        min_value: None,
990                        name: "b".repeat(32),
991                        name_localizations: Some(HashMap::from([(
992                            "en-US".to_string(),
993                            "b".repeat(10),
994                        )])),
995                        options: None,
996                        required: Some(false),
997                    }])),
998                    required: None,
999                }])),
1000                required: None,
1001            }]),
1002            version: Id::new(4),
1003            contexts: None,
1004            integration_types: None,
1005        };
1006
1007        assert_eq!(command_characters(&command), 660);
1008        assert!(super::command(&command).is_ok());
1009
1010        command.description = "a".repeat(3441);
1011        assert_eq!(command_characters(&command), 4001);
1012
1013        assert!(matches!(
1014            super::command(&command).unwrap_err().kind(),
1015            CommandValidationErrorType::CommandTooLarge { characters: 4001 }
1016        ));
1017    }
1018
1019    /// Assert that a list of options can't contain the same name.
1020    #[test]
1021    fn option_name_uniqueness() {
1022        let option = CommandOption {
1023            autocomplete: None,
1024            channel_types: None,
1025            choices: None,
1026            description: "a description".to_owned(),
1027            description_localizations: None,
1028            kind: CommandOptionType::String,
1029            max_length: None,
1030            max_value: None,
1031            min_length: None,
1032            min_value: None,
1033            name: "name".to_owned(),
1034            name_localizations: None,
1035            options: None,
1036            required: None,
1037        };
1038        let mut options = Vec::from([option.clone()]);
1039        assert!(super::options(&options).is_ok());
1040        options.push(option);
1041        assert!(matches!(super::options(&options).unwrap_err().kind(),
1042            CommandValidationErrorType::OptionNameNotUnique { option_index } if *option_index == 1));
1043    }
1044
1045    /// Test if option description length is checked properly
1046    #[test]
1047    fn option_description_length() {
1048        let base = CommandOption {
1049            autocomplete: None,
1050            channel_types: None,
1051            choices: None,
1052            description: String::new(),
1053            description_localizations: None,
1054            kind: CommandOptionType::Boolean,
1055            max_length: None,
1056            max_value: None,
1057            min_length: None,
1058            min_value: None,
1059            name: "testcommand".to_string(),
1060            name_localizations: None,
1061            options: None,
1062            required: None,
1063        };
1064        let toolong = CommandOption {
1065            description: "e".repeat(OPTION_DESCRIPTION_LENGTH_MAX + 1),
1066            ..base.clone()
1067        };
1068        let tooshort = CommandOption {
1069            description: "e".repeat(OPTION_DESCRIPTION_LENGTH_MIN - 1),
1070            ..base.clone()
1071        };
1072        let maxlen = CommandOption {
1073            description: "e".repeat(OPTION_DESCRIPTION_LENGTH_MAX),
1074            ..base.clone()
1075        };
1076        // clippy yells at us if this value is 1, but just using to_string would be incorrect
1077        #[allow(clippy::repeat_once)]
1078        let minlen = CommandOption {
1079            description: "e".repeat(OPTION_DESCRIPTION_LENGTH_MIN),
1080            ..base
1081        };
1082        assert!(option(&toolong).is_err());
1083        assert!(option(&tooshort).is_err());
1084        assert!(option(&maxlen).is_ok());
1085        assert!(option(&minlen).is_ok());
1086    }
1087}