Skip to main content

ferrocat_po/api/
compile_types.rs

1use std::collections::BTreeMap;
2use std::ptr;
3
4use ferrocat_icu::{IcuFormatter, IcuFormatterSupport};
5
6use crate::diagnostic_codes::DiagnosticCode;
7
8use super::{
9    ApiError, CatalogMessageKey, CatalogSemantics, IcuSyntaxPolicy, NormalizedParsedCatalog,
10    compile::{
11        compiled_catalog_translation_kind_for_message, compiled_key_for,
12        describe_compiled_id_catalogs,
13    },
14};
15
16#[cfg(feature = "serde")]
17use serde::{Deserialize, Deserializer, Serialize, Serializer};
18
19/// JSON schema version emitted by [`CompiledCatalogArtifact`] serialization.
20pub const COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION: u16 = 1;
21
22/// Translation value stored in a compiled runtime catalog.
23#[derive(Debug, Clone, PartialEq, Eq)]
24#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
25#[cfg_attr(
26    feature = "serde",
27    serde(tag = "kind", content = "value", rename_all = "snake_case")
28)]
29pub enum CompiledTranslation {
30    /// Singular runtime value.
31    Singular(String),
32    /// Structured plural runtime value.
33    Plural(BTreeMap<String, String>),
34}
35
36/// Built-in key strategy used when compiling runtime catalogs.
37///
38/// This enum is non-exhaustive so additional stable key strategies can be added
39/// without breaking downstream matches.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
42#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
43#[non_exhaustive]
44pub enum CompiledKeyStrategy {
45    /// `ferrocat` v1 key format: SHA-256 over a versioned, length-delimited
46    /// `msgctxt`/`msgid` payload, truncated to 64 bits and encoded as unpadded
47    /// `Base64URL`.
48    #[default]
49    FerrocatV1,
50}
51
52/// Callback used to validate runtime support for ICU formatters.
53///
54/// The callback receives each formatter discovered in a final runtime ICU
55/// message and returns whether that runtime supports the formatter kind and
56/// style.
57///
58/// This is intentionally a non-capturing function pointer so ICU options stay
59/// cheap to copy.
60pub type IcuFormatterSupportPolicy = fn(&IcuFormatter) -> IcuFormatterSupport;
61
62/// Options controlling runtime catalog compilation.
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[non_exhaustive]
65pub struct CompileCatalogOptions<'a> {
66    /// Built-in strategy used to derive stable runtime keys.
67    pub key_strategy: CompiledKeyStrategy,
68    /// Whether empty source-locale values should be filled from the source text.
69    pub source_fallback: bool,
70    /// Source locale used when `source_fallback` is enabled.
71    pub source_locale: Option<&'a str>,
72    /// High-level semantics used by the input catalog set.
73    pub semantics: CatalogSemantics,
74}
75
76impl Default for CompileCatalogOptions<'_> {
77    fn default() -> Self {
78        Self {
79            key_strategy: CompiledKeyStrategy::FerrocatV1,
80            source_fallback: false,
81            source_locale: None,
82            semantics: CatalogSemantics::IcuNative,
83        }
84    }
85}
86
87impl<'a> CompileCatalogOptions<'a> {
88    /// Creates runtime catalog compile options with default behavior.
89    #[must_use]
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Returns options that derive runtime keys with the given strategy.
95    #[must_use]
96    pub fn with_key_strategy(mut self, key_strategy: CompiledKeyStrategy) -> Self {
97        self.key_strategy = key_strategy;
98        self
99    }
100
101    /// Returns options that enable or disable source-locale fallback.
102    #[must_use]
103    pub fn with_source_fallback(mut self, source_fallback: bool) -> Self {
104        self.source_fallback = source_fallback;
105        self
106    }
107
108    /// Returns options that use the given source locale for source fallback.
109    #[must_use]
110    pub fn with_source_locale(mut self, source_locale: &'a str) -> Self {
111        self.source_locale = Some(source_locale);
112        self
113    }
114
115    /// Returns options that interpret input catalogs with the given semantics.
116    #[must_use]
117    pub fn with_semantics(mut self, semantics: CatalogSemantics) -> Self {
118        self.semantics = semantics;
119        self
120    }
121}
122
123/// Options controlling high-level compiled catalog artifact generation.
124#[derive(Debug, Clone, PartialEq, Eq)]
125#[non_exhaustive]
126pub struct CompileCatalogArtifactOptions<'a> {
127    /// Locale for which the runtime artifact should be produced.
128    pub requested_locale: &'a str,
129    /// Source locale used for explicit source fallback behavior.
130    pub source_locale: &'a str,
131    /// Ordered fallback locales consulted after the requested locale.
132    pub fallback_chain: &'a [&'a str],
133    /// Built-in strategy used to derive stable runtime keys.
134    pub key_strategy: CompiledKeyStrategy,
135    /// Whether source text should be used when no non-source translation exists.
136    pub source_fallback: bool,
137    /// Whether invalid final ICU messages should fail compilation instead of producing diagnostics.
138    pub strict_icu: bool,
139    /// Whether final ICU messages should be checked against source ICU structure.
140    pub icu_compatibility: bool,
141    /// High-level semantics used by the input catalog set.
142    pub semantics: CatalogSemantics,
143    /// ICU-specific options used while validating final runtime messages.
144    pub icu_options: CompileCatalogArtifactIcuOptions,
145}
146
147impl<'a> CompileCatalogArtifactOptions<'a> {
148    /// Creates artifact compile options with required locales set.
149    ///
150    /// Optional fields default to an empty fallback chain, `FerrocatV1` keys,
151    /// no source fallback, non-strict ICU diagnostics, no ICU compatibility
152    /// check, and ICU-native semantics.
153    #[must_use]
154    pub fn new(requested_locale: &'a str, source_locale: &'a str) -> Self {
155        Self {
156            requested_locale,
157            source_locale,
158            fallback_chain: &[],
159            key_strategy: CompiledKeyStrategy::FerrocatV1,
160            source_fallback: false,
161            strict_icu: false,
162            icu_compatibility: false,
163            semantics: CatalogSemantics::IcuNative,
164            icu_options: CompileCatalogArtifactIcuOptions::new(),
165        }
166    }
167
168    /// Returns options that compile the given requested locale.
169    #[must_use]
170    pub fn with_requested_locale(mut self, requested_locale: &'a str) -> Self {
171        self.requested_locale = requested_locale;
172        self
173    }
174
175    /// Returns options that use the given source locale.
176    #[must_use]
177    pub fn with_source_locale(mut self, source_locale: &'a str) -> Self {
178        self.source_locale = source_locale;
179        self
180    }
181
182    /// Returns options that consult the given ordered fallback locales.
183    #[must_use]
184    pub fn with_fallback_chain(mut self, fallback_chain: &'a [&'a str]) -> Self {
185        self.fallback_chain = fallback_chain;
186        self
187    }
188
189    /// Returns options that derive runtime keys with the given strategy.
190    #[must_use]
191    pub fn with_key_strategy(mut self, key_strategy: CompiledKeyStrategy) -> Self {
192        self.key_strategy = key_strategy;
193        self
194    }
195
196    /// Returns options that enable or disable source-text fallback.
197    #[must_use]
198    pub fn with_source_fallback(mut self, source_fallback: bool) -> Self {
199        self.source_fallback = source_fallback;
200        self
201    }
202
203    /// Returns options that enable or disable hard errors for invalid final ICU messages.
204    #[must_use]
205    pub fn with_strict_icu(mut self, strict_icu: bool) -> Self {
206        self.strict_icu = strict_icu;
207        self
208    }
209
210    /// Returns options that enable or disable final ICU compatibility checks.
211    #[must_use]
212    pub fn with_icu_compatibility(mut self, icu_compatibility: bool) -> Self {
213        self.icu_compatibility = icu_compatibility;
214        self
215    }
216
217    /// Returns options that interpret input catalogs with the given semantics.
218    #[must_use]
219    pub fn with_semantics(mut self, semantics: CatalogSemantics) -> Self {
220        self.semantics = semantics;
221        self
222    }
223
224    /// Returns options that use the given ICU parser and formatter-support settings.
225    #[must_use]
226    pub fn with_icu_options(mut self, icu_options: CompileCatalogArtifactIcuOptions) -> Self {
227        self.icu_options = icu_options;
228        self
229    }
230}
231
232/// ICU-specific options used while compiling catalog artifacts.
233#[derive(Debug, Clone, Copy, Default)]
234#[non_exhaustive]
235pub struct CompileCatalogArtifactIcuOptions {
236    /// ICU parser behavior used for final runtime message validation.
237    pub syntax_policy: IcuSyntaxPolicy,
238    /// Optional runtime support policy for ICU formatter kinds and styles.
239    pub formatter_support: Option<IcuFormatterSupportPolicy>,
240}
241
242impl PartialEq for CompileCatalogArtifactIcuOptions {
243    fn eq(&self, other: &Self) -> bool {
244        self.syntax_policy == other.syntax_policy
245            && match (self.formatter_support, other.formatter_support) {
246                // Function pointer address equality is best-effort: codegen may
247                // make the same callback compare unequal or merge distinct
248                // callbacks. This keeps option equality useful for tests and
249                // diagnostics, but it is not a stable callback identity contract.
250                (Some(left), Some(right)) => ptr::fn_addr_eq(left, right),
251                (None, None) => true,
252                _ => false,
253            }
254    }
255}
256
257impl Eq for CompileCatalogArtifactIcuOptions {}
258
259impl CompileCatalogArtifactIcuOptions {
260    /// Creates artifact ICU options with default strict parser behavior.
261    #[must_use]
262    pub fn new() -> Self {
263        Self::default()
264    }
265
266    /// Returns options that parse messages with the given ICU syntax policy.
267    #[must_use]
268    pub fn with_syntax_policy(mut self, syntax_policy: IcuSyntaxPolicy) -> Self {
269        self.syntax_policy = syntax_policy;
270        self
271    }
272
273    /// Returns options that validate formatter support with the given callback.
274    #[must_use]
275    pub fn with_formatter_support(mut self, formatter_support: IcuFormatterSupportPolicy) -> Self {
276        self.formatter_support = Some(formatter_support);
277        self
278    }
279}
280
281/// Options controlling selected-subset compiled catalog artifact generation.
282#[derive(Debug, Clone, PartialEq, Eq)]
283#[non_exhaustive]
284pub struct CompileSelectedCatalogArtifactOptions<'a> {
285    /// Requested compiled runtime IDs to include in the artifact.
286    pub compiled_ids: &'a [&'a str],
287    /// Shared artifact compile options applied to the selected IDs.
288    pub options: CompileCatalogArtifactOptions<'a>,
289}
290
291impl<'a> CompileSelectedCatalogArtifactOptions<'a> {
292    /// Creates selected artifact compile options with required locales and IDs set.
293    ///
294    /// Optional fields on the nested artifact options use
295    /// [`CompileCatalogArtifactOptions::new`] defaults.
296    #[must_use]
297    pub fn new(
298        requested_locale: &'a str,
299        source_locale: &'a str,
300        compiled_ids: &'a [&'a str],
301    ) -> Self {
302        Self {
303            compiled_ids,
304            options: CompileCatalogArtifactOptions::new(requested_locale, source_locale),
305        }
306    }
307
308    /// Returns options that include the given compiled runtime IDs.
309    #[must_use]
310    pub fn with_compiled_ids(mut self, compiled_ids: &'a [&'a str]) -> Self {
311        self.compiled_ids = compiled_ids;
312        self
313    }
314
315    /// Returns options that use the given shared artifact compile options.
316    #[must_use]
317    pub fn with_options(mut self, options: CompileCatalogArtifactOptions<'a>) -> Self {
318        self.options = options;
319        self
320    }
321}
322
323/// Message selection for [`super::compile_catalog_artifact_report`].
324#[derive(Debug, Clone, Copy)]
325pub enum CompileCatalogArtifactReportSelection<'a> {
326    /// Compile and report every non-obsolete source identity available in the catalog set.
327    All,
328    /// Compile and report only the requested compiled runtime IDs.
329    Selected {
330        /// Stable ID index used to map compiled IDs back to source identities.
331        index: &'a CompiledCatalogIdIndex,
332        /// Requested compiled runtime IDs to include in the artifact and provenance report.
333        compiled_ids: &'a [&'a str],
334    },
335}
336
337/// Options controlling compiled artifact generation with a sibling provenance report.
338#[derive(Debug, Clone)]
339#[non_exhaustive]
340pub struct CompileCatalogArtifactReportOptions<'a> {
341    /// Shared artifact compile options applied to the generated artifact.
342    pub options: CompileCatalogArtifactOptions<'a>,
343    /// ICU-specific options applied while validating final runtime messages.
344    pub icu_options: CompileCatalogArtifactIcuOptions,
345    /// Source identity selection for the generated artifact and provenance report.
346    pub selection: CompileCatalogArtifactReportSelection<'a>,
347}
348
349impl<'a> CompileCatalogArtifactReportOptions<'a> {
350    /// Creates report compile options for every non-obsolete source identity.
351    #[must_use]
352    pub fn new(requested_locale: &'a str, source_locale: &'a str) -> Self {
353        Self {
354            options: CompileCatalogArtifactOptions::new(requested_locale, source_locale),
355            icu_options: CompileCatalogArtifactIcuOptions::new(),
356            selection: CompileCatalogArtifactReportSelection::All,
357        }
358    }
359
360    /// Creates report compile options for a selected subset of compiled runtime IDs.
361    #[must_use]
362    pub fn selected(
363        requested_locale: &'a str,
364        source_locale: &'a str,
365        index: &'a CompiledCatalogIdIndex,
366        compiled_ids: &'a [&'a str],
367    ) -> Self {
368        Self {
369            options: CompileCatalogArtifactOptions::new(requested_locale, source_locale),
370            icu_options: CompileCatalogArtifactIcuOptions::new(),
371            selection: CompileCatalogArtifactReportSelection::Selected {
372                index,
373                compiled_ids,
374            },
375        }
376    }
377
378    /// Returns report options that use the given shared artifact compile options.
379    #[must_use]
380    pub fn with_options(mut self, options: CompileCatalogArtifactOptions<'a>) -> Self {
381        self.options = options;
382        self
383    }
384
385    /// Returns report options that use the given ICU validation options.
386    #[must_use]
387    pub fn with_icu_options(mut self, icu_options: CompileCatalogArtifactIcuOptions) -> Self {
388        self.icu_options = icu_options;
389        self
390    }
391
392    /// Returns report options that use the given source identity selection.
393    #[must_use]
394    pub fn with_selection(mut self, selection: CompileCatalogArtifactReportSelection<'a>) -> Self {
395        self.selection = selection;
396        self
397    }
398}
399
400/// High-level translation kind associated with a compiled runtime ID.
401#[derive(Debug, Clone, Copy, PartialEq, Eq)]
402#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
403#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
404pub enum CompiledCatalogTranslationKind {
405    /// Translation is a single string value.
406    Singular,
407    /// Translation is a plural/category map.
408    Plural,
409}
410
411/// A compiled runtime message keyed by a derived lookup key.
412#[derive(Debug, Clone, PartialEq, Eq)]
413#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
414#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
415pub struct CompiledMessage {
416    /// Stable runtime key derived from the source identity.
417    pub key: String,
418    /// Original gettext identity preserved for diagnostics and tooling.
419    pub source_key: CatalogMessageKey,
420    /// Materialized translation payload for runtime lookup.
421    pub translation: CompiledTranslation,
422}
423
424/// Runtime-oriented lookup structure compiled from a normalized catalog.
425#[derive(Debug, Clone, PartialEq, Eq, Default)]
426#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
427#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
428pub struct CompiledCatalog {
429    pub(super) entries: BTreeMap<String, CompiledMessage>,
430}
431
432impl CompiledCatalog {
433    /// Returns the compiled message for `key`, if present.
434    #[must_use]
435    pub fn get(&self, key: &str) -> Option<&CompiledMessage> {
436        self.entries.get(key)
437    }
438
439    /// Returns the number of compiled entries.
440    #[must_use]
441    pub fn len(&self) -> usize {
442        self.entries.len()
443    }
444
445    /// Returns `true` when the compiled catalog has no entries.
446    #[must_use]
447    pub fn is_empty(&self) -> bool {
448        self.entries.is_empty()
449    }
450
451    /// Iterates over compiled entries in key order.
452    pub fn iter(&self) -> impl Iterator<Item = (&str, &CompiledMessage)> + '_ {
453        self.entries
454            .iter()
455            .map(|(key, message)| (key.as_str(), message))
456    }
457}
458
459/// Stable compiled runtime ID index built from one or more normalized catalogs.
460#[derive(Debug, Clone, PartialEq, Eq, Default)]
461#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
462#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
463pub struct CompiledCatalogIdIndex {
464    pub(super) ids: BTreeMap<String, CatalogMessageKey>,
465}
466
467/// Metadata describing one compiled runtime ID for a specific catalog set.
468#[derive(Debug, Clone, PartialEq, Eq)]
469#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
470#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
471pub struct CompiledCatalogIdDescription {
472    /// Stable runtime ID derived from the source identity.
473    pub compiled_id: String,
474    /// Original gettext identity preserved for diagnostics and tooling.
475    pub source_key: CatalogMessageKey,
476    /// Locales from the provided catalog set that contain this non-obsolete message.
477    pub available_locales: Vec<String>,
478    /// Whether the message is singular or plural in the provided catalog set.
479    pub translation_kind: CompiledCatalogTranslationKind,
480}
481
482/// Report returned by [`CompiledCatalogIdIndex::describe_compiled_ids`].
483#[derive(Debug, Clone, PartialEq, Eq, Default)]
484#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
485#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
486pub struct DescribeCompiledIdsReport {
487    /// Metadata for requested IDs that were known to the index and present in the provided catalogs.
488    pub described: Vec<CompiledCatalogIdDescription>,
489    /// Requested compiled IDs that were not known to the index at all.
490    pub unknown_compiled_ids: Vec<String>,
491    /// Requested compiled IDs that were known to the index but not present in the provided catalogs.
492    pub unavailable_compiled_ids: Vec<CompiledCatalogUnavailableId>,
493}
494
495/// Known compiled runtime ID that was not present in the provided catalog set.
496#[derive(Debug, Clone, PartialEq, Eq)]
497#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
498#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
499pub struct CompiledCatalogUnavailableId {
500    /// Stable runtime ID derived from the source identity.
501    pub compiled_id: String,
502    /// Original gettext identity preserved for diagnostics and tooling.
503    pub source_key: CatalogMessageKey,
504}
505
506impl CompiledCatalogIdIndex {
507    /// Builds a deterministic compiled-ID index for the union of non-obsolete messages.
508    ///
509    /// # Errors
510    ///
511    /// Returns [`ApiError::Conflict`] when two different source identities compile to the same ID.
512    pub fn new(
513        catalogs: &[&NormalizedParsedCatalog],
514        key_strategy: CompiledKeyStrategy,
515    ) -> Result<Self, ApiError> {
516        Self::new_with_key_generator(catalogs, key_strategy, compiled_key_for)
517    }
518
519    pub(super) fn new_with_key_generator<F>(
520        catalogs: &[&NormalizedParsedCatalog],
521        key_strategy: CompiledKeyStrategy,
522        mut key_generator: F,
523    ) -> Result<Self, ApiError>
524    where
525        F: FnMut(CompiledKeyStrategy, &CatalogMessageKey) -> String,
526    {
527        let mut ids = BTreeMap::<String, CatalogMessageKey>::new();
528
529        for catalog in catalogs {
530            for (source_key, message) in catalog.iter() {
531                if message.obsolete.is_some() {
532                    continue;
533                }
534                let compiled_id = key_generator(key_strategy, source_key);
535                if let Some(existing) = ids.get(&compiled_id) {
536                    if existing != source_key {
537                        return Err(ApiError::Conflict(format!(
538                            "compiled catalog key collision for {:?} / {:?} and {:?} / {:?} using key {}",
539                            existing.msgctxt,
540                            existing.msgid,
541                            source_key.msgctxt,
542                            source_key.msgid,
543                            compiled_id
544                        )));
545                    }
546                    continue;
547                }
548                ids.insert(compiled_id, source_key.clone());
549            }
550        }
551
552        Ok(Self { ids })
553    }
554
555    /// Returns the source key for `compiled_id`, if present.
556    #[must_use]
557    pub fn get(&self, compiled_id: &str) -> Option<&CatalogMessageKey> {
558        self.ids.get(compiled_id)
559    }
560
561    /// Returns `true` when the index contains `compiled_id`.
562    #[must_use]
563    pub fn contains_id(&self, compiled_id: &str) -> bool {
564        self.ids.contains_key(compiled_id)
565    }
566
567    /// Returns the number of indexed compiled IDs.
568    #[must_use]
569    pub fn len(&self) -> usize {
570        self.ids.len()
571    }
572
573    /// Returns `true` when the index contains no compiled IDs.
574    #[must_use]
575    pub fn is_empty(&self) -> bool {
576        self.ids.is_empty()
577    }
578
579    /// Iterates over compiled IDs in sorted order.
580    pub fn iter(&self) -> impl Iterator<Item = (&str, &CatalogMessageKey)> + '_ {
581        self.ids
582            .iter()
583            .map(|(compiled_id, source_key)| (compiled_id.as_str(), source_key))
584    }
585
586    /// Returns the underlying ordered compiled-ID map by reference.
587    #[must_use]
588    pub fn as_btreemap(&self) -> &BTreeMap<String, CatalogMessageKey> {
589        &self.ids
590    }
591
592    /// Consumes the index and returns the underlying ordered compiled-ID map.
593    #[must_use]
594    pub fn into_btreemap(self) -> BTreeMap<String, CatalogMessageKey> {
595        self.ids
596    }
597
598    /// Describes selected compiled IDs against a provided catalog set.
599    ///
600    /// # Errors
601    ///
602    /// Returns [`ApiError::InvalidArguments`] when a provided catalog does not declare
603    /// a locale, or [`ApiError::Conflict`] when the same compiled ID maps to different
604    /// translation kinds across the provided catalogs.
605    pub fn describe_compiled_ids(
606        &self,
607        catalogs: &[&NormalizedParsedCatalog],
608        compiled_ids: &[&str],
609    ) -> Result<DescribeCompiledIdsReport, ApiError> {
610        let locales = describe_compiled_id_catalogs(catalogs)?;
611        let mut report = DescribeCompiledIdsReport::default();
612
613        for compiled_id in std::collections::BTreeSet::from_iter(compiled_ids.iter().copied()) {
614            let Some(source_key) = self.get(compiled_id).cloned() else {
615                report.unknown_compiled_ids.push(compiled_id.to_owned());
616                continue;
617            };
618
619            let mut available_locales = Vec::new();
620            let mut translation_kind = None;
621
622            for (locale, catalog) in &locales {
623                let Some(message) = catalog.get(&source_key) else {
624                    continue;
625                };
626                if message.obsolete.is_some() {
627                    continue;
628                }
629                let next_kind = compiled_catalog_translation_kind_for_message(
630                    catalog.parsed_catalog().semantics,
631                    message,
632                );
633                if let Some(existing_kind) = translation_kind {
634                    if existing_kind != next_kind {
635                        return Err(ApiError::Conflict(format!(
636                            "compiled ID {:?} resolves to inconsistent translation shapes across the provided catalogs",
637                            compiled_id
638                        )));
639                    }
640                } else {
641                    translation_kind = Some(next_kind);
642                }
643                available_locales.push(locale.clone());
644            }
645
646            if let Some(translation_kind) = translation_kind {
647                report.described.push(CompiledCatalogIdDescription {
648                    compiled_id: compiled_id.to_owned(),
649                    source_key,
650                    available_locales,
651                    translation_kind,
652                });
653            } else {
654                report
655                    .unavailable_compiled_ids
656                    .push(CompiledCatalogUnavailableId {
657                        compiled_id: compiled_id.to_owned(),
658                        source_key,
659                    });
660            }
661        }
662
663        Ok(report)
664    }
665}
666
667/// Host-neutral compiled runtime artifact for one requested locale.
668///
669/// When the `serde` feature is enabled, this type serializes with
670/// [`COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION`] as a required
671/// `schema_version` field. Deserialization rejects unknown artifact schema
672/// versions.
673#[derive(Debug, Clone, PartialEq, Eq, Default)]
674pub struct CompiledCatalogArtifact {
675    /// Final runtime message map keyed by the derived lookup key.
676    pub messages: BTreeMap<String, String>,
677    /// Messages that were missing from the requested locale and had to fall back.
678    pub missing: Vec<CompiledCatalogMissingMessage>,
679    /// Diagnostics collected while validating final runtime messages.
680    pub diagnostics: Vec<CompiledCatalogDiagnostic>,
681}
682
683/// Result returned by [`super::compile_catalog_artifact_report`].
684#[derive(Debug, Clone, PartialEq, Eq)]
685#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
686#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
687pub struct CompiledCatalogArtifactReport {
688    /// Host-neutral runtime artifact produced by the same compile path as
689    /// [`super::compile_catalog_artifact`].
690    pub artifact: CompiledCatalogArtifact,
691    /// Sibling report describing how each compiled message resolved.
692    pub provenance: CompiledCatalogProvenanceReport,
693}
694
695/// Provenance metadata for one compiled requested-locale artifact.
696#[derive(Debug, Clone, PartialEq, Eq)]
697#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
698#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
699pub struct CompiledCatalogProvenanceReport {
700    /// Requested locale used for artifact compilation.
701    pub requested_locale: String,
702    /// Source locale used for explicit source fallback behavior.
703    pub source_locale: String,
704    /// Ordered fallback locales configured for this compile request.
705    pub fallback_chain: Vec<String>,
706    /// Per-message resolution rows in the same deterministic source-key order as compilation.
707    pub messages: Vec<CompiledCatalogResolution>,
708}
709
710/// Provenance row for one compiled runtime message identity.
711#[derive(Debug, Clone, PartialEq, Eq)]
712#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
713#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
714pub struct CompiledCatalogResolution {
715    /// Stable runtime key derived from the source identity.
716    pub key: String,
717    /// Original gettext identity preserved for diagnostics and tooling.
718    pub source_key: CatalogMessageKey,
719    /// Locale that ultimately provided the runtime value, if any.
720    pub resolved_locale: Option<String>,
721    /// Resolution category for this message.
722    pub kind: CompiledCatalogResolutionKind,
723}
724
725/// How one compiled runtime message resolved for a requested-locale artifact.
726#[derive(Debug, Clone, Copy, PartialEq, Eq)]
727#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
728#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
729#[non_exhaustive]
730pub enum CompiledCatalogResolutionKind {
731    /// The requested locale provided the final runtime message.
732    Requested,
733    /// A configured non-source fallback locale provided the final runtime message.
734    Fallback,
735    /// The source locale provided the final runtime message through source fallback.
736    SourceFallback,
737    /// No locale provided a final runtime message.
738    Unresolved,
739}
740
741#[cfg(feature = "serde")]
742#[derive(Serialize)]
743struct CompiledCatalogArtifactWireRef<'a> {
744    schema_version: u16,
745    messages: &'a BTreeMap<String, String>,
746    missing: &'a [CompiledCatalogMissingMessage],
747    diagnostics: &'a [CompiledCatalogDiagnostic],
748}
749
750#[cfg(feature = "serde")]
751#[derive(Deserialize)]
752#[serde(deny_unknown_fields)]
753struct CompiledCatalogArtifactWire {
754    schema_version: u16,
755    #[serde(default)]
756    messages: BTreeMap<String, String>,
757    #[serde(default)]
758    missing: Vec<CompiledCatalogMissingMessage>,
759    #[serde(default)]
760    diagnostics: Vec<CompiledCatalogDiagnostic>,
761}
762
763#[cfg(feature = "serde")]
764impl Serialize for CompiledCatalogArtifact {
765    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
766    where
767        S: Serializer,
768    {
769        CompiledCatalogArtifactWireRef {
770            schema_version: COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION,
771            messages: &self.messages,
772            missing: &self.missing,
773            diagnostics: &self.diagnostics,
774        }
775        .serialize(serializer)
776    }
777}
778
779#[cfg(feature = "serde")]
780impl<'de> Deserialize<'de> for CompiledCatalogArtifact {
781    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
782    where
783        D: Deserializer<'de>,
784    {
785        let wire = CompiledCatalogArtifactWire::deserialize(deserializer)?;
786        if wire.schema_version != COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION {
787            return Err(serde::de::Error::custom(format!(
788                "unsupported compiled catalog artifact schema_version {}; expected {}",
789                wire.schema_version, COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION
790            )));
791        }
792
793        Ok(Self {
794            messages: wire.messages,
795            missing: wire.missing,
796            diagnostics: wire.diagnostics,
797        })
798    }
799}
800
801/// Missing-message record emitted by [`super::compile_catalog_artifact`].
802#[derive(Debug, Clone, PartialEq, Eq)]
803#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
804#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
805pub struct CompiledCatalogMissingMessage {
806    /// Stable runtime key derived from the source identity.
807    pub key: String,
808    /// Original gettext identity preserved for diagnostics and tooling.
809    pub source_key: CatalogMessageKey,
810    /// Requested locale for this artifact compilation.
811    pub requested_locale: String,
812    /// Locale that ultimately provided the runtime value, if any.
813    pub resolved_locale: Option<String>,
814}
815
816/// Diagnostic emitted by [`super::compile_catalog_artifact`].
817#[derive(Debug, Clone, PartialEq, Eq)]
818#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
819#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
820pub struct CompiledCatalogDiagnostic {
821    /// Severity for the collected diagnostic.
822    pub severity: super::DiagnosticSeverity,
823    /// Stable machine-readable diagnostic code.
824    pub code: DiagnosticCode,
825    /// Human-readable explanation of the problem.
826    pub message: String,
827    /// Stable runtime key derived from the source identity.
828    pub key: String,
829    /// Source `msgid` associated with the diagnostic.
830    pub msgid: String,
831    /// Source `msgctxt` associated with the diagnostic.
832    pub msgctxt: Option<String>,
833    /// Locale whose final runtime message produced the diagnostic.
834    pub locale: String,
835}
836
837#[cfg(test)]
838mod tests {
839    use std::collections::BTreeMap;
840
841    use super::{
842        COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION, CompileCatalogArtifactIcuOptions,
843        CompileCatalogArtifactOptions, CompileCatalogArtifactReportOptions,
844        CompileCatalogArtifactReportSelection, CompileCatalogOptions,
845        CompileSelectedCatalogArtifactOptions, CompiledCatalogArtifact, CompiledCatalogDiagnostic,
846        CompiledCatalogIdIndex, CompiledCatalogMissingMessage, CompiledKeyStrategy,
847    };
848    use ferrocat_icu::{
849        IcuArgumentKind, IcuDiagnosticSeverity, IcuFormatter, IcuFormatterSupport, IcuStyleKind,
850    };
851
852    use crate::api::{CatalogMessageKey, CatalogSemantics, DiagnosticSeverity, IcuSyntaxPolicy};
853
854    #[test]
855    fn compile_option_constructors_set_required_fields_and_keep_defaults() {
856        let compile = CompileCatalogOptions::new();
857        assert_eq!(compile.key_strategy, CompiledKeyStrategy::FerrocatV1);
858        assert!(!compile.source_fallback);
859
860        let artifact = CompileCatalogArtifactOptions::new("de", "en");
861        assert_eq!(artifact.requested_locale, "de");
862        assert_eq!(artifact.source_locale, "en");
863        assert_eq!(artifact.key_strategy, CompiledKeyStrategy::FerrocatV1);
864        assert_eq!(artifact.semantics, CatalogSemantics::IcuNative);
865
866        let selected_ids = ["abc123"];
867        let selected = CompileSelectedCatalogArtifactOptions::new("de", "en", &selected_ids);
868        assert_eq!(selected.options.requested_locale, "de");
869        assert_eq!(selected.options.source_locale, "en");
870        assert_eq!(selected.compiled_ids, selected_ids.as_slice());
871        assert_eq!(
872            selected.options.key_strategy,
873            CompiledKeyStrategy::FerrocatV1
874        );
875
876        let report = CompileCatalogArtifactReportOptions::new("de", "en");
877        assert_eq!(report.options.requested_locale, "de");
878        assert_eq!(report.options.source_locale, "en");
879        assert!(matches!(
880            report.selection,
881            CompileCatalogArtifactReportSelection::All
882        ));
883    }
884
885    #[test]
886    fn compile_option_builders_set_fields() {
887        let compile = CompileCatalogOptions::new()
888            .with_key_strategy(CompiledKeyStrategy::FerrocatV1)
889            .with_source_fallback(true)
890            .with_source_locale("en")
891            .with_semantics(CatalogSemantics::GettextCompat);
892
893        assert_eq!(compile.key_strategy, CompiledKeyStrategy::FerrocatV1);
894        assert!(compile.source_fallback);
895        assert_eq!(compile.source_locale, Some("en"));
896        assert_eq!(compile.semantics, CatalogSemantics::GettextCompat);
897
898        let fallback_chain = ["fr", "en"];
899        let artifact = CompileCatalogArtifactOptions::new("de", "en")
900            .with_requested_locale("fr")
901            .with_source_locale("en-US")
902            .with_fallback_chain(&fallback_chain)
903            .with_key_strategy(CompiledKeyStrategy::FerrocatV1)
904            .with_source_fallback(true)
905            .with_strict_icu(true)
906            .with_icu_compatibility(true)
907            .with_semantics(CatalogSemantics::GettextCompat);
908
909        assert_eq!(artifact.requested_locale, "fr");
910        assert_eq!(artifact.source_locale, "en-US");
911        assert_eq!(artifact.fallback_chain, fallback_chain.as_slice());
912        assert!(artifact.source_fallback);
913        assert!(artifact.strict_icu);
914        assert!(artifact.icu_compatibility);
915        assert_eq!(artifact.semantics, CatalogSemantics::GettextCompat);
916
917        let selected_ids = ["id-1"];
918        let other_ids = ["id-2"];
919        let selected = CompileSelectedCatalogArtifactOptions::new("de", "en", &selected_ids)
920            .with_compiled_ids(&other_ids)
921            .with_options(artifact.clone());
922
923        assert_eq!(selected.compiled_ids, other_ids.as_slice());
924        assert_eq!(selected.options, artifact);
925
926        let index = CompiledCatalogIdIndex::default();
927        let selection = CompileCatalogArtifactReportSelection::Selected {
928            index: &index,
929            compiled_ids: &other_ids,
930        };
931        let report = CompileCatalogArtifactReportOptions::new("de", "en")
932            .with_options(artifact.clone())
933            .with_icu_options(
934                CompileCatalogArtifactIcuOptions::new()
935                    .with_syntax_policy(IcuSyntaxPolicy::RuntimeLiteralApostrophes),
936            )
937            .with_selection(selection);
938
939        assert_eq!(report.options, artifact);
940        assert_eq!(
941            report.icu_options.syntax_policy,
942            IcuSyntaxPolicy::RuntimeLiteralApostrophes
943        );
944        assert!(matches!(
945            report.selection,
946            CompileCatalogArtifactReportSelection::Selected { compiled_ids, .. }
947                if compiled_ids == other_ids.as_slice()
948        ));
949    }
950
951    #[test]
952    fn artifact_icu_options_compare_formatter_support_callbacks_by_address() {
953        fn support_all(_: &IcuFormatter) -> IcuFormatterSupport {
954            IcuFormatterSupport::Supported
955        }
956
957        fn support_none(_: &IcuFormatter) -> IcuFormatterSupport {
958            IcuFormatterSupport::UnsupportedKind {
959                severity: IcuDiagnosticSeverity::Warning,
960            }
961        }
962
963        let left = CompileCatalogArtifactIcuOptions::new().with_formatter_support(support_all);
964        let same = CompileCatalogArtifactIcuOptions::new().with_formatter_support(support_all);
965        let different =
966            CompileCatalogArtifactIcuOptions::new().with_formatter_support(support_none);
967        let formatter = IcuFormatter {
968            name: "count".to_owned(),
969            kind: IcuArgumentKind::Number,
970            style: None,
971            style_kind: IcuStyleKind::None,
972        };
973
974        assert_eq!(left, same);
975        assert_ne!(left, different);
976        assert_ne!(left, CompileCatalogArtifactIcuOptions::new());
977        assert_eq!(support_all(&formatter), IcuFormatterSupport::Supported);
978        assert_eq!(
979            support_none(&formatter),
980            IcuFormatterSupport::UnsupportedKind {
981                severity: IcuDiagnosticSeverity::Warning
982            }
983        );
984    }
985
986    #[cfg(feature = "serde")]
987    #[test]
988    fn compiled_catalog_artifact_serde_uses_versioned_wire_contract() {
989        let artifact = CompiledCatalogArtifact {
990            messages: BTreeMap::from([("runtime-key".to_owned(), "Hallo".to_owned())]),
991            missing: vec![CompiledCatalogMissingMessage {
992                key: "runtime-key".to_owned(),
993                source_key: CatalogMessageKey::new("Hello", None),
994                requested_locale: "de".to_owned(),
995                resolved_locale: Some("en".to_owned()),
996            }],
997            diagnostics: vec![CompiledCatalogDiagnostic {
998                severity: DiagnosticSeverity::Warning,
999                code: "icu.syntax".into(),
1000                message: "invalid ICU message".to_owned(),
1001                key: "runtime-key".to_owned(),
1002                msgid: "Hello".to_owned(),
1003                msgctxt: None,
1004                locale: "de".to_owned(),
1005            }],
1006        };
1007
1008        let json = serde_json::to_value(&artifact).expect("artifact serialization must succeed");
1009        assert_eq!(
1010            json["schema_version"],
1011            COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION
1012        );
1013        assert_eq!(json["diagnostics"][0]["severity"], "warning");
1014
1015        let roundtrip: CompiledCatalogArtifact =
1016            serde_json::from_value(json).expect("artifact deserialization must succeed");
1017        assert_eq!(roundtrip, artifact);
1018    }
1019
1020    #[cfg(feature = "serde")]
1021    #[test]
1022    fn compiled_catalog_artifact_serde_rejects_unknown_schema_version() {
1023        let json = serde_json::json!({
1024            "schema_version": COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION + 1,
1025            "messages": {},
1026            "missing": [],
1027            "diagnostics": [],
1028        });
1029
1030        let error = serde_json::from_value::<CompiledCatalogArtifact>(json)
1031            .expect_err("unknown artifact schema versions must be rejected");
1032        assert!(
1033            error
1034                .to_string()
1035                .contains("unsupported compiled catalog artifact schema_version")
1036        );
1037    }
1038}