twilight_validate/component/
component_v2.rs1use 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 ComponentType, Container, FileUpload, Label, MediaGallery, MediaGalleryItem, Section,
10 TextDisplay, Thumbnail,
11};
12
13pub const TEXT_DISPLAY_CONTENT_LENGTH_MAX: usize = 2000;
15
16pub const MEDIA_GALLERY_ITEMS_MIN: usize = 1;
18
19pub const MEDIA_GALLERY_ITEMS_MAX: usize = 10;
21
22pub const MEDIA_GALLERY_ITEM_DESCRIPTION_LENGTH_MAX: usize = 1024;
24
25pub const SECTION_COMPONENTS_MIN: usize = 1;
27
28pub const SECTION_COMPONENTS_MAX: usize = 3;
30
31pub const THUMBNAIL_DESCRIPTION_LENGTH_MAX: usize = 1024;
33
34pub const LABEL_LABEL_LENGTH_MAX: usize = 45;
36
37pub const LABEL_DESCRIPTION_LENGTH_MAX: usize = 100;
39
40pub const FILE_UPLOAD_MAXIMUM_VALUES_LIMIT: u8 = 10;
44
45pub const FILE_UPLOAD_MINIMUM_VALUES_LIMIT: u8 = 10;
49
50pub fn component_v2(component: &Component) -> Result<(), ComponentValidationError> {
77 match component {
78 Component::ActionRow(ar) => action_row(ar, true)?,
79 Component::Label(l) => label(l)?,
80 Component::Button(b) => button(b)?,
81 Component::Container(c) => container(c)?,
82 Component::MediaGallery(mg) => media_gallery(mg)?,
83 Component::Section(s) => section(s)?,
84 Component::SelectMenu(sm) => select_menu(sm, true)?,
85 Component::TextDisplay(td) => text_display(td)?,
86 Component::TextInput(_) | Component::FileUpload(_) => {
87 return Err(ComponentValidationError {
88 kind: ComponentValidationErrorType::InvalidRootComponent {
89 kind: ComponentType::TextInput,
90 },
91 });
92 }
93 Component::Thumbnail(t) => thumbnail(t)?,
94 Component::Separator(_) | Component::File(_) | Component::Unknown(_) => (),
95 }
96
97 Ok(())
98}
99
100pub fn label(label: &Label) -> Result<(), ComponentValidationError> {
121 self::label_label(&label.label)?;
122
123 if let Some(description) = &label.description {
124 self::label_description(description)?;
125 }
126
127 match &*label.component {
128 Component::ActionRow(_) | Component::Label(_) => Err(ComponentValidationError {
129 kind: ComponentValidationErrorType::InvalidChildComponent {
130 kind: label.component.kind(),
131 },
132 }),
133 Component::SelectMenu(select_menu) => self::select_menu(select_menu, false),
134 Component::TextInput(text_input) => self::text_input(text_input, false),
135 Component::FileUpload(file_upload) => self::file_upload(file_upload),
136 Component::Unknown(unknown) => Err(ComponentValidationError {
137 kind: ComponentValidationErrorType::InvalidChildComponent {
138 kind: ComponentType::Unknown(*unknown),
139 },
140 }),
141
142 Component::Button(_)
143 | Component::TextDisplay(_)
144 | Component::MediaGallery(_)
145 | Component::Separator(_)
146 | Component::File(_)
147 | Component::Section(_)
148 | Component::Container(_)
149 | Component::Thumbnail(_) => Err(ComponentValidationError {
150 kind: ComponentValidationErrorType::DisallowedChildren,
151 }),
152 }
153}
154
155pub const fn text_display(text_display: &TextDisplay) -> Result<(), ComponentValidationError> {
164 let content_len = text_display.content.len();
165 if content_len > TEXT_DISPLAY_CONTENT_LENGTH_MAX {
166 return Err(ComponentValidationError {
167 kind: ComponentValidationErrorType::TextDisplayContentTooLong { len: content_len },
168 });
169 }
170
171 Ok(())
172}
173
174pub fn media_gallery(media_gallery: &MediaGallery) -> Result<(), ComponentValidationError> {
186 let items = media_gallery.items.len();
187 if !(MEDIA_GALLERY_ITEMS_MIN..=MEDIA_GALLERY_ITEMS_MAX).contains(&items) {
188 return Err(ComponentValidationError {
189 kind: ComponentValidationErrorType::MediaGalleryItemCountOutOfRange { count: items },
190 });
191 }
192
193 for item in &media_gallery.items {
194 media_gallery_item(item)?;
195 }
196
197 Ok(())
198}
199
200pub fn section(section: &Section) -> Result<(), ComponentValidationError> {
215 let components = section.components.len();
216 if !(SECTION_COMPONENTS_MIN..=SECTION_COMPONENTS_MAX).contains(&components) {
217 return Err(ComponentValidationError {
218 kind: ComponentValidationErrorType::SectionComponentCountOutOfRange {
219 count: components,
220 },
221 });
222 }
223
224 for component in §ion.components {
225 match component {
226 Component::TextDisplay(td) => text_display(td)?,
227 _ => {
228 return Err(ComponentValidationError {
229 kind: ComponentValidationErrorType::DisallowedChildren,
230 });
231 }
232 }
233 }
234
235 match section.accessory.as_ref() {
236 Component::Button(b) => button(b)?,
237 Component::Thumbnail(t) => thumbnail(t)?,
238 _ => {
239 return Err(ComponentValidationError {
240 kind: ComponentValidationErrorType::DisallowedChildren,
241 });
242 }
243 }
244
245 Ok(())
246}
247
248pub fn container(container: &Container) -> Result<(), ComponentValidationError> {
265 for component in &container.components {
266 match component {
267 Component::ActionRow(ar) => action_row(ar, true)?,
268 Component::TextDisplay(td) => text_display(td)?,
269 Component::Section(s) => section(s)?,
270 Component::MediaGallery(mg) => media_gallery(mg)?,
271 Component::Separator(_) | Component::File(_) => (),
272 _ => {
273 return Err(ComponentValidationError {
274 kind: ComponentValidationErrorType::DisallowedChildren,
275 });
276 }
277 }
278 }
279
280 Ok(())
281}
282
283pub const fn thumbnail(thumbnail: &Thumbnail) -> Result<(), ComponentValidationError> {
292 let Some(Some(desc)) = thumbnail.description.as_ref() else {
293 return Ok(());
294 };
295
296 let len = desc.len();
297 if len > THUMBNAIL_DESCRIPTION_LENGTH_MAX {
298 return Err(ComponentValidationError {
299 kind: ComponentValidationErrorType::ThumbnailDescriptionTooLong { len },
300 });
301 }
302
303 Ok(())
304}
305
306pub fn file_upload(file_upload: &FileUpload) -> Result<(), ComponentValidationError> {
323 component_custom_id(&file_upload.custom_id)?;
324
325 if let Some(min_values) = file_upload.min_values {
326 component_file_upload_min_values(min_values)?;
327 }
328
329 if let Some(max_value) = file_upload.max_values {
330 component_file_upload_max_values(max_value)?;
331 }
332
333 Ok(())
334}
335
336pub const fn media_gallery_item(item: &MediaGalleryItem) -> Result<(), ComponentValidationError> {
345 let Some(desc) = item.description.as_ref() else {
346 return Ok(());
347 };
348
349 let len = desc.len();
350 if len > MEDIA_GALLERY_ITEM_DESCRIPTION_LENGTH_MAX {
351 return Err(ComponentValidationError {
352 kind: ComponentValidationErrorType::MediaGalleryItemDescriptionTooLong { len },
353 });
354 }
355
356 Ok(())
357}
358
359fn label_label(value: impl AsRef<str>) -> Result<(), ComponentValidationError> {
368 let chars = value.as_ref().chars().count();
369
370 if chars <= LABEL_LABEL_LENGTH_MAX {
371 Ok(())
372 } else {
373 Err(ComponentValidationError {
374 kind: ComponentValidationErrorType::LabelLabelTooLong { len: chars },
375 })
376 }
377}
378
379fn label_description(value: impl AsRef<str>) -> Result<(), ComponentValidationError> {
388 let chars = value.as_ref().chars().count();
389
390 if chars <= LABEL_DESCRIPTION_LENGTH_MAX {
391 Ok(())
392 } else {
393 Err(ComponentValidationError {
394 kind: ComponentValidationErrorType::LabelDescriptionTooLong { len: chars },
395 })
396 }
397}
398
399const fn component_file_upload_max_values(count: u8) -> Result<(), ComponentValidationError> {
410 if count > FILE_UPLOAD_MAXIMUM_VALUES_LIMIT {
411 return Err(ComponentValidationError {
412 kind: ComponentValidationErrorType::FileUploadMaximumValuesCount { count },
413 });
414 }
415
416 Ok(())
417}
418
419const fn component_file_upload_min_values(count: u8) -> Result<(), ComponentValidationError> {
430 if count > FILE_UPLOAD_MINIMUM_VALUES_LIMIT {
431 return Err(ComponentValidationError {
432 kind: ComponentValidationErrorType::FileUploadMinimumValuesCount { count },
433 });
434 }
435
436 Ok(())
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use std::iter;
443 use twilight_model::channel::message::Component;
444 use twilight_model::channel::message::component::{
445 Button, ButtonStyle, Label, SelectMenu, SelectMenuType, TextInput, TextInputStyle,
446 };
447
448 fn wrap_in_label(component: Component) -> Component {
449 Component::Label(Label {
450 id: None,
451 label: "label".to_owned(),
452 description: None,
453 component: Box::new(component),
454 })
455 }
456
457 #[test]
458 fn component_label() {
459 let button = Component::Button(Button {
460 custom_id: None,
461 disabled: false,
462 emoji: None,
463 label: Some("Press me".to_owned()),
464 style: ButtonStyle::Danger,
465 url: None,
466 sku_id: None,
467 id: None,
468 });
469
470 let text_display = Component::TextDisplay(TextDisplay {
471 id: None,
472 content: "Text display".to_owned(),
473 });
474
475 let valid_select_menu = SelectMenu {
476 channel_types: None,
477 custom_id: "my_select".to_owned(),
478 default_values: None,
479 disabled: false,
480 kind: SelectMenuType::User,
481 max_values: None,
482 min_values: None,
483 options: None,
484 placeholder: None,
485 id: None,
486 required: None,
487 };
488
489 let disabled_select_menu = SelectMenu {
490 disabled: true,
491 ..valid_select_menu.clone()
492 };
493
494 let valid_label = Label {
495 id: None,
496 label: "Label".to_owned(),
497 description: Some("This is a description".to_owned()),
498 component: Box::new(Component::SelectMenu(valid_select_menu)),
499 };
500
501 let label_invalid_child_button = Label {
502 component: Box::new(button),
503 ..valid_label.clone()
504 };
505
506 let label_invalid_child_text_display = Label {
507 id: None,
508 label: "Another label".to_owned(),
509 description: None,
510 component: Box::new(text_display),
511 };
512
513 let label_invalid_child_disabled_select = Label {
514 component: Box::new(Component::SelectMenu(disabled_select_menu)),
515 ..valid_label.clone()
516 };
517
518 let label_too_long_description = Label {
519 description: Some(iter::repeat_n('a', 101).collect()),
520 ..valid_label.clone()
521 };
522
523 let label_too_long_label = Label {
524 label: iter::repeat_n('a', 46).collect(),
525 ..valid_label.clone()
526 };
527
528 assert!(label(&valid_label).is_ok());
529 assert!(component_v2(&Component::Label(valid_label)).is_ok());
530 assert!(label(&label_invalid_child_button).is_err());
531 assert!(component_v2(&Component::Label(label_invalid_child_button)).is_err());
532 assert!(label(&label_invalid_child_text_display).is_err());
533 assert!(component_v2(&Component::Label(label_invalid_child_text_display)).is_err());
534 assert!(label(&label_invalid_child_disabled_select).is_err());
535 assert!(component_v2(&Component::Label(label_invalid_child_disabled_select)).is_err());
536 assert!(label(&label_too_long_description).is_err());
537 assert!(component_v2(&Component::Label(label_too_long_description)).is_err());
538 assert!(label(&label_too_long_label).is_err());
539 assert!(component_v2(&Component::Label(label_too_long_label)).is_err());
540 }
541
542 #[test]
543 fn no_text_input_label_in_label_component() {
544 #[allow(deprecated)]
545 let text_input_with_label = Component::TextInput(TextInput {
546 id: None,
547 custom_id: "The custom id".to_owned(),
548 label: Some("The text input label".to_owned()),
549 max_length: None,
550 min_length: None,
551 placeholder: None,
552 required: None,
553 style: TextInputStyle::Short,
554 value: None,
555 });
556
557 let invalid_label_component = Label {
558 id: None,
559 label: "Label".to_owned(),
560 description: None,
561 component: Box::new(text_input_with_label),
562 };
563
564 assert!(label(&invalid_label_component).is_err());
565 assert!(component_v2(&Component::Label(invalid_label_component)).is_err());
566 }
567
568 #[test]
569 fn component_file_upload() {
570 let valid = FileUpload {
571 id: Some(42),
572 custom_id: "custom_id".to_owned(),
573 max_values: Some(10),
574 min_values: Some(10),
575 required: None,
576 };
577
578 assert!(file_upload(&valid).is_ok());
579 assert!(component_v2(&wrap_in_label(Component::FileUpload(valid.clone()))).is_ok());
580
581 let invalid_custom_id = FileUpload {
582 custom_id: iter::repeat_n('a', 101).collect(),
583 ..valid.clone()
584 };
585
586 assert!(file_upload(&invalid_custom_id).is_err());
587 assert!(component_v2(&wrap_in_label(Component::FileUpload(invalid_custom_id))).is_err());
588
589 let invalid_min_values = FileUpload {
590 min_values: Some(11),
591 ..valid.clone()
592 };
593
594 assert!(file_upload(&invalid_min_values).is_err());
595 assert!(component_v2(&wrap_in_label(Component::FileUpload(invalid_min_values))).is_err());
596
597 let invalid_max_values_too_high = FileUpload {
598 max_values: Some(11),
599 ..valid
600 };
601
602 assert!(file_upload(&invalid_max_values_too_high).is_err());
603 assert!(
604 component_v2(&wrap_in_label(Component::FileUpload(
605 invalid_max_values_too_high
606 )))
607 .is_err()
608 );
609 }
610}