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