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#[derive(Debug, Clone, PartialEq, Eq, Default)]
30#[non_exhaustive]
31pub struct CatalogAuditOptions<'a> {
32 pub source_locale: &'a str,
34 pub locales: &'a [&'a str],
36 pub metadata: &'a [MessageMetadataInput],
38 pub checks: CatalogAuditChecks,
40 pub icu_options: CatalogAuditIcuOptions,
42}
43
44impl<'a> CatalogAuditOptions<'a> {
45 #[must_use]
47 pub fn new(source_locale: &'a str) -> Self {
48 Self {
49 source_locale,
50 ..Self::default()
51 }
52 }
53
54 #[must_use]
56 pub fn with_locales(mut self, locales: &'a [&'a str]) -> Self {
57 self.locales = locales;
58 self
59 }
60
61 #[must_use]
63 pub fn with_metadata(mut self, metadata: &'a [MessageMetadataInput]) -> Self {
64 self.metadata = metadata;
65 self
66 }
67
68 #[must_use]
70 pub fn with_checks(mut self, checks: CatalogAuditChecks) -> Self {
71 self.checks = checks;
72 self
73 }
74
75 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85#[non_exhaustive]
86pub struct CatalogAuditIcuOptions {
87 pub syntax_policy: IcuSyntaxPolicy,
89}
90
91impl CatalogAuditIcuOptions {
92 #[must_use]
94 pub fn new() -> Self {
95 Self::default()
96 }
97
98 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108#[non_exhaustive]
109pub struct CatalogAuditChecks {
110 pub completeness: bool,
112 pub extra_messages: bool,
114 pub icu_syntax: bool,
116 pub icu_compatibility: bool,
118 pub semantic_metadata: bool,
120 pub fuzzy_flags: bool,
122 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 #[must_use]
143 pub fn with_completeness(mut self, completeness: bool) -> Self {
144 self.completeness = completeness;
145 self
146 }
147
148 #[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 #[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 #[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 #[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 #[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 #[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#[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 pub source_messages: usize,
198 pub target_locales: usize,
200 pub diagnostics: usize,
202 pub errors: usize,
204 pub warnings: usize,
206 pub infos: usize,
208}
209
210#[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 pub locale: Option<String>,
217 pub msgid: String,
219 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#[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 pub severity: DiagnosticSeverity,
240 pub code: DiagnosticCode,
242 pub message: String,
244 pub source_key: Option<CatalogAuditMessageRef>,
246 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#[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 pub summary: CatalogAuditSummary,
275 pub diagnostics: Vec<CatalogAuditDiagnostic>,
277}
278
279impl CatalogAuditReport {
280 #[must_use]
282 pub fn has_errors(&self) -> bool {
283 self.diagnostics
284 .iter()
285 .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
286 }
287}
288
289pub 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 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}