1use super::{
4 ComponentValidationError, ComponentValidationErrorType, action_row, button,
5 component_custom_id, select_menu, text_input,
6};
7use twilight_model::channel::message::Component;
8use twilight_model::channel::message::component::{
9 Checkbox, CheckboxGroup, ComponentType, Container, FileUpload, Label, MediaGallery,
10 MediaGalleryItem, Section, TextDisplay, Thumbnail,
11};
12
13pub const CHECKBOXGROUP_OPTION_COUNT: usize = 10;
20
21pub const CHECKBOXGROUP_MAXIMUM_VALUES_LIMIT: usize = 10;
29
30pub const CHECKBOXGROUP_MAXIMUM_VALUES_REQUIREMENT: usize = 1;
38
39pub const CHECKBOXGROUP_MINIMUM_VALUES_LIMIT: usize = 10;
47
48pub const TEXT_DISPLAY_CONTENT_LENGTH_MAX: usize = 2000;
50
51pub const MEDIA_GALLERY_ITEMS_MIN: usize = 1;
53
54pub const MEDIA_GALLERY_ITEMS_MAX: usize = 10;
56
57pub const MEDIA_GALLERY_ITEM_DESCRIPTION_LENGTH_MAX: usize = 1024;
59
60pub const SECTION_COMPONENTS_MIN: usize = 1;
62
63pub const SECTION_COMPONENTS_MAX: usize = 3;
65
66pub const THUMBNAIL_DESCRIPTION_LENGTH_MAX: usize = 1024;
68
69pub const LABEL_LABEL_LENGTH_MAX: usize = 45;
71
72pub const LABEL_DESCRIPTION_LENGTH_MAX: usize = 100;
74
75pub const FILE_UPLOAD_MAXIMUM_VALUES_LIMIT: u8 = 10;
79
80pub const FILE_UPLOAD_MINIMUM_VALUES_LIMIT: u8 = 10;
84
85pub const FILE_UPLOAD_FILE_TYPES_LIMIT: usize = 10;
92
93pub fn component_v2(component: &Component) -> Result<(), ComponentValidationError> {
120 match component {
121 Component::ActionRow(ar) => action_row(ar, true)?,
122 Component::Label(l) => label(l)?,
123 Component::Button(b) => button(b)?,
124 Component::Container(c) => container(c)?,
125 Component::MediaGallery(mg) => media_gallery(mg)?,
126 Component::Section(s) => section(s)?,
127 Component::SelectMenu(sm) => select_menu(sm, true)?,
128 Component::TextDisplay(td) => text_display(td)?,
129 Component::TextInput(_)
130 | Component::FileUpload(_)
131 | Component::Checkbox(_)
132 | Component::CheckboxGroup(_) => {
133 return Err(ComponentValidationError {
134 kind: ComponentValidationErrorType::InvalidRootComponent {
135 kind: ComponentType::TextInput,
136 },
137 });
138 }
139 Component::Thumbnail(t) => thumbnail(t)?,
140 Component::Separator(_) | Component::File(_) | Component::Unknown(_) => (),
141 }
142
143 Ok(())
144}
145
146pub fn label(label: &Label) -> Result<(), ComponentValidationError> {
167 self::label_label(&label.label)?;
168
169 if let Some(description) = &label.description {
170 self::label_description(description)?;
171 }
172
173 match &*label.component {
174 Component::ActionRow(_) | Component::Label(_) => Err(ComponentValidationError {
175 kind: ComponentValidationErrorType::InvalidChildComponent {
176 kind: label.component.kind(),
177 },
178 }),
179 Component::SelectMenu(select_menu) => self::select_menu(select_menu, false),
180 Component::TextInput(text_input) => self::text_input(text_input, false),
181 Component::FileUpload(file_upload) => self::file_upload(file_upload),
182 Component::CheckboxGroup(cg) => self::checkbox_group(cg),
183 Component::Checkbox(c) => self::checkbox(c),
184 Component::Unknown(unknown) => Err(ComponentValidationError {
185 kind: ComponentValidationErrorType::InvalidChildComponent {
186 kind: ComponentType::Unknown(*unknown),
187 },
188 }),
189
190 Component::Button(_)
191 | Component::TextDisplay(_)
192 | Component::MediaGallery(_)
193 | Component::Separator(_)
194 | Component::File(_)
195 | Component::Section(_)
196 | Component::Container(_)
197 | Component::Thumbnail(_) => Err(ComponentValidationError {
198 kind: ComponentValidationErrorType::DisallowedChildren,
199 }),
200 }
201}
202
203pub fn text_display(text_display: &TextDisplay) -> Result<(), ComponentValidationError> {
212 let content_len = text_display.content.chars().count();
213 if content_len > TEXT_DISPLAY_CONTENT_LENGTH_MAX {
214 return Err(ComponentValidationError {
215 kind: ComponentValidationErrorType::TextDisplayContentTooLong { len: content_len },
216 });
217 }
218
219 Ok(())
220}
221
222pub fn media_gallery(media_gallery: &MediaGallery) -> Result<(), ComponentValidationError> {
234 let items = media_gallery.items.len();
235 if !(MEDIA_GALLERY_ITEMS_MIN..=MEDIA_GALLERY_ITEMS_MAX).contains(&items) {
236 return Err(ComponentValidationError {
237 kind: ComponentValidationErrorType::MediaGalleryItemCountOutOfRange { count: items },
238 });
239 }
240
241 for item in &media_gallery.items {
242 media_gallery_item(item)?;
243 }
244
245 Ok(())
246}
247
248pub fn section(section: &Section) -> Result<(), ComponentValidationError> {
263 let components = section.components.len();
264 if !(SECTION_COMPONENTS_MIN..=SECTION_COMPONENTS_MAX).contains(&components) {
265 return Err(ComponentValidationError {
266 kind: ComponentValidationErrorType::SectionComponentCountOutOfRange {
267 count: components,
268 },
269 });
270 }
271
272 for component in §ion.components {
273 match component {
274 Component::TextDisplay(td) => text_display(td)?,
275 _ => {
276 return Err(ComponentValidationError {
277 kind: ComponentValidationErrorType::DisallowedChildren,
278 });
279 }
280 }
281 }
282
283 match section.accessory.as_ref() {
284 Component::Button(b) => button(b)?,
285 Component::Thumbnail(t) => thumbnail(t)?,
286 _ => {
287 return Err(ComponentValidationError {
288 kind: ComponentValidationErrorType::DisallowedChildren,
289 });
290 }
291 }
292
293 Ok(())
294}
295
296pub fn container(container: &Container) -> Result<(), ComponentValidationError> {
313 for component in &container.components {
314 match component {
315 Component::ActionRow(ar) => action_row(ar, true)?,
316 Component::TextDisplay(td) => text_display(td)?,
317 Component::Section(s) => section(s)?,
318 Component::MediaGallery(mg) => media_gallery(mg)?,
319 Component::Separator(_) | Component::File(_) => (),
320 _ => {
321 return Err(ComponentValidationError {
322 kind: ComponentValidationErrorType::DisallowedChildren,
323 });
324 }
325 }
326 }
327
328 Ok(())
329}
330
331pub fn thumbnail(thumbnail: &Thumbnail) -> Result<(), ComponentValidationError> {
340 let Some(Some(desc)) = thumbnail.description.as_ref() else {
341 return Ok(());
342 };
343
344 let len = desc.chars().count();
345 if len > THUMBNAIL_DESCRIPTION_LENGTH_MAX {
346 return Err(ComponentValidationError {
347 kind: ComponentValidationErrorType::ThumbnailDescriptionTooLong { len },
348 });
349 }
350
351 Ok(())
352}
353
354pub fn file_upload(file_upload: &FileUpload) -> Result<(), ComponentValidationError> {
375 component_custom_id(&file_upload.custom_id)?;
376
377 if let Some(min_values) = file_upload.min_values {
378 component_file_upload_min_values(min_values)?;
379 }
380
381 if let Some(max_value) = file_upload.max_values {
382 component_file_upload_max_values(max_value)?;
383 }
384
385 if let Some(file_types) = &file_upload.file_types {
386 component_file_upload_file_types(file_types.len())?;
387 }
388
389 Ok(())
390}
391
392pub fn checkbox_group(checkbox_group: &CheckboxGroup) -> Result<(), ComponentValidationError> {
416 component_custom_id(&checkbox_group.custom_id)?;
418
419 if checkbox_group.options.is_empty() {
421 return Err(ComponentValidationError {
422 kind: ComponentValidationErrorType::CheckboxGroupOptionsMissing,
423 });
424 }
425
426 if let Some(max_values) = checkbox_group.max_values {
427 self::component_checkbox_group_max_values(usize::from(max_values))?;
428 }
429
430 if let Some(min_values) = checkbox_group.min_values {
431 self::component_checkbox_group_min_values(usize::from(min_values))?;
432 let required = checkbox_group.required.unwrap_or(true); component_checkbox_group_required(required, usize::from(min_values))?;
434 }
435
436 Ok(())
437}
438
439pub fn checkbox(checkbox: &Checkbox) -> Result<(), ComponentValidationError> {
448 component_custom_id(&checkbox.custom_id)?;
450 Ok(())
451}
452
453pub fn media_gallery_item(item: &MediaGalleryItem) -> Result<(), ComponentValidationError> {
462 let Some(desc) = item.description.as_ref() else {
463 return Ok(());
464 };
465
466 let len = desc.chars().count();
467 if len > MEDIA_GALLERY_ITEM_DESCRIPTION_LENGTH_MAX {
468 return Err(ComponentValidationError {
469 kind: ComponentValidationErrorType::MediaGalleryItemDescriptionTooLong { len },
470 });
471 }
472
473 Ok(())
474}
475
476fn label_label(value: impl AsRef<str>) -> Result<(), ComponentValidationError> {
485 let chars = value.as_ref().chars().count();
486
487 if chars <= LABEL_LABEL_LENGTH_MAX {
488 Ok(())
489 } else {
490 Err(ComponentValidationError {
491 kind: ComponentValidationErrorType::LabelLabelTooLong { len: chars },
492 })
493 }
494}
495
496fn label_description(value: impl AsRef<str>) -> Result<(), ComponentValidationError> {
505 let chars = value.as_ref().chars().count();
506
507 if chars <= LABEL_DESCRIPTION_LENGTH_MAX {
508 Ok(())
509 } else {
510 Err(ComponentValidationError {
511 kind: ComponentValidationErrorType::LabelDescriptionTooLong { len: chars },
512 })
513 }
514}
515
516const fn component_file_upload_max_values(count: u8) -> Result<(), ComponentValidationError> {
527 if count > FILE_UPLOAD_MAXIMUM_VALUES_LIMIT {
528 return Err(ComponentValidationError {
529 kind: ComponentValidationErrorType::FileUploadMaximumValuesCount { count },
530 });
531 }
532
533 Ok(())
534}
535
536const fn component_file_upload_min_values(count: u8) -> Result<(), ComponentValidationError> {
547 if count > FILE_UPLOAD_MINIMUM_VALUES_LIMIT {
548 return Err(ComponentValidationError {
549 kind: ComponentValidationErrorType::FileUploadMinimumValuesCount { count },
550 });
551 }
552
553 Ok(())
554}
555
556const fn component_file_upload_file_types(count: usize) -> Result<(), ComponentValidationError> {
566 if count > FILE_UPLOAD_FILE_TYPES_LIMIT {
567 return Err(ComponentValidationError {
568 kind: ComponentValidationErrorType::FileUploadFileTypesCount { count },
569 });
570 }
571
572 Ok(())
573}
574
575const fn component_checkbox_group_max_values(count: usize) -> Result<(), ComponentValidationError> {
587 if count > CHECKBOXGROUP_MAXIMUM_VALUES_LIMIT {
588 return Err(ComponentValidationError {
589 kind: ComponentValidationErrorType::CheckboxGroupMaximumValuesCount { count },
590 });
591 }
592
593 if count < CHECKBOXGROUP_MAXIMUM_VALUES_REQUIREMENT {
594 return Err(ComponentValidationError {
595 kind: ComponentValidationErrorType::CheckboxGroupMaximumValuesCount { count },
596 });
597 }
598
599 Ok(())
600}
601
602const fn component_checkbox_group_min_values(count: usize) -> Result<(), ComponentValidationError> {
612 if count > CHECKBOXGROUP_MINIMUM_VALUES_LIMIT {
613 return Err(ComponentValidationError {
614 kind: ComponentValidationErrorType::CheckboxGroupMinimumValuesCount { count },
615 });
616 }
617
618 Ok(())
619}
620
621const fn component_checkbox_group_required(
630 required: bool,
631 min_values: usize,
632) -> Result<(), ComponentValidationError> {
633 if required && min_values == 0 {
634 return Err(ComponentValidationError {
635 kind: ComponentValidationErrorType::CheckboxGroupRequiredWithNoMin,
636 });
637 }
638
639 Ok(())
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use std::iter;
646 use twilight_model::channel::message::Component;
647 use twilight_model::channel::message::component::{
648 Button, ButtonStyle, Label, SelectMenu, SelectMenuType, TextInput, TextInputStyle,
649 };
650
651 fn wrap_in_label(component: Component) -> Component {
652 Component::Label(Label {
653 id: None,
654 label: "label".to_owned(),
655 description: None,
656 component: Box::new(component),
657 })
658 }
659
660 #[test]
661 fn component_label() {
662 let button = Component::Button(Button {
663 custom_id: None,
664 disabled: false,
665 emoji: None,
666 label: Some("Press me".to_owned()),
667 style: ButtonStyle::Danger,
668 url: None,
669 sku_id: None,
670 id: None,
671 });
672
673 let text_display = Component::TextDisplay(TextDisplay {
674 id: None,
675 content: "Text display".to_owned(),
676 });
677
678 let valid_select_menu = SelectMenu {
679 channel_types: None,
680 custom_id: "my_select".to_owned(),
681 default_values: None,
682 disabled: false,
683 kind: SelectMenuType::User,
684 max_values: None,
685 min_values: None,
686 options: None,
687 placeholder: None,
688 id: None,
689 required: None,
690 };
691
692 let disabled_select_menu = SelectMenu {
693 disabled: true,
694 ..valid_select_menu.clone()
695 };
696
697 let valid_label = Label {
698 id: None,
699 label: "Label".to_owned(),
700 description: Some("This is a description".to_owned()),
701 component: Box::new(Component::SelectMenu(valid_select_menu)),
702 };
703
704 let label_invalid_child_button = Label {
705 component: Box::new(button),
706 ..valid_label.clone()
707 };
708
709 let label_invalid_child_text_display = Label {
710 id: None,
711 label: "Another label".to_owned(),
712 description: None,
713 component: Box::new(text_display),
714 };
715
716 let label_invalid_child_disabled_select = Label {
717 component: Box::new(Component::SelectMenu(disabled_select_menu)),
718 ..valid_label.clone()
719 };
720
721 let label_too_long_description = Label {
722 description: Some(iter::repeat_n('a', 101).collect()),
723 ..valid_label.clone()
724 };
725
726 let label_too_long_label = Label {
727 label: iter::repeat_n('a', 46).collect(),
728 ..valid_label.clone()
729 };
730
731 assert!(label(&valid_label).is_ok());
732 assert!(component_v2(&Component::Label(valid_label)).is_ok());
733 assert!(label(&label_invalid_child_button).is_err());
734 assert!(component_v2(&Component::Label(label_invalid_child_button)).is_err());
735 assert!(label(&label_invalid_child_text_display).is_err());
736 assert!(component_v2(&Component::Label(label_invalid_child_text_display)).is_err());
737 assert!(label(&label_invalid_child_disabled_select).is_err());
738 assert!(component_v2(&Component::Label(label_invalid_child_disabled_select)).is_err());
739 assert!(label(&label_too_long_description).is_err());
740 assert!(component_v2(&Component::Label(label_too_long_description)).is_err());
741 assert!(label(&label_too_long_label).is_err());
742 assert!(component_v2(&Component::Label(label_too_long_label)).is_err());
743 }
744
745 #[test]
746 fn no_text_input_label_in_label_component() {
747 #[allow(deprecated)]
748 let text_input_with_label = Component::TextInput(TextInput {
749 id: None,
750 custom_id: "The custom id".to_owned(),
751 label: Some("The text input label".to_owned()),
752 max_length: None,
753 min_length: None,
754 placeholder: None,
755 required: None,
756 style: TextInputStyle::Short,
757 value: None,
758 });
759
760 let invalid_label_component = Label {
761 id: None,
762 label: "Label".to_owned(),
763 description: None,
764 component: Box::new(text_input_with_label),
765 };
766
767 assert!(label(&invalid_label_component).is_err());
768 assert!(component_v2(&Component::Label(invalid_label_component)).is_err());
769 }
770
771 #[test]
772 fn component_file_upload() {
773 let valid = FileUpload {
774 id: Some(42),
775 custom_id: "custom_id".to_owned(),
776 max_values: Some(10),
777 min_values: Some(10),
778 file_types: None,
779 required: None,
780 };
781
782 assert!(file_upload(&valid).is_ok());
783 assert!(component_v2(&wrap_in_label(Component::FileUpload(valid.clone()))).is_ok());
784
785 let invalid_custom_id = FileUpload {
786 custom_id: iter::repeat_n('a', 101).collect(),
787 ..valid.clone()
788 };
789
790 assert!(file_upload(&invalid_custom_id).is_err());
791 assert!(component_v2(&wrap_in_label(Component::FileUpload(invalid_custom_id))).is_err());
792
793 let invalid_min_values = FileUpload {
794 min_values: Some(11),
795 ..valid.clone()
796 };
797
798 assert!(file_upload(&invalid_min_values).is_err());
799 assert!(component_v2(&wrap_in_label(Component::FileUpload(invalid_min_values))).is_err());
800
801 let invalid_max_values_too_high = FileUpload {
802 max_values: Some(11),
803 ..valid.clone()
804 };
805
806 assert!(file_upload(&invalid_max_values_too_high).is_err());
807 assert!(
808 component_v2(&wrap_in_label(Component::FileUpload(
809 invalid_max_values_too_high
810 )))
811 .is_err()
812 );
813
814 let invalid_file_types = FileUpload {
815 file_types: Some(iter::repeat_n("image/png".to_owned(), 11).collect()),
816 ..valid
817 };
818
819 assert!(file_upload(&invalid_file_types).is_err());
820 assert!(component_v2(&wrap_in_label(Component::FileUpload(invalid_file_types))).is_err());
821 }
822}