1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::fmt;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, OnceLock};
6
7use crate::{Header, MsgStr, ParseError, PoFile, PoItem, SerializeOptions, parse_po, stringify_po};
8use ferrocat_icu::{IcuMessage, IcuNode, IcuPluralKind, parse_icu};
9use icu_locale::Locale;
10use icu_plurals::{PluralCategory, PluralRules};
11
12#[derive(Debug, Clone, PartialEq, Eq, Default)]
13pub struct CatalogOrigin {
14 pub file: String,
15 pub line: Option<u32>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Default)]
19pub struct ExtractedSingularMessage {
20 pub msgid: String,
21 pub msgctxt: Option<String>,
22 pub comments: Vec<String>,
23 pub origin: Vec<CatalogOrigin>,
24 pub placeholders: BTreeMap<String, Vec<String>>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Default)]
28pub struct PluralSource {
29 pub one: Option<String>,
30 pub other: String,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Default)]
34pub struct ExtractedPluralMessage {
35 pub msgid: String,
36 pub msgctxt: Option<String>,
37 pub source: PluralSource,
38 pub comments: Vec<String>,
39 pub origin: Vec<CatalogOrigin>,
40 pub placeholders: BTreeMap<String, Vec<String>>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum ExtractedMessage {
45 Singular(ExtractedSingularMessage),
46 Plural(ExtractedPluralMessage),
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Default)]
50pub struct SourceExtractedMessage {
51 pub msgid: String,
52 pub msgctxt: Option<String>,
53 pub comments: Vec<String>,
54 pub origin: Vec<CatalogOrigin>,
55 pub placeholders: BTreeMap<String, Vec<String>>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum CatalogUpdateInput {
60 Structured(Vec<ExtractedMessage>),
61 SourceFirst(Vec<SourceExtractedMessage>),
62}
63
64impl Default for CatalogUpdateInput {
65 fn default() -> Self {
66 Self::Structured(Vec::new())
67 }
68}
69
70impl From<Vec<ExtractedMessage>> for CatalogUpdateInput {
71 fn from(value: Vec<ExtractedMessage>) -> Self {
72 Self::Structured(value)
73 }
74}
75
76impl From<Vec<SourceExtractedMessage>> for CatalogUpdateInput {
77 fn from(value: Vec<SourceExtractedMessage>) -> Self {
78 Self::SourceFirst(value)
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum TranslationShape {
84 Singular {
85 value: String,
86 },
87 Plural {
88 source: PluralSource,
89 translation: BTreeMap<String, String>,
90 },
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum EffectiveTranslationRef<'a> {
95 Singular(&'a str),
96 Plural(&'a BTreeMap<String, String>),
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum EffectiveTranslation {
101 Singular(String),
102 Plural(BTreeMap<String, String>),
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Default)]
106pub struct CatalogMessageExtra {
107 pub translator_comments: Vec<String>,
108 pub flags: Vec<String>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct CatalogMessage {
113 pub msgid: String,
114 pub msgctxt: Option<String>,
115 pub translation: TranslationShape,
116 pub comments: Vec<String>,
117 pub origin: Vec<CatalogOrigin>,
118 pub obsolete: bool,
119 pub extra: Option<CatalogMessageExtra>,
120}
121
122impl CatalogMessage {
123 pub fn key(&self) -> CatalogMessageKey {
124 CatalogMessageKey {
125 msgid: self.msgid.clone(),
126 msgctxt: self.msgctxt.clone(),
127 }
128 }
129
130 pub fn effective_translation(&self) -> EffectiveTranslationRef<'_> {
131 match &self.translation {
132 TranslationShape::Singular { value } => EffectiveTranslationRef::Singular(value),
133 TranslationShape::Plural { translation, .. } => {
134 EffectiveTranslationRef::Plural(translation)
135 }
136 }
137 }
138
139 fn effective_translation_owned(&self) -> EffectiveTranslation {
140 match &self.translation {
141 TranslationShape::Singular { value } => EffectiveTranslation::Singular(value.clone()),
142 TranslationShape::Plural { translation, .. } => {
143 EffectiveTranslation::Plural(translation.clone())
144 }
145 }
146 }
147
148 fn source_fallback_translation(&self, locale: Option<&str>) -> EffectiveTranslation {
149 match &self.translation {
150 TranslationShape::Singular { value } => {
151 if value.is_empty() {
152 EffectiveTranslation::Singular(self.msgid.clone())
153 } else {
154 EffectiveTranslation::Singular(value.clone())
155 }
156 }
157 TranslationShape::Plural {
158 source,
159 translation,
160 } => {
161 let profile = PluralProfile::for_locale(locale);
162 let mut effective = profile.materialize_translation(translation);
163 let fallback = profile.source_locale_translation(source);
164 for (category, source_value) in fallback {
165 let should_fill = effective
166 .get(&category)
167 .is_none_or(|value| value.is_empty());
168 if should_fill {
169 effective.insert(category, source_value);
170 }
171 }
172 EffectiveTranslation::Plural(effective)
173 }
174 }
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
179pub struct CatalogMessageKey {
180 pub msgid: String,
181 pub msgctxt: Option<String>,
182}
183
184impl CatalogMessageKey {
185 pub fn new(msgid: impl Into<String>, msgctxt: Option<String>) -> Self {
186 Self {
187 msgid: msgid.into(),
188 msgctxt,
189 }
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum DiagnosticSeverity {
195 Info,
196 Warning,
197 Error,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct Diagnostic {
202 pub severity: DiagnosticSeverity,
203 pub code: String,
204 pub message: String,
205 pub msgid: Option<String>,
206 pub msgctxt: Option<String>,
207}
208
209impl Diagnostic {
210 fn new(
211 severity: DiagnosticSeverity,
212 code: impl Into<String>,
213 message: impl Into<String>,
214 ) -> Self {
215 Self {
216 severity,
217 code: code.into(),
218 message: message.into(),
219 msgid: None,
220 msgctxt: None,
221 }
222 }
223
224 fn with_identity(mut self, msgid: &str, msgctxt: Option<&str>) -> Self {
225 self.msgid = Some(msgid.to_owned());
226 self.msgctxt = msgctxt.map(str::to_owned);
227 self
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Default)]
232pub struct CatalogStats {
233 pub total: usize,
234 pub added: usize,
235 pub changed: usize,
236 pub unchanged: usize,
237 pub obsolete_marked: usize,
238 pub obsolete_removed: usize,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct CatalogUpdateResult {
243 pub content: String,
244 pub created: bool,
245 pub updated: bool,
246 pub stats: CatalogStats,
247 pub diagnostics: Vec<Diagnostic>,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct ParsedCatalog {
252 pub locale: Option<String>,
253 pub headers: BTreeMap<String, String>,
254 pub messages: Vec<CatalogMessage>,
255 pub diagnostics: Vec<Diagnostic>,
256}
257
258impl ParsedCatalog {
259 pub fn into_normalized_view(self) -> Result<NormalizedParsedCatalog, ApiError> {
260 NormalizedParsedCatalog::new(self)
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub struct NormalizedParsedCatalog {
266 catalog: ParsedCatalog,
267 key_index: BTreeMap<CatalogMessageKey, usize>,
268}
269
270impl NormalizedParsedCatalog {
271 fn new(catalog: ParsedCatalog) -> Result<Self, ApiError> {
272 let mut key_index = BTreeMap::new();
273 for (index, message) in catalog.messages.iter().enumerate() {
274 let key = message.key();
275 if key_index.insert(key.clone(), index).is_some() {
276 return Err(ApiError::Conflict(format!(
277 "duplicate parsed catalog message for msgid {:?} and context {:?}",
278 key.msgid, key.msgctxt
279 )));
280 }
281 }
282 Ok(Self { catalog, key_index })
283 }
284
285 pub fn parsed_catalog(&self) -> &ParsedCatalog {
286 &self.catalog
287 }
288
289 pub fn into_parsed_catalog(self) -> ParsedCatalog {
290 self.catalog
291 }
292
293 pub fn get(&self, key: &CatalogMessageKey) -> Option<&CatalogMessage> {
294 self.key_index
295 .get(key)
296 .map(|index| &self.catalog.messages[*index])
297 }
298
299 pub fn contains_key(&self, key: &CatalogMessageKey) -> bool {
300 self.key_index.contains_key(key)
301 }
302
303 pub fn message_count(&self) -> usize {
304 self.catalog.messages.len()
305 }
306
307 pub fn iter(&self) -> impl Iterator<Item = (&CatalogMessageKey, &CatalogMessage)> + '_ {
308 self.key_index
309 .iter()
310 .map(|(key, index)| (key, &self.catalog.messages[*index]))
311 }
312
313 pub fn effective_translation(
314 &self,
315 key: &CatalogMessageKey,
316 ) -> Option<EffectiveTranslationRef<'_>> {
317 self.get(key).map(CatalogMessage::effective_translation)
318 }
319
320 pub fn effective_translation_with_source_fallback(
321 &self,
322 key: &CatalogMessageKey,
323 source_locale: &str,
324 ) -> Option<EffectiveTranslation> {
325 let message = self.get(key)?;
326 if self
327 .catalog
328 .locale
329 .as_deref()
330 .is_none_or(|locale| locale == source_locale)
331 {
332 Some(message.source_fallback_translation(self.catalog.locale.as_deref()))
333 } else {
334 Some(message.effective_translation_owned())
335 }
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
340pub enum PluralEncoding {
341 #[default]
342 Icu,
343 Gettext,
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
347pub enum ObsoleteStrategy {
348 #[default]
349 Mark,
350 Delete,
351 Keep,
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
355pub enum OrderBy {
356 #[default]
357 Msgid,
358 Origin,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub enum PlaceholderCommentMode {
363 Disabled,
364 Enabled { limit: usize },
365}
366
367impl Default for PlaceholderCommentMode {
368 fn default() -> Self {
369 Self::Enabled { limit: 3 }
370 }
371}
372
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct UpdateCatalogOptions {
375 pub locale: Option<String>,
376 pub source_locale: String,
377 pub input: CatalogUpdateInput,
378 pub existing: Option<String>,
379 pub plural_encoding: PluralEncoding,
380 pub obsolete_strategy: ObsoleteStrategy,
381 pub overwrite_source_translations: bool,
382 pub order_by: OrderBy,
383 pub include_origins: bool,
384 pub include_line_numbers: bool,
385 pub print_placeholders_in_comments: PlaceholderCommentMode,
386 pub custom_header_attributes: BTreeMap<String, String>,
387}
388
389impl Default for UpdateCatalogOptions {
390 fn default() -> Self {
391 Self {
392 locale: None,
393 source_locale: String::new(),
394 input: CatalogUpdateInput::default(),
395 existing: None,
396 plural_encoding: PluralEncoding::Icu,
397 obsolete_strategy: ObsoleteStrategy::Mark,
398 overwrite_source_translations: false,
399 order_by: OrderBy::Msgid,
400 include_origins: true,
401 include_line_numbers: true,
402 print_placeholders_in_comments: PlaceholderCommentMode::Enabled { limit: 3 },
403 custom_header_attributes: BTreeMap::new(),
404 }
405 }
406}
407
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct UpdateCatalogFileOptions {
410 pub target_path: PathBuf,
411 pub locale: Option<String>,
412 pub source_locale: String,
413 pub input: CatalogUpdateInput,
414 pub plural_encoding: PluralEncoding,
415 pub obsolete_strategy: ObsoleteStrategy,
416 pub overwrite_source_translations: bool,
417 pub order_by: OrderBy,
418 pub include_origins: bool,
419 pub include_line_numbers: bool,
420 pub print_placeholders_in_comments: PlaceholderCommentMode,
421 pub custom_header_attributes: BTreeMap<String, String>,
422}
423
424impl Default for UpdateCatalogFileOptions {
425 fn default() -> Self {
426 Self {
427 target_path: PathBuf::new(),
428 locale: None,
429 source_locale: String::new(),
430 input: CatalogUpdateInput::default(),
431 plural_encoding: PluralEncoding::Icu,
432 obsolete_strategy: ObsoleteStrategy::Mark,
433 overwrite_source_translations: false,
434 order_by: OrderBy::Msgid,
435 include_origins: true,
436 include_line_numbers: true,
437 print_placeholders_in_comments: PlaceholderCommentMode::Enabled { limit: 3 },
438 custom_header_attributes: BTreeMap::new(),
439 }
440 }
441}
442
443#[derive(Debug, Clone, PartialEq, Eq)]
444pub struct ParseCatalogOptions {
445 pub content: String,
446 pub locale: Option<String>,
447 pub source_locale: String,
448 pub plural_encoding: PluralEncoding,
449 pub strict: bool,
450}
451
452impl Default for ParseCatalogOptions {
453 fn default() -> Self {
454 Self {
455 content: String::new(),
456 locale: None,
457 source_locale: String::new(),
458 plural_encoding: PluralEncoding::Icu,
459 strict: false,
460 }
461 }
462}
463
464#[derive(Debug)]
465pub enum ApiError {
466 Parse(ParseError),
467 Io(std::io::Error),
468 InvalidArguments(String),
469 Conflict(String),
470 Unsupported(String),
471}
472
473impl fmt::Display for ApiError {
474 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
475 match self {
476 Self::Parse(error) => error.fmt(f),
477 Self::Io(error) => error.fmt(f),
478 Self::InvalidArguments(message)
479 | Self::Conflict(message)
480 | Self::Unsupported(message) => f.write_str(message),
481 }
482 }
483}
484
485impl std::error::Error for ApiError {}
486
487impl From<ParseError> for ApiError {
488 fn from(value: ParseError) -> Self {
489 Self::Parse(value)
490 }
491}
492
493impl From<std::io::Error> for ApiError {
494 fn from(value: std::io::Error) -> Self {
495 Self::Io(value)
496 }
497}
498
499#[derive(Debug, Clone, PartialEq, Eq, Default)]
500struct Catalog {
501 locale: Option<String>,
502 headers: BTreeMap<String, String>,
503 file_comments: Vec<String>,
504 file_extracted_comments: Vec<String>,
505 messages: Vec<CanonicalMessage>,
506 diagnostics: Vec<Diagnostic>,
507}
508
509#[derive(Debug, Clone, PartialEq, Eq)]
510struct CanonicalMessage {
511 msgid: String,
512 msgctxt: Option<String>,
513 translation: CanonicalTranslation,
514 comments: Vec<String>,
515 origins: Vec<CatalogOrigin>,
516 placeholders: BTreeMap<String, Vec<String>>,
517 obsolete: bool,
518 translator_comments: Vec<String>,
519 flags: Vec<String>,
520}
521
522#[derive(Debug, Clone, PartialEq, Eq)]
523enum CanonicalTranslation {
524 Singular {
525 value: String,
526 },
527 Plural {
528 source: PluralSource,
529 translation_by_category: BTreeMap<String, String>,
530 variable: String,
531 },
532}
533
534#[derive(Debug, Clone, PartialEq, Eq)]
535struct NormalizedMessage {
536 msgid: String,
537 msgctxt: Option<String>,
538 kind: NormalizedKind,
539 comments: Vec<String>,
540 origins: Vec<CatalogOrigin>,
541 placeholders: BTreeMap<String, Vec<String>>,
542}
543
544#[derive(Debug, Clone, PartialEq, Eq)]
545enum NormalizedKind {
546 Singular,
547 Plural {
548 source: PluralSource,
549 variable: Option<String>,
550 },
551}
552
553#[derive(Debug, Clone, PartialEq, Eq)]
554struct ParsedIcuPlural {
555 variable: String,
556 branches: BTreeMap<String, String>,
557}
558
559enum IcuPluralProjection {
560 NotPlural,
561 Projected(ParsedIcuPlural),
562 Unsupported(&'static str),
563 Malformed,
564}
565
566#[derive(Debug, Clone, PartialEq, Eq, Default)]
567struct ParsedPluralFormsHeader {
568 raw: Option<String>,
569 nplurals: Option<usize>,
570 plural: Option<String>,
571}
572
573#[derive(Debug, Clone, PartialEq, Eq)]
574struct PluralProfile {
575 categories: Vec<String>,
576}
577
578impl PluralProfile {
579 fn new(locale: Option<&str>, nplurals: Option<usize>) -> Self {
580 let categories = if let Some(locale_categories) = locale.and_then(icu_plural_categories_for)
581 {
582 if nplurals.is_none() || nplurals == Some(locale_categories.len()) {
583 locale_categories
584 } else {
585 fallback_plural_categories(nplurals)
586 }
587 } else {
588 fallback_plural_categories(nplurals)
589 };
590
591 Self { categories }
592 }
593
594 fn for_locale(locale: Option<&str>) -> Self {
595 Self::new(locale, None)
596 }
597
598 fn for_gettext_slots(locale: Option<&str>, nplurals: Option<usize>) -> Self {
599 Self::new(locale, nplurals)
600 }
601
602 fn for_translation(
603 locale: Option<&str>,
604 translation_by_category: &BTreeMap<String, String>,
605 ) -> Self {
606 Self::new(locale, Some(translation_by_category.len()))
607 }
608
609 fn categories(&self) -> &[String] {
610 &self.categories
611 }
612
613 fn nplurals(&self) -> usize {
614 self.categories.len().max(1)
615 }
616
617 fn materialize_translation(
618 &self,
619 translation: &BTreeMap<String, String>,
620 ) -> BTreeMap<String, String> {
621 self.categories
622 .iter()
623 .map(|category| {
624 (
625 category.clone(),
626 translation.get(category).cloned().unwrap_or_default(),
627 )
628 })
629 .collect()
630 }
631
632 fn source_locale_translation(&self, source: &PluralSource) -> BTreeMap<String, String> {
633 let mut translation = BTreeMap::new();
634 for category in &self.categories {
635 let value = match category.as_str() {
636 "one" => source.one.clone().unwrap_or_else(|| source.other.clone()),
637 "other" => source.other.clone(),
638 _ => source.other.clone(),
639 };
640 translation.insert(category.clone(), value);
641 }
642 translation
643 }
644
645 fn empty_translation(&self) -> BTreeMap<String, String> {
646 self.categories
647 .iter()
648 .map(|category| (category.clone(), String::new()))
649 .collect()
650 }
651
652 fn gettext_values(&self, translation: &BTreeMap<String, String>) -> Vec<String> {
653 self.categories
654 .iter()
655 .map(|category| translation.get(category).cloned().unwrap_or_default())
656 .collect()
657 }
658
659 fn gettext_header(&self) -> Option<String> {
660 match self.nplurals() {
661 1 => Some("nplurals=1; plural=0;".to_owned()),
662 2 => Some("nplurals=2; plural=(n != 1);".to_owned()),
663 _ => None,
664 }
665 }
666}
667
668pub fn update_catalog(options: UpdateCatalogOptions) -> Result<CatalogUpdateResult, ApiError> {
669 validate_source_locale(&options.source_locale)?;
670
671 let created = options.existing.is_none();
672 let original = options.existing.as_deref().unwrap_or("");
673 let existing = match options.existing.as_deref() {
674 Some(content) if !content.is_empty() => parse_catalog_to_internal(
675 content,
676 options.locale.as_deref(),
677 options.plural_encoding,
678 false,
679 )?,
680 Some(_) | None => Catalog {
681 locale: options.locale.clone(),
682 headers: BTreeMap::new(),
683 file_comments: Vec::new(),
684 file_extracted_comments: Vec::new(),
685 messages: Vec::new(),
686 diagnostics: Vec::new(),
687 },
688 };
689
690 let locale = options
691 .locale
692 .clone()
693 .or_else(|| existing.locale.clone())
694 .or_else(|| existing.headers.get("Language").cloned());
695 let mut diagnostics = existing.diagnostics.clone();
696 let normalized = normalize_update_input(&options.input, &mut diagnostics)?;
697 let (mut merged, stats) = merge_catalogs(
698 existing,
699 &normalized,
700 locale.as_deref(),
701 &options.source_locale,
702 options.overwrite_source_translations,
703 options.obsolete_strategy,
704 &mut diagnostics,
705 );
706 merged.locale = locale.clone();
707 apply_header_defaults(
708 &mut merged.headers,
709 locale.as_deref(),
710 options.plural_encoding,
711 &mut diagnostics,
712 &options.custom_header_attributes,
713 );
714 sort_messages(&mut merged.messages, options.order_by);
715 let file = export_catalog_to_po(&merged, &options, locale.as_deref(), &mut diagnostics)?;
716 let content = stringify_po(&file, &SerializeOptions::default());
717
718 Ok(CatalogUpdateResult {
719 updated: content != original,
720 content,
721 created,
722 stats,
723 diagnostics,
724 })
725}
726
727pub fn update_catalog_file(
728 options: UpdateCatalogFileOptions,
729) -> Result<CatalogUpdateResult, ApiError> {
730 validate_source_locale(&options.source_locale)?;
731 if options.target_path.as_os_str().is_empty() {
732 return Err(ApiError::InvalidArguments(
733 "target_path must not be empty".to_owned(),
734 ));
735 }
736
737 let existing = match fs::read_to_string(&options.target_path) {
738 Ok(content) => Some(content),
739 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
740 Err(error) => return Err(ApiError::Io(error)),
741 };
742
743 let result = update_catalog(UpdateCatalogOptions {
744 locale: options.locale,
745 source_locale: options.source_locale,
746 input: options.input,
747 existing,
748 plural_encoding: options.plural_encoding,
749 obsolete_strategy: options.obsolete_strategy,
750 overwrite_source_translations: options.overwrite_source_translations,
751 order_by: options.order_by,
752 include_origins: options.include_origins,
753 include_line_numbers: options.include_line_numbers,
754 print_placeholders_in_comments: options.print_placeholders_in_comments,
755 custom_header_attributes: options.custom_header_attributes,
756 })?;
757
758 if result.created || result.updated {
759 atomic_write(&options.target_path, &result.content)?;
760 }
761
762 Ok(result)
763}
764
765pub fn parse_catalog(options: ParseCatalogOptions) -> Result<ParsedCatalog, ApiError> {
766 validate_source_locale(&options.source_locale)?;
767 let catalog = parse_catalog_to_internal(
768 &options.content,
769 options.locale.as_deref(),
770 options.plural_encoding,
771 options.strict,
772 )?;
773 let messages = catalog
774 .messages
775 .into_iter()
776 .map(public_message_from_canonical)
777 .collect();
778
779 Ok(ParsedCatalog {
780 locale: catalog.locale,
781 headers: catalog.headers,
782 messages,
783 diagnostics: catalog.diagnostics,
784 })
785}
786
787fn validate_source_locale(source_locale: &str) -> Result<(), ApiError> {
788 if source_locale.trim().is_empty() {
789 return Err(ApiError::InvalidArguments(
790 "source_locale must not be empty".to_owned(),
791 ));
792 }
793 Ok(())
794}
795
796fn normalize_update_input(
797 input: &CatalogUpdateInput,
798 diagnostics: &mut Vec<Diagnostic>,
799) -> Result<Vec<NormalizedMessage>, ApiError> {
800 let mut index = BTreeMap::<(String, Option<String>), usize>::new();
801 let mut normalized = Vec::<NormalizedMessage>::new();
802
803 match input {
804 CatalogUpdateInput::Structured(extracted) => {
805 for message in extracted {
806 let (msgid, msgctxt, kind, comments, origins, placeholders) = match message {
807 ExtractedMessage::Singular(message) => (
808 message.msgid.clone(),
809 message.msgctxt.clone(),
810 NormalizedKind::Singular,
811 message.comments.clone(),
812 message.origin.clone(),
813 message.placeholders.clone(),
814 ),
815 ExtractedMessage::Plural(message) => (
816 message.msgid.clone(),
817 message.msgctxt.clone(),
818 NormalizedKind::Plural {
819 source: message.source.clone(),
820 variable: None,
821 },
822 message.comments.clone(),
823 message.origin.clone(),
824 message.placeholders.clone(),
825 ),
826 };
827
828 push_normalized_message(
829 &mut index,
830 &mut normalized,
831 NormalizedMessage {
832 msgid,
833 msgctxt,
834 kind,
835 comments: dedupe_strings(comments),
836 origins: dedupe_origins(origins),
837 placeholders: dedupe_placeholders(placeholders),
838 },
839 )?;
840 }
841 }
842 CatalogUpdateInput::SourceFirst(messages) => {
843 for message in messages {
844 let kind = match project_icu_plural(&message.msgid) {
845 IcuPluralProjection::Projected(projected) => NormalizedKind::Plural {
846 source: PluralSource {
847 one: projected.branches.get("one").cloned(),
848 other: projected
849 .branches
850 .get("other")
851 .cloned()
852 .unwrap_or_else(|| message.msgid.clone()),
853 },
854 variable: Some(projected.variable),
855 },
856 IcuPluralProjection::Unsupported(reason) => {
857 diagnostics.push(
858 Diagnostic::new(
859 DiagnosticSeverity::Warning,
860 "plural.source_first_fallback",
861 format!(
862 "Could not project source-first ICU plural into catalog plural form: {reason}"
863 ),
864 )
865 .with_identity(&message.msgid, message.msgctxt.as_deref()),
866 );
867 NormalizedKind::Singular
868 }
869 IcuPluralProjection::Malformed => {
870 diagnostics.push(
871 Diagnostic::new(
872 DiagnosticSeverity::Warning,
873 "plural.source_first_fallback",
874 "Could not parse source-first ICU plural safely; keeping the message as singular.",
875 )
876 .with_identity(&message.msgid, message.msgctxt.as_deref()),
877 );
878 NormalizedKind::Singular
879 }
880 IcuPluralProjection::NotPlural => NormalizedKind::Singular,
881 };
882
883 push_normalized_message(
884 &mut index,
885 &mut normalized,
886 NormalizedMessage {
887 msgid: message.msgid.clone(),
888 msgctxt: message.msgctxt.clone(),
889 kind,
890 comments: dedupe_strings(message.comments.clone()),
891 origins: dedupe_origins(message.origin.clone()),
892 placeholders: dedupe_placeholders(message.placeholders.clone()),
893 },
894 )?;
895 }
896 }
897 }
898
899 Ok(normalized)
900}
901
902fn push_normalized_message(
903 index: &mut BTreeMap<(String, Option<String>), usize>,
904 normalized: &mut Vec<NormalizedMessage>,
905 message: NormalizedMessage,
906) -> Result<(), ApiError> {
907 let msgid = message.msgid.clone();
908 let msgctxt = message.msgctxt.clone();
909 if msgid.is_empty() {
910 return Err(ApiError::InvalidArguments(
911 "extracted msgid must not be empty".to_owned(),
912 ));
913 }
914
915 let key = (msgid.clone(), msgctxt.clone());
916 match index.get(&key).copied() {
917 Some(existing_index) => {
918 let existing = &mut normalized[existing_index];
919 if existing.kind != message.kind {
920 return Err(ApiError::Conflict(format!(
921 "conflicting duplicate extracted message for msgid {:?}",
922 msgid
923 )));
924 }
925 merge_unique_strings(&mut existing.comments, message.comments);
926 merge_unique_origins(&mut existing.origins, message.origins);
927 merge_placeholders(&mut existing.placeholders, message.placeholders);
928 }
929 None => {
930 index.insert(key, normalized.len());
931 normalized.push(message);
932 }
933 }
934
935 Ok(())
936}
937
938fn merge_catalogs(
939 existing: Catalog,
940 normalized: &[NormalizedMessage],
941 locale: Option<&str>,
942 source_locale: &str,
943 overwrite_source_translations: bool,
944 obsolete_strategy: ObsoleteStrategy,
945 diagnostics: &mut Vec<Diagnostic>,
946) -> (Catalog, CatalogStats) {
947 let is_source_locale = locale.is_none_or(|value| value == source_locale);
948 let mut stats = CatalogStats::default();
949
950 let mut existing_index = BTreeMap::<(String, Option<String>), usize>::new();
951 for (index, message) in existing.messages.iter().enumerate() {
952 existing_index.insert((message.msgid.clone(), message.msgctxt.clone()), index);
953 }
954
955 let mut matched = vec![false; existing.messages.len()];
956 let mut messages = Vec::with_capacity(normalized.len() + existing.messages.len());
957
958 for next in normalized {
959 let key = (next.msgid.clone(), next.msgctxt.clone());
960 let previous = existing_index.get(&key).copied().map(|index| {
961 matched[index] = true;
962 existing.messages[index].clone()
963 });
964 let merged = merge_message(
965 previous.as_ref(),
966 next,
967 is_source_locale,
968 locale,
969 overwrite_source_translations,
970 diagnostics,
971 );
972 if previous.is_none() {
973 stats.added += 1;
974 } else if previous.as_ref() == Some(&merged) {
975 stats.unchanged += 1;
976 } else {
977 stats.changed += 1;
978 }
979 messages.push(merged);
980 }
981
982 for (index, message) in existing.messages.into_iter().enumerate() {
983 if matched[index] {
984 continue;
985 }
986 match obsolete_strategy {
987 ObsoleteStrategy::Delete => {
988 stats.obsolete_removed += 1;
989 }
990 ObsoleteStrategy::Mark => {
991 let mut message = message;
992 if !message.obsolete {
993 message.obsolete = true;
994 stats.obsolete_marked += 1;
995 }
996 messages.push(message);
997 }
998 ObsoleteStrategy::Keep => {
999 let mut message = message;
1000 message.obsolete = false;
1001 messages.push(message);
1002 }
1003 }
1004 }
1005
1006 stats.total = messages.len();
1007 (
1008 Catalog {
1009 locale: existing.locale,
1010 headers: existing.headers,
1011 file_comments: existing.file_comments,
1012 file_extracted_comments: existing.file_extracted_comments,
1013 messages,
1014 diagnostics: existing.diagnostics,
1015 },
1016 stats,
1017 )
1018}
1019
1020fn merge_message(
1021 previous: Option<&CanonicalMessage>,
1022 next: &NormalizedMessage,
1023 is_source_locale: bool,
1024 locale: Option<&str>,
1025 overwrite_source_translations: bool,
1026 diagnostics: &mut Vec<Diagnostic>,
1027) -> CanonicalMessage {
1028 let plural_profile = match &next.kind {
1029 NormalizedKind::Singular => None,
1030 NormalizedKind::Plural { .. } => Some(PluralProfile::for_locale(locale)),
1031 };
1032
1033 let translation = match (&next.kind, previous) {
1034 (NormalizedKind::Singular, Some(previous))
1035 if matches!(previous.translation, CanonicalTranslation::Singular { .. })
1036 && !(is_source_locale && overwrite_source_translations) =>
1037 {
1038 previous.translation.clone()
1039 }
1040 (NormalizedKind::Plural { source, variable }, Some(previous))
1041 if matches!(previous.translation, CanonicalTranslation::Plural { .. })
1042 && !(is_source_locale && overwrite_source_translations) =>
1043 {
1044 let previous_variable = match &previous.translation {
1045 CanonicalTranslation::Plural { variable, .. } => variable.clone(),
1046 _ => unreachable!(),
1047 };
1048 let previous_map = match &previous.translation {
1049 CanonicalTranslation::Plural {
1050 translation_by_category,
1051 ..
1052 } => translation_by_category.clone(),
1053 _ => unreachable!(),
1054 };
1055 CanonicalTranslation::Plural {
1056 source: source.clone(),
1057 translation_by_category: plural_profile
1058 .as_ref()
1059 .expect("plural messages require plural profile")
1060 .materialize_translation(&previous_map),
1061 variable: variable.clone().unwrap_or(previous_variable),
1062 }
1063 }
1064 (NormalizedKind::Singular, _) => CanonicalTranslation::Singular {
1065 value: if is_source_locale {
1066 next.msgid.clone()
1067 } else {
1068 String::new()
1069 },
1070 },
1071 (NormalizedKind::Plural { source, variable }, previous) => {
1072 let variable = variable
1073 .clone()
1074 .or_else(|| previous.and_then(extract_plural_variable))
1075 .or_else(|| derive_plural_variable(&next.placeholders))
1076 .unwrap_or_else(|| {
1077 diagnostics.push(
1078 Diagnostic::new(
1079 DiagnosticSeverity::Warning,
1080 "plural.assumed_variable",
1081 "Unable to determine plural placeholder name, assuming \"count\".",
1082 )
1083 .with_identity(&next.msgid, next.msgctxt.as_deref()),
1084 );
1085 "count".to_owned()
1086 });
1087
1088 CanonicalTranslation::Plural {
1089 source: source.clone(),
1090 translation_by_category: if is_source_locale {
1091 plural_profile
1092 .as_ref()
1093 .expect("plural messages require plural profile")
1094 .source_locale_translation(source)
1095 } else {
1096 plural_profile
1097 .as_ref()
1098 .expect("plural messages require plural profile")
1099 .empty_translation()
1100 },
1101 variable,
1102 }
1103 }
1104 };
1105
1106 let (translator_comments, flags, obsolete) = previous
1107 .map(|message| {
1108 (
1109 message.translator_comments.clone(),
1110 message.flags.clone(),
1111 false,
1112 )
1113 })
1114 .unwrap_or_else(|| (Vec::new(), Vec::new(), false));
1115
1116 CanonicalMessage {
1117 msgid: next.msgid.clone(),
1118 msgctxt: next.msgctxt.clone(),
1119 translation,
1120 comments: next.comments.clone(),
1121 origins: next.origins.clone(),
1122 placeholders: next.placeholders.clone(),
1123 obsolete,
1124 translator_comments,
1125 flags,
1126 }
1127}
1128
1129fn materialize_plural_categories(
1130 categories: &[String],
1131 translation: BTreeMap<String, String>,
1132) -> BTreeMap<String, String> {
1133 categories
1134 .iter()
1135 .map(|category| {
1136 (
1137 category.clone(),
1138 translation.get(category).cloned().unwrap_or_default(),
1139 )
1140 })
1141 .collect()
1142}
1143
1144fn extract_plural_variable(message: &CanonicalMessage) -> Option<String> {
1145 match &message.translation {
1146 CanonicalTranslation::Plural { variable, .. } => Some(variable.clone()),
1147 CanonicalTranslation::Singular { .. } => None,
1148 }
1149}
1150
1151fn apply_header_defaults(
1152 headers: &mut BTreeMap<String, String>,
1153 locale: Option<&str>,
1154 plural_encoding: PluralEncoding,
1155 diagnostics: &mut Vec<Diagnostic>,
1156 custom: &BTreeMap<String, String>,
1157) {
1158 headers
1159 .entry("MIME-Version".to_owned())
1160 .or_insert_with(|| "1.0".to_owned());
1161 headers
1162 .entry("Content-Type".to_owned())
1163 .or_insert_with(|| "text/plain; charset=utf-8".to_owned());
1164 headers
1165 .entry("Content-Transfer-Encoding".to_owned())
1166 .or_insert_with(|| "8bit".to_owned());
1167 headers
1168 .entry("X-Generator".to_owned())
1169 .or_insert_with(|| "ferrocat".to_owned());
1170 if let Some(locale) = locale {
1171 headers.insert("Language".to_owned(), locale.to_owned());
1172 }
1173 if plural_encoding == PluralEncoding::Gettext && !custom.contains_key("Plural-Forms") {
1174 let profile = PluralProfile::for_locale(locale);
1175 let parsed_header = parse_plural_forms_from_headers(headers);
1176 match (parsed_header.raw.as_deref(), profile.gettext_header()) {
1177 (None, Some(header)) => {
1178 headers.insert("Plural-Forms".to_owned(), header);
1179 }
1180 (None, None) => diagnostics.push(Diagnostic::new(
1181 DiagnosticSeverity::Info,
1182 "plural.missing_plural_forms_header",
1183 "No safe default Plural-Forms header is known for this locale; keeping the header unset.",
1184 )),
1185 (Some(_), Some(header))
1186 if parsed_header.nplurals == Some(profile.nplurals())
1187 && parsed_header.plural.is_none() =>
1188 {
1189 headers.insert("Plural-Forms".to_owned(), header);
1190 diagnostics.push(Diagnostic::new(
1191 DiagnosticSeverity::Info,
1192 "plural.completed_plural_forms_header",
1193 "Plural-Forms header was missing the plural expression and has been completed using a safe locale default.",
1194 ));
1195 }
1196 _ => {}
1197 }
1198 }
1199 for (key, value) in custom {
1200 headers.insert(key.clone(), value.clone());
1201 }
1202}
1203
1204fn sort_messages(messages: &mut [CanonicalMessage], order_by: OrderBy) {
1205 match order_by {
1206 OrderBy::Msgid => messages.sort_by(|left, right| {
1207 left.msgid
1208 .cmp(&right.msgid)
1209 .then_with(|| left.msgctxt.cmp(&right.msgctxt))
1210 .then_with(|| left.obsolete.cmp(&right.obsolete))
1211 }),
1212 OrderBy::Origin => messages.sort_by(|left, right| {
1213 first_origin_sort_key(&left.origins)
1214 .cmp(&first_origin_sort_key(&right.origins))
1215 .then_with(|| left.msgid.cmp(&right.msgid))
1216 .then_with(|| left.msgctxt.cmp(&right.msgctxt))
1217 }),
1218 }
1219}
1220
1221fn first_origin_sort_key(origins: &[CatalogOrigin]) -> (String, Option<u32>) {
1222 origins
1223 .first()
1224 .map(|origin| (origin.file.clone(), origin.line))
1225 .unwrap_or_else(|| (String::new(), None))
1226}
1227
1228fn export_catalog_to_po(
1229 catalog: &Catalog,
1230 options: &UpdateCatalogOptions,
1231 locale: Option<&str>,
1232 diagnostics: &mut Vec<Diagnostic>,
1233) -> Result<PoFile, ApiError> {
1234 let mut file = PoFile {
1235 comments: catalog.file_comments.clone(),
1236 extracted_comments: catalog.file_extracted_comments.clone(),
1237 headers: catalog
1238 .headers
1239 .iter()
1240 .map(|(key, value)| Header {
1241 key: key.clone(),
1242 value: value.clone(),
1243 })
1244 .collect(),
1245 items: Vec::with_capacity(catalog.messages.len()),
1246 };
1247
1248 for message in &catalog.messages {
1249 file.items
1250 .push(export_message_to_po(message, options, locale, diagnostics)?);
1251 }
1252
1253 Ok(file)
1254}
1255
1256fn export_message_to_po(
1257 message: &CanonicalMessage,
1258 options: &UpdateCatalogOptions,
1259 locale: Option<&str>,
1260 diagnostics: &mut Vec<Diagnostic>,
1261) -> Result<PoItem, ApiError> {
1262 let plural_profile = match &message.translation {
1263 CanonicalTranslation::Plural {
1264 translation_by_category,
1265 ..
1266 } => Some(PluralProfile::for_translation(
1267 locale,
1268 translation_by_category,
1269 )),
1270 CanonicalTranslation::Singular { .. } => None,
1271 };
1272 let nplurals = match &message.translation {
1273 CanonicalTranslation::Plural {
1274 translation_by_category,
1275 ..
1276 } => plural_profile
1277 .as_ref()
1278 .expect("plural messages require plural profile")
1279 .nplurals()
1280 .max(translation_by_category.len().max(1)),
1281 CanonicalTranslation::Singular { .. } => 1,
1282 };
1283 let mut item = PoItem::new(nplurals);
1284 item.msgctxt = message.msgctxt.clone();
1285 item.comments = message.translator_comments.clone();
1286 item.flags = message.flags.clone();
1287 item.obsolete = message.obsolete;
1288 item.extracted_comments = message.comments.clone();
1289 append_placeholder_comments(
1290 &mut item.extracted_comments,
1291 &message.placeholders,
1292 &options.print_placeholders_in_comments,
1293 );
1294 item.references = if options.include_origins {
1295 message
1296 .origins
1297 .iter()
1298 .map(|origin| {
1299 if options.include_line_numbers {
1300 match origin.line {
1301 Some(line) => format!("{}:{line}", origin.file),
1302 None => origin.file.clone(),
1303 }
1304 } else {
1305 origin.file.clone()
1306 }
1307 })
1308 .collect()
1309 } else {
1310 Vec::new()
1311 };
1312
1313 match (&message.translation, options.plural_encoding) {
1314 (CanonicalTranslation::Singular { value }, _) => {
1315 item.msgid = message.msgid.clone();
1316 item.msgstr = MsgStr::from(value.clone());
1317 }
1318 (
1319 CanonicalTranslation::Plural {
1320 source,
1321 translation_by_category,
1322 variable,
1323 },
1324 PluralEncoding::Icu,
1325 ) => {
1326 item.msgid = synthesize_icu_plural(variable, &plural_source_branches(source));
1327 item.msgstr = MsgStr::from(synthesize_icu_plural(variable, translation_by_category));
1328 }
1329 (
1330 CanonicalTranslation::Plural {
1331 source,
1332 translation_by_category,
1333 ..
1334 },
1335 PluralEncoding::Gettext,
1336 ) => {
1337 if !translation_by_category.contains_key("other") {
1338 diagnostics.push(
1339 Diagnostic::new(
1340 DiagnosticSeverity::Error,
1341 "plural.unsupported_gettext_export",
1342 "Plural translation is missing the required \"other\" category.",
1343 )
1344 .with_identity(&message.msgid, message.msgctxt.as_deref()),
1345 );
1346 return Err(ApiError::Unsupported(
1347 "plural translation is missing the required \"other\" category".to_owned(),
1348 ));
1349 }
1350 item.msgid = source.one.clone().unwrap_or_else(|| source.other.clone());
1351 item.msgid_plural = Some(source.other.clone());
1352 item.msgstr = MsgStr::from(
1353 plural_profile
1354 .as_ref()
1355 .expect("plural messages require plural profile")
1356 .gettext_values(translation_by_category),
1357 );
1358 item.nplurals = plural_profile
1359 .as_ref()
1360 .expect("plural messages require plural profile")
1361 .nplurals();
1362 }
1363 }
1364
1365 Ok(item)
1366}
1367
1368fn plural_source_branches(source: &PluralSource) -> BTreeMap<String, String> {
1369 let mut map = BTreeMap::new();
1370 if let Some(one) = &source.one {
1371 map.insert("one".to_owned(), one.clone());
1372 }
1373 map.insert("other".to_owned(), source.other.clone());
1374 map
1375}
1376
1377fn append_placeholder_comments(
1378 comments: &mut Vec<String>,
1379 placeholders: &BTreeMap<String, Vec<String>>,
1380 mode: &PlaceholderCommentMode,
1381) {
1382 let limit = match mode {
1383 PlaceholderCommentMode::Disabled => return,
1384 PlaceholderCommentMode::Enabled { limit } => *limit,
1385 };
1386
1387 let mut seen = comments.iter().cloned().collect::<BTreeSet<String>>();
1388
1389 for (name, values) in placeholders {
1390 if !name.chars().all(|ch| ch.is_ascii_digit()) {
1391 continue;
1392 }
1393 for value in values.iter().take(limit) {
1394 let comment = format!(
1395 "placeholder {{{name}}}: {}",
1396 normalize_placeholder_value(value)
1397 );
1398 if seen.insert(comment.clone()) {
1399 comments.push(comment);
1400 }
1401 }
1402 }
1403}
1404
1405fn normalize_placeholder_value(value: &str) -> String {
1406 value.replace('\n', " ")
1407}
1408
1409fn parse_catalog_to_internal(
1410 content: &str,
1411 locale_override: Option<&str>,
1412 plural_encoding: PluralEncoding,
1413 strict: bool,
1414) -> Result<Catalog, ApiError> {
1415 let file = parse_po(content)?;
1416 let headers = file
1417 .headers
1418 .iter()
1419 .map(|header| (header.key.clone(), header.value.clone()))
1420 .collect::<BTreeMap<_, _>>();
1421 let locale = locale_override
1422 .map(str::to_owned)
1423 .or_else(|| headers.get("Language").cloned());
1424 let plural_forms = parse_plural_forms_from_headers(&headers);
1425 let nplurals = plural_forms.nplurals;
1426 let mut diagnostics = Vec::new();
1427 validate_plural_forms_header(
1428 locale.as_deref(),
1429 &plural_forms,
1430 plural_encoding,
1431 &mut diagnostics,
1432 );
1433 let mut messages = Vec::with_capacity(file.items.len());
1434
1435 for item in file.items {
1436 let mut conversion_diagnostics = Vec::new();
1437 let message = import_message_from_po(
1438 item,
1439 locale.as_deref(),
1440 nplurals,
1441 plural_encoding,
1442 strict,
1443 &mut conversion_diagnostics,
1444 )?;
1445 diagnostics.extend(conversion_diagnostics);
1446 messages.push(message);
1447 }
1448
1449 Ok(Catalog {
1450 locale,
1451 headers,
1452 file_comments: file.comments,
1453 file_extracted_comments: file.extracted_comments,
1454 messages,
1455 diagnostics,
1456 })
1457}
1458
1459fn import_message_from_po(
1460 item: PoItem,
1461 locale: Option<&str>,
1462 nplurals: Option<usize>,
1463 plural_encoding: PluralEncoding,
1464 strict: bool,
1465 diagnostics: &mut Vec<Diagnostic>,
1466) -> Result<CanonicalMessage, ApiError> {
1467 let (comments, placeholders) = split_placeholder_comments(item.extracted_comments);
1468 let origins = item
1469 .references
1470 .iter()
1471 .map(|reference| parse_origin(reference))
1472 .collect();
1473
1474 let translation = if let Some(msgid_plural) = &item.msgid_plural {
1475 let plural_profile =
1476 PluralProfile::for_gettext_slots(locale, nplurals.or(Some(item.msgstr.len())));
1477 CanonicalTranslation::Plural {
1478 source: PluralSource {
1479 one: Some(item.msgid.clone()),
1480 other: msgid_plural.clone(),
1481 },
1482 translation_by_category: plural_profile
1483 .categories()
1484 .iter()
1485 .enumerate()
1486 .map(|(index, category)| {
1487 (
1488 category.clone(),
1489 item.msgstr.iter().nth(index).cloned().unwrap_or_default(),
1490 )
1491 })
1492 .collect(),
1493 variable: "count".to_owned(),
1494 }
1495 } else {
1496 let msgstr = item.msgstr.first_str().unwrap_or_default().to_owned();
1497 if plural_encoding == PluralEncoding::Icu {
1498 match project_icu_plural(&item.msgid) {
1499 IcuPluralProjection::Projected(source_plural) => {
1500 let translated_projection = project_icu_plural(&msgstr);
1501 match translated_projection {
1502 IcuPluralProjection::Projected(translated_plural)
1503 if translated_plural.variable == source_plural.variable =>
1504 {
1505 CanonicalTranslation::Plural {
1506 source: PluralSource {
1507 one: source_plural.branches.get("one").cloned(),
1508 other: source_plural
1509 .branches
1510 .get("other")
1511 .cloned()
1512 .unwrap_or_else(|| item.msgid.clone()),
1513 },
1514 translation_by_category: materialize_plural_categories(
1515 &sorted_plural_keys(&translated_plural.branches),
1516 translated_plural.branches,
1517 ),
1518 variable: source_plural.variable,
1519 }
1520 }
1521 IcuPluralProjection::Projected(_) => {
1522 if strict {
1523 return Err(ApiError::Unsupported(
1524 "ICU plural source and translation use different variables"
1525 .to_owned(),
1526 ));
1527 }
1528 diagnostics.push(
1529 Diagnostic::new(
1530 DiagnosticSeverity::Warning,
1531 "plural.partial_icu_parse",
1532 "Could not safely align ICU plural source and translation; keeping the message as singular.",
1533 )
1534 .with_identity(&item.msgid, item.msgctxt.as_deref()),
1535 );
1536 CanonicalTranslation::Singular { value: msgstr }
1537 }
1538 IcuPluralProjection::Unsupported(_) | IcuPluralProjection::Malformed => {
1539 if strict
1540 && matches!(translated_projection, IcuPluralProjection::Malformed)
1541 {
1542 return Err(ApiError::Unsupported(
1543 "ICU plural message could not be parsed in strict mode"
1544 .to_owned(),
1545 ));
1546 }
1547 diagnostics.push(
1548 Diagnostic::new(
1549 DiagnosticSeverity::Warning,
1550 "plural.partial_icu_parse",
1551 "Could not fully parse ICU plural translation; keeping the message as singular.",
1552 )
1553 .with_identity(&item.msgid, item.msgctxt.as_deref()),
1554 );
1555 CanonicalTranslation::Singular { value: msgstr }
1556 }
1557 IcuPluralProjection::NotPlural => {
1558 diagnostics.push(
1559 Diagnostic::new(
1560 DiagnosticSeverity::Warning,
1561 "plural.partial_icu_parse",
1562 "Could not fully parse ICU plural translation; keeping the message as singular.",
1563 )
1564 .with_identity(&item.msgid, item.msgctxt.as_deref()),
1565 );
1566 CanonicalTranslation::Singular { value: msgstr }
1567 }
1568 }
1569 }
1570 IcuPluralProjection::NotPlural => CanonicalTranslation::Singular { value: msgstr },
1571 IcuPluralProjection::Malformed if strict => {
1572 return Err(ApiError::Unsupported(
1573 "ICU plural parsing failed in strict mode".to_owned(),
1574 ));
1575 }
1576 IcuPluralProjection::Unsupported(message) => {
1577 diagnostics.push(
1578 Diagnostic::new(
1579 DiagnosticSeverity::Warning,
1580 "plural.unsupported_icu_projection",
1581 message,
1582 )
1583 .with_identity(&item.msgid, item.msgctxt.as_deref()),
1584 );
1585 CanonicalTranslation::Singular { value: msgstr }
1586 }
1587 IcuPluralProjection::Malformed => CanonicalTranslation::Singular { value: msgstr },
1588 }
1589 } else {
1590 CanonicalTranslation::Singular { value: msgstr }
1591 }
1592 };
1593
1594 Ok(CanonicalMessage {
1595 msgid: item.msgid,
1596 msgctxt: item.msgctxt,
1597 translation,
1598 comments,
1599 origins,
1600 placeholders,
1601 obsolete: item.obsolete,
1602 translator_comments: item.comments,
1603 flags: item.flags,
1604 })
1605}
1606
1607fn split_placeholder_comments(
1608 extracted_comments: Vec<String>,
1609) -> (Vec<String>, BTreeMap<String, Vec<String>>) {
1610 let mut comments = Vec::new();
1611 let mut placeholders = BTreeMap::<String, Vec<String>>::new();
1612
1613 for comment in extracted_comments {
1614 if let Some((name, value)) = parse_placeholder_comment(&comment) {
1615 placeholders.entry(name).or_default().push(value);
1616 } else {
1617 comments.push(comment);
1618 }
1619 }
1620
1621 (comments, dedupe_placeholders(placeholders))
1622}
1623
1624fn parse_placeholder_comment(comment: &str) -> Option<(String, String)> {
1625 let rest = comment.strip_prefix("placeholder {")?;
1626 let end = rest.find("}: ")?;
1627 Some((rest[..end].to_owned(), rest[end + 3..].to_owned()))
1628}
1629
1630fn parse_origin(reference: &str) -> CatalogOrigin {
1631 match reference.rsplit_once(':') {
1632 Some((file, line)) if line.chars().all(|ch| ch.is_ascii_digit()) => CatalogOrigin {
1633 file: file.to_owned(),
1634 line: line.parse::<u32>().ok(),
1635 },
1636 _ => CatalogOrigin {
1637 file: reference.to_owned(),
1638 line: None,
1639 },
1640 }
1641}
1642
1643fn parse_plural_forms_from_headers(headers: &BTreeMap<String, String>) -> ParsedPluralFormsHeader {
1644 let Some(plural_forms) = headers.get("Plural-Forms") else {
1645 return ParsedPluralFormsHeader::default();
1646 };
1647
1648 let mut parsed = ParsedPluralFormsHeader {
1649 raw: Some(plural_forms.clone()),
1650 ..ParsedPluralFormsHeader::default()
1651 };
1652 for part in plural_forms.split(';') {
1653 let trimmed = part.trim();
1654 if let Some(value) = trimmed.strip_prefix("nplurals=") {
1655 parsed.nplurals = value.trim().parse().ok();
1656 } else if let Some(value) = trimmed.strip_prefix("plural=") {
1657 let value = value.trim();
1658 if !value.is_empty() {
1659 parsed.plural = Some(value.to_owned());
1660 }
1661 }
1662 }
1663
1664 parsed
1665}
1666
1667fn validate_plural_forms_header(
1668 locale: Option<&str>,
1669 plural_forms: &ParsedPluralFormsHeader,
1670 plural_encoding: PluralEncoding,
1671 diagnostics: &mut Vec<Diagnostic>,
1672) {
1673 if plural_encoding != PluralEncoding::Gettext {
1674 return;
1675 }
1676
1677 if let Some(nplurals) = plural_forms.nplurals {
1678 let profile = PluralProfile::for_locale(locale);
1679 let expected = profile.nplurals();
1680 if locale.is_some() && nplurals != expected {
1681 diagnostics.push(Diagnostic::new(
1682 DiagnosticSeverity::Warning,
1683 "plural.nplurals_locale_mismatch",
1684 format!(
1685 "Plural-Forms declares nplurals={nplurals}, but locale-derived categories expect {expected}."
1686 ),
1687 ));
1688 }
1689 } else if plural_forms.plural.is_some() {
1690 diagnostics.push(Diagnostic::new(
1691 DiagnosticSeverity::Warning,
1692 "parse.invalid_plural_forms_header",
1693 "Plural-Forms header contains a plural expression but no parseable nplurals value.",
1694 ));
1695 }
1696
1697 if plural_forms.nplurals.is_some() && plural_forms.plural.is_none() {
1698 diagnostics.push(Diagnostic::new(
1699 DiagnosticSeverity::Info,
1700 "plural.missing_plural_expression",
1701 "Plural-Forms header declares nplurals but omits the plural expression.",
1702 ));
1703 }
1704}
1705
1706fn public_message_from_canonical(message: CanonicalMessage) -> CatalogMessage {
1707 let translation = match message.translation {
1708 CanonicalTranslation::Singular { value } => TranslationShape::Singular { value },
1709 CanonicalTranslation::Plural {
1710 source,
1711 translation_by_category,
1712 ..
1713 } => TranslationShape::Plural {
1714 source,
1715 translation: translation_by_category,
1716 },
1717 };
1718
1719 CatalogMessage {
1720 msgid: message.msgid,
1721 msgctxt: message.msgctxt,
1722 translation,
1723 comments: message.comments,
1724 origin: message.origins,
1725 obsolete: message.obsolete,
1726 extra: Some(CatalogMessageExtra {
1727 translator_comments: message.translator_comments,
1728 flags: message.flags,
1729 }),
1730 }
1731}
1732
1733fn icu_plural_categories_for(locale: &str) -> Option<Vec<String>> {
1734 static CACHE: OnceLock<Mutex<HashMap<String, Option<Vec<String>>>>> = OnceLock::new();
1735
1736 let normalized = normalize_plural_locale(locale);
1737 if normalized.is_empty() {
1738 return None;
1739 }
1740
1741 let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
1742 if let Some(cached) = cache
1743 .lock()
1744 .expect("plural category cache mutex poisoned")
1745 .get(&normalized)
1746 .cloned()
1747 {
1748 return cached;
1749 }
1750
1751 let resolved = normalized
1752 .parse::<Locale>()
1753 .ok()
1754 .and_then(|locale| PluralRules::try_new_cardinal(locale.into()).ok())
1755 .map(|rules| {
1756 rules
1757 .categories()
1758 .map(plural_category_name)
1759 .map(str::to_owned)
1760 .collect::<Vec<_>>()
1761 });
1762
1763 cache
1764 .lock()
1765 .expect("plural category cache mutex poisoned")
1766 .insert(normalized, resolved.clone());
1767
1768 resolved
1769}
1770
1771fn normalize_plural_locale(locale: &str) -> String {
1772 locale.trim().replace('_', "-")
1773}
1774
1775fn plural_category_name(category: PluralCategory) -> &'static str {
1776 match category {
1777 PluralCategory::Zero => "zero",
1778 PluralCategory::One => "one",
1779 PluralCategory::Two => "two",
1780 PluralCategory::Few => "few",
1781 PluralCategory::Many => "many",
1782 PluralCategory::Other => "other",
1783 }
1784}
1785
1786fn fallback_plural_categories(nplurals: Option<usize>) -> Vec<String> {
1787 let categories = match nplurals.unwrap_or(2) {
1788 0 | 1 => vec!["other"],
1789 2 => vec!["one", "other"],
1790 3 => vec!["one", "few", "other"],
1791 4 => vec!["one", "few", "many", "other"],
1792 5 => vec!["zero", "one", "few", "many", "other"],
1793 _ => vec!["zero", "one", "two", "few", "many", "other"],
1794 };
1795
1796 categories.into_iter().map(str::to_owned).collect()
1797}
1798
1799fn sorted_plural_keys(map: &BTreeMap<String, String>) -> Vec<String> {
1800 let mut keys: Vec<_> = map.keys().cloned().collect();
1801 keys.sort_by_key(|key| plural_key_rank(key));
1802 if !keys.iter().any(|key| key == "other") {
1803 keys.push("other".to_owned());
1804 }
1805 keys
1806}
1807
1808fn plural_key_rank(key: &str) -> usize {
1809 match key {
1810 "zero" => 0,
1811 "one" => 1,
1812 "two" => 2,
1813 "few" => 3,
1814 "many" => 4,
1815 "other" => 5,
1816 _ => 6,
1817 }
1818}
1819
1820fn dedupe_strings(values: Vec<String>) -> Vec<String> {
1821 let mut out = Vec::new();
1822 for value in values {
1823 if !push_unique_string(&out, &value) {
1824 out.push(value);
1825 }
1826 }
1827 out
1828}
1829
1830fn merge_unique_strings(target: &mut Vec<String>, incoming: Vec<String>) {
1831 if target.len() + incoming.len() < 8 {
1832 for value in incoming {
1833 if !push_unique_string(target, &value) {
1834 target.push(value);
1835 }
1836 }
1837 return;
1838 }
1839
1840 let mut seen = target.iter().cloned().collect::<BTreeSet<_>>();
1841 for value in incoming {
1842 if seen.insert(value.clone()) {
1843 target.push(value);
1844 }
1845 }
1846}
1847
1848fn push_unique_string(target: &[String], value: &str) -> bool {
1849 if target.len() < 8 {
1850 target.iter().any(|existing| existing == value)
1851 } else {
1852 target
1853 .iter()
1854 .cloned()
1855 .collect::<BTreeSet<_>>()
1856 .contains(value)
1857 }
1858}
1859
1860fn dedupe_origins(values: Vec<CatalogOrigin>) -> Vec<CatalogOrigin> {
1861 let mut out = Vec::new();
1862 for value in values {
1863 if !push_unique_origin(&out, &value) {
1864 out.push(value);
1865 }
1866 }
1867 out
1868}
1869
1870fn merge_unique_origins(target: &mut Vec<CatalogOrigin>, incoming: Vec<CatalogOrigin>) {
1871 if target.len() + incoming.len() < 8 {
1872 for value in incoming {
1873 if !push_unique_origin(target, &value) {
1874 target.push(value);
1875 }
1876 }
1877 return;
1878 }
1879
1880 let mut seen = target
1881 .iter()
1882 .map(|origin| (origin.file.clone(), origin.line))
1883 .collect::<BTreeSet<_>>();
1884 for value in incoming {
1885 if seen.insert((value.file.clone(), value.line)) {
1886 target.push(value);
1887 }
1888 }
1889}
1890
1891fn push_unique_origin(target: &[CatalogOrigin], value: &CatalogOrigin) -> bool {
1892 if target.len() < 8 {
1893 target
1894 .iter()
1895 .any(|origin| origin.file == value.file && origin.line == value.line)
1896 } else {
1897 target
1898 .iter()
1899 .map(|origin| (origin.file.clone(), origin.line))
1900 .collect::<BTreeSet<_>>()
1901 .contains(&(value.file.clone(), value.line))
1902 }
1903}
1904
1905fn dedupe_placeholders(
1906 placeholders: BTreeMap<String, Vec<String>>,
1907) -> BTreeMap<String, Vec<String>> {
1908 placeholders
1909 .into_iter()
1910 .map(|(key, values)| (key, dedupe_strings(values)))
1911 .collect()
1912}
1913
1914fn merge_placeholders(
1915 target: &mut BTreeMap<String, Vec<String>>,
1916 incoming: BTreeMap<String, Vec<String>>,
1917) {
1918 for (key, values) in incoming {
1919 merge_unique_strings(target.entry(key).or_default(), values);
1920 }
1921}
1922
1923fn derive_plural_variable(placeholders: &BTreeMap<String, Vec<String>>) -> Option<String> {
1924 if placeholders.contains_key("count") {
1925 return Some("count".to_owned());
1926 }
1927
1928 let mut named = placeholders
1929 .keys()
1930 .filter(|key| !key.chars().all(|ch| ch.is_ascii_digit()))
1931 .cloned();
1932 let first = named.next()?;
1933 if named.next().is_none() {
1934 Some(first)
1935 } else {
1936 None
1937 }
1938}
1939
1940fn synthesize_icu_plural(variable: &str, branches: &BTreeMap<String, String>) -> String {
1941 let mut out = String::new();
1942 out.push('{');
1943 out.push_str(variable);
1944 out.push_str(", plural,");
1945 for (category, value) in branches {
1946 out.push(' ');
1947 out.push_str(category);
1948 out.push_str(" {");
1949 out.push_str(value);
1950 out.push('}');
1951 }
1952 out.push('}');
1953 out
1954}
1955
1956fn project_icu_plural(input: &str) -> IcuPluralProjection {
1957 if !looks_like_icu_message(input.as_bytes()) {
1958 return IcuPluralProjection::NotPlural;
1959 }
1960
1961 let message = match parse_icu(input) {
1962 Ok(message) => message,
1963 Err(_) => return IcuPluralProjection::Malformed,
1964 };
1965
1966 let Some(IcuNode::Plural {
1967 name,
1968 kind: IcuPluralKind::Cardinal,
1969 offset,
1970 options,
1971 }) = only_node(&message)
1972 else {
1973 return IcuPluralProjection::NotPlural;
1974 };
1975
1976 if *offset != 0 {
1977 return IcuPluralProjection::Unsupported(
1978 "ICU plural offset syntax is not projected into the current catalog plural model.",
1979 );
1980 }
1981
1982 let mut branches = BTreeMap::new();
1983 for option in options {
1984 if option.selector.starts_with('=') {
1985 return IcuPluralProjection::Unsupported(
1986 "ICU exact-match plural selectors are not projected into the current catalog plural model.",
1987 );
1988 }
1989
1990 let value = match render_projectable_icu_nodes(&option.value) {
1991 Ok(value) => value,
1992 Err(message) => return IcuPluralProjection::Unsupported(message),
1993 };
1994 branches.insert(option.selector.clone(), value);
1995 }
1996
1997 if !branches.contains_key("other") {
1998 return IcuPluralProjection::Malformed;
1999 }
2000
2001 IcuPluralProjection::Projected(ParsedIcuPlural {
2002 variable: name.clone(),
2003 branches,
2004 })
2005}
2006
2007#[inline]
2008fn looks_like_icu_message(input: &[u8]) -> bool {
2009 input.iter().any(|byte| matches!(byte, b'{' | b'}' | b'<'))
2010}
2011
2012fn only_node(message: &IcuMessage) -> Option<&IcuNode> {
2013 match message.nodes.as_slice() {
2014 [node] => Some(node),
2015 _ => None,
2016 }
2017}
2018
2019fn render_projectable_icu_nodes(nodes: &[IcuNode]) -> Result<String, &'static str> {
2020 let mut out = String::new();
2021 for node in nodes {
2022 render_projectable_icu_node(node, &mut out)?;
2023 }
2024 Ok(out)
2025}
2026
2027fn render_projectable_icu_node(node: &IcuNode, out: &mut String) -> Result<(), &'static str> {
2028 match node {
2029 IcuNode::Literal(value) => append_escaped_icu_literal(out, value),
2030 IcuNode::Argument { name } => {
2031 out.push('{');
2032 out.push_str(name);
2033 out.push('}');
2034 }
2035 IcuNode::Number { name, style } => render_formatter("number", name, style.as_deref(), out),
2036 IcuNode::Date { name, style } => render_formatter("date", name, style.as_deref(), out),
2037 IcuNode::Time { name, style } => render_formatter("time", name, style.as_deref(), out),
2038 IcuNode::List { name, style } => render_formatter("list", name, style.as_deref(), out),
2039 IcuNode::Duration { name, style } => {
2040 render_formatter("duration", name, style.as_deref(), out)
2041 }
2042 IcuNode::Ago { name, style } => render_formatter("ago", name, style.as_deref(), out),
2043 IcuNode::Name { name, style } => render_formatter("name", name, style.as_deref(), out),
2044 IcuNode::Pound => out.push('#'),
2045 IcuNode::Tag { name, children } => {
2046 out.push('<');
2047 out.push_str(name);
2048 out.push('>');
2049 for child in children {
2050 render_projectable_icu_node(child, out)?;
2051 }
2052 out.push_str("</");
2053 out.push_str(name);
2054 out.push('>');
2055 }
2056 IcuNode::Select { .. } | IcuNode::Plural { .. } => {
2057 return Err(
2058 "Nested ICU select/plural structures are not projected into the current catalog plural model.",
2059 );
2060 }
2061 }
2062
2063 Ok(())
2064}
2065
2066fn render_formatter(kind: &str, name: &str, style: Option<&str>, out: &mut String) {
2067 out.push('{');
2068 out.push_str(name);
2069 out.push_str(", ");
2070 out.push_str(kind);
2071 if let Some(style) = style {
2072 out.push_str(", ");
2073 out.push_str(style);
2074 }
2075 out.push('}');
2076}
2077
2078fn append_escaped_icu_literal(out: &mut String, value: &str) {
2079 if !value
2080 .as_bytes()
2081 .iter()
2082 .any(|byte| matches!(byte, b'\'' | b'{' | b'}' | b'#' | b'<' | b'>'))
2083 {
2084 out.push_str(value);
2085 return;
2086 }
2087
2088 for ch in value.chars() {
2089 match ch {
2090 '\'' => out.push_str("''"),
2091 '{' | '}' | '#' | '<' | '>' => {
2092 out.push('\'');
2093 out.push(ch);
2094 out.push('\'');
2095 }
2096 _ => out.push(ch),
2097 }
2098 }
2099}
2100
2101fn atomic_write(path: &Path, content: &str) -> Result<(), ApiError> {
2102 let directory = path.parent().unwrap_or_else(|| Path::new("."));
2103 fs::create_dir_all(directory)?;
2104
2105 let file_name = path
2106 .file_name()
2107 .and_then(|name| name.to_str())
2108 .ok_or_else(|| {
2109 ApiError::InvalidArguments("target_path must have a file name".to_owned())
2110 })?;
2111 let temp_path = directory.join(format!(".{file_name}.ferrocat.tmp"));
2112 fs::write(&temp_path, content)?;
2113 fs::rename(&temp_path, path)?;
2114 Ok(())
2115}
2116
2117#[cfg(test)]
2118mod tests {
2119 use super::{
2120 ApiError, CatalogMessageKey, CatalogUpdateInput, DiagnosticSeverity, EffectiveTranslation,
2121 EffectiveTranslationRef, ExtractedMessage, ExtractedPluralMessage,
2122 ExtractedSingularMessage, ObsoleteStrategy, ParseCatalogOptions, PluralEncoding,
2123 PluralSource, SourceExtractedMessage, TranslationShape, UpdateCatalogFileOptions,
2124 UpdateCatalogOptions, parse_catalog, update_catalog, update_catalog_file,
2125 };
2126 use crate::parse_po;
2127 use std::collections::BTreeMap;
2128 use std::fs;
2129
2130 fn structured_input(messages: Vec<ExtractedMessage>) -> CatalogUpdateInput {
2131 CatalogUpdateInput::Structured(messages)
2132 }
2133
2134 fn source_first_input(messages: Vec<SourceExtractedMessage>) -> CatalogUpdateInput {
2135 CatalogUpdateInput::SourceFirst(messages)
2136 }
2137
2138 #[test]
2139 fn update_catalog_creates_new_source_locale_messages() {
2140 let result = update_catalog(UpdateCatalogOptions {
2141 source_locale: "en".to_owned(),
2142 locale: Some("en".to_owned()),
2143 input: structured_input(vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2144 msgid: "Hello".to_owned(),
2145 ..ExtractedSingularMessage::default()
2146 })]),
2147 ..UpdateCatalogOptions::default()
2148 })
2149 .expect("update");
2150
2151 let parsed = parse_po(&result.content).expect("parse output");
2152 assert_eq!(parsed.items.len(), 1);
2153 assert_eq!(parsed.items[0].msgid, "Hello");
2154 assert_eq!(parsed.items[0].msgstr[0], "Hello");
2155 assert!(result.created);
2156 assert!(result.updated);
2157 assert_eq!(result.stats.added, 1);
2158 }
2159
2160 #[test]
2161 fn update_catalog_preserves_non_source_translations() {
2162 let existing = "msgid \"Hello\"\nmsgstr \"Hallo\"\n";
2163 let result = update_catalog(UpdateCatalogOptions {
2164 source_locale: "en".to_owned(),
2165 locale: Some("de".to_owned()),
2166 existing: Some(existing.to_owned()),
2167 input: structured_input(vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2168 msgid: "Hello".to_owned(),
2169 ..ExtractedSingularMessage::default()
2170 })]),
2171 ..UpdateCatalogOptions::default()
2172 })
2173 .expect("update");
2174
2175 let parsed = parse_po(&result.content).expect("parse output");
2176 assert_eq!(parsed.items[0].msgstr[0], "Hallo");
2177 assert_eq!(result.stats.unchanged, 1);
2178 }
2179
2180 #[test]
2181 fn overwrite_source_translations_refreshes_source_locale() {
2182 let existing = "msgid \"Hello\"\nmsgstr \"Old\"\n";
2183 let result = update_catalog(UpdateCatalogOptions {
2184 source_locale: "en".to_owned(),
2185 locale: Some("en".to_owned()),
2186 existing: Some(existing.to_owned()),
2187 overwrite_source_translations: true,
2188 input: structured_input(vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2189 msgid: "Hello".to_owned(),
2190 ..ExtractedSingularMessage::default()
2191 })]),
2192 ..UpdateCatalogOptions::default()
2193 })
2194 .expect("update");
2195
2196 let parsed = parse_po(&result.content).expect("parse output");
2197 assert_eq!(parsed.items[0].msgstr[0], "Hello");
2198 assert_eq!(result.stats.changed, 1);
2199 }
2200
2201 #[test]
2202 fn obsolete_strategy_delete_removes_missing_messages() {
2203 let existing = "msgid \"keep\"\nmsgstr \"x\"\n\nmsgid \"drop\"\nmsgstr \"y\"\n";
2204 let result = update_catalog(UpdateCatalogOptions {
2205 source_locale: "en".to_owned(),
2206 locale: Some("de".to_owned()),
2207 existing: Some(existing.to_owned()),
2208 obsolete_strategy: ObsoleteStrategy::Delete,
2209 input: structured_input(vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2210 msgid: "keep".to_owned(),
2211 ..ExtractedSingularMessage::default()
2212 })]),
2213 ..UpdateCatalogOptions::default()
2214 })
2215 .expect("update");
2216
2217 let parsed = parse_po(&result.content).expect("parse output");
2218 assert_eq!(parsed.items.len(), 1);
2219 assert_eq!(result.stats.obsolete_removed, 1);
2220 }
2221
2222 #[test]
2223 fn duplicate_conflicts_fail_hard() {
2224 let error = update_catalog(UpdateCatalogOptions {
2225 source_locale: "en".to_owned(),
2226 locale: Some("en".to_owned()),
2227 input: structured_input(vec![
2228 ExtractedMessage::Singular(ExtractedSingularMessage {
2229 msgid: "Hello".to_owned(),
2230 ..ExtractedSingularMessage::default()
2231 }),
2232 ExtractedMessage::Plural(ExtractedPluralMessage {
2233 msgid: "Hello".to_owned(),
2234 source: PluralSource {
2235 one: Some("One".to_owned()),
2236 other: "Many".to_owned(),
2237 },
2238 ..ExtractedPluralMessage::default()
2239 }),
2240 ]),
2241 ..UpdateCatalogOptions::default()
2242 })
2243 .expect_err("conflict");
2244
2245 assert!(matches!(error, ApiError::Conflict(_)));
2246 }
2247
2248 #[test]
2249 fn plural_icu_export_uses_structural_input() {
2250 let result = update_catalog(UpdateCatalogOptions {
2251 source_locale: "en".to_owned(),
2252 locale: Some("en".to_owned()),
2253 input: structured_input(vec![ExtractedMessage::Plural(ExtractedPluralMessage {
2254 msgid: "{count, plural, one {# item} other {# items}}".to_owned(),
2255 source: PluralSource {
2256 one: Some("# item".to_owned()),
2257 other: "# items".to_owned(),
2258 },
2259 placeholders: BTreeMap::from([("count".to_owned(), vec!["count".to_owned()])]),
2260 ..ExtractedPluralMessage::default()
2261 })]),
2262 ..UpdateCatalogOptions::default()
2263 })
2264 .expect("update");
2265
2266 let parsed = parse_po(&result.content).expect("parse output");
2267 assert!(parsed.items[0].msgid.contains("{count, plural,"));
2268 assert!(parsed.items[0].msgid_plural.is_none());
2269 }
2270
2271 #[test]
2272 fn source_first_plain_messages_normalize_as_singular() {
2273 let result = update_catalog(UpdateCatalogOptions {
2274 source_locale: "en".to_owned(),
2275 locale: Some("en".to_owned()),
2276 input: source_first_input(vec![SourceExtractedMessage {
2277 msgid: "Welcome".to_owned(),
2278 ..SourceExtractedMessage::default()
2279 }]),
2280 ..UpdateCatalogOptions::default()
2281 })
2282 .expect("update");
2283
2284 let parsed = parse_po(&result.content).expect("parse output");
2285 assert_eq!(parsed.items[0].msgid, "Welcome");
2286 assert_eq!(parsed.items[0].msgstr[0], "Welcome");
2287 assert!(result.diagnostics.is_empty());
2288 }
2289
2290 #[test]
2291 fn source_first_simple_icu_plural_projects_into_plural_update() {
2292 let result = update_catalog(UpdateCatalogOptions {
2293 source_locale: "en".to_owned(),
2294 locale: Some("en".to_owned()),
2295 input: source_first_input(vec![SourceExtractedMessage {
2296 msgid: "{items, plural, one {# file} other {# files}}".to_owned(),
2297 placeholders: BTreeMap::from([("items".to_owned(), vec!["items".to_owned()])]),
2298 ..SourceExtractedMessage::default()
2299 }]),
2300 ..UpdateCatalogOptions::default()
2301 })
2302 .expect("update");
2303
2304 let parsed = parse_po(&result.content).expect("parse output");
2305 assert!(parsed.items[0].msgid.contains("{items, plural,"));
2306 assert_eq!(
2307 parsed.items[0].msgstr[0],
2308 "{items, plural, one {# file} other {# files}}"
2309 );
2310 assert!(result.diagnostics.is_empty());
2311 }
2312
2313 #[test]
2314 fn source_first_unsupported_icu_plural_falls_back_to_singular_with_warning() {
2315 let result = update_catalog(UpdateCatalogOptions {
2316 source_locale: "en".to_owned(),
2317 locale: Some("en".to_owned()),
2318 input: source_first_input(vec![SourceExtractedMessage {
2319 msgid: "{count, plural, one {{gender, select, male {He has one file} other {They have one file}}} other {{gender, select, male {He has # files} other {They have # files}}}}".to_owned(),
2320 ..SourceExtractedMessage::default()
2321 }]),
2322 ..UpdateCatalogOptions::default()
2323 })
2324 .expect("update");
2325
2326 let parsed = parse_po(&result.content).expect("parse output");
2327 assert_eq!(
2328 parsed.items[0].msgid,
2329 "{count, plural, one {{gender, select, male {He has one file} other {They have one file}}} other {{gender, select, male {He has # files} other {They have # files}}}}"
2330 );
2331 assert_eq!(parsed.items[0].msgstr[0], parsed.items[0].msgid);
2332 assert!(
2333 result
2334 .diagnostics
2335 .iter()
2336 .any(|diagnostic| diagnostic.code == "plural.source_first_fallback")
2337 );
2338 }
2339
2340 #[test]
2341 fn parse_catalog_projects_gettext_plural_into_structured_shape() {
2342 let parsed = parse_catalog(ParseCatalogOptions {
2343 content: concat!(
2344 "msgid \"book\"\n",
2345 "msgid_plural \"books\"\n",
2346 "msgstr[0] \"Buch\"\n",
2347 "msgstr[1] \"Buecher\"\n",
2348 )
2349 .to_owned(),
2350 locale: Some("de".to_owned()),
2351 source_locale: "en".to_owned(),
2352 plural_encoding: PluralEncoding::Gettext,
2353 strict: false,
2354 })
2355 .expect("parse");
2356
2357 match &parsed.messages[0].translation {
2358 TranslationShape::Plural {
2359 source,
2360 translation,
2361 } => {
2362 assert_eq!(source.one.as_deref(), Some("book"));
2363 assert_eq!(source.other, "books");
2364 assert_eq!(translation.get("one").map(String::as_str), Some("Buch"));
2365 assert_eq!(
2366 translation.get("other").map(String::as_str),
2367 Some("Buecher")
2368 );
2369 }
2370 other => panic!("expected plural translation, got {other:?}"),
2371 }
2372 }
2373
2374 #[test]
2375 fn normalized_view_indexes_messages_by_key() {
2376 let parsed = parse_catalog(ParseCatalogOptions {
2377 content: concat!(
2378 "msgctxt \"nav\"\n",
2379 "msgid \"Home\"\n",
2380 "msgstr \"Start\"\n",
2381 )
2382 .to_owned(),
2383 locale: Some("de".to_owned()),
2384 source_locale: "en".to_owned(),
2385 plural_encoding: PluralEncoding::Gettext,
2386 strict: false,
2387 })
2388 .expect("parse");
2389
2390 let normalized = parsed.into_normalized_view().expect("normalized view");
2391 let key = CatalogMessageKey::new("Home", Some("nav".to_owned()));
2392
2393 assert!(normalized.contains_key(&key));
2394 assert_eq!(normalized.message_count(), 1);
2395 assert!(matches!(
2396 normalized.effective_translation(&key),
2397 Some(EffectiveTranslationRef::Singular("Start"))
2398 ));
2399 assert_eq!(normalized.iter().count(), 1);
2400 }
2401
2402 #[test]
2403 fn normalized_view_rejects_duplicate_keys() {
2404 let parsed = parse_catalog(ParseCatalogOptions {
2405 content: concat!(
2406 "msgid \"Hello\"\n",
2407 "msgstr \"Hallo\"\n",
2408 "\n",
2409 "msgid \"Hello\"\n",
2410 "msgstr \"Servus\"\n",
2411 )
2412 .to_owned(),
2413 locale: Some("de".to_owned()),
2414 source_locale: "en".to_owned(),
2415 plural_encoding: PluralEncoding::Gettext,
2416 strict: false,
2417 })
2418 .expect("parse");
2419
2420 let error = parsed
2421 .into_normalized_view()
2422 .expect_err("duplicate keys should fail");
2423 assert!(matches!(error, ApiError::Conflict(_)));
2424 }
2425
2426 #[test]
2427 fn normalized_view_can_apply_source_locale_fallbacks() {
2428 let parsed = parse_catalog(ParseCatalogOptions {
2429 content: concat!(
2430 "msgid \"book\"\n",
2431 "msgid_plural \"books\"\n",
2432 "msgstr[0] \"\"\n",
2433 "msgstr[1] \"\"\n",
2434 "\n",
2435 "msgid \"Welcome\"\n",
2436 "msgstr \"\"\n",
2437 )
2438 .to_owned(),
2439 locale: Some("en".to_owned()),
2440 source_locale: "en".to_owned(),
2441 plural_encoding: PluralEncoding::Gettext,
2442 strict: false,
2443 })
2444 .expect("parse");
2445
2446 let normalized = parsed.into_normalized_view().expect("normalized view");
2447 let plural_key = CatalogMessageKey::new("book", None);
2448 let singular_key = CatalogMessageKey::new("Welcome", None);
2449
2450 assert!(matches!(
2451 normalized.effective_translation(&singular_key),
2452 Some(EffectiveTranslationRef::Singular(""))
2453 ));
2454 assert_eq!(
2455 normalized.effective_translation_with_source_fallback(&singular_key, "en"),
2456 Some(EffectiveTranslation::Singular("Welcome".to_owned()))
2457 );
2458
2459 assert_eq!(
2460 normalized.effective_translation_with_source_fallback(&plural_key, "en"),
2461 Some(EffectiveTranslation::Plural(BTreeMap::from([
2462 ("one".to_owned(), "book".to_owned()),
2463 ("other".to_owned(), "books".to_owned()),
2464 ])))
2465 );
2466 }
2467
2468 #[test]
2469 fn normalized_view_skips_source_fallback_for_non_source_locale_catalogs() {
2470 let parsed = parse_catalog(ParseCatalogOptions {
2471 content: concat!("msgid \"Hello\"\n", "msgstr \"\"\n").to_owned(),
2472 locale: Some("de".to_owned()),
2473 source_locale: "en".to_owned(),
2474 plural_encoding: PluralEncoding::Gettext,
2475 strict: false,
2476 })
2477 .expect("parse");
2478
2479 let normalized = parsed.into_normalized_view().expect("normalized view");
2480 let key = CatalogMessageKey::new("Hello", None);
2481
2482 assert_eq!(
2483 normalized.effective_translation_with_source_fallback(&key, "en"),
2484 Some(EffectiveTranslation::Singular(String::new()))
2485 );
2486 }
2487
2488 #[test]
2489 fn parse_catalog_uses_icu_plural_categories_for_french_gettext() {
2490 let parsed = parse_catalog(ParseCatalogOptions {
2491 content: concat!(
2492 "msgid \"fichier\"\n",
2493 "msgid_plural \"fichiers\"\n",
2494 "msgstr[0] \"fichier\"\n",
2495 "msgstr[1] \"millions de fichiers\"\n",
2496 "msgstr[2] \"fichiers\"\n",
2497 )
2498 .to_owned(),
2499 locale: Some("fr".to_owned()),
2500 source_locale: "en".to_owned(),
2501 plural_encoding: PluralEncoding::Gettext,
2502 strict: false,
2503 })
2504 .expect("parse");
2505
2506 match &parsed.messages[0].translation {
2507 TranslationShape::Plural { translation, .. } => {
2508 assert_eq!(translation.get("one").map(String::as_str), Some("fichier"));
2509 assert_eq!(
2510 translation.get("many").map(String::as_str),
2511 Some("millions de fichiers")
2512 );
2513 assert_eq!(
2514 translation.get("other").map(String::as_str),
2515 Some("fichiers")
2516 );
2517 }
2518 other => panic!("expected plural translation, got {other:?}"),
2519 }
2520 }
2521
2522 #[test]
2523 fn parse_catalog_prefers_gettext_slot_count_when_it_disagrees_with_locale_categories() {
2524 let parsed = parse_catalog(ParseCatalogOptions {
2525 content: concat!(
2526 "msgid \"\"\n",
2527 "msgstr \"\"\n",
2528 "\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n",
2529 "\n",
2530 "msgid \"livre\"\n",
2531 "msgid_plural \"livres\"\n",
2532 "msgstr[0] \"livre\"\n",
2533 "msgstr[1] \"livres\"\n",
2534 )
2535 .to_owned(),
2536 locale: Some("fr".to_owned()),
2537 source_locale: "en".to_owned(),
2538 plural_encoding: PluralEncoding::Gettext,
2539 strict: false,
2540 })
2541 .expect("parse");
2542
2543 match &parsed.messages[0].translation {
2544 TranslationShape::Plural { translation, .. } => {
2545 assert_eq!(translation.len(), 2);
2546 assert_eq!(translation.get("one").map(String::as_str), Some("livre"));
2547 assert_eq!(translation.get("other").map(String::as_str), Some("livres"));
2548 assert!(translation.get("many").is_none());
2549 }
2550 other => panic!("expected plural translation, got {other:?}"),
2551 }
2552 }
2553
2554 #[test]
2555 fn parse_catalog_reports_plural_forms_locale_mismatch() {
2556 let parsed = parse_catalog(ParseCatalogOptions {
2557 content: concat!(
2558 "msgid \"\"\n",
2559 "msgstr \"\"\n",
2560 "\"Language: fr\\n\"\n",
2561 "\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"\n",
2562 )
2563 .to_owned(),
2564 locale: Some("fr".to_owned()),
2565 source_locale: "en".to_owned(),
2566 plural_encoding: PluralEncoding::Gettext,
2567 strict: false,
2568 })
2569 .expect("parse");
2570
2571 assert!(
2572 parsed
2573 .diagnostics
2574 .iter()
2575 .any(|diagnostic| diagnostic.code == "plural.nplurals_locale_mismatch")
2576 );
2577 }
2578
2579 #[test]
2580 fn parse_catalog_detects_simple_icu_plural_when_requested() {
2581 let parsed = parse_catalog(ParseCatalogOptions {
2582 content: concat!(
2583 "msgid \"{count, plural, one {# item} other {# items}}\"\n",
2584 "msgstr \"{count, plural, one {# Artikel} other {# Artikel}}\"\n",
2585 )
2586 .to_owned(),
2587 locale: Some("de".to_owned()),
2588 source_locale: "en".to_owned(),
2589 plural_encoding: PluralEncoding::Icu,
2590 strict: false,
2591 })
2592 .expect("parse");
2593
2594 match &parsed.messages[0].translation {
2595 TranslationShape::Plural { translation, .. } => {
2596 assert_eq!(
2597 translation.get("one").map(String::as_str),
2598 Some("# Artikel")
2599 );
2600 assert_eq!(
2601 translation.get("other").map(String::as_str),
2602 Some("# Artikel")
2603 );
2604 }
2605 other => panic!("expected plural translation, got {other:?}"),
2606 }
2607 }
2608
2609 #[test]
2610 fn parse_catalog_warns_and_falls_back_for_unsupported_nested_icu_plural() {
2611 let parsed = parse_catalog(ParseCatalogOptions {
2612 content: concat!(
2613 "msgid \"{count, plural, one {{gender, select, male {He has one item} other {They have one item}}} other {{gender, select, male {He has # items} other {They have # items}}}}\"\n",
2614 "msgstr \"{count, plural, one {{gender, select, male {Er hat einen Artikel} other {Sie haben einen Artikel}}} other {{gender, select, male {Er hat # Artikel} other {Sie haben # Artikel}}}}\"\n",
2615 )
2616 .to_owned(),
2617 locale: Some("de".to_owned()),
2618 source_locale: "en".to_owned(),
2619 plural_encoding: PluralEncoding::Icu,
2620 strict: false,
2621 })
2622 .expect("parse");
2623
2624 assert!(
2625 parsed
2626 .diagnostics
2627 .iter()
2628 .any(|diagnostic| diagnostic.code == "plural.unsupported_icu_projection")
2629 );
2630 assert!(matches!(
2631 parsed.messages[0].translation,
2632 TranslationShape::Singular { .. }
2633 ));
2634 }
2635
2636 #[test]
2637 fn parse_catalog_strict_fails_on_malformed_icu_plural() {
2638 let error = parse_catalog(ParseCatalogOptions {
2639 content: concat!(
2640 "msgid \"{count, plural, one {# item} other {# items}\"\n",
2641 "msgstr \"{count, plural, one {# Artikel} other {# Artikel}}\"\n",
2642 )
2643 .to_owned(),
2644 locale: Some("de".to_owned()),
2645 source_locale: "en".to_owned(),
2646 plural_encoding: PluralEncoding::Icu,
2647 strict: true,
2648 })
2649 .expect_err("strict parse should fail");
2650
2651 match error {
2652 ApiError::Unsupported(message) => assert!(message.contains("strict mode")),
2653 other => panic!("unexpected error: {other:?}"),
2654 }
2655 }
2656
2657 #[test]
2658 fn update_catalog_file_writes_only_when_changed() {
2659 let temp_dir = std::env::temp_dir().join("ferrocat-po-update-file-test");
2660 let _ = fs::remove_dir_all(&temp_dir);
2661 fs::create_dir_all(&temp_dir).expect("create temp dir");
2662 let path = temp_dir.join("messages.po");
2663
2664 let first = update_catalog_file(UpdateCatalogFileOptions {
2665 target_path: path.clone(),
2666 source_locale: "en".to_owned(),
2667 locale: Some("en".to_owned()),
2668 input: structured_input(vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2669 msgid: "Hello".to_owned(),
2670 ..ExtractedSingularMessage::default()
2671 })]),
2672 ..UpdateCatalogFileOptions::default()
2673 })
2674 .expect("first write");
2675 assert!(first.created);
2676
2677 let second = update_catalog_file(UpdateCatalogFileOptions {
2678 target_path: path.clone(),
2679 source_locale: "en".to_owned(),
2680 locale: Some("en".to_owned()),
2681 input: structured_input(vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2682 msgid: "Hello".to_owned(),
2683 ..ExtractedSingularMessage::default()
2684 })]),
2685 ..UpdateCatalogFileOptions::default()
2686 })
2687 .expect("second write");
2688 assert!(!second.created);
2689 assert!(!second.updated);
2690
2691 let _ = fs::remove_dir_all(&temp_dir);
2692 }
2693
2694 #[test]
2695 fn update_catalog_gettext_export_emits_plural_slots() {
2696 let result = update_catalog(UpdateCatalogOptions {
2697 source_locale: "en".to_owned(),
2698 locale: Some("de".to_owned()),
2699 plural_encoding: PluralEncoding::Gettext,
2700 input: structured_input(vec![ExtractedMessage::Plural(ExtractedPluralMessage {
2701 msgid: "books".to_owned(),
2702 source: PluralSource {
2703 one: Some("book".to_owned()),
2704 other: "books".to_owned(),
2705 },
2706 placeholders: BTreeMap::from([("count".to_owned(), vec!["count".to_owned()])]),
2707 ..ExtractedPluralMessage::default()
2708 })]),
2709 ..UpdateCatalogOptions::default()
2710 })
2711 .expect("update");
2712
2713 let parsed = parse_po(&result.content).expect("parse output");
2714 assert_eq!(parsed.items[0].msgid, "book");
2715 assert_eq!(parsed.items[0].msgid_plural.as_deref(), Some("books"));
2716 assert_eq!(parsed.items[0].msgstr.len(), 2);
2717 }
2718
2719 #[test]
2720 fn update_catalog_gettext_export_uses_icu_plural_categories_for_french() {
2721 let result = update_catalog(UpdateCatalogOptions {
2722 source_locale: "en".to_owned(),
2723 locale: Some("fr".to_owned()),
2724 plural_encoding: PluralEncoding::Gettext,
2725 input: structured_input(vec![ExtractedMessage::Plural(ExtractedPluralMessage {
2726 msgid: "files".to_owned(),
2727 source: PluralSource {
2728 one: Some("file".to_owned()),
2729 other: "files".to_owned(),
2730 },
2731 placeholders: BTreeMap::from([("count".to_owned(), vec!["count".to_owned()])]),
2732 ..ExtractedPluralMessage::default()
2733 })]),
2734 ..UpdateCatalogOptions::default()
2735 })
2736 .expect("update");
2737
2738 let parsed = parse_po(&result.content).expect("parse output");
2739 assert_eq!(parsed.items[0].msgstr.len(), 3);
2740 }
2741
2742 #[test]
2743 fn update_catalog_gettext_sets_safe_plural_forms_header_for_two_form_locale() {
2744 let result = update_catalog(UpdateCatalogOptions {
2745 source_locale: "en".to_owned(),
2746 locale: Some("de".to_owned()),
2747 plural_encoding: PluralEncoding::Gettext,
2748 input: structured_input(vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2749 msgid: "Hello".to_owned(),
2750 ..ExtractedSingularMessage::default()
2751 })]),
2752 ..UpdateCatalogOptions::default()
2753 })
2754 .expect("update");
2755
2756 let parsed = parse_po(&result.content).expect("parse output");
2757 let plural_forms = parsed
2758 .headers
2759 .iter()
2760 .find(|header| header.key == "Plural-Forms")
2761 .map(|header| header.value.as_str());
2762 assert_eq!(plural_forms, Some("nplurals=2; plural=(n != 1);"));
2763 }
2764
2765 #[test]
2766 fn update_catalog_gettext_reports_when_no_safe_plural_forms_header_is_known() {
2767 let result = update_catalog(UpdateCatalogOptions {
2768 source_locale: "en".to_owned(),
2769 locale: Some("fr".to_owned()),
2770 plural_encoding: PluralEncoding::Gettext,
2771 input: structured_input(vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2772 msgid: "Bonjour".to_owned(),
2773 ..ExtractedSingularMessage::default()
2774 })]),
2775 ..UpdateCatalogOptions::default()
2776 })
2777 .expect("update");
2778
2779 assert!(
2780 result
2781 .diagnostics
2782 .iter()
2783 .any(|diagnostic| diagnostic.code == "plural.missing_plural_forms_header")
2784 );
2785 }
2786
2787 #[test]
2788 fn update_catalog_gettext_completes_partial_plural_forms_header_when_safe() {
2789 let result = update_catalog(UpdateCatalogOptions {
2790 source_locale: "en".to_owned(),
2791 locale: Some("de".to_owned()),
2792 plural_encoding: PluralEncoding::Gettext,
2793 existing: Some(
2794 concat!(
2795 "msgid \"\"\n",
2796 "msgstr \"\"\n",
2797 "\"Language: de\\n\"\n",
2798 "\"Plural-Forms: nplurals=2;\\n\"\n",
2799 )
2800 .to_owned(),
2801 ),
2802 input: structured_input(vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2803 msgid: "Hello".to_owned(),
2804 ..ExtractedSingularMessage::default()
2805 })]),
2806 ..UpdateCatalogOptions::default()
2807 })
2808 .expect("update");
2809
2810 assert!(
2811 result
2812 .diagnostics
2813 .iter()
2814 .any(|diagnostic| diagnostic.code == "plural.completed_plural_forms_header")
2815 );
2816
2817 let parsed = parse_po(&result.content).expect("parse output");
2818 let plural_forms = parsed
2819 .headers
2820 .iter()
2821 .find(|header| header.key == "Plural-Forms")
2822 .map(|header| header.value.as_str());
2823 assert_eq!(plural_forms, Some("nplurals=2; plural=(n != 1);"));
2824 }
2825
2826 #[test]
2827 fn update_catalog_gettext_preserves_existing_complete_plural_forms_header() {
2828 let result = update_catalog(UpdateCatalogOptions {
2829 source_locale: "en".to_owned(),
2830 locale: Some("de".to_owned()),
2831 plural_encoding: PluralEncoding::Gettext,
2832 existing: Some(
2833 concat!(
2834 "msgid \"\"\n",
2835 "msgstr \"\"\n",
2836 "\"Language: de\\n\"\n",
2837 "\"Plural-Forms: nplurals=2; plural=(n > 1);\\n\"\n",
2838 )
2839 .to_owned(),
2840 ),
2841 input: structured_input(vec![ExtractedMessage::Singular(ExtractedSingularMessage {
2842 msgid: "Hello".to_owned(),
2843 ..ExtractedSingularMessage::default()
2844 })]),
2845 ..UpdateCatalogOptions::default()
2846 })
2847 .expect("update");
2848
2849 assert!(
2850 !result
2851 .diagnostics
2852 .iter()
2853 .any(|diagnostic| diagnostic.code == "plural.completed_plural_forms_header")
2854 );
2855
2856 let parsed = parse_po(&result.content).expect("parse output");
2857 let plural_forms = parsed
2858 .headers
2859 .iter()
2860 .find(|header| header.key == "Plural-Forms")
2861 .map(|header| header.value.as_str());
2862 assert_eq!(plural_forms, Some("nplurals=2; plural=(n > 1);"));
2863 }
2864
2865 #[test]
2866 fn parse_catalog_requires_source_locale() {
2867 let error = parse_catalog(ParseCatalogOptions {
2868 content: String::new(),
2869 source_locale: String::new(),
2870 ..ParseCatalogOptions::default()
2871 })
2872 .expect_err("missing source locale");
2873
2874 match error {
2875 ApiError::InvalidArguments(message) => {
2876 assert!(message.contains("source_locale"));
2877 }
2878 other => panic!("unexpected error: {other:?}"),
2879 }
2880 }
2881
2882 #[test]
2883 fn warnings_use_expected_namespace() {
2884 let mut placeholders = BTreeMap::new();
2885 placeholders.insert("first".to_owned(), vec!["first".to_owned()]);
2886 placeholders.insert("second".to_owned(), vec!["second".to_owned()]);
2887
2888 let result = update_catalog(UpdateCatalogOptions {
2889 source_locale: "en".to_owned(),
2890 locale: Some("de".to_owned()),
2891 input: structured_input(vec![ExtractedMessage::Plural(ExtractedPluralMessage {
2892 msgid: "Developers".to_owned(),
2893 source: PluralSource {
2894 one: Some("Developer".to_owned()),
2895 other: "Developers".to_owned(),
2896 },
2897 placeholders,
2898 ..ExtractedPluralMessage::default()
2899 })]),
2900 ..UpdateCatalogOptions::default()
2901 })
2902 .expect("update");
2903
2904 assert!(
2905 result
2906 .diagnostics
2907 .iter()
2908 .any(|diagnostic| diagnostic.code.starts_with("plural."))
2909 );
2910 assert!(result.diagnostics.iter().all(|diagnostic| matches!(
2911 diagnostic.severity,
2912 DiagnosticSeverity::Warning | DiagnosticSeverity::Error | DiagnosticSeverity::Info
2913 )));
2914 }
2915}