use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::OnceLock;
use serde::Deserialize;
use crate::core::error::{CatalogError, Result, WorkshopError};
use super::{
CATALOG_DATA, Catalog, CatalogEntry, EnumDomain, EnumMember, Kind, Locale,
LocalizedStringEntry, NormalizedIndex, ParamCoercions, ParamContent, Provenance, TargetMeta,
normalize_spelling,
};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CatalogFile {
schema_version: u32,
locales: Vec<String>,
target: TargetMeta,
provenance: Provenance,
#[serde(default)]
version: Option<String>,
#[serde(default)]
digest: Option<String>,
#[serde(default)]
structural: Vec<EntryFile>,
#[serde(default)]
actions: Vec<EntryFile>,
#[serde(default)]
values: Vec<EntryFile>,
#[serde(default)]
events: Vec<EntryFile>,
#[serde(default)]
operators: Vec<EntryFile>,
#[serde(default)]
settings: Vec<EntryFile>,
#[serde(default)]
localized_strings: Vec<LocalizedStringFile>,
#[serde(default)]
enums: Vec<EnumFile>,
#[serde(default)]
param_display_names: BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>,
param_display_name_gaps: Option<ParamDisplayNameGapsFile>,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct ParamDisplayNameGapsFile {
#[serde(default)]
locales: Vec<String>,
#[serde(default)]
positions: Vec<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct EntryFile {
id: String,
aliases: HashMap<String, AliasFile>,
#[serde(default)]
params: Option<Vec<String>>,
#[serde(default)]
param_names: Vec<String>,
#[serde(default)]
param_aliases: Vec<HashMap<String, AliasFile>>,
#[serde(default)]
param_domains: Vec<Option<String>>,
#[serde(default)]
param_defaults: Vec<Option<String>>,
#[serde(default)]
param_types: Vec<Option<String>>,
#[serde(default)]
param_coercions: Vec<Option<ParamCoercions>>,
#[serde(default)]
param_content: Vec<Option<ParamContent>>,
#[serde(default)]
return_type: Option<String>,
#[serde(default)]
reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
#[serde(default)]
variadic: bool,
}
#[derive(Deserialize)]
struct LocalizedStringFile {
id: String,
aliases: HashMap<String, AliasFile>,
}
#[derive(Deserialize)]
struct EnumFile {
domain: String,
#[serde(default)]
aliases: HashMap<String, AliasFile>,
members: Vec<MemberFile>,
}
#[derive(Deserialize)]
struct MemberFile {
id: String,
aliases: HashMap<String, AliasFile>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum AliasFile {
One(String),
Many(Vec<String>),
}
impl AliasFile {
fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
let spellings = match self {
AliasFile::One(spelling) => vec![spelling],
AliasFile::Many(spellings) => spellings,
};
if spellings.is_empty() || spellings.iter().any(String::is_empty) {
return Err(CatalogError::validation(format!(
"catalog entry '{id}' declares an empty alias for locale '{locale}'"
)));
}
Ok(spellings)
}
}
impl Catalog {
pub fn load(json: &str) -> Result<Catalog> {
let catalog = Self::load_unverified(json)?;
if let Some(declared) = &catalog.catalog_digest {
let computed = content_digest(json)?;
if declared != &computed {
return Err(CatalogError::validation(format!(
"catalog digest mismatch: declared '{declared}', content '{computed}' — \
run the catalog pipeline (workshop-catalog-gen build)"
)));
}
}
Ok(catalog)
}
pub fn load_unverified(json: &str) -> Result<Catalog> {
let file: CatalogFile = serde_json::from_str(json)
.map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
if file.schema_version != 1 {
return Err(CatalogError::malformed(format!(
"unsupported catalog schemaVersion {}",
file.schema_version
)));
}
let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
if locales.is_empty() {
return Err(CatalogError::malformed(
"catalog declares no locales".to_string(),
));
}
let mut catalog = Catalog {
detection_index: None,
schema_version: file.schema_version,
locales,
target: file.target,
provenance: file.provenance,
catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
catalog_digest: file.digest,
entries: Vec::new(),
localized_strings: Vec::new(),
enums: Vec::new(),
by_id: Default::default(),
alias_to_entry: HashMap::new(),
localized_string_by_id: HashMap::new(),
localized_string_alias: HashMap::new(),
enum_by_domain: HashMap::new(),
enum_alias_to_domain: HashMap::new(),
enum_alias_to_member: HashMap::new(),
bare_member_index: HashMap::new(),
normalized: NormalizedIndex::default(),
descriptions: Default::default(),
};
for (kind, items) in [
(Kind::Structural, file.structural),
(Kind::Action, file.actions),
(Kind::Value, file.values),
(Kind::Event, file.events),
(Kind::Operator, file.operators),
(Kind::Setting, file.settings),
] {
for item in items {
catalog.insert_entry(kind, item)?;
}
}
for item in file.localized_strings {
catalog.insert_localized_string(item)?;
}
for domain in file.enums {
catalog.insert_enum(domain)?;
}
catalog
.insert_param_display_names(file.param_display_names, file.param_display_name_gaps)?;
for domain in &catalog.enums {
for member in &domain.members {
for (locale, spellings) in &member.aliases {
for spelling in spellings {
catalog
.bare_member_index
.entry(locale.clone())
.or_default()
.entry(spelling.clone())
.or_default()
.push((domain.domain.clone(), member.member.clone()));
}
}
}
}
catalog.build_normalized_index();
catalog.validate_param_domains()?;
catalog.validate_param_content()?;
catalog.detection_index =
Some(super::detect::AliasIndex::build(&catalog).map_err(|error| {
CatalogError::validation(format!("locale detection index: {error}"))
})?);
Ok(catalog)
}
pub fn builtin() -> Result<Catalog> {
Self::builtin_ref().cloned().map_err(Clone::clone)
}
pub fn builtin_ref() -> std::result::Result<&'static Catalog, &'static WorkshopError> {
static DATA: OnceLock<Result<Catalog>> = OnceLock::new();
match DATA.get_or_init(|| {
let mut catalog = Self::load(CATALOG_DATA)?;
catalog.load_descriptions(super::descriptions::DESCRIPTIONS_DATA)?;
Ok(catalog)
}) {
Ok(catalog) => Ok(catalog),
Err(error) => Err(error),
}
}
fn build_normalized_index(&mut self) {
fn insert<T: PartialEq + Clone>(
map: &mut HashMap<String, Option<T>>,
key: String,
value: T,
) {
match map.get_mut(&key) {
Some(slot) if *slot != Some(value.clone()) => *slot = None,
Some(_) => {}
None => {
map.insert(key, Some(value));
}
}
}
let mut normalized = NormalizedIndex::default();
for (locale, by_kind) in &self.alias_to_entry {
let by_kind_norm = normalized
.alias_to_entry
.entry(locale.clone())
.or_insert_with(|| std::array::from_fn(|_| HashMap::new()));
for (kind_index, map) in by_kind.iter().enumerate() {
for (spelling, &entry_index) in map {
let key = normalize_spelling(spelling);
if key != spelling.as_str() {
insert(&mut by_kind_norm[kind_index], key.into_owned(), entry_index);
}
}
}
}
for (locale, map) in &self.localized_string_alias {
let norm = normalized
.localized_string_alias
.entry(locale.clone())
.or_default();
for (spelling, &string_index) in map {
let key = normalize_spelling(spelling);
if key != spelling.as_str() {
insert(norm, key.into_owned(), string_index);
}
}
}
for (locale, map) in &self.enum_alias_to_domain {
let norm = normalized
.enum_alias_to_domain
.entry(locale.clone())
.or_default();
for (spelling, domain) in map {
let key = normalize_spelling(spelling);
if key != spelling.as_str() {
insert(norm, key.into_owned(), domain.clone());
}
}
}
for (locale, domains) in &self.enum_alias_to_member {
let norm = normalized
.enum_alias_to_member
.entry(locale.clone())
.or_default();
for (domain, map) in domains {
let norm_map = norm.entry(domain.clone()).or_default();
for (spelling, &indexes) in map {
let key = normalize_spelling(spelling);
if key != spelling.as_str() {
insert(norm_map, key.into_owned(), indexes);
}
}
}
}
for (locale, map) in &self.bare_member_index {
let norm = normalized.bare_member.entry(locale.clone()).or_default();
for (spelling, matches) in map {
let key = normalize_spelling(spelling);
if key != spelling.as_str() {
let merged = norm.entry(key.into_owned()).or_default();
for pair in matches {
if !merged.contains(pair) {
merged.push(pair.clone());
}
}
}
}
}
self.normalized = normalized;
}
fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
let index = self.entries.len();
let aliases = self.collect_aliases(
&item.id,
&format!("entry '{}'", item.id),
item.aliases,
true,
|catalog, locale, spelling| {
let locale_map = catalog.alias_to_entry.entry(locale.clone()).or_default();
if locale_map[kind.as_index()].contains_key(spelling) {
return Err(CatalogError::validation(format!(
"duplicate {} alias '{spelling}' for locale '{locale}'",
kind.as_str()
)));
}
locale_map[kind.as_index()].insert(spelling.to_string(), index);
Ok(())
},
)?;
if self.by_id[kind.as_index()].contains_key(&item.id) {
return Err(CatalogError::validation(format!(
"duplicate {} id '{}'",
kind.as_str(),
item.id
)));
}
let callable = matches!(kind, Kind::Action | Kind::Value)
|| (kind == Kind::Structural && item.params.is_some());
let params = item.params.unwrap_or_default();
let param_names = match item.param_names {
names if names.is_empty() => None,
names if names.len() != params.len() => {
return Err(CatalogError::validation(format!(
"{} '{}' declares {} param names for {} params",
kind.as_str(),
item.id,
names.len(),
params.len()
)));
}
names if names == params => None,
names => Some(names),
};
self.by_id[kind.as_index()].insert(item.id.clone(), index);
let item_id = item.id.clone();
self.entries.push(CatalogEntry {
id: item.id,
kind,
callable,
params,
param_names,
param_aliases: item
.param_aliases
.into_iter()
.map(|aliases| {
aliases
.into_iter()
.map(|(locale, alias)| {
let locale_key = Locale::new(&locale);
let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
Ok((locale_key, spellings))
})
.collect::<Result<HashMap<_, _>>>()
})
.collect::<Result<Vec<_>>>()?,
param_domains: item.param_domains,
param_display_names: Vec::new(),
param_defaults: item.param_defaults,
param_types: item.param_types,
param_coercions: item.param_coercions,
param_content: item.param_content,
return_type: item.return_type,
reevaluation_coverage: item.reevaluation_coverage,
variadic: item.variadic,
aliases,
});
Ok(())
}
fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
if self.localized_string_by_id.contains_key(&item.id) {
return Err(CatalogError::validation(format!(
"duplicate localized string id '{}'",
item.id
)));
}
let index = self.localized_strings.len();
let aliases = self.collect_aliases(
&item.id,
&format!("localized string '{}'", item.id),
item.aliases,
true,
|catalog, locale, spelling| {
let locale_map = catalog
.localized_string_alias
.entry(locale.clone())
.or_default();
if locale_map.contains_key(spelling) {
return Err(CatalogError::validation(format!(
"duplicate localized string alias '{spelling}' for locale '{locale}'"
)));
}
locale_map.insert(spelling.to_string(), index);
Ok(())
},
)?;
self.localized_string_by_id.insert(item.id.clone(), index);
self.localized_strings.push(LocalizedStringEntry {
id: item.id,
aliases,
});
Ok(())
}
fn validate_param_domains(&self) -> Result<()> {
for entry in &self.entries {
for (name, count) in [
("parameter alias sets", entry.param_aliases.len()),
("param domains", entry.param_domains.len()),
("param defaults", entry.param_defaults.len()),
("param types", entry.param_types.len()),
("param coercions", entry.param_coercions.len()),
("param content facts", entry.param_content.len()),
] {
if count > entry.params.len() {
return Err(CatalogError::validation(format!(
"{} '{}' declares more {name} than params",
entry.kind.as_str(),
entry.id
)));
}
}
if entry.kind != Kind::Value && entry.return_type.is_some() {
return Err(CatalogError::validation(format!(
"{} '{}' declares a return type but is not a value",
entry.kind.as_str(),
entry.id
)));
}
for domain in entry.param_domains.iter().flatten() {
if !self.enum_by_domain.contains_key(domain) {
return Err(CatalogError::validation(format!(
"{} '{}' declares undeclared enum domain '{domain}'",
entry.kind.as_str(),
entry.id
)));
}
}
if let Some(coverage) = &entry.reevaluation_coverage {
let reeval_params: Vec<usize> = (0..entry.params.len())
.filter(|index| {
entry
.param_domains
.get(*index)
.and_then(Option::as_deref)
.is_some_and(|domain| domain.ends_with("Reeval"))
})
.collect();
if reeval_params.len() != 1 {
return Err(CatalogError::validation(format!(
"{} '{}' declares reevaluation coverage but has {} *Reeval parameters",
entry.kind.as_str(),
entry.id,
reeval_params.len()
)));
}
let domain_name = entry.param_domains[reeval_params[0]]
.as_deref()
.expect("filtered above");
let domain = self
.enum_by_domain
.get(domain_name)
.map(|index| &self.enums[*index])
.expect("param domain validated above");
for (member, positions) in coverage {
if !domain.members.iter().any(|m| m.member == *member) {
return Err(CatalogError::validation(format!(
"{} '{}' declares reevaluation coverage for '{member}', which is not a member of '{domain_name}'",
entry.kind.as_str(),
entry.id
)));
}
for position in positions {
if *position >= entry.params.len() {
return Err(CatalogError::validation(format!(
"{} '{}' reevaluation coverage for '{member}' names parameter {position}, out of {} params",
entry.kind.as_str(),
entry.id,
entry.params.len()
)));
}
if *position == reeval_params[0] {
return Err(CatalogError::validation(format!(
"{} '{}' reevaluation coverage for '{member}' covers the reevaluation parameter itself",
entry.kind.as_str(),
entry.id
)));
}
}
}
for member in &domain.members {
if !coverage.contains_key(&member.member) {
return Err(CatalogError::validation(format!(
"{} '{}' reevaluation coverage does not review '{domain_name}' member '{}'",
entry.kind.as_str(),
entry.id,
member.member
)));
}
}
}
for aliases in &entry.param_aliases {
for locale in aliases.keys() {
if !self.locales.contains(locale) {
return Err(CatalogError::validation(format!(
"{} '{}' declares parameter alias for undeclared locale '{}'",
entry.kind.as_str(),
entry.id,
locale
)));
}
}
}
}
Ok(())
}
fn validate_param_content(&self) -> Result<()> {
for entry in &self.entries {
let mut groups: BTreeMap<&str, Vec<(usize, usize)>> = BTreeMap::new();
for (index, content) in entry.param_content.iter().enumerate() {
let Some(content) = content else {
continue;
};
if content.fold.is_none() && content.operations.is_empty() {
return Err(CatalogError::validation(format!(
"{} '{}' parameter {} declares an empty paramContent fact",
entry.kind.as_str(),
entry.id,
index + 1
)));
}
for operation in &content.operations {
if !self.by_id[Kind::Operator.as_index()].contains_key(operation) {
return Err(CatalogError::validation(format!(
"{} '{}' parameter {} accepts undeclared operator '{operation}'",
entry.kind.as_str(),
entry.id,
index + 1
)));
}
}
let Some(fold) = &content.fold else {
continue;
};
let (arity, variable_arg) = match fold.call.as_str() {
"globalVariable" => (1, 0),
"playerVariable" => (2, 1),
_ => {
return Err(CatalogError::validation(format!(
"{} '{}' parameter {} declares a fold into unknown \
call '{}'",
entry.kind.as_str(),
entry.id,
index + 1,
fold.call
)));
}
};
if fold.arg >= arity {
return Err(CatalogError::validation(format!(
"{} '{}' parameter {} declares a fold into '{}' \
argument {}, out of {}",
entry.kind.as_str(),
entry.id,
index + 1,
fold.call,
fold.arg,
arity
)));
}
let variable_typed = matches!(
entry.param_type(index),
Some("Variable") | Some("Global Variable")
);
if fold.arg == variable_arg && !variable_typed {
return Err(CatalogError::validation(format!(
"{} '{}' parameter {} supplies '{}' argument {}, the tagged \
variable node, but is not Variable-typed",
entry.kind.as_str(),
entry.id,
index + 1,
fold.call,
fold.arg
)));
}
groups
.entry(fold.call.as_str())
.or_default()
.push((index, fold.arg));
}
for (call, members) in groups {
let arity = if call == "globalVariable" { 1 } else { 2 };
let positions: Vec<usize> = members.iter().map(|(index, _)| *index).collect();
let contiguous = positions.windows(2).all(|pair| pair[1] == pair[0] + 1);
let mut args: Vec<usize> = members.iter().map(|(_, arg)| *arg).collect();
args.sort_unstable();
if !contiguous || args != (0..arity).collect::<Vec<_>>() {
return Err(CatalogError::validation(format!(
"{} '{}' declares an incomplete '{}' fold group: members must be \
contiguous parameters covering all {} argument(s)",
entry.kind.as_str(),
entry.id,
call,
arity
)));
}
}
}
Ok(())
}
fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
let domain_index = self.enums.len();
if self.enum_by_domain.contains_key(&domain.domain) {
return Err(CatalogError::validation(format!(
"duplicate enum domain '{}'",
domain.domain
)));
}
let primary = self.locales[0].clone();
let mut domain_aliases = self.collect_aliases(
&domain.domain,
&format!("enum domain '{}'", domain.domain),
domain.aliases,
false,
|catalog, locale, spelling| {
let locale_map = catalog
.enum_alias_to_domain
.entry(locale.clone())
.or_default();
if let Some(existing) = locale_map.get(spelling) {
return Err(CatalogError::validation(format!(
"duplicate enum domain alias '{spelling}' for '{existing}' and '{}' in locale '{locale}'",
domain.domain
)));
}
locale_map.insert(spelling.to_string(), domain.domain.clone());
Ok(())
},
)?;
domain_aliases
.entry(primary.clone())
.or_insert_with(|| vec![domain.domain.clone()]);
self.enum_alias_to_domain
.entry(primary.clone())
.or_default()
.entry(domain.domain.clone())
.or_insert_with(|| domain.domain.clone());
let mut members = Vec::new();
for (member_index, member) in domain.members.into_iter().enumerate() {
let aliases = self.collect_aliases(
&member.id,
&format!("enum {}::{}", domain.domain, member.id),
member.aliases,
true,
|catalog, locale, spelling| {
let domain_map = catalog
.enum_alias_to_member
.entry(locale.clone())
.or_default()
.entry(domain.domain.clone())
.or_default();
if domain_map.contains_key(spelling) {
return Err(CatalogError::validation(format!(
"duplicate enum alias '{spelling}' in '{}' for locale '{locale}'",
domain.domain
)));
}
domain_map.insert(spelling.to_string(), (domain_index, member_index));
Ok(())
},
)?;
members.push(EnumMember {
member: member.id,
aliases,
});
}
self.enum_by_domain
.insert(domain.domain.clone(), domain_index);
self.enums.push(EnumDomain {
domain: domain.domain,
aliases: domain_aliases,
members,
});
Ok(())
}
fn insert_param_display_names(
&mut self,
names: BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>,
gaps: Option<ParamDisplayNameGapsFile>,
) -> Result<()> {
let mut gap_locales = BTreeSet::new();
let mut gap_positions = BTreeSet::new();
if let Some(gaps) = &gaps {
for gap_locale in &gaps.locales {
let locale = Locale::new(gap_locale);
if !self.locales.contains(&locale) {
return Err(CatalogError::validation(format!(
"param display name gap locale '{locale}' is not a declared locale"
)));
}
if !gap_locales.insert(locale) {
return Err(CatalogError::validation(format!(
"duplicate param display name gap locale '{gap_locale}'"
)));
}
}
for position in &gaps.positions {
let (id, index) = position
.strip_suffix(']')
.and_then(|text| text.split_once('['))
.ok_or_else(|| {
CatalogError::validation(format!(
"param display name gap '{position}' is not written 'entryId[index]'"
))
})?;
let index: usize = index.parse().map_err(|_| {
CatalogError::validation(format!(
"param display name gap '{position}' has a non-numeric index"
))
})?;
let param_counts = self
.by_id
.iter()
.filter_map(|by_id| by_id.get(id))
.map(|&entry_index| self.entries[entry_index].params.len())
.collect::<Vec<_>>();
if param_counts.is_empty() {
return Err(CatalogError::validation(format!(
"param display name gap '{position}' names undeclared entry '{id}'"
)));
}
if !param_counts.iter().any(|&len| index < len) {
return Err(CatalogError::validation(format!(
"param display name gap '{position}' exceeds the parameters of '{id}'"
)));
}
if !gap_positions.insert((id.to_string(), index)) {
return Err(CatalogError::validation(format!(
"duplicate param display name gap '{position}'"
)));
}
}
}
for (id, positions) in names {
let entry_indices = self
.by_id
.iter()
.filter_map(|by_id| by_id.get(&id))
.copied()
.collect::<Vec<_>>();
if entry_indices.is_empty() {
return Err(CatalogError::validation(format!(
"param display names name undeclared entry '{id}'"
)));
}
for (position, map) in positions {
let index: usize = position.parse().map_err(|_| {
CatalogError::validation(format!(
"'{id}' param display name position '{position}' is not a zero-based index"
))
})?;
let mut recorded = HashMap::new();
for (name_locale, name) in map {
let locale = Locale::new(&name_locale);
if !self.locales.contains(&locale) {
return Err(CatalogError::validation(format!(
"'{id}' declares a parameter display name for undeclared locale '{locale}'"
)));
}
if gap_locales.contains(&locale) {
return Err(CatalogError::validation(format!(
"'{id}' declares a parameter display name for gap locale '{locale}'"
)));
}
if name.is_empty() {
return Err(CatalogError::validation(format!(
"'{id}' declares an empty parameter display name for locale '{locale}'"
)));
}
recorded.insert(locale, name);
}
if recorded.is_empty() {
return Err(CatalogError::validation(format!(
"'{id}' declares no display names for parameter {index}"
)));
}
let mut applied = false;
for &entry_index in &entry_indices {
let entry = &mut self.entries[entry_index];
if index >= entry.params.len() {
continue;
}
entry.param_display_names.resize(entry.params.len(), None);
entry.param_display_names[index] = Some(recorded.clone());
applied = true;
}
if !applied {
return Err(CatalogError::validation(format!(
"'{id}' declares a display name for parameter {index}, which no entry of that id has"
)));
}
}
}
if gaps.is_some() {
let supported = self
.locales
.iter()
.filter(|locale| !gap_locales.contains(*locale))
.collect::<Vec<_>>();
for entry in &self.entries {
for index in 0..entry.params.len() {
let names = entry
.param_display_names
.get(index)
.and_then(Option::as_ref);
let complete = supported
.iter()
.all(|locale| names.is_some_and(|map| map.contains_key(*locale)));
let gapped = gap_positions.contains(&(entry.id.clone(), index));
if complete == gapped {
return Err(CatalogError::validation(format!(
"{} '{}' parameter {index} must be listed in paramDisplayNameGaps exactly when it lacks a display name in a supported locale",
entry.kind.as_str(),
entry.id
)));
}
}
}
}
Ok(())
}
fn collect_aliases(
&mut self,
id: &str,
label: &str,
aliases: HashMap<String, AliasFile>,
require_primary: bool,
mut register: impl FnMut(&mut Self, &Locale, &str) -> Result<()>,
) -> Result<HashMap<Locale, Vec<String>>> {
let mut collected = HashMap::new();
for (locale_str, alias_file) in aliases {
let locale = Locale::new(&locale_str);
if !self.locales.contains(&locale) {
return Err(CatalogError::validation(format!(
"{label} declares alias for undeclared locale '{locale}'"
)));
}
let spellings = alias_file.into_spellings(id, locale.as_str())?;
for spelling in &spellings {
register(self, &locale, spelling)?;
}
collected.insert(locale, spellings);
}
let primary = &self.locales[0];
if require_primary && !collected.contains_key(primary) {
return Err(CatalogError::validation(format!(
"{label} is missing a '{primary}' alias"
)));
}
Ok(collected)
}
}
pub fn canonicalize(json: &str) -> Result<String> {
Catalog::load_unverified(json)?;
let value: serde_json::Value = serde_json::from_str(json)
.map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
pretty_json(&value)
}
pub fn build_canonical(json: &str) -> Result<String> {
let mut value: serde_json::Value = serde_json::from_str(json)
.map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
let digest = content_digest(json)?;
if let Some(object) = value.as_object_mut() {
object.insert("digest".to_string(), serde_json::Value::String(digest));
}
let output = pretty_json(&value)?;
Catalog::load(&output)?;
Ok(output)
}
pub fn content_digest(json: &str) -> Result<String> {
let mut value: serde_json::Value = serde_json::from_str(json)
.map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
if let Some(object) = value.as_object_mut() {
object.remove("digest");
}
let canonical = pretty_json(&value)?;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(canonical.trim_end().as_bytes());
Ok(format!("{:x}", hasher.finalize()))
}
pub(crate) fn pretty_json(value: &serde_json::Value) -> Result<String> {
serde_json::to_string_pretty(value)
.map(|mut out| {
out.push('\n');
out
})
.map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
}