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