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, 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/// Options controlling catalog audit checks.
23#[derive(Debug, Clone, PartialEq, Eq, Default)]
24pub struct CatalogAuditOptions<'a> {
25    /// Source locale used as the expected message set.
26    pub source_locale: &'a str,
27    /// Optional target locale filter. Empty means all non-source locales present in `catalogs`.
28    pub locales: &'a [&'a str],
29    /// Optional source-side semantic metadata records.
30    pub metadata: &'a [MessageMetadataInput],
31    /// Individual audit checks to run.
32    pub checks: CatalogAuditChecks,
33}
34
35impl<'a> CatalogAuditOptions<'a> {
36    /// Creates audit options with the required source locale set.
37    #[must_use]
38    pub fn new(source_locale: &'a str) -> Self {
39        Self {
40            source_locale,
41            ..Self::default()
42        }
43    }
44}
45
46/// ICU-specific options used by catalog audit checks.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48#[non_exhaustive]
49pub struct CatalogAuditIcuOptions {
50    /// ICU parser behavior used by syntax and compatibility checks.
51    pub syntax_policy: IcuSyntaxPolicy,
52}
53
54impl CatalogAuditIcuOptions {
55    /// Creates audit ICU options with default strict parser behavior.
56    #[must_use]
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Returns options that parse messages with the given ICU syntax policy.
62    #[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/// Enables or disables individual catalog audit checks.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct CatalogAuditChecks {
72    /// Check that target locales cover active source messages.
73    pub completeness: bool,
74    /// Check for active target messages that are not active in the source catalog.
75    pub extra_messages: bool,
76    /// Validate active source and target message strings as ICU MessageFormat v1.
77    pub icu_syntax: bool,
78    /// Compare target ICU structure against source ICU structure.
79    pub icu_compatibility: bool,
80    /// Validate source-side semantic message metadata.
81    pub semantic_metadata: bool,
82    /// Report existing `fuzzy` flags.
83    pub fuzzy_flags: bool,
84    /// Report obsolete entries.
85    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/// Summary counters for a catalog audit report.
103#[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    /// Active source messages considered expected by the audit.
108    pub source_messages: usize,
109    /// Target locales audited.
110    pub target_locales: usize,
111    /// Total diagnostics emitted.
112    pub diagnostics: usize,
113    /// Error diagnostics emitted.
114    pub errors: usize,
115    /// Warning diagnostics emitted.
116    pub warnings: usize,
117    /// Informational diagnostics emitted.
118    pub infos: usize,
119}
120
121/// Catalog message reference attached to audit diagnostics.
122#[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    /// Locale associated with the diagnostic, when known.
127    pub locale: Option<String>,
128    /// Source message identifier.
129    pub msgid: String,
130    /// Optional gettext context.
131    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/// One machine-readable catalog audit diagnostic.
145#[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    /// Severity for the diagnostic.
150    pub severity: DiagnosticSeverity,
151    /// Stable machine-readable diagnostic code.
152    pub code: String,
153    /// Human-readable explanation of the condition.
154    pub message: String,
155    /// Message identity associated with the diagnostic, when applicable.
156    pub source_key: Option<CatalogAuditMessageRef>,
157    /// Argument, selector, tag, locale, or field name associated with the diagnostic.
158    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/// Report returned by [`audit_catalogs`].
180#[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    /// Aggregate audit counters.
185    pub summary: CatalogAuditSummary,
186    /// Diagnostics found by the audit.
187    pub diagnostics: Vec<CatalogAuditDiagnostic>,
188}
189
190impl CatalogAuditReport {
191    /// Returns `true` when the report contains at least one error diagnostic.
192    #[must_use]
193    pub fn has_errors(&self) -> bool {
194        self.diagnostics
195            .iter()
196            .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
197    }
198}
199
200/// Audits a normalized catalog set for catalog QA and authoring issues.
201///
202/// The audit is read-only: it does not rewrite catalogs, generate fuzzy
203/// matches, or apply source fallback to hide missing target translations.
204///
205/// # Errors
206///
207/// Returns [`ApiError::InvalidArguments`] when `source_locale` is empty or when
208/// catalogs cannot be inspected because their declared locales are missing or
209/// duplicated.
210///
211/// # Examples
212///
213/// ```rust
214/// use ferrocat_po::{CatalogAuditOptions, ParseCatalogOptions, audit_catalogs, parse_catalog};
215///
216/// let source = parse_catalog(ParseCatalogOptions {
217///     locale: Some("en"),
218///     ..ParseCatalogOptions::new("msgid \"Checkout\"\nmsgstr \"Checkout\"\n", "en")
219/// })?
220/// .into_normalized_view()?;
221/// let target = parse_catalog(ParseCatalogOptions {
222///     locale: Some("de"),
223///     ..ParseCatalogOptions::new("msgid \"Checkout\"\nmsgstr \"\"\n", "en")
224/// })?
225/// .into_normalized_view()?;
226///
227/// let report = audit_catalogs(&[&source, &target], &CatalogAuditOptions::new("en"))?;
228/// assert!(report.has_errors());
229/// # Ok::<(), ferrocat_po::ApiError>(())
230/// ```
231pub 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
238/// Audits normalized catalogs with explicit ICU syntax options.
239///
240/// # Errors
241///
242/// Returns [`ApiError::InvalidArguments`] when `source_locale` is empty or when
243/// catalogs cannot be inspected because their declared locales are missing or
244/// duplicated.
245pub 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}