use std::collections::{BTreeMap, BTreeSet};
use crate::diagnostic_codes;
use super::catalog::{
CanonicalMessage, CanonicalTranslation, Catalog, apply_header_defaults, sort_messages,
};
use super::export::export_catalog_content;
use super::helpers::{merge_placeholders, merge_unique_origins, merge_unique_strings};
use super::{
ApiError, CatalogCombineResult, CatalogCombineStats, CatalogConflictStrategy,
CatalogStorageFormat, CatalogUpdateInput, CombineCatalogOptions, Diagnostic,
DiagnosticSeverity, ObsoleteStrategy, PlaceholderCommentMode, RenderOptions,
UpdateCatalogOptions,
};
#[derive(Debug, Clone, PartialEq, Eq)]
struct CombineEntry {
message: CanonicalMessage,
definitions: usize,
labels: Vec<String>,
}
#[expect(
clippy::needless_pass_by_value,
reason = "Public API takes owned option structs so callers can build and move them ergonomically."
)]
pub fn combine_catalogs(
options: CombineCatalogOptions<'_>,
) -> Result<CatalogCombineResult, ApiError> {
super::validate_source_locale(options.source_locale)?;
if options.inputs.is_empty() {
return Err(ApiError::InvalidArguments(
"inputs must not be empty".to_owned(),
));
}
let mut diagnostics = Vec::new();
let mut headers = BTreeMap::new();
let mut file_comments = Vec::new();
let mut file_extracted_comments = Vec::new();
let mut inferred_locale = options.locale.map(str::to_owned);
let mut entries = Vec::<CombineEntry>::new();
let mut index = BTreeMap::<(String, Option<String>), usize>::new();
let mut stats = CatalogCombineStats {
inputs: options.inputs.len(),
..CatalogCombineStats::default()
};
for (input_index, input) in options.inputs.iter().enumerate() {
let catalog = super::catalog::parse_catalog_to_internal(
input.content,
options.locale,
options.source_locale,
options.mode.semantics(),
options.mode.plural_encoding(),
false,
options.mode.storage_format(),
)?;
if input_index == 0 {
headers = catalog.headers.clone();
file_comments = catalog.file_comments.clone();
file_extracted_comments = catalog.file_extracted_comments.clone();
if inferred_locale.is_none() {
inferred_locale.clone_from(&catalog.locale);
}
}
diagnostics.extend(catalog.diagnostics);
let label = input_label(input.label, input_index);
for message in catalog.messages {
if message.obsolete && !options.include_obsolete {
continue;
}
stats.definitions += 1;
push_combine_message(
&mut entries,
&mut index,
message,
label.clone(),
options.conflict_strategy,
&mut stats,
&mut diagnostics,
)?;
}
}
let mut messages = Vec::with_capacity(entries.len());
for entry in entries {
if options.selection.includes(entry.definitions) {
messages.push(entry.message);
} else {
stats.skipped += 1;
}
}
stats.selected = messages.len();
stats.total = messages.len();
let mut combined = Catalog {
locale: inferred_locale.clone(),
headers,
file_comments,
file_extracted_comments,
messages,
diagnostics: Vec::new(),
};
apply_storage_defaults_for_combine(
&mut combined,
&options,
inferred_locale.as_deref(),
&mut diagnostics,
)?;
sort_messages(&mut combined.messages, options.order_by);
let content =
export_catalog_content_for_combine(&combined, &options, inferred_locale.as_deref())?;
Ok(CatalogCombineResult {
content,
stats,
diagnostics,
})
}
fn input_label(label: Option<&str>, index: usize) -> String {
label.map_or_else(|| format!("input {}", index + 1), str::to_owned)
}
fn push_combine_message(
entries: &mut Vec<CombineEntry>,
index: &mut BTreeMap<(String, Option<String>), usize>,
message: CanonicalMessage,
label: String,
conflict_strategy: CatalogConflictStrategy,
stats: &mut CatalogCombineStats,
diagnostics: &mut Vec<Diagnostic>,
) -> Result<(), ApiError> {
let key = (message.msgid.clone(), message.msgctxt.clone());
let Some(existing_index) = index.get(&key).copied() else {
index.insert(key, entries.len());
entries.push(CombineEntry {
message,
definitions: 1,
labels: vec![label],
});
return Ok(());
};
let entry = &mut entries[existing_index];
entry.definitions += 1;
entry.labels.push(label);
merge_combine_message(entry, message, conflict_strategy, stats, diagnostics)
}
fn merge_combine_message(
entry: &mut CombineEntry,
message: CanonicalMessage,
conflict_strategy: CatalogConflictStrategy,
stats: &mut CatalogCombineStats,
diagnostics: &mut Vec<Diagnostic>,
) -> Result<(), ApiError> {
if translation_shape(&entry.message.translation) != translation_shape(&message.translation) {
return Err(ApiError::Conflict(format!(
"conflicting catalog message shape for msgid {:?} and context {:?}",
entry.message.msgid, entry.message.msgctxt
)));
}
let has_conflict =
has_meaningful_translation_conflict(&entry.message.translation, &message.translation);
if has_conflict {
match conflict_strategy {
CatalogConflictStrategy::Error => {
return Err(ApiError::Conflict(format!(
"conflicting catalog translation for msgid {:?} and context {:?}",
entry.message.msgid, entry.message.msgctxt
)));
}
CatalogConflictStrategy::UseFirst | CatalogConflictStrategy::UseLast => {
stats.conflicts_resolved += 1;
diagnostics.push(
Diagnostic::new(
DiagnosticSeverity::Warning,
diagnostic_codes::combine::CONFLICT_RESOLVED,
format!(
"Resolved conflicting translations for definitions from {} using {:?}.",
entry.labels.join(", "),
conflict_strategy
),
)
.with_identity(&entry.message.msgid, entry.message.msgctxt.as_deref()),
);
}
}
}
if conflict_strategy == CatalogConflictStrategy::UseLast {
entry.message.translation = message.translation.clone();
entry.message.machine_translation = message.machine_translation.clone();
}
merge_combine_metadata(&mut entry.message, message);
Ok(())
}
fn merge_combine_metadata(target: &mut CanonicalMessage, source: CanonicalMessage) {
merge_unique_strings(&mut target.comments, source.comments);
merge_unique_origins(&mut target.origins, source.origins);
merge_placeholders(&mut target.placeholders, source.placeholders);
merge_unique_strings(&mut target.translator_comments, source.translator_comments);
merge_unique_strings(&mut target.flags, source.flags);
if target.machine_translation.is_none() {
target.machine_translation = source.machine_translation;
}
target.obsolete = target.obsolete && source.obsolete;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TranslationKind {
Singular,
Plural,
}
fn translation_shape(translation: &CanonicalTranslation) -> TranslationKind {
match translation {
CanonicalTranslation::Singular { .. } => TranslationKind::Singular,
CanonicalTranslation::Plural { .. } => TranslationKind::Plural,
}
}
fn has_meaningful_translation_conflict(
left: &CanonicalTranslation,
right: &CanonicalTranslation,
) -> bool {
match (left, right) {
(
CanonicalTranslation::Singular { value: left },
CanonicalTranslation::Singular { value: right },
) => !left.is_empty() && !right.is_empty() && left != right,
(
CanonicalTranslation::Plural {
translation_by_category: left,
..
},
CanonicalTranslation::Plural {
translation_by_category: right,
..
},
) => {
let categories = left.keys().chain(right.keys()).collect::<BTreeSet<_>>();
categories.into_iter().any(|category| {
let left = left.get(category).map(String::as_str).unwrap_or_default();
let right = right.get(category).map(String::as_str).unwrap_or_default();
!left.is_empty() && !right.is_empty() && left != right
})
}
_ => false,
}
}
fn apply_storage_defaults_for_combine(
catalog: &mut Catalog,
options: &CombineCatalogOptions<'_>,
locale: Option<&str>,
diagnostics: &mut Vec<Diagnostic>,
) -> Result<(), ApiError> {
match options.mode.storage_format() {
CatalogStorageFormat::Po => {
apply_header_defaults(
&mut catalog.headers,
locale,
options.mode.semantics(),
diagnostics,
&BTreeMap::new(),
);
Ok(())
}
CatalogStorageFormat::Ndjson => {
catalog.headers.clear();
Ok(())
}
}
}
fn export_catalog_content_for_combine(
catalog: &Catalog,
options: &CombineCatalogOptions<'_>,
locale: Option<&str>,
) -> Result<String, ApiError> {
let export_options = UpdateCatalogOptions {
locale: options.locale,
mode: options.mode,
obsolete_strategy: ObsoleteStrategy::Keep,
render: RenderOptions {
order_by: options.order_by,
include_origins: options.include_origins,
include_line_numbers: options.include_line_numbers,
print_placeholders_in_comments: PlaceholderCommentMode::default(),
custom_header_attributes: None,
},
..UpdateCatalogOptions::new(options.source_locale, CatalogUpdateInput::default())
};
let mut diagnostics = Vec::new();
export_catalog_content(catalog, &export_options, locale, &mut diagnostics)
}