1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::Path;
4
5use crate::ParseError;
6
7use super::plural::PluralProfile;
8
9#[derive(Debug, Clone, PartialEq, Eq, Default)]
11pub struct CatalogOrigin {
12 pub file: String,
14 pub line: Option<u32>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Default)]
20pub struct ExtractedSingularMessage {
21 pub msgid: String,
23 pub msgctxt: Option<String>,
25 pub comments: Vec<String>,
27 pub origin: Vec<CatalogOrigin>,
29 pub placeholders: BTreeMap<String, Vec<String>>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Default)]
35pub struct PluralSource {
36 pub one: Option<String>,
38 pub other: String,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Default)]
44pub struct ExtractedPluralMessage {
45 pub msgid: String,
47 pub msgctxt: Option<String>,
49 pub source: PluralSource,
51 pub comments: Vec<String>,
53 pub origin: Vec<CatalogOrigin>,
55 pub placeholders: BTreeMap<String, Vec<String>>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum ExtractedMessage {
62 Singular(ExtractedSingularMessage),
64 Plural(ExtractedPluralMessage),
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Default)]
70pub struct SourceExtractedMessage {
71 pub msgid: String,
73 pub msgctxt: Option<String>,
75 pub comments: Vec<String>,
77 pub origin: Vec<CatalogOrigin>,
79 pub placeholders: BTreeMap<String, Vec<String>>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum CatalogUpdateInput {
86 Structured(Vec<ExtractedMessage>),
88 SourceFirst(Vec<SourceExtractedMessage>),
90}
91
92impl Default for CatalogUpdateInput {
93 fn default() -> Self {
94 Self::Structured(Vec::new())
95 }
96}
97
98impl From<Vec<ExtractedMessage>> for CatalogUpdateInput {
99 fn from(value: Vec<ExtractedMessage>) -> Self {
100 Self::Structured(value)
101 }
102}
103
104impl From<Vec<SourceExtractedMessage>> for CatalogUpdateInput {
105 fn from(value: Vec<SourceExtractedMessage>) -> Self {
106 Self::SourceFirst(value)
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum TranslationShape {
113 Singular {
115 value: String,
117 },
118 Plural {
120 source: PluralSource,
122 translation: BTreeMap<String, String>,
124 variable: String,
126 },
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum EffectiveTranslationRef<'a> {
132 Singular(&'a str),
134 Plural(&'a BTreeMap<String, String>),
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum EffectiveTranslation {
141 Singular(String),
143 Plural(BTreeMap<String, String>),
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Default)]
149pub struct CatalogMessageExtra {
150 pub translator_comments: Vec<String>,
152 pub flags: Vec<String>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct CatalogMessage {
159 pub msgid: String,
161 pub msgctxt: Option<String>,
163 pub translation: TranslationShape,
165 pub comments: Vec<String>,
167 pub origin: Vec<CatalogOrigin>,
169 pub obsolete: bool,
171 pub extra: Option<CatalogMessageExtra>,
173}
174
175impl CatalogMessage {
176 #[must_use]
178 pub fn key(&self) -> CatalogMessageKey {
179 CatalogMessageKey {
180 msgid: self.msgid.clone(),
181 msgctxt: self.msgctxt.clone(),
182 }
183 }
184
185 #[must_use]
187 pub fn effective_translation(&self) -> EffectiveTranslationRef<'_> {
188 match &self.translation {
189 TranslationShape::Singular { value } => EffectiveTranslationRef::Singular(value),
190 TranslationShape::Plural { translation, .. } => {
191 EffectiveTranslationRef::Plural(translation)
192 }
193 }
194 }
195
196 pub(super) fn effective_translation_owned(&self) -> EffectiveTranslation {
197 match &self.translation {
198 TranslationShape::Singular { value } => EffectiveTranslation::Singular(value.clone()),
199 TranslationShape::Plural { translation, .. } => {
200 EffectiveTranslation::Plural(translation.clone())
201 }
202 }
203 }
204
205 pub(super) fn source_fallback_translation(&self, locale: Option<&str>) -> EffectiveTranslation {
211 match &self.translation {
212 TranslationShape::Singular { value } => {
213 if value.is_empty() {
214 EffectiveTranslation::Singular(self.msgid.clone())
215 } else {
216 EffectiveTranslation::Singular(value.clone())
217 }
218 }
219 TranslationShape::Plural {
220 source,
221 translation,
222 ..
223 } => {
224 let profile = PluralProfile::for_locale(locale);
225 let mut effective = profile.materialize_translation(translation);
226 let fallback = profile.source_locale_translation(source);
227 for (category, source_value) in fallback {
228 let should_fill = effective.get(&category).is_none_or(String::is_empty);
229 if should_fill {
230 effective.insert(category, source_value);
231 }
232 }
233 EffectiveTranslation::Plural(effective)
234 }
235 }
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
241pub struct CatalogMessageKey {
242 pub msgid: String,
244 pub msgctxt: Option<String>,
246}
247
248impl CatalogMessageKey {
249 #[must_use]
251 pub fn new(msgid: impl Into<String>, msgctxt: Option<String>) -> Self {
252 Self {
253 msgid: msgid.into(),
254 msgctxt,
255 }
256 }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum DiagnosticSeverity {
262 Info,
264 Warning,
266 Error,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct Diagnostic {
273 pub severity: DiagnosticSeverity,
275 pub code: String,
277 pub message: String,
279 pub msgid: Option<String>,
281 pub msgctxt: Option<String>,
283}
284
285impl Diagnostic {
286 pub(super) fn new(
287 severity: DiagnosticSeverity,
288 code: impl Into<String>,
289 message: impl Into<String>,
290 ) -> Self {
291 Self {
292 severity,
293 code: code.into(),
294 message: message.into(),
295 msgid: None,
296 msgctxt: None,
297 }
298 }
299
300 pub(super) fn with_identity(mut self, msgid: &str, msgctxt: Option<&str>) -> Self {
301 self.msgid = Some(msgid.to_owned());
302 self.msgctxt = msgctxt.map(str::to_owned);
303 self
304 }
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Default)]
309pub struct CatalogStats {
310 pub total: usize,
312 pub added: usize,
314 pub changed: usize,
316 pub unchanged: usize,
318 pub obsolete_marked: usize,
320 pub obsolete_removed: usize,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct CatalogUpdateResult {
327 pub content: String,
329 pub created: bool,
331 pub updated: bool,
333 pub stats: CatalogStats,
335 pub diagnostics: Vec<Diagnostic>,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq)]
341pub struct ParsedCatalog {
342 pub locale: Option<String>,
344 pub semantics: CatalogSemantics,
346 pub headers: BTreeMap<String, String>,
348 pub messages: Vec<CatalogMessage>,
350 pub diagnostics: Vec<Diagnostic>,
352}
353
354impl ParsedCatalog {
355 pub fn into_normalized_view(self) -> Result<NormalizedParsedCatalog, ApiError> {
362 NormalizedParsedCatalog::new(self)
363 }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq)]
368pub struct NormalizedParsedCatalog {
369 pub(super) catalog: ParsedCatalog,
370 pub(super) key_index: BTreeMap<CatalogMessageKey, usize>,
371}
372
373impl NormalizedParsedCatalog {
374 pub(super) fn new(catalog: ParsedCatalog) -> Result<Self, ApiError> {
376 let mut key_index = BTreeMap::new();
377 for (index, message) in catalog.messages.iter().enumerate() {
378 let key = message.key();
379 if key_index.insert(key.clone(), index).is_some() {
380 return Err(ApiError::Conflict(format!(
381 "duplicate parsed catalog message for msgid {:?} and context {:?}",
382 key.msgid, key.msgctxt
383 )));
384 }
385 }
386 Ok(Self { catalog, key_index })
387 }
388
389 #[must_use]
391 pub const fn parsed_catalog(&self) -> &ParsedCatalog {
392 &self.catalog
393 }
394
395 #[must_use]
397 pub fn into_parsed_catalog(self) -> ParsedCatalog {
398 self.catalog
399 }
400
401 #[must_use]
403 pub fn get(&self, key: &CatalogMessageKey) -> Option<&CatalogMessage> {
404 self.key_index
405 .get(key)
406 .map(|index| &self.catalog.messages[*index])
407 }
408
409 #[must_use]
411 pub fn contains_key(&self, key: &CatalogMessageKey) -> bool {
412 self.key_index.contains_key(key)
413 }
414
415 #[must_use]
417 pub fn message_count(&self) -> usize {
418 self.catalog.messages.len()
419 }
420
421 pub fn iter(&self) -> impl Iterator<Item = (&CatalogMessageKey, &CatalogMessage)> + '_ {
423 self.key_index
424 .iter()
425 .map(|(key, index)| (key, &self.catalog.messages[*index]))
426 }
427
428 pub fn effective_translation(
430 &self,
431 key: &CatalogMessageKey,
432 ) -> Option<EffectiveTranslationRef<'_>> {
433 self.get(key).map(CatalogMessage::effective_translation)
434 }
435
436 #[must_use]
439 pub fn effective_translation_with_source_fallback(
440 &self,
441 key: &CatalogMessageKey,
442 source_locale: &str,
443 ) -> Option<EffectiveTranslation> {
444 let message = self.get(key)?;
445 if self
446 .catalog
447 .locale
448 .as_deref()
449 .is_none_or(|locale| locale == source_locale)
450 {
451 Some(message.source_fallback_translation(self.catalog.locale.as_deref()))
452 } else {
453 Some(message.effective_translation_owned())
454 }
455 }
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
460pub enum PluralEncoding {
461 #[default]
463 Icu,
464 Gettext,
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
470pub enum CatalogStorageFormat {
471 #[default]
473 Po,
474 Ndjson,
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
480pub enum CatalogSemantics {
481 #[default]
483 IcuNative,
484 GettextCompat,
486}
487
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
490pub enum ObsoleteStrategy {
491 #[default]
493 Mark,
494 Delete,
496 Keep,
498}
499
500#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
502pub enum OrderBy {
503 #[default]
505 Msgid,
506 Origin,
508}
509
510#[derive(Debug, Clone, PartialEq, Eq)]
512pub enum PlaceholderCommentMode {
513 Disabled,
515 Enabled {
517 limit: usize,
519 },
520}
521
522impl Default for PlaceholderCommentMode {
523 fn default() -> Self {
524 Self::Enabled { limit: 3 }
525 }
526}
527
528#[derive(Debug, Clone, PartialEq, Eq)]
530pub struct UpdateCatalogOptions<'a> {
531 pub locale: Option<&'a str>,
533 pub source_locale: &'a str,
535 pub input: CatalogUpdateInput,
537 pub existing: Option<&'a str>,
539 pub storage_format: CatalogStorageFormat,
541 pub semantics: CatalogSemantics,
543 pub plural_encoding: PluralEncoding,
545 pub obsolete_strategy: ObsoleteStrategy,
547 pub overwrite_source_translations: bool,
549 pub order_by: OrderBy,
551 pub include_origins: bool,
553 pub include_line_numbers: bool,
555 pub print_placeholders_in_comments: PlaceholderCommentMode,
557 pub custom_header_attributes: Option<&'a BTreeMap<String, String>>,
559}
560
561impl Default for UpdateCatalogOptions<'_> {
562 fn default() -> Self {
563 Self {
564 locale: None,
565 source_locale: "",
566 input: CatalogUpdateInput::default(),
567 existing: None,
568 storage_format: CatalogStorageFormat::Po,
569 semantics: CatalogSemantics::IcuNative,
570 plural_encoding: PluralEncoding::Icu,
571 obsolete_strategy: ObsoleteStrategy::Mark,
572 overwrite_source_translations: false,
573 order_by: OrderBy::Msgid,
574 include_origins: true,
575 include_line_numbers: true,
576 print_placeholders_in_comments: PlaceholderCommentMode::Enabled { limit: 3 },
577 custom_header_attributes: None,
578 }
579 }
580}
581
582#[derive(Debug, Clone, PartialEq, Eq)]
584pub struct UpdateCatalogFileOptions<'a> {
585 pub target_path: &'a Path,
587 pub locale: Option<&'a str>,
589 pub source_locale: &'a str,
591 pub input: CatalogUpdateInput,
593 pub storage_format: CatalogStorageFormat,
595 pub semantics: CatalogSemantics,
597 pub plural_encoding: PluralEncoding,
599 pub obsolete_strategy: ObsoleteStrategy,
601 pub overwrite_source_translations: bool,
603 pub order_by: OrderBy,
605 pub include_origins: bool,
607 pub include_line_numbers: bool,
609 pub print_placeholders_in_comments: PlaceholderCommentMode,
611 pub custom_header_attributes: Option<&'a BTreeMap<String, String>>,
613}
614
615impl Default for UpdateCatalogFileOptions<'_> {
616 fn default() -> Self {
617 Self {
618 target_path: Path::new(""),
619 locale: None,
620 source_locale: "",
621 input: CatalogUpdateInput::default(),
622 storage_format: CatalogStorageFormat::Po,
623 semantics: CatalogSemantics::IcuNative,
624 plural_encoding: PluralEncoding::Icu,
625 obsolete_strategy: ObsoleteStrategy::Mark,
626 overwrite_source_translations: false,
627 order_by: OrderBy::Msgid,
628 include_origins: true,
629 include_line_numbers: true,
630 print_placeholders_in_comments: PlaceholderCommentMode::Enabled { limit: 3 },
631 custom_header_attributes: None,
632 }
633 }
634}
635
636#[derive(Debug, Clone, PartialEq, Eq)]
638pub struct ParseCatalogOptions<'a> {
639 pub content: &'a str,
641 pub locale: Option<&'a str>,
643 pub source_locale: &'a str,
645 pub storage_format: CatalogStorageFormat,
647 pub semantics: CatalogSemantics,
649 pub plural_encoding: PluralEncoding,
651 pub strict: bool,
653}
654
655impl Default for ParseCatalogOptions<'_> {
656 fn default() -> Self {
657 Self {
658 content: "",
659 locale: None,
660 source_locale: "",
661 storage_format: CatalogStorageFormat::Po,
662 semantics: CatalogSemantics::IcuNative,
663 plural_encoding: PluralEncoding::Icu,
664 strict: false,
665 }
666 }
667}
668
669#[derive(Debug)]
671pub enum ApiError {
672 Parse(ParseError),
674 Io(std::io::Error),
676 InvalidArguments(String),
678 Conflict(String),
680 Unsupported(String),
682}
683
684impl fmt::Display for ApiError {
685 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
686 match self {
687 Self::Parse(error) => error.fmt(f),
688 Self::Io(error) => error.fmt(f),
689 Self::InvalidArguments(message)
690 | Self::Conflict(message)
691 | Self::Unsupported(message) => f.write_str(message),
692 }
693 }
694}
695
696impl std::error::Error for ApiError {}
697
698impl From<ParseError> for ApiError {
699 fn from(value: ParseError) -> Self {
700 Self::Parse(value)
701 }
702}
703
704impl From<std::io::Error> for ApiError {
705 fn from(value: std::io::Error) -> Self {
706 Self::Io(value)
707 }
708}
709
710#[cfg(test)]
711mod tests {
712 use std::collections::BTreeMap;
713 use std::io;
714 use std::path::Path;
715
716 use super::{
717 ApiError, CatalogMessage, CatalogMessageExtra, CatalogMessageKey, CatalogSemantics,
718 CatalogStorageFormat, CatalogUpdateInput, Diagnostic, DiagnosticSeverity,
719 EffectiveTranslation, EffectiveTranslationRef, NormalizedParsedCatalog, ObsoleteStrategy,
720 OrderBy, ParseCatalogOptions, ParsedCatalog, PlaceholderCommentMode, PluralEncoding,
721 PluralSource, TranslationShape, UpdateCatalogFileOptions, UpdateCatalogOptions,
722 };
723
724 #[test]
725 fn catalog_update_input_defaults_and_conversions_use_expected_variants() {
726 assert!(matches!(
727 CatalogUpdateInput::default(),
728 CatalogUpdateInput::Structured(messages) if messages.is_empty()
729 ));
730 assert!(matches!(
731 CatalogUpdateInput::from(Vec::<super::ExtractedMessage>::new()),
732 CatalogUpdateInput::Structured(messages) if messages.is_empty()
733 ));
734 assert!(matches!(
735 CatalogUpdateInput::from(Vec::<super::SourceExtractedMessage>::new()),
736 CatalogUpdateInput::SourceFirst(messages) if messages.is_empty()
737 ));
738 }
739
740 #[test]
741 fn catalog_message_helpers_cover_key_and_fallback_behavior() {
742 let singular = CatalogMessage {
743 msgid: "Hello".to_owned(),
744 msgctxt: Some("button".to_owned()),
745 translation: TranslationShape::Singular {
746 value: String::new(),
747 },
748 comments: vec!["Shown in toolbar".to_owned()],
749 origin: Vec::new(),
750 obsolete: false,
751 extra: Some(CatalogMessageExtra {
752 translator_comments: vec!["Imperative".to_owned()],
753 flags: vec!["fuzzy".to_owned()],
754 }),
755 };
756
757 assert_eq!(
758 singular.key(),
759 CatalogMessageKey::new("Hello", Some("button".to_owned()))
760 );
761 assert!(matches!(
762 singular.effective_translation(),
763 EffectiveTranslationRef::Singular("")
764 ));
765 assert_eq!(
766 singular.source_fallback_translation(Some("en")),
767 EffectiveTranslation::Singular("Hello".to_owned())
768 );
769
770 let plural = CatalogMessage {
771 msgid: "{count, plural, one {# file} other {# files}}".to_owned(),
772 msgctxt: None,
773 translation: TranslationShape::Plural {
774 source: PluralSource {
775 one: Some("{count} file".to_owned()),
776 other: "{count} files".to_owned(),
777 },
778 translation: BTreeMap::from([
779 ("one".to_owned(), String::new()),
780 ("other".to_owned(), "{count} Dateien".to_owned()),
781 ]),
782 variable: "count".to_owned(),
783 },
784 comments: Vec::new(),
785 origin: Vec::new(),
786 obsolete: false,
787 extra: None,
788 };
789
790 assert!(matches!(
791 plural.effective_translation(),
792 EffectiveTranslationRef::Plural(values)
793 if values.get("other") == Some(&"{count} Dateien".to_owned())
794 ));
795 assert_eq!(
796 plural.source_fallback_translation(Some("de")),
797 EffectiveTranslation::Plural(BTreeMap::from([
798 ("one".to_owned(), "{count} file".to_owned()),
799 ("other".to_owned(), "{count} Dateien".to_owned()),
800 ]))
801 );
802 }
803
804 #[test]
805 fn normalized_catalog_helpers_expose_lookup_and_source_fallback_views() {
806 let parsed = ParsedCatalog {
807 locale: Some("en".to_owned()),
808 semantics: CatalogSemantics::IcuNative,
809 headers: BTreeMap::new(),
810 messages: vec![CatalogMessage {
811 msgid: "Hello".to_owned(),
812 msgctxt: None,
813 translation: TranslationShape::Singular {
814 value: String::new(),
815 },
816 comments: Vec::new(),
817 origin: Vec::new(),
818 obsolete: false,
819 extra: None,
820 }],
821 diagnostics: Vec::new(),
822 };
823
824 let normalized = NormalizedParsedCatalog::new(parsed.clone()).expect("normalized");
825 let key = CatalogMessageKey::new("Hello", None);
826
827 assert_eq!(normalized.message_count(), 1);
828 assert!(normalized.contains_key(&key));
829 assert_eq!(
830 normalized.parsed_catalog().semantics,
831 CatalogSemantics::IcuNative
832 );
833 assert!(normalized.get(&key).is_some());
834 assert_eq!(
835 normalized.effective_translation_with_source_fallback(&key, "en"),
836 Some(EffectiveTranslation::Singular("Hello".to_owned()))
837 );
838 assert_eq!(normalized.into_parsed_catalog(), parsed);
839 }
840
841 #[test]
842 fn option_defaults_reflect_native_po_defaults() {
843 let update = UpdateCatalogOptions::default();
844 assert_eq!(update.storage_format, CatalogStorageFormat::Po);
845 assert_eq!(update.semantics, CatalogSemantics::IcuNative);
846 assert_eq!(update.plural_encoding, PluralEncoding::Icu);
847 assert_eq!(update.obsolete_strategy, ObsoleteStrategy::Mark);
848 assert_eq!(update.order_by, OrderBy::Msgid);
849 assert!(update.include_origins);
850 assert!(update.include_line_numbers);
851 assert_eq!(
852 update.print_placeholders_in_comments,
853 PlaceholderCommentMode::Enabled { limit: 3 }
854 );
855
856 let update_file = UpdateCatalogFileOptions::default();
857 assert_eq!(update_file.target_path, Path::new(""));
858 assert_eq!(update_file.storage_format, CatalogStorageFormat::Po);
859 assert_eq!(update_file.semantics, CatalogSemantics::IcuNative);
860 assert_eq!(update_file.plural_encoding, PluralEncoding::Icu);
861
862 let parse = ParseCatalogOptions::default();
863 assert_eq!(parse.storage_format, CatalogStorageFormat::Po);
864 assert_eq!(parse.semantics, CatalogSemantics::IcuNative);
865 assert_eq!(parse.plural_encoding, PluralEncoding::Icu);
866 assert!(!parse.strict);
867 }
868
869 #[test]
870 fn diagnostics_and_api_errors_preserve_human_readable_messages() {
871 let diagnostic = Diagnostic::new(DiagnosticSeverity::Warning, "code", "message")
872 .with_identity("Hello", Some("button"));
873 assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning);
874 assert_eq!(diagnostic.code, "code");
875 assert_eq!(diagnostic.message, "message");
876 assert_eq!(diagnostic.msgid.as_deref(), Some("Hello"));
877 assert_eq!(diagnostic.msgctxt.as_deref(), Some("button"));
878
879 let io_error = ApiError::from(io::Error::other("disk"));
880 assert_eq!(io_error.to_string(), "disk");
881 assert_eq!(
882 ApiError::InvalidArguments("bad input".to_owned()).to_string(),
883 "bad input"
884 );
885 assert_eq!(
886 ApiError::Conflict("duplicate".to_owned()).to_string(),
887 "duplicate"
888 );
889 assert_eq!(
890 ApiError::Unsupported("unsupported".to_owned()).to_string(),
891 "unsupported"
892 );
893 }
894}