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.len();
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.len();
423
424    if let Some(localizations) = localizations {
425        for localization in localizations.values() {
426            if localization.len() > characters {
427                characters = localization.len();
428            }
429        }
430    }
431
432    characters
433}
434
435/// Validate the description of a [`Command`].
436///
437/// The length of the description must be more than [`DESCRIPTION_LENGTH_MIN`]
438/// and less than or equal to [`DESCRIPTION_LENGTH_MAX`].
439///
440/// # Errors
441///
442/// Returns an error of type [`DescriptionInvalid`] if the description is
443/// invalid.
444///
445/// [`DescriptionInvalid`]: CommandValidationErrorType::DescriptionInvalid
446pub fn description(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
447    let len = value.as_ref().chars().count();
448
449    // https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-structure
450    if (DESCRIPTION_LENGTH_MIN..=DESCRIPTION_LENGTH_MAX).contains(&len) {
451        Ok(())
452    } else {
453        Err(CommandValidationError {
454            kind: CommandValidationErrorType::DescriptionInvalid,
455        })
456    }
457}
458
459/// Validate the name of a [`User`] or [`Message`] command.
460///
461/// The length of the name must be more than [`NAME_LENGTH_MIN`] and less than
462/// or equal to [`NAME_LENGTH_MAX`].
463///
464/// Use [`chat_input_name`] to validate name of a [`ChatInput`] command.
465///
466/// # Errors
467///
468/// Returns an error of type [`NameLengthInvalid`] if the name is invalid.
469///
470/// [`User`]: CommandType::User
471/// [`Message`]: CommandType::Message
472/// [`ChatInput`]: CommandType::ChatInput
473/// [`NameLengthInvalid`]: CommandValidationErrorType::NameLengthInvalid
474pub fn name(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
475    let len = value.as_ref().chars().count();
476
477    // https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-structure
478    if (NAME_LENGTH_MIN..=NAME_LENGTH_MAX).contains(&len) {
479        Ok(())
480    } else {
481        Err(CommandValidationError {
482            kind: CommandValidationErrorType::NameLengthInvalid,
483        })
484    }
485}
486
487/// Validate the name of a [`ChatInput`] command.
488///
489/// The length of the name must be more than [`NAME_LENGTH_MIN`] and less than
490/// or equal to [`NAME_LENGTH_MAX`]. It can only contain alphanumeric characters
491/// and lowercase variants must be used where possible. Special characters `-`
492/// and `_` are allowed.
493///
494/// # Errors
495///
496/// Returns an error of type [`NameLengthInvalid`] if the length is invalid.
497///
498/// Returns an error of type [`NameCharacterInvalid`] if the name contains a
499/// non-alphanumeric character or an uppercase character for which a lowercase
500/// variant exists.
501///
502/// [`ChatInput`]: CommandType::ChatInput
503/// [`NameLengthInvalid`]: CommandValidationErrorType::NameLengthInvalid
504/// [`NameCharacterInvalid`]: CommandValidationErrorType::NameCharacterInvalid
505pub fn chat_input_name(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
506    self::name(&value)?;
507
508    self::name_characters(value)?;
509
510    Ok(())
511}
512
513/// Validate the name of a [`CommandOption`].
514///
515/// The length of the name must be more than [`NAME_LENGTH_MIN`] and less than
516/// or equal to [`NAME_LENGTH_MAX`]. It can only contain alphanumeric characters
517/// and lowercase variants must be used where possible. Special characters `-`
518/// and `_` are allowed.
519///
520/// # Errors
521///
522/// Returns an error of type [`NameLengthInvalid`] if the length is invalid.
523///
524/// Returns an error of type [`NameCharacterInvalid`] if the name contains a
525/// non-alphanumeric character or an uppercase character for which a lowercase
526/// variant exists.
527///
528/// [`NameLengthInvalid`]: CommandValidationErrorType::NameLengthInvalid
529/// [`NameCharacterInvalid`]: CommandValidationErrorType::NameCharacterInvalid
530pub fn option_name(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
531    let len = value.as_ref().chars().count();
532
533    if !(OPTION_NAME_LENGTH_MIN..=OPTION_NAME_LENGTH_MAX).contains(&len) {
534        return Err(CommandValidationError {
535            kind: CommandValidationErrorType::NameLengthInvalid,
536        });
537    }
538
539    self::name_characters(value)?;
540
541    Ok(())
542}
543
544/// Validate the characters of a [`ChatInput`] command name or a
545/// [`CommandOption`] name.
546///
547/// The name can only contain alphanumeric characters and lowercase variants
548/// must be used where possible. Special characters `-` and `_` are allowed.
549///
550/// # Errors
551///
552/// Returns an error of type [`NameCharacterInvalid`] if the name contains a
553/// non-alphanumeric character or an uppercase character for which a lowercase
554/// variant exists.
555///
556/// [`ChatInput`]: CommandType::ChatInput
557/// [`NameCharacterInvalid`]: CommandValidationErrorType::NameCharacterInvalid
558fn name_characters(value: impl AsRef<str>) -> Result<(), CommandValidationError> {
559    let chars = value.as_ref().chars();
560
561    for char in chars {
562        if !char.is_alphanumeric() && char != '_' && char != '-' {
563            return Err(CommandValidationError {
564                kind: CommandValidationErrorType::NameCharacterInvalid { character: char },
565            });
566        }
567
568        if char.to_lowercase().next() != Some(char) {
569            return Err(CommandValidationError {
570                kind: CommandValidationErrorType::NameCharacterInvalid { character: char },
571            });
572        }
573    }
574
575    Ok(())
576}
577
578/// Validate a single name localization in a [`CommandOptionChoice`].
579///
580/// # Errors
581///
582/// Returns an error of type [`OptionChoiceNameLengthInvalid`] if the name is
583/// less than [`OPTION_CHOICE_NAME_LENGTH_MIN`] or more than [`OPTION_CHOICE_NAME_LENGTH_MAX`].
584///
585/// [`OptionChoiceNameLengthInvalid`]: CommandValidationErrorType::OptionChoiceNameLengthInvalid
586pub fn choice_name(name: &str) -> Result<(), CommandValidationError> {
587    let len = name.chars().count();
588
589    if (OPTION_CHOICE_NAME_LENGTH_MIN..=OPTION_CHOICE_NAME_LENGTH_MAX).contains(&len) {
590        Ok(())
591    } else {
592        Err(CommandValidationError {
593            kind: CommandValidationErrorType::OptionChoiceNameLengthInvalid,
594        })
595    }
596}
597
598/// Validate a single [`CommandOptionChoice`].
599///
600/// # Errors
601///
602/// Returns an error of type [`OptionChoiceNameLengthInvalid`] if the name is
603/// less than [`OPTION_CHOICE_NAME_LENGTH_MIN`] or more than [`OPTION_CHOICE_NAME_LENGTH_MAX`].
604///
605/// [`OptionChoiceNameLengthInvalid`]: CommandValidationErrorType::OptionChoiceNameLengthInvalid
606pub fn choice(choice: &CommandOptionChoice) -> Result<(), CommandValidationError> {
607    self::choice_name(&choice.name)?;
608
609    if let CommandOptionChoiceValue::String(value) = &choice.value {
610        let value_len = value.chars().count();
611
612        if !(OPTION_CHOICE_STRING_VALUE_LENGTH_MIN..=OPTION_CHOICE_STRING_VALUE_LENGTH_MAX)
613            .contains(&value_len)
614        {
615            return Err(CommandValidationError {
616                kind: CommandValidationErrorType::OptionChoiceStringValueLengthInvalid,
617            });
618        }
619    }
620
621    if let Some(name_localizations) = &choice.name_localizations {
622        name_localizations
623            .values()
624            .try_for_each(|name| self::choice_name(name))?;
625    }
626
627    Ok(())
628}
629
630/// Validate a single [`CommandOption`].
631///
632/// # Errors
633///
634/// Returns an error of type [`OptionDescriptionInvalid`] if the description is
635/// invalid.
636///
637/// Returns an error of type [`OptionNameLengthInvalid`] or [`OptionNameCharacterInvalid`]
638/// if the name is invalid.
639///
640/// [`OptionDescriptionInvalid`]: CommandValidationErrorType::OptionDescriptionInvalid
641/// [`OptionNameLengthInvalid`]: CommandValidationErrorType::OptionNameLengthInvalid
642/// [`OptionNameCharacterInvalid`]: CommandValidationErrorType::OptionNameCharacterInvalid
643pub fn option(option: &CommandOption) -> Result<(), CommandValidationError> {
644    let description_len = option.description.chars().count();
645    if !(OPTION_DESCRIPTION_LENGTH_MIN..=OPTION_DESCRIPTION_LENGTH_MAX).contains(&description_len) {
646        return Err(CommandValidationError {
647            kind: CommandValidationErrorType::OptionDescriptionInvalid,
648        });
649    }
650
651    if let Some(choices) = &option.choices {
652        choices.iter().try_for_each(self::choice)?;
653    }
654
655    self::option_name(&option.name)
656}
657
658/// Validate a list of command options for count, order, and internal validity.
659///
660/// # Errors
661///
662/// Returns an error of type [`OptionsRequiredFirst`] if a required option is
663/// listed before an optional option.
664///
665/// Returns an error of type [`OptionsCountInvalid`] if the list of options or
666/// any sub-list of options is too long.
667///
668/// [`OptionsRequiredFirst`]: CommandValidationErrorType::OptionsRequiredFirst
669/// [`OptionsCountInvalid`]: CommandValidationErrorType::OptionsCountInvalid
670pub fn options(options: &[CommandOption]) -> Result<(), CommandValidationError> {
671    // https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure
672    if options.len() > OPTIONS_LIMIT {
673        return Err(CommandValidationError {
674            kind: CommandValidationErrorType::OptionsCountInvalid,
675        });
676    }
677
678    let mut names = HashSet::with_capacity(options.len());
679
680    for (option_index, option) in options.iter().enumerate() {
681        if !names.insert(&option.name) {
682            return Err(CommandValidationError::option_name_not_unique(option_index));
683        }
684    }
685
686    // Validate that there are no required options listed after optional ones.
687    options
688        .iter()
689        .zip(options.iter().skip(1))
690        .enumerate()
691        .try_for_each(|(index, (first, second))| {
692            if !first.required.unwrap_or_default() && second.required.unwrap_or_default() {
693                Err(CommandValidationError::option_required_first(index))
694            } else {
695                Ok(())
696            }
697        })?;
698
699    // Validate that each option is correct.
700    options.iter().try_for_each(|option| {
701        if let Some(options) = &option.options {
702            self::options(options)
703        } else {
704            self::option(option)
705        }
706    })?;
707
708    Ok(())
709}
710
711/// Validate the number of guild command permission overwrites.
712///
713/// The maximum number of commands allowed in a guild is defined by
714/// [`GUILD_COMMAND_PERMISSION_LIMIT`].
715///
716/// # Errors
717///
718/// Returns an error of type [`PermissionsCountInvalid`] if the permissions are
719/// invalid.
720///
721/// [`PermissionsCountInvalid`]: CommandValidationErrorType::PermissionsCountInvalid
722pub const fn guild_permissions(count: usize) -> Result<(), CommandValidationError> {
723    // https://discord.com/developers/docs/interactions/application-commands#registering-a-command
724    if count <= GUILD_COMMAND_PERMISSION_LIMIT {
725        Ok(())
726    } else {
727        Err(CommandValidationError {
728            kind: CommandValidationErrorType::PermissionsCountInvalid,
729        })
730    }
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use twilight_model::id::Id;
737
738    #[test]
739    fn choice_name_limit() {
740        let valid_choice = CommandOptionChoice {
741            name: "a".repeat(100),
742            name_localizations: None,
743            value: CommandOptionChoiceValue::String("a".to_string()),
744        };
745
746        assert!(choice(&valid_choice).is_ok());
747
748        let invalid_choice = CommandOptionChoice {
749            name: "a".repeat(101),
750            name_localizations: None,
751            value: CommandOptionChoiceValue::String("b".to_string()),
752        };
753
754        assert!(choice(&invalid_choice).is_err());
755
756        let invalid_choice = CommandOptionChoice {
757            name: String::new(),
758            name_localizations: None,
759            value: CommandOptionChoiceValue::String("c".to_string()),
760        };
761
762        assert!(choice(&invalid_choice).is_err());
763    }
764
765    #[test]
766    fn choice_name_localizations() {
767        let mut name_localizations = HashMap::new();
768        name_localizations.insert("en-US".to_string(), "a".repeat(100));
769
770        let valid_choice = CommandOptionChoice {
771            name: "a".to_string(),
772            name_localizations: Some(name_localizations),
773            value: CommandOptionChoiceValue::String("a".to_string()),
774        };
775
776        assert!(choice(&valid_choice).is_ok());
777
778        let mut name_localizations = HashMap::new();
779        name_localizations.insert("en-US".to_string(), "a".repeat(101));
780
781        let invalid_choice = CommandOptionChoice {
782            name: "a".to_string(),
783            name_localizations: Some(name_localizations),
784            value: CommandOptionChoiceValue::String("b".to_string()),
785        };
786
787        assert!(choice(&invalid_choice).is_err());
788
789        let mut name_localizations = HashMap::new();
790        name_localizations.insert("en-US".to_string(), String::new());
791
792        let invalid_choice = CommandOptionChoice {
793            name: "a".to_string(),
794            name_localizations: Some(name_localizations),
795            value: CommandOptionChoiceValue::String("c".to_string()),
796        };
797
798        assert!(choice(&invalid_choice).is_err());
799
800        let mut name_localizations = HashMap::new();
801        name_localizations.insert("en-US".to_string(), String::from("a"));
802        name_localizations.insert("en-GB".to_string(), "a".repeat(101));
803        name_localizations.insert("es-ES".to_string(), "a".repeat(100));
804
805        let invalid_choice = CommandOptionChoice {
806            name: "a".to_string(),
807            name_localizations: Some(name_localizations),
808            value: CommandOptionChoiceValue::String("c".to_string()),
809        };
810
811        assert!(choice(&invalid_choice).is_err());
812    }
813
814    #[test]
815    fn choice_string_value() {
816        let valid_choice = CommandOptionChoice {
817            name: "a".to_string(),
818            name_localizations: None,
819            value: CommandOptionChoiceValue::String("a".to_string()),
820        };
821
822        assert!(choice(&valid_choice).is_ok());
823
824        let invalid_choice = CommandOptionChoice {
825            name: "b".to_string(),
826            name_localizations: None,
827            value: CommandOptionChoiceValue::String("b".repeat(101)),
828        };
829
830        assert!(choice(&invalid_choice).is_err());
831
832        let invalid_choice = CommandOptionChoice {
833            name: "c".to_string(),
834            name_localizations: None,
835            value: CommandOptionChoiceValue::String(String::new()),
836        };
837
838        assert!(choice(&invalid_choice).is_err());
839    }
840
841    // This tests [`description`] and [`name`] by proxy.
842    #[test]
843    #[allow(deprecated)]
844    fn command_length() {
845        let valid_command = Command {
846            application_id: Some(Id::new(1)),
847            contexts: None,
848            default_member_permissions: None,
849            dm_permission: None,
850            description: "a".repeat(100),
851            description_localizations: Some(HashMap::from([(
852                "en-US".to_string(),
853                "a".repeat(100),
854            )])),
855            guild_id: Some(Id::new(2)),
856            id: Some(Id::new(3)),
857            integration_types: None,
858            kind: CommandType::ChatInput,
859            name: "b".repeat(32),
860            name_localizations: Some(HashMap::from([("en-US".to_string(), "b".repeat(32))])),
861            nsfw: None,
862            options: Vec::new(),
863            version: Id::new(4),
864        };
865
866        assert!(command(&valid_command).is_ok());
867
868        let invalid_message_command = Command {
869            description: "c".repeat(101),
870            name: "d".repeat(33),
871            ..valid_command.clone()
872        };
873        assert!(command(&invalid_message_command).is_err());
874
875        let valid_context_menu_command = Command {
876            description: String::new(),
877            kind: CommandType::Message,
878            ..valid_command.clone()
879        };
880
881        assert!(command(&valid_context_menu_command).is_ok());
882
883        let invalid_context_menu_command = Command {
884            description: "example description".to_string(),
885            kind: CommandType::Message,
886            ..valid_command
887        };
888
889        assert!(command(&invalid_context_menu_command).is_err());
890    }
891
892    #[test]
893    fn name_allowed_characters() {
894        assert!(name_characters("hello-command").is_ok()); // Latin language
895        assert!(name_characters("Hello").is_err()); // Latin language with uppercase
896        assert!(name_characters("hello!").is_err()); // Latin language with non-alphanumeric
897
898        assert!(name_characters("здрасти").is_ok()); // Russian
899        assert!(name_characters("Здрасти").is_err()); // Russian with uppercase
900        assert!(name_characters("здрасти!").is_err()); // Russian with non-alphanumeric
901
902        assert!(name_characters("你好").is_ok()); // Chinese (no upper and lowercase variants)
903        assert!(name_characters("你好。").is_err()); // Chinese with non-alphanumeric
904    }
905
906    #[test]
907    fn guild_permissions_count() {
908        assert!(guild_permissions(0).is_ok());
909        assert!(guild_permissions(1).is_ok());
910        assert!(guild_permissions(10).is_ok());
911
912        assert!(guild_permissions(11).is_err());
913    }
914
915    #[test]
916    #[allow(deprecated)]
917    fn command_combined_limit() {
918        let mut command = Command {
919            application_id: Some(Id::new(1)),
920            default_member_permissions: None,
921            dm_permission: None,
922            description: "a".repeat(10),
923            description_localizations: Some(HashMap::from([(
924                "en-US".to_string(),
925                "a".repeat(100),
926            )])),
927            guild_id: Some(Id::new(2)),
928            id: Some(Id::new(3)),
929            kind: CommandType::ChatInput,
930            name: "b".repeat(10),
931            name_localizations: Some(HashMap::from([("en-US".to_string(), "b".repeat(32))])),
932            nsfw: None,
933            options: Vec::from([CommandOption {
934                autocomplete: None,
935                channel_types: None,
936                choices: None,
937                description: "a".repeat(10),
938                description_localizations: Some(HashMap::from([(
939                    "en-US".to_string(),
940                    "a".repeat(100),
941                )])),
942                kind: CommandOptionType::SubCommandGroup,
943                max_length: None,
944                max_value: None,
945                min_length: None,
946                min_value: None,
947                name: "b".repeat(10),
948                name_localizations: Some(HashMap::from([("en-US".to_string(), "b".repeat(32))])),
949                options: Some(Vec::from([CommandOption {
950                    autocomplete: None,
951                    channel_types: None,
952                    choices: None,
953                    description: "a".repeat(100),
954                    description_localizations: Some(HashMap::from([(
955                        "en-US".to_string(),
956                        "a".repeat(10),
957                    )])),
958                    kind: CommandOptionType::SubCommand,
959                    max_length: None,
960                    max_value: None,
961                    min_length: None,
962                    min_value: None,
963                    name: "b".repeat(32),
964                    name_localizations: Some(HashMap::from([(
965                        "en-US".to_string(),
966                        "b".repeat(10),
967                    )])),
968                    options: Some(Vec::from([CommandOption {
969                        autocomplete: Some(false),
970                        channel_types: None,
971                        choices: Some(Vec::from([CommandOptionChoice {
972                            name: "b".repeat(32),
973                            name_localizations: Some(HashMap::from([(
974                                "en-US".to_string(),
975                                "b".repeat(10),
976                            )])),
977                            value: CommandOptionChoiceValue::String("c".repeat(100)),
978                        }])),
979                        description: "a".repeat(100),
980                        description_localizations: Some(HashMap::from([(
981                            "en-US".to_string(),
982                            "a".repeat(10),
983                        )])),
984                        kind: CommandOptionType::String,
985                        max_length: None,
986                        max_value: None,
987                        min_length: None,
988                        min_value: None,
989                        name: "b".repeat(32),
990                        name_localizations: Some(HashMap::from([(
991                            "en-US".to_string(),
992                            "b".repeat(10),
993                        )])),
994                        options: None,
995                        required: Some(false),
996                    }])),
997                    required: None,
998                }])),
999                required: None,
1000            }]),
1001            version: Id::new(4),
1002            contexts: None,
1003            integration_types: None,
1004        };
1005
1006        assert_eq!(command_characters(&command), 660);
1007        assert!(super::command(&command).is_ok());
1008
1009        command.description = "a".repeat(3441);
1010        assert_eq!(command_characters(&command), 4001);
1011
1012        assert!(matches!(
1013            super::command(&command).unwrap_err().kind(),
1014            CommandValidationErrorType::CommandTooLarge { characters: 4001 }
1015        ));
1016    }
1017
1018    /// Assert that a list of options can't contain the same name.
1019    #[test]
1020    fn option_name_uniqueness() {
1021        let option = CommandOption {
1022            autocomplete: None,
1023            channel_types: None,
1024            choices: None,
1025            description: "a description".to_owned(),
1026            description_localizations: None,
1027            kind: CommandOptionType::String,
1028            max_length: None,
1029            max_value: None,
1030            min_length: None,
1031            min_value: None,
1032            name: "name".to_owned(),
1033            name_localizations: None,
1034            options: None,
1035            required: None,
1036        };
1037        let mut options = Vec::from([option.clone()]);
1038        assert!(super::options(&options).is_ok());
1039        options.push(option);
1040        assert!(matches!(super::options(&options).unwrap_err().kind(),
1041            CommandValidationErrorType::OptionNameNotUnique { option_index } if *option_index == 1));
1042    }
1043
1044    /// Test if option description length is checked properly
1045    #[test]
1046    fn option_description_length() {
1047        let base = CommandOption {
1048            autocomplete: None,
1049            channel_types: None,
1050            choices: None,
1051            description: String::new(),
1052            description_localizations: None,
1053            kind: CommandOptionType::Boolean,
1054            max_length: None,
1055            max_value: None,
1056            min_length: None,
1057            min_value: None,
1058            name: "testcommand".to_string(),
1059            name_localizations: None,
1060            options: None,
1061            required: None,
1062        };
1063        let toolong = CommandOption {
1064            description: "e".repeat(OPTION_DESCRIPTION_LENGTH_MAX + 1),
1065            ..base.clone()
1066        };
1067        let tooshort = CommandOption {
1068            description: "e".repeat(OPTION_DESCRIPTION_LENGTH_MIN - 1),
1069            ..base.clone()
1070        };
1071        let maxlen = CommandOption {
1072            description: "e".repeat(OPTION_DESCRIPTION_LENGTH_MAX),
1073            ..base.clone()
1074        };
1075        // clippy yells at us if this value is 1, but just using to_string would be incorrect
1076        #[allow(clippy::repeat_once)]
1077        let minlen = CommandOption {
1078            description: "e".repeat(OPTION_DESCRIPTION_LENGTH_MIN),
1079            ..base
1080        };
1081        assert!(option(&toolong).is_err());
1082        assert!(option(&tooshort).is_err());
1083        assert!(option(&maxlen).is_ok());
1084        assert!(option(&minlen).is_ok());
1085    }
1086}