use std::collections::{BTreeMap, btree_map};
use std::fmt;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use rustc_hash::{FxHashMap, FxHasher};
use crate::{ParseError, PoVec, diagnostic_codes::DiagnosticCode};
use super::mt::MachineMetadata;
use super::plural::PluralProfile;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CatalogOrigin {
pub file: String,
pub scope: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ExtractedSingularMessage {
pub msgid: String,
pub msgctxt: Option<String>,
pub comments: Vec<String>,
pub origin: Vec<CatalogOrigin>,
pub placeholders: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct PluralSource {
pub one: Option<String>,
pub other: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ExtractedPluralMessage {
pub msgid: String,
pub msgctxt: Option<String>,
pub source: PluralSource,
pub comments: Vec<String>,
pub origin: Vec<CatalogOrigin>,
pub placeholders: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExtractedMessage {
Singular(ExtractedSingularMessage),
Plural(ExtractedPluralMessage),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SourceExtractedMessage {
pub msgid: String,
pub msgctxt: Option<String>,
pub comments: Vec<String>,
pub origin: Vec<CatalogOrigin>,
pub placeholders: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CatalogUpdateInput {
Structured(Vec<ExtractedMessage>),
SourceFirst(Vec<SourceExtractedMessage>),
}
impl Default for CatalogUpdateInput {
fn default() -> Self {
Self::Structured(Vec::new())
}
}
impl From<Vec<ExtractedMessage>> for CatalogUpdateInput {
fn from(value: Vec<ExtractedMessage>) -> Self {
Self::Structured(value)
}
}
impl From<Vec<SourceExtractedMessage>> for CatalogUpdateInput {
fn from(value: Vec<SourceExtractedMessage>) -> Self {
Self::SourceFirst(value)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
pub enum TranslationShape {
Singular {
value: String,
},
Plural {
source: PluralSource,
translation: BTreeMap<String, String>,
variable: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EffectiveTranslationRef<'a> {
Singular(&'a str),
Plural(&'a BTreeMap<String, String>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EffectiveTranslation {
Singular(String),
Plural(BTreeMap<String, String>),
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CatalogMessage {
pub msgid: String,
pub msgctxt: Option<String>,
pub translation: TranslationShape,
pub comments: Vec<String>,
pub origin: PoVec<CatalogOrigin>,
pub obsolete: Option<ObsoleteInfo>,
pub machine: Option<MachineMetadata>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct ObsoleteInfo {
pub since: Option<String>,
}
impl CatalogMessage {
#[must_use]
pub fn key(&self) -> CatalogMessageKey {
CatalogMessageKey {
msgid: self.msgid.clone(),
msgctxt: self.msgctxt.clone(),
}
}
#[must_use]
pub fn effective_translation(&self) -> EffectiveTranslationRef<'_> {
match &self.translation {
TranslationShape::Singular { value } => EffectiveTranslationRef::Singular(value),
TranslationShape::Plural { translation, .. } => {
EffectiveTranslationRef::Plural(translation)
}
}
}
pub(super) fn effective_translation_owned(&self) -> EffectiveTranslation {
match &self.translation {
TranslationShape::Singular { value } => EffectiveTranslation::Singular(value.clone()),
TranslationShape::Plural { translation, .. } => {
EffectiveTranslation::Plural(translation.clone())
}
}
}
pub(super) fn source_fallback_translation(&self, locale: Option<&str>) -> EffectiveTranslation {
match &self.translation {
TranslationShape::Singular { value } => {
if value.is_empty() {
EffectiveTranslation::Singular(self.msgid.clone())
} else {
EffectiveTranslation::Singular(value.clone())
}
}
TranslationShape::Plural {
source,
translation,
..
} => {
let profile = PluralProfile::for_locale(locale);
let mut effective = profile.materialize_translation(translation);
for category in profile.categories() {
let should_fill = effective.get(category).is_none_or(String::is_empty);
if should_fill {
effective.insert(
category.clone(),
profile.source_locale_value(category, source),
);
}
}
EffectiveTranslation::Plural(effective)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CatalogMessageKey {
pub msgid: String,
pub msgctxt: Option<String>,
}
impl CatalogMessageKey {
#[must_use]
pub fn new(msgid: impl Into<String>, msgctxt: Option<String>) -> Self {
Self {
msgid: msgid.into(),
msgctxt,
}
}
#[must_use]
pub fn with_context(msgid: impl Into<String>, msgctxt: impl Into<String>) -> Self {
Self {
msgid: msgid.into(),
msgctxt: Some(msgctxt.into()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
pub enum DiagnosticSeverity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct Diagnostic {
pub severity: DiagnosticSeverity,
pub code: DiagnosticCode,
pub message: String,
pub msgid: Option<String>,
pub msgctxt: Option<String>,
}
impl Diagnostic {
pub(super) fn new(
severity: DiagnosticSeverity,
code: impl Into<DiagnosticCode>,
message: impl Into<String>,
) -> Self {
Self {
severity,
code: code.into(),
message: message.into(),
msgid: None,
msgctxt: None,
}
}
pub(super) fn with_identity(mut self, msgid: &str, msgctxt: Option<&str>) -> Self {
self.msgid = Some(msgid.to_owned());
self.msgctxt = msgctxt.map(str::to_owned);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CatalogStats {
pub total: usize,
pub added: usize,
pub changed: usize,
pub unchanged: usize,
pub obsolete_marked: usize,
pub obsolete_removed: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogUpdateResult {
pub content: String,
pub created: bool,
pub updated: bool,
pub stats: CatalogStats,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CatalogCombineInput<'a> {
pub content: &'a str,
pub label: Option<&'a str>,
}
impl<'a> CatalogCombineInput<'a> {
#[must_use]
pub const fn new(content: &'a str) -> Self {
Self {
content,
label: None,
}
}
#[must_use]
pub const fn labeled(content: &'a str, label: &'a str) -> Self {
Self {
content,
label: Some(label),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CatalogConflictStrategy {
#[default]
UseFirst,
UseLast,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CatalogCombineSelection {
#[default]
All,
MoreThan(usize),
LessThan(usize),
Unique,
}
impl CatalogCombineSelection {
pub(super) const fn includes(self, definitions: usize) -> bool {
match self {
Self::All => true,
Self::MoreThan(limit) => definitions > limit,
Self::LessThan(limit) => definitions < limit,
Self::Unique => definitions < 2,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CatalogCombineStats {
pub inputs: usize,
pub definitions: usize,
pub selected: usize,
pub skipped: usize,
pub conflicts_resolved: usize,
pub total: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogCombineResult {
pub content: String,
pub stats: CatalogCombineStats,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum CatalogFileFormat {
#[default]
Po,
Fcl,
}
impl CatalogFileFormat {
pub fn infer_from_path(path: &Path) -> Result<Self, ApiError> {
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if name.ends_with(".po") || name.ends_with(".pot") {
return Ok(Self::Po);
}
if name.ends_with(".fcl") {
return Ok(Self::Fcl);
}
Err(ApiError::Unsupported(format!(
"could not infer catalog file format from `{}`; expected .po, .pot, or .fcl",
path.display()
)))
}
pub(super) const fn default_mode(self) -> CatalogMode {
match self {
Self::Po => CatalogMode::IcuPo,
Self::Fcl => CatalogMode::IcuFcl,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CombineCatalogFilesOptions<'a> {
pub input_paths: &'a [PathBuf],
pub output_path: &'a Path,
pub format: Option<CatalogFileFormat>,
pub mode: Option<CatalogMode>,
pub locale: Option<&'a str>,
pub source_locale: &'a str,
pub conflict_strategy: CatalogConflictStrategy,
pub selection: CatalogCombineSelection,
pub order_by: OrderBy,
pub include_origins: bool,
pub include_obsolete: bool,
}
impl<'a> CombineCatalogFilesOptions<'a> {
#[must_use]
pub fn new(input_paths: &'a [PathBuf], output_path: &'a Path, source_locale: &'a str) -> Self {
Self {
input_paths,
output_path,
format: None,
mode: None,
locale: None,
source_locale,
conflict_strategy: CatalogConflictStrategy::UseFirst,
selection: CatalogCombineSelection::All,
order_by: OrderBy::Msgid,
include_origins: true,
include_obsolete: false,
}
}
#[must_use]
pub fn with_input_paths(mut self, input_paths: &'a [PathBuf]) -> Self {
self.input_paths = input_paths;
self
}
#[must_use]
pub fn with_output_path(mut self, output_path: &'a Path) -> Self {
self.output_path = output_path;
self
}
#[must_use]
pub fn with_format(mut self, format: CatalogFileFormat) -> Self {
self.format = Some(format);
self
}
#[must_use]
pub fn with_mode(mut self, mode: CatalogMode) -> Self {
self.mode = Some(mode);
self
}
#[must_use]
pub fn with_locale(mut self, locale: &'a str) -> Self {
self.locale = Some(locale);
self
}
#[must_use]
pub fn with_conflict_strategy(mut self, conflict_strategy: CatalogConflictStrategy) -> Self {
self.conflict_strategy = conflict_strategy;
self
}
#[must_use]
pub fn with_selection(mut self, selection: CatalogCombineSelection) -> Self {
self.selection = selection;
self
}
#[must_use]
pub fn with_order_by(mut self, order_by: OrderBy) -> Self {
self.order_by = order_by;
self
}
#[must_use]
pub fn with_include_origins(mut self, include_origins: bool) -> Self {
self.include_origins = include_origins;
self
}
#[must_use]
pub fn with_include_obsolete(mut self, include_obsolete: bool) -> Self {
self.include_obsolete = include_obsolete;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogFileCombineResult {
pub output_path: PathBuf,
pub format: CatalogFileFormat,
pub stats: CatalogCombineStats,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParsedCatalog {
pub locale: Option<String>,
pub semantics: CatalogSemantics,
pub headers: BTreeMap<String, String>,
pub messages: Vec<CatalogMessage>,
pub diagnostics: Vec<Diagnostic>,
}
impl ParsedCatalog {
pub fn into_normalized_view(self) -> Result<NormalizedParsedCatalog, ApiError> {
NormalizedParsedCatalog::new(self)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct NormalizedParsedCatalog {
pub(super) catalog: ParsedCatalog,
pub(super) key_index: BTreeMap<CatalogMessageKey, usize>,
msgid_hash_index: FxHashMap<u64, Vec<usize>>,
}
impl NormalizedParsedCatalog {
pub(super) fn new(catalog: ParsedCatalog) -> Result<Self, ApiError> {
let mut key_index = BTreeMap::new();
let mut msgid_hash_index = FxHashMap::<u64, Vec<usize>>::with_capacity_and_hasher(
catalog.messages.len(),
Default::default(),
);
for (index, message) in catalog.messages.iter().enumerate() {
let key = message.key();
match key_index.entry(key) {
btree_map::Entry::Vacant(entry) => {
entry.insert(index);
}
btree_map::Entry::Occupied(entry) => {
let key = entry.key();
return Err(ApiError::Conflict(format!(
"duplicate parsed catalog message for msgid {:?} and context {:?}",
key.msgid, key.msgctxt
)));
}
}
msgid_hash_index
.entry(message_id_hash(&message.msgid))
.or_default()
.push(index);
}
Ok(Self {
catalog,
key_index,
msgid_hash_index,
})
}
#[must_use]
pub const fn parsed_catalog(&self) -> &ParsedCatalog {
&self.catalog
}
#[must_use]
pub fn into_parsed_catalog(self) -> ParsedCatalog {
self.catalog
}
#[must_use]
pub fn get(&self, key: &CatalogMessageKey) -> Option<&CatalogMessage> {
self.key_index
.get(key)
.map(|index| &self.catalog.messages[*index])
}
#[must_use]
pub fn get_by_parts(&self, msgid: &str, msgctxt: Option<&str>) -> Option<&CatalogMessage> {
self.msgid_hash_index
.get(&message_id_hash(msgid))?
.iter()
.find_map(|index| {
let message = &self.catalog.messages[*index];
(message.msgid.as_str() == msgid && message.msgctxt.as_deref() == msgctxt)
.then_some(message)
})
}
#[must_use]
pub fn contains_key(&self, key: &CatalogMessageKey) -> bool {
self.key_index.contains_key(key)
}
#[must_use]
pub fn contains_parts(&self, msgid: &str, msgctxt: Option<&str>) -> bool {
self.get_by_parts(msgid, msgctxt).is_some()
}
#[must_use]
pub fn message_count(&self) -> usize {
self.catalog.messages.len()
}
pub fn iter(&self) -> impl Iterator<Item = (&CatalogMessageKey, &CatalogMessage)> + '_ {
self.key_index
.iter()
.map(|(key, index)| (key, &self.catalog.messages[*index]))
}
pub fn effective_translation(
&self,
key: &CatalogMessageKey,
) -> Option<EffectiveTranslationRef<'_>> {
self.get(key).map(CatalogMessage::effective_translation)
}
pub fn effective_translation_by_parts(
&self,
msgid: &str,
msgctxt: Option<&str>,
) -> Option<EffectiveTranslationRef<'_>> {
self.get_by_parts(msgid, msgctxt)
.map(CatalogMessage::effective_translation)
}
#[must_use]
pub fn effective_translation_with_source_fallback(
&self,
key: &CatalogMessageKey,
source_locale: &str,
) -> Option<EffectiveTranslation> {
let message = self.get(key)?;
Some(self.effective_translation_for_message(message, source_locale))
}
#[must_use]
pub fn effective_translation_with_source_fallback_by_parts(
&self,
msgid: &str,
msgctxt: Option<&str>,
source_locale: &str,
) -> Option<EffectiveTranslation> {
let message = self.get_by_parts(msgid, msgctxt)?;
Some(self.effective_translation_for_message(message, source_locale))
}
pub(super) fn effective_translation_for_message(
&self,
message: &CatalogMessage,
source_locale: &str,
) -> EffectiveTranslation {
if self
.catalog
.locale
.as_deref()
.is_none_or(|locale| locale == source_locale)
{
message.source_fallback_translation(self.catalog.locale.as_deref())
} else {
message.effective_translation_owned()
}
}
}
fn message_id_hash(msgid: &str) -> u64 {
let mut hasher = FxHasher::default();
msgid.hash(&mut hasher);
hasher.finish()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PluralEncoding {
#[default]
Icu,
Gettext,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum CatalogStorageFormat {
#[default]
Po,
Fcl,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CatalogSemantics {
#[default]
IcuNative,
GettextCompat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum IcuSyntaxPolicy {
#[default]
Strict,
RuntimeLiteralApostrophes,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum CatalogMode {
#[default]
IcuPo,
GettextPo,
IcuFcl,
}
impl CatalogMode {
#[must_use]
pub const fn storage_format(self) -> CatalogStorageFormat {
match self {
Self::IcuPo | Self::GettextPo => CatalogStorageFormat::Po,
Self::IcuFcl => CatalogStorageFormat::Fcl,
}
}
#[must_use]
pub const fn semantics(self) -> CatalogSemantics {
match self {
Self::IcuPo | Self::IcuFcl => CatalogSemantics::IcuNative,
Self::GettextPo => CatalogSemantics::GettextCompat,
}
}
#[must_use]
pub const fn plural_encoding(self) -> PluralEncoding {
match self {
Self::IcuPo | Self::IcuFcl => PluralEncoding::Icu,
Self::GettextPo => PluralEncoding::Gettext,
}
}
#[must_use]
pub const fn from_parts(
storage_format: CatalogStorageFormat,
semantics: CatalogSemantics,
plural_encoding: PluralEncoding,
) -> Option<Self> {
match (storage_format, semantics, plural_encoding) {
(CatalogStorageFormat::Po, CatalogSemantics::IcuNative, PluralEncoding::Icu) => {
Some(Self::IcuPo)
}
(CatalogStorageFormat::Fcl, CatalogSemantics::IcuNative, PluralEncoding::Icu) => {
Some(Self::IcuFcl)
}
(
CatalogStorageFormat::Po,
CatalogSemantics::GettextCompat,
PluralEncoding::Gettext,
) => Some(Self::GettextPo),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ObsoleteStrategy {
#[default]
Mark,
Delete,
Keep,
DropObsoleteBefore(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OrderBy {
#[default]
Msgid,
Origin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlaceholderCommentMode {
Disabled,
Enabled {
limit: usize,
},
}
impl Default for PlaceholderCommentMode {
fn default() -> Self {
Self::Enabled { limit: 3 }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RenderOptions<'a> {
pub order_by: OrderBy,
pub include_origins: bool,
pub print_placeholders_in_comments: PlaceholderCommentMode,
pub custom_header_attributes: Option<&'a BTreeMap<String, String>>,
}
impl Default for RenderOptions<'_> {
fn default() -> Self {
Self {
order_by: OrderBy::Msgid,
include_origins: true,
print_placeholders_in_comments: PlaceholderCommentMode::Enabled { limit: 3 },
custom_header_attributes: None,
}
}
}
impl<'a> RenderOptions<'a> {
#[must_use]
pub fn with_order_by(mut self, order_by: OrderBy) -> Self {
self.order_by = order_by;
self
}
#[must_use]
pub fn with_include_origins(mut self, include_origins: bool) -> Self {
self.include_origins = include_origins;
self
}
#[must_use]
pub fn with_placeholder_comments(
mut self,
print_placeholders_in_comments: PlaceholderCommentMode,
) -> Self {
self.print_placeholders_in_comments = print_placeholders_in_comments;
self
}
#[must_use]
pub fn with_custom_header_attributes(
mut self,
custom_header_attributes: &'a BTreeMap<String, String>,
) -> Self {
self.custom_header_attributes = Some(custom_header_attributes);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct UpdateCatalogOptions<'a> {
pub locale: Option<&'a str>,
pub source_locale: &'a str,
pub input: CatalogUpdateInput,
pub existing: Option<&'a str>,
pub mode: CatalogMode,
pub obsolete_strategy: ObsoleteStrategy,
pub overwrite_source_translations: bool,
pub now: Option<&'a str>,
pub render: RenderOptions<'a>,
}
impl<'a> UpdateCatalogOptions<'a> {
#[must_use]
pub fn new(source_locale: &'a str, input: impl Into<CatalogUpdateInput>) -> Self {
Self {
locale: None,
source_locale,
input: input.into(),
existing: None,
mode: CatalogMode::default(),
obsolete_strategy: ObsoleteStrategy::Mark,
overwrite_source_translations: false,
now: None,
render: RenderOptions::default(),
}
}
#[must_use]
pub fn with_locale(mut self, locale: &'a str) -> Self {
self.locale = Some(locale);
self
}
#[must_use]
pub fn with_existing(mut self, existing: &'a str) -> Self {
self.existing = Some(existing);
self
}
#[must_use]
pub fn with_mode(mut self, mode: CatalogMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub fn with_render(mut self, render: RenderOptions<'a>) -> Self {
self.render = render;
self
}
#[must_use]
pub fn with_obsolete_strategy(mut self, obsolete_strategy: ObsoleteStrategy) -> Self {
self.obsolete_strategy = obsolete_strategy;
self
}
#[must_use]
pub fn with_overwrite_source_translations(
mut self,
overwrite_source_translations: bool,
) -> Self {
self.overwrite_source_translations = overwrite_source_translations;
self
}
#[must_use]
pub fn with_now(mut self, now: &'a str) -> Self {
self.now = Some(now);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct UpdateCatalogFileOptions<'a> {
pub target_path: &'a Path,
pub options: UpdateCatalogOptions<'a>,
}
impl<'a> UpdateCatalogFileOptions<'a> {
#[must_use]
pub fn new(
target_path: &'a Path,
source_locale: &'a str,
input: impl Into<CatalogUpdateInput>,
) -> Self {
Self {
target_path,
options: UpdateCatalogOptions::new(source_locale, input),
}
}
#[must_use]
pub fn with_options(mut self, options: UpdateCatalogOptions<'a>) -> Self {
self.options = options;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CombineCatalogOptions<'a> {
pub inputs: &'a [CatalogCombineInput<'a>],
pub locale: Option<&'a str>,
pub source_locale: &'a str,
pub mode: CatalogMode,
pub conflict_strategy: CatalogConflictStrategy,
pub selection: CatalogCombineSelection,
pub order_by: OrderBy,
pub include_origins: bool,
pub include_obsolete: bool,
}
impl<'a> CombineCatalogOptions<'a> {
#[must_use]
pub fn new(inputs: &'a [CatalogCombineInput<'a>], source_locale: &'a str) -> Self {
Self {
inputs,
locale: None,
source_locale,
mode: CatalogMode::default(),
conflict_strategy: CatalogConflictStrategy::UseFirst,
selection: CatalogCombineSelection::All,
order_by: OrderBy::Msgid,
include_origins: true,
include_obsolete: false,
}
}
#[must_use]
pub fn with_inputs(mut self, inputs: &'a [CatalogCombineInput<'a>]) -> Self {
self.inputs = inputs;
self
}
#[must_use]
pub fn with_locale(mut self, locale: &'a str) -> Self {
self.locale = Some(locale);
self
}
#[must_use]
pub fn with_mode(mut self, mode: CatalogMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub fn with_conflict_strategy(mut self, conflict_strategy: CatalogConflictStrategy) -> Self {
self.conflict_strategy = conflict_strategy;
self
}
#[must_use]
pub fn with_selection(mut self, selection: CatalogCombineSelection) -> Self {
self.selection = selection;
self
}
#[must_use]
pub fn with_order_by(mut self, order_by: OrderBy) -> Self {
self.order_by = order_by;
self
}
#[must_use]
pub fn with_include_origins(mut self, include_origins: bool) -> Self {
self.include_origins = include_origins;
self
}
#[must_use]
pub fn with_include_obsolete(mut self, include_obsolete: bool) -> Self {
self.include_obsolete = include_obsolete;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ParseCatalogOptions<'a> {
pub content: &'a str,
pub locale: Option<&'a str>,
pub source_locale: &'a str,
pub mode: CatalogMode,
pub strict: bool,
}
impl<'a> ParseCatalogOptions<'a> {
#[must_use]
pub fn new(content: &'a str, source_locale: &'a str) -> Self {
Self {
content,
locale: None,
source_locale,
mode: CatalogMode::default(),
strict: false,
}
}
#[must_use]
pub fn with_locale(mut self, locale: &'a str) -> Self {
self.locale = Some(locale);
self
}
#[must_use]
pub fn with_mode(mut self, mode: CatalogMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub fn with_strict(mut self, strict: bool) -> Self {
self.strict = strict;
self
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ApiError {
Parse(ParseError),
Io(std::io::Error),
InvalidArguments(String),
Conflict(String),
Unsupported(String),
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Parse(error) => error.fmt(f),
Self::Io(error) => error.fmt(f),
Self::InvalidArguments(message)
| Self::Conflict(message)
| Self::Unsupported(message) => f.write_str(message),
}
}
}
impl std::error::Error for ApiError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Parse(error) => Some(error),
Self::Io(error) => Some(error),
Self::InvalidArguments(_) | Self::Conflict(_) | Self::Unsupported(_) => None,
}
}
}
impl ApiError {
#[must_use]
pub fn io_with_path(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
let kind = source.kind();
Self::Io(std::io::Error::new(
kind,
PathIoError {
path: path.into(),
source,
},
))
}
#[must_use]
pub fn path(&self) -> Option<&Path> {
match self {
Self::Io(error) => error
.get_ref()
.and_then(|source| source.downcast_ref::<PathIoError>())
.map(|error| error.path.as_path()),
Self::Parse(_)
| Self::InvalidArguments(_)
| Self::Conflict(_)
| Self::Unsupported(_) => None,
}
}
}
#[derive(Debug)]
struct PathIoError {
path: PathBuf,
source: std::io::Error,
}
impl fmt::Display for PathIoError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"I/O error for `{}`: {}",
self.path.display(),
self.source
)
}
}
impl std::error::Error for PathIoError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
impl From<ParseError> for ApiError {
fn from(value: ParseError) -> Self {
Self::Parse(value)
}
}
impl From<std::io::Error> for ApiError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::error::Error;
use std::io;
use std::path::{Path, PathBuf};
use super::{
ApiError, CatalogCombineInput, CatalogCombineSelection, CatalogConflictStrategy,
CatalogFileFormat, CatalogMessage, CatalogMessageKey, CatalogMode, CatalogSemantics,
CatalogStorageFormat, CatalogUpdateInput, CombineCatalogFilesOptions,
CombineCatalogOptions, Diagnostic, DiagnosticSeverity, EffectiveTranslation,
EffectiveTranslationRef, NormalizedParsedCatalog, ObsoleteStrategy, OrderBy,
ParseCatalogOptions, ParsedCatalog, PlaceholderCommentMode, PluralEncoding, PluralSource,
RenderOptions, TranslationShape, UpdateCatalogFileOptions, UpdateCatalogOptions,
};
use crate::ParseError;
#[test]
fn catalog_update_input_defaults_and_conversions_use_expected_variants() {
assert!(matches!(
CatalogUpdateInput::default(),
CatalogUpdateInput::Structured(messages) if messages.is_empty()
));
assert!(matches!(
CatalogUpdateInput::from(Vec::<super::ExtractedMessage>::new()),
CatalogUpdateInput::Structured(messages) if messages.is_empty()
));
assert!(matches!(
CatalogUpdateInput::from(Vec::<super::SourceExtractedMessage>::new()),
CatalogUpdateInput::SourceFirst(messages) if messages.is_empty()
));
}
#[test]
fn catalog_message_helpers_cover_key_and_fallback_behavior() {
let singular = CatalogMessage {
msgid: "Hello".to_owned(),
msgctxt: Some("button".to_owned()),
translation: TranslationShape::Singular {
value: String::new(),
},
comments: vec!["Shown in toolbar".to_owned()],
origin: crate::PoVec::new(),
obsolete: None,
machine: None,
};
assert_eq!(
singular.key(),
CatalogMessageKey::with_context("Hello", "button")
);
assert!(matches!(
singular.effective_translation(),
EffectiveTranslationRef::Singular("")
));
assert_eq!(
singular.source_fallback_translation(Some("en")),
EffectiveTranslation::Singular("Hello".to_owned())
);
let plural = CatalogMessage {
msgid: "{count, plural, one {# file} other {# files}}".to_owned(),
msgctxt: None,
translation: TranslationShape::Plural {
source: PluralSource {
one: Some("{count} file".to_owned()),
other: "{count} files".to_owned(),
},
translation: BTreeMap::from([
("one".to_owned(), String::new()),
("other".to_owned(), "{count} Dateien".to_owned()),
]),
variable: "count".to_owned(),
},
comments: Vec::new(),
origin: crate::PoVec::new(),
obsolete: None,
machine: None,
};
assert!(matches!(
plural.effective_translation(),
EffectiveTranslationRef::Plural(values)
if values.get("other") == Some(&"{count} Dateien".to_owned())
));
assert_eq!(
plural.source_fallback_translation(Some("de")),
EffectiveTranslation::Plural(BTreeMap::from([
("one".to_owned(), "{count} file".to_owned()),
("other".to_owned(), "{count} Dateien".to_owned()),
]))
);
}
#[test]
fn normalized_catalog_helpers_expose_lookup_and_source_fallback_views() {
let parsed = ParsedCatalog {
locale: Some("en".to_owned()),
semantics: CatalogSemantics::IcuNative,
headers: BTreeMap::new(),
messages: vec![
CatalogMessage {
msgid: "Hello".to_owned(),
msgctxt: None,
translation: TranslationShape::Singular {
value: String::new(),
},
comments: Vec::new(),
origin: crate::PoVec::new(),
obsolete: None,
machine: None,
},
CatalogMessage {
msgid: "Hello".to_owned(),
msgctxt: Some("button".to_owned()),
translation: TranslationShape::Singular {
value: "Howdy".to_owned(),
},
comments: Vec::new(),
origin: crate::PoVec::new(),
obsolete: None,
machine: None,
},
],
diagnostics: Vec::new(),
};
let normalized = NormalizedParsedCatalog::new(parsed.clone()).expect("normalized");
let key = CatalogMessageKey::new("Hello", None);
assert_eq!(normalized.message_count(), 2);
assert!(normalized.contains_key(&key));
assert!(normalized.contains_parts("Hello", Some("button")));
assert_eq!(
normalized.parsed_catalog().semantics,
CatalogSemantics::IcuNative
);
assert!(normalized.get(&key).is_some());
assert_eq!(
normalized
.get_by_parts("Hello", Some("button"))
.and_then(|message| match message.effective_translation() {
EffectiveTranslationRef::Singular(value) => Some(value),
EffectiveTranslationRef::Plural(_) => None,
}),
Some("Howdy")
);
assert!(matches!(
normalized.effective_translation_by_parts("Hello", None),
Some(EffectiveTranslationRef::Singular(""))
));
assert_eq!(
normalized.effective_translation_with_source_fallback(&key, "en"),
Some(EffectiveTranslation::Singular("Hello".to_owned()))
);
assert_eq!(
normalized.effective_translation_with_source_fallback_by_parts(
"Hello",
Some("button"),
"en"
),
Some(EffectiveTranslation::Singular("Howdy".to_owned()))
);
assert_eq!(normalized.into_parsed_catalog(), parsed);
}
#[test]
fn option_defaults_reflect_native_po_defaults() {
let update = UpdateCatalogOptions::new("en", Vec::<super::ExtractedMessage>::new());
assert_eq!(update.mode, CatalogMode::IcuPo);
assert_eq!(update.obsolete_strategy, ObsoleteStrategy::Mark);
assert_eq!(update.render.order_by, OrderBy::Msgid);
assert!(update.render.include_origins);
assert_eq!(
update.render.print_placeholders_in_comments,
PlaceholderCommentMode::Enabled { limit: 3 }
);
assert_eq!(update.source_locale, "en");
assert!(matches!(
update.input,
CatalogUpdateInput::Structured(messages) if messages.is_empty()
));
let update_file = UpdateCatalogFileOptions::new(
Path::new("locale/de.po"),
"en",
Vec::<super::SourceExtractedMessage>::new(),
);
assert_eq!(update_file.target_path, Path::new("locale/de.po"));
assert_eq!(update_file.options.mode, CatalogMode::IcuPo);
assert_eq!(update_file.options.source_locale, "en");
assert!(matches!(
update_file.options.input,
CatalogUpdateInput::SourceFirst(messages) if messages.is_empty()
));
let parse = ParseCatalogOptions::new("msgid \"Hello\"\nmsgstr \"Hallo\"\n", "en");
assert_eq!(parse.content, "msgid \"Hello\"\nmsgstr \"Hallo\"\n");
assert_eq!(parse.source_locale, "en");
assert_eq!(parse.mode, CatalogMode::IcuPo);
assert!(!parse.strict);
let inputs = [CatalogCombineInput::labeled(
"msgid \"Hello\"\nmsgstr \"Hallo\"\n",
"de.po",
)];
let combine = CombineCatalogOptions::new(&inputs, "en");
assert_eq!(combine.inputs, &inputs);
assert_eq!(combine.source_locale, "en");
assert_eq!(combine.mode, CatalogMode::IcuPo);
assert_eq!(combine.conflict_strategy, CatalogConflictStrategy::UseFirst);
assert_eq!(combine.selection, CatalogCombineSelection::All);
assert!(combine.include_origins);
assert!(!combine.include_obsolete);
let input_paths = [PathBuf::from("locale/de.po")];
let combine_files =
CombineCatalogFilesOptions::new(&input_paths, Path::new("locale/merged.po"), "en");
assert_eq!(combine_files.input_paths, &input_paths);
assert_eq!(combine_files.output_path, Path::new("locale/merged.po"));
assert_eq!(combine_files.format, None);
assert_eq!(combine_files.mode, None);
assert_eq!(combine_files.source_locale, "en");
assert_eq!(
combine_files.conflict_strategy,
CatalogConflictStrategy::UseFirst
);
assert_eq!(combine_files.selection, CatalogCombineSelection::All);
assert!(combine_files.include_origins);
assert!(!combine_files.include_obsolete);
}
#[test]
fn catalog_option_builders_set_fields() {
let headers = BTreeMap::from([("X-Generator".to_owned(), "ferrocat".to_owned())]);
let render = RenderOptions::default()
.with_order_by(OrderBy::Origin)
.with_include_origins(false)
.with_placeholder_comments(PlaceholderCommentMode::Disabled)
.with_custom_header_attributes(&headers);
assert_eq!(render.order_by, OrderBy::Origin);
assert!(!render.include_origins);
assert_eq!(
render.print_placeholders_in_comments,
PlaceholderCommentMode::Disabled
);
assert_eq!(render.custom_header_attributes, Some(&headers));
let update = UpdateCatalogOptions::new("en", CatalogUpdateInput::default())
.with_locale("de")
.with_existing("msgid \"Hello\"\nmsgstr \"Hallo\"\n")
.with_mode(CatalogMode::GettextPo)
.with_render(render.clone())
.with_obsolete_strategy(ObsoleteStrategy::Delete)
.with_overwrite_source_translations(true)
.with_now("2026-07-02");
assert_eq!(update.locale, Some("de"));
assert_eq!(update.existing, Some("msgid \"Hello\"\nmsgstr \"Hallo\"\n"));
assert_eq!(update.mode, CatalogMode::GettextPo);
assert_eq!(update.render, render);
assert_eq!(update.obsolete_strategy, ObsoleteStrategy::Delete);
assert!(update.overwrite_source_translations);
assert_eq!(update.now, Some("2026-07-02"));
let parse = ParseCatalogOptions::new("content", "en")
.with_locale("fr")
.with_mode(CatalogMode::IcuFcl)
.with_strict(true);
assert_eq!(parse.locale, Some("fr"));
assert_eq!(parse.mode, CatalogMode::IcuFcl);
assert!(parse.strict);
let input_paths = [PathBuf::from("base.po"), PathBuf::from("overlay.po")];
let output_path = Path::new("messages.fcl");
let combine_files = CombineCatalogFilesOptions::new(&[], Path::new("unused.po"), "en")
.with_input_paths(&input_paths)
.with_output_path(output_path)
.with_format(CatalogFileFormat::Fcl)
.with_mode(CatalogMode::IcuFcl)
.with_locale("de")
.with_conflict_strategy(CatalogConflictStrategy::UseLast)
.with_selection(CatalogCombineSelection::Unique)
.with_order_by(OrderBy::Origin)
.with_include_origins(false)
.with_include_obsolete(true);
assert_eq!(combine_files.input_paths, &input_paths);
assert_eq!(combine_files.output_path, output_path);
assert_eq!(combine_files.format, Some(CatalogFileFormat::Fcl));
assert_eq!(combine_files.mode, Some(CatalogMode::IcuFcl));
assert_eq!(combine_files.locale, Some("de"));
assert_eq!(
combine_files.conflict_strategy,
CatalogConflictStrategy::UseLast
);
assert_eq!(combine_files.selection, CatalogCombineSelection::Unique);
assert_eq!(combine_files.order_by, OrderBy::Origin);
assert!(!combine_files.include_origins);
assert!(combine_files.include_obsolete);
let inputs = [CatalogCombineInput::labeled(
"msgid \"Hello\"\nmsgstr \"Hallo\"\n",
"base",
)];
let combine = CombineCatalogOptions::new(&[], "en")
.with_inputs(&inputs)
.with_locale("de")
.with_mode(CatalogMode::GettextPo)
.with_conflict_strategy(CatalogConflictStrategy::UseLast)
.with_selection(CatalogCombineSelection::Unique)
.with_order_by(OrderBy::Origin)
.with_include_origins(false)
.with_include_obsolete(true);
assert_eq!(combine.inputs, &inputs);
assert_eq!(combine.locale, Some("de"));
assert_eq!(combine.mode, CatalogMode::GettextPo);
assert_eq!(combine.conflict_strategy, CatalogConflictStrategy::UseLast);
assert_eq!(combine.selection, CatalogCombineSelection::Unique);
assert_eq!(combine.order_by, OrderBy::Origin);
assert!(!combine.include_origins);
assert!(combine.include_obsolete);
let file_update = UpdateCatalogFileOptions::new(
Path::new("messages.po"),
"en",
CatalogUpdateInput::default(),
)
.with_options(update.clone());
assert_eq!(file_update.target_path, Path::new("messages.po"));
assert_eq!(file_update.options, update);
}
#[test]
fn catalog_file_format_infers_supported_suffixes() {
assert_eq!(
CatalogFileFormat::infer_from_path(Path::new("locale/de.po")).expect("po"),
CatalogFileFormat::Po
);
assert_eq!(
CatalogFileFormat::infer_from_path(Path::new("locale/messages.POT")).expect("pot"),
CatalogFileFormat::Po
);
assert_eq!(
CatalogFileFormat::infer_from_path(Path::new("locale/de.fcl")).expect("fcl"),
CatalogFileFormat::Fcl
);
assert!(matches!(
CatalogFileFormat::infer_from_path(Path::new("locale/de.txt")),
Err(ApiError::Unsupported(message)) if message.contains("could not infer")
));
}
#[test]
fn catalog_mode_maps_only_supported_catalog_combinations() {
assert_eq!(CatalogMode::default(), CatalogMode::IcuPo);
assert_eq!(
CatalogMode::IcuPo.storage_format(),
CatalogStorageFormat::Po
);
assert_eq!(CatalogMode::IcuPo.semantics(), CatalogSemantics::IcuNative);
assert_eq!(CatalogMode::IcuPo.plural_encoding(), PluralEncoding::Icu);
assert_eq!(
CatalogMode::from_parts(
CatalogStorageFormat::Fcl,
CatalogSemantics::IcuNative,
PluralEncoding::Icu
),
Some(CatalogMode::IcuFcl)
);
assert_eq!(
CatalogMode::from_parts(
CatalogStorageFormat::Po,
CatalogSemantics::GettextCompat,
PluralEncoding::Gettext
),
Some(CatalogMode::GettextPo)
);
assert_eq!(
CatalogMode::from_parts(
CatalogStorageFormat::Fcl,
CatalogSemantics::GettextCompat,
PluralEncoding::Gettext
),
None
);
assert_eq!(
CatalogMode::from_parts(
CatalogStorageFormat::Po,
CatalogSemantics::IcuNative,
PluralEncoding::Icu
),
Some(CatalogMode::IcuPo)
);
let update = UpdateCatalogOptions::new("en", Vec::<super::SourceExtractedMessage>::new())
.with_mode(CatalogMode::GettextPo);
assert_eq!(update.mode, CatalogMode::GettextPo);
}
#[test]
fn diagnostics_and_api_errors_preserve_human_readable_messages() {
let diagnostic = Diagnostic::new(DiagnosticSeverity::Warning, "code", "message")
.with_identity("Hello", Some("button"));
assert_eq!(diagnostic.severity, DiagnosticSeverity::Warning);
assert_eq!(diagnostic.code, "code");
assert_eq!(diagnostic.message, "message");
assert_eq!(diagnostic.msgid.as_deref(), Some("Hello"));
assert_eq!(diagnostic.msgctxt.as_deref(), Some("button"));
let io_error = ApiError::from(io::Error::other("disk"));
assert_eq!(io_error.to_string(), "disk");
assert_eq!(
io_error.source().map(ToString::to_string).as_deref(),
Some("disk")
);
assert_eq!(io_error.path(), None);
let io_path_error = ApiError::io_with_path(
Path::new("locale/de.po"),
io::Error::other("permission denied"),
);
assert_eq!(io_path_error.path(), Some(Path::new("locale/de.po")));
let source = io_path_error.source().expect("io source");
assert!(source.to_string().contains("locale/de.po"));
assert_eq!(
source.source().map(ToString::to_string).as_deref(),
Some("permission denied")
);
assert!(io_path_error.to_string().contains("locale/de.po"));
let parse_error = ApiError::from(ParseError::new("bad syntax"));
assert_eq!(
parse_error.source().map(ToString::to_string).as_deref(),
Some("bad syntax")
);
assert_eq!(
ApiError::InvalidArguments("bad input".to_owned()).to_string(),
"bad input"
);
assert!(
ApiError::InvalidArguments("bad input".to_owned())
.source()
.is_none()
);
assert_eq!(
ApiError::Conflict("duplicate".to_owned()).to_string(),
"duplicate"
);
assert_eq!(
ApiError::Unsupported("unsupported".to_owned()).to_string(),
"unsupported"
);
}
#[cfg(feature = "serde")]
#[test]
fn catalog_message_and_diagnostic_serde_use_stable_public_shapes() {
let message = CatalogMessage {
msgid: "Hello".to_owned(),
msgctxt: Some("button".to_owned()),
translation: TranslationShape::Singular {
value: "Hallo".to_owned(),
},
comments: vec!["Shown in toolbar".to_owned()],
origin: crate::PoVec::new(),
obsolete: None,
machine: None,
};
let message_json =
serde_json::to_value(&message).expect("catalog message serialization must succeed");
assert_eq!(message_json["translation"]["kind"], "singular");
let roundtrip_message: CatalogMessage = serde_json::from_value(message_json)
.expect("catalog message deserialization must succeed");
assert_eq!(roundtrip_message, message);
let diagnostic = Diagnostic::new(
DiagnosticSeverity::Error,
"catalog.missing",
"missing translation",
)
.with_identity("Hello", Some("button"));
let diagnostic_json =
serde_json::to_value(&diagnostic).expect("diagnostic serialization must succeed");
assert_eq!(diagnostic_json["severity"], "error");
let roundtrip_diagnostic: Diagnostic = serde_json::from_value(diagnostic_json)
.expect("diagnostic deserialization must succeed");
assert_eq!(roundtrip_diagnostic, diagnostic);
}
}