Skip to main content

ferrocat_po/api/
audit.rs

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