1use std::collections::HashMap;
6
7use serde::Deserialize;
8
9use crate::core::error::{CatalogError, Result};
10
11use super::{
12 CATALOG_DATA, Catalog, CatalogEntry, EnumDomain, EnumMember, Kind, Locale,
13 LocalizedStringEntry, ParamCoercions, Provenance, TargetMeta,
14};
15
16#[derive(Deserialize)]
17#[serde(rename_all = "camelCase")]
18struct CatalogFile {
19 schema_version: u32,
20 locales: Vec<String>,
21 target: TargetMeta,
22 provenance: Provenance,
23 #[serde(default)]
25 version: Option<String>,
26 #[serde(default)]
28 digest: Option<String>,
29 #[serde(default)]
30 structural: Vec<EntryFile>,
31 #[serde(default)]
32 actions: Vec<EntryFile>,
33 #[serde(default)]
34 values: Vec<EntryFile>,
35 #[serde(default)]
36 events: Vec<EntryFile>,
37 #[serde(default)]
38 operators: Vec<EntryFile>,
39 #[serde(default)]
40 settings: Vec<EntryFile>,
41 #[serde(default)]
42 localized_strings: Vec<LocalizedStringFile>,
43 #[serde(default)]
44 enums: Vec<EnumFile>,
45}
46
47#[derive(Deserialize)]
48#[serde(rename_all = "camelCase")]
49struct EntryFile {
50 id: String,
51 aliases: HashMap<String, AliasFile>,
52 #[serde(default)]
53 params: Vec<String>,
54 #[serde(default)]
56 param_names: Vec<String>,
57 #[serde(default)]
58 param_aliases: Vec<HashMap<String, AliasFile>>,
59 #[serde(default)]
62 param_domains: Vec<Option<String>>,
63 #[serde(default)]
70 param_defaults: Vec<Option<String>>,
71 #[serde(default)]
72 param_types: Vec<Option<String>>,
73 #[serde(default)]
74 param_coercions: Vec<Option<ParamCoercions>>,
75 #[serde(default)]
76 return_type: Option<String>,
77 #[serde(default)]
78 variadic: bool,
79}
80
81#[derive(Deserialize)]
82struct LocalizedStringFile {
83 id: String,
84 aliases: HashMap<String, AliasFile>,
85}
86
87#[derive(Deserialize)]
88struct EnumFile {
89 domain: String,
90 #[serde(default)]
91 aliases: HashMap<String, AliasFile>,
92 members: Vec<MemberFile>,
93}
94
95#[derive(Deserialize)]
96struct MemberFile {
97 id: String,
98 aliases: HashMap<String, AliasFile>,
99}
100
101#[derive(Debug, Deserialize)]
106#[serde(untagged)]
107enum AliasFile {
108 One(String),
109 Many(Vec<String>),
110}
111
112impl AliasFile {
113 fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
114 let spellings = match self {
115 AliasFile::One(spelling) => vec![spelling],
116 AliasFile::Many(spellings) => spellings,
117 };
118 if spellings.is_empty() || spellings.iter().any(String::is_empty) {
119 return Err(CatalogError::validation(format!(
120 "catalog entry '{}' declares an empty alias for locale '{}'",
121 id, locale
122 )));
123 }
124 Ok(spellings)
125 }
126}
127impl Catalog {
128 pub fn load(json: &str) -> Result<Catalog> {
131 let catalog = Self::load_unverified(json)?;
132 if let Some(declared) = &catalog.catalog_digest {
133 let computed = content_digest(json)?;
134 if declared != &computed {
135 return Err(CatalogError::validation(format!(
136 "catalog digest mismatch: declared '{declared}', content '{computed}' — \
137 run the catalog pipeline (workshop-catalog-gen build)"
138 )));
139 }
140 }
141 Ok(catalog)
142 }
143
144 pub fn load_unverified(json: &str) -> Result<Catalog> {
147 let file: CatalogFile = serde_json::from_str(json)
148 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
149 if file.schema_version != 1 {
150 return Err(CatalogError::malformed(format!(
151 "unsupported catalog schemaVersion {}",
152 file.schema_version
153 )));
154 }
155 let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
156 if locales.is_empty() {
157 return Err(CatalogError::malformed(
158 "catalog declares no locales".to_string(),
159 ));
160 }
161
162 let mut catalog = Catalog {
163 detection_index: None,
164 schema_version: file.schema_version,
165 locales,
166 target: file.target,
167 provenance: file.provenance,
168 catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
169 catalog_digest: file.digest,
170 entries: Vec::new(),
171 localized_strings: Vec::new(),
172 enums: Vec::new(),
173 by_id: Default::default(),
174 alias_to_entry: HashMap::new(),
175 localized_string_by_id: HashMap::new(),
176 localized_string_alias: HashMap::new(),
177 enum_by_domain: HashMap::new(),
178 enum_alias_to_domain: HashMap::new(),
179 enum_alias_to_member: HashMap::new(),
180 bare_member_index: HashMap::new(),
181 };
182
183 for (kind, items) in [
184 (Kind::Structural, file.structural),
185 (Kind::Action, file.actions),
186 (Kind::Value, file.values),
187 (Kind::Event, file.events),
188 (Kind::Operator, file.operators),
189 (Kind::Setting, file.settings),
190 ] {
191 for item in items {
192 catalog.insert_entry(kind, item)?;
193 }
194 }
195 for item in file.localized_strings {
196 catalog.insert_localized_string(item)?;
197 }
198 for domain in file.enums {
199 catalog.insert_enum(domain)?;
200 }
201 for domain in &catalog.enums {
202 for member in &domain.members {
203 for (locale, spellings) in &member.aliases {
204 for spelling in spellings {
205 catalog
206 .bare_member_index
207 .entry(locale.clone())
208 .or_default()
209 .entry(spelling.clone())
210 .or_default()
211 .push((domain.domain.clone(), member.member.clone()));
212 }
213 }
214 }
215 }
216 catalog.validate_param_domains()?;
217 catalog.detection_index =
218 Some(super::detect::AliasIndex::build(&catalog).map_err(|error| {
219 CatalogError::validation(format!("locale detection index: {error}"))
220 })?);
221 Ok(catalog)
222 }
223
224 pub fn builtin() -> Result<Catalog> {
226 Self::load(CATALOG_DATA)
227 }
228 fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
229 let index = self.entries.len();
230 let aliases = self.collect_aliases(
231 &item.id,
232 &format!("entry '{}'", item.id),
233 item.aliases,
234 true,
235 |catalog, locale, spelling| {
236 let locale_map = catalog.alias_to_entry.entry(locale.clone()).or_default();
237 if locale_map[kind.as_index()].contains_key(spelling) {
238 return Err(CatalogError::validation(format!(
239 "duplicate {} alias '{spelling}' for locale '{locale}'",
240 kind.as_str()
241 )));
242 }
243 locale_map[kind.as_index()].insert(spelling.to_string(), index);
244 Ok(())
245 },
246 )?;
247 if self.by_id[kind.as_index()].contains_key(&item.id) {
248 return Err(CatalogError::validation(format!(
249 "duplicate {} id '{}'",
250 kind.as_str(),
251 item.id
252 )));
253 }
254 let param_names = match item.param_names {
259 names if names.is_empty() => None,
260 names if names.len() != item.params.len() => {
261 return Err(CatalogError::validation(format!(
262 "{} '{}' declares {} param names for {} params",
263 kind.as_str(),
264 item.id,
265 names.len(),
266 item.params.len()
267 )));
268 }
269 names if names == item.params => None,
270 names => Some(names),
271 };
272 self.by_id[kind.as_index()].insert(item.id.clone(), index);
273 let item_id = item.id.clone();
274 self.entries.push(CatalogEntry {
275 id: item.id,
276 kind,
277 params: item.params,
278 param_names,
279 param_aliases: item
280 .param_aliases
281 .into_iter()
282 .map(|aliases| {
283 aliases
284 .into_iter()
285 .map(|(locale, alias)| {
286 let locale_key = Locale::new(&locale);
287 let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
288 Ok((locale_key, spellings))
289 })
290 .collect::<Result<HashMap<_, _>>>()
291 })
292 .collect::<Result<Vec<_>>>()?,
293 param_domains: item.param_domains,
294 param_defaults: item.param_defaults,
295 param_types: item.param_types,
296 param_coercions: item.param_coercions,
297 return_type: item.return_type,
298 variadic: item.variadic,
299 aliases,
300 });
301 Ok(())
302 }
303
304 fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
305 if self.localized_string_by_id.contains_key(&item.id) {
306 return Err(CatalogError::validation(format!(
307 "duplicate localized string id '{}'",
308 item.id
309 )));
310 }
311 let index = self.localized_strings.len();
312 let aliases = self.collect_aliases(
313 &item.id,
314 &format!("localized string '{}'", item.id),
315 item.aliases,
316 true,
317 |catalog, locale, spelling| {
318 let locale_map = catalog
319 .localized_string_alias
320 .entry(locale.clone())
321 .or_default();
322 if locale_map.contains_key(spelling) {
323 return Err(CatalogError::validation(format!(
324 "duplicate localized string alias '{spelling}' for locale '{locale}'"
325 )));
326 }
327 locale_map.insert(spelling.to_string(), index);
328 Ok(())
329 },
330 )?;
331 self.localized_string_by_id.insert(item.id.clone(), index);
332 self.localized_strings.push(LocalizedStringEntry {
333 id: item.id,
334 aliases,
335 });
336 Ok(())
337 }
338
339 fn validate_param_domains(&self) -> Result<()> {
341 for entry in &self.entries {
342 for (name, count) in [
343 ("parameter alias sets", entry.param_aliases.len()),
344 ("param domains", entry.param_domains.len()),
345 ("param defaults", entry.param_defaults.len()),
346 ("param types", entry.param_types.len()),
347 ("param coercions", entry.param_coercions.len()),
348 ] {
349 if count > entry.params.len() {
350 return Err(CatalogError::validation(format!(
351 "{} '{}' declares more {name} than params",
352 entry.kind.as_str(),
353 entry.id
354 )));
355 }
356 }
357 if entry.kind != Kind::Value && entry.return_type.is_some() {
358 return Err(CatalogError::validation(format!(
359 "{} '{}' declares a return type but is not a value",
360 entry.kind.as_str(),
361 entry.id
362 )));
363 }
364 for domain in entry.param_domains.iter().flatten() {
365 if !self.enum_by_domain.contains_key(domain) {
366 return Err(CatalogError::validation(format!(
367 "{} '{}' declares undeclared enum domain '{domain}'",
368 entry.kind.as_str(),
369 entry.id
370 )));
371 }
372 }
373 for aliases in &entry.param_aliases {
374 for locale in aliases.keys() {
375 if !self.locales.contains(locale) {
376 return Err(CatalogError::validation(format!(
377 "{} '{}' declares parameter alias for undeclared locale '{}'",
378 entry.kind.as_str(),
379 entry.id,
380 locale
381 )));
382 }
383 }
384 }
385 }
386 Ok(())
387 }
388
389 fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
390 let domain_index = self.enums.len();
391 if self.enum_by_domain.contains_key(&domain.domain) {
392 return Err(CatalogError::validation(format!(
393 "duplicate enum domain '{}'",
394 domain.domain
395 )));
396 }
397 let primary = self.locales[0].clone();
398 let mut domain_aliases = self.collect_aliases(
399 &domain.domain,
400 &format!("enum domain '{}'", domain.domain),
401 domain.aliases,
402 false,
403 |catalog, locale, spelling| {
404 let locale_map = catalog
405 .enum_alias_to_domain
406 .entry(locale.clone())
407 .or_default();
408 if let Some(existing) = locale_map.get(spelling) {
409 return Err(CatalogError::validation(format!(
410 "duplicate enum domain alias '{spelling}' for '{existing}' and '{}' in locale '{locale}'",
411 domain.domain
412 )));
413 }
414 locale_map.insert(spelling.to_string(), domain.domain.clone());
415 Ok(())
416 },
417 )?;
418 domain_aliases
419 .entry(primary.clone())
420 .or_insert_with(|| vec![domain.domain.clone()]);
421 self.enum_alias_to_domain
422 .entry(primary.clone())
423 .or_default()
424 .entry(domain.domain.clone())
425 .or_insert_with(|| domain.domain.clone());
426 let mut members = Vec::new();
427 for (member_index, member) in domain.members.into_iter().enumerate() {
428 let aliases = self.collect_aliases(
429 &member.id,
430 &format!("enum {}::{}", domain.domain, member.id),
431 member.aliases,
432 true,
433 |catalog, locale, spelling| {
434 let domain_map = catalog
435 .enum_alias_to_member
436 .entry(locale.clone())
437 .or_default()
438 .entry(domain.domain.clone())
439 .or_default();
440 if domain_map.contains_key(spelling) {
441 return Err(CatalogError::validation(format!(
442 "duplicate enum alias '{spelling}' in '{}' for locale '{locale}'",
443 domain.domain
444 )));
445 }
446 domain_map.insert(spelling.to_string(), (domain_index, member_index));
447 Ok(())
448 },
449 )?;
450 members.push(EnumMember {
451 member: member.id,
452 aliases,
453 });
454 }
455 self.enum_by_domain
456 .insert(domain.domain.clone(), domain_index);
457 self.enums.push(EnumDomain {
458 domain: domain.domain,
459 aliases: domain_aliases,
460 members,
461 });
462 Ok(())
463 }
464
465 fn collect_aliases(
470 &mut self,
471 id: &str,
472 label: &str,
473 aliases: HashMap<String, AliasFile>,
474 require_primary: bool,
475 mut register: impl FnMut(&mut Self, &Locale, &str) -> Result<()>,
476 ) -> Result<HashMap<Locale, Vec<String>>> {
477 let mut collected = HashMap::new();
478 for (locale_str, alias_file) in aliases {
479 let locale = Locale::new(&locale_str);
480 if !self.locales.contains(&locale) {
481 return Err(CatalogError::validation(format!(
482 "{label} declares alias for undeclared locale '{locale}'"
483 )));
484 }
485 let spellings = alias_file.into_spellings(id, locale.as_str())?;
486 for spelling in &spellings {
487 register(self, &locale, spelling)?;
488 }
489 collected.insert(locale, spellings);
490 }
491 let primary = &self.locales[0];
492 if require_primary && !collected.contains_key(primary) {
493 return Err(CatalogError::validation(format!(
494 "{label} is missing a '{primary}' alias"
495 )));
496 }
497 Ok(collected)
498 }
499}
500
501pub fn canonicalize(json: &str) -> Result<String> {
507 Catalog::load_unverified(json)?;
509 let value: serde_json::Value = serde_json::from_str(json)
510 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
511 pretty_json(&value)
512}
513
514pub fn build_canonical(json: &str) -> Result<String> {
518 let mut value: serde_json::Value = serde_json::from_str(json)
519 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
520 let digest = content_digest(json)?;
521 if let Some(object) = value.as_object_mut() {
522 object.insert("digest".to_string(), serde_json::Value::String(digest));
523 }
524 let output = pretty_json(&value)?;
525 Catalog::load(&output)?;
528 Ok(output)
529}
530
531pub fn content_digest(json: &str) -> Result<String> {
536 let mut value: serde_json::Value = serde_json::from_str(json)
537 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
538 if let Some(object) = value.as_object_mut() {
539 object.remove("digest");
540 }
541 let canonical = pretty_json(&value)?;
542 use sha2::{Digest, Sha256};
543 let mut hasher = Sha256::new();
544 hasher.update(canonical.trim_end().as_bytes());
545 Ok(format!("{:x}", hasher.finalize()))
546}
547
548fn pretty_json(value: &serde_json::Value) -> Result<String> {
551 serde_json::to_string_pretty(value)
552 .map(|mut out| {
553 out.push('\n');
554 out
555 })
556 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
557}