Skip to main content

ferrocat_po/api/
compile.rs

1//! Runtime-oriented compilation helpers for normalized catalogs.
2//!
3//! This module sits on the far side of parsing/update work: it turns normalized
4//! catalog messages into stable compiled IDs and final runtime message payloads,
5//! including fallback resolution and optional ICU validation.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use ferrocat_icu::parse_icu;
10use sha2::{Digest, Sha256};
11
12use super::plural::synthesize_icu_plural;
13use super::{
14    ApiError, CatalogMessage, CatalogMessageKey, CatalogSemantics, CompileCatalogArtifactOptions,
15    CompileCatalogOptions, CompileSelectedCatalogArtifactOptions, CompiledCatalog,
16    CompiledCatalogArtifact, CompiledCatalogDiagnostic, CompiledCatalogIdIndex,
17    CompiledCatalogMissingMessage, CompiledCatalogTranslationKind, CompiledKeyStrategy,
18    CompiledMessage, CompiledTranslation, DiagnosticSeverity, EffectiveTranslation,
19    NormalizedParsedCatalog, TranslationShape,
20};
21
22impl NormalizedParsedCatalog {
23    /// Compiles the normalized catalog into a runtime-oriented lookup map.
24    ///
25    /// Compiled keys are derived from the canonical gettext identity
26    /// (`msgctxt` + `msgid`) using the selected built-in key strategy.
27    /// The default configuration keeps translations as-is without filling
28    /// missing values from the source text.
29    ///
30    /// # Errors
31    ///
32    /// Returns [`ApiError::InvalidArguments`] when source fallback is enabled
33    /// without a `source_locale`, or [`ApiError::Conflict`] when two source
34    /// messages compile to the same derived key.
35    ///
36    /// ```rust
37    /// use ferrocat_po::{CompileCatalogOptions, ParseCatalogOptions, parse_catalog};
38    ///
39    /// let parsed = parse_catalog(ParseCatalogOptions {
40    ///     content: "msgid \"Hello\"\nmsgstr \"Hallo\"\n",
41    ///     source_locale: "en",
42    ///     locale: Some("de"),
43    ///     ..ParseCatalogOptions::default()
44    /// })?;
45    /// let normalized = parsed.into_normalized_view()?;
46    /// let compiled = normalized.compile(&CompileCatalogOptions::default())?;
47    ///
48    /// assert_eq!(compiled.len(), 1);
49    /// let (_, message) = compiled.iter().next().expect("compiled message");
50    /// assert_eq!(message.source_key.msgid, "Hello");
51    /// # Ok::<(), Box<dyn std::error::Error>>(())
52    /// ```
53    pub fn compile(
54        &self,
55        options: &CompileCatalogOptions<'_>,
56    ) -> Result<CompiledCatalog, ApiError> {
57        self.compile_with_key_generator(options, compiled_key_for)
58    }
59
60    /// Shared compile core used by the public API and collision-focused tests.
61    pub(super) fn compile_with_key_generator<F>(
62        &self,
63        options: &CompileCatalogOptions<'_>,
64        mut key_generator: F,
65    ) -> Result<CompiledCatalog, ApiError>
66    where
67        F: FnMut(CompiledKeyStrategy, &CatalogMessageKey) -> String,
68    {
69        validate_compiled_catalog_semantics(self, options.semantics)?;
70        let source_locale = if options.source_fallback {
71            Some(options.source_locale.ok_or_else(|| {
72                ApiError::InvalidArguments(
73                    "compile_catalog source_fallback requires source_locale".to_owned(),
74                )
75            })?)
76        } else {
77            None
78        };
79        let mut entries = BTreeMap::new();
80
81        for (source_key, message) in self.iter() {
82            let effective = source_locale.map_or_else(
83                || message.effective_translation_owned(),
84                |source_locale| {
85                    self.effective_translation_with_source_fallback(source_key, source_locale)
86                        .expect("normalized catalog lookup")
87                },
88            );
89            let translation = compiled_translation_for_message(
90                message,
91                effective,
92                self.parsed_catalog().semantics,
93            )
94            .ok_or_else(|| {
95                ApiError::InvalidArguments(format!(
96                    "catalog semantics {:?} were inconsistent with message {:?} / {:?}",
97                    self.parsed_catalog().semantics,
98                    source_key.msgctxt,
99                    source_key.msgid
100                ))
101            })?;
102            let compiled_key = key_generator(options.key_strategy, source_key);
103            let compiled_message = CompiledMessage {
104                key: compiled_key.clone(),
105                source_key: source_key.clone(),
106                translation,
107            };
108
109            if let Some(existing) = entries.insert(compiled_key.clone(), compiled_message) {
110                return Err(ApiError::Conflict(format!(
111                    "compiled catalog key collision for {:?} / {:?} and {:?} / {:?} using key {}",
112                    existing.source_key.msgctxt,
113                    existing.source_key.msgid,
114                    source_key.msgctxt,
115                    source_key.msgid,
116                    compiled_key
117                )));
118            }
119        }
120
121        Ok(CompiledCatalog { entries })
122    }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126struct ResolvedArtifactMessage {
127    locale: String,
128    message: String,
129}
130
131/// Compiles one requested-locale runtime artifact from one or more normalized catalogs.
132///
133/// The artifact is host-neutral: it produces the final runtime message strings keyed by
134/// Ferrocat's derived lookup key, plus missing-message records and compile diagnostics.
135///
136/// # Errors
137///
138/// Returns [`ApiError::InvalidArguments`] when required locales are missing, duplicated,
139/// or inconsistent with the provided catalog set; [`ApiError::Conflict`] when two source
140/// identities compile to the same derived key; or [`ApiError::Unsupported`] when
141/// `strict_icu` is enabled and a final runtime message fails ICU validation.
142pub fn compile_catalog_artifact(
143    catalogs: &[&NormalizedParsedCatalog],
144    options: &CompileCatalogArtifactOptions<'_>,
145) -> Result<CompiledCatalogArtifact, ApiError> {
146    let locales = prepare_compiled_catalog_artifact_catalogs(
147        catalogs,
148        options.requested_locale,
149        options.source_locale,
150        options.fallback_chain,
151        options.semantics,
152    )?;
153    compile_catalog_artifact_from_source_keys(
154        &locales,
155        collect_compiled_catalog_artifact_source_keys(&locales),
156        options,
157    )
158}
159
160/// Compiles one requested-locale runtime artifact for a selected subset of compiled IDs.
161///
162/// # Errors
163///
164/// Returns [`ApiError::InvalidArguments`] when the selected IDs are unknown or the
165/// catalog inputs are inconsistent, [`ApiError::Conflict`] on compiled-key collisions,
166/// or [`ApiError::Unsupported`] when `strict_icu` is enabled and a final runtime
167/// message fails ICU validation.
168pub fn compile_catalog_artifact_selected(
169    catalogs: &[&NormalizedParsedCatalog],
170    index: &CompiledCatalogIdIndex,
171    options: &CompileSelectedCatalogArtifactOptions<'_>,
172) -> Result<CompiledCatalogArtifact, ApiError> {
173    let artifact_options = options.artifact_options();
174    let locales = prepare_compiled_catalog_artifact_catalogs(
175        catalogs,
176        artifact_options.requested_locale,
177        artifact_options.source_locale,
178        artifact_options.fallback_chain,
179        artifact_options.semantics,
180    )?;
181
182    let mut source_keys = BTreeSet::new();
183    for compiled_id in options.compiled_ids {
184        let source_key = index.get(compiled_id).ok_or_else(|| {
185            ApiError::InvalidArguments(format!(
186                "compile_catalog_artifact_selected received unknown compiled ID {:?}",
187                compiled_id
188            ))
189        })?;
190        if !compiled_catalog_artifact_catalogs_contain_key(&locales, source_key) {
191            return Err(ApiError::InvalidArguments(format!(
192                "compile_catalog_artifact_selected compiled ID {:?} was not present in the provided catalog set",
193                compiled_id
194            )));
195        }
196        source_keys.insert(source_key.clone());
197    }
198
199    compile_catalog_artifact_from_source_keys(&locales, source_keys, &artifact_options)
200}
201
202fn compiled_translation_for_message(
203    message: &CatalogMessage,
204    value: EffectiveTranslation,
205    semantics: CatalogSemantics,
206) -> Option<CompiledTranslation> {
207    match (&message.translation, value) {
208        (TranslationShape::Singular { .. }, EffectiveTranslation::Singular(value)) => {
209            Some(CompiledTranslation::Singular(value))
210        }
211        (TranslationShape::Plural { variable, .. }, EffectiveTranslation::Plural(values)) => {
212            match semantics {
213                CatalogSemantics::IcuNative => Some(CompiledTranslation::Singular(
214                    synthesize_icu_plural(variable, &values),
215                )),
216                CatalogSemantics::GettextCompat => Some(CompiledTranslation::Plural(values)),
217            }
218        }
219        _ => None,
220    }
221}
222
223/// Derives the default stable runtime lookup key for `msgid` and `msgctxt`.
224///
225/// This public helper uses the same `FerrocatV1` key contract as
226/// [`NormalizedParsedCatalog::compile`] and [`compile_catalog_artifact`].
227///
228/// ```rust
229/// use ferrocat_po::compiled_key;
230///
231/// let without_context = compiled_key("Save", None);
232/// let with_context = compiled_key("Save", Some("menu"));
233///
234/// assert_eq!(without_context.len(), 11);
235/// assert_ne!(without_context, with_context);
236/// ```
237#[must_use]
238pub fn compiled_key(msgid: &str, msgctxt: Option<&str>) -> String {
239    compiled_key_for(
240        CompiledKeyStrategy::FerrocatV1,
241        &CatalogMessageKey::new(msgid, msgctxt.map(str::to_owned)),
242    )
243}
244
245pub(super) fn compiled_key_for(strategy: CompiledKeyStrategy, key: &CatalogMessageKey) -> String {
246    match strategy {
247        CompiledKeyStrategy::FerrocatV1 => ferrocat_v1_compiled_key(key),
248    }
249}
250
251fn ferrocat_v1_compiled_key(key: &CatalogMessageKey) -> String {
252    let mut payload = Vec::with_capacity(
253        16 + 1 + 4 + key.msgctxt.as_ref().map_or(0, String::len) + 1 + 4 + key.msgid.len(),
254    );
255    payload.extend_from_slice(b"ferrocat:compile:v1");
256    push_compiled_key_component(&mut payload, key.msgctxt.as_deref());
257    push_compiled_key_component(&mut payload, Some(key.msgid.as_str()));
258    let digest = Sha256::digest(&payload);
259    base64_url_no_pad(&digest[..8])
260}
261
262fn push_compiled_key_component(out: &mut Vec<u8>, value: Option<&str>) {
263    if let Some(value) = value {
264        out.push(1);
265        let value_len = u32::try_from(value.len()).expect("compiled key component exceeds u32");
266        out.extend_from_slice(&value_len.to_be_bytes());
267        out.extend_from_slice(value.as_bytes());
268    } else {
269        out.push(0);
270        out.extend_from_slice(&0u32.to_be_bytes());
271    }
272}
273
274fn base64_url_no_pad(bytes: &[u8]) -> String {
275    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
276    let mut out = String::with_capacity((bytes.len() * 4).div_ceil(3));
277    let mut index = 0;
278
279    while index + 3 <= bytes.len() {
280        let chunk = (u32::from(bytes[index]) << 16)
281            | (u32::from(bytes[index + 1]) << 8)
282            | u32::from(bytes[index + 2]);
283        out.push(ALPHABET[((chunk >> 18) & 0x3f) as usize] as char);
284        out.push(ALPHABET[((chunk >> 12) & 0x3f) as usize] as char);
285        out.push(ALPHABET[((chunk >> 6) & 0x3f) as usize] as char);
286        out.push(ALPHABET[(chunk & 0x3f) as usize] as char);
287        index += 3;
288    }
289
290    match bytes.len() - index {
291        1 => {
292            let chunk = u32::from(bytes[index]) << 16;
293            out.push(ALPHABET[((chunk >> 18) & 0x3f) as usize] as char);
294            out.push(ALPHABET[((chunk >> 12) & 0x3f) as usize] as char);
295        }
296        2 => {
297            let chunk = (u32::from(bytes[index]) << 16) | (u32::from(bytes[index + 1]) << 8);
298            out.push(ALPHABET[((chunk >> 18) & 0x3f) as usize] as char);
299            out.push(ALPHABET[((chunk >> 12) & 0x3f) as usize] as char);
300            out.push(ALPHABET[((chunk >> 6) & 0x3f) as usize] as char);
301        }
302        _ => {}
303    }
304
305    out
306}
307
308pub(super) fn describe_compiled_id_catalogs<'a>(
309    catalogs: &[&'a NormalizedParsedCatalog],
310) -> Result<BTreeMap<String, &'a NormalizedParsedCatalog>, ApiError> {
311    let mut locales = BTreeMap::<String, &NormalizedParsedCatalog>::new();
312
313    for catalog in catalogs {
314        let locale = catalog
315            .parsed_catalog()
316            .locale
317            .as_deref()
318            .ok_or_else(|| {
319                ApiError::InvalidArguments(
320                    "describe_compiled_ids requires every catalog to declare a locale".to_owned(),
321                )
322            })?
323            .trim()
324            .to_owned();
325        if locale.is_empty() {
326            return Err(ApiError::InvalidArguments(
327                "describe_compiled_ids does not accept empty catalog locales".to_owned(),
328            ));
329        }
330        if locales.insert(locale.clone(), *catalog).is_some() {
331            return Err(ApiError::InvalidArguments(format!(
332                "describe_compiled_ids received duplicate catalog locale {locale:?}"
333            )));
334        }
335    }
336
337    Ok(locales)
338}
339
340pub(super) fn compiled_catalog_translation_kind_for_message(
341    semantics: CatalogSemantics,
342    message: &CatalogMessage,
343) -> CompiledCatalogTranslationKind {
344    match (semantics, &message.translation) {
345        (_, TranslationShape::Singular { .. }) => CompiledCatalogTranslationKind::Singular,
346        (CatalogSemantics::IcuNative, TranslationShape::Plural { .. }) => {
347            CompiledCatalogTranslationKind::Singular
348        }
349        (CatalogSemantics::GettextCompat, TranslationShape::Plural { .. }) => {
350            CompiledCatalogTranslationKind::Plural
351        }
352    }
353}
354
355/// Validates and indexes the locale set used by artifact compilation.
356///
357/// This up-front normalization keeps the later artifact loop allocation-light
358/// and lets it assume that requested/source/fallback locales are all present and unique.
359fn prepare_compiled_catalog_artifact_catalogs<'a>(
360    catalogs: &[&'a NormalizedParsedCatalog],
361    requested_locale: &str,
362    source_locale: &str,
363    fallback_chain: &[String],
364    semantics: CatalogSemantics,
365) -> Result<BTreeMap<String, &'a NormalizedParsedCatalog>, ApiError> {
366    super::validate_source_locale(source_locale)?;
367    if requested_locale.trim().is_empty() {
368        return Err(ApiError::InvalidArguments(
369            "requested_locale must not be empty".to_owned(),
370        ));
371    }
372    if catalogs.is_empty() {
373        return Err(ApiError::InvalidArguments(
374            "compile_catalog_artifact requires at least one catalog".to_owned(),
375        ));
376    }
377
378    let mut locales = BTreeMap::<String, &NormalizedParsedCatalog>::new();
379    for catalog in catalogs {
380        validate_compiled_catalog_semantics(catalog, semantics)?;
381        let locale = catalog
382            .parsed_catalog()
383            .locale
384            .as_deref()
385            .ok_or_else(|| {
386                ApiError::InvalidArguments(
387                    "compile_catalog_artifact requires every catalog to declare a locale"
388                        .to_owned(),
389                )
390            })?
391            .trim()
392            .to_owned();
393        if locale.is_empty() {
394            return Err(ApiError::InvalidArguments(
395                "compile_catalog_artifact does not accept empty catalog locales".to_owned(),
396            ));
397        }
398        if locales.insert(locale.clone(), *catalog).is_some() {
399            return Err(ApiError::InvalidArguments(format!(
400                "compile_catalog_artifact received duplicate catalog locale {locale:?}"
401            )));
402        }
403    }
404
405    if !locales.contains_key(requested_locale) {
406        return Err(ApiError::InvalidArguments(format!(
407            "compile_catalog_artifact is missing the requested locale catalog {:?}",
408            requested_locale
409        )));
410    }
411    if !locales.contains_key(source_locale) {
412        return Err(ApiError::InvalidArguments(format!(
413            "compile_catalog_artifact is missing the source locale catalog {:?}",
414            source_locale
415        )));
416    }
417
418    let mut seen_fallbacks = BTreeSet::new();
419    for locale in fallback_chain {
420        if locale == requested_locale || locale == source_locale {
421            return Err(ApiError::InvalidArguments(format!(
422                "compile_catalog_artifact fallback_chain must not repeat requested or source locale {:?}",
423                locale
424            )));
425        }
426        if !seen_fallbacks.insert(locale.clone()) {
427            return Err(ApiError::InvalidArguments(format!(
428                "compile_catalog_artifact fallback_chain contains duplicate locale {:?}",
429                locale
430            )));
431        }
432        if !locales.contains_key(locale) {
433            return Err(ApiError::InvalidArguments(format!(
434                "compile_catalog_artifact fallback locale {:?} was not provided",
435                locale
436            )));
437        }
438    }
439
440    Ok(locales)
441}
442
443/// Collects every non-obsolete source key that might need to appear in an
444/// artifact compiled from the provided locale set.
445fn collect_compiled_catalog_artifact_source_keys(
446    locales: &BTreeMap<String, &NormalizedParsedCatalog>,
447) -> BTreeSet<CatalogMessageKey> {
448    let mut source_keys = BTreeSet::new();
449    for catalog in locales.values() {
450        for (source_key, message) in catalog.iter() {
451            if !message.obsolete {
452                source_keys.insert(source_key.clone());
453            }
454        }
455    }
456    source_keys
457}
458
459fn compiled_catalog_artifact_catalogs_contain_key(
460    locales: &BTreeMap<String, &NormalizedParsedCatalog>,
461    source_key: &CatalogMessageKey,
462) -> bool {
463    locales.values().any(|catalog| {
464        catalog
465            .get(source_key)
466            .is_some_and(|message| !message.obsolete)
467    })
468}
469
470/// Compiles the final runtime artifact for a known set of source identities.
471///
472/// This is where derived key collision checks, fallback bookkeeping, and final
473/// ICU validation come together before the artifact is returned.
474fn compile_catalog_artifact_from_source_keys<I>(
475    locales: &BTreeMap<String, &NormalizedParsedCatalog>,
476    source_keys: I,
477    options: &CompileCatalogArtifactOptions<'_>,
478) -> Result<CompiledCatalogArtifact, ApiError>
479where
480    I: IntoIterator<Item = CatalogMessageKey>,
481{
482    let mut compiled_keys = BTreeMap::<String, CatalogMessageKey>::new();
483    let mut artifact = CompiledCatalogArtifact::default();
484
485    for source_key in source_keys {
486        let compiled_key = compiled_key_for(options.key_strategy, &source_key);
487        if let Some(existing) = compiled_keys.insert(compiled_key.clone(), source_key.clone()) {
488            return Err(ApiError::Conflict(format!(
489                "compiled catalog key collision for {:?} / {:?} and {:?} / {:?} using key {}",
490                existing.msgctxt,
491                existing.msgid,
492                source_key.msgctxt,
493                source_key.msgid,
494                compiled_key
495            )));
496        }
497
498        let resolved = resolve_compiled_catalog_artifact_message(locales, &source_key, options);
499        if options.requested_locale != options.source_locale {
500            let resolved_locale = resolved.as_ref().map(|value| value.locale.clone());
501            if resolved_locale.as_deref() != Some(options.requested_locale) {
502                artifact.missing.push(CompiledCatalogMissingMessage {
503                    key: compiled_key.clone(),
504                    source_key: source_key.clone(),
505                    requested_locale: options.requested_locale.to_owned(),
506                    resolved_locale: resolved_locale.clone(),
507                });
508            }
509        }
510
511        let Some(resolved) = resolved else {
512            continue;
513        };
514
515        if let Err(error) = parse_icu(&resolved.message) {
516            if options.strict_icu {
517                return Err(ApiError::Unsupported(format!(
518                    "compiled catalog artifact produced invalid ICU for locale {:?}, msgid {:?}, context {:?}: {}",
519                    resolved.locale, source_key.msgid, source_key.msgctxt, error
520                )));
521            }
522            artifact.diagnostics.push(CompiledCatalogDiagnostic {
523                severity: DiagnosticSeverity::Error,
524                code: "compile.invalid_icu_message".to_owned(),
525                message: format!("Final runtime message failed ICU validation: {error}"),
526                key: compiled_key.clone(),
527                msgid: source_key.msgid.clone(),
528                msgctxt: source_key.msgctxt.clone(),
529                locale: resolved.locale.clone(),
530            });
531        }
532
533        artifact.messages.insert(compiled_key, resolved.message);
534    }
535
536    Ok(artifact)
537}
538
539/// Resolves one runtime message by trying requested locale, configured
540/// fallbacks, and finally the source locale when allowed.
541fn resolve_compiled_catalog_artifact_message(
542    catalogs: &BTreeMap<String, &NormalizedParsedCatalog>,
543    source_key: &CatalogMessageKey,
544    options: &CompileCatalogArtifactOptions<'_>,
545) -> Option<ResolvedArtifactMessage> {
546    for locale in std::iter::once(options.requested_locale)
547        .chain(options.fallback_chain.iter().map(String::as_str))
548    {
549        let Some(catalog) = catalogs.get(locale) else {
550            continue;
551        };
552        let Some(message) = catalog.get(source_key) else {
553            continue;
554        };
555        if message.obsolete || !message_has_runtime_translation(message) {
556            continue;
557        }
558        return rendered_compiled_catalog_artifact_message(
559            catalog,
560            source_key,
561            options.source_locale,
562            false,
563        )
564        .map(|message| ResolvedArtifactMessage {
565            locale: locale.to_owned(),
566            message,
567        });
568    }
569
570    let should_consult_source =
571        options.requested_locale == options.source_locale || options.source_fallback;
572    if !should_consult_source {
573        return None;
574    }
575
576    let catalog = catalogs.get(options.source_locale)?;
577    let message = catalog.get(source_key)?;
578    if message.obsolete {
579        return None;
580    }
581
582    rendered_compiled_catalog_artifact_message(catalog, source_key, options.source_locale, true)
583        .map(|message| ResolvedArtifactMessage {
584            locale: options.source_locale.to_owned(),
585            message,
586        })
587}
588
589/// Renders the final runtime string for one message after translation fallback
590/// decisions have already been made.
591///
592/// Plural messages are re-synthesized into ICU strings so runtime consumers see
593/// one uniform message format regardless of the catalog's stored plural encoding.
594fn rendered_compiled_catalog_artifact_message(
595    catalog: &NormalizedParsedCatalog,
596    source_key: &CatalogMessageKey,
597    source_locale: &str,
598    use_source_fallback: bool,
599) -> Option<String> {
600    let message = catalog.get(source_key)?;
601    let effective = if use_source_fallback {
602        catalog.effective_translation_with_source_fallback(source_key, source_locale)?
603    } else {
604        message.effective_translation_owned()
605    };
606
607    match (&message.translation, effective) {
608        (TranslationShape::Singular { .. }, EffectiveTranslation::Singular(value)) => Some(value),
609        (TranslationShape::Plural { variable, .. }, EffectiveTranslation::Plural(translation)) => {
610            Some(synthesize_icu_plural(variable, &translation))
611        }
612        (TranslationShape::Singular { .. }, EffectiveTranslation::Plural(_))
613        | (TranslationShape::Plural { .. }, EffectiveTranslation::Singular(_)) => None,
614    }
615}
616
617/// Treats an empty singular string or an all-empty plural map as "missing" for
618/// runtime artifact purposes.
619fn message_has_runtime_translation(message: &CatalogMessage) -> bool {
620    match &message.translation {
621        TranslationShape::Singular { value } => !value.is_empty(),
622        TranslationShape::Plural { translation, .. } => {
623            translation.values().any(|value| !value.is_empty())
624        }
625    }
626}
627
628fn validate_compiled_catalog_semantics(
629    catalog: &NormalizedParsedCatalog,
630    expected: CatalogSemantics,
631) -> Result<(), ApiError> {
632    let actual = catalog.parsed_catalog().semantics;
633    if actual != expected {
634        return Err(ApiError::InvalidArguments(format!(
635            "compile options requested {:?} semantics, but catalog locale {:?} uses {:?}",
636            expected,
637            catalog.parsed_catalog().locale,
638            actual
639        )));
640    }
641    Ok(())
642}
643
644#[cfg(test)]
645mod unit_tests {
646    use std::collections::BTreeMap;
647
648    use super::{
649        ApiError, CatalogMessage, CatalogMessageKey, CatalogSemantics,
650        CompiledCatalogTranslationKind, EffectiveTranslation, NormalizedParsedCatalog,
651        TranslationShape, collect_compiled_catalog_artifact_source_keys,
652        compiled_catalog_artifact_catalogs_contain_key,
653        compiled_catalog_translation_kind_for_message, compiled_translation_for_message,
654        describe_compiled_id_catalogs, message_has_runtime_translation,
655        prepare_compiled_catalog_artifact_catalogs, rendered_compiled_catalog_artifact_message,
656        validate_compiled_catalog_semantics,
657    };
658    use crate::ParsedCatalog;
659    use crate::api::PluralSource;
660
661    fn normalized_catalog(
662        locale: Option<&str>,
663        semantics: CatalogSemantics,
664        messages: Vec<CatalogMessage>,
665    ) -> NormalizedParsedCatalog {
666        NormalizedParsedCatalog::new(ParsedCatalog {
667            locale: locale.map(str::to_owned),
668            semantics,
669            headers: BTreeMap::new(),
670            messages,
671            diagnostics: Vec::new(),
672        })
673        .expect("normalized catalog")
674    }
675
676    fn singular_message(msgid: &str, value: &str) -> CatalogMessage {
677        CatalogMessage {
678            msgid: msgid.to_owned(),
679            msgctxt: None,
680            translation: TranslationShape::Singular {
681                value: value.to_owned(),
682            },
683            comments: Vec::new(),
684            origin: Vec::new(),
685            obsolete: false,
686            extra: None,
687        }
688    }
689
690    fn plural_message(msgid: &str) -> CatalogMessage {
691        CatalogMessage {
692            msgid: msgid.to_owned(),
693            msgctxt: None,
694            translation: TranslationShape::Plural {
695                source: PluralSource {
696                    one: Some("# file".to_owned()),
697                    other: "# files".to_owned(),
698                },
699                translation: BTreeMap::from([
700                    ("one".to_owned(), "# Datei".to_owned()),
701                    ("other".to_owned(), "# Dateien".to_owned()),
702                ]),
703                variable: "count".to_owned(),
704            },
705            comments: Vec::new(),
706            origin: Vec::new(),
707            obsolete: false,
708            extra: None,
709        }
710    }
711
712    #[test]
713    fn compile_translation_helpers_cover_native_compat_and_mismatch_paths() {
714        let plural_message = plural_message("files");
715        assert_eq!(
716            compiled_catalog_translation_kind_for_message(
717                CatalogSemantics::IcuNative,
718                &plural_message
719            ),
720            CompiledCatalogTranslationKind::Singular
721        );
722        assert_eq!(
723            compiled_catalog_translation_kind_for_message(
724                CatalogSemantics::GettextCompat,
725                &plural_message
726            ),
727            CompiledCatalogTranslationKind::Plural
728        );
729
730        assert!(matches!(
731            compiled_translation_for_message(
732                &plural_message,
733                EffectiveTranslation::Plural(BTreeMap::from([
734                    ("one".to_owned(), "# Datei".to_owned()),
735                    ("other".to_owned(), "# Dateien".to_owned()),
736                ])),
737                CatalogSemantics::IcuNative,
738            ),
739            Some(super::CompiledTranslation::Singular(value))
740                if value == "{count, plural, one {# Datei} other {# Dateien}}"
741        ));
742        assert!(
743            compiled_translation_for_message(
744                &plural_message,
745                EffectiveTranslation::Singular("wrong".to_owned()),
746                CatalogSemantics::IcuNative,
747            )
748            .is_none()
749        );
750    }
751
752    #[test]
753    fn compile_artifact_preparation_rejects_invalid_locale_sets() {
754        let de = normalized_catalog(
755            Some("de"),
756            CatalogSemantics::IcuNative,
757            vec![singular_message("Hello", "Hallo")],
758        );
759        let en = normalized_catalog(
760            Some("en"),
761            CatalogSemantics::IcuNative,
762            vec![singular_message("Hello", "Hello")],
763        );
764        let compat = normalized_catalog(
765            Some("fr"),
766            CatalogSemantics::GettextCompat,
767            vec![singular_message("Hello", "Bonjour")],
768        );
769
770        assert!(matches!(
771            prepare_compiled_catalog_artifact_catalogs(
772                &[],
773                "de",
774                "en",
775                &[],
776                CatalogSemantics::IcuNative,
777            ),
778            Err(ApiError::InvalidArguments(message))
779                if message.contains("at least one catalog")
780        ));
781        assert!(matches!(
782            prepare_compiled_catalog_artifact_catalogs(
783                &[&de],
784                " ",
785                "en",
786                &[],
787                CatalogSemantics::IcuNative,
788            ),
789            Err(ApiError::InvalidArguments(message))
790                if message.contains("requested_locale")
791        ));
792        assert!(matches!(
793            prepare_compiled_catalog_artifact_catalogs(
794                &[&de, &compat],
795                "de",
796                "en",
797                &[],
798                CatalogSemantics::IcuNative,
799            ),
800            Err(ApiError::InvalidArguments(message))
801                if message.contains("uses")
802        ));
803        assert!(matches!(
804            prepare_compiled_catalog_artifact_catalogs(
805                &[&de, &en],
806                "de",
807                "en",
808                &[String::from("de")],
809                CatalogSemantics::IcuNative,
810            ),
811            Err(ApiError::InvalidArguments(message))
812                if message.contains("must not repeat")
813        ));
814        assert!(matches!(
815            prepare_compiled_catalog_artifact_catalogs(
816                &[&de, &en],
817                "de",
818                "en",
819                &[String::from("fr")],
820                CatalogSemantics::IcuNative,
821            ),
822            Err(ApiError::InvalidArguments(message))
823                if message.contains("was not provided")
824        ));
825    }
826
827    #[test]
828    fn compile_artifact_helper_views_cover_lookup_and_runtime_rendering() {
829        let mut obsolete = singular_message("Old", "Alt");
830        obsolete.obsolete = true;
831        let de = normalized_catalog(
832            Some("de"),
833            CatalogSemantics::IcuNative,
834            vec![
835                singular_message("Hello", "Hallo"),
836                plural_message("files"),
837                obsolete,
838            ],
839        );
840        let en = normalized_catalog(
841            Some("en"),
842            CatalogSemantics::IcuNative,
843            vec![singular_message("Hello", "Hello"), plural_message("files")],
844        );
845        let locales = BTreeMap::from([("de".to_owned(), &de), ("en".to_owned(), &en)]);
846
847        let source_keys = collect_compiled_catalog_artifact_source_keys(&locales);
848        assert!(source_keys.contains(&CatalogMessageKey::new("Hello", None)));
849        assert!(!source_keys.contains(&CatalogMessageKey::new("Old", None)));
850        assert!(compiled_catalog_artifact_catalogs_contain_key(
851            &locales,
852            &CatalogMessageKey::new("files", None)
853        ));
854        assert!(!compiled_catalog_artifact_catalogs_contain_key(
855            &locales,
856            &CatalogMessageKey::new("missing", None)
857        ));
858
859        assert_eq!(
860            rendered_compiled_catalog_artifact_message(
861                &de,
862                &CatalogMessageKey::new("Hello", None),
863                "en",
864                false,
865            ),
866            Some("Hallo".to_owned())
867        );
868        assert_eq!(
869            rendered_compiled_catalog_artifact_message(
870                &de,
871                &CatalogMessageKey::new("files", None),
872                "en",
873                false,
874            ),
875            Some("{count, plural, one {# Datei} other {# Dateien}}".to_owned())
876        );
877        assert!(message_has_runtime_translation(&singular_message(
878            "Hello", "Hallo"
879        )));
880        assert!(!message_has_runtime_translation(&singular_message(
881            "Hello", ""
882        )));
883        assert!(message_has_runtime_translation(&plural_message("files")));
884        assert!(validate_compiled_catalog_semantics(&de, CatalogSemantics::IcuNative).is_ok());
885    }
886
887    #[test]
888    fn describe_compiled_id_catalogs_rejects_missing_empty_and_duplicate_locales() {
889        let missing_locale = normalized_catalog(
890            None,
891            CatalogSemantics::IcuNative,
892            vec![singular_message("Hello", "Hallo")],
893        );
894        let blank_locale = normalized_catalog(
895            Some(" "),
896            CatalogSemantics::IcuNative,
897            vec![singular_message("Hello", "Hallo")],
898        );
899        let de_one = normalized_catalog(
900            Some("de"),
901            CatalogSemantics::IcuNative,
902            vec![singular_message("Hello", "Hallo")],
903        );
904        let de_two = normalized_catalog(
905            Some("de"),
906            CatalogSemantics::IcuNative,
907            vec![singular_message("Bye", "Tschuess")],
908        );
909
910        assert!(describe_compiled_id_catalogs(&[&missing_locale]).is_err());
911        assert!(describe_compiled_id_catalogs(&[&blank_locale]).is_err());
912        assert!(describe_compiled_id_catalogs(&[&de_one, &de_two]).is_err());
913    }
914}