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 fn component_v2(component: &Component) -> Result<(), ComponentValidationError> {
112 match component {
113 Component::ActionRow(ar) => action_row(ar, true)?,
114 Component::Label(l) => label(l)?,
115 Component::Button(b) => button(b)?,
116 Component::Container(c) => container(c)?,
117 Component::MediaGallery(mg) => media_gallery(mg)?,
118 Component::Section(s) => section(s)?,
119 Component::SelectMenu(sm) => select_menu(sm, true)?,
120 Component::TextDisplay(td) => text_display(td)?,
121 Component::TextInput(_)
122 | Component::FileUpload(_)
123 | Component::Checkbox(_)
124 | Component::CheckboxGroup(_) => {
125 return Err(ComponentValidationError {
126 kind: ComponentValidationErrorType::InvalidRootComponent {
127 kind: ComponentType::TextInput,
128 },
129 });
130 }
131 Component::Thumbnail(t) => thumbnail(t)?,
132 Component::Separator(_) | Component::File(_) | Component::Unknown(_) => (),
133 }
134
135 Ok(())
136}
137
138pub fn label(label: &Label) -> Result<(), ComponentValidationError> {
159 self::label_label(&label.label)?;
160
161 if let Some(description) = &label.description {
162 self::label_description(description)?;
163 }
164
165 match &*label.component {
166 Component::ActionRow(_) | Component::Label(_) => Err(ComponentValidationError {
167 kind: ComponentValidationErrorType::InvalidChildComponent {
168 kind: label.component.kind(),
169 },
170 }),
171 Component::SelectMenu(select_menu) => self::select_menu(select_menu, false),
172 Component::TextInput(text_input) => self::text_input(text_input, false),
173 Component::FileUpload(file_upload) => self::file_upload(file_upload),
174 Component::CheckboxGroup(cg) => self::checkbox_group(cg),
175 Component::Checkbox(c) => self::checkbox(c),
176 Component::Unknown(unknown) => Err(ComponentValidationError {
177 kind: ComponentValidationErrorType::InvalidChildComponent {
178 kind: ComponentType::Unknown(*unknown),
179 },
180 }),
181
182 Component::Button(_)
183 | Component::TextDisplay(_)
184 | Component::MediaGallery(_)
185 | Component::Separator(_)
186 | Component::File(_)
187 | Component::Section(_)
188 | Component::Container(_)
189 | Component::Thumbnail(_) => Err(ComponentValidationError {
190 kind: ComponentValidationErrorType::DisallowedChildren,
191 }),
192 }
193}
194
195pub const fn text_display(text_display: &TextDisplay) -> Result<(), ComponentValidationError> {
204 let content_len = text_display.content.len();
205 if content_len > TEXT_DISPLAY_CONTENT_LENGTH_MAX {
206 return Err(ComponentValidationError {
207 kind: ComponentValidationErrorType::TextDisplayContentTooLong { len: content_len },
208 });
209 }
210
211 Ok(())
212}
213
214pub fn media_gallery(media_gallery: &MediaGallery) -> Result<(), ComponentValidationError> {
226 let items = media_gallery.items.len();
227 if !(MEDIA_GALLERY_ITEMS_MIN..=MEDIA_GALLERY_ITEMS_MAX).contains(&items) {
228 return Err(ComponentValidationError {
229 kind: ComponentValidationErrorType::MediaGalleryItemCountOutOfRange { count: items },
230 });
231 }
232
233 for item in &media_gallery.items {
234 media_gallery_item(item)?;
235 }
236
237 Ok(())
238}
239
240pub fn section(section: &Section) -> Result<(), ComponentValidationError> {
255 let components = section.components.len();
256 if !(SECTION_COMPONENTS_MIN..=SECTION_COMPONENTS_MAX).contains(&components) {
257 return Err(ComponentValidationError {
258 kind: ComponentValidationErrorType::SectionComponentCountOutOfRange {
259 count: components,
260 },
261 });
262 }
263
264 for component in §ion.components {
265 match component {
266 Component::TextDisplay(td) => text_display(td)?,
267 _ => {
268 return Err(ComponentValidationError {
269 kind: ComponentValidationErrorType::DisallowedChildren,
270 });
271 }
272 }
273 }
274
275 match section.accessory.as_ref() {
276 Component::Button(b) => button(b)?,
277 Component::Thumbnail(t) => thumbnail(t)?,
278 _ => {
279 return Err(ComponentValidationError {
280 kind: ComponentValidationErrorType::DisallowedChildren,
281 });
282 }
283 }
284
285 Ok(())
286}
287
288pub fn container(container: &Container) -> Result<(), ComponentValidationError> {
305 for component in &container.components {
306 match component {
307 Component::ActionRow(ar) => action_row(ar, true)?,
308 Component::TextDisplay(td) => text_display(td)?,
309 Component::Section(s) => section(s)?,
310 Component::MediaGallery(mg) => media_gallery(mg)?,
311 Component::Separator(_) | Component::File(_) => (),
312 _ => {
313 return Err(ComponentValidationError {
314 kind: ComponentValidationErrorType::DisallowedChildren,
315 });
316 }
317 }
318 }
319
320 Ok(())
321}
322
323pub const fn thumbnail(thumbnail: &Thumbnail) -> Result<(), ComponentValidationError> {
332 let Some(Some(desc)) = thumbnail.description.as_ref() else {
333 return Ok(());
334 };
335
336 let len = desc.len();
337 if len > THUMBNAIL_DESCRIPTION_LENGTH_MAX {
338 return Err(ComponentValidationError {
339 kind: ComponentValidationErrorType::ThumbnailDescriptionTooLong { len },
340 });
341 }
342
343 Ok(())
344}
345
346pub fn file_upload(file_upload: &FileUpload) -> Result<(), ComponentValidationError> {
363 component_custom_id(&file_upload.custom_id)?;
364
365 if let Some(min_values) = file_upload.min_values {
366 component_file_upload_min_values(min_values)?;
367 }
368
369 if let Some(max_value) = file_upload.max_values {
370 component_file_upload_max_values(max_value)?;
371 }
372
373 Ok(())
374}
375
376pub fn checkbox_group(checkbox_group: &CheckboxGroup) -> Result<(), ComponentValidationError> {
400 component_custom_id(&checkbox_group.custom_id)?;
402
403 if checkbox_group.options.is_empty() {
405 return Err(ComponentValidationError {
406 kind: ComponentValidationErrorType::CheckboxGroupOptionsMissing,
407 });
408 }
409
410 if let Some(max_values) = checkbox_group.max_values {
411 self::component_checkbox_group_max_values(usize::from(max_values))?;
412 }
413
414 if let Some(min_values) = checkbox_group.min_values {
415 self::component_checkbox_group_min_values(usize::from(min_values))?;
416 let required = checkbox_group.required.unwrap_or(true); component_checkbox_group_required(required, usize::from(min_values))?;
418 }
419
420 Ok(())
421}
422
423pub fn checkbox(checkbox: &Checkbox) -> Result<(), ComponentValidationError> {
432 component_custom_id(&checkbox.custom_id)?;
434 Ok(())
435}
436
437pub const fn media_gallery_item(item: &MediaGalleryItem) -> Result<(), ComponentValidationError> {
446 let Some(desc) = item.description.as_ref() else {
447 return Ok(());
448 };
449
450 let len = desc.len();
451 if len > MEDIA_GALLERY_ITEM_DESCRIPTION_LENGTH_MAX {
452 return Err(ComponentValidationError {
453 kind: ComponentValidationErrorType::MediaGalleryItemDescriptionTooLong { len },
454 });
455 }
456
457 Ok(())
458}
459
460fn label_label(value: impl AsRef<str>) -> Result<(), ComponentValidationError> {
469 let chars = value.as_ref().chars().count();
470
471 if chars <= LABEL_LABEL_LENGTH_MAX {
472 Ok(())
473 } else {
474 Err(ComponentValidationError {
475 kind: ComponentValidationErrorType::LabelLabelTooLong { len: chars },
476 })
477 }
478}
479
480fn label_description(value: impl AsRef<str>) -> Result<(), ComponentValidationError> {
489 let chars = value.as_ref().chars().count();
490
491 if chars <= LABEL_DESCRIPTION_LENGTH_MAX {
492 Ok(())
493 } else {
494 Err(ComponentValidationError {
495 kind: ComponentValidationErrorType::LabelDescriptionTooLong { len: chars },
496 })
497 }
498}
499
500const fn component_file_upload_max_values(count: u8) -> Result<(), ComponentValidationError> {
511 if count > FILE_UPLOAD_MAXIMUM_VALUES_LIMIT {
512 return Err(ComponentValidationError {
513 kind: ComponentValidationErrorType::FileUploadMaximumValuesCount { count },
514 });
515 }
516
517 Ok(())
518}
519
520const fn component_file_upload_min_values(count: u8) -> Result<(), ComponentValidationError> {
531 if count > FILE_UPLOAD_MINIMUM_VALUES_LIMIT {
532 return Err(ComponentValidationError {
533 kind: ComponentValidationErrorType::FileUploadMinimumValuesCount { count },
534 });
535 }
536
537 Ok(())
538}
539
540const fn component_checkbox_group_max_values(count: usize) -> Result<(), ComponentValidationError> {
552 if count > CHECKBOXGROUP_MAXIMUM_VALUES_LIMIT {
553 return Err(ComponentValidationError {
554 kind: ComponentValidationErrorType::CheckboxGroupMaximumValuesCount { count },
555 });
556 }
557
558 if count < CHECKBOXGROUP_MAXIMUM_VALUES_REQUIREMENT {
559 return Err(ComponentValidationError {
560 kind: ComponentValidationErrorType::CheckboxGroupMaximumValuesCount { count },
561 });
562 }
563
564 Ok(())
565}
566
567const fn component_checkbox_group_min_values(count: usize) -> Result<(), ComponentValidationError> {
577 if count > CHECKBOXGROUP_MINIMUM_VALUES_LIMIT {
578 return Err(ComponentValidationError {
579 kind: ComponentValidationErrorType::CheckboxGroupMinimumValuesCount { count },
580 });
581 }
582
583 Ok(())
584}
585
586const fn component_checkbox_group_required(
595 required: bool,
596 min_values: usize,
597) -> Result<(), ComponentValidationError> {
598 if required && min_values == 0 {
599 return Err(ComponentValidationError {
600 kind: ComponentValidationErrorType::CheckboxGroupRequiredWithNoMin,
601 });
602 }
603
604 Ok(())
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610 use std::iter;
611 use twilight_model::channel::message::Component;
612 use twilight_model::channel::message::component::{
613 Button, ButtonStyle, Label, SelectMenu, SelectMenuType, TextInput, TextInputStyle,
614 };
615
616 fn wrap_in_label(component: Component) -> Component {
617 Component::Label(Label {
618 id: None,
619 label: "label".to_owned(),
620 description: None,
621 component: Box::new(component),
622 })
623 }
624
625 #[test]
626 fn component_label() {
627 let button = Component::Button(Button {
628 custom_id: None,
629 disabled: false,
630 emoji: None,
631 label: Some("Press me".to_owned()),
632 style: ButtonStyle::Danger,
633 url: None,
634 sku_id: None,
635 id: None,
636 });
637
638 let text_display = Component::TextDisplay(TextDisplay {
639 id: None,
640 content: "Text display".to_owned(),
641 });
642
643 let valid_select_menu = SelectMenu {
644 channel_types: None,
645 custom_id: "my_select".to_owned(),
646 default_values: None,
647 disabled: false,
648 kind: SelectMenuType::User,
649 max_values: None,
650 min_values: None,
651 options: None,
652 placeholder: None,
653 id: None,
654 required: None,
655 };
656
657 let disabled_select_menu = SelectMenu {
658 disabled: true,
659 ..valid_select_menu.clone()
660 };
661
662 let valid_label = Label {
663 id: None,
664 label: "Label".to_owned(),
665 description: Some("This is a description".to_owned()),
666 component: Box::new(Component::SelectMenu(valid_select_menu)),
667 };
668
669 let label_invalid_child_button = Label {
670 component: Box::new(button),
671 ..valid_label.clone()
672 };
673
674 let label_invalid_child_text_display = Label {
675 id: None,
676 label: "Another label".to_owned(),
677 description: None,
678 component: Box::new(text_display),
679 };
680
681 let label_invalid_child_disabled_select = Label {
682 component: Box::new(Component::SelectMenu(disabled_select_menu)),
683 ..valid_label.clone()
684 };
685
686 let label_too_long_description = Label {
687 description: Some(iter::repeat_n('a', 101).collect()),
688 ..valid_label.clone()
689 };
690
691 let label_too_long_label = Label {
692 label: iter::repeat_n('a', 46).collect(),
693 ..valid_label.clone()
694 };
695
696 assert!(label(&valid_label).is_ok());
697 assert!(component_v2(&Component::Label(valid_label)).is_ok());
698 assert!(label(&label_invalid_child_button).is_err());
699 assert!(component_v2(&Component::Label(label_invalid_child_button)).is_err());
700 assert!(label(&label_invalid_child_text_display).is_err());
701 assert!(component_v2(&Component::Label(label_invalid_child_text_display)).is_err());
702 assert!(label(&label_invalid_child_disabled_select).is_err());
703 assert!(component_v2(&Component::Label(label_invalid_child_disabled_select)).is_err());
704 assert!(label(&label_too_long_description).is_err());
705 assert!(component_v2(&Component::Label(label_too_long_description)).is_err());
706 assert!(label(&label_too_long_label).is_err());
707 assert!(component_v2(&Component::Label(label_too_long_label)).is_err());
708 }
709
710 #[test]
711 fn no_text_input_label_in_label_component() {
712 #[allow(deprecated)]
713 let text_input_with_label = Component::TextInput(TextInput {
714 id: None,
715 custom_id: "The custom id".to_owned(),
716 label: Some("The text input label".to_owned()),
717 max_length: None,
718 min_length: None,
719 placeholder: None,
720 required: None,
721 style: TextInputStyle::Short,
722 value: None,
723 });
724
725 let invalid_label_component = Label {
726 id: None,
727 label: "Label".to_owned(),
728 description: None,
729 component: Box::new(text_input_with_label),
730 };
731
732 assert!(label(&invalid_label_component).is_err());
733 assert!(component_v2(&Component::Label(invalid_label_component)).is_err());
734 }
735
736 #[test]
737 fn component_file_upload() {
738 let valid = FileUpload {
739 id: Some(42),
740 custom_id: "custom_id".to_owned(),
741 max_values: Some(10),
742 min_values: Some(10),
743 required: None,
744 };
745
746 assert!(file_upload(&valid).is_ok());
747 assert!(component_v2(&wrap_in_label(Component::FileUpload(valid.clone()))).is_ok());
748
749 let invalid_custom_id = FileUpload {
750 custom_id: iter::repeat_n('a', 101).collect(),
751 ..valid.clone()
752 };
753
754 assert!(file_upload(&invalid_custom_id).is_err());
755 assert!(component_v2(&wrap_in_label(Component::FileUpload(invalid_custom_id))).is_err());
756
757 let invalid_min_values = FileUpload {
758 min_values: Some(11),
759 ..valid.clone()
760 };
761
762 assert!(file_upload(&invalid_min_values).is_err());
763 assert!(component_v2(&wrap_in_label(Component::FileUpload(invalid_min_values))).is_err());
764
765 let invalid_max_values_too_high = FileUpload {
766 max_values: Some(11),
767 ..valid
768 };
769
770 assert!(file_upload(&invalid_max_values_too_high).is_err());
771 assert!(
772 component_v2(&wrap_in_label(Component::FileUpload(
773 invalid_max_values_too_high
774 )))
775 .is_err()
776 );
777 }
778}