Skip to main content

ferrocat_po/api/
audit.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use ferrocat_icu::{
4    IcuCompatibilityOptions, IcuDiagnosticSeverity, MessageMetadataInput, compare_icu_messages,
5    normalize_message_metadata, validate_message_metadata,
6};
7#[cfg(feature = "serde")]
8use serde::{Deserialize, Serialize};
9
10use crate::diagnostic_codes::{self, DiagnosticCode};
11
12use super::icu_syntax::parse_icu_with_syntax_policy;
13use super::message_status::{
14    CatalogMessageStatus, active_message_keys, classify_expected_message, is_extra_target_message,
15};
16use super::{
17    ApiError, CatalogMessage, CatalogMessageKey, DiagnosticSeverity, EffectiveTranslationRef,
18    IcuSyntaxPolicy, NormalizedParsedCatalog, validate_source_locale,
19};
20
21/// Options controlling catalog audit checks.
22#[derive(Debug, Clone, PartialEq, Eq, Default)]
23#[non_exhaustive]
24pub struct CatalogAuditOptions<'a> {
25    /// Source locale used as the expected message set.
26    pub source_locale: &'a str,
27    /// Optional target locale filter. Empty means all non-source locales present in `catalogs`.
28    pub locales: &'a [&'a str],
29    /// Optional source-side semantic metadata records.
30    pub metadata: &'a [MessageMetadataInput],
31    /// Individual audit checks to run.
32    pub checks: CatalogAuditChecks,
33    /// ICU-specific options used by syntax and compatibility checks.
34    pub icu_options: CatalogAuditIcuOptions,
35}
36
37impl<'a> CatalogAuditOptions<'a> {
38    /// Creates audit options with the required source locale set.
39    #[must_use]
40    pub fn new(source_locale: &'a str) -> Self {
41        Self {
42            source_locale,
43            ..Self::default()
44        }
45    }
46
47    /// Returns options that audit only the given target locales.
48    #[must_use]
49    pub fn with_locales(mut self, locales: &'a [&'a str]) -> Self {
50        self.locales = locales;
51        self
52    }
53
54    /// Returns options that validate the given source-side metadata records.
55    #[must_use]
56    pub fn with_metadata(mut self, metadata: &'a [MessageMetadataInput]) -> Self {
57        self.metadata = metadata;
58        self
59    }
60
61    /// Returns options that run the given audit check set.
62    #[must_use]
63    pub fn with_checks(mut self, checks: CatalogAuditChecks) -> Self {
64        self.checks = checks;
65        self
66    }
67
68    /// Returns options that use the given ICU parser and compatibility settings.
69    #[must_use]
70    pub fn with_icu_options(mut self, icu_options: CatalogAuditIcuOptions) -> Self {
71        self.icu_options = icu_options;
72        self
73    }
74}
75
76/// ICU-specific options used by catalog audit checks.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78#[non_exhaustive]
79pub struct CatalogAuditIcuOptions {
80    /// ICU parser behavior used by syntax and compatibility checks.
81    pub syntax_policy: IcuSyntaxPolicy,
82}
83
84impl CatalogAuditIcuOptions {
85    /// Creates audit ICU options with default strict parser behavior.
86    #[must_use]
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Returns options that parse messages with the given ICU syntax policy.
92    #[must_use]
93    pub fn with_syntax_policy(mut self, syntax_policy: IcuSyntaxPolicy) -> Self {
94        self.syntax_policy = syntax_policy;
95        self
96    }
97}
98
99/// Enables or disables individual catalog audit checks.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101#[non_exhaustive]
102pub struct CatalogAuditChecks {
103    /// Check that target locales cover active source messages.
104    pub completeness: bool,
105    /// Check for active target messages that are not active in the source catalog.
106    pub extra_messages: bool,
107    /// Validate active source and target message strings as ICU MessageFormat v1.
108    pub icu_syntax: bool,
109    /// Compare target ICU structure against source ICU structure.
110    pub icu_compatibility: bool,
111    /// Validate source-side semantic message metadata.
112    pub semantic_metadata: bool,
113    /// Report obsolete entries.
114    pub obsolete_entries: bool,
115}
116
117impl Default for CatalogAuditChecks {
118    fn default() -> Self {
119        Self {
120            completeness: true,
121            extra_messages: true,
122            icu_syntax: true,
123            icu_compatibility: true,
124            semantic_metadata: true,
125            obsolete_entries: true,
126        }
127    }
128}
129
130impl CatalogAuditChecks {
131    /// Returns checks with completeness validation enabled or disabled.
132    #[must_use]
133    pub fn with_completeness(mut self, completeness: bool) -> Self {
134        self.completeness = completeness;
135        self
136    }
137
138    /// Returns checks with extra-message validation enabled or disabled.
139    #[must_use]
140    pub fn with_extra_messages(mut self, extra_messages: bool) -> Self {
141        self.extra_messages = extra_messages;
142        self
143    }
144
145    /// Returns checks with ICU syntax validation enabled or disabled.
146    #[must_use]
147    pub fn with_icu_syntax(mut self, icu_syntax: bool) -> Self {
148        self.icu_syntax = icu_syntax;
149        self
150    }
151
152    /// Returns checks with ICU compatibility validation enabled or disabled.
153    #[must_use]
154    pub fn with_icu_compatibility(mut self, icu_compatibility: bool) -> Self {
155        self.icu_compatibility = icu_compatibility;
156        self
157    }
158
159    /// Returns checks with semantic metadata validation enabled or disabled.
160    #[must_use]
161    pub fn with_semantic_metadata(mut self, semantic_metadata: bool) -> Self {
162        self.semantic_metadata = semantic_metadata;
163        self
164    }
165
166    /// Returns checks with obsolete-entry reporting enabled or disabled.
167    #[must_use]
168    pub fn with_obsolete_entries(mut self, obsolete_entries: bool) -> Self {
169        self.obsolete_entries = obsolete_entries;
170        self
171    }
172}
173
174/// Summary counters for a catalog audit report.
175#[derive(Debug, Clone, PartialEq, Eq, Default)]
176#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
177#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
178pub struct CatalogAuditSummary {
179    /// Active source messages considered expected by the audit.
180    pub source_messages: usize,
181    /// Target locales audited.
182    pub target_locales: usize,
183    /// Total diagnostics emitted.
184    pub diagnostics: usize,
185    /// Error diagnostics emitted.
186    pub errors: usize,
187    /// Warning diagnostics emitted.
188    pub warnings: usize,
189    /// Informational diagnostics emitted.
190    pub infos: usize,
191}
192
193/// Catalog message reference attached to audit diagnostics.
194#[derive(Debug, Clone, PartialEq, Eq)]
195#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
196#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
197pub struct CatalogAuditMessageRef {
198    /// Locale associated with the diagnostic, when known.
199    pub locale: Option<String>,
200    /// Source message identifier.
201    pub msgid: String,
202    /// Optional gettext context.
203    pub msgctxt: Option<String>,
204}
205
206impl CatalogAuditMessageRef {
207    fn new(locale: Option<&str>, key: &CatalogMessageKey) -> Self {
208        Self {
209            locale: locale.map(str::to_owned),
210            msgid: key.msgid.clone(),
211            msgctxt: key.msgctxt.clone(),
212        }
213    }
214}
215
216/// One machine-readable catalog audit diagnostic.
217#[derive(Debug, Clone, PartialEq, Eq)]
218#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
219#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
220pub struct CatalogAuditDiagnostic {
221    /// Severity for the diagnostic.
222    pub severity: DiagnosticSeverity,
223    /// Stable machine-readable diagnostic code.
224    pub code: DiagnosticCode,
225    /// Human-readable explanation of the condition.
226    pub message: String,
227    /// Message identity associated with the diagnostic, when applicable.
228    pub source_key: Option<CatalogAuditMessageRef>,
229    /// Argument, selector, tag, locale, or field name associated with the diagnostic.
230    pub name: Option<String>,
231}
232
233impl CatalogAuditDiagnostic {
234    fn new(
235        severity: DiagnosticSeverity,
236        code: impl Into<DiagnosticCode>,
237        message: impl Into<String>,
238        source_key: Option<CatalogAuditMessageRef>,
239        name: Option<String>,
240    ) -> Self {
241        Self {
242            severity,
243            code: code.into(),
244            message: message.into(),
245            source_key,
246            name,
247        }
248    }
249}
250
251/// Report returned by [`audit_catalogs`].
252#[derive(Debug, Clone, PartialEq, Eq, Default)]
253#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
254#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
255pub struct CatalogAuditReport {
256    /// Aggregate audit counters.
257    pub summary: CatalogAuditSummary,
258    /// Diagnostics found by the audit.
259    pub diagnostics: Vec<CatalogAuditDiagnostic>,
260}
261
262impl CatalogAuditReport {
263    /// Returns `true` when the report contains at least one error diagnostic.
264    #[must_use]
265    pub fn has_errors(&self) -> bool {
266        self.diagnostics
267            .iter()
268            .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
269    }
270}
271
272/// Audits a normalized catalog set for catalog QA and authoring issues.
273///
274/// The audit is read-only: it does not rewrite catalogs, generate fuzzy
275/// matches, or apply source fallback to hide missing target translations.
276///
277/// # Errors
278///
279/// Returns [`ApiError::InvalidArguments`] when `source_locale` is empty or when
280/// catalogs cannot be inspected because their declared locales are missing or
281/// duplicated.
282///
283/// # Examples
284///
285/// ```rust
286/// use ferrocat_po::{CatalogAuditOptions, ParseCatalogOptions, audit_catalogs, parse_catalog};
287///
288/// let source = parse_catalog(
289///     ParseCatalogOptions::new("msgid \"Checkout\"\nmsgstr \"Checkout\"\n", "en").with_locale("en"),
290/// )?
291/// .into_normalized_view()?;
292/// let target = parse_catalog(
293///     ParseCatalogOptions::new("msgid \"Checkout\"\nmsgstr \"\"\n", "en").with_locale("de"),
294/// )?
295/// .into_normalized_view()?;
296///
297/// let report = audit_catalogs(&[&source, &target], &CatalogAuditOptions::new("en"))?;
298/// assert!(report.has_errors());
299/// # Ok::<(), ferrocat_po::ApiError>(())
300/// ```
301pub fn audit_catalogs(
302    catalogs: &[&NormalizedParsedCatalog],
303    options: &CatalogAuditOptions<'_>,
304) -> Result<CatalogAuditReport, ApiError> {
305    validate_source_locale(options.source_locale)?;
306    let catalog_index = index_catalogs(catalogs)?;
307    let mut report = CatalogAuditReport::default();
308    let icu_options = &options.icu_options;
309
310    let Some(source_catalog) = catalog_index.get(options.source_locale).copied() else {
311        report.diagnostics.push(CatalogAuditDiagnostic::new(
312            DiagnosticSeverity::Error,
313            diagnostic_codes::catalog::MISSING_SOURCE_LOCALE,
314            format!(
315                "Catalog audit did not receive source locale `{}`.",
316                options.source_locale
317            ),
318            None,
319            Some(options.source_locale.to_owned()),
320        ));
321        finalize_summary(&mut report, 0, 0);
322        return Ok(report);
323    };
324
325    let source_keys = active_message_keys(source_catalog);
326    let target_locales = select_target_locales(&catalog_index, options, &mut report);
327    let source_locale = source_catalog.parsed_catalog().locale.as_deref();
328
329    if options.checks.obsolete_entries || options.checks.icu_syntax {
330        audit_catalog_entries(
331            source_catalog,
332            source_locale,
333            true,
334            options,
335            icu_options,
336            &mut report,
337        );
338    }
339    if options.checks.semantic_metadata {
340        audit_metadata(options.metadata, &source_keys, &mut report);
341    }
342
343    for target_locale in &target_locales {
344        let Some(target_catalog) = catalog_index.get(target_locale.as_str()).copied() else {
345            continue;
346        };
347        audit_catalog_entries(
348            target_catalog,
349            Some(target_locale),
350            false,
351            options,
352            icu_options,
353            &mut report,
354        );
355        audit_target_catalog(
356            target_catalog,
357            target_locale,
358            &source_keys,
359            options,
360            icu_options,
361            &mut report,
362        );
363    }
364
365    finalize_summary(&mut report, source_keys.len(), target_locales.len());
366    Ok(report)
367}
368
369fn index_catalogs<'a>(
370    catalogs: &'a [&'a NormalizedParsedCatalog],
371) -> Result<BTreeMap<String, &'a NormalizedParsedCatalog>, ApiError> {
372    let mut index = BTreeMap::new();
373    for catalog in catalogs {
374        let locale = catalog
375            .parsed_catalog()
376            .locale
377            .as_deref()
378            .filter(|locale| !locale.trim().is_empty())
379            .ok_or_else(|| {
380                ApiError::InvalidArguments(
381                    "audit_catalogs requires every catalog to declare a locale".to_owned(),
382                )
383            })?;
384        if index.insert(locale.to_owned(), *catalog).is_some() {
385            return Err(ApiError::InvalidArguments(format!(
386                "audit_catalogs received duplicate catalog locale {locale:?}"
387            )));
388        }
389    }
390    Ok(index)
391}
392
393fn select_target_locales(
394    catalog_index: &BTreeMap<String, &NormalizedParsedCatalog>,
395    options: &CatalogAuditOptions<'_>,
396    report: &mut CatalogAuditReport,
397) -> Vec<String> {
398    if options.locales.is_empty() {
399        return catalog_index
400            .keys()
401            .filter(|locale| locale.as_str() != options.source_locale)
402            .cloned()
403            .collect();
404    }
405
406    let mut seen = BTreeSet::new();
407    let mut locales = Vec::new();
408    for locale in options.locales {
409        if !seen.insert((*locale).to_owned()) {
410            continue;
411        }
412        if catalog_index.contains_key(*locale) {
413            if *locale != options.source_locale {
414                locales.push((*locale).to_owned());
415            }
416        } else {
417            report.diagnostics.push(CatalogAuditDiagnostic::new(
418                DiagnosticSeverity::Error,
419                diagnostic_codes::catalog::MISSING_LOCALE,
420                format!("Catalog audit did not receive requested locale `{locale}`."),
421                None,
422                Some((*locale).to_owned()),
423            ));
424        }
425    }
426    locales
427}
428
429fn audit_catalog_entries(
430    catalog: &NormalizedParsedCatalog,
431    locale: Option<&str>,
432    validate_source_identity: bool,
433    options: &CatalogAuditOptions<'_>,
434    icu_options: &CatalogAuditIcuOptions,
435    report: &mut CatalogAuditReport,
436) {
437    for (key, message) in catalog.iter() {
438        let message_ref = CatalogAuditMessageRef::new(locale, key);
439        if options.checks.obsolete_entries && message.obsolete.is_some() {
440            report.diagnostics.push(CatalogAuditDiagnostic::new(
441                DiagnosticSeverity::Info,
442                diagnostic_codes::catalog::OBSOLETE_ENTRY,
443                "Catalog contains an obsolete entry.",
444                Some(message_ref.clone()),
445                None,
446            ));
447        }
448        if options.checks.icu_syntax && message.obsolete.is_none() {
449            audit_icu_syntax_for_message(
450                message,
451                validate_source_identity,
452                icu_options.syntax_policy,
453                &message_ref,
454                report,
455            );
456        }
457    }
458}
459
460fn audit_target_catalog(
461    target_catalog: &NormalizedParsedCatalog,
462    target_locale: &str,
463    source_keys: &BTreeSet<CatalogMessageKey>,
464    options: &CatalogAuditOptions<'_>,
465    icu_options: &CatalogAuditIcuOptions,
466    report: &mut CatalogAuditReport,
467) {
468    if options.checks.completeness {
469        for key in source_keys {
470            let message_ref = CatalogAuditMessageRef::new(Some(target_locale), key);
471            match classify_expected_message(target_catalog, key) {
472                CatalogMessageStatus::Missing | CatalogMessageStatus::Obsolete => {
473                    report.diagnostics.push(CatalogAuditDiagnostic::new(
474                        DiagnosticSeverity::Error,
475                        diagnostic_codes::catalog::MISSING_TRANSLATION,
476                        format!(
477                            "Locale `{target_locale}` is missing translation for source message."
478                        ),
479                        Some(message_ref),
480                        Some(target_locale.to_owned()),
481                    ));
482                }
483                CatalogMessageStatus::Empty => {
484                    report.diagnostics.push(CatalogAuditDiagnostic::new(
485                        DiagnosticSeverity::Error,
486                        diagnostic_codes::catalog::EMPTY_TRANSLATION,
487                        format!("Locale `{target_locale}` has an empty translation."),
488                        Some(message_ref),
489                        Some(target_locale.to_owned()),
490                    ));
491                }
492                CatalogMessageStatus::Translated => {}
493                CatalogMessageStatus::Extra => {
494                    unreachable!("expected source-key classification cannot produce extra status")
495                }
496            }
497        }
498    }
499
500    if options.checks.extra_messages {
501        for (key, message) in target_catalog.iter() {
502            if is_extra_target_message(source_keys, key, message) {
503                report.diagnostics.push(CatalogAuditDiagnostic::new(
504                    DiagnosticSeverity::Warning,
505                    diagnostic_codes::catalog::EXTRA_TRANSLATION,
506                    format!(
507                        "Locale `{target_locale}` contains an active message that is not present in the source catalog."
508                    ),
509                    Some(CatalogAuditMessageRef::new(Some(target_locale), key)),
510                    Some(target_locale.to_owned()),
511                ));
512            }
513        }
514    }
515
516    if options.checks.icu_compatibility {
517        audit_icu_compatibility(
518            target_catalog,
519            target_locale,
520            source_keys,
521            icu_options,
522            report,
523        );
524    }
525}
526
527fn audit_icu_syntax_for_message(
528    message: &CatalogMessage,
529    validate_source_identity: bool,
530    syntax_policy: IcuSyntaxPolicy,
531    message_ref: &CatalogAuditMessageRef,
532    report: &mut CatalogAuditReport,
533) {
534    for value in message_strings(message, validate_source_identity) {
535        if value.trim().is_empty() {
536            continue;
537        }
538        if let Err(error) = parse_icu_with_syntax_policy(value, syntax_policy) {
539            report.diagnostics.push(CatalogAuditDiagnostic::new(
540                DiagnosticSeverity::Error,
541                diagnostic_codes::icu::INVALID_SYNTAX,
542                format!("Catalog message is not valid ICU MessageFormat v1: {error}"),
543                Some(message_ref.clone()),
544                None,
545            ));
546        }
547    }
548}
549
550fn audit_icu_compatibility(
551    target_catalog: &NormalizedParsedCatalog,
552    target_locale: &str,
553    source_keys: &BTreeSet<CatalogMessageKey>,
554    icu_options: &CatalogAuditIcuOptions,
555    report: &mut CatalogAuditReport,
556) {
557    for key in source_keys {
558        let Some(target_message) = target_catalog
559            .get(key)
560            .filter(|message| message.obsolete.is_none())
561        else {
562            continue;
563        };
564        let Some(target_value) =
565            singular_translation(target_message).filter(|value| !value.trim().is_empty())
566        else {
567            continue;
568        };
569
570        let Ok(source) = parse_icu_with_syntax_policy(&key.msgid, icu_options.syntax_policy) else {
571            continue;
572        };
573        let Ok(translation) = parse_icu_with_syntax_policy(target_value, icu_options.syntax_policy)
574        else {
575            continue;
576        };
577        let compatibility =
578            compare_icu_messages(&source, &translation, &IcuCompatibilityOptions::default());
579        for diagnostic in compatibility.diagnostics {
580            report.diagnostics.push(CatalogAuditDiagnostic::new(
581                severity_from_icu(diagnostic.severity),
582                diagnostic.code,
583                diagnostic.message,
584                Some(CatalogAuditMessageRef::new(Some(target_locale), key)),
585                diagnostic.name,
586            ));
587        }
588    }
589}
590
591fn audit_metadata(
592    metadata: &[MessageMetadataInput],
593    source_keys: &BTreeSet<CatalogMessageKey>,
594    report: &mut CatalogAuditReport,
595) {
596    let mut seen = BTreeSet::<CatalogMessageKey>::new();
597    for input in metadata {
598        let key = CatalogMessageKey::new(input.msgid.clone(), input.msgctxt.clone());
599        let source_ref = CatalogAuditMessageRef::new(None, &key);
600        if !seen.insert(key.clone()) {
601            report.diagnostics.push(CatalogAuditDiagnostic::new(
602                DiagnosticSeverity::Error,
603                diagnostic_codes::catalog::DUPLICATE_METADATA,
604                "Semantic metadata contains a duplicate message identity.",
605                Some(source_ref.clone()),
606                None,
607            ));
608        }
609        if !source_keys.contains(&key) {
610            report.diagnostics.push(CatalogAuditDiagnostic::new(
611                DiagnosticSeverity::Warning,
612                diagnostic_codes::catalog::METADATA_UNKNOWN_MESSAGE,
613                "Semantic metadata refers to a message that is not active in the source catalog.",
614                Some(source_ref.clone()),
615                None,
616            ));
617        }
618        if let Err(error) = normalize_message_metadata(input.clone()) {
619            report.diagnostics.push(CatalogAuditDiagnostic::new(
620                DiagnosticSeverity::Error,
621                diagnostic_codes::metadata::INVALID_MSGID,
622                format!("Semantic metadata `msgid` is not valid ICU MessageFormat v1: {error}"),
623                Some(source_ref.clone()),
624                Some("msgid".to_owned()),
625            ));
626            continue;
627        }
628        let metadata_report = validate_message_metadata(input);
629        for diagnostic in metadata_report.diagnostics {
630            report.diagnostics.push(CatalogAuditDiagnostic::new(
631                severity_from_icu(diagnostic.severity),
632                diagnostic.code,
633                diagnostic.message,
634                Some(source_ref.clone()),
635                diagnostic.name,
636            ));
637        }
638    }
639}
640
641fn message_strings(message: &CatalogMessage, include_msgid: bool) -> Vec<&str> {
642    let mut values = Vec::new();
643    if include_msgid {
644        push_unique(&mut values, message.msgid.as_str());
645    }
646    match message.effective_translation() {
647        EffectiveTranslationRef::Singular(value) => push_unique(&mut values, value),
648        EffectiveTranslationRef::Plural(translations) => {
649            for value in translations.values().map(String::as_str) {
650                push_unique(&mut values, value);
651            }
652        }
653    }
654    values
655}
656
657fn push_unique<'a>(values: &mut Vec<&'a str>, value: &'a str) {
658    if !values.contains(&value) {
659        values.push(value);
660    }
661}
662
663#[cfg(test)]
664mod tests {
665    use ferrocat_icu::MessageMetadataInput;
666
667    use super::{CatalogAuditChecks, CatalogAuditOptions};
668
669    #[test]
670    fn audit_option_builders_set_fields() {
671        let metadata = [MessageMetadataInput::new("Checkout")];
672        let locales = ["de", "fr"];
673        let checks = CatalogAuditChecks::default()
674            .with_completeness(false)
675            .with_extra_messages(false)
676            .with_icu_syntax(false)
677            .with_icu_compatibility(false)
678            .with_semantic_metadata(false)
679            .with_obsolete_entries(false);
680
681        assert!(!checks.completeness);
682        assert!(!checks.extra_messages);
683        assert!(!checks.icu_syntax);
684        assert!(!checks.icu_compatibility);
685        assert!(!checks.semantic_metadata);
686        assert!(!checks.obsolete_entries);
687
688        let options = CatalogAuditOptions::new("en")
689            .with_locales(&locales)
690            .with_metadata(&metadata)
691            .with_checks(checks);
692
693        assert_eq!(options.source_locale, "en");
694        assert_eq!(options.locales, &locales);
695        assert_eq!(options.metadata, &metadata);
696        assert_eq!(options.checks, checks);
697    }
698}
699
700fn singular_translation(message: &CatalogMessage) -> Option<&str> {
701    match message.effective_translation() {
702        EffectiveTranslationRef::Singular(value) => Some(value),
703        EffectiveTranslationRef::Plural(_) => None,
704    }
705}
706
707fn severity_from_icu(severity: IcuDiagnosticSeverity) -> DiagnosticSeverity {
708    match severity {
709        IcuDiagnosticSeverity::Info => DiagnosticSeverity::Info,
710        IcuDiagnosticSeverity::Warning => DiagnosticSeverity::Warning,
711        IcuDiagnosticSeverity::Error => DiagnosticSeverity::Error,
712    }
713}
714
715fn finalize_summary(
716    report: &mut CatalogAuditReport,
717    source_messages: usize,
718    target_locales: usize,
719) {
720    let mut summary = CatalogAuditSummary {
721        source_messages,
722        target_locales,
723        diagnostics: report.diagnostics.len(),
724        ..CatalogAuditSummary::default()
725    };
726    for diagnostic in &report.diagnostics {
727        match diagnostic.severity {
728            DiagnosticSeverity::Info => summary.infos += 1,
729            DiagnosticSeverity::Warning => summary.warnings += 1,
730            DiagnosticSeverity::Error => summary.errors += 1,
731        }
732    }
733    report.summary = summary;
734}
735
736#[cfg(all(test, feature = "serde"))]
737mod serde_tests {
738    use super::{
739        CatalogAuditDiagnostic, CatalogAuditMessageRef, CatalogAuditReport, CatalogAuditSummary,
740    };
741    use crate::api::DiagnosticSeverity;
742
743    #[test]
744    fn catalog_audit_report_serde_round_trips_ci_report_shape() {
745        let report = CatalogAuditReport {
746            summary: CatalogAuditSummary {
747                source_messages: 1,
748                target_locales: 1,
749                diagnostics: 1,
750                errors: 1,
751                warnings: 0,
752                infos: 0,
753            },
754            diagnostics: vec![CatalogAuditDiagnostic {
755                severity: DiagnosticSeverity::Error,
756                code: "catalog.missing_translation".into(),
757                message: "missing target translation".to_owned(),
758                source_key: Some(CatalogAuditMessageRef {
759                    locale: Some("de".to_owned()),
760                    msgid: "Checkout".to_owned(),
761                    msgctxt: None,
762                }),
763                name: Some("de".to_owned()),
764            }],
765        };
766
767        let json = serde_json::to_value(&report).expect("audit report serialization must succeed");
768        assert_eq!(json["diagnostics"][0]["severity"], "error");
769        assert_eq!(json["summary"]["errors"], 1);
770
771        let roundtrip: CatalogAuditReport =
772            serde_json::from_value(json).expect("audit report deserialization must succeed");
773        assert_eq!(roundtrip, report);
774    }
775}