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;
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 message_has_fuzzy_flag,
16};
17use super::{
18 ApiError, CatalogMessage, CatalogMessageKey, DiagnosticSeverity, EffectiveTranslationRef,
19 IcuSyntaxPolicy, NormalizedParsedCatalog, validate_source_locale,
20};
21
22#[derive(Debug, Clone, PartialEq, Eq, Default)]
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}
34
35impl<'a> CatalogAuditOptions<'a> {
36 #[must_use]
38 pub fn new(source_locale: &'a str) -> Self {
39 Self {
40 source_locale,
41 ..Self::default()
42 }
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48#[non_exhaustive]
49pub struct CatalogAuditIcuOptions {
50 pub syntax_policy: IcuSyntaxPolicy,
52}
53
54impl CatalogAuditIcuOptions {
55 #[must_use]
57 pub fn new() -> Self {
58 Self::default()
59 }
60
61 #[must_use]
63 pub fn with_syntax_policy(mut self, syntax_policy: IcuSyntaxPolicy) -> Self {
64 self.syntax_policy = syntax_policy;
65 self
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct CatalogAuditChecks {
72 pub completeness: bool,
74 pub extra_messages: bool,
76 pub icu_syntax: bool,
78 pub icu_compatibility: bool,
80 pub semantic_metadata: bool,
82 pub fuzzy_flags: bool,
84 pub obsolete_entries: bool,
86}
87
88impl Default for CatalogAuditChecks {
89 fn default() -> Self {
90 Self {
91 completeness: true,
92 extra_messages: true,
93 icu_syntax: true,
94 icu_compatibility: true,
95 semantic_metadata: true,
96 fuzzy_flags: true,
97 obsolete_entries: true,
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Default)]
104#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
105#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
106pub struct CatalogAuditSummary {
107 pub source_messages: usize,
109 pub target_locales: usize,
111 pub diagnostics: usize,
113 pub errors: usize,
115 pub warnings: usize,
117 pub infos: usize,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
124#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
125pub struct CatalogAuditMessageRef {
126 pub locale: Option<String>,
128 pub msgid: String,
130 pub msgctxt: Option<String>,
132}
133
134impl CatalogAuditMessageRef {
135 fn new(locale: Option<&str>, key: &CatalogMessageKey) -> Self {
136 Self {
137 locale: locale.map(str::to_owned),
138 msgid: key.msgid.clone(),
139 msgctxt: key.msgctxt.clone(),
140 }
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
146#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
147#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
148pub struct CatalogAuditDiagnostic {
149 pub severity: DiagnosticSeverity,
151 pub code: String,
153 pub message: String,
155 pub source_key: Option<CatalogAuditMessageRef>,
157 pub name: Option<String>,
159}
160
161impl CatalogAuditDiagnostic {
162 fn new(
163 severity: DiagnosticSeverity,
164 code: impl Into<String>,
165 message: impl Into<String>,
166 source_key: Option<CatalogAuditMessageRef>,
167 name: Option<String>,
168 ) -> Self {
169 Self {
170 severity,
171 code: code.into(),
172 message: message.into(),
173 source_key,
174 name,
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Default)]
181#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
182#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
183pub struct CatalogAuditReport {
184 pub summary: CatalogAuditSummary,
186 pub diagnostics: Vec<CatalogAuditDiagnostic>,
188}
189
190impl CatalogAuditReport {
191 #[must_use]
193 pub fn has_errors(&self) -> bool {
194 self.diagnostics
195 .iter()
196 .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
197 }
198}
199
200pub fn audit_catalogs(
232 catalogs: &[&NormalizedParsedCatalog],
233 options: &CatalogAuditOptions<'_>,
234) -> Result<CatalogAuditReport, ApiError> {
235 audit_catalogs_with_icu_options(catalogs, options, &CatalogAuditIcuOptions::default())
236}
237
238pub fn audit_catalogs_with_icu_options(
246 catalogs: &[&NormalizedParsedCatalog],
247 options: &CatalogAuditOptions<'_>,
248 icu_options: &CatalogAuditIcuOptions,
249) -> Result<CatalogAuditReport, ApiError> {
250 validate_source_locale(options.source_locale)?;
251 let catalog_index = index_catalogs(catalogs)?;
252 let mut report = CatalogAuditReport::default();
253
254 let Some(source_catalog) = catalog_index.get(options.source_locale).copied() else {
255 report.diagnostics.push(CatalogAuditDiagnostic::new(
256 DiagnosticSeverity::Error,
257 diagnostic_codes::catalog::MISSING_SOURCE_LOCALE,
258 format!(
259 "Catalog audit did not receive source locale `{}`.",
260 options.source_locale
261 ),
262 None,
263 Some(options.source_locale.to_owned()),
264 ));
265 finalize_summary(&mut report, 0, 0);
266 return Ok(report);
267 };
268
269 let source_keys = active_message_keys(source_catalog);
270 let target_locales = select_target_locales(&catalog_index, options, &mut report);
271 let source_locale = source_catalog.parsed_catalog().locale.as_deref();
272
273 if options.checks.fuzzy_flags || options.checks.obsolete_entries || options.checks.icu_syntax {
274 audit_catalog_entries(
275 source_catalog,
276 source_locale,
277 true,
278 options,
279 icu_options,
280 &mut report,
281 );
282 }
283 if options.checks.semantic_metadata {
284 audit_metadata(options.metadata, &source_keys, &mut report);
285 }
286
287 for target_locale in &target_locales {
288 let Some(target_catalog) = catalog_index.get(target_locale.as_str()).copied() else {
289 continue;
290 };
291 audit_catalog_entries(
292 target_catalog,
293 Some(target_locale),
294 false,
295 options,
296 icu_options,
297 &mut report,
298 );
299 audit_target_catalog(
300 target_catalog,
301 target_locale,
302 &source_keys,
303 options,
304 icu_options,
305 &mut report,
306 );
307 }
308
309 finalize_summary(&mut report, source_keys.len(), target_locales.len());
310 Ok(report)
311}
312
313fn index_catalogs<'a>(
314 catalogs: &'a [&'a NormalizedParsedCatalog],
315) -> Result<BTreeMap<String, &'a NormalizedParsedCatalog>, ApiError> {
316 let mut index = BTreeMap::new();
317 for catalog in catalogs {
318 let locale = catalog
319 .parsed_catalog()
320 .locale
321 .as_deref()
322 .filter(|locale| !locale.trim().is_empty())
323 .ok_or_else(|| {
324 ApiError::InvalidArguments(
325 "audit_catalogs requires every catalog to declare a locale".to_owned(),
326 )
327 })?;
328 if index.insert(locale.to_owned(), *catalog).is_some() {
329 return Err(ApiError::InvalidArguments(format!(
330 "audit_catalogs received duplicate catalog locale {locale:?}"
331 )));
332 }
333 }
334 Ok(index)
335}
336
337fn select_target_locales(
338 catalog_index: &BTreeMap<String, &NormalizedParsedCatalog>,
339 options: &CatalogAuditOptions<'_>,
340 report: &mut CatalogAuditReport,
341) -> Vec<String> {
342 if options.locales.is_empty() {
343 return catalog_index
344 .keys()
345 .filter(|locale| locale.as_str() != options.source_locale)
346 .cloned()
347 .collect();
348 }
349
350 let mut seen = BTreeSet::new();
351 let mut locales = Vec::new();
352 for locale in options.locales {
353 if !seen.insert((*locale).to_owned()) {
354 continue;
355 }
356 if catalog_index.contains_key(*locale) {
357 if *locale != options.source_locale {
358 locales.push((*locale).to_owned());
359 }
360 } else {
361 report.diagnostics.push(CatalogAuditDiagnostic::new(
362 DiagnosticSeverity::Error,
363 diagnostic_codes::catalog::MISSING_LOCALE,
364 format!("Catalog audit did not receive requested locale `{locale}`."),
365 None,
366 Some((*locale).to_owned()),
367 ));
368 }
369 }
370 locales
371}
372
373fn audit_catalog_entries(
374 catalog: &NormalizedParsedCatalog,
375 locale: Option<&str>,
376 validate_source_identity: bool,
377 options: &CatalogAuditOptions<'_>,
378 icu_options: &CatalogAuditIcuOptions,
379 report: &mut CatalogAuditReport,
380) {
381 for (key, message) in catalog.iter() {
382 let message_ref = CatalogAuditMessageRef::new(locale, key);
383 if options.checks.obsolete_entries && message.obsolete {
384 report.diagnostics.push(CatalogAuditDiagnostic::new(
385 DiagnosticSeverity::Info,
386 diagnostic_codes::catalog::OBSOLETE_ENTRY,
387 "Catalog contains an obsolete entry.",
388 Some(message_ref.clone()),
389 None,
390 ));
391 }
392 if options.checks.fuzzy_flags && message_has_fuzzy_flag(message) {
393 report.diagnostics.push(CatalogAuditDiagnostic::new(
394 DiagnosticSeverity::Info,
395 diagnostic_codes::catalog::FUZZY_FLAG,
396 "Catalog entry carries a fuzzy flag.",
397 Some(message_ref.clone()),
398 Some("fuzzy".to_owned()),
399 ));
400 }
401 if options.checks.icu_syntax && !message.obsolete {
402 audit_icu_syntax_for_message(
403 message,
404 validate_source_identity,
405 icu_options.syntax_policy,
406 &message_ref,
407 report,
408 );
409 }
410 }
411}
412
413fn audit_target_catalog(
414 target_catalog: &NormalizedParsedCatalog,
415 target_locale: &str,
416 source_keys: &BTreeSet<CatalogMessageKey>,
417 options: &CatalogAuditOptions<'_>,
418 icu_options: &CatalogAuditIcuOptions,
419 report: &mut CatalogAuditReport,
420) {
421 if options.checks.completeness {
422 for key in source_keys {
423 let message_ref = CatalogAuditMessageRef::new(Some(target_locale), key);
424 match classify_expected_message(target_catalog, key) {
425 CatalogMessageStatus::Missing | CatalogMessageStatus::Obsolete => {
426 report.diagnostics.push(CatalogAuditDiagnostic::new(
427 DiagnosticSeverity::Error,
428 diagnostic_codes::catalog::MISSING_TRANSLATION,
429 format!(
430 "Locale `{target_locale}` is missing translation for source message."
431 ),
432 Some(message_ref),
433 Some(target_locale.to_owned()),
434 ));
435 }
436 CatalogMessageStatus::Empty => {
437 report.diagnostics.push(CatalogAuditDiagnostic::new(
438 DiagnosticSeverity::Error,
439 diagnostic_codes::catalog::EMPTY_TRANSLATION,
440 format!("Locale `{target_locale}` has an empty translation."),
441 Some(message_ref),
442 Some(target_locale.to_owned()),
443 ));
444 }
445 CatalogMessageStatus::Translated | CatalogMessageStatus::Fuzzy => {}
446 CatalogMessageStatus::Extra => {
447 unreachable!("expected source-key classification cannot produce extra status")
448 }
449 }
450 }
451 }
452
453 if options.checks.extra_messages {
454 for (key, message) in target_catalog.iter() {
455 if is_extra_target_message(source_keys, key, message) {
456 report.diagnostics.push(CatalogAuditDiagnostic::new(
457 DiagnosticSeverity::Warning,
458 diagnostic_codes::catalog::EXTRA_TRANSLATION,
459 format!(
460 "Locale `{target_locale}` contains an active message that is not present in the source catalog."
461 ),
462 Some(CatalogAuditMessageRef::new(Some(target_locale), key)),
463 Some(target_locale.to_owned()),
464 ));
465 }
466 }
467 }
468
469 if options.checks.icu_compatibility {
470 audit_icu_compatibility(
471 target_catalog,
472 target_locale,
473 source_keys,
474 icu_options,
475 report,
476 );
477 }
478}
479
480fn audit_icu_syntax_for_message(
481 message: &CatalogMessage,
482 validate_source_identity: bool,
483 syntax_policy: IcuSyntaxPolicy,
484 message_ref: &CatalogAuditMessageRef,
485 report: &mut CatalogAuditReport,
486) {
487 for value in message_strings(message, validate_source_identity) {
488 if value.trim().is_empty() {
489 continue;
490 }
491 if let Err(error) = parse_icu_with_syntax_policy(value, syntax_policy) {
492 report.diagnostics.push(CatalogAuditDiagnostic::new(
493 DiagnosticSeverity::Error,
494 diagnostic_codes::icu::INVALID_SYNTAX,
495 format!("Catalog message is not valid ICU MessageFormat v1: {error}"),
496 Some(message_ref.clone()),
497 None,
498 ));
499 }
500 }
501}
502
503fn audit_icu_compatibility(
504 target_catalog: &NormalizedParsedCatalog,
505 target_locale: &str,
506 source_keys: &BTreeSet<CatalogMessageKey>,
507 icu_options: &CatalogAuditIcuOptions,
508 report: &mut CatalogAuditReport,
509) {
510 for key in source_keys {
511 let Some(target_message) = target_catalog.get(key).filter(|message| !message.obsolete)
512 else {
513 continue;
514 };
515 let Some(target_value) =
516 singular_translation(target_message).filter(|value| !value.trim().is_empty())
517 else {
518 continue;
519 };
520
521 let Ok(source) = parse_icu_with_syntax_policy(&key.msgid, icu_options.syntax_policy) else {
522 continue;
523 };
524 let Ok(translation) = parse_icu_with_syntax_policy(target_value, icu_options.syntax_policy)
525 else {
526 continue;
527 };
528 let compatibility =
529 compare_icu_messages(&source, &translation, &IcuCompatibilityOptions::default());
530 for diagnostic in compatibility.diagnostics {
531 report.diagnostics.push(CatalogAuditDiagnostic::new(
532 severity_from_icu(diagnostic.severity),
533 diagnostic.code,
534 diagnostic.message,
535 Some(CatalogAuditMessageRef::new(Some(target_locale), key)),
536 diagnostic.name,
537 ));
538 }
539 }
540}
541
542fn audit_metadata(
543 metadata: &[MessageMetadataInput],
544 source_keys: &BTreeSet<CatalogMessageKey>,
545 report: &mut CatalogAuditReport,
546) {
547 let mut seen = BTreeSet::<CatalogMessageKey>::new();
548 for input in metadata {
549 let key = CatalogMessageKey::new(input.msgid.clone(), input.msgctxt.clone());
550 let source_ref = CatalogAuditMessageRef::new(None, &key);
551 if !seen.insert(key.clone()) {
552 report.diagnostics.push(CatalogAuditDiagnostic::new(
553 DiagnosticSeverity::Error,
554 diagnostic_codes::catalog::DUPLICATE_METADATA,
555 "Semantic metadata contains a duplicate message identity.",
556 Some(source_ref.clone()),
557 None,
558 ));
559 }
560 if !source_keys.contains(&key) {
561 report.diagnostics.push(CatalogAuditDiagnostic::new(
562 DiagnosticSeverity::Warning,
563 diagnostic_codes::catalog::METADATA_UNKNOWN_MESSAGE,
564 "Semantic metadata refers to a message that is not active in the source catalog.",
565 Some(source_ref.clone()),
566 None,
567 ));
568 }
569 if let Err(error) = normalize_message_metadata(input.clone()) {
570 report.diagnostics.push(CatalogAuditDiagnostic::new(
571 DiagnosticSeverity::Error,
572 diagnostic_codes::metadata::INVALID_MSGID,
573 format!("Semantic metadata `msgid` is not valid ICU MessageFormat v1: {error}"),
574 Some(source_ref.clone()),
575 Some("msgid".to_owned()),
576 ));
577 continue;
578 }
579 let metadata_report = validate_message_metadata(input);
580 for diagnostic in metadata_report.diagnostics {
581 report.diagnostics.push(CatalogAuditDiagnostic::new(
582 severity_from_icu(diagnostic.severity),
583 diagnostic.code,
584 diagnostic.message,
585 Some(source_ref.clone()),
586 diagnostic.name,
587 ));
588 }
589 }
590}
591
592fn message_strings(message: &CatalogMessage, include_msgid: bool) -> Vec<&str> {
593 let mut values = Vec::new();
594 if include_msgid {
595 push_unique(&mut values, message.msgid.as_str());
596 }
597 match message.effective_translation() {
598 EffectiveTranslationRef::Singular(value) => push_unique(&mut values, value),
599 EffectiveTranslationRef::Plural(translations) => {
600 for value in translations.values().map(String::as_str) {
601 push_unique(&mut values, value);
602 }
603 }
604 }
605 values
606}
607
608fn push_unique<'a>(values: &mut Vec<&'a str>, value: &'a str) {
609 if !values.contains(&value) {
610 values.push(value);
611 }
612}
613
614fn singular_translation(message: &CatalogMessage) -> Option<&str> {
615 match message.effective_translation() {
616 EffectiveTranslationRef::Singular(value) => Some(value),
617 EffectiveTranslationRef::Plural(_) => None,
618 }
619}
620
621fn severity_from_icu(severity: IcuDiagnosticSeverity) -> DiagnosticSeverity {
622 match severity {
623 IcuDiagnosticSeverity::Info => DiagnosticSeverity::Info,
624 IcuDiagnosticSeverity::Warning => DiagnosticSeverity::Warning,
625 IcuDiagnosticSeverity::Error => DiagnosticSeverity::Error,
626 }
627}
628
629fn finalize_summary(
630 report: &mut CatalogAuditReport,
631 source_messages: usize,
632 target_locales: usize,
633) {
634 let mut summary = CatalogAuditSummary {
635 source_messages,
636 target_locales,
637 diagnostics: report.diagnostics.len(),
638 ..CatalogAuditSummary::default()
639 };
640 for diagnostic in &report.diagnostics {
641 match diagnostic.severity {
642 DiagnosticSeverity::Info => summary.infos += 1,
643 DiagnosticSeverity::Warning => summary.warnings += 1,
644 DiagnosticSeverity::Error => summary.errors += 1,
645 }
646 }
647 report.summary = summary;
648}
649
650#[cfg(all(test, feature = "serde"))]
651mod serde_tests {
652 use super::{
653 CatalogAuditDiagnostic, CatalogAuditMessageRef, CatalogAuditReport, CatalogAuditSummary,
654 };
655 use crate::api::DiagnosticSeverity;
656
657 #[test]
658 fn catalog_audit_report_serde_round_trips_ci_report_shape() {
659 let report = CatalogAuditReport {
660 summary: CatalogAuditSummary {
661 source_messages: 1,
662 target_locales: 1,
663 diagnostics: 1,
664 errors: 1,
665 warnings: 0,
666 infos: 0,
667 },
668 diagnostics: vec![CatalogAuditDiagnostic {
669 severity: DiagnosticSeverity::Error,
670 code: "catalog.missing_translation".to_owned(),
671 message: "missing target translation".to_owned(),
672 source_key: Some(CatalogAuditMessageRef {
673 locale: Some("de".to_owned()),
674 msgid: "Checkout".to_owned(),
675 msgctxt: None,
676 }),
677 name: Some("de".to_owned()),
678 }],
679 };
680
681 let json = serde_json::to_value(&report).expect("audit report serialization must succeed");
682 assert_eq!(json["diagnostics"][0]["severity"], "error");
683 assert_eq!(json["summary"]["errors"], 1);
684
685 let roundtrip: CatalogAuditReport =
686 serde_json::from_value(json).expect("audit report deserialization must succeed");
687 assert_eq!(roundtrip, report);
688 }
689}