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, Copy, Default, PartialEq, Eq, Deserialize)]
105#[serde(rename_all = "camelCase")]
106pub struct ParamCoercions {
107 #[serde(default)]
109 pub false_as_number: bool,
110 #[serde(default)]
112 pub true_as_number: bool,
113 #[serde(default)]
115 pub zero_as_null: bool,
116 #[serde(default)]
118 pub null_vector_as_null: bool,
119 #[serde(default)]
121 pub empty_array_as_string: bool,
122}
123
124#[derive(Debug, Clone)]
126pub struct CatalogEntry {
127 pub id: String,
128 pub kind: Kind,
129 pub params: Vec<String>,
131 pub param_domains: Vec<Option<String>>,
139 pub param_defaults: Vec<Option<String>>,
143 pub param_types: Vec<Option<String>>,
146 pub param_coercions: Vec<Option<ParamCoercions>>,
148 pub return_type: Option<String>,
151 pub variadic: bool,
153 aliases: HashMap<Locale, Vec<String>>,
154}
155
156#[derive(Debug, Clone)]
160pub struct LocalizedStringEntry {
161 pub id: String,
162 aliases: HashMap<Locale, Vec<String>>,
163}
164
165impl LocalizedStringEntry {
166 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
168 self.aliases
169 .get(locale)
170 .and_then(|spellings| spellings.first())
171 .map(String::as_str)
172 }
173
174 pub fn spellings(&self, locale: &Locale) -> &[String] {
176 self.aliases
177 .get(locale)
178 .map(Vec::as_slice)
179 .unwrap_or_default()
180 }
181}
182
183impl CatalogEntry {
184 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
186 self.aliases
187 .get(locale)
188 .and_then(|spellings| spellings.first())
189 .map(String::as_str)
190 }
191
192 pub fn spellings(&self, locale: &Locale) -> &[String] {
195 self.aliases.get(locale).map(Vec::as_slice).unwrap_or(&[])
196 }
197
198 pub fn param_count(&self) -> usize {
200 self.params.len()
201 }
202
203 pub fn required_param_count(&self) -> usize {
207 (0..self.params.len())
208 .rev()
209 .find(|index| {
210 self.param_defaults
211 .get(*index)
212 .and_then(Option::as_ref)
213 .is_none()
214 })
215 .map_or(0, |index| index + 1)
216 }
217
218 pub fn param_domain(&self, index: usize) -> Option<&str> {
220 self.param_domains
221 .get(index)
222 .or_else(|| self.variadic.then(|| self.param_domains.last()).flatten())
223 .and_then(Option::as_deref)
224 }
225
226 pub fn param_type(&self, index: usize) -> Option<&str> {
229 self.param_types
230 .get(index)
231 .or_else(|| self.variadic.then(|| self.param_types.last()).flatten())
232 .and_then(Option::as_deref)
233 }
234
235 pub fn param_coercions(&self, index: usize) -> Option<&ParamCoercions> {
237 self.param_coercions
238 .get(index)
239 .or_else(|| self.variadic.then(|| self.param_coercions.last()).flatten())
240 .and_then(Option::as_ref)
241 }
242
243 pub fn return_type(&self) -> Option<&str> {
245 self.return_type.as_deref()
246 }
247}
248
249#[derive(Debug, Clone)]
251pub struct EnumMember {
252 pub member: String,
253 aliases: HashMap<Locale, Vec<String>>,
254}
255
256impl EnumMember {
257 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
259 self.aliases
260 .get(locale)
261 .and_then(|spellings| spellings.first())
262 .map(String::as_str)
263 }
264
265 pub fn spellings(&self, locale: &Locale) -> &[String] {
268 self.aliases.get(locale).map(Vec::as_slice).unwrap_or(&[])
269 }
270}
271
272#[derive(Debug, Clone)]
274pub struct EnumDomain {
275 pub domain: String,
276 aliases: HashMap<Locale, Vec<String>>,
277 pub members: Vec<EnumMember>,
278}
279
280impl EnumDomain {
281 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
282 self.aliases
283 .get(locale)
284 .and_then(|spellings| spellings.first())
285 .map(String::as_str)
286 }
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
291pub struct TargetMeta {
292 pub game: String,
293 pub format: String,
294 pub surface: String,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
299#[serde(rename_all = "camelCase")]
300pub struct Provenance {
301 pub generator: String,
302 pub generator_version: String,
303 pub source: String,
304 pub license: String,
305 pub reviewed: bool,
306 #[serde(default, skip_serializing_if = "Vec::is_empty")]
309 pub source_notes: Vec<String>,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
316pub struct LocaleCoverage {
317 pub locale: Locale,
318 pub mapped: usize,
320 pub total: usize,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
330#[serde(rename_all = "kebab-case")]
331pub struct CatalogIdentity {
332 pub implementation_version: String,
334 pub catalog_version: String,
336 pub catalog_digest: Option<String>,
339 pub locale_coverage: Vec<LocaleCoverage>,
341 pub target: TargetMeta,
343 pub provenance: Provenance,
345}
346
347#[derive(Debug, Clone)]
349pub struct Catalog {
350 pub schema_version: u32,
351 pub locales: Vec<Locale>,
354 pub target: TargetMeta,
355 pub provenance: Provenance,
356 catalog_version: String,
358 catalog_digest: Option<String>,
361 entries: Vec<CatalogEntry>,
362 localized_strings: Vec<LocalizedStringEntry>,
363 enums: Vec<EnumDomain>,
364 by_id: HashMap<(Kind, String), usize>,
365 alias_to_entry: HashMap<(Kind, Locale, String), usize>,
366 localized_string_by_id: HashMap<String, usize>,
367 localized_string_alias: HashMap<(Locale, String), usize>,
368 enum_by_domain: HashMap<String, usize>,
369 enum_alias_to_domain: HashMap<(Locale, String), String>,
370 enum_alias_to_member: HashMap<(String, Locale, String), (usize, usize)>,
371}
372
373#[derive(Deserialize)]
374#[serde(rename_all = "camelCase")]
375struct CatalogFile {
376 schema_version: u32,
377 locales: Vec<String>,
378 target: TargetMeta,
379 provenance: Provenance,
380 #[serde(default)]
382 version: Option<String>,
383 #[serde(default)]
385 digest: Option<String>,
386 #[serde(default)]
387 structural: Vec<EntryFile>,
388 #[serde(default)]
389 actions: Vec<EntryFile>,
390 #[serde(default)]
391 values: Vec<EntryFile>,
392 #[serde(default)]
393 events: Vec<EntryFile>,
394 #[serde(default)]
395 operators: Vec<EntryFile>,
396 #[serde(default)]
397 settings: Vec<EntryFile>,
398 #[serde(default)]
399 localized_strings: Vec<LocalizedStringFile>,
400 #[serde(default)]
401 enums: Vec<EnumFile>,
402}
403
404#[derive(Deserialize)]
405#[serde(rename_all = "camelCase")]
406struct EntryFile {
407 id: String,
408 aliases: HashMap<String, AliasFile>,
409 #[serde(default)]
410 params: Vec<String>,
411 #[serde(default)]
414 param_domains: Vec<Option<String>>,
415 #[serde(default)]
422 param_defaults: Vec<Option<String>>,
423 #[serde(default)]
424 param_types: Vec<Option<String>>,
425 #[serde(default)]
426 param_coercions: Vec<Option<ParamCoercions>>,
427 #[serde(default)]
428 return_type: Option<String>,
429 #[serde(default)]
430 variadic: bool,
431}
432
433#[derive(Deserialize)]
434struct LocalizedStringFile {
435 id: String,
436 aliases: HashMap<String, AliasFile>,
437}
438
439#[derive(Deserialize)]
440struct EnumFile {
441 domain: String,
442 #[serde(default)]
443 aliases: HashMap<String, AliasFile>,
444 members: Vec<MemberFile>,
445}
446
447#[derive(Deserialize)]
448struct MemberFile {
449 id: String,
450 aliases: HashMap<String, AliasFile>,
451}
452
453#[derive(Debug, Deserialize)]
458#[serde(untagged)]
459enum AliasFile {
460 One(String),
461 Many(Vec<String>),
462}
463
464impl AliasFile {
465 fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
466 let spellings = match self {
467 AliasFile::One(spelling) => vec![spelling],
468 AliasFile::Many(spellings) => spellings,
469 };
470 if spellings.is_empty() || spellings.iter().any(String::is_empty) {
471 return Err(CatalogError::validation(format!(
472 "catalog entry '{}' declares an empty alias for locale '{}'",
473 id, locale
474 )));
475 }
476 Ok(spellings)
477 }
478}
479
480impl Catalog {
481 pub fn load(json: &str) -> Result<Catalog> {
484 let catalog = Self::load_unverified(json)?;
485 if let Some(declared) = &catalog.catalog_digest {
486 let computed = content_digest(json)?;
487 if declared != &computed {
488 return Err(CatalogError::validation(format!(
489 "catalog digest mismatch: declared '{declared}', content '{computed}' — \
490 run the catalog pipeline (workshop-catalog-gen build)"
491 )));
492 }
493 }
494 Ok(catalog)
495 }
496
497 pub fn load_unverified(json: &str) -> Result<Catalog> {
500 let file: CatalogFile = serde_json::from_str(json)
501 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
502 if file.schema_version != 1 {
503 return Err(CatalogError::malformed(format!(
504 "unsupported catalog schemaVersion {}",
505 file.schema_version
506 )));
507 }
508 let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
509 if locales.is_empty() {
510 return Err(CatalogError::malformed(
511 "catalog declares no locales".to_string(),
512 ));
513 }
514
515 let mut catalog = Catalog {
516 schema_version: file.schema_version,
517 locales,
518 target: file.target,
519 provenance: file.provenance,
520 catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
521 catalog_digest: file.digest,
522 entries: Vec::new(),
523 localized_strings: Vec::new(),
524 enums: Vec::new(),
525 by_id: HashMap::new(),
526 alias_to_entry: HashMap::new(),
527 localized_string_by_id: HashMap::new(),
528 localized_string_alias: HashMap::new(),
529 enum_by_domain: HashMap::new(),
530 enum_alias_to_domain: HashMap::new(),
531 enum_alias_to_member: HashMap::new(),
532 };
533
534 for (kind, items) in [
535 (Kind::Structural, file.structural),
536 (Kind::Action, file.actions),
537 (Kind::Value, file.values),
538 (Kind::Event, file.events),
539 (Kind::Operator, file.operators),
540 (Kind::Setting, file.settings),
541 ] {
542 for item in items {
543 catalog.insert_entry(kind, item)?;
544 }
545 }
546 for item in file.localized_strings {
547 catalog.insert_localized_string(item)?;
548 }
549 for domain in file.enums {
550 catalog.insert_enum(domain)?;
551 }
552 catalog.validate_param_domains()?;
553 Ok(catalog)
554 }
555
556 pub fn builtin() -> Result<Catalog> {
558 Self::load(CATALOG_DATA)
559 }
560
561 pub fn locales(&self) -> &[Locale] {
563 &self.locales
564 }
565
566 pub fn primary_locale(&self) -> &Locale {
569 &self.locales[0]
570 }
571
572 pub fn supports(&self, locale: &Locale) -> bool {
574 self.locales.contains(locale)
575 }
576
577 pub fn catalog_version(&self) -> &str {
579 &self.catalog_version
580 }
581
582 pub fn catalog_digest(&self) -> Option<&str> {
585 self.catalog_digest.as_deref()
586 }
587
588 pub fn implementation_version() -> &'static str {
590 env!("CARGO_PKG_VERSION")
591 }
592
593 pub fn identity(&self) -> CatalogIdentity {
596 CatalogIdentity {
597 implementation_version: Self::implementation_version().to_string(),
598 catalog_version: self.catalog_version.clone(),
599 catalog_digest: self.catalog_digest.clone(),
600 locale_coverage: self
601 .locales
602 .iter()
603 .map(|locale| self.locale_coverage(locale))
604 .collect(),
605 target: self.target.clone(),
606 provenance: self.provenance.clone(),
607 }
608 }
609
610 pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
615 let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
616 let total = self.entries.len() + self.localized_strings.len() + member_total;
617 let mapped = self
618 .entries
619 .iter()
620 .filter(|entry| entry.aliases.contains_key(locale))
621 .count()
622 + self
623 .localized_strings
624 .iter()
625 .filter(|entry| entry.aliases.contains_key(locale))
626 .count()
627 + self
628 .enums
629 .iter()
630 .flat_map(|domain| &domain.members)
631 .filter(|member| member.aliases.contains_key(locale))
632 .count();
633 LocaleCoverage {
634 locale: locale.clone(),
635 mapped,
636 total,
637 }
638 }
639
640 pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
642 self.locales
643 .iter()
644 .map(|locale| self.locale_coverage(locale))
645 .collect()
646 }
647
648 pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
650 self.by_id
651 .get(&(kind, id.to_string()))
652 .map(|i| &self.entries[*i])
653 }
654
655 pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
657 self.alias_to_entry
658 .get(&(kind, locale.clone(), spelling.to_string()))
659 .map(|i| &self.entries[*i])
660 }
661
662 pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
664 self.entry(kind, id)?.spelling(locale)
665 }
666
667 pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
669 self.entries.iter().filter(move |entry| entry.kind == kind)
670 }
671
672 pub fn resolve_localized_string(
674 &self,
675 locale: &Locale,
676 spelling: &str,
677 ) -> Option<&LocalizedStringEntry> {
678 self.localized_string_alias
679 .get(&(locale.clone(), spelling.to_string()))
680 .map(|index| &self.localized_strings[*index])
681 }
682
683 pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
685 self.localized_strings
686 .get(*self.localized_string_by_id.get(id)?)
687 .and_then(|entry| entry.spelling(locale))
688 }
689
690 pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
692 self.localized_strings.iter()
693 }
694
695 pub fn entry_count(&self) -> usize {
697 self.entries.len()
698 }
699
700 pub fn enum_domains_count(&self) -> usize {
702 self.enums.len()
703 }
704
705 pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
707 self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
708 }
709
710 pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
712 self.enum_by_domain
713 .get_key_value(spelling)
714 .map(|(domain, _)| domain.as_str())
715 .or_else(|| {
716 self.enum_alias_to_domain
717 .get(&(locale.clone(), spelling.to_string()))
718 .map(String::as_str)
719 })
720 }
721
722 pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
724 self.enums.iter()
725 }
726
727 pub fn resolve_enum_member(
729 &self,
730 domain: &str,
731 locale: &Locale,
732 spelling: &str,
733 ) -> Option<(String, String)> {
734 let (domain_index, member_index) = self.enum_alias_to_member.get(&(
735 domain.to_string(),
736 locale.clone(),
737 spelling.to_string(),
738 ))?;
739 Some((
740 domain.to_string(),
741 self.enums[*domain_index].members[*member_index]
742 .member
743 .clone(),
744 ))
745 }
746
747 pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
749 let domain_index = self.enum_by_domain.get(domain)?;
750 let domain = &self.enums[*domain_index];
751 domain
752 .members
753 .iter()
754 .find(|candidate| candidate.member == member)?
755 .spelling(locale)
756 }
757
758 pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
763 let mut matches = Vec::new();
764 for domain in &self.enums {
765 for member in &domain.members {
766 if member
767 .spellings(locale)
768 .iter()
769 .any(|alias| alias == spelling)
770 {
771 matches.push((domain.domain.clone(), member.member.clone()));
772 }
773 }
774 }
775 matches
776 }
777
778 fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
779 let index = self.entries.len();
780 let mut aliases = HashMap::new();
781 for (locale_str, alias_file) in item.aliases {
782 let locale = Locale::new(&locale_str);
783 if !self.locales.contains(&locale) {
784 return Err(CatalogError::validation(format!(
785 "entry '{}' declares alias for undeclared locale '{}'",
786 item.id, locale
787 )));
788 }
789 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
790 for spelling in &spellings {
791 let key = (kind, locale.clone(), spelling.clone());
792 if self.alias_to_entry.contains_key(&key) {
793 return Err(CatalogError::validation(format!(
794 "duplicate {} alias '{spelling}' for locale '{}'",
795 kind.as_str(),
796 locale
797 )));
798 }
799 self.alias_to_entry.insert(key, index);
800 }
801 aliases.insert(locale, spellings);
802 }
803 let id_key = (kind, item.id.clone());
804 if self.by_id.contains_key(&id_key) {
805 return Err(CatalogError::validation(format!(
806 "duplicate {} id '{}'",
807 kind.as_str(),
808 item.id
809 )));
810 }
811 let primary = self.locales[0].clone();
816 if !aliases.contains_key(&primary) {
817 return Err(CatalogError::validation(format!(
818 "{} '{}' is missing a '{}' alias",
819 kind.as_str(),
820 item.id,
821 primary
822 )));
823 }
824 self.by_id.insert(id_key, index);
825 self.entries.push(CatalogEntry {
826 id: item.id,
827 kind,
828 params: item.params,
829 param_domains: item.param_domains,
830 param_defaults: item.param_defaults,
831 param_types: item.param_types,
832 param_coercions: item.param_coercions,
833 return_type: item.return_type,
834 variadic: item.variadic,
835 aliases,
836 });
837 Ok(())
838 }
839
840 fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
841 if self.localized_string_by_id.contains_key(&item.id) {
842 return Err(CatalogError::validation(format!(
843 "duplicate localized string id '{}'",
844 item.id
845 )));
846 }
847 let index = self.localized_strings.len();
848 let mut aliases = HashMap::new();
849 for (locale_str, alias_file) in item.aliases {
850 let locale = Locale::new(&locale_str);
851 if !self.locales.contains(&locale) {
852 return Err(CatalogError::validation(format!(
853 "localized string '{}' declares alias for undeclared locale '{}'",
854 item.id, locale
855 )));
856 }
857 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
858 for spelling in &spellings {
859 let key = (locale.clone(), spelling.clone());
860 if self.localized_string_alias.contains_key(&key) {
861 return Err(CatalogError::validation(format!(
862 "duplicate localized string alias '{spelling}' for locale '{locale}'"
863 )));
864 }
865 self.localized_string_alias.insert(key, index);
866 }
867 aliases.insert(locale, spellings);
868 }
869 let primary = self.locales[0].clone();
870 if !aliases.contains_key(&primary) {
871 return Err(CatalogError::validation(format!(
872 "localized string '{}' is missing a '{}' alias",
873 item.id, primary
874 )));
875 }
876 self.localized_string_by_id.insert(item.id.clone(), index);
877 self.localized_strings.push(LocalizedStringEntry {
878 id: item.id,
879 aliases,
880 });
881 Ok(())
882 }
883
884 fn validate_param_domains(&self) -> Result<()> {
886 for entry in &self.entries {
887 if entry.param_domains.len() > entry.params.len() {
888 return Err(CatalogError::validation(format!(
889 "{} '{}' declares more param domains than params",
890 entry.kind.as_str(),
891 entry.id
892 )));
893 }
894 if entry.param_defaults.len() > entry.params.len() {
895 return Err(CatalogError::validation(format!(
896 "{} '{}' declares more param defaults than params",
897 entry.kind.as_str(),
898 entry.id
899 )));
900 }
901 if entry.param_types.len() > entry.params.len() {
902 return Err(CatalogError::validation(format!(
903 "{} '{}' declares more param types than params",
904 entry.kind.as_str(),
905 entry.id
906 )));
907 }
908 if entry.param_coercions.len() > entry.params.len() {
909 return Err(CatalogError::validation(format!(
910 "{} '{}' declares more param coercions than params",
911 entry.kind.as_str(),
912 entry.id
913 )));
914 }
915 if entry.kind != Kind::Value && entry.return_type.is_some() {
916 return Err(CatalogError::validation(format!(
917 "{} '{}' declares a return type but is not a value",
918 entry.kind.as_str(),
919 entry.id
920 )));
921 }
922 for domain in entry.param_domains.iter().flatten() {
923 if !self.enum_by_domain.contains_key(domain) {
924 return Err(CatalogError::validation(format!(
925 "{} '{}' declares undeclared enum domain '{domain}'",
926 entry.kind.as_str(),
927 entry.id
928 )));
929 }
930 }
931 }
932 Ok(())
933 }
934
935 fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
936 let domain_index = self.enums.len();
937 if self.enum_by_domain.contains_key(&domain.domain) {
938 return Err(CatalogError::validation(format!(
939 "duplicate enum domain '{}'",
940 domain.domain
941 )));
942 }
943 let primary = self.locales[0].clone();
944 let mut domain_aliases = HashMap::new();
945 for (locale_str, alias_file) in domain.aliases {
946 let locale = Locale::new(&locale_str);
947 if !self.locales.contains(&locale) {
948 return Err(CatalogError::validation(format!(
949 "enum domain '{}' declares alias for undeclared locale '{}'",
950 domain.domain, locale
951 )));
952 }
953 let spellings = alias_file.into_spellings(&domain.domain, locale.as_str())?;
954 for spelling in &spellings {
955 let key = (locale.clone(), spelling.clone());
956 if let Some(existing) = self.enum_alias_to_domain.get(&key) {
957 return Err(CatalogError::validation(format!(
958 "duplicate enum domain alias '{spelling}' for '{}' and '{}' in locale '{}'",
959 existing, domain.domain, locale
960 )));
961 }
962 self.enum_alias_to_domain.insert(key, domain.domain.clone());
963 }
964 domain_aliases.insert(locale, spellings);
965 }
966 domain_aliases
967 .entry(primary.clone())
968 .or_insert_with(|| vec![domain.domain.clone()]);
969 self.enum_alias_to_domain
970 .entry((primary.clone(), domain.domain.clone()))
971 .or_insert_with(|| domain.domain.clone());
972 let mut members = Vec::new();
973 for (member_index, member) in domain.members.into_iter().enumerate() {
974 let mut aliases = HashMap::new();
975 for (locale_str, alias_file) in member.aliases {
976 let locale = Locale::new(&locale_str);
977 if !self.locales.contains(&locale) {
978 return Err(CatalogError::validation(format!(
979 "enum {}::{} declares alias for undeclared locale '{}'",
980 domain.domain, member.id, locale
981 )));
982 }
983 let spellings = alias_file.into_spellings(&member.id, locale.as_str())?;
984 for spelling in &spellings {
985 let key = (domain.domain.clone(), locale.clone(), spelling.clone());
986 if self.enum_alias_to_member.contains_key(&key) {
987 return Err(CatalogError::validation(format!(
988 "duplicate enum alias '{spelling}' in '{}' for locale '{}'",
989 domain.domain, locale
990 )));
991 }
992 self.enum_alias_to_member
993 .insert(key, (domain_index, member_index));
994 }
995 aliases.insert(locale, spellings);
996 }
997 if !aliases.contains_key(&primary) {
998 return Err(CatalogError::validation(format!(
999 "enum {}::{} is missing a '{}' alias",
1000 domain.domain, member.id, primary
1001 )));
1002 }
1003 members.push(EnumMember {
1004 member: member.id,
1005 aliases,
1006 });
1007 }
1008 self.enum_by_domain
1009 .insert(domain.domain.clone(), domain_index);
1010 self.enums.push(EnumDomain {
1011 domain: domain.domain,
1012 aliases: domain_aliases,
1013 members,
1014 });
1015 Ok(())
1016 }
1017}
1018
1019impl ExpectedDomain for Catalog {
1026 fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
1027 for kind in [Kind::Action, Kind::Value] {
1028 if let Some(entry) = self.entry(kind, catalog_id) {
1029 if let Some(domain) = entry
1030 .param_domains
1031 .get(arg_index)
1032 .and_then(Option::as_deref)
1033 {
1034 return Some(domain);
1035 }
1036 }
1037 }
1038 None
1039 }
1040}
1041
1042pub fn canonicalize(json: &str) -> Result<String> {
1048 Catalog::load_unverified(json)?;
1050 let value: serde_json::Value = serde_json::from_str(json)
1051 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1052 serde_json::to_string_pretty(&value)
1053 .map(|mut out| {
1054 out.push('\n');
1055 out
1056 })
1057 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
1058}
1059
1060pub fn build_canonical(json: &str) -> Result<String> {
1064 let mut value: serde_json::Value = serde_json::from_str(json)
1065 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1066 let digest = content_digest(json)?;
1067 if let Some(object) = value.as_object_mut() {
1068 object.insert("digest".to_string(), serde_json::Value::String(digest));
1069 }
1070 let output = serde_json::to_string_pretty(&value)
1071 .map(|mut out| {
1072 out.push('\n');
1073 out
1074 })
1075 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1076 Catalog::load(&output)?;
1079 Ok(output)
1080}
1081
1082pub fn content_digest(json: &str) -> Result<String> {
1087 let mut value: serde_json::Value = serde_json::from_str(json)
1088 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1089 if let Some(object) = value.as_object_mut() {
1090 object.remove("digest");
1091 }
1092 let canonical = serde_json::to_string_pretty(&value)
1093 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1094 use sha2::{Digest, Sha256};
1095 let mut hasher = Sha256::new();
1096 hasher.update(canonical.as_bytes());
1097 Ok(format!("{:x}", hasher.finalize()))
1098}