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#[derive(Debug, Clone, PartialEq, Eq, Default)]
23#[non_exhaustive]
24pub struct CatalogAuditOptions<'a> {
25 pub source_locale: &'a str,
27 pub locales: &'a [&'a str],
29 pub metadata: &'a [MessageMetadataInput],
31 pub checks: CatalogAuditChecks,
33 pub icu_options: CatalogAuditIcuOptions,
35}
36
37impl<'a> CatalogAuditOptions<'a> {
38 #[must_use]
40 pub fn new(source_locale: &'a str) -> Self {
41 Self {
42 source_locale,
43 ..Self::default()
44 }
45 }
46
47 #[must_use]
49 pub fn with_locales(mut self, locales: &'a [&'a str]) -> Self {
50 self.locales = locales;
51 self
52 }
53
54 #[must_use]
56 pub fn with_metadata(mut self, metadata: &'a [MessageMetadataInput]) -> Self {
57 self.metadata = metadata;
58 self
59 }
60
61 #[must_use]
63 pub fn with_checks(mut self, checks: CatalogAuditChecks) -> Self {
64 self.checks = checks;
65 self
66 }
67
68 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78#[non_exhaustive]
79pub struct CatalogAuditIcuOptions {
80 pub syntax_policy: IcuSyntaxPolicy,
82}
83
84impl CatalogAuditIcuOptions {
85 #[must_use]
87 pub fn new() -> Self {
88 Self::default()
89 }
90
91 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101#[non_exhaustive]
102pub struct CatalogAuditChecks {
103 pub completeness: bool,
105 pub extra_messages: bool,
107 pub icu_syntax: bool,
109 pub icu_compatibility: bool,
111 pub semantic_metadata: bool,
113 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 #[must_use]
133 pub fn with_completeness(mut self, completeness: bool) -> Self {
134 self.completeness = completeness;
135 self
136 }
137
138 #[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 #[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 #[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 #[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 #[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#[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 pub source_messages: usize,
181 pub target_locales: usize,
183 pub diagnostics: usize,
185 pub errors: usize,
187 pub warnings: usize,
189 pub infos: usize,
191}
192
193#[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 pub locale: Option<String>,
200 pub msgid: String,
202 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#[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 pub severity: DiagnosticSeverity,
223 pub code: DiagnosticCode,
225 pub message: String,
227 pub source_key: Option<CatalogAuditMessageRef>,
229 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#[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 pub summary: CatalogAuditSummary,
258 pub diagnostics: Vec<CatalogAuditDiagnostic>,
260}
261
262impl CatalogAuditReport {
263 #[must_use]
265 pub fn has_errors(&self) -> bool {
266 self.diagnostics
267 .iter()
268 .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
269 }
270}
271
272pub 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}