1use std::collections::{BTreeMap, btree_map};
2use std::fmt;
3use std::hash::{Hash, Hasher};
4use std::path::{Path, PathBuf};
5
6use rustc_hash::{FxHashMap, FxHasher};
7
8use crate::{ParseError, PoVec, diagnostic_codes::DiagnosticCode};
9
10use super::mt::MachineMetadata;
11use super::plural::PluralProfile;
12
13#[cfg(feature = "serde")]
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, PartialEq, Eq, Default)]
18#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
19#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
20pub struct CatalogOrigin {
21 pub file: String,
27 pub scope: Option<String>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Default)]
41pub struct ExtractedSingularMessage {
42 pub msgid: String,
44 pub msgctxt: Option<String>,
46 pub comments: Vec<String>,
48 pub origin: Vec<CatalogOrigin>,
50 pub placeholders: BTreeMap<String, Vec<String>>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Default)]
56#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
57#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
58pub struct PluralSource {
59 pub one: Option<String>,
61 pub other: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Default)]
67pub struct ExtractedPluralMessage {
68 pub msgid: String,
70 pub msgctxt: Option<String>,
72 pub source: PluralSource,
74 pub comments: Vec<String>,
76 pub origin: Vec<CatalogOrigin>,
78 pub placeholders: BTreeMap<String, Vec<String>>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ExtractedMessage {
85 Singular(ExtractedSingularMessage),
87 Plural(ExtractedPluralMessage),
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Default)]
93pub struct SourceExtractedMessage {
94 pub msgid: String,
96 pub msgctxt: Option<String>,
98 pub comments: Vec<String>,
100 pub origin: Vec<CatalogOrigin>,
102 pub placeholders: BTreeMap<String, Vec<String>>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum CatalogUpdateInput {
109 Structured(Vec<ExtractedMessage>),
111 SourceFirst(Vec<SourceExtractedMessage>),
113}
114
115impl Default for CatalogUpdateInput {
116 fn default() -> Self {
117 Self::Structured(Vec::new())
118 }
119}
120
121impl From<Vec<ExtractedMessage>> for CatalogUpdateInput {
122 fn from(value: Vec<ExtractedMessage>) -> Self {
123 Self::Structured(value)
124 }
125}
126
127impl From<Vec<SourceExtractedMessage>> for CatalogUpdateInput {
128 fn from(value: Vec<SourceExtractedMessage>) -> Self {
129 Self::SourceFirst(value)
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
136#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
137pub enum TranslationShape {
138 Singular {
140 value: String,
142 },
143 Plural {
145 source: PluralSource,
147 translation: BTreeMap<String, String>,
149 variable: String,
151 },
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum EffectiveTranslationRef<'a> {
157 Singular(&'a str),
159 Plural(&'a BTreeMap<String, String>),
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum EffectiveTranslation {
166 Singular(String),
168 Plural(BTreeMap<String, String>),
170}
171
172#[derive(Debug, Clone, PartialEq)]
176#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
177#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
178pub struct CatalogMessage {
179 pub msgid: String,
181 pub msgctxt: Option<String>,
183 pub translation: TranslationShape,
185 pub comments: Vec<String>,
187 pub origin: PoVec<CatalogOrigin>,
189 pub obsolete: Option<ObsoleteInfo>,
192 pub machine: Option<MachineMetadata>,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)]
201#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
202#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
203pub struct ObsoleteInfo {
204 pub since: Option<String>,
207}
208
209impl CatalogMessage {
210 #[must_use]
212 pub fn key(&self) -> CatalogMessageKey {
213 CatalogMessageKey {
214 msgid: self.msgid.clone(),
215 msgctxt: self.msgctxt.clone(),
216 }
217 }
218
219 #[must_use]
221 pub fn effective_translation(&self) -> EffectiveTranslationRef<'_> {
222 match &self.translation {
223 TranslationShape::Singular { value } => EffectiveTranslationRef::Singular(value),
224 TranslationShape::Plural { translation, .. } => {
225 EffectiveTranslationRef::Plural(translation)
226 }
227 }
228 }
229
230 pub(super) fn effective_translation_owned(&self) -> EffectiveTranslation {
231 match &self.translation {
232 TranslationShape::Singular { value } => EffectiveTranslation::Singular(value.clone()),
233 TranslationShape::Plural { translation, .. } => {
234 EffectiveTranslation::Plural(translation.clone())
235 }
236 }
237 }
238
239 pub(super) fn source_fallback_translation(&self, locale: Option<&str>) -> EffectiveTranslation {
245 match &self.translation {
246 TranslationShape::Singular { value } => {
247 if value.is_empty() {
248 EffectiveTranslation::Singular(self.msgid.clone())
249 } else {
250 EffectiveTranslation::Singular(value.clone())
251 }
252 }
253 TranslationShape::Plural {
254 source,
255 translation,
256 ..
257 } => {
258 let profile = PluralProfile::for_locale(locale);
259 let mut effective = profile.materialize_translation(translation);
260 for category in profile.categories() {
261 let should_fill = effective.get(category).is_none_or(String::is_empty);
262 if should_fill {
263 effective.insert(
264 category.clone(),
265 profile.source_locale_value(category, source),
266 );
267 }
268 }
269 EffectiveTranslation::Plural(effective)
270 }
271 }
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
277#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
278#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
279pub struct CatalogMessageKey {
280 pub msgid: String,
282 pub msgctxt: Option<String>,
284}
285
286impl CatalogMessageKey {
287 #[must_use]
289 pub fn new(msgid: impl Into<String>, msgctxt: Option<String>) -> Self {
290 Self {
291 msgid: msgid.into(),
292 msgctxt,
293 }
294 }
295
296 #[must_use]
302 pub fn with_context(msgid: impl Into<String>, msgctxt: impl Into<String>) -> Self {
303 Self {
304 msgid: msgid.into(),
305 msgctxt: Some(msgctxt.into()),
306 }
307 }
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
313#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
314pub enum DiagnosticSeverity {
315 Info,
317 Warning,
319 Error,
321}
322
323#[derive(Debug, Clone, PartialEq, Eq)]
325#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
326#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
327pub struct Diagnostic {
328 pub severity: DiagnosticSeverity,
330 pub code: DiagnosticCode,
332 pub message: String,
334 pub msgid: Option<String>,
336 pub msgctxt: Option<String>,
338}
339
340impl Diagnostic {
341 pub(super) fn new(
342 severity: DiagnosticSeverity,
343 code: impl Into<DiagnosticCode>,
344 message: impl Into<String>,
345 ) -> Self {
346 Self {
347 severity,
348 code: code.into(),
349 message: message.into(),
350 msgid: None,
351 msgctxt: None,
352 }
353 }
354
355 pub(super) fn with_identity(mut self, msgid: &str, msgctxt: Option<&str>) -> Self {
356 self.msgid = Some(msgid.to_owned());
357 self.msgctxt = msgctxt.map(str::to_owned);
358 self
359 }
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Default)]
364pub struct CatalogStats {
365 pub total: usize,
367 pub added: usize,
369 pub changed: usize,
371 pub unchanged: usize,
373 pub obsolete_marked: usize,
375 pub obsolete_removed: usize,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct CatalogUpdateResult {
382 pub content: String,
384 pub created: bool,
386 pub updated: bool,
388 pub stats: CatalogStats,
390 pub diagnostics: Vec<Diagnostic>,
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub struct CatalogCombineInput<'a> {
397 pub content: &'a str,
399 pub label: Option<&'a str>,
401}
402
403impl<'a> CatalogCombineInput<'a> {
404 #[must_use]
406 pub const fn new(content: &'a str) -> Self {
407 Self {
408 content,
409 label: None,
410 }
411 }
412
413 #[must_use]
415 pub const fn labeled(content: &'a str, label: &'a str) -> Self {
416 Self {
417 content,
418 label: Some(label),
419 }
420 }
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
425pub enum CatalogConflictStrategy {
426 #[default]
428 UseFirst,
429 UseLast,
431 Error,
433}
434
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
437pub enum CatalogCombineSelection {
438 #[default]
440 All,
441 MoreThan(usize),
443 LessThan(usize),
445 Unique,
447}
448
449impl CatalogCombineSelection {
450 pub(super) const fn includes(self, definitions: usize) -> bool {
451 match self {
452 Self::All => true,
453 Self::MoreThan(limit) => definitions > limit,
454 Self::LessThan(limit) => definitions < limit,
455 Self::Unique => definitions < 2,
456 }
457 }
458}
459
460#[derive(Debug, Clone, PartialEq, Eq, Default)]
462pub struct CatalogCombineStats {
463 pub inputs: usize,
465 pub definitions: usize,
467 pub selected: usize,
469 pub skipped: usize,
471 pub conflicts_resolved: usize,
473 pub total: usize,
475}
476
477#[derive(Debug, Clone, PartialEq, Eq)]
479pub struct CatalogCombineResult {
480 pub content: String,
482 pub stats: CatalogCombineStats,
484 pub diagnostics: Vec<Diagnostic>,
486}
487
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
493#[non_exhaustive]
494pub enum CatalogFileFormat {
495 #[default]
497 Po,
498 Fcl,
500}
501
502impl CatalogFileFormat {
503 pub fn infer_from_path(path: &Path) -> Result<Self, ApiError> {
512 let name = path
513 .file_name()
514 .and_then(|name| name.to_str())
515 .unwrap_or_default()
516 .to_ascii_lowercase();
517
518 if name.ends_with(".po") || name.ends_with(".pot") {
519 return Ok(Self::Po);
520 }
521 if name.ends_with(".fcl") {
522 return Ok(Self::Fcl);
523 }
524
525 Err(ApiError::Unsupported(format!(
526 "could not infer catalog file format from `{}`; expected .po, .pot, or .fcl",
527 path.display()
528 )))
529 }
530
531 pub(super) const fn default_mode(self) -> CatalogMode {
532 match self {
533 Self::Po => CatalogMode::IcuPo,
534 Self::Fcl => CatalogMode::IcuFcl,
535 }
536 }
537}
538
539#[derive(Debug, Clone, PartialEq, Eq)]
541#[non_exhaustive]
542pub struct CombineCatalogFilesOptions<'a> {
543 pub input_paths: &'a [PathBuf],
545 pub output_path: &'a Path,
547 pub format: Option<CatalogFileFormat>,
550 pub mode: Option<CatalogMode>,
553 pub locale: Option<&'a str>,
555 pub source_locale: &'a str,
557 pub conflict_strategy: CatalogConflictStrategy,
560 pub selection: CatalogCombineSelection,
562 pub order_by: OrderBy,
564 pub include_origins: bool,
566 pub include_obsolete: bool,
568}
569
570impl<'a> CombineCatalogFilesOptions<'a> {
571 #[must_use]
575 pub fn new(input_paths: &'a [PathBuf], output_path: &'a Path, source_locale: &'a str) -> Self {
576 Self {
577 input_paths,
578 output_path,
579 format: None,
580 mode: None,
581 locale: None,
582 source_locale,
583 conflict_strategy: CatalogConflictStrategy::UseFirst,
584 selection: CatalogCombineSelection::All,
585 order_by: OrderBy::Msgid,
586 include_origins: true,
587 include_obsolete: false,
588 }
589 }
590
591 #[must_use]
593 pub fn with_input_paths(mut self, input_paths: &'a [PathBuf]) -> Self {
594 self.input_paths = input_paths;
595 self
596 }
597
598 #[must_use]
600 pub fn with_output_path(mut self, output_path: &'a Path) -> Self {
601 self.output_path = output_path;
602 self
603 }
604
605 #[must_use]
607 pub fn with_format(mut self, format: CatalogFileFormat) -> Self {
608 self.format = Some(format);
609 self
610 }
611
612 #[must_use]
614 pub fn with_mode(mut self, mode: CatalogMode) -> Self {
615 self.mode = Some(mode);
616 self
617 }
618
619 #[must_use]
621 pub fn with_locale(mut self, locale: &'a str) -> Self {
622 self.locale = Some(locale);
623 self
624 }
625
626 #[must_use]
628 pub fn with_conflict_strategy(mut self, conflict_strategy: CatalogConflictStrategy) -> Self {
629 self.conflict_strategy = conflict_strategy;
630 self
631 }
632
633 #[must_use]
635 pub fn with_selection(mut self, selection: CatalogCombineSelection) -> Self {
636 self.selection = selection;
637 self
638 }
639
640 #[must_use]
642 pub fn with_order_by(mut self, order_by: OrderBy) -> Self {
643 self.order_by = order_by;
644 self
645 }
646
647 #[must_use]
649 pub fn with_include_origins(mut self, include_origins: bool) -> Self {
650 self.include_origins = include_origins;
651 self
652 }
653
654 #[must_use]
656 pub fn with_include_obsolete(mut self, include_obsolete: bool) -> Self {
657 self.include_obsolete = include_obsolete;
658 self
659 }
660}
661
662#[derive(Debug, Clone, PartialEq, Eq)]
664pub struct CatalogFileCombineResult {
665 pub output_path: PathBuf,
667 pub format: CatalogFileFormat,
669 pub stats: CatalogCombineStats,
671 pub diagnostics: Vec<Diagnostic>,
673}
674
675#[derive(Debug, Clone, PartialEq)]
677pub struct ParsedCatalog {
678 pub locale: Option<String>,
680 pub semantics: CatalogSemantics,
682 pub headers: BTreeMap<String, String>,
684 pub messages: Vec<CatalogMessage>,
686 pub diagnostics: Vec<Diagnostic>,
688}
689
690impl ParsedCatalog {
691 pub fn into_normalized_view(self) -> Result<NormalizedParsedCatalog, ApiError> {
698 NormalizedParsedCatalog::new(self)
699 }
700}
701
702#[derive(Debug, Clone, PartialEq)]
704pub struct NormalizedParsedCatalog {
705 pub(super) catalog: ParsedCatalog,
706 pub(super) key_index: BTreeMap<CatalogMessageKey, usize>,
707 msgid_hash_index: FxHashMap<u64, Vec<usize>>,
708}
709
710impl NormalizedParsedCatalog {
711 pub(super) fn new(catalog: ParsedCatalog) -> Result<Self, ApiError> {
713 let mut key_index = BTreeMap::new();
714 let mut msgid_hash_index = FxHashMap::<u64, Vec<usize>>::with_capacity_and_hasher(
715 catalog.messages.len(),
716 Default::default(),
717 );
718 for (index, message) in catalog.messages.iter().enumerate() {
719 let key = message.key();
720 match key_index.entry(key) {
721 btree_map::Entry::Vacant(entry) => {
722 entry.insert(index);
723 }
724 btree_map::Entry::Occupied(entry) => {
725 let key = entry.key();
726 return Err(ApiError::Conflict(format!(
727 "duplicate parsed catalog message for msgid {:?} and context {:?}",
728 key.msgid, key.msgctxt
729 )));
730 }
731 }
732 msgid_hash_index
733 .entry(message_id_hash(&message.msgid))
734 .or_default()
735 .push(index);
736 }
737 Ok(Self {
738 catalog,
739 key_index,
740 msgid_hash_index,
741 })
742 }
743
744 #[must_use]
746 pub const fn parsed_catalog(&self) -> &ParsedCatalog {
747 &self.catalog
748 }
749
750 #[must_use]
752 pub fn into_parsed_catalog(self) -> ParsedCatalog {
753 self.catalog
754 }
755
756 #[must_use]
758 pub fn get(&self, key: &CatalogMessageKey) -> Option<&CatalogMessage> {
759 self.key_index
760 .get(key)
761 .map(|index| &self.catalog.messages[*index])
762 }
763
764 #[must_use]
769 pub fn get_by_parts(&self, msgid: &str, msgctxt: Option<&str>) -> Option<&CatalogMessage> {
770 self.msgid_hash_index
771 .get(&message_id_hash(msgid))?
772 .iter()
773 .find_map(|index| {
774 let message = &self.catalog.messages[*index];
775 (message.msgid.as_str() == msgid && message.msgctxt.as_deref() == msgctxt)
776 .then_some(message)
777 })
778 }
779
780 #[must_use]
782 pub fn contains_key(&self, key: &CatalogMessageKey) -> bool {
783 self.key_index.contains_key(key)
784 }
785
786 #[must_use]
788 pub fn contains_parts(&self, msgid: &str, msgctxt: Option<&str>) -> bool {
789 self.get_by_parts(msgid, msgctxt).is_some()
790 }
791
792 #[must_use]
794 pub fn message_count(&self) -> usize {
795 self.catalog.messages.len()
796 }
797
798 pub fn iter(&self) -> impl Iterator<Item = (&CatalogMessageKey, &CatalogMessage)> + '_ {
800 self.key_index
801 .iter()
802 .map(|(key, index)| (key, &self.catalog.messages[*index]))
803 }
804
805 pub fn effective_translation(
807 &self,
808 key: &CatalogMessageKey,
809 ) -> Option<EffectiveTranslationRef<'_>> {
810 self.get(key).map(CatalogMessage::effective_translation)
811 }
812
813 pub fn effective_translation_by_parts(
815 &self,
816 msgid: &str,
817 msgctxt: Option<&str>,
818 ) -> Option<EffectiveTranslationRef<'_>> {
819 self.get_by_parts(msgid, msgctxt)
820 .map(CatalogMessage::effective_translation)
821 }
822
823 #[must_use]
826 pub fn effective_translation_with_source_fallback(
827 &self,
828 key: &CatalogMessageKey,
829 source_locale: &str,
830 ) -> Option<EffectiveTranslation> {
831 let message = self.get(key)?;
832 Some(self.effective_translation_for_message(message, source_locale))
833 }
834
835 #[must_use]
838 pub fn effective_translation_with_source_fallback_by_parts(
839 &self,
840 msgid: &str,
841 msgctxt: Option<&str>,
842 source_locale: &str,
843 ) -> Option<EffectiveTranslation> {
844 let message = self.get_by_parts(msgid, msgctxt)?;
845 Some(self.effective_translation_for_message(message, source_locale))
846 }
847
848 pub(super) fn effective_translation_for_message(
849 &self,
850 message: &CatalogMessage,
851 source_locale: &str,
852 ) -> EffectiveTranslation {
853 if self
854 .catalog
855 .locale
856 .as_deref()
857 .is_none_or(|locale| locale == source_locale)
858 {
859 message.source_fallback_translation(self.catalog.locale.as_deref())
860 } else {
861 message.effective_translation_owned()
862 }
863 }
864}
865
866fn message_id_hash(msgid: &str) -> u64 {
867 let mut hasher = FxHasher::default();
868 msgid.hash(&mut hasher);
869 hasher.finish()
870}
871
872#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
874pub enum PluralEncoding {
875 #[default]
877 Icu,
878 Gettext,
880}
881
882#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
887#[non_exhaustive]
888pub enum CatalogStorageFormat {
889 #[default]
891 Po,
892 Fcl,
895}
896
897#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
899pub enum CatalogSemantics {
900 #[default]
902 IcuNative,
903 GettextCompat,
905}
906
907#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
909#[non_exhaustive]
910pub enum IcuSyntaxPolicy {
911 #[default]
913 Strict,
914 RuntimeLiteralApostrophes,
921}
922
923#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
930#[non_exhaustive]
931pub enum CatalogMode {
932 #[default]
934 IcuPo,
935 GettextPo,
937 IcuFcl,
939}
940
941impl CatalogMode {
942 #[must_use]
944 pub const fn storage_format(self) -> CatalogStorageFormat {
945 match self {
946 Self::IcuPo | Self::GettextPo => CatalogStorageFormat::Po,
947 Self::IcuFcl => CatalogStorageFormat::Fcl,
948 }
949 }
950
951 #[must_use]
953 pub const fn semantics(self) -> CatalogSemantics {
954 match self {
955 Self::IcuPo | Self::IcuFcl => CatalogSemantics::IcuNative,
956 Self::GettextPo => CatalogSemantics::GettextCompat,
957 }
958 }
959
960 #[must_use]
962 pub const fn plural_encoding(self) -> PluralEncoding {
963 match self {
964 Self::IcuPo | Self::IcuFcl => PluralEncoding::Icu,
965 Self::GettextPo => PluralEncoding::Gettext,
966 }
967 }
968
969 #[must_use]
971 pub const fn from_parts(
972 storage_format: CatalogStorageFormat,
973 semantics: CatalogSemantics,
974 plural_encoding: PluralEncoding,
975 ) -> Option<Self> {
976 match (storage_format, semantics, plural_encoding) {
977 (CatalogStorageFormat::Po, CatalogSemantics::IcuNative, PluralEncoding::Icu) => {
978 Some(Self::IcuPo)
979 }
980 (CatalogStorageFormat::Fcl, CatalogSemantics::IcuNative, PluralEncoding::Icu) => {
981 Some(Self::IcuFcl)
982 }
983 (
984 CatalogStorageFormat::Po,
985 CatalogSemantics::GettextCompat,
986 PluralEncoding::Gettext,
987 ) => Some(Self::GettextPo),
988 _ => None,
989 }
990 }
991}
992
993#[derive(Debug, Clone, PartialEq, Eq, Default)]
995pub enum ObsoleteStrategy {
996 #[default]
998 Mark,
999 Delete,
1001 Keep,
1003 DropObsoleteBefore(String),
1010}
1011
1012#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1014pub enum OrderBy {
1015 #[default]
1017 Msgid,
1018 Origin,
1020}
1021
1022#[derive(Debug, Clone, PartialEq, Eq)]
1024pub enum PlaceholderCommentMode {
1025 Disabled,
1027 Enabled {
1029 limit: usize,
1031 },
1032}
1033
1034impl Default for PlaceholderCommentMode {
1035 fn default() -> Self {
1036 Self::Enabled { limit: 3 }
1037 }
1038}
1039
1040#[derive(Debug, Clone, PartialEq, Eq)]
1050#[non_exhaustive]
1051pub struct RenderOptions<'a> {
1052 pub order_by: OrderBy,
1054 pub include_origins: bool,
1056 pub print_placeholders_in_comments: PlaceholderCommentMode,
1058 pub custom_header_attributes: Option<&'a BTreeMap<String, String>>,
1060}
1061
1062impl Default for RenderOptions<'_> {
1063 fn default() -> Self {
1064 Self {
1065 order_by: OrderBy::Msgid,
1066 include_origins: true,
1067 print_placeholders_in_comments: PlaceholderCommentMode::Enabled { limit: 3 },
1068 custom_header_attributes: None,
1069 }
1070 }
1071}
1072
1073impl<'a> RenderOptions<'a> {
1074 #[must_use]
1076 pub fn with_order_by(mut self, order_by: OrderBy) -> Self {
1077 self.order_by = order_by;
1078 self
1079 }
1080
1081 #[must_use]
1083 pub fn with_include_origins(mut self, include_origins: bool) -> Self {
1084 self.include_origins = include_origins;
1085 self
1086 }
1087
1088 #[must_use]
1090 pub fn with_placeholder_comments(
1091 mut self,
1092 print_placeholders_in_comments: PlaceholderCommentMode,
1093 ) -> Self {
1094 self.print_placeholders_in_comments = print_placeholders_in_comments;
1095 self
1096 }
1097
1098 #[must_use]
1100 pub fn with_custom_header_attributes(
1101 mut self,
1102 custom_header_attributes: &'a BTreeMap<String, String>,
1103 ) -> Self {
1104 self.custom_header_attributes = Some(custom_header_attributes);
1105 self
1106 }
1107}
1108
1109#[derive(Debug, Clone, PartialEq, Eq)]
1111#[non_exhaustive]
1112pub struct UpdateCatalogOptions<'a> {
1113 pub locale: Option<&'a str>,
1115 pub source_locale: &'a str,
1117 pub input: CatalogUpdateInput,
1119 pub existing: Option<&'a str>,
1121 pub mode: CatalogMode,
1123 pub obsolete_strategy: ObsoleteStrategy,
1125 pub overwrite_source_translations: bool,
1127 pub now: Option<&'a str>,
1132 pub render: RenderOptions<'a>,
1134}
1135
1136impl<'a> UpdateCatalogOptions<'a> {
1137 #[must_use]
1143 pub fn new(source_locale: &'a str, input: impl Into<CatalogUpdateInput>) -> Self {
1144 Self {
1145 locale: None,
1146 source_locale,
1147 input: input.into(),
1148 existing: None,
1149 mode: CatalogMode::default(),
1150 obsolete_strategy: ObsoleteStrategy::Mark,
1151 overwrite_source_translations: false,
1152 now: None,
1153 render: RenderOptions::default(),
1154 }
1155 }
1156
1157 #[must_use]
1159 pub fn with_locale(mut self, locale: &'a str) -> Self {
1160 self.locale = Some(locale);
1161 self
1162 }
1163
1164 #[must_use]
1166 pub fn with_existing(mut self, existing: &'a str) -> Self {
1167 self.existing = Some(existing);
1168 self
1169 }
1170
1171 #[must_use]
1173 pub fn with_mode(mut self, mode: CatalogMode) -> Self {
1174 self.mode = mode;
1175 self
1176 }
1177
1178 #[must_use]
1180 pub fn with_render(mut self, render: RenderOptions<'a>) -> Self {
1181 self.render = render;
1182 self
1183 }
1184
1185 #[must_use]
1187 pub fn with_obsolete_strategy(mut self, obsolete_strategy: ObsoleteStrategy) -> Self {
1188 self.obsolete_strategy = obsolete_strategy;
1189 self
1190 }
1191
1192 #[must_use]
1194 pub fn with_overwrite_source_translations(
1195 mut self,
1196 overwrite_source_translations: bool,
1197 ) -> Self {
1198 self.overwrite_source_translations = overwrite_source_translations;
1199 self
1200 }
1201
1202 #[must_use]
1204 pub fn with_now(mut self, now: &'a str) -> Self {
1205 self.now = Some(now);
1206 self
1207 }
1208}
1209
1210#[derive(Debug, Clone, PartialEq, Eq)]
1212#[non_exhaustive]
1213pub struct UpdateCatalogFileOptions<'a> {
1214 pub target_path: &'a Path,
1216 pub options: UpdateCatalogOptions<'a>,
1218}
1219
1220impl<'a> UpdateCatalogFileOptions<'a> {
1221 #[must_use]
1226 pub fn new(
1227 target_path: &'a Path,
1228 source_locale: &'a str,
1229 input: impl Into<CatalogUpdateInput>,
1230 ) -> Self {
1231 Self {
1232 target_path,
1233 options: UpdateCatalogOptions::new(source_locale, input),
1234 }
1235 }
1236
1237 #[must_use]
1239 pub fn with_options(mut self, options: UpdateCatalogOptions<'a>) -> Self {
1240 self.options = options;
1241 self
1242 }
1243}
1244
1245#[derive(Debug, Clone, PartialEq, Eq)]
1247#[non_exhaustive]
1248pub struct CombineCatalogOptions<'a> {
1249 pub inputs: &'a [CatalogCombineInput<'a>],
1251 pub locale: Option<&'a str>,
1253 pub source_locale: &'a str,
1255 pub mode: CatalogMode,
1257 pub conflict_strategy: CatalogConflictStrategy,
1260 pub selection: CatalogCombineSelection,
1262 pub order_by: OrderBy,
1264 pub include_origins: bool,
1266 pub include_obsolete: bool,
1268}
1269
1270impl<'a> CombineCatalogOptions<'a> {
1271 #[must_use]
1277 pub fn new(inputs: &'a [CatalogCombineInput<'a>], source_locale: &'a str) -> Self {
1278 Self {
1279 inputs,
1280 locale: None,
1281 source_locale,
1282 mode: CatalogMode::default(),
1283 conflict_strategy: CatalogConflictStrategy::UseFirst,
1284 selection: CatalogCombineSelection::All,
1285 order_by: OrderBy::Msgid,
1286 include_origins: true,
1287 include_obsolete: false,
1288 }
1289 }
1290
1291 #[must_use]
1293 pub fn with_inputs(mut self, inputs: &'a [CatalogCombineInput<'a>]) -> Self {
1294 self.inputs = inputs;
1295 self
1296 }
1297
1298 #[must_use]
1300 pub fn with_locale(mut self, locale: &'a str) -> Self {
1301 self.locale = Some(locale);
1302 self
1303 }
1304
1305 #[must_use]
1307 pub fn with_mode(mut self, mode: CatalogMode) -> Self {
1308 self.mode = mode;
1309 self
1310 }
1311
1312 #[must_use]
1314 pub fn with_conflict_strategy(mut self, conflict_strategy: CatalogConflictStrategy) -> Self {
1315 self.conflict_strategy = conflict_strategy;
1316 self
1317 }
1318
1319 #[must_use]
1321 pub fn with_selection(mut self, selection: CatalogCombineSelection) -> Self {
1322 self.selection = selection;
1323 self
1324 }
1325
1326 #[must_use]
1328 pub fn with_order_by(mut self, order_by: OrderBy) -> Self {
1329 self.order_by = order_by;
1330 self
1331 }
1332
1333 #[must_use]
1335 pub fn with_include_origins(mut self, include_origins: bool) -> Self {
1336 self.include_origins = include_origins;
1337 self
1338 }
1339
1340 #[must_use]
1342 pub fn with_include_obsolete(mut self, include_obsolete: bool) -> Self {
1343 self.include_obsolete = include_obsolete;
1344 self
1345 }
1346}
1347
1348#[derive(Debug, Clone, PartialEq, Eq)]
1350#[non_exhaustive]
1351pub struct ParseCatalogOptions<'a> {
1352 pub content: &'a str,
1354 pub locale: Option<&'a str>,
1356 pub source_locale: &'a str,
1358 pub mode: CatalogMode,
1360 pub strict: bool,
1362}
1363
1364impl<'a> ParseCatalogOptions<'a> {
1365 #[must_use]
1370 pub fn new(content: &'a str, source_locale: &'a str) -> Self {
1371 Self {
1372 content,
1373 locale: None,
1374 source_locale,
1375 mode: CatalogMode::default(),
1376 strict: false,
1377 }
1378 }
1379
1380 #[must_use]
1382 pub fn with_locale(mut self, locale: &'a str) -> Self {
1383 self.locale = Some(locale);
1384 self
1385 }
1386
1387 #[must_use]
1389 pub fn with_mode(mut self, mode: CatalogMode) -> Self {
1390 self.mode = mode;
1391 self
1392 }
1393
1394 #[must_use]
1396 pub fn with_strict(mut self, strict: bool) -> Self {
1397 self.strict = strict;
1398 self
1399 }
1400}
1401
1402#[derive(Debug)]
1407#[non_exhaustive]
1408pub enum ApiError {
1409 Parse(ParseError),
1411 Io(std::io::Error),
1413 InvalidArguments(String),
1415 Conflict(String),
1417 Unsupported(String),
1419}
1420
1421impl fmt::Display for ApiError {
1422 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1423 match self {
1424 Self::Parse(error) => error.fmt(f),
1425 Self::Io(error) => error.fmt(f),
1426 Self::InvalidArguments(message)
1427 | Self::Conflict(message)
1428 | Self::Unsupported(message) => f.write_str(message),
1429 }
1430 }
1431}
1432
1433impl std::error::Error for ApiError {
1434 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1435 match self {
1436 Self::Parse(error) => Some(error),
1437 Self::Io(error) => Some(error),
1438 Self::InvalidArguments(_) | Self::Conflict(_) | Self::Unsupported(_) => None,
1439 }
1440 }
1441}
1442
1443impl ApiError {
1444 #[must_use]
1446 pub fn io_with_path(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
1447 let kind = source.kind();
1448 Self::Io(std::io::Error::new(
1449 kind,
1450 PathIoError {
1451 path: path.into(),
1452 source,
1453 },
1454 ))
1455 }
1456
1457 #[must_use]
1459 pub fn path(&self) -> Option<&Path> {
1460 match self {
1461 Self::Io(error) => error
1462 .get_ref()
1463 .and_then(|source| source.downcast_ref::<PathIoError>())
1464 .map(|error| error.path.as_path()),
1465 Self::Parse(_)
1466 | Self::InvalidArguments(_)
1467 | Self::Conflict(_)
1468 | Self::Unsupported(_) => None,
1469 }
1470 }
1471}
1472
1473#[derive(Debug)]
1474struct PathIoError {
1475 path: PathBuf,
1476 source: std::io::Error,
1477}
1478
1479impl fmt::Display for PathIoError {
1480 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1481 write!(
1482 f,
1483 "I/O error for `{}`: {}",
1484 self.path.display(),
1485 self.source
1486 )
1487 }
1488}
1489
1490impl std::error::Error for PathIoError {
1491 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1492 Some(&self.source)
1493 }
1494}
1495
1496impl From<ParseError> for ApiError {
1497 fn from(value: ParseError) -> Self {
1498 Self::Parse(value)
1499 }
1500}
1501
1502impl From<std::io::Error> for ApiError {
1503 fn from(value: std::io::Error) -> Self {
1504 Self::Io(value)
1505 }
1506}
1507
1508#[cfg(test)]
1509mod tests {
1510 use std::collections::BTreeMap;
1511 use std::error::Error;
1512 use std::io;
1513 use std::path::{Path, PathBuf};
1514
1515 use super::{
1516 ApiError, CatalogCombineInput, CatalogCombineSelection, CatalogConflictStrategy,
1517 CatalogFileFormat, CatalogMessage, CatalogMessageKey, CatalogMode, CatalogSemantics,
1518 CatalogStorageFormat, CatalogUpdateInput, CombineCatalogFilesOptions,
1519 CombineCatalogOptions, Diagnostic, DiagnosticSeverity, EffectiveTranslation,
1520 EffectiveTranslationRef, NormalizedParsedCatalog, ObsoleteStrategy, OrderBy,
1521 ParseCatalogOptions, ParsedCatalog, PlaceholderCommentMode, PluralEncoding, PluralSource,
1522 RenderOptions, TranslationShape, UpdateCatalogFileOptions, UpdateCatalogOptions,
1523 };
1524 use crate::ParseError;
1525
1526 #[test]
1527 fn catalog_update_input_defaults_and_conversions_use_expected_variants() {
1528 assert!(matches!(
1529 CatalogUpdateInput::default(),
1530 CatalogUpdateInput::Structured(messages) if messages.is_empty()
1531 ));
1532 assert!(matches!(
1533 CatalogUpdateInput::from(Vec::<super::ExtractedMessage>::new()),
1534 CatalogUpdateInput::Structured(messages) if messages.is_empty()
1535 ));
1536 assert!(matches!(
1537 CatalogUpdateInput::from(Vec::<super::SourceExtractedMessage>::new()),
1538 CatalogUpdateInput::SourceFirst(messages) if messages.is_empty()
1539 ));
1540 }
1541
1542 #[test]
1543 fn catalog_message_helpers_cover_key_and_fallback_behavior() {
1544 let singular = CatalogMessage {
1545 msgid: "Hello".to_owned(),
1546 msgctxt: Some("button".to_owned()),
1547 translation: TranslationShape::Singular {
1548 value: String::new(),
1549 },
1550 comments: vec!["Shown in toolbar".to_owned()],
1551 origin: crate::PoVec::new(),
1552 obsolete: None,
1553 machine: None,
1554 };
1555
1556 assert_eq!(
1557 singular.key(),
1558 CatalogMessageKey::with_context("Hello", "button")
1559 );
1560 assert!(matches!(
1561 singular.effective_translation(),
1562 EffectiveTranslationRef::Singular("")
1563 ));
1564 assert_eq!(
1565 singular.source_fallback_translation(Some("en")),
1566 EffectiveTranslation::Singular("Hello".to_owned())
1567 );
1568
1569 let plural = CatalogMessage {
1570 msgid: "{count, plural, one {# file} other {# files}}".to_owned(),
1571 msgctxt: None,
1572 translation: TranslationShape::Plural {
1573 source: PluralSource {
1574 one: Some("{count} file".to_owned()),
1575 other: "{count} files".to_owned(),
1576 },
1577 translation: BTreeMap::from([
1578 ("one".to_owned(), String::new()),
1579 ("other".to_owned(), "{count} Dateien".to_owned()),
1580 ]),
1581 variable: "count".to_owned(),
1582 },
1583 comments: Vec::new(),
1584 origin: crate::PoVec::new(),
1585 obsolete: None,
1586 machine: None,
1587 };
1588
1589 assert!(matches!(
1590 plural.effective_translation(),
1591 EffectiveTranslationRef::Plural(values)
1592 if values.get("other") == Some(&"{count} Dateien".to_owned())
1593 ));
1594 assert_eq!(
1595 plural.source_fallback_translation(Some("de")),
1596 EffectiveTranslation::Plural(BTreeMap::from([
1597 ("one".to_owned(), "{count} file".to_owned()),
1598 ("other".to_owned(), "{count} Dateien".to_owned()),
1599 ]))
1600 );
1601 }
1602
1603 #[test]
1604 fn normalized_catalog_helpers_expose_lookup_and_source_fallback_views() {
1605 let parsed = ParsedCatalog {
1606 locale: Some("en".to_owned()),
1607 semantics: CatalogSemantics::IcuNative,
1608 headers: BTreeMap::new(),
1609 messages: vec![
1610 CatalogMessage {
1611 msgid: "Hello".to_owned(),
1612 msgctxt: None,
1613 translation: TranslationShape::Singular {
1614 value: String::new(),
1615 },
1616 comments: Vec::new(),
1617 origin: crate::PoVec::new(),
1618 obsolete: None,
1619 machine: None,
1620 },
1621 CatalogMessage {
1622 msgid: "Hello".to_owned(),
1623 msgctxt: Some("button".to_owned()),
1624 translation: TranslationShape::Singular {
1625 value: "Howdy".to_owned(),
1626 },
1627 comments: Vec::new(),
1628 origin: crate::PoVec::new(),
1629 obsolete: None,
1630 machine: None,
1631 },
1632 ],
1633 diagnostics: Vec::new(),
1634 };
1635
1636 let normalized = NormalizedParsedCatalog::new(parsed.clone()).expect("normalized");
1637 let key = CatalogMessageKey::new("Hello", None);
1638
1639 assert_eq!(normalized.message_count(), 2);
1640 assert!(normalized.contains_key(&key));
1641 assert!(normalized.contains_parts("Hello", Some("button")));
1642 assert_eq!(
1643 normalized.parsed_catalog().semantics,
1644 CatalogSemantics::IcuNative
1645 );
1646 assert!(normalized.get(&key).is_some());
1647 assert_eq!(
1648 normalized
1649 .get_by_parts("Hello", Some("button"))
1650 .and_then(|message| match message.effective_translation() {
1651 EffectiveTranslationRef::Singular(value) => Some(value),
1652 EffectiveTranslationRef::Plural(_) => None,
1653 }),
1654 Some("Howdy")
1655 );
1656 assert!(matches!(
1657 normalized.effective_translation_by_parts("Hello", None),
1658 Some(EffectiveTranslationRef::Singular(""))
1659 ));
1660 assert_eq!(
1661 normalized.effective_translation_with_source_fallback(&key, "en"),
1662 Some(EffectiveTranslation::Singular("Hello".to_owned()))
1663 );
1664 assert_eq!(
1665 normalized.effective_translation_with_source_fallback_by_parts(
1666 "Hello",
1667 Some("button"),
1668 "en"
1669 ),
1670 Some(EffectiveTranslation::Singular("Howdy".to_owned()))
1671 );
1672 assert_eq!(normalized.into_parsed_catalog(), parsed);
1673 }
1674
1675 #[test]
1676 fn option_defaults_reflect_native_po_defaults() {
1677 let update = UpdateCatalogOptions::new("en", Vec::<super::ExtractedMessage>::new());
1678 assert_eq!(update.mode, CatalogMode::IcuPo);
1679 assert_eq!(update.obsolete_strategy, ObsoleteStrategy::Mark);
1680 assert_eq!(update.render.order_by, OrderBy::Msgid);
1681 assert!(update.render.include_origins);
1682 assert_eq!(
1683 update.render.print_placeholders_in_comments,
1684 PlaceholderCommentMode::Enabled { limit: 3 }
1685 );
1686 assert_eq!(update.source_locale, "en");
1687 assert!(matches!(
1688 update.input,
1689 CatalogUpdateInput::Structured(messages) if messages.is_empty()
1690 ));
1691
1692 let update_file = UpdateCatalogFileOptions::new(
1693 Path::new("locale/de.po"),
1694 "en",
1695 Vec::<super::SourceExtractedMessage>::new(),
1696 );
1697 assert_eq!(update_file.target_path, Path::new("locale/de.po"));
1698 assert_eq!(update_file.options.mode, CatalogMode::IcuPo);
1699 assert_eq!(update_file.options.source_locale, "en");
1700 assert!(matches!(
1701 update_file.options.input,
1702 CatalogUpdateInput::SourceFirst(messages) if messages.is_empty()
1703 ));
1704
1705 let parse = ParseCatalogOptions::new("msgid \"Hello\"\nmsgstr \"Hallo\"\n", "en");
1706 assert_eq!(parse.content, "msgid \"Hello\"\nmsgstr \"Hallo\"\n");
1707 assert_eq!(parse.source_locale, "en");
1708 assert_eq!(parse.mode, CatalogMode::IcuPo);
1709 assert!(!parse.strict);
1710
1711 let inputs = [CatalogCombineInput::labeled(
1712 "msgid \"Hello\"\nmsgstr \"Hallo\"\n",
1713 "de.po",
1714 )];
1715 let combine = CombineCatalogOptions::new(&inputs, "en");
1716 assert_eq!(combine.inputs, &inputs);
1717 assert_eq!(combine.source_locale, "en");
1718 assert_eq!(combine.mode, CatalogMode::IcuPo);
1719 assert_eq!(combine.conflict_strategy, CatalogConflictStrategy::UseFirst);
1720 assert_eq!(combine.selection, CatalogCombineSelection::All);
1721 assert!(combine.include_origins);
1722 assert!(!combine.include_obsolete);
1723
1724 let input_paths = [PathBuf::from("locale/de.po")];
1725 let combine_files =
1726 CombineCatalogFilesOptions::new(&input_paths, Path::new("locale/merged.po"), "en");
1727 assert_eq!(combine_files.input_paths, &input_paths);
1728 assert_eq!(combine_files.output_path, Path::new("locale/merged.po"));
1729 assert_eq!(combine_files.format, None);
1730 assert_eq!(combine_files.mode, None);
1731 assert_eq!(combine_files.source_locale, "en");
1732 assert_eq!(
1733 combine_files.conflict_strategy,
1734 CatalogConflictStrategy::UseFirst
1735 );
1736 assert_eq!(combine_files.selection, CatalogCombineSelection::All);
1737 assert!(combine_files.include_origins);
1738 assert!(!combine_files.include_obsolete);
1739 }
1740
1741 #[test]
1742 fn catalog_option_builders_set_fields() {
1743 let headers = BTreeMap::from([("X-Generator".to_owned(), "ferrocat".to_owned())]);
1744 let render = RenderOptions::default()
1745 .with_order_by(OrderBy::Origin)
1746 .with_include_origins(false)
1747 .with_placeholder_comments(PlaceholderCommentMode::Disabled)
1748 .with_custom_header_attributes(&headers);
1749
1750 assert_eq!(render.order_by, OrderBy::Origin);
1751 assert!(!render.include_origins);
1752 assert_eq!(
1753 render.print_placeholders_in_comments,
1754 PlaceholderCommentMode::Disabled
1755 );
1756 assert_eq!(render.custom_header_attributes, Some(&headers));
1757
1758 let update = UpdateCatalogOptions::new("en", CatalogUpdateInput::default())
1759 .with_locale("de")
1760 .with_existing("msgid \"Hello\"\nmsgstr \"Hallo\"\n")
1761 .with_mode(CatalogMode::GettextPo)
1762 .with_render(render.clone())
1763 .with_obsolete_strategy(ObsoleteStrategy::Delete)
1764 .with_overwrite_source_translations(true)
1765 .with_now("2026-07-02");
1766
1767 assert_eq!(update.locale, Some("de"));
1768 assert_eq!(update.existing, Some("msgid \"Hello\"\nmsgstr \"Hallo\"\n"));
1769 assert_eq!(update.mode, CatalogMode::GettextPo);
1770 assert_eq!(update.render, render);
1771 assert_eq!(update.obsolete_strategy, ObsoleteStrategy::Delete);
1772 assert!(update.overwrite_source_translations);
1773 assert_eq!(update.now, Some("2026-07-02"));
1774
1775 let parse = ParseCatalogOptions::new("content", "en")
1776 .with_locale("fr")
1777 .with_mode(CatalogMode::IcuFcl)
1778 .with_strict(true);
1779
1780 assert_eq!(parse.locale, Some("fr"));
1781 assert_eq!(parse.mode, CatalogMode::IcuFcl);
1782 assert!(parse.strict);
1783
1784 let input_paths = [PathBuf::from("base.po"), PathBuf::from("overlay.po")];
1785 let output_path = Path::new("messages.fcl");
1786 let combine_files = CombineCatalogFilesOptions::new(&[], Path::new("unused.po"), "en")
1787 .with_input_paths(&input_paths)
1788 .with_output_path(output_path)
1789 .with_format(CatalogFileFormat::Fcl)
1790 .with_mode(CatalogMode::IcuFcl)
1791 .with_locale("de")
1792 .with_conflict_strategy(CatalogConflictStrategy::UseLast)
1793 .with_selection(CatalogCombineSelection::Unique)
1794 .with_order_by(OrderBy::Origin)
1795 .with_include_origins(false)
1796 .with_include_obsolete(true);
1797
1798 assert_eq!(combine_files.input_paths, &input_paths);
1799 assert_eq!(combine_files.output_path, output_path);
1800 assert_eq!(combine_files.format, Some(CatalogFileFormat::Fcl));
1801 assert_eq!(combine_files.mode, Some(CatalogMode::IcuFcl));
1802 assert_eq!(combine_files.locale, Some("de"));
1803 assert_eq!(
1804 combine_files.conflict_strategy,
1805 CatalogConflictStrategy::UseLast
1806 );
1807 assert_eq!(combine_files.selection, CatalogCombineSelection::Unique);
1808 assert_eq!(combine_files.order_by, OrderBy::Origin);
1809 assert!(!combine_files.include_origins);
1810 assert!(combine_files.include_obsolete);
1811
1812 let inputs = [CatalogCombineInput::labeled(
1813 "msgid \"Hello\"\nmsgstr \"Hallo\"\n",
1814 "base",
1815 )];
1816 let combine = CombineCatalogOptions::new(&[], "en")
1817 .with_inputs(&inputs)
1818 .with_locale("de")
1819 .with_mode(CatalogMode::GettextPo)
1820 .with_conflict_strategy(CatalogConflictStrategy::UseLast)
1821 .with_selection(CatalogCombineSelection::Unique)
1822 .with_order_by(OrderBy::Origin)
1823 .with_include_origins(false)
1824 .with_include_obsolete(true);
1825
1826 assert_eq!(combine.inputs, &inputs);
1827 assert_eq!(combine.locale, Some("de"));
1828 assert_eq!(combine.mode, CatalogMode::GettextPo);
1829 assert_eq!(combine.conflict_strategy, CatalogConflictStrategy::UseLast);
1830 assert_eq!(combine.selection, CatalogCombineSelection::Unique);
1831 assert_eq!(combine.order_by, OrderBy::Origin);
1832 assert!(!combine.include_origins);
1833 assert!(combine.include_obsolete);
1834
1835 let file_update = UpdateCatalogFileOptions::new(
1836 Path::new("messages.po"),
1837 "en",
1838 CatalogUpdateInput::default(),
1839 )
1840 .with_options(update.clone());
1841
1842 assert_eq!(file_update.target_path, Path::new("messages.po"));
1843 assert_eq!(file_update.options, update);
1844 }
1845
1846 #[test]
1847 fn catalog_file_format_infers_supported_suffixes() {
1848 assert_eq!(
1849 CatalogFileFormat::infer_from_path(Path::new("locale/de.po")).expect("po"),
1850 CatalogFileFormat::Po
1851 );
1852 assert_eq!(
1853 CatalogFileFormat::infer_from_path(Path::new("locale/messages.POT")).expect("pot"),
1854 CatalogFileFormat::Po
1855 );
1856 assert_eq!(
1857 CatalogFileFormat::infer_from_path(Path::new("locale/de.fcl")).expect("fcl"),
1858 CatalogFileFormat::Fcl
1859 );
1860 assert!(matches!(
1861 CatalogFileFormat::infer_from_path(Path::new("locale/de.txt")),
1862 Err(ApiError::Unsupported(message)) if message.contains("could not infer")
1863 ));
1864 }
1865
1866 #[test]
1867 fn catalog_mode_maps_only_supported_catalog_combinations() {
1868 assert_eq!(CatalogMode::default(), CatalogMode::IcuPo);
1869 assert_eq!(
1870 CatalogMode::IcuPo.storage_format(),
1871 CatalogStorageFormat::Po
1872 );
1873 assert_eq!(CatalogMode::IcuPo.semantics(), CatalogSemantics::IcuNative);
1874 assert_eq!(CatalogMode::IcuPo.plural_encoding(), PluralEncoding::Icu);
1875
1876 assert_eq!(
1877 CatalogMode::from_parts(
1878 CatalogStorageFormat::Fcl,
1879 CatalogSemantics::IcuNative,
1880 PluralEncoding::Icu
1881 ),
1882 Some(CatalogMode::IcuFcl)
1883 );
1884 assert_eq!(
1885 CatalogMode::from_parts(
1886 CatalogStorageFormat::Po,
1887 CatalogSemantics::GettextCompat,
1888 PluralEncoding::Gettext
1889 ),
1890 Some(CatalogMode::GettextPo)
1891 );
1892 assert_eq!(
1893 CatalogMode::from_parts(
1894 CatalogStorageFormat::Fcl,
1895 CatalogSemantics::GettextCompat,
1896 PluralEncoding::Gettext
1897 ),
1898 None
1899 );
1900 assert_eq!(
1901 CatalogMode::from_parts(
1902 CatalogStorageFormat::Po,
1903 CatalogSemantics::IcuNative,
1904 PluralEncoding::Icu
1905 ),
1906 Some(CatalogMode::IcuPo)
1907 );
1908 let update = UpdateCatalogOptions::new("en", Vec::<super::SourceExtractedMessage>::new())
1909 .with_mode(CatalogMode::GettextPo);
1910 assert_eq!(update.mode, CatalogMode::GettextPo);
1911 }
1912
1913 #[test]
1914 fn diagnostics_and_api_errors_preserve_human_readable_messages() {
1915 let diagnostic = Diagnostic::new(DiagnosticSeverity::Warning, "code", "message")
1916 .with_identity("Hello", Some("button"));
1917 assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning);
1918 assert_eq!(diagnostic.code, "code");
1919 assert_eq!(diagnostic.message, "message");
1920 assert_eq!(diagnostic.msgid.as_deref(), Some("Hello"));
1921 assert_eq!(diagnostic.msgctxt.as_deref(), Some("button"));
1922
1923 let io_error = ApiError::from(io::Error::other("disk"));
1924 assert_eq!(io_error.to_string(), "disk");
1925 assert_eq!(
1926 io_error.source().map(ToString::to_string).as_deref(),
1927 Some("disk")
1928 );
1929 assert_eq!(io_error.path(), None);
1930
1931 let io_path_error = ApiError::io_with_path(
1932 Path::new("locale/de.po"),
1933 io::Error::other("permission denied"),
1934 );
1935 assert_eq!(io_path_error.path(), Some(Path::new("locale/de.po")));
1936 let source = io_path_error.source().expect("io source");
1937 assert!(source.to_string().contains("locale/de.po"));
1938 assert_eq!(
1939 source.source().map(ToString::to_string).as_deref(),
1940 Some("permission denied")
1941 );
1942 assert!(io_path_error.to_string().contains("locale/de.po"));
1943
1944 let parse_error = ApiError::from(ParseError::new("bad syntax"));
1945 assert_eq!(
1946 parse_error.source().map(ToString::to_string).as_deref(),
1947 Some("bad syntax")
1948 );
1949 assert_eq!(
1950 ApiError::InvalidArguments("bad input".to_owned()).to_string(),
1951 "bad input"
1952 );
1953 assert!(
1954 ApiError::InvalidArguments("bad input".to_owned())
1955 .source()
1956 .is_none()
1957 );
1958 assert_eq!(
1959 ApiError::Conflict("duplicate".to_owned()).to_string(),
1960 "duplicate"
1961 );
1962 assert_eq!(
1963 ApiError::Unsupported("unsupported".to_owned()).to_string(),
1964 "unsupported"
1965 );
1966 }
1967
1968 #[cfg(feature = "serde")]
1969 #[test]
1970 fn catalog_message_and_diagnostic_serde_use_stable_public_shapes() {
1971 let message = CatalogMessage {
1972 msgid: "Hello".to_owned(),
1973 msgctxt: Some("button".to_owned()),
1974 translation: TranslationShape::Singular {
1975 value: "Hallo".to_owned(),
1976 },
1977 comments: vec!["Shown in toolbar".to_owned()],
1978 origin: crate::PoVec::new(),
1979 obsolete: None,
1980 machine: None,
1981 };
1982 let message_json =
1983 serde_json::to_value(&message).expect("catalog message serialization must succeed");
1984 assert_eq!(message_json["translation"]["kind"], "singular");
1985
1986 let roundtrip_message: CatalogMessage = serde_json::from_value(message_json)
1987 .expect("catalog message deserialization must succeed");
1988 assert_eq!(roundtrip_message, message);
1989
1990 let diagnostic = Diagnostic::new(
1991 DiagnosticSeverity::Error,
1992 "catalog.missing",
1993 "missing translation",
1994 )
1995 .with_identity("Hello", Some("button"));
1996 let diagnostic_json =
1997 serde_json::to_value(&diagnostic).expect("diagnostic serialization must succeed");
1998 assert_eq!(diagnostic_json["severity"], "error");
1999
2000 let roundtrip_diagnostic: Diagnostic = serde_json::from_value(diagnostic_json)
2001 .expect("diagnostic deserialization must succeed");
2002 assert_eq!(roundtrip_diagnostic, diagnostic);
2003 }
2004}