1use std::collections::{BTreeMap, BTreeSet};
2
3use ferrocat_icu::{
4 IcuCompatibilityOptions, IcuDiagnosticSeverity, MessageMetadataInput, compare_icu_messages,
5 normalize_message_metadata, parse_icu, validate_message_metadata,
6};
7#[cfg(feature = "serde")]
8use serde::{Deserialize, Serialize};
9
10use crate::diagnostic_codes;
11
12use super::{
13 ApiError, CatalogMessage, CatalogMessageKey, DiagnosticSeverity, EffectiveTranslationRef,
14 NormalizedParsedCatalog, validate_source_locale,
15};
16
17#[derive(Debug, Clone, PartialEq, Eq, Default)]
19pub struct CatalogAuditOptions<'a> {
20 pub source_locale: &'a str,
22 pub locales: &'a [&'a str],
24 pub metadata: &'a [MessageMetadataInput],
26 pub checks: CatalogAuditChecks,
28}
29
30impl<'a> CatalogAuditOptions<'a> {
31 #[must_use]
33 pub fn new(source_locale: &'a str) -> Self {
34 Self {
35 source_locale,
36 ..Self::default()
37 }
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct CatalogAuditChecks {
44 pub completeness: bool,
46 pub extra_messages: bool,
48 pub icu_syntax: bool,
50 pub icu_compatibility: bool,
52 pub semantic_metadata: bool,
54 pub fuzzy_flags: bool,
56 pub obsolete_entries: bool,
58}
59
60impl Default for CatalogAuditChecks {
61 fn default() -> Self {
62 Self {
63 completeness: true,
64 extra_messages: true,
65 icu_syntax: true,
66 icu_compatibility: true,
67 semantic_metadata: true,
68 fuzzy_flags: true,
69 obsolete_entries: true,
70 }
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Default)]
76#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
77#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
78pub struct CatalogAuditSummary {
79 pub source_messages: usize,
81 pub target_locales: usize,
83 pub diagnostics: usize,
85 pub errors: usize,
87 pub warnings: usize,
89 pub infos: usize,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
96#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
97pub struct CatalogAuditMessageRef {
98 pub locale: Option<String>,
100 pub msgid: String,
102 pub msgctxt: Option<String>,
104}
105
106impl CatalogAuditMessageRef {
107 fn new(locale: Option<&str>, key: &CatalogMessageKey) -> Self {
108 Self {
109 locale: locale.map(str::to_owned),
110 msgid: key.msgid.clone(),
111 msgctxt: key.msgctxt.clone(),
112 }
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
119#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
120pub struct CatalogAuditDiagnostic {
121 pub severity: DiagnosticSeverity,
123 pub code: String,
125 pub message: String,
127 pub source_key: Option<CatalogAuditMessageRef>,
129 pub name: Option<String>,
131}
132
133impl CatalogAuditDiagnostic {
134 fn new(
135 severity: DiagnosticSeverity,
136 code: impl Into<String>,
137 message: impl Into<String>,
138 source_key: Option<CatalogAuditMessageRef>,
139 name: Option<String>,
140 ) -> Self {
141 Self {
142 severity,
143 code: code.into(),
144 message: message.into(),
145 source_key,
146 name,
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Default)]
153#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
154#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
155pub struct CatalogAuditReport {
156 pub summary: CatalogAuditSummary,
158 pub diagnostics: Vec<CatalogAuditDiagnostic>,
160}
161
162impl CatalogAuditReport {
163 #[must_use]
165 pub fn has_errors(&self) -> bool {
166 self.diagnostics
167 .iter()
168 .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
169 }
170}
171
172pub fn audit_catalogs(
204 catalogs: &[&NormalizedParsedCatalog],
205 options: &CatalogAuditOptions<'_>,
206) -> Result<CatalogAuditReport, ApiError> {
207 validate_source_locale(options.source_locale)?;
208 let catalog_index = index_catalogs(catalogs)?;
209 let mut report = CatalogAuditReport::default();
210
211 let Some(source_catalog) = catalog_index.get(options.source_locale).copied() else {
212 report.diagnostics.push(CatalogAuditDiagnostic::new(
213 DiagnosticSeverity::Error,
214 diagnostic_codes::catalog::MISSING_SOURCE_LOCALE,
215 format!(
216 "Catalog audit did not receive source locale `{}`.",
217 options.source_locale
218 ),
219 None,
220 Some(options.source_locale.to_owned()),
221 ));
222 finalize_summary(&mut report, 0, 0);
223 return Ok(report);
224 };
225
226 let source_keys = active_keys(source_catalog);
227 let target_locales = select_target_locales(&catalog_index, options, &mut report);
228 let source_locale = source_catalog.parsed_catalog().locale.as_deref();
229
230 if options.checks.fuzzy_flags || options.checks.obsolete_entries || options.checks.icu_syntax {
231 audit_catalog_entries(source_catalog, source_locale, true, options, &mut report);
232 }
233 if options.checks.semantic_metadata {
234 audit_metadata(options.metadata, &source_keys, &mut report);
235 }
236
237 for target_locale in &target_locales {
238 let Some(target_catalog) = catalog_index.get(target_locale.as_str()).copied() else {
239 continue;
240 };
241 audit_catalog_entries(
242 target_catalog,
243 Some(target_locale),
244 false,
245 options,
246 &mut report,
247 );
248 audit_target_catalog(
249 target_catalog,
250 target_locale,
251 &source_keys,
252 options,
253 &mut report,
254 );
255 }
256
257 finalize_summary(&mut report, source_keys.len(), target_locales.len());
258 Ok(report)
259}
260
261fn index_catalogs<'a>(
262 catalogs: &'a [&'a NormalizedParsedCatalog],
263) -> Result<BTreeMap<String, &'a NormalizedParsedCatalog>, ApiError> {
264 let mut index = BTreeMap::new();
265 for catalog in catalogs {
266 let locale = catalog
267 .parsed_catalog()
268 .locale
269 .as_deref()
270 .filter(|locale| !locale.trim().is_empty())
271 .ok_or_else(|| {
272 ApiError::InvalidArguments(
273 "audit_catalogs requires every catalog to declare a locale".to_owned(),
274 )
275 })?;
276 if index.insert(locale.to_owned(), *catalog).is_some() {
277 return Err(ApiError::InvalidArguments(format!(
278 "audit_catalogs received duplicate catalog locale {locale:?}"
279 )));
280 }
281 }
282 Ok(index)
283}
284
285fn select_target_locales(
286 catalog_index: &BTreeMap<String, &NormalizedParsedCatalog>,
287 options: &CatalogAuditOptions<'_>,
288 report: &mut CatalogAuditReport,
289) -> Vec<String> {
290 if options.locales.is_empty() {
291 return catalog_index
292 .keys()
293 .filter(|locale| locale.as_str() != options.source_locale)
294 .cloned()
295 .collect();
296 }
297
298 let mut seen = BTreeSet::new();
299 let mut locales = Vec::new();
300 for locale in options.locales {
301 if !seen.insert((*locale).to_owned()) {
302 continue;
303 }
304 if catalog_index.contains_key(*locale) {
305 if *locale != options.source_locale {
306 locales.push((*locale).to_owned());
307 }
308 } else {
309 report.diagnostics.push(CatalogAuditDiagnostic::new(
310 DiagnosticSeverity::Error,
311 diagnostic_codes::catalog::MISSING_LOCALE,
312 format!("Catalog audit did not receive requested locale `{locale}`."),
313 None,
314 Some((*locale).to_owned()),
315 ));
316 }
317 }
318 locales
319}
320
321fn active_keys(catalog: &NormalizedParsedCatalog) -> BTreeSet<CatalogMessageKey> {
322 catalog
323 .iter()
324 .filter_map(|(key, message)| (!message.obsolete).then_some(key.clone()))
325 .collect()
326}
327
328fn audit_catalog_entries(
329 catalog: &NormalizedParsedCatalog,
330 locale: Option<&str>,
331 validate_source_identity: bool,
332 options: &CatalogAuditOptions<'_>,
333 report: &mut CatalogAuditReport,
334) {
335 for (key, message) in catalog.iter() {
336 let message_ref = CatalogAuditMessageRef::new(locale, key);
337 if options.checks.obsolete_entries && message.obsolete {
338 report.diagnostics.push(CatalogAuditDiagnostic::new(
339 DiagnosticSeverity::Info,
340 diagnostic_codes::catalog::OBSOLETE_ENTRY,
341 "Catalog contains an obsolete entry.",
342 Some(message_ref.clone()),
343 None,
344 ));
345 }
346 if options.checks.fuzzy_flags && message_has_fuzzy_flag(message) {
347 report.diagnostics.push(CatalogAuditDiagnostic::new(
348 DiagnosticSeverity::Info,
349 diagnostic_codes::catalog::FUZZY_FLAG,
350 "Catalog entry carries a fuzzy flag.",
351 Some(message_ref.clone()),
352 Some("fuzzy".to_owned()),
353 ));
354 }
355 if options.checks.icu_syntax && !message.obsolete {
356 audit_icu_syntax_for_message(message, validate_source_identity, &message_ref, report);
357 }
358 }
359}
360
361fn audit_target_catalog(
362 target_catalog: &NormalizedParsedCatalog,
363 target_locale: &str,
364 source_keys: &BTreeSet<CatalogMessageKey>,
365 options: &CatalogAuditOptions<'_>,
366 report: &mut CatalogAuditReport,
367) {
368 if options.checks.completeness {
369 for key in source_keys {
370 let message_ref = CatalogAuditMessageRef::new(Some(target_locale), key);
371 let Some(target_message) = target_catalog.get(key).filter(|message| !message.obsolete)
372 else {
373 report.diagnostics.push(CatalogAuditDiagnostic::new(
374 DiagnosticSeverity::Error,
375 diagnostic_codes::catalog::MISSING_TRANSLATION,
376 format!("Locale `{target_locale}` is missing translation for source message."),
377 Some(message_ref),
378 Some(target_locale.to_owned()),
379 ));
380 continue;
381 };
382 if translation_is_empty(target_message) {
383 report.diagnostics.push(CatalogAuditDiagnostic::new(
384 DiagnosticSeverity::Error,
385 diagnostic_codes::catalog::EMPTY_TRANSLATION,
386 format!("Locale `{target_locale}` has an empty translation."),
387 Some(message_ref),
388 Some(target_locale.to_owned()),
389 ));
390 }
391 }
392 }
393
394 if options.checks.extra_messages {
395 for (key, message) in target_catalog.iter() {
396 if !message.obsolete && !source_keys.contains(key) {
397 report.diagnostics.push(CatalogAuditDiagnostic::new(
398 DiagnosticSeverity::Warning,
399 diagnostic_codes::catalog::EXTRA_TRANSLATION,
400 format!(
401 "Locale `{target_locale}` contains an active message that is not present in the source catalog."
402 ),
403 Some(CatalogAuditMessageRef::new(Some(target_locale), key)),
404 Some(target_locale.to_owned()),
405 ));
406 }
407 }
408 }
409
410 if options.checks.icu_compatibility {
411 audit_icu_compatibility(target_catalog, target_locale, source_keys, report);
412 }
413}
414
415fn audit_icu_syntax_for_message(
416 message: &CatalogMessage,
417 validate_source_identity: bool,
418 message_ref: &CatalogAuditMessageRef,
419 report: &mut CatalogAuditReport,
420) {
421 for value in message_strings(message, validate_source_identity) {
422 if value.trim().is_empty() {
423 continue;
424 }
425 if let Err(error) = parse_icu(value) {
426 report.diagnostics.push(CatalogAuditDiagnostic::new(
427 DiagnosticSeverity::Error,
428 diagnostic_codes::icu::INVALID_SYNTAX,
429 format!("Catalog message is not valid ICU MessageFormat v1: {error}"),
430 Some(message_ref.clone()),
431 None,
432 ));
433 }
434 }
435}
436
437fn audit_icu_compatibility(
438 target_catalog: &NormalizedParsedCatalog,
439 target_locale: &str,
440 source_keys: &BTreeSet<CatalogMessageKey>,
441 report: &mut CatalogAuditReport,
442) {
443 for key in source_keys {
444 let Some(target_message) = target_catalog.get(key).filter(|message| !message.obsolete)
445 else {
446 continue;
447 };
448 let Some(target_value) =
449 singular_translation(target_message).filter(|value| !value.trim().is_empty())
450 else {
451 continue;
452 };
453
454 let Ok(source) = parse_icu(&key.msgid) else {
455 continue;
456 };
457 let Ok(translation) = parse_icu(target_value) else {
458 continue;
459 };
460 let compatibility =
461 compare_icu_messages(&source, &translation, &IcuCompatibilityOptions::default());
462 for diagnostic in compatibility.diagnostics {
463 report.diagnostics.push(CatalogAuditDiagnostic::new(
464 severity_from_icu(diagnostic.severity),
465 diagnostic.code,
466 diagnostic.message,
467 Some(CatalogAuditMessageRef::new(Some(target_locale), key)),
468 diagnostic.name,
469 ));
470 }
471 }
472}
473
474fn audit_metadata(
475 metadata: &[MessageMetadataInput],
476 source_keys: &BTreeSet<CatalogMessageKey>,
477 report: &mut CatalogAuditReport,
478) {
479 let mut seen = BTreeSet::<CatalogMessageKey>::new();
480 for input in metadata {
481 let key = CatalogMessageKey::new(input.msgid.clone(), input.msgctxt.clone());
482 let source_ref = CatalogAuditMessageRef::new(None, &key);
483 if !seen.insert(key.clone()) {
484 report.diagnostics.push(CatalogAuditDiagnostic::new(
485 DiagnosticSeverity::Error,
486 diagnostic_codes::catalog::DUPLICATE_METADATA,
487 "Semantic metadata contains a duplicate message identity.",
488 Some(source_ref.clone()),
489 None,
490 ));
491 }
492 if !source_keys.contains(&key) {
493 report.diagnostics.push(CatalogAuditDiagnostic::new(
494 DiagnosticSeverity::Warning,
495 diagnostic_codes::catalog::METADATA_UNKNOWN_MESSAGE,
496 "Semantic metadata refers to a message that is not active in the source catalog.",
497 Some(source_ref.clone()),
498 None,
499 ));
500 }
501 if let Err(error) = normalize_message_metadata(input.clone()) {
502 report.diagnostics.push(CatalogAuditDiagnostic::new(
503 DiagnosticSeverity::Error,
504 diagnostic_codes::metadata::INVALID_MSGID,
505 format!("Semantic metadata `msgid` is not valid ICU MessageFormat v1: {error}"),
506 Some(source_ref.clone()),
507 Some("msgid".to_owned()),
508 ));
509 continue;
510 }
511 let metadata_report = validate_message_metadata(input);
512 for diagnostic in metadata_report.diagnostics {
513 report.diagnostics.push(CatalogAuditDiagnostic::new(
514 severity_from_icu(diagnostic.severity),
515 diagnostic.code,
516 diagnostic.message,
517 Some(source_ref.clone()),
518 diagnostic.name,
519 ));
520 }
521 }
522}
523
524fn message_strings(message: &CatalogMessage, include_msgid: bool) -> Vec<&str> {
525 let mut values = Vec::new();
526 if include_msgid {
527 push_unique(&mut values, message.msgid.as_str());
528 }
529 match message.effective_translation() {
530 EffectiveTranslationRef::Singular(value) => push_unique(&mut values, value),
531 EffectiveTranslationRef::Plural(translations) => {
532 for value in translations.values().map(String::as_str) {
533 push_unique(&mut values, value);
534 }
535 }
536 }
537 values
538}
539
540fn push_unique<'a>(values: &mut Vec<&'a str>, value: &'a str) {
541 if !values.contains(&value) {
542 values.push(value);
543 }
544}
545
546fn singular_translation(message: &CatalogMessage) -> Option<&str> {
547 match message.effective_translation() {
548 EffectiveTranslationRef::Singular(value) => Some(value),
549 EffectiveTranslationRef::Plural(_) => None,
550 }
551}
552
553fn translation_is_empty(message: &CatalogMessage) -> bool {
554 match message.effective_translation() {
555 EffectiveTranslationRef::Singular(value) => value.trim().is_empty(),
556 EffectiveTranslationRef::Plural(translations) => {
557 translations.is_empty() || translations.values().any(|value| value.trim().is_empty())
558 }
559 }
560}
561
562fn message_has_fuzzy_flag(message: &CatalogMessage) -> bool {
563 message
564 .extra
565 .as_ref()
566 .is_some_and(|extra| extra.flags.iter().any(|flag| flag == "fuzzy"))
567}
568
569fn severity_from_icu(severity: IcuDiagnosticSeverity) -> DiagnosticSeverity {
570 match severity {
571 IcuDiagnosticSeverity::Info => DiagnosticSeverity::Info,
572 IcuDiagnosticSeverity::Warning => DiagnosticSeverity::Warning,
573 IcuDiagnosticSeverity::Error => DiagnosticSeverity::Error,
574 }
575}
576
577fn finalize_summary(
578 report: &mut CatalogAuditReport,
579 source_messages: usize,
580 target_locales: usize,
581) {
582 let mut summary = CatalogAuditSummary {
583 source_messages,
584 target_locales,
585 diagnostics: report.diagnostics.len(),
586 ..CatalogAuditSummary::default()
587 };
588 for diagnostic in &report.diagnostics {
589 match diagnostic.severity {
590 DiagnosticSeverity::Info => summary.infos += 1,
591 DiagnosticSeverity::Warning => summary.warnings += 1,
592 DiagnosticSeverity::Error => summary.errors += 1,
593 }
594 }
595 report.summary = summary;
596}
597
598#[cfg(all(test, feature = "serde"))]
599mod serde_tests {
600 use super::{
601 CatalogAuditDiagnostic, CatalogAuditMessageRef, CatalogAuditReport, CatalogAuditSummary,
602 };
603 use crate::api::DiagnosticSeverity;
604
605 #[test]
606 fn catalog_audit_report_serde_round_trips_ci_report_shape() {
607 let report = CatalogAuditReport {
608 summary: CatalogAuditSummary {
609 source_messages: 1,
610 target_locales: 1,
611 diagnostics: 1,
612 errors: 1,
613 warnings: 0,
614 infos: 0,
615 },
616 diagnostics: vec![CatalogAuditDiagnostic {
617 severity: DiagnosticSeverity::Error,
618 code: "catalog.missing_translation".to_owned(),
619 message: "missing target translation".to_owned(),
620 source_key: Some(CatalogAuditMessageRef {
621 locale: Some("de".to_owned()),
622 msgid: "Checkout".to_owned(),
623 msgctxt: None,
624 }),
625 name: Some("de".to_owned()),
626 }],
627 };
628
629 let json = serde_json::to_value(&report).expect("audit report serialization must succeed");
630 assert_eq!(json["diagnostics"][0]["severity"], "error");
631 assert_eq!(json["summary"]["errors"], 1);
632
633 let roundtrip: CatalogAuditReport =
634 serde_json::from_value(json).expect("audit report deserialization must succeed");
635 assert_eq!(roundtrip, report);
636 }
637}