Skip to main content

ferrocat_po/
api.rs

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}