1use 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 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 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
131pub 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
160pub 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#[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
355fn 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
443fn 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
470fn 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
539fn 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
589fn 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
617fn 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}