use std::collections::BTreeMap;
use std::fmt;
use std::path::{Path, PathBuf};
use crate::ParseError;
use super::mt::MachineTranslationMetadata;
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 line: Option<u32>,
}
#[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, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CatalogMessageExtra {
pub translator_comments: Vec<String>,
pub flags: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[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: Vec<CatalogOrigin>,
pub obsolete: bool,
pub machine_translation: Option<MachineTranslationMetadata>,
pub extra: Option<CatalogMessageExtra>,
}
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,
}
}
}
#[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: String,
pub message: String,
pub msgid: Option<String>,
pub msgctxt: Option<String>,
}
impl Diagnostic {
pub(super) fn new(
severity: DiagnosticSeverity,
code: impl Into<String>,
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, PartialEq, Eq)]
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, Eq)]
pub struct NormalizedParsedCatalog {
pub(super) catalog: ParsedCatalog,
pub(super) key_index: BTreeMap<CatalogMessageKey, usize>,
msgid_index: BTreeMap<String, Vec<usize>>,
}
impl NormalizedParsedCatalog {
pub(super) fn new(catalog: ParsedCatalog) -> Result<Self, ApiError> {
let mut key_index = BTreeMap::new();
let mut msgid_index = BTreeMap::<String, Vec<usize>>::new();
for (index, message) in catalog.messages.iter().enumerate() {
let key = message.key();
if key_index.insert(key.clone(), index).is_some() {
return Err(ApiError::Conflict(format!(
"duplicate parsed catalog message for msgid {:?} and context {:?}",
key.msgid, key.msgctxt
)));
}
msgid_index.entry(key.msgid).or_default().push(index);
}
Ok(Self {
catalog,
key_index,
msgid_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_index.get(msgid)?.iter().find_map(|index| {
let message = &self.catalog.messages[*index];
(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))
}
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()
}
}
}
#[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,
Ndjson,
}
#[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,
IcuNdjson,
GettextPo,
}
impl CatalogMode {
#[must_use]
pub const fn storage_format(self) -> CatalogStorageFormat {
match self {
Self::IcuPo | Self::GettextPo => CatalogStorageFormat::Po,
Self::IcuNdjson => CatalogStorageFormat::Ndjson,
}
}
#[must_use]
pub const fn semantics(self) -> CatalogSemantics {
match self {
Self::IcuPo | Self::IcuNdjson => CatalogSemantics::IcuNative,
Self::GettextPo => CatalogSemantics::GettextCompat,
}
}
#[must_use]
pub const fn plural_encoding(self) -> PluralEncoding {
match self {
Self::IcuPo | Self::IcuNdjson => 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::Ndjson, CatalogSemantics::IcuNative, PluralEncoding::Icu) => {
Some(Self::IcuNdjson)
}
(
CatalogStorageFormat::Po,
CatalogSemantics::GettextCompat,
PluralEncoding::Gettext,
) => Some(Self::GettextPo),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ObsoleteStrategy {
#[default]
Mark,
Delete,
Keep,
}
#[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)]
pub struct RenderOptions<'a> {
pub order_by: OrderBy,
pub include_origins: bool,
pub include_line_numbers: 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,
include_line_numbers: true,
print_placeholders_in_comments: PlaceholderCommentMode::Enabled { limit: 3 },
custom_header_attributes: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
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 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,
render: RenderOptions::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
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),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
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_line_numbers: 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_line_numbers: true,
include_obsolete: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
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,
}
}
}
#[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;
use super::{
ApiError, CatalogCombineInput, CatalogCombineSelection, CatalogConflictStrategy,
CatalogMessage, CatalogMessageExtra, CatalogMessageKey, CatalogMode, CatalogSemantics,
CatalogStorageFormat, CatalogUpdateInput, CombineCatalogOptions, Diagnostic,
DiagnosticSeverity, EffectiveTranslation, EffectiveTranslationRef, NormalizedParsedCatalog,
ObsoleteStrategy, OrderBy, ParseCatalogOptions, ParsedCatalog, PlaceholderCommentMode,
PluralEncoding, PluralSource, 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: Vec::new(),
obsolete: false,
machine_translation: None,
extra: Some(CatalogMessageExtra {
translator_comments: vec!["Imperative".to_owned()],
flags: vec!["fuzzy".to_owned()],
}),
};
assert_eq!(
singular.key(),
CatalogMessageKey::new("Hello", Some("button".to_owned()))
);
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: Vec::new(),
obsolete: false,
machine_translation: None,
extra: 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: Vec::new(),
obsolete: false,
machine_translation: None,
extra: None,
},
CatalogMessage {
msgid: "Hello".to_owned(),
msgctxt: Some("button".to_owned()),
translation: TranslationShape::Singular {
value: "Howdy".to_owned(),
},
comments: Vec::new(),
origin: Vec::new(),
obsolete: false,
machine_translation: None,
extra: 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!(update.render.include_line_numbers);
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_line_numbers);
assert!(!combine.include_obsolete);
}
#[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::Ndjson,
CatalogSemantics::IcuNative,
PluralEncoding::Icu
),
Some(CatalogMode::IcuNdjson)
);
assert_eq!(
CatalogMode::from_parts(
CatalogStorageFormat::Po,
CatalogSemantics::GettextCompat,
PluralEncoding::Gettext
),
Some(CatalogMode::GettextPo)
);
assert_eq!(
CatalogMode::from_parts(
CatalogStorageFormat::Ndjson,
CatalogSemantics::GettextCompat,
PluralEncoding::Gettext
),
None
);
assert_eq!(
CatalogMode::from_parts(
CatalogStorageFormat::Po,
CatalogSemantics::IcuNative,
PluralEncoding::Icu
),
Some(CatalogMode::IcuPo)
);
let update = UpdateCatalogOptions {
mode: CatalogMode::GettextPo,
..UpdateCatalogOptions::new("en", Vec::<super::SourceExtractedMessage>::new())
};
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: Vec::new(),
obsolete: false,
machine_translation: None,
extra: 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);
}
}