Skip to main content

ferrocat_po/api/
types.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::Path;
4
5use crate::ParseError;
6
7use super::plural::PluralProfile;
8
9/// File and line information for an extracted message origin.
10#[derive(Debug, Clone, PartialEq, Eq, Default)]
11pub struct CatalogOrigin {
12    /// Path-like source identifier where the message came from.
13    pub file: String,
14    /// One-based line number when the extractor provided one.
15    pub line: Option<u32>,
16}
17
18/// Structured singular message input used by catalog update operations.
19#[derive(Debug, Clone, PartialEq, Eq, Default)]
20pub struct ExtractedSingularMessage {
21    /// Source message identifier.
22    pub msgid: String,
23    /// Optional gettext message context.
24    pub msgctxt: Option<String>,
25    /// Extracted comments that should become translator-facing guidance.
26    pub comments: Vec<String>,
27    /// Source locations collected by the extractor.
28    pub origin: Vec<CatalogOrigin>,
29    /// Placeholder hints keyed by placeholder name.
30    pub placeholders: BTreeMap<String, Vec<String>>,
31}
32
33/// Source-side plural forms for structured catalog messages.
34#[derive(Debug, Clone, PartialEq, Eq, Default)]
35pub struct PluralSource {
36    /// Singular source form, when one exists separately from `other`.
37    pub one: Option<String>,
38    /// Required plural catch-all source form.
39    pub other: String,
40}
41
42/// Structured plural message input used by catalog update operations.
43#[derive(Debug, Clone, PartialEq, Eq, Default)]
44pub struct ExtractedPluralMessage {
45    /// Stable source identifier for the message family.
46    pub msgid: String,
47    /// Optional gettext message context.
48    pub msgctxt: Option<String>,
49    /// Structured source-side plural forms.
50    pub source: PluralSource,
51    /// Extracted comments that should become translator-facing guidance.
52    pub comments: Vec<String>,
53    /// Source locations collected by the extractor.
54    pub origin: Vec<CatalogOrigin>,
55    /// Placeholder hints keyed by placeholder name.
56    pub placeholders: BTreeMap<String, Vec<String>>,
57}
58
59/// Structured extractor input accepted by [`super::update_catalog`] and [`super::update_catalog_file`].
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum ExtractedMessage {
62    /// Message that has a single source/translation value.
63    Singular(ExtractedSingularMessage),
64    /// Message that carries structured plural source forms.
65    Plural(ExtractedPluralMessage),
66}
67
68/// Source-first extractor input that lets `ferrocat` infer plural structure.
69#[derive(Debug, Clone, PartialEq, Eq, Default)]
70pub struct SourceExtractedMessage {
71    /// Source message text used both as identifier and source value.
72    pub msgid: String,
73    /// Optional gettext message context.
74    pub msgctxt: Option<String>,
75    /// Extracted comments that should become translator-facing guidance.
76    pub comments: Vec<String>,
77    /// Source locations collected by the extractor.
78    pub origin: Vec<CatalogOrigin>,
79    /// Placeholder hints keyed by placeholder name.
80    pub placeholders: BTreeMap<String, Vec<String>>,
81}
82
83/// Input payload accepted by catalog update operations.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum CatalogUpdateInput {
86    /// Pre-projected singular/plural messages.
87    Structured(Vec<ExtractedMessage>),
88    /// Source-first messages that let `ferrocat` infer plural structure.
89    SourceFirst(Vec<SourceExtractedMessage>),
90}
91
92impl Default for CatalogUpdateInput {
93    fn default() -> Self {
94        Self::Structured(Vec::new())
95    }
96}
97
98impl From<Vec<ExtractedMessage>> for CatalogUpdateInput {
99    fn from(value: Vec<ExtractedMessage>) -> Self {
100        Self::Structured(value)
101    }
102}
103
104impl From<Vec<SourceExtractedMessage>> for CatalogUpdateInput {
105    fn from(value: Vec<SourceExtractedMessage>) -> Self {
106        Self::SourceFirst(value)
107    }
108}
109
110/// Public translation shape returned from parsed catalogs.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum TranslationShape {
113    /// Message represented by a single string value.
114    Singular {
115        /// The current translation value.
116        value: String,
117    },
118    /// Message represented by structured plural categories.
119    Plural {
120        /// Source-side plural forms.
121        source: PluralSource,
122        /// Translation values keyed by plural category.
123        translation: BTreeMap<String, String>,
124        /// Variable name used when re-synthesizing ICU plural strings.
125        variable: String,
126    },
127}
128
129/// Borrowed view over a message translation.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum EffectiveTranslationRef<'a> {
132    /// Singular translation borrowed from the parsed catalog.
133    Singular(&'a str),
134    /// Plural translation borrowed from the parsed catalog.
135    Plural(&'a BTreeMap<String, String>),
136}
137
138/// Owned translation value materialized from a parsed catalog.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum EffectiveTranslation {
141    /// Singular translation value.
142    Singular(String),
143    /// Plural translation values keyed by category.
144    Plural(BTreeMap<String, String>),
145}
146
147/// Extra translator-facing metadata preserved on a catalog message.
148#[derive(Debug, Clone, PartialEq, Eq, Default)]
149pub struct CatalogMessageExtra {
150    /// Translator comments that were attached to the original PO item.
151    pub translator_comments: Vec<String>,
152    /// PO flags such as `fuzzy`.
153    pub flags: Vec<String>,
154}
155
156/// Public message representation returned by [`super::parse_catalog`].
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct CatalogMessage {
159    /// Source message identifier.
160    pub msgid: String,
161    /// Optional gettext message context.
162    pub msgctxt: Option<String>,
163    /// Public translation representation.
164    pub translation: TranslationShape,
165    /// Extracted comments preserved from the source catalog.
166    pub comments: Vec<String>,
167    /// Source origins preserved from PO references.
168    pub origin: Vec<CatalogOrigin>,
169    /// Whether the message is marked obsolete.
170    pub obsolete: bool,
171    /// Optional additional translator-facing PO metadata.
172    pub extra: Option<CatalogMessageExtra>,
173}
174
175impl CatalogMessage {
176    /// Returns the lookup key for this message.
177    #[must_use]
178    pub fn key(&self) -> CatalogMessageKey {
179        CatalogMessageKey {
180            msgid: self.msgid.clone(),
181            msgctxt: self.msgctxt.clone(),
182        }
183    }
184
185    /// Returns the effective translation without source-locale fallback.
186    #[must_use]
187    pub fn effective_translation(&self) -> EffectiveTranslationRef<'_> {
188        match &self.translation {
189            TranslationShape::Singular { value } => EffectiveTranslationRef::Singular(value),
190            TranslationShape::Plural { translation, .. } => {
191                EffectiveTranslationRef::Plural(translation)
192            }
193        }
194    }
195
196    pub(super) fn effective_translation_owned(&self) -> EffectiveTranslation {
197        match &self.translation {
198            TranslationShape::Singular { value } => EffectiveTranslation::Singular(value.clone()),
199            TranslationShape::Plural { translation, .. } => {
200                EffectiveTranslation::Plural(translation.clone())
201            }
202        }
203    }
204
205    /// Applies the source-locale fallback semantics used by compilation and
206    /// runtime artifact generation.
207    ///
208    /// Singular messages fall back to `msgid` when empty. Plural messages keep
209    /// their category shape and only fill categories that are missing or empty.
210    pub(super) fn source_fallback_translation(&self, locale: Option<&str>) -> EffectiveTranslation {
211        match &self.translation {
212            TranslationShape::Singular { value } => {
213                if value.is_empty() {
214                    EffectiveTranslation::Singular(self.msgid.clone())
215                } else {
216                    EffectiveTranslation::Singular(value.clone())
217                }
218            }
219            TranslationShape::Plural {
220                source,
221                translation,
222                ..
223            } => {
224                let profile = PluralProfile::for_locale(locale);
225                let mut effective = profile.materialize_translation(translation);
226                let fallback = profile.source_locale_translation(source);
227                for (category, source_value) in fallback {
228                    let should_fill = effective.get(&category).is_none_or(String::is_empty);
229                    if should_fill {
230                        effective.insert(category, source_value);
231                    }
232                }
233                EffectiveTranslation::Plural(effective)
234            }
235        }
236    }
237}
238
239/// Stable lookup key for catalog messages.
240#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
241pub struct CatalogMessageKey {
242    /// Source message identifier.
243    pub msgid: String,
244    /// Optional gettext message context.
245    pub msgctxt: Option<String>,
246}
247
248impl CatalogMessageKey {
249    /// Creates a message key from `msgid` and optional context.
250    #[must_use]
251    pub fn new(msgid: impl Into<String>, msgctxt: Option<String>) -> Self {
252        Self {
253            msgid: msgid.into(),
254            msgctxt,
255        }
256    }
257}
258
259/// Severity level attached to a [`Diagnostic`].
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum DiagnosticSeverity {
262    /// Informational message that does not indicate a problem.
263    Info,
264    /// Non-fatal condition that may require user attention.
265    Warning,
266    /// Serious condition associated with invalid input or unsupported output.
267    Error,
268}
269
270/// Non-fatal issue collected while parsing or updating catalogs.
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct Diagnostic {
273    /// Severity level for the diagnostic.
274    pub severity: DiagnosticSeverity,
275    /// Stable machine-readable code for the diagnostic.
276    pub code: String,
277    /// Human-readable explanation of the condition.
278    pub message: String,
279    /// Source `msgid`, when the diagnostic can be tied to one message.
280    pub msgid: Option<String>,
281    /// Source `msgctxt`, when the diagnostic can be tied to one message.
282    pub msgctxt: Option<String>,
283}
284
285impl Diagnostic {
286    pub(super) fn new(
287        severity: DiagnosticSeverity,
288        code: impl Into<String>,
289        message: impl Into<String>,
290    ) -> Self {
291        Self {
292            severity,
293            code: code.into(),
294            message: message.into(),
295            msgid: None,
296            msgctxt: None,
297        }
298    }
299
300    pub(super) fn with_identity(mut self, msgid: &str, msgctxt: Option<&str>) -> Self {
301        self.msgid = Some(msgid.to_owned());
302        self.msgctxt = msgctxt.map(str::to_owned);
303        self
304    }
305}
306
307/// Basic counters describing an update operation.
308#[derive(Debug, Clone, PartialEq, Eq, Default)]
309pub struct CatalogStats {
310    /// Total messages in the final catalog.
311    pub total: usize,
312    /// Messages added during the update.
313    pub added: usize,
314    /// Existing messages whose rendered representation changed.
315    pub changed: usize,
316    /// Existing messages preserved without changes.
317    pub unchanged: usize,
318    /// Messages newly marked obsolete.
319    pub obsolete_marked: usize,
320    /// Messages removed because the obsolete strategy deleted them.
321    pub obsolete_removed: usize,
322}
323
324/// Result returned by catalog update operations.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct CatalogUpdateResult {
327    /// Final PO content after applying the update.
328    pub content: String,
329    /// Whether the update created a new catalog from scratch.
330    pub created: bool,
331    /// Whether the final content differs from the original input.
332    pub updated: bool,
333    /// Summary counters for the operation.
334    pub stats: CatalogStats,
335    /// Non-fatal diagnostics collected during processing.
336    pub diagnostics: Vec<Diagnostic>,
337}
338
339/// Parsed catalog plus diagnostics and normalized headers.
340#[derive(Debug, Clone, PartialEq, Eq)]
341pub struct ParsedCatalog {
342    /// Declared or overridden catalog locale.
343    pub locale: Option<String>,
344    /// High-level semantics used to parse the catalog.
345    pub semantics: CatalogSemantics,
346    /// Normalized header map keyed by header name.
347    pub headers: BTreeMap<String, String>,
348    /// Parsed catalog messages in source order.
349    pub messages: Vec<CatalogMessage>,
350    /// Non-fatal diagnostics collected while parsing.
351    pub diagnostics: Vec<Diagnostic>,
352}
353
354impl ParsedCatalog {
355    /// Builds a lookup-oriented view that rejects duplicate message keys.
356    ///
357    /// # Errors
358    ///
359    /// Returns [`ApiError::Conflict`] when the parsed catalog contains
360    /// duplicate `msgid`/`msgctxt` pairs.
361    pub fn into_normalized_view(self) -> Result<NormalizedParsedCatalog, ApiError> {
362        NormalizedParsedCatalog::new(self)
363    }
364}
365
366/// Parsed catalog with fast key-based lookup helpers.
367#[derive(Debug, Clone, PartialEq, Eq)]
368pub struct NormalizedParsedCatalog {
369    pub(super) catalog: ParsedCatalog,
370    pub(super) key_index: BTreeMap<CatalogMessageKey, usize>,
371}
372
373impl NormalizedParsedCatalog {
374    /// Builds the lookup index once and rejects duplicate gettext identities up front.
375    pub(super) fn new(catalog: ParsedCatalog) -> Result<Self, ApiError> {
376        let mut key_index = BTreeMap::new();
377        for (index, message) in catalog.messages.iter().enumerate() {
378            let key = message.key();
379            if key_index.insert(key.clone(), index).is_some() {
380                return Err(ApiError::Conflict(format!(
381                    "duplicate parsed catalog message for msgid {:?} and context {:?}",
382                    key.msgid, key.msgctxt
383                )));
384            }
385        }
386        Ok(Self { catalog, key_index })
387    }
388
389    /// Returns the underlying parsed catalog.
390    #[must_use]
391    pub const fn parsed_catalog(&self) -> &ParsedCatalog {
392        &self.catalog
393    }
394
395    /// Consumes the normalized view and returns the underlying parsed catalog.
396    #[must_use]
397    pub fn into_parsed_catalog(self) -> ParsedCatalog {
398        self.catalog
399    }
400
401    /// Returns a message by key.
402    #[must_use]
403    pub fn get(&self, key: &CatalogMessageKey) -> Option<&CatalogMessage> {
404        self.key_index
405            .get(key)
406            .map(|index| &self.catalog.messages[*index])
407    }
408
409    /// Returns `true` if a message for `key` exists.
410    #[must_use]
411    pub fn contains_key(&self, key: &CatalogMessageKey) -> bool {
412        self.key_index.contains_key(key)
413    }
414
415    /// Returns the number of indexed messages.
416    #[must_use]
417    pub fn message_count(&self) -> usize {
418        self.catalog.messages.len()
419    }
420
421    /// Iterates over all indexed messages in key order.
422    pub fn iter(&self) -> impl Iterator<Item = (&CatalogMessageKey, &CatalogMessage)> + '_ {
423        self.key_index
424            .iter()
425            .map(|(key, index)| (key, &self.catalog.messages[*index]))
426    }
427
428    /// Returns the effective translation for `key`, if present.
429    pub fn effective_translation(
430        &self,
431        key: &CatalogMessageKey,
432    ) -> Option<EffectiveTranslationRef<'_>> {
433        self.get(key).map(CatalogMessage::effective_translation)
434    }
435
436    /// Returns the effective translation and fills empty source-locale values
437    /// from the source text when appropriate.
438    #[must_use]
439    pub fn effective_translation_with_source_fallback(
440        &self,
441        key: &CatalogMessageKey,
442        source_locale: &str,
443    ) -> Option<EffectiveTranslation> {
444        let message = self.get(key)?;
445        if self
446            .catalog
447            .locale
448            .as_deref()
449            .is_none_or(|locale| locale == source_locale)
450        {
451            Some(message.source_fallback_translation(self.catalog.locale.as_deref()))
452        } else {
453            Some(message.effective_translation_owned())
454        }
455    }
456}
457
458/// Encoding used for plural messages in PO files.
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
460pub enum PluralEncoding {
461    /// Keep plural messages in Ferrocat's structured ICU-oriented representation.
462    #[default]
463    Icu,
464    /// Materialize plural messages as classic gettext `msgid_plural` plus `msgstr[n]`.
465    Gettext,
466}
467
468/// Storage format used by the high-level catalog API.
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
470pub enum CatalogStorageFormat {
471    /// Read and write classic gettext PO catalogs.
472    #[default]
473    Po,
474    /// Read and write Ferrocat's NDJSON catalog format with a small frontmatter header.
475    Ndjson,
476}
477
478/// High-level semantics used by the catalog API.
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
480pub enum CatalogSemantics {
481    /// ICU-native semantics with raw ICU/text messages as the primary representation.
482    #[default]
483    IcuNative,
484    /// Classic gettext plural semantics used for PO compatibility workflows.
485    GettextCompat,
486}
487
488/// Strategy used for messages that disappear from the extracted input.
489#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
490pub enum ObsoleteStrategy {
491    /// Mark missing messages obsolete and keep them in the file.
492    #[default]
493    Mark,
494    /// Remove missing messages entirely.
495    Delete,
496    /// Keep missing messages as active entries.
497    Keep,
498}
499
500/// Sort order used when writing output catalogs.
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
502pub enum OrderBy {
503    /// Sort by `msgid` then context.
504    #[default]
505    Msgid,
506    /// Sort by the first source origin, then by message identity.
507    Origin,
508}
509
510/// Controls whether placeholder hints are emitted as extracted comments.
511#[derive(Debug, Clone, PartialEq, Eq)]
512pub enum PlaceholderCommentMode {
513    /// Do not emit placeholder comments.
514    Disabled,
515    /// Emit up to `limit` placeholder comments per placeholder name.
516    Enabled {
517        /// Maximum number of values rendered per placeholder name.
518        limit: usize,
519    },
520}
521
522impl Default for PlaceholderCommentMode {
523    fn default() -> Self {
524        Self::Enabled { limit: 3 }
525    }
526}
527
528/// Options for in-memory catalog updates.
529#[derive(Debug, Clone, PartialEq, Eq)]
530pub struct UpdateCatalogOptions<'a> {
531    /// Locale of the catalog being updated. When `None`, Ferrocat infers it from the existing file.
532    pub locale: Option<&'a str>,
533    /// Source locale used for source-side semantics and fallback handling.
534    pub source_locale: &'a str,
535    /// Extracted messages to merge into the catalog.
536    pub input: CatalogUpdateInput,
537    /// Existing catalog content, when updating an in-memory catalog.
538    pub existing: Option<&'a str>,
539    /// Storage format used when reading existing content and rendering the result.
540    pub storage_format: CatalogStorageFormat,
541    /// High-level semantics used when parsing, merging, and rendering the catalog.
542    pub semantics: CatalogSemantics,
543    /// Target plural representation for the rendered PO file.
544    pub plural_encoding: PluralEncoding,
545    /// Strategy for messages absent from the extracted input.
546    pub obsolete_strategy: ObsoleteStrategy,
547    /// Whether source-locale translations should be refreshed from the extracted source strings.
548    pub overwrite_source_translations: bool,
549    /// Sort order for the final rendered catalog.
550    pub order_by: OrderBy,
551    /// Whether source origins should be rendered as references.
552    pub include_origins: bool,
553    /// Whether rendered references should include line numbers.
554    pub include_line_numbers: bool,
555    /// Controls emission of placeholder comments.
556    pub print_placeholders_in_comments: PlaceholderCommentMode,
557    /// Optional additional header attributes to inject or override.
558    pub custom_header_attributes: Option<&'a BTreeMap<String, String>>,
559}
560
561impl Default for UpdateCatalogOptions<'_> {
562    fn default() -> Self {
563        Self {
564            locale: None,
565            source_locale: "",
566            input: CatalogUpdateInput::default(),
567            existing: None,
568            storage_format: CatalogStorageFormat::Po,
569            semantics: CatalogSemantics::IcuNative,
570            plural_encoding: PluralEncoding::Icu,
571            obsolete_strategy: ObsoleteStrategy::Mark,
572            overwrite_source_translations: false,
573            order_by: OrderBy::Msgid,
574            include_origins: true,
575            include_line_numbers: true,
576            print_placeholders_in_comments: PlaceholderCommentMode::Enabled { limit: 3 },
577            custom_header_attributes: None,
578        }
579    }
580}
581
582/// Options for updating a catalog file on disk.
583#[derive(Debug, Clone, PartialEq, Eq)]
584pub struct UpdateCatalogFileOptions<'a> {
585    /// Path to the catalog file that should be read and conditionally written.
586    pub target_path: &'a Path,
587    /// Locale of the catalog being updated. When `None`, Ferrocat infers it from the existing file.
588    pub locale: Option<&'a str>,
589    /// Source locale used for source-side semantics and fallback handling.
590    pub source_locale: &'a str,
591    /// Extracted messages to merge into the catalog.
592    pub input: CatalogUpdateInput,
593    /// Storage format used when reading and writing the file content.
594    pub storage_format: CatalogStorageFormat,
595    /// High-level semantics used when parsing, merging, and rendering the catalog.
596    pub semantics: CatalogSemantics,
597    /// Target plural representation for the rendered PO file.
598    pub plural_encoding: PluralEncoding,
599    /// Strategy for messages absent from the extracted input.
600    pub obsolete_strategy: ObsoleteStrategy,
601    /// Whether source-locale translations should be refreshed from the extracted source strings.
602    pub overwrite_source_translations: bool,
603    /// Sort order for the final rendered catalog.
604    pub order_by: OrderBy,
605    /// Whether source origins should be rendered as references.
606    pub include_origins: bool,
607    /// Whether rendered references should include line numbers.
608    pub include_line_numbers: bool,
609    /// Controls emission of placeholder comments.
610    pub print_placeholders_in_comments: PlaceholderCommentMode,
611    /// Optional additional header attributes to inject or override.
612    pub custom_header_attributes: Option<&'a BTreeMap<String, String>>,
613}
614
615impl Default for UpdateCatalogFileOptions<'_> {
616    fn default() -> Self {
617        Self {
618            target_path: Path::new(""),
619            locale: None,
620            source_locale: "",
621            input: CatalogUpdateInput::default(),
622            storage_format: CatalogStorageFormat::Po,
623            semantics: CatalogSemantics::IcuNative,
624            plural_encoding: PluralEncoding::Icu,
625            obsolete_strategy: ObsoleteStrategy::Mark,
626            overwrite_source_translations: false,
627            order_by: OrderBy::Msgid,
628            include_origins: true,
629            include_line_numbers: true,
630            print_placeholders_in_comments: PlaceholderCommentMode::Enabled { limit: 3 },
631            custom_header_attributes: None,
632        }
633    }
634}
635
636/// Options for parsing a catalog into the higher-level message model.
637#[derive(Debug, Clone, PartialEq, Eq)]
638pub struct ParseCatalogOptions<'a> {
639    /// Catalog content to parse.
640    pub content: &'a str,
641    /// Optional explicit locale override.
642    pub locale: Option<&'a str>,
643    /// Source locale used for source-side semantics and validation.
644    pub source_locale: &'a str,
645    /// Storage format used when parsing the content.
646    pub storage_format: CatalogStorageFormat,
647    /// High-level semantics used when interpreting catalog content.
648    pub semantics: CatalogSemantics,
649    /// Target plural interpretation for the resulting catalog view.
650    pub plural_encoding: PluralEncoding,
651    /// Whether unsupported ICU plural projection cases should become hard errors.
652    pub strict: bool,
653}
654
655impl Default for ParseCatalogOptions<'_> {
656    fn default() -> Self {
657        Self {
658            content: "",
659            locale: None,
660            source_locale: "",
661            storage_format: CatalogStorageFormat::Po,
662            semantics: CatalogSemantics::IcuNative,
663            plural_encoding: PluralEncoding::Icu,
664            strict: false,
665        }
666    }
667}
668
669/// Error returned by catalog parsing and update APIs.
670#[derive(Debug)]
671pub enum ApiError {
672    /// Underlying PO parse or string-unescape failure.
673    Parse(ParseError),
674    /// Filesystem failure raised by disk-based helpers.
675    Io(std::io::Error),
676    /// Caller-supplied arguments were missing, inconsistent, or invalid.
677    InvalidArguments(String),
678    /// The requested operation encountered conflicting catalog state.
679    Conflict(String),
680    /// The requested behavior cannot be represented safely.
681    Unsupported(String),
682}
683
684impl fmt::Display for ApiError {
685    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
686        match self {
687            Self::Parse(error) => error.fmt(f),
688            Self::Io(error) => error.fmt(f),
689            Self::InvalidArguments(message)
690            | Self::Conflict(message)
691            | Self::Unsupported(message) => f.write_str(message),
692        }
693    }
694}
695
696impl std::error::Error for ApiError {}
697
698impl From<ParseError> for ApiError {
699    fn from(value: ParseError) -> Self {
700        Self::Parse(value)
701    }
702}
703
704impl From<std::io::Error> for ApiError {
705    fn from(value: std::io::Error) -> Self {
706        Self::Io(value)
707    }
708}
709
710#[cfg(test)]
711mod tests {
712    use std::collections::BTreeMap;
713    use std::io;
714    use std::path::Path;
715
716    use super::{
717        ApiError, CatalogMessage, CatalogMessageExtra, CatalogMessageKey, CatalogSemantics,
718        CatalogStorageFormat, CatalogUpdateInput, Diagnostic, DiagnosticSeverity,
719        EffectiveTranslation, EffectiveTranslationRef, NormalizedParsedCatalog, ObsoleteStrategy,
720        OrderBy, ParseCatalogOptions, ParsedCatalog, PlaceholderCommentMode, PluralEncoding,
721        PluralSource, TranslationShape, UpdateCatalogFileOptions, UpdateCatalogOptions,
722    };
723
724    #[test]
725    fn catalog_update_input_defaults_and_conversions_use_expected_variants() {
726        assert!(matches!(
727            CatalogUpdateInput::default(),
728            CatalogUpdateInput::Structured(messages) if messages.is_empty()
729        ));
730        assert!(matches!(
731            CatalogUpdateInput::from(Vec::<super::ExtractedMessage>::new()),
732            CatalogUpdateInput::Structured(messages) if messages.is_empty()
733        ));
734        assert!(matches!(
735            CatalogUpdateInput::from(Vec::<super::SourceExtractedMessage>::new()),
736            CatalogUpdateInput::SourceFirst(messages) if messages.is_empty()
737        ));
738    }
739
740    #[test]
741    fn catalog_message_helpers_cover_key_and_fallback_behavior() {
742        let singular = CatalogMessage {
743            msgid: "Hello".to_owned(),
744            msgctxt: Some("button".to_owned()),
745            translation: TranslationShape::Singular {
746                value: String::new(),
747            },
748            comments: vec!["Shown in toolbar".to_owned()],
749            origin: Vec::new(),
750            obsolete: false,
751            extra: Some(CatalogMessageExtra {
752                translator_comments: vec!["Imperative".to_owned()],
753                flags: vec!["fuzzy".to_owned()],
754            }),
755        };
756
757        assert_eq!(
758            singular.key(),
759            CatalogMessageKey::new("Hello", Some("button".to_owned()))
760        );
761        assert!(matches!(
762            singular.effective_translation(),
763            EffectiveTranslationRef::Singular("")
764        ));
765        assert_eq!(
766            singular.source_fallback_translation(Some("en")),
767            EffectiveTranslation::Singular("Hello".to_owned())
768        );
769
770        let plural = CatalogMessage {
771            msgid: "{count, plural, one {# file} other {# files}}".to_owned(),
772            msgctxt: None,
773            translation: TranslationShape::Plural {
774                source: PluralSource {
775                    one: Some("{count} file".to_owned()),
776                    other: "{count} files".to_owned(),
777                },
778                translation: BTreeMap::from([
779                    ("one".to_owned(), String::new()),
780                    ("other".to_owned(), "{count} Dateien".to_owned()),
781                ]),
782                variable: "count".to_owned(),
783            },
784            comments: Vec::new(),
785            origin: Vec::new(),
786            obsolete: false,
787            extra: None,
788        };
789
790        assert!(matches!(
791            plural.effective_translation(),
792            EffectiveTranslationRef::Plural(values)
793                if values.get("other") == Some(&"{count} Dateien".to_owned())
794        ));
795        assert_eq!(
796            plural.source_fallback_translation(Some("de")),
797            EffectiveTranslation::Plural(BTreeMap::from([
798                ("one".to_owned(), "{count} file".to_owned()),
799                ("other".to_owned(), "{count} Dateien".to_owned()),
800            ]))
801        );
802    }
803
804    #[test]
805    fn normalized_catalog_helpers_expose_lookup_and_source_fallback_views() {
806        let parsed = ParsedCatalog {
807            locale: Some("en".to_owned()),
808            semantics: CatalogSemantics::IcuNative,
809            headers: BTreeMap::new(),
810            messages: vec![CatalogMessage {
811                msgid: "Hello".to_owned(),
812                msgctxt: None,
813                translation: TranslationShape::Singular {
814                    value: String::new(),
815                },
816                comments: Vec::new(),
817                origin: Vec::new(),
818                obsolete: false,
819                extra: None,
820            }],
821            diagnostics: Vec::new(),
822        };
823
824        let normalized = NormalizedParsedCatalog::new(parsed.clone()).expect("normalized");
825        let key = CatalogMessageKey::new("Hello", None);
826
827        assert_eq!(normalized.message_count(), 1);
828        assert!(normalized.contains_key(&key));
829        assert_eq!(
830            normalized.parsed_catalog().semantics,
831            CatalogSemantics::IcuNative
832        );
833        assert!(normalized.get(&key).is_some());
834        assert_eq!(
835            normalized.effective_translation_with_source_fallback(&key, "en"),
836            Some(EffectiveTranslation::Singular("Hello".to_owned()))
837        );
838        assert_eq!(normalized.into_parsed_catalog(), parsed);
839    }
840
841    #[test]
842    fn option_defaults_reflect_native_po_defaults() {
843        let update = UpdateCatalogOptions::default();
844        assert_eq!(update.storage_format, CatalogStorageFormat::Po);
845        assert_eq!(update.semantics, CatalogSemantics::IcuNative);
846        assert_eq!(update.plural_encoding, PluralEncoding::Icu);
847        assert_eq!(update.obsolete_strategy, ObsoleteStrategy::Mark);
848        assert_eq!(update.order_by, OrderBy::Msgid);
849        assert!(update.include_origins);
850        assert!(update.include_line_numbers);
851        assert_eq!(
852            update.print_placeholders_in_comments,
853            PlaceholderCommentMode::Enabled { limit: 3 }
854        );
855
856        let update_file = UpdateCatalogFileOptions::default();
857        assert_eq!(update_file.target_path, Path::new(""));
858        assert_eq!(update_file.storage_format, CatalogStorageFormat::Po);
859        assert_eq!(update_file.semantics, CatalogSemantics::IcuNative);
860        assert_eq!(update_file.plural_encoding, PluralEncoding::Icu);
861
862        let parse = ParseCatalogOptions::default();
863        assert_eq!(parse.storage_format, CatalogStorageFormat::Po);
864        assert_eq!(parse.semantics, CatalogSemantics::IcuNative);
865        assert_eq!(parse.plural_encoding, PluralEncoding::Icu);
866        assert!(!parse.strict);
867    }
868
869    #[test]
870    fn diagnostics_and_api_errors_preserve_human_readable_messages() {
871        let diagnostic = Diagnostic::new(DiagnosticSeverity::Warning, "code", "message")
872            .with_identity("Hello", Some("button"));
873        assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning);
874        assert_eq!(diagnostic.code, "code");
875        assert_eq!(diagnostic.message, "message");
876        assert_eq!(diagnostic.msgid.as_deref(), Some("Hello"));
877        assert_eq!(diagnostic.msgctxt.as_deref(), Some("button"));
878
879        let io_error = ApiError::from(io::Error::other("disk"));
880        assert_eq!(io_error.to_string(), "disk");
881        assert_eq!(
882            ApiError::InvalidArguments("bad input".to_owned()).to_string(),
883            "bad input"
884        );
885        assert_eq!(
886            ApiError::Conflict("duplicate".to_owned()).to_string(),
887            "duplicate"
888        );
889        assert_eq!(
890            ApiError::Unsupported("unsupported".to_owned()).to_string(),
891            "unsupported"
892        );
893    }
894}