Skip to main content

ferrocat_po/api/
audit.rs

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/// Options controlling catalog audit checks.
18#[derive(Debug, Clone, PartialEq, Eq, Default)]
19pub struct CatalogAuditOptions<'a> {
20    /// Source locale used as the expected message set.
21    pub source_locale: &'a str,
22    /// Optional target locale filter. Empty means all non-source locales present in `catalogs`.
23    pub locales: &'a [&'a str],
24    /// Optional source-side semantic metadata records.
25    pub metadata: &'a [MessageMetadataInput],
26    /// Individual audit checks to run.
27    pub checks: CatalogAuditChecks,
28}
29
30impl<'a> CatalogAuditOptions<'a> {
31    /// Creates audit options with the required source locale set.
32    #[must_use]
33    pub fn new(source_locale: &'a str) -> Self {
34        Self {
35            source_locale,
36            ..Self::default()
37        }
38    }
39}
40
41/// Enables or disables individual catalog audit checks.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct CatalogAuditChecks {
44    /// Check that target locales cover active source messages.
45    pub completeness: bool,
46    /// Check for active target messages that are not active in the source catalog.
47    pub extra_messages: bool,
48    /// Validate active source and target message strings as ICU MessageFormat v1.
49    pub icu_syntax: bool,
50    /// Compare target ICU structure against source ICU structure.
51    pub icu_compatibility: bool,
52    /// Validate source-side semantic message metadata.
53    pub semantic_metadata: bool,
54    /// Report existing `fuzzy` flags.
55    pub fuzzy_flags: bool,
56    /// Report obsolete entries.
57    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/// Summary counters for a catalog audit report.
75#[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    /// Active source messages considered expected by the audit.
80    pub source_messages: usize,
81    /// Target locales audited.
82    pub target_locales: usize,
83    /// Total diagnostics emitted.
84    pub diagnostics: usize,
85    /// Error diagnostics emitted.
86    pub errors: usize,
87    /// Warning diagnostics emitted.
88    pub warnings: usize,
89    /// Informational diagnostics emitted.
90    pub infos: usize,
91}
92
93/// Catalog message reference attached to audit diagnostics.
94#[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    /// Locale associated with the diagnostic, when known.
99    pub locale: Option<String>,
100    /// Source message identifier.
101    pub msgid: String,
102    /// Optional gettext context.
103    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/// One machine-readable catalog audit diagnostic.
117#[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    /// Severity for the diagnostic.
122    pub severity: DiagnosticSeverity,
123    /// Stable machine-readable diagnostic code.
124    pub code: String,
125    /// Human-readable explanation of the condition.
126    pub message: String,
127    /// Message identity associated with the diagnostic, when applicable.
128    pub source_key: Option<CatalogAuditMessageRef>,
129    /// Argument, selector, tag, locale, or field name associated with the diagnostic.
130    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/// Report returned by [`audit_catalogs`].
152#[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    /// Aggregate audit counters.
157    pub summary: CatalogAuditSummary,
158    /// Diagnostics found by the audit.
159    pub diagnostics: Vec<CatalogAuditDiagnostic>,
160}
161
162impl CatalogAuditReport {
163    /// Returns `true` when the report contains at least one error diagnostic.
164    #[must_use]
165    pub fn has_errors(&self) -> bool {
166        self.diagnostics
167            .iter()
168            .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
169    }
170}
171
172/// Audits a normalized catalog set for catalog QA and authoring issues.
173///
174/// The audit is read-only: it does not rewrite catalogs, generate fuzzy
175/// matches, or apply source fallback to hide missing target translations.
176///
177/// # Errors
178///
179/// Returns [`ApiError::InvalidArguments`] when `source_locale` is empty or when
180/// catalogs cannot be inspected because their declared locales are missing or
181/// duplicated.
182///
183/// # Examples
184///
185/// ```rust
186/// use ferrocat_po::{CatalogAuditOptions, ParseCatalogOptions, audit_catalogs, parse_catalog};
187///
188/// let source = parse_catalog(ParseCatalogOptions {
189///     locale: Some("en"),
190///     ..ParseCatalogOptions::new("msgid \"Checkout\"\nmsgstr \"Checkout\"\n", "en")
191/// })?
192/// .into_normalized_view()?;
193/// let target = parse_catalog(ParseCatalogOptions {
194///     locale: Some("de"),
195///     ..ParseCatalogOptions::new("msgid \"Checkout\"\nmsgstr \"\"\n", "en")
196/// })?
197/// .into_normalized_view()?;
198///
199/// let report = audit_catalogs(&[&source, &target], &CatalogAuditOptions::new("en"))?;
200/// assert!(report.has_errors());
201/// # Ok::<(), ferrocat_po::ApiError>(())
202/// ```
203pub 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}