use std::collections::BTreeMap;
use ferrocat_icu::{IcuFormatter, IcuFormatterSupport};
use super::{
ApiError, CatalogMessageKey, CatalogSemantics, IcuSyntaxPolicy, NormalizedParsedCatalog,
compile::{
compiled_catalog_translation_kind_for_message, compiled_key_for,
describe_compiled_id_catalogs,
},
};
#[cfg(feature = "serde")]
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub const COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION: u16 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(
feature = "serde",
serde(tag = "kind", content = "value", rename_all = "snake_case")
)]
pub enum CompiledTranslation {
Singular(String),
Plural(BTreeMap<String, String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[non_exhaustive]
pub enum CompiledKeyStrategy {
#[default]
FerrocatV1,
}
pub type IcuFormatterSupportPolicy = fn(&IcuFormatter) -> IcuFormatterSupport;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompileCatalogOptions<'a> {
pub key_strategy: CompiledKeyStrategy,
pub source_fallback: bool,
pub source_locale: Option<&'a str>,
pub semantics: CatalogSemantics,
}
impl Default for CompileCatalogOptions<'_> {
fn default() -> Self {
Self {
key_strategy: CompiledKeyStrategy::FerrocatV1,
source_fallback: false,
source_locale: None,
semantics: CatalogSemantics::IcuNative,
}
}
}
impl CompileCatalogOptions<'_> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompileCatalogArtifactOptions<'a> {
pub requested_locale: &'a str,
pub source_locale: &'a str,
pub fallback_chain: &'a [String],
pub key_strategy: CompiledKeyStrategy,
pub source_fallback: bool,
pub strict_icu: bool,
pub icu_compatibility: bool,
pub semantics: CatalogSemantics,
}
impl<'a> CompileCatalogArtifactOptions<'a> {
#[must_use]
pub fn new(requested_locale: &'a str, source_locale: &'a str) -> Self {
Self {
requested_locale,
source_locale,
fallback_chain: &[],
key_strategy: CompiledKeyStrategy::FerrocatV1,
source_fallback: false,
strict_icu: false,
icu_compatibility: false,
semantics: CatalogSemantics::IcuNative,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub struct CompileCatalogArtifactIcuOptions {
pub syntax_policy: IcuSyntaxPolicy,
pub formatter_support: Option<IcuFormatterSupportPolicy>,
}
impl CompileCatalogArtifactIcuOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_syntax_policy(mut self, syntax_policy: IcuSyntaxPolicy) -> Self {
self.syntax_policy = syntax_policy;
self
}
#[must_use]
pub fn with_formatter_support(mut self, formatter_support: IcuFormatterSupportPolicy) -> Self {
self.formatter_support = Some(formatter_support);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompileSelectedCatalogArtifactOptions<'a> {
pub compiled_ids: &'a [String],
pub options: CompileCatalogArtifactOptions<'a>,
}
impl<'a> CompileSelectedCatalogArtifactOptions<'a> {
#[must_use]
pub fn new(
requested_locale: &'a str,
source_locale: &'a str,
compiled_ids: &'a [String],
) -> Self {
Self {
compiled_ids,
options: CompileCatalogArtifactOptions::new(requested_locale, source_locale),
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum CompileCatalogArtifactReportSelection<'a> {
All,
Selected {
index: &'a CompiledCatalogIdIndex,
compiled_ids: &'a [String],
},
}
#[derive(Debug, Clone)]
pub struct CompileCatalogArtifactReportOptions<'a> {
pub options: CompileCatalogArtifactOptions<'a>,
pub icu_options: CompileCatalogArtifactIcuOptions,
pub selection: CompileCatalogArtifactReportSelection<'a>,
}
impl<'a> CompileCatalogArtifactReportOptions<'a> {
#[must_use]
pub fn new(requested_locale: &'a str, source_locale: &'a str) -> Self {
Self {
options: CompileCatalogArtifactOptions::new(requested_locale, source_locale),
icu_options: CompileCatalogArtifactIcuOptions::new(),
selection: CompileCatalogArtifactReportSelection::All,
}
}
#[must_use]
pub fn selected(
requested_locale: &'a str,
source_locale: &'a str,
index: &'a CompiledCatalogIdIndex,
compiled_ids: &'a [String],
) -> Self {
Self {
options: CompileCatalogArtifactOptions::new(requested_locale, source_locale),
icu_options: CompileCatalogArtifactIcuOptions::new(),
selection: CompileCatalogArtifactReportSelection::Selected {
index,
compiled_ids,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum CompiledCatalogTranslationKind {
Singular,
Plural,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CompiledMessage {
pub key: String,
pub source_key: CatalogMessageKey,
pub translation: CompiledTranslation,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CompiledCatalog {
pub(super) entries: BTreeMap<String, CompiledMessage>,
}
impl CompiledCatalog {
#[must_use]
pub fn get(&self, key: &str) -> Option<&CompiledMessage> {
self.entries.get(key)
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &CompiledMessage)> + '_ {
self.entries
.iter()
.map(|(key, message)| (key.as_str(), message))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CompiledCatalogIdIndex {
pub(super) ids: BTreeMap<String, CatalogMessageKey>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CompiledCatalogIdDescription {
pub compiled_id: String,
pub source_key: CatalogMessageKey,
pub available_locales: Vec<String>,
pub translation_kind: CompiledCatalogTranslationKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct DescribeCompiledIdsReport {
pub described: Vec<CompiledCatalogIdDescription>,
pub unknown_compiled_ids: Vec<String>,
pub unavailable_compiled_ids: Vec<CompiledCatalogUnavailableId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CompiledCatalogUnavailableId {
pub compiled_id: String,
pub source_key: CatalogMessageKey,
}
impl CompiledCatalogIdIndex {
pub fn new(
catalogs: &[&NormalizedParsedCatalog],
key_strategy: CompiledKeyStrategy,
) -> Result<Self, ApiError> {
Self::new_with_key_generator(catalogs, key_strategy, compiled_key_for)
}
pub(super) fn new_with_key_generator<F>(
catalogs: &[&NormalizedParsedCatalog],
key_strategy: CompiledKeyStrategy,
mut key_generator: F,
) -> Result<Self, ApiError>
where
F: FnMut(CompiledKeyStrategy, &CatalogMessageKey) -> String,
{
let mut ids = BTreeMap::<String, CatalogMessageKey>::new();
for catalog in catalogs {
for (source_key, message) in catalog.iter() {
if message.obsolete {
continue;
}
let compiled_id = key_generator(key_strategy, source_key);
if let Some(existing) = ids.get(&compiled_id) {
if existing != source_key {
return Err(ApiError::Conflict(format!(
"compiled catalog key collision for {:?} / {:?} and {:?} / {:?} using key {}",
existing.msgctxt,
existing.msgid,
source_key.msgctxt,
source_key.msgid,
compiled_id
)));
}
continue;
}
ids.insert(compiled_id, source_key.clone());
}
}
Ok(Self { ids })
}
#[must_use]
pub fn get(&self, compiled_id: &str) -> Option<&CatalogMessageKey> {
self.ids.get(compiled_id)
}
#[must_use]
pub fn contains_id(&self, compiled_id: &str) -> bool {
self.ids.contains_key(compiled_id)
}
#[must_use]
pub fn len(&self) -> usize {
self.ids.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.ids.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &CatalogMessageKey)> + '_ {
self.ids
.iter()
.map(|(compiled_id, source_key)| (compiled_id.as_str(), source_key))
}
#[must_use]
pub fn as_btreemap(&self) -> &BTreeMap<String, CatalogMessageKey> {
&self.ids
}
#[must_use]
pub fn into_btreemap(self) -> BTreeMap<String, CatalogMessageKey> {
self.ids
}
pub fn describe_compiled_ids(
&self,
catalogs: &[&NormalizedParsedCatalog],
compiled_ids: &[String],
) -> Result<DescribeCompiledIdsReport, ApiError> {
let locales = describe_compiled_id_catalogs(catalogs)?;
let mut report = DescribeCompiledIdsReport::default();
for compiled_id in std::collections::BTreeSet::from_iter(compiled_ids.iter().cloned()) {
let Some(source_key) = self.get(&compiled_id).cloned() else {
report.unknown_compiled_ids.push(compiled_id);
continue;
};
let mut available_locales = Vec::new();
let mut translation_kind = None;
for (locale, catalog) in &locales {
let Some(message) = catalog.get(&source_key) else {
continue;
};
if message.obsolete {
continue;
}
let next_kind = compiled_catalog_translation_kind_for_message(
catalog.parsed_catalog().semantics,
message,
);
if let Some(existing_kind) = translation_kind {
if existing_kind != next_kind {
return Err(ApiError::Conflict(format!(
"compiled ID {:?} resolves to inconsistent translation shapes across the provided catalogs",
compiled_id
)));
}
} else {
translation_kind = Some(next_kind);
}
available_locales.push(locale.clone());
}
if let Some(translation_kind) = translation_kind {
report.described.push(CompiledCatalogIdDescription {
compiled_id,
source_key,
available_locales,
translation_kind,
});
} else {
report
.unavailable_compiled_ids
.push(CompiledCatalogUnavailableId {
compiled_id,
source_key,
});
}
}
Ok(report)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CompiledCatalogArtifact {
pub messages: BTreeMap<String, String>,
pub missing: Vec<CompiledCatalogMissingMessage>,
pub diagnostics: Vec<CompiledCatalogDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CompiledCatalogArtifactReport {
pub artifact: CompiledCatalogArtifact,
pub provenance: CompiledCatalogProvenanceReport,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CompiledCatalogProvenanceReport {
pub requested_locale: String,
pub source_locale: String,
pub fallback_chain: Vec<String>,
pub messages: Vec<CompiledCatalogResolution>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CompiledCatalogResolution {
pub key: String,
pub source_key: CatalogMessageKey,
pub resolved_locale: Option<String>,
pub kind: CompiledCatalogResolutionKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[non_exhaustive]
pub enum CompiledCatalogResolutionKind {
Requested,
Fallback,
SourceFallback,
Unresolved,
}
#[cfg(feature = "serde")]
#[derive(Serialize)]
struct CompiledCatalogArtifactWireRef<'a> {
schema_version: u16,
messages: &'a BTreeMap<String, String>,
missing: &'a [CompiledCatalogMissingMessage],
diagnostics: &'a [CompiledCatalogDiagnostic],
}
#[cfg(feature = "serde")]
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CompiledCatalogArtifactWire {
schema_version: u16,
#[serde(default)]
messages: BTreeMap<String, String>,
#[serde(default)]
missing: Vec<CompiledCatalogMissingMessage>,
#[serde(default)]
diagnostics: Vec<CompiledCatalogDiagnostic>,
}
#[cfg(feature = "serde")]
impl Serialize for CompiledCatalogArtifact {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
CompiledCatalogArtifactWireRef {
schema_version: COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION,
messages: &self.messages,
missing: &self.missing,
diagnostics: &self.diagnostics,
}
.serialize(serializer)
}
}
#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for CompiledCatalogArtifact {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = CompiledCatalogArtifactWire::deserialize(deserializer)?;
if wire.schema_version != COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION {
return Err(serde::de::Error::custom(format!(
"unsupported compiled catalog artifact schema_version {}; expected {}",
wire.schema_version, COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION
)));
}
Ok(Self {
messages: wire.messages,
missing: wire.missing,
diagnostics: wire.diagnostics,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CompiledCatalogMissingMessage {
pub key: String,
pub source_key: CatalogMessageKey,
pub requested_locale: String,
pub resolved_locale: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
pub struct CompiledCatalogDiagnostic {
pub severity: super::DiagnosticSeverity,
pub code: String,
pub message: String,
pub key: String,
pub msgid: String,
pub msgctxt: Option<String>,
pub locale: String,
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{
COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION, CompileCatalogArtifactOptions,
CompileCatalogArtifactReportOptions, CompileCatalogArtifactReportSelection,
CompileCatalogOptions, CompileSelectedCatalogArtifactOptions, CompiledCatalogArtifact,
CompiledCatalogDiagnostic, CompiledCatalogMissingMessage, CompiledKeyStrategy,
};
use crate::api::{CatalogMessageKey, CatalogSemantics, DiagnosticSeverity};
#[test]
fn compile_option_constructors_set_required_fields_and_keep_defaults() {
let compile = CompileCatalogOptions::new();
assert_eq!(compile.key_strategy, CompiledKeyStrategy::FerrocatV1);
assert!(!compile.source_fallback);
let artifact = CompileCatalogArtifactOptions::new("de", "en");
assert_eq!(artifact.requested_locale, "de");
assert_eq!(artifact.source_locale, "en");
assert_eq!(artifact.key_strategy, CompiledKeyStrategy::FerrocatV1);
assert_eq!(artifact.semantics, CatalogSemantics::IcuNative);
let selected_ids = vec!["abc123".to_owned()];
let selected = CompileSelectedCatalogArtifactOptions::new("de", "en", &selected_ids);
assert_eq!(selected.options.requested_locale, "de");
assert_eq!(selected.options.source_locale, "en");
assert_eq!(selected.compiled_ids, selected_ids.as_slice());
assert_eq!(
selected.options.key_strategy,
CompiledKeyStrategy::FerrocatV1
);
let report = CompileCatalogArtifactReportOptions::new("de", "en");
assert_eq!(report.options.requested_locale, "de");
assert_eq!(report.options.source_locale, "en");
assert!(matches!(
report.selection,
CompileCatalogArtifactReportSelection::All
));
}
#[cfg(feature = "serde")]
#[test]
fn compiled_catalog_artifact_serde_uses_versioned_wire_contract() {
let artifact = CompiledCatalogArtifact {
messages: BTreeMap::from([("runtime-key".to_owned(), "Hallo".to_owned())]),
missing: vec![CompiledCatalogMissingMessage {
key: "runtime-key".to_owned(),
source_key: CatalogMessageKey::new("Hello", None),
requested_locale: "de".to_owned(),
resolved_locale: Some("en".to_owned()),
}],
diagnostics: vec![CompiledCatalogDiagnostic {
severity: DiagnosticSeverity::Warning,
code: "icu.syntax".to_owned(),
message: "invalid ICU message".to_owned(),
key: "runtime-key".to_owned(),
msgid: "Hello".to_owned(),
msgctxt: None,
locale: "de".to_owned(),
}],
};
let json = serde_json::to_value(&artifact).expect("artifact serialization must succeed");
assert_eq!(
json["schema_version"],
COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION
);
assert_eq!(json["diagnostics"][0]["severity"], "warning");
let roundtrip: CompiledCatalogArtifact =
serde_json::from_value(json).expect("artifact deserialization must succeed");
assert_eq!(roundtrip, artifact);
}
#[cfg(feature = "serde")]
#[test]
fn compiled_catalog_artifact_serde_rejects_unknown_schema_version() {
let json = serde_json::json!({
"schema_version": COMPILED_CATALOG_ARTIFACT_SCHEMA_VERSION + 1,
"messages": {},
"missing": [],
"diagnostics": [],
});
let error = serde_json::from_value::<CompiledCatalogArtifact>(json)
.expect_err("unknown artifact schema versions must be rejected");
assert!(
error
.to_string()
.contains("unsupported compiled catalog artifact schema_version")
);
}
}