1use std::collections::HashMap;
23
24use serde::{Deserialize, Deserializer, Serialize};
25
26use crate::signatures::ExpectedDomain;
27
28use crate::error::{CatalogError, Result};
29
30pub const CATALOG_DATA: &str = include_str!("data/catalog.json");
32
33#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
35pub struct Locale(String);
36
37impl Locale {
38 pub fn new(value: &str) -> Locale {
40 Locale(value.trim().to_ascii_lowercase())
41 }
42
43 pub fn as_str(&self) -> &str {
45 &self.0
46 }
47}
48
49impl<'de> Deserialize<'de> for Locale {
50 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
51 where
52 D: Deserializer<'de>,
53 {
54 let value = String::deserialize(deserializer)?;
55 Ok(Self::new(&value))
56 }
57}
58
59impl std::fmt::Display for Locale {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 f.write_str(&self.0)
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum Kind {
68 Structural,
70 Action,
72 Value,
74 Event,
76 Operator,
78 Enum,
80 Setting,
82}
83
84impl Kind {
85 pub fn as_str(self) -> &'static str {
86 match self {
87 Kind::Structural => "structural",
88 Kind::Action => "action",
89 Kind::Value => "value",
90 Kind::Event => "event",
91 Kind::Operator => "operator",
92 Kind::Enum => "enum",
93 Kind::Setting => "setting",
94 }
95 }
96}
97
98#[derive(Debug, Clone)]
100pub struct CatalogEntry {
101 pub id: String,
102 pub kind: Kind,
103 pub params: Vec<String>,
105 pub param_domains: Vec<Option<String>>,
113 pub param_defaults: Vec<Option<String>>,
117 pub param_types: Vec<Option<String>>,
120 pub return_type: Option<String>,
123 pub variadic: bool,
125 aliases: HashMap<Locale, Vec<String>>,
126}
127
128#[derive(Debug, Clone)]
132pub struct LocalizedStringEntry {
133 pub id: String,
134 aliases: HashMap<Locale, Vec<String>>,
135}
136
137impl LocalizedStringEntry {
138 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
140 self.aliases
141 .get(locale)
142 .and_then(|spellings| spellings.first())
143 .map(String::as_str)
144 }
145
146 pub fn spellings(&self, locale: &Locale) -> &[String] {
148 self.aliases
149 .get(locale)
150 .map(Vec::as_slice)
151 .unwrap_or_default()
152 }
153}
154
155impl CatalogEntry {
156 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
158 self.aliases
159 .get(locale)
160 .and_then(|spellings| spellings.first())
161 .map(String::as_str)
162 }
163
164 pub fn spellings(&self, locale: &Locale) -> &[String] {
167 self.aliases.get(locale).map(Vec::as_slice).unwrap_or(&[])
168 }
169
170 pub fn param_count(&self) -> usize {
172 self.params.len()
173 }
174
175 pub fn required_param_count(&self) -> usize {
179 (0..self.params.len())
180 .rev()
181 .find(|index| {
182 self.param_defaults
183 .get(*index)
184 .and_then(Option::as_ref)
185 .is_none()
186 })
187 .map_or(0, |index| index + 1)
188 }
189
190 pub fn param_domain(&self, index: usize) -> Option<&str> {
192 self.param_domains
193 .get(index)
194 .or_else(|| self.variadic.then(|| self.param_domains.last()).flatten())
195 .and_then(Option::as_deref)
196 }
197
198 pub fn param_type(&self, index: usize) -> Option<&str> {
201 self.param_types
202 .get(index)
203 .or_else(|| self.variadic.then(|| self.param_types.last()).flatten())
204 .and_then(Option::as_deref)
205 }
206
207 pub fn return_type(&self) -> Option<&str> {
209 self.return_type.as_deref()
210 }
211}
212
213#[derive(Debug, Clone)]
215pub struct EnumMember {
216 pub member: String,
217 aliases: HashMap<Locale, Vec<String>>,
218}
219
220impl EnumMember {
221 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
223 self.aliases
224 .get(locale)
225 .and_then(|spellings| spellings.first())
226 .map(String::as_str)
227 }
228
229 pub fn spellings(&self, locale: &Locale) -> &[String] {
232 self.aliases.get(locale).map(Vec::as_slice).unwrap_or(&[])
233 }
234}
235
236#[derive(Debug, Clone)]
238pub struct EnumDomain {
239 pub domain: String,
240 aliases: HashMap<Locale, Vec<String>>,
241 pub members: Vec<EnumMember>,
242}
243
244impl EnumDomain {
245 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
246 self.aliases
247 .get(locale)
248 .and_then(|spellings| spellings.first())
249 .map(String::as_str)
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
255pub struct TargetMeta {
256 pub game: String,
257 pub format: String,
258 pub surface: String,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
263#[serde(rename_all = "camelCase")]
264pub struct Provenance {
265 pub generator: String,
266 pub generator_version: String,
267 pub source: String,
268 pub license: String,
269 pub reviewed: bool,
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
273 pub source_notes: Vec<String>,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
280pub struct LocaleCoverage {
281 pub locale: Locale,
282 pub mapped: usize,
284 pub total: usize,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
294#[serde(rename_all = "kebab-case")]
295pub struct CatalogIdentity {
296 pub implementation_version: String,
298 pub catalog_version: String,
300 pub catalog_digest: Option<String>,
303 pub locale_coverage: Vec<LocaleCoverage>,
305 pub target: TargetMeta,
307 pub provenance: Provenance,
309}
310
311#[derive(Debug, Clone)]
313pub struct Catalog {
314 pub schema_version: u32,
315 pub locales: Vec<Locale>,
318 pub target: TargetMeta,
319 pub provenance: Provenance,
320 catalog_version: String,
322 catalog_digest: Option<String>,
325 entries: Vec<CatalogEntry>,
326 localized_strings: Vec<LocalizedStringEntry>,
327 enums: Vec<EnumDomain>,
328 by_id: HashMap<(Kind, String), usize>,
329 alias_to_entry: HashMap<(Kind, Locale, String), usize>,
330 localized_string_by_id: HashMap<String, usize>,
331 localized_string_alias: HashMap<(Locale, String), usize>,
332 enum_by_domain: HashMap<String, usize>,
333 enum_alias_to_domain: HashMap<(Locale, String), String>,
334 enum_alias_to_member: HashMap<(String, Locale, String), (usize, usize)>,
335}
336
337#[derive(Deserialize)]
338#[serde(rename_all = "camelCase")]
339struct CatalogFile {
340 schema_version: u32,
341 locales: Vec<String>,
342 target: TargetMeta,
343 provenance: Provenance,
344 #[serde(default)]
346 version: Option<String>,
347 #[serde(default)]
349 digest: Option<String>,
350 #[serde(default)]
351 structural: Vec<EntryFile>,
352 #[serde(default)]
353 actions: Vec<EntryFile>,
354 #[serde(default)]
355 values: Vec<EntryFile>,
356 #[serde(default)]
357 events: Vec<EntryFile>,
358 #[serde(default)]
359 operators: Vec<EntryFile>,
360 #[serde(default)]
361 settings: Vec<EntryFile>,
362 #[serde(default)]
363 localized_strings: Vec<LocalizedStringFile>,
364 #[serde(default)]
365 enums: Vec<EnumFile>,
366}
367
368#[derive(Deserialize)]
369#[serde(rename_all = "camelCase")]
370struct EntryFile {
371 id: String,
372 aliases: HashMap<String, AliasFile>,
373 #[serde(default)]
374 params: Vec<String>,
375 #[serde(default)]
378 param_domains: Vec<Option<String>>,
379 #[serde(default)]
386 param_defaults: Vec<Option<String>>,
387 #[serde(default)]
388 param_types: Vec<Option<String>>,
389 #[serde(default)]
390 return_type: Option<String>,
391 #[serde(default)]
392 variadic: bool,
393}
394
395#[derive(Deserialize)]
396struct LocalizedStringFile {
397 id: String,
398 aliases: HashMap<String, AliasFile>,
399}
400
401#[derive(Deserialize)]
402struct EnumFile {
403 domain: String,
404 #[serde(default)]
405 aliases: HashMap<String, AliasFile>,
406 members: Vec<MemberFile>,
407}
408
409#[derive(Deserialize)]
410struct MemberFile {
411 id: String,
412 aliases: HashMap<String, AliasFile>,
413}
414
415#[derive(Debug, Deserialize)]
420#[serde(untagged)]
421enum AliasFile {
422 One(String),
423 Many(Vec<String>),
424}
425
426impl AliasFile {
427 fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
428 let spellings = match self {
429 AliasFile::One(spelling) => vec![spelling],
430 AliasFile::Many(spellings) => spellings,
431 };
432 if spellings.is_empty() || spellings.iter().any(String::is_empty) {
433 return Err(CatalogError::validation(format!(
434 "catalog entry '{}' declares an empty alias for locale '{}'",
435 id, locale
436 )));
437 }
438 Ok(spellings)
439 }
440}
441
442impl Catalog {
443 pub fn load(json: &str) -> Result<Catalog> {
446 let catalog = Self::load_unverified(json)?;
447 if let Some(declared) = &catalog.catalog_digest {
448 let computed = content_digest(json)?;
449 if declared != &computed {
450 return Err(CatalogError::validation(format!(
451 "catalog digest mismatch: declared '{declared}', content '{computed}' — \
452 run the catalog pipeline (workshop-catalog-gen build)"
453 )));
454 }
455 }
456 Ok(catalog)
457 }
458
459 pub fn load_unverified(json: &str) -> Result<Catalog> {
462 let file: CatalogFile = serde_json::from_str(json)
463 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
464 if file.schema_version != 1 {
465 return Err(CatalogError::malformed(format!(
466 "unsupported catalog schemaVersion {}",
467 file.schema_version
468 )));
469 }
470 let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
471 if locales.is_empty() {
472 return Err(CatalogError::malformed(
473 "catalog declares no locales".to_string(),
474 ));
475 }
476
477 let mut catalog = Catalog {
478 schema_version: file.schema_version,
479 locales,
480 target: file.target,
481 provenance: file.provenance,
482 catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
483 catalog_digest: file.digest,
484 entries: Vec::new(),
485 localized_strings: Vec::new(),
486 enums: Vec::new(),
487 by_id: HashMap::new(),
488 alias_to_entry: HashMap::new(),
489 localized_string_by_id: HashMap::new(),
490 localized_string_alias: HashMap::new(),
491 enum_by_domain: HashMap::new(),
492 enum_alias_to_domain: HashMap::new(),
493 enum_alias_to_member: HashMap::new(),
494 };
495
496 for (kind, items) in [
497 (Kind::Structural, file.structural),
498 (Kind::Action, file.actions),
499 (Kind::Value, file.values),
500 (Kind::Event, file.events),
501 (Kind::Operator, file.operators),
502 (Kind::Setting, file.settings),
503 ] {
504 for item in items {
505 catalog.insert_entry(kind, item)?;
506 }
507 }
508 for item in file.localized_strings {
509 catalog.insert_localized_string(item)?;
510 }
511 for domain in file.enums {
512 catalog.insert_enum(domain)?;
513 }
514 catalog.validate_param_domains()?;
515 Ok(catalog)
516 }
517
518 pub fn builtin() -> Result<Catalog> {
520 Self::load(CATALOG_DATA)
521 }
522
523 pub fn locales(&self) -> &[Locale] {
525 &self.locales
526 }
527
528 pub fn primary_locale(&self) -> &Locale {
531 &self.locales[0]
532 }
533
534 pub fn supports(&self, locale: &Locale) -> bool {
536 self.locales.contains(locale)
537 }
538
539 pub fn catalog_version(&self) -> &str {
541 &self.catalog_version
542 }
543
544 pub fn catalog_digest(&self) -> Option<&str> {
547 self.catalog_digest.as_deref()
548 }
549
550 pub fn implementation_version() -> &'static str {
552 env!("CARGO_PKG_VERSION")
553 }
554
555 pub fn identity(&self) -> CatalogIdentity {
558 CatalogIdentity {
559 implementation_version: Self::implementation_version().to_string(),
560 catalog_version: self.catalog_version.clone(),
561 catalog_digest: self.catalog_digest.clone(),
562 locale_coverage: self
563 .locales
564 .iter()
565 .map(|locale| self.locale_coverage(locale))
566 .collect(),
567 target: self.target.clone(),
568 provenance: self.provenance.clone(),
569 }
570 }
571
572 pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
577 let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
578 let total = self.entries.len() + self.localized_strings.len() + member_total;
579 let mapped = self
580 .entries
581 .iter()
582 .filter(|entry| entry.aliases.contains_key(locale))
583 .count()
584 + self
585 .localized_strings
586 .iter()
587 .filter(|entry| entry.aliases.contains_key(locale))
588 .count()
589 + self
590 .enums
591 .iter()
592 .flat_map(|domain| &domain.members)
593 .filter(|member| member.aliases.contains_key(locale))
594 .count();
595 LocaleCoverage {
596 locale: locale.clone(),
597 mapped,
598 total,
599 }
600 }
601
602 pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
604 self.locales
605 .iter()
606 .map(|locale| self.locale_coverage(locale))
607 .collect()
608 }
609
610 pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
612 self.by_id
613 .get(&(kind, id.to_string()))
614 .map(|i| &self.entries[*i])
615 }
616
617 pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
619 self.alias_to_entry
620 .get(&(kind, locale.clone(), spelling.to_string()))
621 .map(|i| &self.entries[*i])
622 }
623
624 pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
626 self.entry(kind, id)?.spelling(locale)
627 }
628
629 pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
631 self.entries.iter().filter(move |entry| entry.kind == kind)
632 }
633
634 pub fn resolve_localized_string(
636 &self,
637 locale: &Locale,
638 spelling: &str,
639 ) -> Option<&LocalizedStringEntry> {
640 self.localized_string_alias
641 .get(&(locale.clone(), spelling.to_string()))
642 .map(|index| &self.localized_strings[*index])
643 }
644
645 pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
647 self.localized_strings
648 .get(*self.localized_string_by_id.get(id)?)
649 .and_then(|entry| entry.spelling(locale))
650 }
651
652 pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
654 self.localized_strings.iter()
655 }
656
657 pub fn entry_count(&self) -> usize {
659 self.entries.len()
660 }
661
662 pub fn enum_domains_count(&self) -> usize {
664 self.enums.len()
665 }
666
667 pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
669 self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
670 }
671
672 pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
674 self.enum_by_domain
675 .get_key_value(spelling)
676 .map(|(domain, _)| domain.as_str())
677 .or_else(|| {
678 self.enum_alias_to_domain
679 .get(&(locale.clone(), spelling.to_string()))
680 .map(String::as_str)
681 })
682 }
683
684 pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
686 self.enums.iter()
687 }
688
689 pub fn resolve_enum_member(
691 &self,
692 domain: &str,
693 locale: &Locale,
694 spelling: &str,
695 ) -> Option<(String, String)> {
696 let (domain_index, member_index) = self.enum_alias_to_member.get(&(
697 domain.to_string(),
698 locale.clone(),
699 spelling.to_string(),
700 ))?;
701 Some((
702 domain.to_string(),
703 self.enums[*domain_index].members[*member_index]
704 .member
705 .clone(),
706 ))
707 }
708
709 pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
711 let domain_index = self.enum_by_domain.get(domain)?;
712 let domain = &self.enums[*domain_index];
713 domain
714 .members
715 .iter()
716 .find(|candidate| candidate.member == member)?
717 .spelling(locale)
718 }
719
720 pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
725 let mut matches = Vec::new();
726 for domain in &self.enums {
727 for member in &domain.members {
728 if member
729 .spellings(locale)
730 .iter()
731 .any(|alias| alias == spelling)
732 {
733 matches.push((domain.domain.clone(), member.member.clone()));
734 }
735 }
736 }
737 matches
738 }
739
740 fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
741 let index = self.entries.len();
742 let mut aliases = HashMap::new();
743 for (locale_str, alias_file) in item.aliases {
744 let locale = Locale::new(&locale_str);
745 if !self.locales.contains(&locale) {
746 return Err(CatalogError::validation(format!(
747 "entry '{}' declares alias for undeclared locale '{}'",
748 item.id, locale
749 )));
750 }
751 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
752 for spelling in &spellings {
753 let key = (kind, locale.clone(), spelling.clone());
754 if self.alias_to_entry.contains_key(&key) {
755 return Err(CatalogError::validation(format!(
756 "duplicate {} alias '{spelling}' for locale '{}'",
757 kind.as_str(),
758 locale
759 )));
760 }
761 self.alias_to_entry.insert(key, index);
762 }
763 aliases.insert(locale, spellings);
764 }
765 let id_key = (kind, item.id.clone());
766 if self.by_id.contains_key(&id_key) {
767 return Err(CatalogError::validation(format!(
768 "duplicate {} id '{}'",
769 kind.as_str(),
770 item.id
771 )));
772 }
773 let primary = self.locales[0].clone();
778 if !aliases.contains_key(&primary) {
779 return Err(CatalogError::validation(format!(
780 "{} '{}' is missing a '{}' alias",
781 kind.as_str(),
782 item.id,
783 primary
784 )));
785 }
786 self.by_id.insert(id_key, index);
787 self.entries.push(CatalogEntry {
788 id: item.id,
789 kind,
790 params: item.params,
791 param_domains: item.param_domains,
792 param_defaults: item.param_defaults,
793 param_types: item.param_types,
794 return_type: item.return_type,
795 variadic: item.variadic,
796 aliases,
797 });
798 Ok(())
799 }
800
801 fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
802 if self.localized_string_by_id.contains_key(&item.id) {
803 return Err(CatalogError::validation(format!(
804 "duplicate localized string id '{}'",
805 item.id
806 )));
807 }
808 let index = self.localized_strings.len();
809 let mut aliases = HashMap::new();
810 for (locale_str, alias_file) in item.aliases {
811 let locale = Locale::new(&locale_str);
812 if !self.locales.contains(&locale) {
813 return Err(CatalogError::validation(format!(
814 "localized string '{}' declares alias for undeclared locale '{}'",
815 item.id, locale
816 )));
817 }
818 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
819 for spelling in &spellings {
820 let key = (locale.clone(), spelling.clone());
821 if self.localized_string_alias.contains_key(&key) {
822 return Err(CatalogError::validation(format!(
823 "duplicate localized string alias '{spelling}' for locale '{locale}'"
824 )));
825 }
826 self.localized_string_alias.insert(key, index);
827 }
828 aliases.insert(locale, spellings);
829 }
830 let primary = self.locales[0].clone();
831 if !aliases.contains_key(&primary) {
832 return Err(CatalogError::validation(format!(
833 "localized string '{}' is missing a '{}' alias",
834 item.id, primary
835 )));
836 }
837 self.localized_string_by_id.insert(item.id.clone(), index);
838 self.localized_strings.push(LocalizedStringEntry {
839 id: item.id,
840 aliases,
841 });
842 Ok(())
843 }
844
845 fn validate_param_domains(&self) -> Result<()> {
847 for entry in &self.entries {
848 if entry.param_domains.len() > entry.params.len() {
849 return Err(CatalogError::validation(format!(
850 "{} '{}' declares more param domains than params",
851 entry.kind.as_str(),
852 entry.id
853 )));
854 }
855 if entry.param_defaults.len() > entry.params.len() {
856 return Err(CatalogError::validation(format!(
857 "{} '{}' declares more param defaults than params",
858 entry.kind.as_str(),
859 entry.id
860 )));
861 }
862 if entry.param_types.len() > entry.params.len() {
863 return Err(CatalogError::validation(format!(
864 "{} '{}' declares more param types than params",
865 entry.kind.as_str(),
866 entry.id
867 )));
868 }
869 if entry.kind != Kind::Value && entry.return_type.is_some() {
870 return Err(CatalogError::validation(format!(
871 "{} '{}' declares a return type but is not a value",
872 entry.kind.as_str(),
873 entry.id
874 )));
875 }
876 for domain in entry.param_domains.iter().flatten() {
877 if !self.enum_by_domain.contains_key(domain) {
878 return Err(CatalogError::validation(format!(
879 "{} '{}' declares undeclared enum domain '{domain}'",
880 entry.kind.as_str(),
881 entry.id
882 )));
883 }
884 }
885 }
886 Ok(())
887 }
888
889 fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
890 let domain_index = self.enums.len();
891 if self.enum_by_domain.contains_key(&domain.domain) {
892 return Err(CatalogError::validation(format!(
893 "duplicate enum domain '{}'",
894 domain.domain
895 )));
896 }
897 let primary = self.locales[0].clone();
898 let mut domain_aliases = HashMap::new();
899 for (locale_str, alias_file) in domain.aliases {
900 let locale = Locale::new(&locale_str);
901 if !self.locales.contains(&locale) {
902 return Err(CatalogError::validation(format!(
903 "enum domain '{}' declares alias for undeclared locale '{}'",
904 domain.domain, locale
905 )));
906 }
907 let spellings = alias_file.into_spellings(&domain.domain, locale.as_str())?;
908 for spelling in &spellings {
909 let key = (locale.clone(), spelling.clone());
910 if let Some(existing) = self.enum_alias_to_domain.get(&key) {
911 return Err(CatalogError::validation(format!(
912 "duplicate enum domain alias '{spelling}' for '{}' and '{}' in locale '{}'",
913 existing, domain.domain, locale
914 )));
915 }
916 self.enum_alias_to_domain.insert(key, domain.domain.clone());
917 }
918 domain_aliases.insert(locale, spellings);
919 }
920 domain_aliases
921 .entry(primary.clone())
922 .or_insert_with(|| vec![domain.domain.clone()]);
923 self.enum_alias_to_domain
924 .entry((primary.clone(), domain.domain.clone()))
925 .or_insert_with(|| domain.domain.clone());
926 let mut members = Vec::new();
927 for (member_index, member) in domain.members.into_iter().enumerate() {
928 let mut aliases = HashMap::new();
929 for (locale_str, alias_file) in member.aliases {
930 let locale = Locale::new(&locale_str);
931 if !self.locales.contains(&locale) {
932 return Err(CatalogError::validation(format!(
933 "enum {}::{} declares alias for undeclared locale '{}'",
934 domain.domain, member.id, locale
935 )));
936 }
937 let spellings = alias_file.into_spellings(&member.id, locale.as_str())?;
938 for spelling in &spellings {
939 let key = (domain.domain.clone(), locale.clone(), spelling.clone());
940 if self.enum_alias_to_member.contains_key(&key) {
941 return Err(CatalogError::validation(format!(
942 "duplicate enum alias '{spelling}' in '{}' for locale '{}'",
943 domain.domain, locale
944 )));
945 }
946 self.enum_alias_to_member
947 .insert(key, (domain_index, member_index));
948 }
949 aliases.insert(locale, spellings);
950 }
951 if !aliases.contains_key(&primary) {
952 return Err(CatalogError::validation(format!(
953 "enum {}::{} is missing a '{}' alias",
954 domain.domain, member.id, primary
955 )));
956 }
957 members.push(EnumMember {
958 member: member.id,
959 aliases,
960 });
961 }
962 self.enum_by_domain
963 .insert(domain.domain.clone(), domain_index);
964 self.enums.push(EnumDomain {
965 domain: domain.domain,
966 aliases: domain_aliases,
967 members,
968 });
969 Ok(())
970 }
971}
972
973impl ExpectedDomain for Catalog {
980 fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
981 for kind in [Kind::Action, Kind::Value] {
982 if let Some(entry) = self.entry(kind, catalog_id) {
983 if let Some(domain) = entry
984 .param_domains
985 .get(arg_index)
986 .and_then(Option::as_deref)
987 {
988 return Some(domain);
989 }
990 }
991 }
992 None
993 }
994}
995
996pub fn canonicalize(json: &str) -> Result<String> {
1002 Catalog::load_unverified(json)?;
1004 let value: serde_json::Value = serde_json::from_str(json)
1005 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1006 serde_json::to_string_pretty(&value)
1007 .map(|mut out| {
1008 out.push('\n');
1009 out
1010 })
1011 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
1012}
1013
1014pub fn build_canonical(json: &str) -> Result<String> {
1018 let mut value: serde_json::Value = serde_json::from_str(json)
1019 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1020 let digest = content_digest(json)?;
1021 if let Some(object) = value.as_object_mut() {
1022 object.insert("digest".to_string(), serde_json::Value::String(digest));
1023 }
1024 let output = serde_json::to_string_pretty(&value)
1025 .map(|mut out| {
1026 out.push('\n');
1027 out
1028 })
1029 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1030 Catalog::load(&output)?;
1033 Ok(output)
1034}
1035
1036pub fn content_digest(json: &str) -> Result<String> {
1041 let mut value: serde_json::Value = serde_json::from_str(json)
1042 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1043 if let Some(object) = value.as_object_mut() {
1044 object.remove("digest");
1045 }
1046 let canonical = serde_json::to_string_pretty(&value)
1047 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1048 use sha2::{Digest, Sha256};
1049 let mut hasher = Sha256::new();
1050 hasher.update(canonical.as_bytes());
1051 Ok(format!("{:x}", hasher.finalize()))
1052}