1use std::collections::{BTreeMap, BTreeSet};
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6use crate::{
7 IcuAnalysis, IcuArgumentKind, IcuDiagnosticSeverity, IcuParseError, IcuPluralKind,
8 IcuStyleKind, analyze_icu,
9 diagnostic_codes::{self, DiagnosticCode},
10 parse_icu,
11};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
19#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
20#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
21pub struct MessageMetadataInput {
22 pub msgid: String,
24 #[cfg_attr(
26 feature = "serde",
27 serde(default, skip_serializing_if = "Option::is_none")
28 )]
29 pub msgctxt: Option<String>,
30 #[cfg_attr(
32 feature = "serde",
33 serde(default, skip_serializing_if = "Option::is_none")
34 )]
35 pub description: Option<String>,
36 #[cfg_attr(
38 feature = "serde",
39 serde(default, skip_serializing_if = "Vec::is_empty")
40 )]
41 pub origin: Vec<MessageOriginMetadata>,
42 #[cfg_attr(
44 feature = "serde",
45 serde(default, skip_serializing_if = "Option::is_none")
46 )]
47 pub args: Option<BTreeMap<String, MessageArgumentMetadataInput>>,
48 #[cfg_attr(
50 feature = "serde",
51 serde(default, skip_serializing_if = "Option::is_none")
52 )]
53 pub tags: Option<Vec<String>>,
54 #[cfg_attr(
56 feature = "serde",
57 serde(default, skip_serializing_if = "Option::is_none")
58 )]
59 pub selectors: Option<BTreeMap<String, MessageSelectorMetadata>>,
60}
61
62impl MessageMetadataInput {
63 #[must_use]
65 pub fn new(msgid: impl Into<String>) -> Self {
66 Self {
67 msgid: msgid.into(),
68 msgctxt: None,
69 description: None,
70 origin: Vec::new(),
71 args: None,
72 tags: None,
73 selectors: None,
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Default)]
80#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
81#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
82pub struct MessageMetadata {
83 pub msgid: String,
85 #[cfg_attr(
87 feature = "serde",
88 serde(default, skip_serializing_if = "Option::is_none")
89 )]
90 pub msgctxt: Option<String>,
91 #[cfg_attr(
93 feature = "serde",
94 serde(default, skip_serializing_if = "Option::is_none")
95 )]
96 pub description: Option<String>,
97 #[cfg_attr(
99 feature = "serde",
100 serde(default, skip_serializing_if = "Vec::is_empty")
101 )]
102 pub origin: Vec<MessageOriginMetadata>,
103 #[cfg_attr(
105 feature = "serde",
106 serde(default, skip_serializing_if = "BTreeMap::is_empty")
107 )]
108 pub args: BTreeMap<String, MessageArgumentMetadata>,
109 #[cfg_attr(
111 feature = "serde",
112 serde(default, skip_serializing_if = "Vec::is_empty")
113 )]
114 pub tags: Vec<String>,
115 #[cfg_attr(
117 feature = "serde",
118 serde(default, skip_serializing_if = "BTreeMap::is_empty")
119 )]
120 pub selectors: BTreeMap<String, MessageSelectorMetadata>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Default)]
125#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
126#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
127pub struct MessageOriginMetadata {
128 #[cfg_attr(
130 feature = "serde",
131 serde(default, skip_serializing_if = "Option::is_none")
132 )]
133 pub file: Option<String>,
134 #[cfg_attr(
136 feature = "serde",
137 serde(default, skip_serializing_if = "Option::is_none")
138 )]
139 pub line: Option<u32>,
140 #[cfg_attr(
142 feature = "serde",
143 serde(default, skip_serializing_if = "Option::is_none")
144 )]
145 pub component: Option<String>,
146 #[cfg_attr(
148 feature = "serde",
149 serde(default, skip_serializing_if = "Option::is_none")
150 )]
151 pub route: Option<String>,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
157#[cfg_attr(feature = "serde", serde(untagged))]
158pub enum MessageArgumentMetadataInput {
159 Kind(MessageArgumentKind),
161 Details(MessageArgumentMetadata),
163}
164
165impl From<MessageArgumentKind> for MessageArgumentMetadataInput {
166 fn from(kind: MessageArgumentKind) -> Self {
167 Self::Kind(kind)
168 }
169}
170
171impl From<MessageArgumentMetadata> for MessageArgumentMetadataInput {
172 fn from(metadata: MessageArgumentMetadata) -> Self {
173 Self::Details(metadata)
174 }
175}
176
177impl MessageArgumentMetadataInput {
178 fn into_metadata(self) -> MessageArgumentMetadata {
179 match self {
180 Self::Kind(kind) => MessageArgumentMetadata {
181 kind,
182 ..MessageArgumentMetadata::default()
183 },
184 Self::Details(metadata) => metadata,
185 }
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
191#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
192#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
193pub struct MessageArgumentMetadata {
194 #[cfg_attr(feature = "serde", serde(default))]
196 pub kind: MessageArgumentKind,
197 #[cfg_attr(
199 feature = "serde",
200 serde(default, skip_serializing_if = "Option::is_none")
201 )]
202 pub role: Option<String>,
203 #[cfg_attr(
205 feature = "serde",
206 serde(default, skip_serializing_if = "Vec::is_empty")
207 )]
208 pub values: Vec<String>,
209 #[cfg_attr(
211 feature = "serde",
212 serde(default, skip_serializing_if = "Option::is_none")
213 )]
214 pub format: Option<MessageArgumentFormatMetadata>,
215}
216
217impl Default for MessageArgumentMetadata {
218 fn default() -> Self {
219 Self {
220 kind: MessageArgumentKind::Unknown,
221 role: None,
222 values: Vec::new(),
223 format: None,
224 }
225 }
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
230#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
231#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
232pub enum MessageArgumentKind {
233 String,
235 Number,
237 Date,
239 Time,
241 Datetime,
243 Boolean,
245 Enum,
247 List,
249 Duration,
251 RelativeTime,
253 Name,
255 #[default]
257 Unknown,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Default)]
262#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
263#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
264pub struct MessageArgumentFormatMetadata {
265 #[cfg_attr(
267 feature = "serde",
268 serde(default, skip_serializing_if = "Option::is_none")
269 )]
270 pub style: Option<String>,
271 #[cfg_attr(
273 feature = "serde",
274 serde(default, skip_serializing_if = "Option::is_none")
275 )]
276 pub style_kind: Option<MessageFormatStyleKind>,
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
282#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
283pub enum MessageFormatStyleKind {
284 None,
286 Predefined,
288 Skeleton,
290 Pattern,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
296#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
297#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
298pub struct MessageSelectorMetadata {
299 pub kind: MessageSelectorKind,
301 #[cfg_attr(
303 feature = "serde",
304 serde(default, skip_serializing_if = "Vec::is_empty")
305 )]
306 pub cases: Vec<String>,
307 #[cfg_attr(
309 feature = "serde",
310 serde(default, skip_serializing_if = "Option::is_none")
311 )]
312 pub offset: Option<u32>,
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
318#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
319pub enum MessageSelectorKind {
320 Select,
322 Plural,
324 #[cfg_attr(feature = "serde", serde(rename = "selectordinal"))]
326 SelectOrdinal,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct MessageMetadataDiagnostic {
332 pub severity: IcuDiagnosticSeverity,
334 pub code: DiagnosticCode,
336 pub message: String,
338 pub name: Option<String>,
340}
341
342impl MessageMetadataDiagnostic {
343 fn new(
344 severity: IcuDiagnosticSeverity,
345 code: impl Into<DiagnosticCode>,
346 message: impl Into<String>,
347 name: impl Into<Option<String>>,
348 ) -> Self {
349 Self {
350 severity,
351 code: code.into(),
352 message: message.into(),
353 name: name.into(),
354 }
355 }
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Default)]
360pub struct MessageMetadataValidationReport {
361 pub diagnostics: Vec<MessageMetadataDiagnostic>,
363}
364
365impl MessageMetadataValidationReport {
366 #[must_use]
368 pub fn has_errors(&self) -> bool {
369 self.diagnostics
370 .iter()
371 .any(|diagnostic| diagnostic.severity == IcuDiagnosticSeverity::Error)
372 }
373}
374
375pub fn derive_message_metadata_from_icu(
384 msgid: &str,
385 msgctxt: Option<&str>,
386) -> Result<MessageMetadata, IcuParseError> {
387 let message = parse_icu(msgid)?;
388 let analysis = analyze_icu(&message);
389 Ok(MessageMetadata {
390 msgid: msgid.to_owned(),
391 msgctxt: msgctxt.map(str::to_owned),
392 description: None,
393 origin: Vec::new(),
394 args: derive_args(&analysis),
395 tags: unique_strings(analysis.tags.iter().map(|tag| tag.name.as_str())),
396 selectors: derive_selectors(&analysis),
397 })
398}
399
400pub fn normalize_message_metadata(
410 input: MessageMetadataInput,
411) -> Result<MessageMetadata, IcuParseError> {
412 let mut derived = derive_message_metadata_from_icu(&input.msgid, input.msgctxt.as_deref())?;
413 derived.description = input.description;
414 derived.origin = input.origin;
415
416 if let Some(args) = input.args {
417 let mut normalized_args = args
418 .into_iter()
419 .map(|(name, argument)| (name, argument.into_metadata()))
420 .collect::<BTreeMap<_, _>>();
421 for (name, argument) in derived.args {
422 normalized_args.entry(name).or_insert(argument);
423 }
424 derived.args = normalized_args;
425 }
426
427 if let Some(tags) = input.tags {
428 let mut normalized_tags = unique_strings(tags.iter().map(String::as_str));
429 for tag in derived.tags {
430 if !normalized_tags.contains(&tag) {
431 normalized_tags.push(tag);
432 }
433 }
434 derived.tags = normalized_tags;
435 }
436
437 if let Some(selectors) = input.selectors {
438 let mut normalized_selectors = selectors;
439 for (name, selector) in derived.selectors {
440 normalized_selectors.entry(name).or_insert(selector);
441 }
442 derived.selectors = normalized_selectors;
443 }
444
445 Ok(derived)
446}
447
448#[must_use]
450pub fn validate_message_metadata(input: &MessageMetadataInput) -> MessageMetadataValidationReport {
451 let Ok(derived) = derive_message_metadata_from_icu(&input.msgid, input.msgctxt.as_deref())
452 else {
453 return MessageMetadataValidationReport {
454 diagnostics: vec![MessageMetadataDiagnostic::new(
455 IcuDiagnosticSeverity::Error,
456 diagnostic_codes::metadata::INVALID_MSGID,
457 "Message metadata `msgid` is not valid ICU MessageFormat v1.",
458 Some("msgid".to_owned()),
459 )],
460 };
461 };
462
463 let mut report = MessageMetadataValidationReport::default();
464 if let Some(args) = &input.args {
465 validate_args(args, &derived.args, &mut report);
466 }
467 if let Some(tags) = &input.tags {
468 validate_tags(tags, &derived.tags, &mut report);
469 }
470 if let Some(selectors) = &input.selectors {
471 validate_selectors(selectors, &derived.selectors, &mut report);
472 }
473 report
474}
475
476fn derive_args(analysis: &IcuAnalysis) -> BTreeMap<String, MessageArgumentMetadata> {
477 let mut args = BTreeMap::<String, MessageArgumentMetadata>::new();
478 for argument in &analysis.arguments {
479 args.entry(argument.name.clone())
480 .and_modify(|metadata| merge_icu_argument(metadata, argument.kind))
481 .or_insert_with(|| metadata_for_icu_argument(argument.kind));
482 }
483 for formatter in &analysis.formatters {
484 args.entry(formatter.name.clone())
485 .and_modify(|metadata| {
486 merge_icu_argument(metadata, formatter.kind);
487 metadata.format = Some(MessageArgumentFormatMetadata {
488 style: formatter.style.clone(),
489 style_kind: Some(style_kind(formatter.style_kind)),
490 });
491 })
492 .or_insert_with(|| {
493 let mut metadata = metadata_for_icu_argument(formatter.kind);
494 metadata.format = Some(MessageArgumentFormatMetadata {
495 style: formatter.style.clone(),
496 style_kind: Some(style_kind(formatter.style_kind)),
497 });
498 metadata
499 });
500 }
501 for select in &analysis.selects {
502 let metadata = args.entry(select.name.clone()).or_default();
503 metadata.kind = MessageArgumentKind::Enum;
504 metadata.values = select.selectors.clone();
505 }
506 for plural in &analysis.plurals {
507 let metadata = args.entry(plural.name.clone()).or_default();
508 match plural.kind {
509 IcuPluralKind::Cardinal => {
510 metadata.kind = MessageArgumentKind::Number;
511 metadata.role.get_or_insert_with(|| "count".to_owned());
512 }
513 IcuPluralKind::Ordinal => {
514 metadata.kind = MessageArgumentKind::Number;
515 metadata.role.get_or_insert_with(|| "ordinal".to_owned());
516 }
517 }
518 }
519 args
520}
521
522fn merge_icu_argument(metadata: &mut MessageArgumentMetadata, kind: IcuArgumentKind) {
523 let next = argument_kind(kind);
524 if metadata.kind == MessageArgumentKind::Unknown || next != MessageArgumentKind::Unknown {
525 metadata.kind = next;
526 }
527}
528
529fn metadata_for_icu_argument(kind: IcuArgumentKind) -> MessageArgumentMetadata {
530 MessageArgumentMetadata {
531 kind: argument_kind(kind),
532 role: role_for_icu_argument(kind),
533 values: Vec::new(),
534 format: None,
535 }
536}
537
538fn argument_kind(kind: IcuArgumentKind) -> MessageArgumentKind {
539 match kind {
540 IcuArgumentKind::Argument => MessageArgumentKind::Unknown,
541 IcuArgumentKind::Number | IcuArgumentKind::Plural | IcuArgumentKind::SelectOrdinal => {
542 MessageArgumentKind::Number
543 }
544 IcuArgumentKind::Date => MessageArgumentKind::Date,
545 IcuArgumentKind::Time => MessageArgumentKind::Time,
546 IcuArgumentKind::List => MessageArgumentKind::List,
547 IcuArgumentKind::Duration => MessageArgumentKind::Duration,
548 IcuArgumentKind::Ago => MessageArgumentKind::RelativeTime,
549 IcuArgumentKind::Name => MessageArgumentKind::Name,
550 IcuArgumentKind::Select => MessageArgumentKind::Enum,
551 }
552}
553
554fn role_for_icu_argument(kind: IcuArgumentKind) -> Option<String> {
555 match kind {
556 IcuArgumentKind::Plural => Some("count".to_owned()),
557 IcuArgumentKind::SelectOrdinal => Some("ordinal".to_owned()),
558 _ => None,
559 }
560}
561
562fn style_kind(kind: IcuStyleKind) -> MessageFormatStyleKind {
563 match kind {
564 IcuStyleKind::None => MessageFormatStyleKind::None,
565 IcuStyleKind::Predefined => MessageFormatStyleKind::Predefined,
566 IcuStyleKind::Skeleton => MessageFormatStyleKind::Skeleton,
567 IcuStyleKind::Pattern => MessageFormatStyleKind::Pattern,
568 }
569}
570
571fn derive_selectors(analysis: &IcuAnalysis) -> BTreeMap<String, MessageSelectorMetadata> {
572 let mut selectors = BTreeMap::new();
573 for select in &analysis.selects {
574 selectors.insert(
575 select.name.clone(),
576 MessageSelectorMetadata {
577 kind: MessageSelectorKind::Select,
578 cases: select.selectors.clone(),
579 offset: None,
580 },
581 );
582 }
583 for plural in &analysis.plurals {
584 selectors.insert(
585 plural.name.clone(),
586 MessageSelectorMetadata {
587 kind: match plural.kind {
588 IcuPluralKind::Cardinal => MessageSelectorKind::Plural,
589 IcuPluralKind::Ordinal => MessageSelectorKind::SelectOrdinal,
590 },
591 cases: plural.selectors.clone(),
592 offset: (plural.offset != 0).then_some(plural.offset),
593 },
594 );
595 }
596 selectors
597}
598
599fn validate_args(
600 input: &BTreeMap<String, MessageArgumentMetadataInput>,
601 derived: &BTreeMap<String, MessageArgumentMetadata>,
602 report: &mut MessageMetadataValidationReport,
603) {
604 let normalized = input
605 .iter()
606 .map(|(name, argument)| (name, argument.clone().into_metadata()))
607 .collect::<BTreeMap<_, _>>();
608
609 for name in derived.keys() {
610 if !normalized.contains_key(name) {
611 report.diagnostics.push(MessageMetadataDiagnostic::new(
612 IcuDiagnosticSeverity::Warning,
613 diagnostic_codes::metadata::MISSING_ARGUMENT,
614 format!("Message metadata is missing parsed ICU argument `{name}`."),
615 Some(name.clone()),
616 ));
617 }
618 }
619 for (name, argument) in &normalized {
620 let Some(derived_argument) = derived.get(*name) else {
621 report.diagnostics.push(MessageMetadataDiagnostic::new(
622 IcuDiagnosticSeverity::Error,
623 diagnostic_codes::metadata::EXTRA_ARGUMENT,
624 format!("Message metadata declares argument `{name}` that is not used by `msgid`."),
625 Some((*name).clone()),
626 ));
627 continue;
628 };
629 if argument.kind != MessageArgumentKind::Unknown
630 && derived_argument.kind != MessageArgumentKind::Unknown
631 && argument.kind != derived_argument.kind
632 {
633 report.diagnostics.push(MessageMetadataDiagnostic::new(
634 IcuDiagnosticSeverity::Error,
635 diagnostic_codes::metadata::ARGUMENT_KIND_MISMATCH,
636 format!(
637 "Message metadata declares argument `{name}` as {:?}, but `msgid` uses {:?}.",
638 argument.kind, derived_argument.kind
639 ),
640 Some((*name).clone()),
641 ));
642 }
643 }
644}
645
646fn validate_tags(
647 input: &[String],
648 derived: &[String],
649 report: &mut MessageMetadataValidationReport,
650) {
651 let input = input.iter().cloned().collect::<BTreeSet<_>>();
652 let derived = derived.iter().cloned().collect::<BTreeSet<_>>();
653 for tag in &derived {
654 if !input.contains(tag) {
655 report.diagnostics.push(MessageMetadataDiagnostic::new(
656 IcuDiagnosticSeverity::Warning,
657 diagnostic_codes::metadata::MISSING_TAG,
658 format!("Message metadata is missing parsed ICU tag `{tag}`."),
659 Some(tag.clone()),
660 ));
661 }
662 }
663 for tag in &input {
664 if !derived.contains(tag) {
665 report.diagnostics.push(MessageMetadataDiagnostic::new(
666 IcuDiagnosticSeverity::Error,
667 diagnostic_codes::metadata::EXTRA_TAG,
668 format!("Message metadata declares tag `{tag}` that is not used by `msgid`."),
669 Some(tag.clone()),
670 ));
671 }
672 }
673}
674
675fn validate_selectors(
676 input: &BTreeMap<String, MessageSelectorMetadata>,
677 derived: &BTreeMap<String, MessageSelectorMetadata>,
678 report: &mut MessageMetadataValidationReport,
679) {
680 for name in derived.keys() {
681 if !input.contains_key(name) {
682 report.diagnostics.push(MessageMetadataDiagnostic::new(
683 IcuDiagnosticSeverity::Warning,
684 diagnostic_codes::metadata::MISSING_SELECTOR,
685 format!("Message metadata is missing parsed ICU selector `{name}`."),
686 Some(name.clone()),
687 ));
688 }
689 }
690 for (name, selector) in input {
691 let Some(derived_selector) = derived.get(name) else {
692 report.diagnostics.push(MessageMetadataDiagnostic::new(
693 IcuDiagnosticSeverity::Error,
694 diagnostic_codes::metadata::EXTRA_SELECTOR,
695 format!("Message metadata declares selector `{name}` that is not used by `msgid`."),
696 Some(name.clone()),
697 ));
698 continue;
699 };
700 if selector.kind != derived_selector.kind {
701 report.diagnostics.push(MessageMetadataDiagnostic::new(
702 IcuDiagnosticSeverity::Error,
703 diagnostic_codes::metadata::SELECTOR_KIND_MISMATCH,
704 format!(
705 "Message metadata declares selector `{name}` as {:?}, but `msgid` uses {:?}.",
706 selector.kind, derived_selector.kind
707 ),
708 Some(name.clone()),
709 ));
710 }
711 let derived_cases = derived_selector
712 .cases
713 .iter()
714 .cloned()
715 .collect::<BTreeSet<_>>();
716 let input_cases = selector.cases.iter().cloned().collect::<BTreeSet<_>>();
717 for case in &derived_selector.cases {
718 if !input_cases.contains(case) {
719 report.diagnostics.push(MessageMetadataDiagnostic::new(
720 IcuDiagnosticSeverity::Warning,
721 diagnostic_codes::metadata::MISSING_SELECTOR_CASE,
722 format!(
723 "Message metadata is missing parsed ICU selector case `{case}` for `{name}`."
724 ),
725 Some(format!("{name}:{case}")),
726 ));
727 }
728 }
729 for case in &selector.cases {
730 if !derived_cases.contains(case) {
731 report.diagnostics.push(MessageMetadataDiagnostic::new(
732 IcuDiagnosticSeverity::Error,
733 diagnostic_codes::metadata::EXTRA_SELECTOR_CASE,
734 format!(
735 "Message metadata declares selector case `{case}` for `{name}` that is not used by `msgid`."
736 ),
737 Some(format!("{name}:{case}")),
738 ));
739 }
740 }
741 if selector.offset != derived_selector.offset {
742 report.diagnostics.push(MessageMetadataDiagnostic::new(
743 IcuDiagnosticSeverity::Error,
744 diagnostic_codes::metadata::SELECTOR_OFFSET_MISMATCH,
745 format!(
746 "Message metadata declares selector `{name}` offset {:?}, but `msgid` uses {:?}.",
747 selector.offset, derived_selector.offset
748 ),
749 Some(name.clone()),
750 ));
751 }
752 }
753}
754
755fn unique_strings<'a>(values: impl IntoIterator<Item = &'a str>) -> Vec<String> {
756 let mut seen = BTreeSet::new();
757 let mut out = Vec::new();
758 for value in values {
759 if seen.insert(value.to_owned()) {
760 out.push(value.to_owned());
761 }
762 }
763 out
764}
765
766#[cfg(test)]
767mod tests {
768 use std::collections::BTreeMap;
769
770 use crate::{
771 IcuDiagnosticSeverity, MessageArgumentFormatMetadata, MessageArgumentKind,
772 MessageArgumentMetadata, MessageArgumentMetadataInput, MessageFormatStyleKind,
773 MessageMetadataInput, MessageSelectorKind, MessageSelectorMetadata,
774 derive_message_metadata_from_icu, normalize_message_metadata, validate_message_metadata,
775 };
776
777 #[test]
778 fn minimal_metadata_normalizes_without_semantic_fields() {
779 let input = MessageMetadataInput::new("Cart");
780
781 let metadata = normalize_message_metadata(input).expect("normalize metadata");
782
783 assert_eq!(metadata.msgid, "Cart");
784 assert!(metadata.args.is_empty());
785 assert!(metadata.tags.is_empty());
786 assert!(metadata.selectors.is_empty());
787 }
788
789 #[test]
790 fn placeholder_msgid_derives_argument_when_omitted() {
791 let metadata =
792 derive_message_metadata_from_icu("Hello {name}", None).expect("derive metadata");
793
794 assert_eq!(
795 metadata.args.get("name").map(|argument| argument.kind),
796 Some(MessageArgumentKind::Unknown)
797 );
798 }
799
800 #[test]
801 fn shorthand_argument_input_normalizes_to_object_metadata() {
802 let mut args = BTreeMap::new();
803 args.insert(
804 "name".to_owned(),
805 MessageArgumentMetadataInput::Kind(MessageArgumentKind::String),
806 );
807 let mut input = MessageMetadataInput::new("Hello {name}");
808 input.args = Some(args);
809
810 let metadata = normalize_message_metadata(input).expect("normalize metadata");
811
812 assert_eq!(
813 metadata.args.get("name").map(|argument| argument.kind),
814 Some(MessageArgumentKind::String)
815 );
816 }
817
818 #[test]
819 fn msgctxt_and_msgid_remain_exact_source_identity() {
820 let mut input = MessageMetadataInput::new("Home");
821 input.msgctxt = Some("navigation".to_owned());
822
823 let metadata = normalize_message_metadata(input).expect("normalize metadata");
824
825 assert_eq!(metadata.msgid, "Home");
826 assert_eq!(metadata.msgctxt.as_deref(), Some("navigation"));
827 }
828
829 #[test]
830 fn plural_msgid_derives_count_argument_and_selector_cases() {
831 let metadata = derive_message_metadata_from_icu(
832 "{count, plural, one {One item} other {# items}}",
833 None,
834 )
835 .expect("derive metadata");
836
837 let count = metadata.args.get("count").expect("count metadata");
838 assert_eq!(count.kind, MessageArgumentKind::Number);
839 assert_eq!(count.role.as_deref(), Some("count"));
840 let selector = metadata.selectors.get("count").expect("count selector");
841 assert_eq!(selector.kind, MessageSelectorKind::Plural);
842 assert_eq!(selector.cases, vec!["one", "other"]);
843 assert_eq!(selector.offset, None);
844 }
845
846 #[test]
847 fn select_msgid_derives_enum_argument_and_selector_cases() {
848 let metadata = derive_message_metadata_from_icu(
849 "{status, select, shipped {Shipped} cancelled {Cancelled} other {Updated}}",
850 None,
851 )
852 .expect("derive metadata");
853
854 let status = metadata.args.get("status").expect("status argument");
855 assert_eq!(status.kind, MessageArgumentKind::Enum);
856 assert_eq!(status.values, vec!["shipped", "cancelled", "other"]);
857 assert_eq!(
858 metadata
859 .selectors
860 .get("status")
861 .map(|selector| selector.kind),
862 Some(MessageSelectorKind::Select)
863 );
864 }
865
866 #[test]
867 fn rich_text_tags_derive_into_metadata() {
868 let metadata =
869 derive_message_metadata_from_icu("Read <link>terms</link>", None).expect("derive");
870
871 assert_eq!(metadata.tags, vec!["link"]);
872 }
873
874 #[test]
875 fn conflicting_explicit_metadata_emits_diagnostics() {
876 let mut args = BTreeMap::new();
877 args.insert(
878 "count".to_owned(),
879 MessageArgumentMetadataInput::Kind(MessageArgumentKind::String),
880 );
881 args.insert(
882 "unused".to_owned(),
883 MessageArgumentMetadataInput::Kind(MessageArgumentKind::String),
884 );
885 let mut input =
886 MessageMetadataInput::new("{count, plural, one {One item} other {# items}}");
887 input.args = Some(args);
888
889 let report = validate_message_metadata(&input);
890 let codes = report
891 .diagnostics
892 .iter()
893 .map(|diagnostic| diagnostic.code.as_str())
894 .collect::<Vec<_>>();
895
896 assert!(report.has_errors());
897 assert!(codes.contains(&"metadata.argument_kind_mismatch"));
898 assert!(codes.contains(&"metadata.extra_argument"));
899 assert!(
900 report
901 .diagnostics
902 .iter()
903 .any(|diagnostic| diagnostic.severity == IcuDiagnosticSeverity::Error)
904 );
905 }
906
907 #[test]
908 fn selector_metadata_reports_missing_source_cases() {
909 let mut selectors = BTreeMap::new();
910 selectors.insert(
911 "count".to_owned(),
912 MessageSelectorMetadata {
913 kind: MessageSelectorKind::Plural,
914 cases: vec!["one".to_owned()],
915 offset: None,
916 },
917 );
918 let mut input =
919 MessageMetadataInput::new("{count, plural, one {One item} other {# items}}");
920 input.selectors = Some(selectors);
921
922 let report = validate_message_metadata(&input);
923
924 assert!(report.diagnostics.iter().any(|diagnostic| {
925 diagnostic.code == "metadata.missing_selector_case"
926 && diagnostic.name.as_deref() == Some("count:other")
927 }));
928 }
929
930 #[test]
931 fn id_style_msgid_is_accepted_without_special_behavior() {
932 let input = MessageMetadataInput::new("cart.item_count");
933
934 let report = validate_message_metadata(&input);
935
936 assert!(report.diagnostics.is_empty());
937 }
938
939 #[test]
940 fn json_shorthand_argument_deserializes() {
941 let input: MessageMetadataInput = serde_json::from_str(
942 r#"{
943 "msgid": "Hello {name}",
944 "args": {
945 "name": "string"
946 }
947 }"#,
948 )
949 .expect("deserialize metadata input");
950
951 let metadata = normalize_message_metadata(input).expect("normalize metadata");
952
953 assert_eq!(
954 metadata.args.get("name").map(|argument| argument.kind),
955 Some(MessageArgumentKind::String)
956 );
957 }
958
959 #[test]
960 fn explicit_details_origin_tags_and_selectors_are_preserved_and_enriched() {
961 let mut args = BTreeMap::new();
962 args.insert(
963 "name".to_owned(),
964 MessageArgumentMetadata {
965 kind: MessageArgumentKind::String,
966 ..MessageArgumentMetadata::default()
967 }
968 .into(),
969 );
970 let mut selectors = BTreeMap::new();
971 selectors.insert(
972 "status".to_owned(),
973 MessageSelectorMetadata {
974 kind: MessageSelectorKind::Select,
975 cases: vec!["open".to_owned(), "other".to_owned()],
976 offset: None,
977 },
978 );
979 let mut input = MessageMetadataInput::new(
980 "<link>{status, select, open {Hello {name}} other {Done}}</link> <strong>!</strong>",
981 );
982 input.description = Some("Shown in the activity feed.".to_owned());
983 input.origin.push(crate::MessageOriginMetadata {
984 file: Some("src/app.rs".to_owned()),
985 line: Some(12),
986 component: Some("ActivityFeed".to_owned()),
987 route: Some("/activity".to_owned()),
988 });
989 input.args = Some(args);
990 input.tags = Some(vec!["link".to_owned(), "link".to_owned()]);
991 input.selectors = Some(selectors);
992
993 let metadata = normalize_message_metadata(input).expect("normalize metadata");
994
995 assert_eq!(
996 metadata.args.get("name").map(|argument| argument.kind),
997 Some(MessageArgumentKind::String)
998 );
999 assert_eq!(
1000 metadata.args.get("status").map(|argument| argument.kind),
1001 Some(MessageArgumentKind::Enum)
1002 );
1003 assert_eq!(metadata.tags, vec!["link", "strong"]);
1004 assert_eq!(
1005 metadata.description.as_deref(),
1006 Some("Shown in the activity feed.")
1007 );
1008 assert_eq!(metadata.origin[0].file.as_deref(), Some("src/app.rs"));
1009 assert_eq!(
1010 metadata
1011 .selectors
1012 .get("status")
1013 .map(|selector| selector.cases.as_slice()),
1014 Some(&["open".to_owned(), "other".to_owned()][..])
1015 );
1016 }
1017
1018 #[test]
1019 fn formatter_metadata_derives_kinds_styles_roles_and_selector_offsets() {
1020 let metadata = derive_message_metadata_from_icu(
1021 "{price, number, ::currency/USD} {created, date, short} {time, time, HH:mm} \
1022 {items, list, conjunction} {elapsed, duration} {since, ago} {person, name} \
1023 {rank, selectordinal, offset:1 one {#st} other {#th}}",
1024 None,
1025 )
1026 .expect("derive metadata");
1027
1028 assert_eq!(
1029 metadata
1030 .args
1031 .get("price")
1032 .and_then(|argument| argument.format.as_ref()),
1033 Some(&MessageArgumentFormatMetadata {
1034 style: Some("::currency/USD".to_owned()),
1035 style_kind: Some(MessageFormatStyleKind::Skeleton),
1036 })
1037 );
1038 assert_eq!(
1039 metadata
1040 .args
1041 .get("created")
1042 .and_then(|argument| argument.format.as_ref()),
1043 Some(&MessageArgumentFormatMetadata {
1044 style: Some("short".to_owned()),
1045 style_kind: Some(MessageFormatStyleKind::Predefined),
1046 })
1047 );
1048 assert_eq!(
1049 metadata
1050 .args
1051 .get("time")
1052 .and_then(|argument| argument.format.as_ref()),
1053 Some(&MessageArgumentFormatMetadata {
1054 style: Some("HH:mm".to_owned()),
1055 style_kind: Some(MessageFormatStyleKind::Pattern),
1056 })
1057 );
1058 assert_eq!(
1059 metadata.args.get("items").map(|argument| argument.kind),
1060 Some(MessageArgumentKind::List)
1061 );
1062 assert_eq!(
1063 metadata.args.get("elapsed").map(|argument| argument.kind),
1064 Some(MessageArgumentKind::Duration)
1065 );
1066 assert_eq!(
1067 metadata.args.get("since").map(|argument| argument.kind),
1068 Some(MessageArgumentKind::RelativeTime)
1069 );
1070 assert_eq!(
1071 metadata.args.get("person").map(|argument| argument.kind),
1072 Some(MessageArgumentKind::Name)
1073 );
1074 let rank = metadata.args.get("rank").expect("rank argument");
1075 assert_eq!(rank.kind, MessageArgumentKind::Number);
1076 assert_eq!(rank.role.as_deref(), Some("ordinal"));
1077 let selector = metadata.selectors.get("rank").expect("rank selector");
1078 assert_eq!(selector.kind, MessageSelectorKind::SelectOrdinal);
1079 assert_eq!(selector.offset, Some(1));
1080 }
1081
1082 #[test]
1083 fn invalid_msgid_reports_metadata_diagnostic() {
1084 let input = MessageMetadataInput::new("{count, plural, one {One item}}");
1085
1086 let report = validate_message_metadata(&input);
1087
1088 assert_eq!(
1089 report
1090 .diagnostics
1091 .iter()
1092 .map(|diagnostic| diagnostic.code.as_str())
1093 .collect::<Vec<_>>(),
1094 vec!["metadata.invalid_msgid"]
1095 );
1096 assert!(report.has_errors());
1097 }
1098
1099 #[test]
1100 fn validation_reports_missing_and_extra_tags_and_selectors() {
1101 let mut selectors = BTreeMap::new();
1102 selectors.insert(
1103 "status".to_owned(),
1104 MessageSelectorMetadata {
1105 kind: MessageSelectorKind::Plural,
1106 cases: vec!["open".to_owned(), "closed".to_owned(), "other".to_owned()],
1107 offset: Some(1),
1108 },
1109 );
1110 selectors.insert(
1111 "unused".to_owned(),
1112 MessageSelectorMetadata {
1113 kind: MessageSelectorKind::Select,
1114 cases: vec!["other".to_owned()],
1115 offset: None,
1116 },
1117 );
1118 let mut input =
1119 MessageMetadataInput::new("<link>{status, select, open {Open} other {Other}}</link>");
1120 input.tags = Some(vec!["button".to_owned()]);
1121 input.selectors = Some(selectors);
1122
1123 let report = validate_message_metadata(&input);
1124 let codes = report
1125 .diagnostics
1126 .iter()
1127 .map(|diagnostic| diagnostic.code.as_str())
1128 .collect::<Vec<_>>();
1129
1130 assert!(report.has_errors());
1131 assert!(codes.contains(&"metadata.missing_tag"));
1132 assert!(codes.contains(&"metadata.extra_tag"));
1133 assert!(codes.contains(&"metadata.selector_kind_mismatch"));
1134 assert!(codes.contains(&"metadata.extra_selector"));
1135 assert!(codes.contains(&"metadata.extra_selector_case"));
1136 assert!(codes.contains(&"metadata.selector_offset_mismatch"));
1137 }
1138
1139 #[test]
1140 fn validation_reports_missing_argument_tag_and_selector_metadata() {
1141 let mut input = MessageMetadataInput::new(
1142 "<link>{count, plural, one {{name} has one item} other {{name} has # items}}</link>",
1143 );
1144 input.args = Some(BTreeMap::new());
1145 input.tags = Some(Vec::new());
1146 input.selectors = Some(BTreeMap::new());
1147
1148 let report = validate_message_metadata(&input);
1149 let codes = report
1150 .diagnostics
1151 .iter()
1152 .map(|diagnostic| diagnostic.code.as_str())
1153 .collect::<Vec<_>>();
1154
1155 assert!(!report.has_errors());
1156 assert!(codes.contains(&"metadata.missing_argument"));
1157 assert!(codes.contains(&"metadata.missing_tag"));
1158 assert!(codes.contains(&"metadata.missing_selector"));
1159 }
1160}