1use std::collections::{BTreeMap, HashMap};
2use std::sync::OnceLock;
3
4use serde::Deserialize;
5
6use crate::core::error::{CatalogError, Result, WorkshopError};
7
8use super::{
9 CATALOG_DATA, Catalog, CatalogEntry, EnumDomain, EnumMember, Kind, Locale,
10 LocalizedStringEntry, NormalizedIndex, ParamCoercions, Provenance, TargetMeta,
11 normalize_spelling,
12};
13
14#[derive(Deserialize)]
15#[serde(rename_all = "camelCase")]
16struct CatalogFile {
17 schema_version: u32,
18 locales: Vec<String>,
19 target: TargetMeta,
20 provenance: Provenance,
21 #[serde(default)]
22 version: Option<String>,
23 #[serde(default)]
24 digest: Option<String>,
25 #[serde(default)]
26 structural: Vec<EntryFile>,
27 #[serde(default)]
28 actions: Vec<EntryFile>,
29 #[serde(default)]
30 values: Vec<EntryFile>,
31 #[serde(default)]
32 events: Vec<EntryFile>,
33 #[serde(default)]
34 operators: Vec<EntryFile>,
35 #[serde(default)]
36 settings: Vec<EntryFile>,
37 #[serde(default)]
38 localized_strings: Vec<LocalizedStringFile>,
39 #[serde(default)]
40 enums: Vec<EnumFile>,
41}
42
43#[derive(Deserialize)]
44#[serde(rename_all = "camelCase")]
45struct EntryFile {
46 id: String,
47 aliases: HashMap<String, AliasFile>,
48 #[serde(default)]
49 params: Vec<String>,
50 #[serde(default)]
52 param_names: Vec<String>,
53 #[serde(default)]
54 param_aliases: Vec<HashMap<String, AliasFile>>,
55 #[serde(default)]
58 param_domains: Vec<Option<String>>,
59 #[serde(default)]
66 param_defaults: Vec<Option<String>>,
67 #[serde(default)]
68 param_types: Vec<Option<String>>,
69 #[serde(default)]
70 param_coercions: Vec<Option<ParamCoercions>>,
71 #[serde(default)]
72 return_type: Option<String>,
73 #[serde(default)]
79 reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
80 #[serde(default)]
81 variadic: bool,
82}
83
84#[derive(Deserialize)]
85struct LocalizedStringFile {
86 id: String,
87 aliases: HashMap<String, AliasFile>,
88}
89
90#[derive(Deserialize)]
91struct EnumFile {
92 domain: String,
93 #[serde(default)]
94 aliases: HashMap<String, AliasFile>,
95 members: Vec<MemberFile>,
96}
97
98#[derive(Deserialize)]
99struct MemberFile {
100 id: String,
101 aliases: HashMap<String, AliasFile>,
102}
103
104#[derive(Debug, Deserialize)]
109#[serde(untagged)]
110enum AliasFile {
111 One(String),
112 Many(Vec<String>),
113}
114
115impl AliasFile {
116 fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
117 let spellings = match self {
118 AliasFile::One(spelling) => vec![spelling],
119 AliasFile::Many(spellings) => spellings,
120 };
121 if spellings.is_empty() || spellings.iter().any(String::is_empty) {
122 return Err(CatalogError::validation(format!(
123 "catalog entry '{}' declares an empty alias for locale '{}'",
124 id, locale
125 )));
126 }
127 Ok(spellings)
128 }
129}
130impl Catalog {
131 pub fn load(json: &str) -> Result<Catalog> {
134 let catalog = Self::load_unverified(json)?;
135 if let Some(declared) = &catalog.catalog_digest {
136 let computed = content_digest(json)?;
137 if declared != &computed {
138 return Err(CatalogError::validation(format!(
139 "catalog digest mismatch: declared '{declared}', content '{computed}' — \
140 run the catalog pipeline (workshop-catalog-gen build)"
141 )));
142 }
143 }
144 Ok(catalog)
145 }
146
147 pub fn load_unverified(json: &str) -> Result<Catalog> {
150 let file: CatalogFile = serde_json::from_str(json)
151 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
152 if file.schema_version != 1 {
153 return Err(CatalogError::malformed(format!(
154 "unsupported catalog schemaVersion {}",
155 file.schema_version
156 )));
157 }
158 let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
159 if locales.is_empty() {
160 return Err(CatalogError::malformed(
161 "catalog declares no locales".to_string(),
162 ));
163 }
164
165 let mut catalog = Catalog {
166 detection_index: None,
167 schema_version: file.schema_version,
168 locales,
169 target: file.target,
170 provenance: file.provenance,
171 catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
172 catalog_digest: file.digest,
173 entries: Vec::new(),
174 localized_strings: Vec::new(),
175 enums: Vec::new(),
176 by_id: Default::default(),
177 alias_to_entry: HashMap::new(),
178 localized_string_by_id: HashMap::new(),
179 localized_string_alias: HashMap::new(),
180 enum_by_domain: HashMap::new(),
181 enum_alias_to_domain: HashMap::new(),
182 enum_alias_to_member: HashMap::new(),
183 bare_member_index: HashMap::new(),
184 normalized: NormalizedIndex::default(),
185 };
186
187 for (kind, items) in [
188 (Kind::Structural, file.structural),
189 (Kind::Action, file.actions),
190 (Kind::Value, file.values),
191 (Kind::Event, file.events),
192 (Kind::Operator, file.operators),
193 (Kind::Setting, file.settings),
194 ] {
195 for item in items {
196 catalog.insert_entry(kind, item)?;
197 }
198 }
199 for item in file.localized_strings {
200 catalog.insert_localized_string(item)?;
201 }
202 for domain in file.enums {
203 catalog.insert_enum(domain)?;
204 }
205 for domain in &catalog.enums {
206 for member in &domain.members {
207 for (locale, spellings) in &member.aliases {
208 for spelling in spellings {
209 catalog
210 .bare_member_index
211 .entry(locale.clone())
212 .or_default()
213 .entry(spelling.clone())
214 .or_default()
215 .push((domain.domain.clone(), member.member.clone()));
216 }
217 }
218 }
219 }
220 catalog.build_normalized_index();
221 catalog.validate_param_domains()?;
222 catalog.detection_index =
223 Some(super::detect::AliasIndex::build(&catalog).map_err(|error| {
224 CatalogError::validation(format!("locale detection index: {error}"))
225 })?);
226 Ok(catalog)
227 }
228
229 pub fn builtin() -> Result<Catalog> {
231 Self::builtin_ref().cloned().map_err(Clone::clone)
232 }
233
234 pub fn builtin_ref() -> std::result::Result<&'static Catalog, &'static WorkshopError> {
236 static DATA: OnceLock<Result<Catalog>> = OnceLock::new();
237 match DATA.get_or_init(|| Self::load(CATALOG_DATA)) {
238 Ok(catalog) => Ok(catalog),
239 Err(error) => Err(error),
240 }
241 }
242 fn build_normalized_index(&mut self) {
248 fn insert<T: PartialEq + Clone>(
249 map: &mut HashMap<String, Option<T>>,
250 key: String,
251 value: T,
252 ) {
253 match map.get_mut(&key) {
254 Some(slot) if *slot != Some(value.clone()) => *slot = None,
255 Some(_) => {}
256 None => {
257 map.insert(key, Some(value));
258 }
259 }
260 }
261
262 let mut normalized = NormalizedIndex::default();
263 for (locale, by_kind) in &self.alias_to_entry {
264 let by_kind_norm = normalized
265 .alias_to_entry
266 .entry(locale.clone())
267 .or_insert_with(|| std::array::from_fn(|_| HashMap::new()));
268 for (kind_index, map) in by_kind.iter().enumerate() {
269 for (spelling, &entry_index) in map {
270 let key = normalize_spelling(spelling);
271 if key != spelling.as_str() {
272 insert(&mut by_kind_norm[kind_index], key.into_owned(), entry_index);
273 }
274 }
275 }
276 }
277 for (locale, map) in &self.localized_string_alias {
278 let norm = normalized
279 .localized_string_alias
280 .entry(locale.clone())
281 .or_default();
282 for (spelling, &string_index) in map {
283 let key = normalize_spelling(spelling);
284 if key != spelling.as_str() {
285 insert(norm, key.into_owned(), string_index);
286 }
287 }
288 }
289 for (locale, map) in &self.enum_alias_to_domain {
290 let norm = normalized
291 .enum_alias_to_domain
292 .entry(locale.clone())
293 .or_default();
294 for (spelling, domain) in map {
295 let key = normalize_spelling(spelling);
296 if key != spelling.as_str() {
297 insert(norm, key.into_owned(), domain.clone());
298 }
299 }
300 }
301 for (locale, domains) in &self.enum_alias_to_member {
302 let norm = normalized
303 .enum_alias_to_member
304 .entry(locale.clone())
305 .or_default();
306 for (domain, map) in domains {
307 let norm_map = norm.entry(domain.clone()).or_default();
308 for (spelling, &indexes) in map {
309 let key = normalize_spelling(spelling);
310 if key != spelling.as_str() {
311 insert(norm_map, key.into_owned(), indexes);
312 }
313 }
314 }
315 }
316 for (locale, map) in &self.bare_member_index {
317 let norm = normalized.bare_member.entry(locale.clone()).or_default();
318 for (spelling, matches) in map {
319 let key = normalize_spelling(spelling);
320 if key != spelling.as_str() {
321 let merged = norm.entry(key.into_owned()).or_default();
322 for pair in matches {
323 if !merged.contains(pair) {
324 merged.push(pair.clone());
325 }
326 }
327 }
328 }
329 }
330 self.normalized = normalized;
331 }
332
333 fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
334 let index = self.entries.len();
335 let aliases = self.collect_aliases(
336 &item.id,
337 &format!("entry '{}'", item.id),
338 item.aliases,
339 true,
340 |catalog, locale, spelling| {
341 let locale_map = catalog.alias_to_entry.entry(locale.clone()).or_default();
342 if locale_map[kind.as_index()].contains_key(spelling) {
343 return Err(CatalogError::validation(format!(
344 "duplicate {} alias '{spelling}' for locale '{locale}'",
345 kind.as_str()
346 )));
347 }
348 locale_map[kind.as_index()].insert(spelling.to_string(), index);
349 Ok(())
350 },
351 )?;
352 if self.by_id[kind.as_index()].contains_key(&item.id) {
353 return Err(CatalogError::validation(format!(
354 "duplicate {} id '{}'",
355 kind.as_str(),
356 item.id
357 )));
358 }
359 let param_names = match item.param_names {
364 names if names.is_empty() => None,
365 names if names.len() != item.params.len() => {
366 return Err(CatalogError::validation(format!(
367 "{} '{}' declares {} param names for {} params",
368 kind.as_str(),
369 item.id,
370 names.len(),
371 item.params.len()
372 )));
373 }
374 names if names == item.params => None,
375 names => Some(names),
376 };
377 self.by_id[kind.as_index()].insert(item.id.clone(), index);
378 let item_id = item.id.clone();
379 self.entries.push(CatalogEntry {
380 id: item.id,
381 kind,
382 params: item.params,
383 param_names,
384 param_aliases: item
385 .param_aliases
386 .into_iter()
387 .map(|aliases| {
388 aliases
389 .into_iter()
390 .map(|(locale, alias)| {
391 let locale_key = Locale::new(&locale);
392 let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
393 Ok((locale_key, spellings))
394 })
395 .collect::<Result<HashMap<_, _>>>()
396 })
397 .collect::<Result<Vec<_>>>()?,
398 param_domains: item.param_domains,
399 param_defaults: item.param_defaults,
400 param_types: item.param_types,
401 param_coercions: item.param_coercions,
402 return_type: item.return_type,
403 reevaluation_coverage: item.reevaluation_coverage,
404 variadic: item.variadic,
405 aliases,
406 });
407 Ok(())
408 }
409
410 fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
411 if self.localized_string_by_id.contains_key(&item.id) {
412 return Err(CatalogError::validation(format!(
413 "duplicate localized string id '{}'",
414 item.id
415 )));
416 }
417 let index = self.localized_strings.len();
418 let aliases = self.collect_aliases(
419 &item.id,
420 &format!("localized string '{}'", item.id),
421 item.aliases,
422 true,
423 |catalog, locale, spelling| {
424 let locale_map = catalog
425 .localized_string_alias
426 .entry(locale.clone())
427 .or_default();
428 if locale_map.contains_key(spelling) {
429 return Err(CatalogError::validation(format!(
430 "duplicate localized string alias '{spelling}' for locale '{locale}'"
431 )));
432 }
433 locale_map.insert(spelling.to_string(), index);
434 Ok(())
435 },
436 )?;
437 self.localized_string_by_id.insert(item.id.clone(), index);
438 self.localized_strings.push(LocalizedStringEntry {
439 id: item.id,
440 aliases,
441 });
442 Ok(())
443 }
444
445 fn validate_param_domains(&self) -> Result<()> {
446 for entry in &self.entries {
447 for (name, count) in [
448 ("parameter alias sets", entry.param_aliases.len()),
449 ("param domains", entry.param_domains.len()),
450 ("param defaults", entry.param_defaults.len()),
451 ("param types", entry.param_types.len()),
452 ("param coercions", entry.param_coercions.len()),
453 ] {
454 if count > entry.params.len() {
455 return Err(CatalogError::validation(format!(
456 "{} '{}' declares more {name} than params",
457 entry.kind.as_str(),
458 entry.id
459 )));
460 }
461 }
462 if entry.kind != Kind::Value && entry.return_type.is_some() {
463 return Err(CatalogError::validation(format!(
464 "{} '{}' declares a return type but is not a value",
465 entry.kind.as_str(),
466 entry.id
467 )));
468 }
469 for domain in entry.param_domains.iter().flatten() {
470 if !self.enum_by_domain.contains_key(domain) {
471 return Err(CatalogError::validation(format!(
472 "{} '{}' declares undeclared enum domain '{domain}'",
473 entry.kind.as_str(),
474 entry.id
475 )));
476 }
477 }
478 if let Some(coverage) = &entry.reevaluation_coverage {
479 let reeval_params: Vec<usize> = (0..entry.params.len())
480 .filter(|index| {
481 entry
482 .param_domains
483 .get(*index)
484 .and_then(Option::as_deref)
485 .is_some_and(|domain| domain.ends_with("Reeval"))
486 })
487 .collect();
488 if reeval_params.len() != 1 {
489 return Err(CatalogError::validation(format!(
490 "{} '{}' declares reevaluation coverage but has {} *Reeval parameters",
491 entry.kind.as_str(),
492 entry.id,
493 reeval_params.len()
494 )));
495 }
496 let domain_name = entry.param_domains[reeval_params[0]]
497 .as_deref()
498 .expect("filtered above");
499 let domain = self
500 .enum_by_domain
501 .get(domain_name)
502 .map(|index| &self.enums[*index])
503 .expect("param domain validated above");
504 for (member, positions) in coverage {
505 if !domain.members.iter().any(|m| m.member == *member) {
506 return Err(CatalogError::validation(format!(
507 "{} '{}' declares reevaluation coverage for '{member}', which is not a member of '{domain_name}'",
508 entry.kind.as_str(),
509 entry.id
510 )));
511 }
512 for position in positions {
513 if *position >= entry.params.len() {
514 return Err(CatalogError::validation(format!(
515 "{} '{}' reevaluation coverage for '{member}' names parameter {position}, out of {} params",
516 entry.kind.as_str(),
517 entry.id,
518 entry.params.len()
519 )));
520 }
521 if *position == reeval_params[0] {
522 return Err(CatalogError::validation(format!(
523 "{} '{}' reevaluation coverage for '{member}' covers the reevaluation parameter itself",
524 entry.kind.as_str(),
525 entry.id
526 )));
527 }
528 }
529 }
530 for member in &domain.members {
531 if !coverage.contains_key(&member.member) {
532 return Err(CatalogError::validation(format!(
533 "{} '{}' reevaluation coverage does not review '{domain_name}' member '{}'",
534 entry.kind.as_str(),
535 entry.id,
536 member.member
537 )));
538 }
539 }
540 }
541 for aliases in &entry.param_aliases {
542 for locale in aliases.keys() {
543 if !self.locales.contains(locale) {
544 return Err(CatalogError::validation(format!(
545 "{} '{}' declares parameter alias for undeclared locale '{}'",
546 entry.kind.as_str(),
547 entry.id,
548 locale
549 )));
550 }
551 }
552 }
553 }
554 Ok(())
555 }
556
557 fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
558 let domain_index = self.enums.len();
559 if self.enum_by_domain.contains_key(&domain.domain) {
560 return Err(CatalogError::validation(format!(
561 "duplicate enum domain '{}'",
562 domain.domain
563 )));
564 }
565 let primary = self.locales[0].clone();
566 let mut domain_aliases = self.collect_aliases(
567 &domain.domain,
568 &format!("enum domain '{}'", domain.domain),
569 domain.aliases,
570 false,
571 |catalog, locale, spelling| {
572 let locale_map = catalog
573 .enum_alias_to_domain
574 .entry(locale.clone())
575 .or_default();
576 if let Some(existing) = locale_map.get(spelling) {
577 return Err(CatalogError::validation(format!(
578 "duplicate enum domain alias '{spelling}' for '{existing}' and '{}' in locale '{locale}'",
579 domain.domain
580 )));
581 }
582 locale_map.insert(spelling.to_string(), domain.domain.clone());
583 Ok(())
584 },
585 )?;
586 domain_aliases
587 .entry(primary.clone())
588 .or_insert_with(|| vec![domain.domain.clone()]);
589 self.enum_alias_to_domain
590 .entry(primary.clone())
591 .or_default()
592 .entry(domain.domain.clone())
593 .or_insert_with(|| domain.domain.clone());
594 let mut members = Vec::new();
595 for (member_index, member) in domain.members.into_iter().enumerate() {
596 let aliases = self.collect_aliases(
597 &member.id,
598 &format!("enum {}::{}", domain.domain, member.id),
599 member.aliases,
600 true,
601 |catalog, locale, spelling| {
602 let domain_map = catalog
603 .enum_alias_to_member
604 .entry(locale.clone())
605 .or_default()
606 .entry(domain.domain.clone())
607 .or_default();
608 if domain_map.contains_key(spelling) {
609 return Err(CatalogError::validation(format!(
610 "duplicate enum alias '{spelling}' in '{}' for locale '{locale}'",
611 domain.domain
612 )));
613 }
614 domain_map.insert(spelling.to_string(), (domain_index, member_index));
615 Ok(())
616 },
617 )?;
618 members.push(EnumMember {
619 member: member.id,
620 aliases,
621 });
622 }
623 self.enum_by_domain
624 .insert(domain.domain.clone(), domain_index);
625 self.enums.push(EnumDomain {
626 domain: domain.domain,
627 aliases: domain_aliases,
628 members,
629 });
630 Ok(())
631 }
632
633 fn collect_aliases(
634 &mut self,
635 id: &str,
636 label: &str,
637 aliases: HashMap<String, AliasFile>,
638 require_primary: bool,
639 mut register: impl FnMut(&mut Self, &Locale, &str) -> Result<()>,
640 ) -> Result<HashMap<Locale, Vec<String>>> {
641 let mut collected = HashMap::new();
642 for (locale_str, alias_file) in aliases {
643 let locale = Locale::new(&locale_str);
644 if !self.locales.contains(&locale) {
645 return Err(CatalogError::validation(format!(
646 "{label} declares alias for undeclared locale '{locale}'"
647 )));
648 }
649 let spellings = alias_file.into_spellings(id, locale.as_str())?;
650 for spelling in &spellings {
651 register(self, &locale, spelling)?;
652 }
653 collected.insert(locale, spellings);
654 }
655 let primary = &self.locales[0];
656 if require_primary && !collected.contains_key(primary) {
657 return Err(CatalogError::validation(format!(
658 "{label} is missing a '{primary}' alias"
659 )));
660 }
661 Ok(collected)
662 }
663}
664
665pub fn canonicalize(json: &str) -> Result<String> {
671 Catalog::load_unverified(json)?;
672 let value: serde_json::Value = serde_json::from_str(json)
673 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
674 pretty_json(&value)
675}
676
677pub fn build_canonical(json: &str) -> Result<String> {
681 let mut value: serde_json::Value = serde_json::from_str(json)
682 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
683 let digest = content_digest(json)?;
684 if let Some(object) = value.as_object_mut() {
685 object.insert("digest".to_string(), serde_json::Value::String(digest));
686 }
687 let output = pretty_json(&value)?;
688 Catalog::load(&output)?;
689 Ok(output)
690}
691
692pub fn content_digest(json: &str) -> Result<String> {
697 let mut value: serde_json::Value = serde_json::from_str(json)
698 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
699 if let Some(object) = value.as_object_mut() {
700 object.remove("digest");
701 }
702 let canonical = pretty_json(&value)?;
703 use sha2::{Digest, Sha256};
704 let mut hasher = Sha256::new();
705 hasher.update(canonical.trim_end().as_bytes());
706 Ok(format!("{:x}", hasher.finalize()))
707}
708
709fn pretty_json(value: &serde_json::Value) -> Result<String> {
710 serde_json::to_string_pretty(value)
711 .map(|mut out| {
712 out.push('\n');
713 out
714 })
715 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
716}