Skip to main content

ferrocat_icu/
metadata.rs

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/// Authoring input for semantic message metadata.
14///
15/// This is the progressive, JSON-friendly shape. Only `msgid` is required;
16/// semantic fields may be omitted and derived from the ICU MessageFormat v1
17/// source where possible.
18#[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    /// Exact catalog identity and authored source payload.
23    pub msgid: String,
24    /// Optional gettext-style context used to disambiguate identical `msgid`s.
25    #[cfg_attr(
26        feature = "serde",
27        serde(default, skip_serializing_if = "Option::is_none")
28    )]
29    pub msgctxt: Option<String>,
30    /// Optional translator-facing note.
31    #[cfg_attr(
32        feature = "serde",
33        serde(default, skip_serializing_if = "Option::is_none")
34    )]
35    pub description: Option<String>,
36    /// Optional extraction origins.
37    #[cfg_attr(
38        feature = "serde",
39        serde(default, skip_serializing_if = "Vec::is_empty")
40    )]
41    pub origin: Vec<MessageOriginMetadata>,
42    /// Optional argument metadata keyed by argument name.
43    #[cfg_attr(
44        feature = "serde",
45        serde(default, skip_serializing_if = "Option::is_none")
46    )]
47    pub args: Option<BTreeMap<String, MessageArgumentMetadataInput>>,
48    /// Optional rich-text tag names.
49    #[cfg_attr(
50        feature = "serde",
51        serde(default, skip_serializing_if = "Option::is_none")
52    )]
53    pub tags: Option<Vec<String>>,
54    /// Optional selector metadata keyed by selecting argument name.
55    #[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    /// Creates a minimal metadata input from a `msgid`.
64    #[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/// Normalized semantic metadata for one source message.
79#[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    /// Exact catalog identity and authored source payload.
84    pub msgid: String,
85    /// Optional gettext-style context used to disambiguate identical `msgid`s.
86    #[cfg_attr(
87        feature = "serde",
88        serde(default, skip_serializing_if = "Option::is_none")
89    )]
90    pub msgctxt: Option<String>,
91    /// Optional translator-facing note.
92    #[cfg_attr(
93        feature = "serde",
94        serde(default, skip_serializing_if = "Option::is_none")
95    )]
96    pub description: Option<String>,
97    /// Optional extraction origins.
98    #[cfg_attr(
99        feature = "serde",
100        serde(default, skip_serializing_if = "Vec::is_empty")
101    )]
102    pub origin: Vec<MessageOriginMetadata>,
103    /// Normalized argument metadata keyed by argument name.
104    #[cfg_attr(
105        feature = "serde",
106        serde(default, skip_serializing_if = "BTreeMap::is_empty")
107    )]
108    pub args: BTreeMap<String, MessageArgumentMetadata>,
109    /// Rich-text tag names in first-seen order.
110    #[cfg_attr(
111        feature = "serde",
112        serde(default, skip_serializing_if = "Vec::is_empty")
113    )]
114    pub tags: Vec<String>,
115    /// Normalized selector metadata keyed by selecting argument name.
116    #[cfg_attr(
117        feature = "serde",
118        serde(default, skip_serializing_if = "BTreeMap::is_empty")
119    )]
120    pub selectors: BTreeMap<String, MessageSelectorMetadata>,
121}
122
123/// Extraction origin attached to semantic message metadata.
124#[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    /// Source file path, when known.
129    #[cfg_attr(
130        feature = "serde",
131        serde(default, skip_serializing_if = "Option::is_none")
132    )]
133    pub file: Option<String>,
134    /// Source line number, when known.
135    #[cfg_attr(
136        feature = "serde",
137        serde(default, skip_serializing_if = "Option::is_none")
138    )]
139    pub line: Option<u32>,
140    /// Host component name, when known.
141    #[cfg_attr(
142        feature = "serde",
143        serde(default, skip_serializing_if = "Option::is_none")
144    )]
145    pub component: Option<String>,
146    /// Host route or page identifier, when known.
147    #[cfg_attr(
148        feature = "serde",
149        serde(default, skip_serializing_if = "Option::is_none")
150    )]
151    pub route: Option<String>,
152}
153
154/// Progressive authoring input for one argument.
155#[derive(Debug, Clone, PartialEq, Eq)]
156#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
157#[cfg_attr(feature = "serde", serde(untagged))]
158pub enum MessageArgumentMetadataInput {
159    /// Shorthand kind form, for example `"string"`.
160    Kind(MessageArgumentKind),
161    /// Full object form.
162    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/// Normalized semantic metadata for one message argument.
190#[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    /// Broad message-level data kind.
195    #[cfg_attr(feature = "serde", serde(default))]
196    pub kind: MessageArgumentKind,
197    /// Optional semantic role such as `count`, `currency`, or `url`.
198    #[cfg_attr(
199        feature = "serde",
200        serde(default, skip_serializing_if = "Option::is_none")
201    )]
202    pub role: Option<String>,
203    /// Allowed enum/select values when known from extraction.
204    #[cfg_attr(
205        feature = "serde",
206        serde(default, skip_serializing_if = "Vec::is_empty")
207    )]
208    pub values: Vec<String>,
209    /// Optional formatter metadata.
210    #[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/// Broad argument kind used by semantic message metadata.
229#[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    /// Text or string-like value.
234    String,
235    /// Number-like value.
236    Number,
237    /// Date value.
238    Date,
239    /// Time value.
240    Time,
241    /// Date-time value.
242    Datetime,
243    /// Boolean value.
244    Boolean,
245    /// Enumerated value.
246    Enum,
247    /// List value.
248    List,
249    /// Duration value.
250    Duration,
251    /// Relative-time value.
252    RelativeTime,
253    /// Person, region, or display name value.
254    Name,
255    /// Unknown or intentionally unspecified value.
256    #[default]
257    Unknown,
258}
259
260/// Formatter metadata attached to an argument.
261#[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    /// Raw formatter style, when present.
266    #[cfg_attr(
267        feature = "serde",
268        serde(default, skip_serializing_if = "Option::is_none")
269    )]
270    pub style: Option<String>,
271    /// Classification of the formatter style.
272    #[cfg_attr(
273        feature = "serde",
274        serde(default, skip_serializing_if = "Option::is_none")
275    )]
276    pub style_kind: Option<MessageFormatStyleKind>,
277}
278
279/// Classification of a message formatter style.
280#[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    /// Formatter has no style segment.
285    None,
286    /// Formatter uses a known named style.
287    Predefined,
288    /// Formatter uses an ICU skeleton.
289    Skeleton,
290    /// Formatter uses an opaque pattern-style segment.
291    Pattern,
292}
293
294/// Selector metadata attached to a selecting argument.
295#[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    /// Selector expression kind.
300    pub kind: MessageSelectorKind,
301    /// Known selector cases in source order.
302    #[cfg_attr(
303        feature = "serde",
304        serde(default, skip_serializing_if = "Vec::is_empty")
305    )]
306    pub cases: Vec<String>,
307    /// Optional plural offset. Omitted when zero.
308    #[cfg_attr(
309        feature = "serde",
310        serde(default, skip_serializing_if = "Option::is_none")
311    )]
312    pub offset: Option<u32>,
313}
314
315/// Selector kind used by semantic message metadata.
316#[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    /// General select expression.
321    Select,
322    /// Cardinal plural expression.
323    Plural,
324    /// Ordinal plural expression.
325    #[cfg_attr(feature = "serde", serde(rename = "selectordinal"))]
326    SelectOrdinal,
327}
328
329/// One diagnostic emitted while validating semantic message metadata.
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct MessageMetadataDiagnostic {
332    /// Severity for the diagnostic.
333    pub severity: IcuDiagnosticSeverity,
334    /// Stable machine-readable diagnostic code.
335    pub code: DiagnosticCode,
336    /// Human-readable explanation of the condition.
337    pub message: String,
338    /// Argument, selector, tag, or field name associated with the diagnostic.
339    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/// Report returned by [`validate_message_metadata`].
359#[derive(Debug, Clone, PartialEq, Eq, Default)]
360pub struct MessageMetadataValidationReport {
361    /// Diagnostics found while validating the metadata.
362    pub diagnostics: Vec<MessageMetadataDiagnostic>,
363}
364
365impl MessageMetadataValidationReport {
366    /// Returns `true` when the report contains at least one error diagnostic.
367    #[must_use]
368    pub fn has_errors(&self) -> bool {
369        self.diagnostics
370            .iter()
371            .any(|diagnostic| diagnostic.severity == IcuDiagnosticSeverity::Error)
372    }
373}
374
375/// Derives normalized semantic metadata from an ICU MessageFormat v1 `msgid`.
376///
377/// Plain text messages are valid and produce metadata without arguments, tags,
378/// or selectors.
379///
380/// # Errors
381///
382/// Returns an ICU parse error when `msgid` is not valid ICU MessageFormat v1.
383pub 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
400/// Normalizes progressive semantic message metadata into canonical object form.
401///
402/// Omitted semantic fields are derived from `msgid`. Explicit metadata is kept
403/// and enriched with any derived facts that do not conflict.
404///
405/// # Errors
406///
407/// Returns an ICU parse error when `input.msgid` is not valid ICU MessageFormat
408/// v1.
409pub 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/// Validates progressive semantic message metadata against its `msgid`.
449#[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}