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