Skip to main content

ferrocat_po/api/
types.rs

1use std::collections::{BTreeMap, btree_map};
2use std::fmt;
3use std::hash::{Hash, Hasher};
4use std::path::{Path, PathBuf};
5
6use rustc_hash::{FxHashMap, FxHasher};
7
8use crate::{ParseError, PoVec, diagnostic_codes::DiagnosticCode};
9
10use super::mt::MachineMetadata;
11use super::plural::PluralProfile;
12
13#[cfg(feature = "serde")]
14use serde::{Deserialize, Serialize};
15
16/// Source origin metadata for an extracted message.
17#[derive(Debug, Clone, PartialEq, Eq, Default)]
18#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
19#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
20pub struct CatalogOrigin {
21    /// Path-like source file identifier where the message came from.
22    ///
23    /// Ferrocat intentionally tracks no line number: line numbers shift on every
24    /// edit above a message and add diff and merge churn without identifying
25    /// anything the `(msgid, msgctxt)` key does not already.
26    pub file: String,
27    /// Optional stable scope within the file, such as the enclosing component,
28    /// function, class, route handler, or similar named authoring unit.
29    ///
30    /// Unlike a line number it survives edits, so it adds context for
31    /// translators and tools without churn. It is metadata, not message
32    /// identity, and it is not a replacement for gettext context / `msgctxt`.
33    /// Producers (e.g. extractors) fill it with values like `CheckoutButton`,
34    /// `formatInvoiceStatus`, or `SettingsPage`; serialized with the file as
35    /// `file#scope`.
36    pub scope: Option<String>,
37}
38
39/// Structured singular message input used by catalog update operations.
40#[derive(Debug, Clone, PartialEq, Eq, Default)]
41pub struct ExtractedSingularMessage {
42    /// Source message identifier.
43    pub msgid: String,
44    /// Optional gettext message context.
45    pub msgctxt: Option<String>,
46    /// Extracted comments that should become translator-facing guidance.
47    pub comments: Vec<String>,
48    /// Source locations collected by the extractor.
49    pub origin: Vec<CatalogOrigin>,
50    /// Placeholder hints keyed by placeholder name.
51    pub placeholders: BTreeMap<String, Vec<String>>,
52}
53
54/// Source-side plural forms for structured catalog messages.
55#[derive(Debug, Clone, PartialEq, Eq, Default)]
56#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
57#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
58pub struct PluralSource {
59    /// Singular source form, when one exists separately from `other`.
60    pub one: Option<String>,
61    /// Required plural catch-all source form.
62    pub other: String,
63}
64
65/// Structured plural message input used by catalog update operations.
66#[derive(Debug, Clone, PartialEq, Eq, Default)]
67pub struct ExtractedPluralMessage {
68    /// Stable source identifier for the message family.
69    pub msgid: String,
70    /// Optional gettext message context.
71    pub msgctxt: Option<String>,
72    /// Structured source-side plural forms.
73    pub source: PluralSource,
74    /// Extracted comments that should become translator-facing guidance.
75    pub comments: Vec<String>,
76    /// Source locations collected by the extractor.
77    pub origin: Vec<CatalogOrigin>,
78    /// Placeholder hints keyed by placeholder name.
79    pub placeholders: BTreeMap<String, Vec<String>>,
80}
81
82/// Structured extractor input accepted by [`super::update_catalog`] and [`super::update_catalog_file`].
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ExtractedMessage {
85    /// Message that has a single source/translation value.
86    Singular(ExtractedSingularMessage),
87    /// Message that carries structured plural source forms.
88    Plural(ExtractedPluralMessage),
89}
90
91/// Source-first extractor input that lets `ferrocat` infer plural structure.
92#[derive(Debug, Clone, PartialEq, Eq, Default)]
93pub struct SourceExtractedMessage {
94    /// Source message text used both as identifier and source value.
95    pub msgid: String,
96    /// Optional gettext message context.
97    pub msgctxt: Option<String>,
98    /// Extracted comments that should become translator-facing guidance.
99    pub comments: Vec<String>,
100    /// Source locations collected by the extractor.
101    pub origin: Vec<CatalogOrigin>,
102    /// Placeholder hints keyed by placeholder name.
103    pub placeholders: BTreeMap<String, Vec<String>>,
104}
105
106/// Input payload accepted by catalog update operations.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum CatalogUpdateInput {
109    /// Pre-projected singular/plural messages.
110    Structured(Vec<ExtractedMessage>),
111    /// Source-first messages that let `ferrocat` infer plural structure.
112    SourceFirst(Vec<SourceExtractedMessage>),
113}
114
115impl Default for CatalogUpdateInput {
116    fn default() -> Self {
117        Self::Structured(Vec::new())
118    }
119}
120
121impl From<Vec<ExtractedMessage>> for CatalogUpdateInput {
122    fn from(value: Vec<ExtractedMessage>) -> Self {
123        Self::Structured(value)
124    }
125}
126
127impl From<Vec<SourceExtractedMessage>> for CatalogUpdateInput {
128    fn from(value: Vec<SourceExtractedMessage>) -> Self {
129        Self::SourceFirst(value)
130    }
131}
132
133/// Public translation shape returned from parsed catalogs.
134#[derive(Debug, Clone, PartialEq, Eq)]
135#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
136#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
137pub enum TranslationShape {
138    /// Message represented by a single string value.
139    Singular {
140        /// The current translation value.
141        value: String,
142    },
143    /// Message represented by structured plural categories.
144    Plural {
145        /// Source-side plural forms.
146        source: PluralSource,
147        /// Translation values keyed by plural category.
148        translation: BTreeMap<String, String>,
149        /// Variable name used when re-synthesizing ICU plural strings.
150        variable: String,
151    },
152}
153
154/// Borrowed view over a message translation.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum EffectiveTranslationRef<'a> {
157    /// Singular translation borrowed from the parsed catalog.
158    Singular(&'a str),
159    /// Plural translation borrowed from the parsed catalog.
160    Plural(&'a BTreeMap<String, String>),
161}
162
163/// Owned translation value materialized from a parsed catalog.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum EffectiveTranslation {
166    /// Singular translation value.
167    Singular(String),
168    /// Plural translation values keyed by category.
169    Plural(BTreeMap<String, String>),
170}
171
172/// Public message representation returned by [`super::parse_catalog`].
173///
174/// Not `Eq`: AI confidence is a float ([`MachineMetadata`]).
175#[derive(Debug, Clone, PartialEq)]
176#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
177#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
178pub struct CatalogMessage {
179    /// Source message identifier.
180    pub msgid: String,
181    /// Optional gettext message context.
182    pub msgctxt: Option<String>,
183    /// Public translation representation.
184    pub translation: TranslationShape,
185    /// Free-text notes for translators (developer- or translator-provided).
186    pub comments: Vec<String>,
187    /// Source origins preserved from PO references.
188    pub origin: PoVec<CatalogOrigin>,
189    /// Obsolete state: `None` when active, `Some` when the message is obsolete
190    /// (with an optional write-once `since` date, see [`ObsoleteInfo`]).
191    pub obsolete: Option<ObsoleteInfo>,
192    /// Optional metadata when the current value is machine-managed (see
193    /// [`MachineMetadata`]).
194    pub machine: Option<MachineMetadata>,
195}
196
197/// Obsolete-entry payload. Presence on [`CatalogMessage::obsolete`] marks the
198/// entry obsolete; `since` records when it became obsolete so hosts can
199/// age-cleanup with [`ObsoleteStrategy::DropObsoleteBefore`].
200#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)]
201#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
202#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
203pub struct ObsoleteInfo {
204    /// Write-once ISO-8601 date the entry became obsolete, set from the host's
205    /// injected clock. `None` when the obsolescence date is unknown.
206    pub since: Option<String>,
207}
208
209impl CatalogMessage {
210    /// Returns the lookup key for this message.
211    #[must_use]
212    pub fn key(&self) -> CatalogMessageKey {
213        CatalogMessageKey {
214            msgid: self.msgid.clone(),
215            msgctxt: self.msgctxt.clone(),
216        }
217    }
218
219    /// Returns the effective translation without source-locale fallback.
220    #[must_use]
221    pub fn effective_translation(&self) -> EffectiveTranslationRef<'_> {
222        match &self.translation {
223            TranslationShape::Singular { value } => EffectiveTranslationRef::Singular(value),
224            TranslationShape::Plural { translation, .. } => {
225                EffectiveTranslationRef::Plural(translation)
226            }
227        }
228    }
229
230    pub(super) fn effective_translation_owned(&self) -> EffectiveTranslation {
231        match &self.translation {
232            TranslationShape::Singular { value } => EffectiveTranslation::Singular(value.clone()),
233            TranslationShape::Plural { translation, .. } => {
234                EffectiveTranslation::Plural(translation.clone())
235            }
236        }
237    }
238
239    /// Applies the source-locale fallback semantics used by compilation and
240    /// runtime artifact generation.
241    ///
242    /// Singular messages fall back to `msgid` when empty. Plural messages keep
243    /// their category shape and only fill categories that are missing or empty.
244    pub(super) fn source_fallback_translation(&self, locale: Option<&str>) -> EffectiveTranslation {
245        match &self.translation {
246            TranslationShape::Singular { value } => {
247                if value.is_empty() {
248                    EffectiveTranslation::Singular(self.msgid.clone())
249                } else {
250                    EffectiveTranslation::Singular(value.clone())
251                }
252            }
253            TranslationShape::Plural {
254                source,
255                translation,
256                ..
257            } => {
258                let profile = PluralProfile::for_locale(locale);
259                let mut effective = profile.materialize_translation(translation);
260                for category in profile.categories() {
261                    let should_fill = effective.get(category).is_none_or(String::is_empty);
262                    if should_fill {
263                        effective.insert(
264                            category.clone(),
265                            profile.source_locale_value(category, source),
266                        );
267                    }
268                }
269                EffectiveTranslation::Plural(effective)
270            }
271        }
272    }
273}
274
275/// Stable lookup key for catalog messages.
276#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
277#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
278#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
279pub struct CatalogMessageKey {
280    /// Source message identifier.
281    pub msgid: String,
282    /// Optional gettext message context.
283    pub msgctxt: Option<String>,
284}
285
286impl CatalogMessageKey {
287    /// Creates a message key from `msgid` and optional context.
288    #[must_use]
289    pub fn new(msgid: impl Into<String>, msgctxt: Option<String>) -> Self {
290        Self {
291            msgid: msgid.into(),
292            msgctxt,
293        }
294    }
295
296    /// Creates a message key from `msgid` and context.
297    ///
298    /// This is equivalent to `CatalogMessageKey::new(msgid, Some(msgctxt.into()))`
299    /// but lets callers pass borrowed context strings without spelling the
300    /// intermediate `Some(String)`.
301    #[must_use]
302    pub fn with_context(msgid: impl Into<String>, msgctxt: impl Into<String>) -> Self {
303        Self {
304            msgid: msgid.into(),
305            msgctxt: Some(msgctxt.into()),
306        }
307    }
308}
309
310/// Severity level attached to a [`Diagnostic`].
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
313#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
314pub enum DiagnosticSeverity {
315    /// Informational message that does not indicate a problem.
316    Info,
317    /// Non-fatal condition that may require user attention.
318    Warning,
319    /// Serious condition associated with invalid input or unsupported output.
320    Error,
321}
322
323/// Non-fatal issue collected while parsing or updating catalogs.
324#[derive(Debug, Clone, PartialEq, Eq)]
325#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
326#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
327pub struct Diagnostic {
328    /// Severity level for the diagnostic.
329    pub severity: DiagnosticSeverity,
330    /// Stable machine-readable code for the diagnostic.
331    pub code: DiagnosticCode,
332    /// Human-readable explanation of the condition.
333    pub message: String,
334    /// Source `msgid`, when the diagnostic can be tied to one message.
335    pub msgid: Option<String>,
336    /// Source `msgctxt`, when the diagnostic can be tied to one message.
337    pub msgctxt: Option<String>,
338}
339
340impl Diagnostic {
341    pub(super) fn new(
342        severity: DiagnosticSeverity,
343        code: impl Into<DiagnosticCode>,
344        message: impl Into<String>,
345    ) -> Self {
346        Self {
347            severity,
348            code: code.into(),
349            message: message.into(),
350            msgid: None,
351            msgctxt: None,
352        }
353    }
354
355    pub(super) fn with_identity(mut self, msgid: &str, msgctxt: Option<&str>) -> Self {
356        self.msgid = Some(msgid.to_owned());
357        self.msgctxt = msgctxt.map(str::to_owned);
358        self
359    }
360}
361
362/// Basic counters describing an update operation.
363#[derive(Debug, Clone, PartialEq, Eq, Default)]
364pub struct CatalogStats {
365    /// Total messages in the final catalog.
366    pub total: usize,
367    /// Messages added during the update.
368    pub added: usize,
369    /// Existing messages whose rendered representation changed.
370    pub changed: usize,
371    /// Existing messages preserved without changes.
372    pub unchanged: usize,
373    /// Messages newly marked obsolete.
374    pub obsolete_marked: usize,
375    /// Messages removed because the obsolete strategy deleted them.
376    pub obsolete_removed: usize,
377}
378
379/// Result returned by catalog update operations.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct CatalogUpdateResult {
382    /// Final PO content after applying the update.
383    pub content: String,
384    /// Whether the update created a new catalog from scratch.
385    pub created: bool,
386    /// Whether the final content differs from the original input.
387    pub updated: bool,
388    /// Summary counters for the operation.
389    pub stats: CatalogStats,
390    /// Non-fatal diagnostics collected during processing.
391    pub diagnostics: Vec<Diagnostic>,
392}
393
394/// One catalog input passed to [`super::combine_catalogs`].
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub struct CatalogCombineInput<'a> {
397    /// Catalog content to parse and include in the combine operation.
398    pub content: &'a str,
399    /// Optional human-readable label used in diagnostics.
400    pub label: Option<&'a str>,
401}
402
403impl<'a> CatalogCombineInput<'a> {
404    /// Creates a combine input without a diagnostic label.
405    #[must_use]
406    pub const fn new(content: &'a str) -> Self {
407        Self {
408            content,
409            label: None,
410        }
411    }
412
413    /// Creates a combine input with a diagnostic label.
414    #[must_use]
415    pub const fn labeled(content: &'a str, label: &'a str) -> Self {
416        Self {
417            content,
418            label: Some(label),
419        }
420    }
421}
422
423/// Strategy used when multiple catalogs define conflicting translations for one identity.
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
425pub enum CatalogConflictStrategy {
426    /// Keep the first non-empty translation encountered for each `msgid`/`msgctxt`.
427    #[default]
428    UseFirst,
429    /// Replace the current non-empty translation with the latest non-empty definition.
430    UseLast,
431    /// Return an error when two non-empty translations differ.
432    Error,
433}
434
435/// Selection rule used after definitions from all inputs have been counted.
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
437pub enum CatalogCombineSelection {
438    /// Keep every message identity.
439    #[default]
440    All,
441    /// Keep identities with more than the provided number of definitions.
442    MoreThan(usize),
443    /// Keep identities with less than the provided number of definitions.
444    LessThan(usize),
445    /// Keep identities defined only once.
446    Unique,
447}
448
449impl CatalogCombineSelection {
450    pub(super) const fn includes(self, definitions: usize) -> bool {
451        match self {
452            Self::All => true,
453            Self::MoreThan(limit) => definitions > limit,
454            Self::LessThan(limit) => definitions < limit,
455            Self::Unique => definitions < 2,
456        }
457    }
458}
459
460/// Basic counters describing a catalog combine operation.
461#[derive(Debug, Clone, PartialEq, Eq, Default)]
462pub struct CatalogCombineStats {
463    /// Number of input catalogs parsed.
464    pub inputs: usize,
465    /// Total message definitions considered after obsolete filtering.
466    pub definitions: usize,
467    /// Message identities written to the final catalog.
468    pub selected: usize,
469    /// Message identities removed by the selection rule.
470    pub skipped: usize,
471    /// Translation conflicts resolved according to the selected strategy.
472    pub conflicts_resolved: usize,
473    /// Total messages in the final catalog.
474    pub total: usize,
475}
476
477/// Result returned by catalog combine operations.
478#[derive(Debug, Clone, PartialEq, Eq)]
479pub struct CatalogCombineResult {
480    /// Final catalog content after combining the inputs.
481    pub content: String,
482    /// Summary counters for the operation.
483    pub stats: CatalogCombineStats,
484    /// Non-fatal diagnostics collected during processing.
485    pub diagnostics: Vec<Diagnostic>,
486}
487
488/// File format used by disk-based catalog combine operations.
489///
490/// This enum is non-exhaustive because Ferrocat can add additional catalog
491/// file formats over time.
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
493#[non_exhaustive]
494pub enum CatalogFileFormat {
495    /// Classic gettext PO catalog files, including gettext template (`.pot`) files.
496    #[default]
497    Po,
498    /// Ferrocat Catalog Lines (`.fcl`) files.
499    Fcl,
500}
501
502impl CatalogFileFormat {
503    /// Infers a catalog file format from a path extension.
504    ///
505    /// Supported path suffixes are `.po`, `.pot`, and `.fcl`.
506    ///
507    /// # Errors
508    ///
509    /// Returns [`ApiError::Unsupported`] when the path suffix does not map to a
510    /// supported catalog file format.
511    pub fn infer_from_path(path: &Path) -> Result<Self, ApiError> {
512        let name = path
513            .file_name()
514            .and_then(|name| name.to_str())
515            .unwrap_or_default()
516            .to_ascii_lowercase();
517
518        if name.ends_with(".po") || name.ends_with(".pot") {
519            return Ok(Self::Po);
520        }
521        if name.ends_with(".fcl") {
522            return Ok(Self::Fcl);
523        }
524
525        Err(ApiError::Unsupported(format!(
526            "could not infer catalog file format from `{}`; expected .po, .pot, or .fcl",
527            path.display()
528        )))
529    }
530
531    pub(super) const fn default_mode(self) -> CatalogMode {
532        match self {
533            Self::Po => CatalogMode::IcuPo,
534            Self::Fcl => CatalogMode::IcuFcl,
535        }
536    }
537}
538
539/// Options for combining catalog files on disk.
540#[derive(Debug, Clone, PartialEq, Eq)]
541#[non_exhaustive]
542pub struct CombineCatalogFilesOptions<'a> {
543    /// Input catalog paths in precedence order.
544    pub input_paths: &'a [PathBuf],
545    /// Output catalog path to atomically replace after a successful combine.
546    pub output_path: &'a Path,
547    /// Optional explicit file format. When `None`, Ferrocat infers it from the
548    /// input and output paths and requires all inferred formats to match.
549    pub format: Option<CatalogFileFormat>,
550    /// Optional high-level catalog mode. When `None`, Ferrocat chooses
551    /// `CatalogMode::IcuPo` for PO files and `CatalogMode::IcuFcl` for FCL files.
552    pub mode: Option<CatalogMode>,
553    /// Locale of the combined catalog. When `None`, Ferrocat uses the first input locale if present.
554    pub locale: Option<&'a str>,
555    /// Source locale used for source-side semantics and validation.
556    pub source_locale: &'a str,
557    /// Strategy for resolving conflicting non-empty translations.
558    /// Empty template translations never clear non-empty values.
559    pub conflict_strategy: CatalogConflictStrategy,
560    /// Message identity selection rule applied after all inputs are read.
561    pub selection: CatalogCombineSelection,
562    /// Sort order for the final rendered catalog.
563    pub order_by: OrderBy,
564    /// Whether source origins should be rendered as references.
565    pub include_origins: bool,
566    /// Whether obsolete definitions should participate in the combine operation.
567    pub include_obsolete: bool,
568}
569
570impl<'a> CombineCatalogFilesOptions<'a> {
571    /// Creates file combine options with required fields set.
572    ///
573    /// Optional fields use the same defaults as [`CombineCatalogOptions`].
574    #[must_use]
575    pub fn new(input_paths: &'a [PathBuf], output_path: &'a Path, source_locale: &'a str) -> Self {
576        Self {
577            input_paths,
578            output_path,
579            format: None,
580            mode: None,
581            locale: None,
582            source_locale,
583            conflict_strategy: CatalogConflictStrategy::UseFirst,
584            selection: CatalogCombineSelection::All,
585            order_by: OrderBy::Msgid,
586            include_origins: true,
587            include_obsolete: false,
588        }
589    }
590
591    /// Returns options that use the given input paths.
592    #[must_use]
593    pub fn with_input_paths(mut self, input_paths: &'a [PathBuf]) -> Self {
594        self.input_paths = input_paths;
595        self
596    }
597
598    /// Returns options that atomically replace the given output path.
599    #[must_use]
600    pub fn with_output_path(mut self, output_path: &'a Path) -> Self {
601        self.output_path = output_path;
602        self
603    }
604
605    /// Returns options that use the given explicit file format.
606    #[must_use]
607    pub fn with_format(mut self, format: CatalogFileFormat) -> Self {
608        self.format = Some(format);
609        self
610    }
611
612    /// Returns options that read and render with the given catalog mode.
613    #[must_use]
614    pub fn with_mode(mut self, mode: CatalogMode) -> Self {
615        self.mode = Some(mode);
616        self
617    }
618
619    /// Returns options that use the given combined catalog locale.
620    #[must_use]
621    pub fn with_locale(mut self, locale: &'a str) -> Self {
622        self.locale = Some(locale);
623        self
624    }
625
626    /// Returns options that resolve conflicts with the given strategy.
627    #[must_use]
628    pub fn with_conflict_strategy(mut self, conflict_strategy: CatalogConflictStrategy) -> Self {
629        self.conflict_strategy = conflict_strategy;
630        self
631    }
632
633    /// Returns options that apply the given message selection.
634    #[must_use]
635    pub fn with_selection(mut self, selection: CatalogCombineSelection) -> Self {
636        self.selection = selection;
637        self
638    }
639
640    /// Returns options that render the combined catalog with the given sort order.
641    #[must_use]
642    pub fn with_order_by(mut self, order_by: OrderBy) -> Self {
643        self.order_by = order_by;
644        self
645    }
646
647    /// Returns options that enable or disable rendered source origins.
648    #[must_use]
649    pub fn with_include_origins(mut self, include_origins: bool) -> Self {
650        self.include_origins = include_origins;
651        self
652    }
653
654    /// Returns options that include or skip obsolete definitions.
655    #[must_use]
656    pub fn with_include_obsolete(mut self, include_obsolete: bool) -> Self {
657        self.include_obsolete = include_obsolete;
658        self
659    }
660}
661
662/// Result returned by catalog file combine operations.
663#[derive(Debug, Clone, PartialEq, Eq)]
664pub struct CatalogFileCombineResult {
665    /// Output path replaced by the operation.
666    pub output_path: PathBuf,
667    /// File format used for reading inputs and writing the output.
668    pub format: CatalogFileFormat,
669    /// Summary counters for the operation.
670    pub stats: CatalogCombineStats,
671    /// Non-fatal diagnostics collected during processing.
672    pub diagnostics: Vec<Diagnostic>,
673}
674
675/// Parsed catalog plus diagnostics and normalized headers.
676#[derive(Debug, Clone, PartialEq)]
677pub struct ParsedCatalog {
678    /// Declared or overridden catalog locale.
679    pub locale: Option<String>,
680    /// High-level semantics used to parse the catalog.
681    pub semantics: CatalogSemantics,
682    /// Normalized header map keyed by header name.
683    pub headers: BTreeMap<String, String>,
684    /// Parsed catalog messages in source order.
685    pub messages: Vec<CatalogMessage>,
686    /// Non-fatal diagnostics collected while parsing.
687    pub diagnostics: Vec<Diagnostic>,
688}
689
690impl ParsedCatalog {
691    /// Builds a lookup-oriented view that rejects duplicate message keys.
692    ///
693    /// # Errors
694    ///
695    /// Returns [`ApiError::Conflict`] when the parsed catalog contains
696    /// duplicate `msgid`/`msgctxt` pairs.
697    pub fn into_normalized_view(self) -> Result<NormalizedParsedCatalog, ApiError> {
698        NormalizedParsedCatalog::new(self)
699    }
700}
701
702/// Parsed catalog with fast key-based lookup helpers.
703#[derive(Debug, Clone, PartialEq)]
704pub struct NormalizedParsedCatalog {
705    pub(super) catalog: ParsedCatalog,
706    pub(super) key_index: BTreeMap<CatalogMessageKey, usize>,
707    msgid_hash_index: FxHashMap<u64, Vec<usize>>,
708}
709
710impl NormalizedParsedCatalog {
711    /// Builds the lookup index once and rejects duplicate gettext identities up front.
712    pub(super) fn new(catalog: ParsedCatalog) -> Result<Self, ApiError> {
713        let mut key_index = BTreeMap::new();
714        let mut msgid_hash_index = FxHashMap::<u64, Vec<usize>>::with_capacity_and_hasher(
715            catalog.messages.len(),
716            Default::default(),
717        );
718        for (index, message) in catalog.messages.iter().enumerate() {
719            let key = message.key();
720            match key_index.entry(key) {
721                btree_map::Entry::Vacant(entry) => {
722                    entry.insert(index);
723                }
724                btree_map::Entry::Occupied(entry) => {
725                    let key = entry.key();
726                    return Err(ApiError::Conflict(format!(
727                        "duplicate parsed catalog message for msgid {:?} and context {:?}",
728                        key.msgid, key.msgctxt
729                    )));
730                }
731            }
732            msgid_hash_index
733                .entry(message_id_hash(&message.msgid))
734                .or_default()
735                .push(index);
736        }
737        Ok(Self {
738            catalog,
739            key_index,
740            msgid_hash_index,
741        })
742    }
743
744    /// Returns the underlying parsed catalog.
745    #[must_use]
746    pub const fn parsed_catalog(&self) -> &ParsedCatalog {
747        &self.catalog
748    }
749
750    /// Consumes the normalized view and returns the underlying parsed catalog.
751    #[must_use]
752    pub fn into_parsed_catalog(self) -> ParsedCatalog {
753        self.catalog
754    }
755
756    /// Returns a message by key.
757    #[must_use]
758    pub fn get(&self, key: &CatalogMessageKey) -> Option<&CatalogMessage> {
759        self.key_index
760            .get(key)
761            .map(|index| &self.catalog.messages[*index])
762    }
763
764    /// Returns a message by borrowed `msgid` and optional context parts.
765    ///
766    /// This avoids constructing an owned [`CatalogMessageKey`] when callers
767    /// already have borrowed source identity fields.
768    #[must_use]
769    pub fn get_by_parts(&self, msgid: &str, msgctxt: Option<&str>) -> Option<&CatalogMessage> {
770        self.msgid_hash_index
771            .get(&message_id_hash(msgid))?
772            .iter()
773            .find_map(|index| {
774                let message = &self.catalog.messages[*index];
775                (message.msgid.as_str() == msgid && message.msgctxt.as_deref() == msgctxt)
776                    .then_some(message)
777            })
778    }
779
780    /// Returns `true` if a message for `key` exists.
781    #[must_use]
782    pub fn contains_key(&self, key: &CatalogMessageKey) -> bool {
783        self.key_index.contains_key(key)
784    }
785
786    /// Returns `true` if a message exists for borrowed `msgid` and context parts.
787    #[must_use]
788    pub fn contains_parts(&self, msgid: &str, msgctxt: Option<&str>) -> bool {
789        self.get_by_parts(msgid, msgctxt).is_some()
790    }
791
792    /// Returns the number of indexed messages.
793    #[must_use]
794    pub fn message_count(&self) -> usize {
795        self.catalog.messages.len()
796    }
797
798    /// Iterates over all indexed messages in key order.
799    pub fn iter(&self) -> impl Iterator<Item = (&CatalogMessageKey, &CatalogMessage)> + '_ {
800        self.key_index
801            .iter()
802            .map(|(key, index)| (key, &self.catalog.messages[*index]))
803    }
804
805    /// Returns the effective translation for `key`, if present.
806    pub fn effective_translation(
807        &self,
808        key: &CatalogMessageKey,
809    ) -> Option<EffectiveTranslationRef<'_>> {
810        self.get(key).map(CatalogMessage::effective_translation)
811    }
812
813    /// Returns the effective translation for borrowed `msgid` and context parts.
814    pub fn effective_translation_by_parts(
815        &self,
816        msgid: &str,
817        msgctxt: Option<&str>,
818    ) -> Option<EffectiveTranslationRef<'_>> {
819        self.get_by_parts(msgid, msgctxt)
820            .map(CatalogMessage::effective_translation)
821    }
822
823    /// Returns the effective translation and fills empty source-locale values
824    /// from the source text when appropriate.
825    #[must_use]
826    pub fn effective_translation_with_source_fallback(
827        &self,
828        key: &CatalogMessageKey,
829        source_locale: &str,
830    ) -> Option<EffectiveTranslation> {
831        let message = self.get(key)?;
832        Some(self.effective_translation_for_message(message, source_locale))
833    }
834
835    /// Returns the effective translation for borrowed identity parts and fills
836    /// empty source-locale values from the source text when appropriate.
837    #[must_use]
838    pub fn effective_translation_with_source_fallback_by_parts(
839        &self,
840        msgid: &str,
841        msgctxt: Option<&str>,
842        source_locale: &str,
843    ) -> Option<EffectiveTranslation> {
844        let message = self.get_by_parts(msgid, msgctxt)?;
845        Some(self.effective_translation_for_message(message, source_locale))
846    }
847
848    pub(super) fn effective_translation_for_message(
849        &self,
850        message: &CatalogMessage,
851        source_locale: &str,
852    ) -> EffectiveTranslation {
853        if self
854            .catalog
855            .locale
856            .as_deref()
857            .is_none_or(|locale| locale == source_locale)
858        {
859            message.source_fallback_translation(self.catalog.locale.as_deref())
860        } else {
861            message.effective_translation_owned()
862        }
863    }
864}
865
866fn message_id_hash(msgid: &str) -> u64 {
867    let mut hasher = FxHasher::default();
868    msgid.hash(&mut hasher);
869    hasher.finish()
870}
871
872/// Encoding used for plural messages in PO files.
873#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
874pub enum PluralEncoding {
875    /// Keep plural messages in Ferrocat's structured ICU-oriented representation.
876    #[default]
877    Icu,
878    /// Materialize plural messages as classic gettext `msgid_plural` plus `msgstr[n]`.
879    Gettext,
880}
881
882/// Storage format used by the high-level catalog API.
883///
884/// This enum is non-exhaustive because Ferrocat can add additional catalog
885/// storage formats over time.
886#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
887#[non_exhaustive]
888pub enum CatalogStorageFormat {
889    /// Read and write classic gettext PO catalogs.
890    #[default]
891    Po,
892    /// Read and write Ferrocat Catalog Lines (`.fcl`): a line-oriented,
893    /// git-merge-optimized, machine-owned catalog format.
894    Fcl,
895}
896
897/// High-level semantics used by the catalog API.
898#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
899pub enum CatalogSemantics {
900    /// ICU-native semantics with raw ICU/text messages as the primary representation.
901    #[default]
902    IcuNative,
903    /// Classic gettext plural semantics used for PO compatibility workflows.
904    GettextCompat,
905}
906
907/// ICU parser behavior used by catalog audit and runtime artifact validation.
908#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
909#[non_exhaustive]
910pub enum IcuSyntaxPolicy {
911    /// Parse ICU MessageFormat v1 with Ferrocat's strict apostrophe rules.
912    #[default]
913    Strict,
914    /// Treat ordinary literal apostrophes as runtime-valid text before parsing.
915    ///
916    /// Use this when a downstream runtime accepts messages such as `you're`
917    /// and `We've got {count, plural, one {...} other {...}}` without requiring
918    /// translators to double every literal apostrophe.
919    /// Callers that rely on ICU apostrophe quoting should keep [`Self::Strict`].
920    RuntimeLiteralApostrophes,
921}
922
923/// Valid high-level catalog mode combinations.
924///
925/// This type groups the storage format, semantic model, and plural encoding
926/// choices that must be kept in sync for catalog parse, update, and combine
927/// operations. It is non-exhaustive so future supported catalog modes can be
928/// added without breaking downstream matches.
929#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
930#[non_exhaustive]
931pub enum CatalogMode {
932    /// Gettext PO storage with ICU-native message semantics.
933    #[default]
934    IcuPo,
935    /// Gettext PO storage with classic gettext plural semantics.
936    GettextPo,
937    /// FCL (Ferrocat Catalog Lines) storage with ICU-native message semantics.
938    IcuFcl,
939}
940
941impl CatalogMode {
942    /// Returns the storage format implied by this mode.
943    #[must_use]
944    pub const fn storage_format(self) -> CatalogStorageFormat {
945        match self {
946            Self::IcuPo | Self::GettextPo => CatalogStorageFormat::Po,
947            Self::IcuFcl => CatalogStorageFormat::Fcl,
948        }
949    }
950
951    /// Returns the catalog semantics implied by this mode.
952    #[must_use]
953    pub const fn semantics(self) -> CatalogSemantics {
954        match self {
955            Self::IcuPo | Self::IcuFcl => CatalogSemantics::IcuNative,
956            Self::GettextPo => CatalogSemantics::GettextCompat,
957        }
958    }
959
960    /// Returns the plural encoding implied by this mode.
961    #[must_use]
962    pub const fn plural_encoding(self) -> PluralEncoding {
963        match self {
964            Self::IcuPo | Self::IcuFcl => PluralEncoding::Icu,
965            Self::GettextPo => PluralEncoding::Gettext,
966        }
967    }
968
969    /// Returns the matching catalog mode for explicit parts.
970    #[must_use]
971    pub const fn from_parts(
972        storage_format: CatalogStorageFormat,
973        semantics: CatalogSemantics,
974        plural_encoding: PluralEncoding,
975    ) -> Option<Self> {
976        match (storage_format, semantics, plural_encoding) {
977            (CatalogStorageFormat::Po, CatalogSemantics::IcuNative, PluralEncoding::Icu) => {
978                Some(Self::IcuPo)
979            }
980            (CatalogStorageFormat::Fcl, CatalogSemantics::IcuNative, PluralEncoding::Icu) => {
981                Some(Self::IcuFcl)
982            }
983            (
984                CatalogStorageFormat::Po,
985                CatalogSemantics::GettextCompat,
986                PluralEncoding::Gettext,
987            ) => Some(Self::GettextPo),
988            _ => None,
989        }
990    }
991}
992
993/// Strategy used for messages that disappear from the extracted input.
994#[derive(Debug, Clone, PartialEq, Eq, Default)]
995pub enum ObsoleteStrategy {
996    /// Mark missing messages obsolete and keep them in the file.
997    #[default]
998    Mark,
999    /// Remove missing messages entirely.
1000    Delete,
1001    /// Keep missing messages as active entries.
1002    Keep,
1003    /// Mark missing messages obsolete (like [`ObsoleteStrategy::Mark`]) and
1004    /// additionally drop any obsolete entry whose `since` date predates the given
1005    /// ISO-8601 cutoff. Undated obsolete entries are kept. The host computes the
1006    /// cutoff (e.g. today minus 90 days); ISO dates compare lexicographically, so
1007    /// no date arithmetic happens inside Ferrocat. See
1008    /// [ADR 0025](https://ferrocat.dev/architecture/adr/0025-obsolete-age-and-cleanup).
1009    DropObsoleteBefore(String),
1010}
1011
1012/// Sort order used when writing output catalogs.
1013#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1014pub enum OrderBy {
1015    /// Sort by `msgid` then context.
1016    #[default]
1017    Msgid,
1018    /// Sort by the first source origin, then by message identity.
1019    Origin,
1020}
1021
1022/// Controls whether placeholder hints are emitted as extracted comments.
1023#[derive(Debug, Clone, PartialEq, Eq)]
1024pub enum PlaceholderCommentMode {
1025    /// Do not emit placeholder comments.
1026    Disabled,
1027    /// Emit up to `limit` placeholder comments per placeholder name.
1028    Enabled {
1029        /// Maximum number of values rendered per placeholder name.
1030        limit: usize,
1031    },
1032}
1033
1034impl Default for PlaceholderCommentMode {
1035    fn default() -> Self {
1036        Self::Enabled { limit: 3 }
1037    }
1038}
1039
1040/// Shared rendering options for catalog serialization.
1041///
1042/// These fields control how a catalog is sorted and which optional reference and
1043/// placeholder details are written. Some storage formats still impose their own
1044/// invariants: FCL always renders in canonical `(id, ctxt)` order, while origin
1045/// and placeholder detail flags apply across supported catalog storage formats.
1046///
1047/// References render the source file only; Ferrocat does not track or emit line
1048/// numbers (see [`CatalogOrigin`]).
1049#[derive(Debug, Clone, PartialEq, Eq)]
1050#[non_exhaustive]
1051pub struct RenderOptions<'a> {
1052    /// Sort order for the final rendered catalog.
1053    pub order_by: OrderBy,
1054    /// Whether source origins should be rendered as references.
1055    pub include_origins: bool,
1056    /// Controls emission of placeholder comments.
1057    pub print_placeholders_in_comments: PlaceholderCommentMode,
1058    /// Optional additional header attributes to inject or override.
1059    pub custom_header_attributes: Option<&'a BTreeMap<String, String>>,
1060}
1061
1062impl Default for RenderOptions<'_> {
1063    fn default() -> Self {
1064        Self {
1065            order_by: OrderBy::Msgid,
1066            include_origins: true,
1067            print_placeholders_in_comments: PlaceholderCommentMode::Enabled { limit: 3 },
1068            custom_header_attributes: None,
1069        }
1070    }
1071}
1072
1073impl<'a> RenderOptions<'a> {
1074    /// Returns options that render messages with the given sort order.
1075    #[must_use]
1076    pub fn with_order_by(mut self, order_by: OrderBy) -> Self {
1077        self.order_by = order_by;
1078        self
1079    }
1080
1081    /// Returns options that enable or disable rendered source origins.
1082    #[must_use]
1083    pub fn with_include_origins(mut self, include_origins: bool) -> Self {
1084        self.include_origins = include_origins;
1085        self
1086    }
1087
1088    /// Returns options that use the given placeholder comment mode.
1089    #[must_use]
1090    pub fn with_placeholder_comments(
1091        mut self,
1092        print_placeholders_in_comments: PlaceholderCommentMode,
1093    ) -> Self {
1094        self.print_placeholders_in_comments = print_placeholders_in_comments;
1095        self
1096    }
1097
1098    /// Returns options that inject or override the given header attributes.
1099    #[must_use]
1100    pub fn with_custom_header_attributes(
1101        mut self,
1102        custom_header_attributes: &'a BTreeMap<String, String>,
1103    ) -> Self {
1104        self.custom_header_attributes = Some(custom_header_attributes);
1105        self
1106    }
1107}
1108
1109/// Options for in-memory catalog updates.
1110#[derive(Debug, Clone, PartialEq, Eq)]
1111#[non_exhaustive]
1112pub struct UpdateCatalogOptions<'a> {
1113    /// Locale of the catalog being updated. When `None`, Ferrocat infers it from the existing file.
1114    pub locale: Option<&'a str>,
1115    /// Source locale used for source-side semantics and fallback handling.
1116    pub source_locale: &'a str,
1117    /// Extracted messages to merge into the catalog.
1118    pub input: CatalogUpdateInput,
1119    /// Existing catalog content, when updating an in-memory catalog.
1120    pub existing: Option<&'a str>,
1121    /// High-level catalog mode used when parsing, merging, and rendering the catalog.
1122    pub mode: CatalogMode,
1123    /// Strategy for messages absent from the extracted input.
1124    pub obsolete_strategy: ObsoleteStrategy,
1125    /// Whether source-locale translations should be refreshed from the extracted source strings.
1126    pub overwrite_source_translations: bool,
1127    /// Optional host-provided clock as an ISO-8601 date. When set, entries newly
1128    /// transitioning to obsolete are stamped with this `since` date (write-once).
1129    /// Ferrocat never reads a clock itself, so updates stay deterministic given
1130    /// their inputs. See [ADR 0025](https://ferrocat.dev/architecture/adr/0025-obsolete-age-and-cleanup).
1131    pub now: Option<&'a str>,
1132    /// Shared serialization options for the rendered catalog.
1133    pub render: RenderOptions<'a>,
1134}
1135
1136impl<'a> UpdateCatalogOptions<'a> {
1137    /// Creates in-memory update options with required fields set.
1138    ///
1139    /// Optional fields default to an inferred locale, ICU-native PO mode,
1140    /// marking missing messages obsolete, preserving source-locale translations,
1141    /// no host clock, and [`RenderOptions::default`].
1142    #[must_use]
1143    pub fn new(source_locale: &'a str, input: impl Into<CatalogUpdateInput>) -> Self {
1144        Self {
1145            locale: None,
1146            source_locale,
1147            input: input.into(),
1148            existing: None,
1149            mode: CatalogMode::default(),
1150            obsolete_strategy: ObsoleteStrategy::Mark,
1151            overwrite_source_translations: false,
1152            now: None,
1153            render: RenderOptions::default(),
1154        }
1155    }
1156
1157    /// Returns options that use the given catalog locale.
1158    #[must_use]
1159    pub fn with_locale(mut self, locale: &'a str) -> Self {
1160        self.locale = Some(locale);
1161        self
1162    }
1163
1164    /// Returns options that update the given existing catalog content.
1165    #[must_use]
1166    pub fn with_existing(mut self, existing: &'a str) -> Self {
1167        self.existing = Some(existing);
1168        self
1169    }
1170
1171    /// Returns options that parse, merge, and render with the given catalog mode.
1172    #[must_use]
1173    pub fn with_mode(mut self, mode: CatalogMode) -> Self {
1174        self.mode = mode;
1175        self
1176    }
1177
1178    /// Returns options that render the updated catalog with the given options.
1179    #[must_use]
1180    pub fn with_render(mut self, render: RenderOptions<'a>) -> Self {
1181        self.render = render;
1182        self
1183    }
1184
1185    /// Returns options that handle missing extracted messages with the given strategy.
1186    #[must_use]
1187    pub fn with_obsolete_strategy(mut self, obsolete_strategy: ObsoleteStrategy) -> Self {
1188        self.obsolete_strategy = obsolete_strategy;
1189        self
1190    }
1191
1192    /// Returns options that enable or disable source-locale translation refreshes.
1193    #[must_use]
1194    pub fn with_overwrite_source_translations(
1195        mut self,
1196        overwrite_source_translations: bool,
1197    ) -> Self {
1198        self.overwrite_source_translations = overwrite_source_translations;
1199        self
1200    }
1201
1202    /// Returns options that stamp newly obsolete entries with the given ISO date.
1203    #[must_use]
1204    pub fn with_now(mut self, now: &'a str) -> Self {
1205        self.now = Some(now);
1206        self
1207    }
1208}
1209
1210/// Options for updating a catalog file on disk.
1211#[derive(Debug, Clone, PartialEq, Eq)]
1212#[non_exhaustive]
1213pub struct UpdateCatalogFileOptions<'a> {
1214    /// Path to the catalog file that should be read and conditionally written.
1215    pub target_path: &'a Path,
1216    /// In-memory update options applied to the file content.
1217    pub options: UpdateCatalogOptions<'a>,
1218}
1219
1220impl<'a> UpdateCatalogFileOptions<'a> {
1221    /// Creates file update options with required fields set.
1222    ///
1223    /// Optional fields on the nested update options use
1224    /// [`UpdateCatalogOptions::new`] defaults.
1225    #[must_use]
1226    pub fn new(
1227        target_path: &'a Path,
1228        source_locale: &'a str,
1229        input: impl Into<CatalogUpdateInput>,
1230    ) -> Self {
1231        Self {
1232            target_path,
1233            options: UpdateCatalogOptions::new(source_locale, input),
1234        }
1235    }
1236
1237    /// Returns file update options that use the given in-memory update options.
1238    #[must_use]
1239    pub fn with_options(mut self, options: UpdateCatalogOptions<'a>) -> Self {
1240        self.options = options;
1241        self
1242    }
1243}
1244
1245/// Options for combining multiple catalogs into one deterministic catalog.
1246#[derive(Debug, Clone, PartialEq, Eq)]
1247#[non_exhaustive]
1248pub struct CombineCatalogOptions<'a> {
1249    /// Input catalogs in precedence order.
1250    pub inputs: &'a [CatalogCombineInput<'a>],
1251    /// Locale of the combined catalog. When `None`, Ferrocat uses the first input locale if present.
1252    pub locale: Option<&'a str>,
1253    /// Source locale used for source-side semantics and validation.
1254    pub source_locale: &'a str,
1255    /// High-level catalog mode used when reading inputs and rendering the result.
1256    pub mode: CatalogMode,
1257    /// Strategy for resolving conflicting non-empty translations.
1258    /// Empty template translations never clear non-empty values.
1259    pub conflict_strategy: CatalogConflictStrategy,
1260    /// Message identity selection rule applied after all inputs are read.
1261    pub selection: CatalogCombineSelection,
1262    /// Sort order for the final rendered catalog.
1263    pub order_by: OrderBy,
1264    /// Whether source origins should be rendered as references.
1265    pub include_origins: bool,
1266    /// Whether obsolete definitions should participate in the combine operation.
1267    pub include_obsolete: bool,
1268}
1269
1270impl<'a> CombineCatalogOptions<'a> {
1271    /// Creates combine options with required fields set.
1272    ///
1273    /// Optional fields default to an inferred locale, ICU-native PO mode,
1274    /// first-definition conflict resolution, all message identities, `msgid`
1275    /// ordering, rendered origins, and skipped obsolete entries.
1276    #[must_use]
1277    pub fn new(inputs: &'a [CatalogCombineInput<'a>], source_locale: &'a str) -> Self {
1278        Self {
1279            inputs,
1280            locale: None,
1281            source_locale,
1282            mode: CatalogMode::default(),
1283            conflict_strategy: CatalogConflictStrategy::UseFirst,
1284            selection: CatalogCombineSelection::All,
1285            order_by: OrderBy::Msgid,
1286            include_origins: true,
1287            include_obsolete: false,
1288        }
1289    }
1290
1291    /// Returns options that use the given input catalogs.
1292    #[must_use]
1293    pub fn with_inputs(mut self, inputs: &'a [CatalogCombineInput<'a>]) -> Self {
1294        self.inputs = inputs;
1295        self
1296    }
1297
1298    /// Returns options that use the given combined catalog locale.
1299    #[must_use]
1300    pub fn with_locale(mut self, locale: &'a str) -> Self {
1301        self.locale = Some(locale);
1302        self
1303    }
1304
1305    /// Returns options that read and render with the given catalog mode.
1306    #[must_use]
1307    pub fn with_mode(mut self, mode: CatalogMode) -> Self {
1308        self.mode = mode;
1309        self
1310    }
1311
1312    /// Returns options that resolve conflicts with the given strategy.
1313    #[must_use]
1314    pub fn with_conflict_strategy(mut self, conflict_strategy: CatalogConflictStrategy) -> Self {
1315        self.conflict_strategy = conflict_strategy;
1316        self
1317    }
1318
1319    /// Returns options that apply the given message selection.
1320    #[must_use]
1321    pub fn with_selection(mut self, selection: CatalogCombineSelection) -> Self {
1322        self.selection = selection;
1323        self
1324    }
1325
1326    /// Returns options that render the combined catalog with the given sort order.
1327    #[must_use]
1328    pub fn with_order_by(mut self, order_by: OrderBy) -> Self {
1329        self.order_by = order_by;
1330        self
1331    }
1332
1333    /// Returns options that enable or disable rendered source origins.
1334    #[must_use]
1335    pub fn with_include_origins(mut self, include_origins: bool) -> Self {
1336        self.include_origins = include_origins;
1337        self
1338    }
1339
1340    /// Returns options that include or skip obsolete definitions.
1341    #[must_use]
1342    pub fn with_include_obsolete(mut self, include_obsolete: bool) -> Self {
1343        self.include_obsolete = include_obsolete;
1344        self
1345    }
1346}
1347
1348/// Options for parsing a catalog into the higher-level message model.
1349#[derive(Debug, Clone, PartialEq, Eq)]
1350#[non_exhaustive]
1351pub struct ParseCatalogOptions<'a> {
1352    /// Catalog content to parse.
1353    pub content: &'a str,
1354    /// Optional explicit locale override.
1355    pub locale: Option<&'a str>,
1356    /// Source locale used for source-side semantics and validation.
1357    pub source_locale: &'a str,
1358    /// High-level catalog mode used when interpreting catalog content.
1359    pub mode: CatalogMode,
1360    /// Whether unsupported ICU plural projection cases should become hard errors.
1361    pub strict: bool,
1362}
1363
1364impl<'a> ParseCatalogOptions<'a> {
1365    /// Creates parse options with required fields set.
1366    ///
1367    /// Optional fields default to an inferred locale, ICU-native PO mode, and
1368    /// non-strict plural projection.
1369    #[must_use]
1370    pub fn new(content: &'a str, source_locale: &'a str) -> Self {
1371        Self {
1372            content,
1373            locale: None,
1374            source_locale,
1375            mode: CatalogMode::default(),
1376            strict: false,
1377        }
1378    }
1379
1380    /// Returns options that use the given explicit catalog locale.
1381    #[must_use]
1382    pub fn with_locale(mut self, locale: &'a str) -> Self {
1383        self.locale = Some(locale);
1384        self
1385    }
1386
1387    /// Returns options that interpret catalog content with the given mode.
1388    #[must_use]
1389    pub fn with_mode(mut self, mode: CatalogMode) -> Self {
1390        self.mode = mode;
1391        self
1392    }
1393
1394    /// Returns options that enable or disable strict plural projection.
1395    #[must_use]
1396    pub fn with_strict(mut self, strict: bool) -> Self {
1397        self.strict = strict;
1398        self
1399    }
1400}
1401
1402/// Error returned by catalog parsing and update APIs.
1403///
1404/// This enum is non-exhaustive so new API-level failure categories can be added
1405/// without turning downstream error matches into a breaking change.
1406#[derive(Debug)]
1407#[non_exhaustive]
1408pub enum ApiError {
1409    /// Underlying PO parse or string-unescape failure.
1410    Parse(ParseError),
1411    /// Filesystem failure raised by disk-based helpers.
1412    Io(std::io::Error),
1413    /// Caller-supplied arguments were missing, inconsistent, or invalid.
1414    InvalidArguments(String),
1415    /// The requested operation encountered conflicting catalog state.
1416    Conflict(String),
1417    /// The requested behavior cannot be represented safely.
1418    Unsupported(String),
1419}
1420
1421impl fmt::Display for ApiError {
1422    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1423        match self {
1424            Self::Parse(error) => error.fmt(f),
1425            Self::Io(error) => error.fmt(f),
1426            Self::InvalidArguments(message)
1427            | Self::Conflict(message)
1428            | Self::Unsupported(message) => f.write_str(message),
1429        }
1430    }
1431}
1432
1433impl std::error::Error for ApiError {
1434    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1435        match self {
1436            Self::Parse(error) => Some(error),
1437            Self::Io(error) => Some(error),
1438            Self::InvalidArguments(_) | Self::Conflict(_) | Self::Unsupported(_) => None,
1439        }
1440    }
1441}
1442
1443impl ApiError {
1444    /// Creates a filesystem error with path context.
1445    #[must_use]
1446    pub fn io_with_path(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
1447        let kind = source.kind();
1448        Self::Io(std::io::Error::new(
1449            kind,
1450            PathIoError {
1451                path: path.into(),
1452                source,
1453            },
1454        ))
1455    }
1456
1457    /// Returns the filesystem path associated with this error, when available.
1458    #[must_use]
1459    pub fn path(&self) -> Option<&Path> {
1460        match self {
1461            Self::Io(error) => error
1462                .get_ref()
1463                .and_then(|source| source.downcast_ref::<PathIoError>())
1464                .map(|error| error.path.as_path()),
1465            Self::Parse(_)
1466            | Self::InvalidArguments(_)
1467            | Self::Conflict(_)
1468            | Self::Unsupported(_) => None,
1469        }
1470    }
1471}
1472
1473#[derive(Debug)]
1474struct PathIoError {
1475    path: PathBuf,
1476    source: std::io::Error,
1477}
1478
1479impl fmt::Display for PathIoError {
1480    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1481        write!(
1482            f,
1483            "I/O error for `{}`: {}",
1484            self.path.display(),
1485            self.source
1486        )
1487    }
1488}
1489
1490impl std::error::Error for PathIoError {
1491    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1492        Some(&self.source)
1493    }
1494}
1495
1496impl From<ParseError> for ApiError {
1497    fn from(value: ParseError) -> Self {
1498        Self::Parse(value)
1499    }
1500}
1501
1502impl From<std::io::Error> for ApiError {
1503    fn from(value: std::io::Error) -> Self {
1504        Self::Io(value)
1505    }
1506}
1507
1508#[cfg(test)]
1509mod tests {
1510    use std::collections::BTreeMap;
1511    use std::error::Error;
1512    use std::io;
1513    use std::path::{Path, PathBuf};
1514
1515    use super::{
1516        ApiError, CatalogCombineInput, CatalogCombineSelection, CatalogConflictStrategy,
1517        CatalogFileFormat, CatalogMessage, CatalogMessageKey, CatalogMode, CatalogSemantics,
1518        CatalogStorageFormat, CatalogUpdateInput, CombineCatalogFilesOptions,
1519        CombineCatalogOptions, Diagnostic, DiagnosticSeverity, EffectiveTranslation,
1520        EffectiveTranslationRef, NormalizedParsedCatalog, ObsoleteStrategy, OrderBy,
1521        ParseCatalogOptions, ParsedCatalog, PlaceholderCommentMode, PluralEncoding, PluralSource,
1522        RenderOptions, TranslationShape, UpdateCatalogFileOptions, UpdateCatalogOptions,
1523    };
1524    use crate::ParseError;
1525
1526    #[test]
1527    fn catalog_update_input_defaults_and_conversions_use_expected_variants() {
1528        assert!(matches!(
1529            CatalogUpdateInput::default(),
1530            CatalogUpdateInput::Structured(messages) if messages.is_empty()
1531        ));
1532        assert!(matches!(
1533            CatalogUpdateInput::from(Vec::<super::ExtractedMessage>::new()),
1534            CatalogUpdateInput::Structured(messages) if messages.is_empty()
1535        ));
1536        assert!(matches!(
1537            CatalogUpdateInput::from(Vec::<super::SourceExtractedMessage>::new()),
1538            CatalogUpdateInput::SourceFirst(messages) if messages.is_empty()
1539        ));
1540    }
1541
1542    #[test]
1543    fn catalog_message_helpers_cover_key_and_fallback_behavior() {
1544        let singular = CatalogMessage {
1545            msgid: "Hello".to_owned(),
1546            msgctxt: Some("button".to_owned()),
1547            translation: TranslationShape::Singular {
1548                value: String::new(),
1549            },
1550            comments: vec!["Shown in toolbar".to_owned()],
1551            origin: crate::PoVec::new(),
1552            obsolete: None,
1553            machine: None,
1554        };
1555
1556        assert_eq!(
1557            singular.key(),
1558            CatalogMessageKey::with_context("Hello", "button")
1559        );
1560        assert!(matches!(
1561            singular.effective_translation(),
1562            EffectiveTranslationRef::Singular("")
1563        ));
1564        assert_eq!(
1565            singular.source_fallback_translation(Some("en")),
1566            EffectiveTranslation::Singular("Hello".to_owned())
1567        );
1568
1569        let plural = CatalogMessage {
1570            msgid: "{count, plural, one {# file} other {# files}}".to_owned(),
1571            msgctxt: None,
1572            translation: TranslationShape::Plural {
1573                source: PluralSource {
1574                    one: Some("{count} file".to_owned()),
1575                    other: "{count} files".to_owned(),
1576                },
1577                translation: BTreeMap::from([
1578                    ("one".to_owned(), String::new()),
1579                    ("other".to_owned(), "{count} Dateien".to_owned()),
1580                ]),
1581                variable: "count".to_owned(),
1582            },
1583            comments: Vec::new(),
1584            origin: crate::PoVec::new(),
1585            obsolete: None,
1586            machine: None,
1587        };
1588
1589        assert!(matches!(
1590            plural.effective_translation(),
1591            EffectiveTranslationRef::Plural(values)
1592                if values.get("other") == Some(&"{count} Dateien".to_owned())
1593        ));
1594        assert_eq!(
1595            plural.source_fallback_translation(Some("de")),
1596            EffectiveTranslation::Plural(BTreeMap::from([
1597                ("one".to_owned(), "{count} file".to_owned()),
1598                ("other".to_owned(), "{count} Dateien".to_owned()),
1599            ]))
1600        );
1601    }
1602
1603    #[test]
1604    fn normalized_catalog_helpers_expose_lookup_and_source_fallback_views() {
1605        let parsed = ParsedCatalog {
1606            locale: Some("en".to_owned()),
1607            semantics: CatalogSemantics::IcuNative,
1608            headers: BTreeMap::new(),
1609            messages: vec![
1610                CatalogMessage {
1611                    msgid: "Hello".to_owned(),
1612                    msgctxt: None,
1613                    translation: TranslationShape::Singular {
1614                        value: String::new(),
1615                    },
1616                    comments: Vec::new(),
1617                    origin: crate::PoVec::new(),
1618                    obsolete: None,
1619                    machine: None,
1620                },
1621                CatalogMessage {
1622                    msgid: "Hello".to_owned(),
1623                    msgctxt: Some("button".to_owned()),
1624                    translation: TranslationShape::Singular {
1625                        value: "Howdy".to_owned(),
1626                    },
1627                    comments: Vec::new(),
1628                    origin: crate::PoVec::new(),
1629                    obsolete: None,
1630                    machine: None,
1631                },
1632            ],
1633            diagnostics: Vec::new(),
1634        };
1635
1636        let normalized = NormalizedParsedCatalog::new(parsed.clone()).expect("normalized");
1637        let key = CatalogMessageKey::new("Hello", None);
1638
1639        assert_eq!(normalized.message_count(), 2);
1640        assert!(normalized.contains_key(&key));
1641        assert!(normalized.contains_parts("Hello", Some("button")));
1642        assert_eq!(
1643            normalized.parsed_catalog().semantics,
1644            CatalogSemantics::IcuNative
1645        );
1646        assert!(normalized.get(&key).is_some());
1647        assert_eq!(
1648            normalized
1649                .get_by_parts("Hello", Some("button"))
1650                .and_then(|message| match message.effective_translation() {
1651                    EffectiveTranslationRef::Singular(value) => Some(value),
1652                    EffectiveTranslationRef::Plural(_) => None,
1653                }),
1654            Some("Howdy")
1655        );
1656        assert!(matches!(
1657            normalized.effective_translation_by_parts("Hello", None),
1658            Some(EffectiveTranslationRef::Singular(""))
1659        ));
1660        assert_eq!(
1661            normalized.effective_translation_with_source_fallback(&key, "en"),
1662            Some(EffectiveTranslation::Singular("Hello".to_owned()))
1663        );
1664        assert_eq!(
1665            normalized.effective_translation_with_source_fallback_by_parts(
1666                "Hello",
1667                Some("button"),
1668                "en"
1669            ),
1670            Some(EffectiveTranslation::Singular("Howdy".to_owned()))
1671        );
1672        assert_eq!(normalized.into_parsed_catalog(), parsed);
1673    }
1674
1675    #[test]
1676    fn option_defaults_reflect_native_po_defaults() {
1677        let update = UpdateCatalogOptions::new("en", Vec::<super::ExtractedMessage>::new());
1678        assert_eq!(update.mode, CatalogMode::IcuPo);
1679        assert_eq!(update.obsolete_strategy, ObsoleteStrategy::Mark);
1680        assert_eq!(update.render.order_by, OrderBy::Msgid);
1681        assert!(update.render.include_origins);
1682        assert_eq!(
1683            update.render.print_placeholders_in_comments,
1684            PlaceholderCommentMode::Enabled { limit: 3 }
1685        );
1686        assert_eq!(update.source_locale, "en");
1687        assert!(matches!(
1688            update.input,
1689            CatalogUpdateInput::Structured(messages) if messages.is_empty()
1690        ));
1691
1692        let update_file = UpdateCatalogFileOptions::new(
1693            Path::new("locale/de.po"),
1694            "en",
1695            Vec::<super::SourceExtractedMessage>::new(),
1696        );
1697        assert_eq!(update_file.target_path, Path::new("locale/de.po"));
1698        assert_eq!(update_file.options.mode, CatalogMode::IcuPo);
1699        assert_eq!(update_file.options.source_locale, "en");
1700        assert!(matches!(
1701            update_file.options.input,
1702            CatalogUpdateInput::SourceFirst(messages) if messages.is_empty()
1703        ));
1704
1705        let parse = ParseCatalogOptions::new("msgid \"Hello\"\nmsgstr \"Hallo\"\n", "en");
1706        assert_eq!(parse.content, "msgid \"Hello\"\nmsgstr \"Hallo\"\n");
1707        assert_eq!(parse.source_locale, "en");
1708        assert_eq!(parse.mode, CatalogMode::IcuPo);
1709        assert!(!parse.strict);
1710
1711        let inputs = [CatalogCombineInput::labeled(
1712            "msgid \"Hello\"\nmsgstr \"Hallo\"\n",
1713            "de.po",
1714        )];
1715        let combine = CombineCatalogOptions::new(&inputs, "en");
1716        assert_eq!(combine.inputs, &inputs);
1717        assert_eq!(combine.source_locale, "en");
1718        assert_eq!(combine.mode, CatalogMode::IcuPo);
1719        assert_eq!(combine.conflict_strategy, CatalogConflictStrategy::UseFirst);
1720        assert_eq!(combine.selection, CatalogCombineSelection::All);
1721        assert!(combine.include_origins);
1722        assert!(!combine.include_obsolete);
1723
1724        let input_paths = [PathBuf::from("locale/de.po")];
1725        let combine_files =
1726            CombineCatalogFilesOptions::new(&input_paths, Path::new("locale/merged.po"), "en");
1727        assert_eq!(combine_files.input_paths, &input_paths);
1728        assert_eq!(combine_files.output_path, Path::new("locale/merged.po"));
1729        assert_eq!(combine_files.format, None);
1730        assert_eq!(combine_files.mode, None);
1731        assert_eq!(combine_files.source_locale, "en");
1732        assert_eq!(
1733            combine_files.conflict_strategy,
1734            CatalogConflictStrategy::UseFirst
1735        );
1736        assert_eq!(combine_files.selection, CatalogCombineSelection::All);
1737        assert!(combine_files.include_origins);
1738        assert!(!combine_files.include_obsolete);
1739    }
1740
1741    #[test]
1742    fn catalog_option_builders_set_fields() {
1743        let headers = BTreeMap::from([("X-Generator".to_owned(), "ferrocat".to_owned())]);
1744        let render = RenderOptions::default()
1745            .with_order_by(OrderBy::Origin)
1746            .with_include_origins(false)
1747            .with_placeholder_comments(PlaceholderCommentMode::Disabled)
1748            .with_custom_header_attributes(&headers);
1749
1750        assert_eq!(render.order_by, OrderBy::Origin);
1751        assert!(!render.include_origins);
1752        assert_eq!(
1753            render.print_placeholders_in_comments,
1754            PlaceholderCommentMode::Disabled
1755        );
1756        assert_eq!(render.custom_header_attributes, Some(&headers));
1757
1758        let update = UpdateCatalogOptions::new("en", CatalogUpdateInput::default())
1759            .with_locale("de")
1760            .with_existing("msgid \"Hello\"\nmsgstr \"Hallo\"\n")
1761            .with_mode(CatalogMode::GettextPo)
1762            .with_render(render.clone())
1763            .with_obsolete_strategy(ObsoleteStrategy::Delete)
1764            .with_overwrite_source_translations(true)
1765            .with_now("2026-07-02");
1766
1767        assert_eq!(update.locale, Some("de"));
1768        assert_eq!(update.existing, Some("msgid \"Hello\"\nmsgstr \"Hallo\"\n"));
1769        assert_eq!(update.mode, CatalogMode::GettextPo);
1770        assert_eq!(update.render, render);
1771        assert_eq!(update.obsolete_strategy, ObsoleteStrategy::Delete);
1772        assert!(update.overwrite_source_translations);
1773        assert_eq!(update.now, Some("2026-07-02"));
1774
1775        let parse = ParseCatalogOptions::new("content", "en")
1776            .with_locale("fr")
1777            .with_mode(CatalogMode::IcuFcl)
1778            .with_strict(true);
1779
1780        assert_eq!(parse.locale, Some("fr"));
1781        assert_eq!(parse.mode, CatalogMode::IcuFcl);
1782        assert!(parse.strict);
1783
1784        let input_paths = [PathBuf::from("base.po"), PathBuf::from("overlay.po")];
1785        let output_path = Path::new("messages.fcl");
1786        let combine_files = CombineCatalogFilesOptions::new(&[], Path::new("unused.po"), "en")
1787            .with_input_paths(&input_paths)
1788            .with_output_path(output_path)
1789            .with_format(CatalogFileFormat::Fcl)
1790            .with_mode(CatalogMode::IcuFcl)
1791            .with_locale("de")
1792            .with_conflict_strategy(CatalogConflictStrategy::UseLast)
1793            .with_selection(CatalogCombineSelection::Unique)
1794            .with_order_by(OrderBy::Origin)
1795            .with_include_origins(false)
1796            .with_include_obsolete(true);
1797
1798        assert_eq!(combine_files.input_paths, &input_paths);
1799        assert_eq!(combine_files.output_path, output_path);
1800        assert_eq!(combine_files.format, Some(CatalogFileFormat::Fcl));
1801        assert_eq!(combine_files.mode, Some(CatalogMode::IcuFcl));
1802        assert_eq!(combine_files.locale, Some("de"));
1803        assert_eq!(
1804            combine_files.conflict_strategy,
1805            CatalogConflictStrategy::UseLast
1806        );
1807        assert_eq!(combine_files.selection, CatalogCombineSelection::Unique);
1808        assert_eq!(combine_files.order_by, OrderBy::Origin);
1809        assert!(!combine_files.include_origins);
1810        assert!(combine_files.include_obsolete);
1811
1812        let inputs = [CatalogCombineInput::labeled(
1813            "msgid \"Hello\"\nmsgstr \"Hallo\"\n",
1814            "base",
1815        )];
1816        let combine = CombineCatalogOptions::new(&[], "en")
1817            .with_inputs(&inputs)
1818            .with_locale("de")
1819            .with_mode(CatalogMode::GettextPo)
1820            .with_conflict_strategy(CatalogConflictStrategy::UseLast)
1821            .with_selection(CatalogCombineSelection::Unique)
1822            .with_order_by(OrderBy::Origin)
1823            .with_include_origins(false)
1824            .with_include_obsolete(true);
1825
1826        assert_eq!(combine.inputs, &inputs);
1827        assert_eq!(combine.locale, Some("de"));
1828        assert_eq!(combine.mode, CatalogMode::GettextPo);
1829        assert_eq!(combine.conflict_strategy, CatalogConflictStrategy::UseLast);
1830        assert_eq!(combine.selection, CatalogCombineSelection::Unique);
1831        assert_eq!(combine.order_by, OrderBy::Origin);
1832        assert!(!combine.include_origins);
1833        assert!(combine.include_obsolete);
1834
1835        let file_update = UpdateCatalogFileOptions::new(
1836            Path::new("messages.po"),
1837            "en",
1838            CatalogUpdateInput::default(),
1839        )
1840        .with_options(update.clone());
1841
1842        assert_eq!(file_update.target_path, Path::new("messages.po"));
1843        assert_eq!(file_update.options, update);
1844    }
1845
1846    #[test]
1847    fn catalog_file_format_infers_supported_suffixes() {
1848        assert_eq!(
1849            CatalogFileFormat::infer_from_path(Path::new("locale/de.po")).expect("po"),
1850            CatalogFileFormat::Po
1851        );
1852        assert_eq!(
1853            CatalogFileFormat::infer_from_path(Path::new("locale/messages.POT")).expect("pot"),
1854            CatalogFileFormat::Po
1855        );
1856        assert_eq!(
1857            CatalogFileFormat::infer_from_path(Path::new("locale/de.fcl")).expect("fcl"),
1858            CatalogFileFormat::Fcl
1859        );
1860        assert!(matches!(
1861            CatalogFileFormat::infer_from_path(Path::new("locale/de.txt")),
1862            Err(ApiError::Unsupported(message)) if message.contains("could not infer")
1863        ));
1864    }
1865
1866    #[test]
1867    fn catalog_mode_maps_only_supported_catalog_combinations() {
1868        assert_eq!(CatalogMode::default(), CatalogMode::IcuPo);
1869        assert_eq!(
1870            CatalogMode::IcuPo.storage_format(),
1871            CatalogStorageFormat::Po
1872        );
1873        assert_eq!(CatalogMode::IcuPo.semantics(), CatalogSemantics::IcuNative);
1874        assert_eq!(CatalogMode::IcuPo.plural_encoding(), PluralEncoding::Icu);
1875
1876        assert_eq!(
1877            CatalogMode::from_parts(
1878                CatalogStorageFormat::Fcl,
1879                CatalogSemantics::IcuNative,
1880                PluralEncoding::Icu
1881            ),
1882            Some(CatalogMode::IcuFcl)
1883        );
1884        assert_eq!(
1885            CatalogMode::from_parts(
1886                CatalogStorageFormat::Po,
1887                CatalogSemantics::GettextCompat,
1888                PluralEncoding::Gettext
1889            ),
1890            Some(CatalogMode::GettextPo)
1891        );
1892        assert_eq!(
1893            CatalogMode::from_parts(
1894                CatalogStorageFormat::Fcl,
1895                CatalogSemantics::GettextCompat,
1896                PluralEncoding::Gettext
1897            ),
1898            None
1899        );
1900        assert_eq!(
1901            CatalogMode::from_parts(
1902                CatalogStorageFormat::Po,
1903                CatalogSemantics::IcuNative,
1904                PluralEncoding::Icu
1905            ),
1906            Some(CatalogMode::IcuPo)
1907        );
1908        let update = UpdateCatalogOptions::new("en", Vec::<super::SourceExtractedMessage>::new())
1909            .with_mode(CatalogMode::GettextPo);
1910        assert_eq!(update.mode, CatalogMode::GettextPo);
1911    }
1912
1913    #[test]
1914    fn diagnostics_and_api_errors_preserve_human_readable_messages() {
1915        let diagnostic = Diagnostic::new(DiagnosticSeverity::Warning, "code", "message")
1916            .with_identity("Hello", Some("button"));
1917        assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning);
1918        assert_eq!(diagnostic.code, "code");
1919        assert_eq!(diagnostic.message, "message");
1920        assert_eq!(diagnostic.msgid.as_deref(), Some("Hello"));
1921        assert_eq!(diagnostic.msgctxt.as_deref(), Some("button"));
1922
1923        let io_error = ApiError::from(io::Error::other("disk"));
1924        assert_eq!(io_error.to_string(), "disk");
1925        assert_eq!(
1926            io_error.source().map(ToString::to_string).as_deref(),
1927            Some("disk")
1928        );
1929        assert_eq!(io_error.path(), None);
1930
1931        let io_path_error = ApiError::io_with_path(
1932            Path::new("locale/de.po"),
1933            io::Error::other("permission denied"),
1934        );
1935        assert_eq!(io_path_error.path(), Some(Path::new("locale/de.po")));
1936        let source = io_path_error.source().expect("io source");
1937        assert!(source.to_string().contains("locale/de.po"));
1938        assert_eq!(
1939            source.source().map(ToString::to_string).as_deref(),
1940            Some("permission denied")
1941        );
1942        assert!(io_path_error.to_string().contains("locale/de.po"));
1943
1944        let parse_error = ApiError::from(ParseError::new("bad syntax"));
1945        assert_eq!(
1946            parse_error.source().map(ToString::to_string).as_deref(),
1947            Some("bad syntax")
1948        );
1949        assert_eq!(
1950            ApiError::InvalidArguments("bad input".to_owned()).to_string(),
1951            "bad input"
1952        );
1953        assert!(
1954            ApiError::InvalidArguments("bad input".to_owned())
1955                .source()
1956                .is_none()
1957        );
1958        assert_eq!(
1959            ApiError::Conflict("duplicate".to_owned()).to_string(),
1960            "duplicate"
1961        );
1962        assert_eq!(
1963            ApiError::Unsupported("unsupported".to_owned()).to_string(),
1964            "unsupported"
1965        );
1966    }
1967
1968    #[cfg(feature = "serde")]
1969    #[test]
1970    fn catalog_message_and_diagnostic_serde_use_stable_public_shapes() {
1971        let message = CatalogMessage {
1972            msgid: "Hello".to_owned(),
1973            msgctxt: Some("button".to_owned()),
1974            translation: TranslationShape::Singular {
1975                value: "Hallo".to_owned(),
1976            },
1977            comments: vec!["Shown in toolbar".to_owned()],
1978            origin: crate::PoVec::new(),
1979            obsolete: None,
1980            machine: None,
1981        };
1982        let message_json =
1983            serde_json::to_value(&message).expect("catalog message serialization must succeed");
1984        assert_eq!(message_json["translation"]["kind"], "singular");
1985
1986        let roundtrip_message: CatalogMessage = serde_json::from_value(message_json)
1987            .expect("catalog message deserialization must succeed");
1988        assert_eq!(roundtrip_message, message);
1989
1990        let diagnostic = Diagnostic::new(
1991            DiagnosticSeverity::Error,
1992            "catalog.missing",
1993            "missing translation",
1994        )
1995        .with_identity("Hello", Some("button"));
1996        let diagnostic_json =
1997            serde_json::to_value(&diagnostic).expect("diagnostic serialization must succeed");
1998        assert_eq!(diagnostic_json["severity"], "error");
1999
2000        let roundtrip_diagnostic: Diagnostic = serde_json::from_value(diagnostic_json)
2001            .expect("diagnostic deserialization must succeed");
2002        assert_eq!(roundtrip_diagnostic, diagnostic);
2003    }
2004}