1pub mod detect;
23
24use std::collections::HashMap;
25
26use serde::{Deserialize, Deserializer, Serialize};
27
28use crate::core::signatures::ExpectedDomain;
29
30use crate::core::error::{CatalogError, Result};
31
32pub const CATALOG_DATA: &str = include_str!("data/catalog.json");
34
35#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
37pub struct Locale(String);
38
39impl Locale {
40 pub fn new(value: &str) -> Locale {
42 Locale(value.trim().to_ascii_lowercase())
43 }
44
45 pub fn as_str(&self) -> &str {
47 &self.0
48 }
49}
50
51impl<'de> Deserialize<'de> for Locale {
52 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
53 where
54 D: Deserializer<'de>,
55 {
56 let value = String::deserialize(deserializer)?;
57 Ok(Self::new(&value))
58 }
59}
60
61impl std::fmt::Display for Locale {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.write_str(&self.0)
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub enum Kind {
70 Structural,
72 Action,
74 Value,
76 Event,
78 Operator,
80 Enum,
82 Setting,
84}
85
86impl Kind {
87 pub fn as_str(self) -> &'static str {
88 match self {
89 Kind::Structural => "structural",
90 Kind::Action => "action",
91 Kind::Value => "value",
92 Kind::Event => "event",
93 Kind::Operator => "operator",
94 Kind::Enum => "enum",
95 Kind::Setting => "setting",
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct ParamCoercions {
109 #[serde(default)]
111 pub false_as_number: bool,
112 #[serde(default)]
114 pub true_as_number: bool,
115 #[serde(default)]
117 pub zero_as_null: bool,
118 #[serde(default)]
120 pub null_vector_as_null: bool,
121 #[serde(default)]
123 pub empty_array_as_string: bool,
124}
125
126#[derive(Debug, Clone)]
128pub struct CatalogEntry {
129 pub id: String,
130 pub kind: Kind,
131 pub params: Vec<String>,
133 pub param_domains: Vec<Option<String>>,
141 pub param_defaults: Vec<Option<String>>,
145 pub param_types: Vec<Option<String>>,
148 pub param_coercions: Vec<Option<ParamCoercions>>,
150 pub return_type: Option<String>,
153 pub variadic: bool,
155 aliases: HashMap<Locale, Vec<String>>,
156}
157
158#[derive(Debug, Clone)]
162pub struct LocalizedStringEntry {
163 pub id: String,
164 aliases: HashMap<Locale, Vec<String>>,
165}
166
167impl LocalizedStringEntry {
168 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
170 self.aliases
171 .get(locale)
172 .and_then(|spellings| spellings.first())
173 .map(String::as_str)
174 }
175
176 pub fn spellings(&self, locale: &Locale) -> &[String] {
178 self.aliases
179 .get(locale)
180 .map(Vec::as_slice)
181 .unwrap_or_default()
182 }
183}
184
185impl CatalogEntry {
186 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
188 self.aliases
189 .get(locale)
190 .and_then(|spellings| spellings.first())
191 .map(String::as_str)
192 }
193
194 pub fn spellings(&self, locale: &Locale) -> &[String] {
197 self.aliases.get(locale).map(Vec::as_slice).unwrap_or(&[])
198 }
199
200 pub fn param_count(&self) -> usize {
202 self.params.len()
203 }
204
205 pub fn required_param_count(&self) -> usize {
209 (0..self.params.len())
210 .rev()
211 .find(|index| {
212 self.param_defaults
213 .get(*index)
214 .and_then(Option::as_ref)
215 .is_none()
216 })
217 .map_or(0, |index| index + 1)
218 }
219
220 pub fn param_domain(&self, index: usize) -> Option<&str> {
222 self.param_domains
223 .get(index)
224 .or_else(|| self.variadic.then(|| self.param_domains.last()).flatten())
225 .and_then(Option::as_deref)
226 }
227
228 pub fn param_type(&self, index: usize) -> Option<&str> {
231 self.param_types
232 .get(index)
233 .or_else(|| self.variadic.then(|| self.param_types.last()).flatten())
234 .and_then(Option::as_deref)
235 }
236
237 pub fn param_coercions(&self, index: usize) -> Option<&ParamCoercions> {
239 self.param_coercions
240 .get(index)
241 .or_else(|| self.variadic.then(|| self.param_coercions.last()).flatten())
242 .and_then(Option::as_ref)
243 }
244
245 pub fn return_type(&self) -> Option<&str> {
247 self.return_type.as_deref()
248 }
249}
250
251#[derive(Debug, Clone)]
253pub struct EnumMember {
254 pub member: String,
255 aliases: HashMap<Locale, Vec<String>>,
256}
257
258impl EnumMember {
259 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
261 self.aliases
262 .get(locale)
263 .and_then(|spellings| spellings.first())
264 .map(String::as_str)
265 }
266
267 pub fn spellings(&self, locale: &Locale) -> &[String] {
270 self.aliases.get(locale).map(Vec::as_slice).unwrap_or(&[])
271 }
272}
273
274#[derive(Debug, Clone)]
276pub struct EnumDomain {
277 pub domain: String,
278 aliases: HashMap<Locale, Vec<String>>,
279 pub members: Vec<EnumMember>,
280}
281
282impl EnumDomain {
283 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
284 self.aliases
285 .get(locale)
286 .and_then(|spellings| spellings.first())
287 .map(String::as_str)
288 }
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
293pub struct TargetMeta {
294 pub game: String,
295 pub format: String,
296 pub surface: String,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
301#[serde(rename_all = "camelCase")]
302pub struct Provenance {
303 pub generator: String,
304 pub generator_version: String,
305 pub source: String,
306 pub license: String,
307 pub reviewed: bool,
308 #[serde(default, skip_serializing_if = "Vec::is_empty")]
311 pub source_notes: Vec<String>,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
318pub struct LocaleCoverage {
319 pub locale: Locale,
320 pub mapped: usize,
322 pub total: usize,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
332#[serde(rename_all = "kebab-case")]
333pub struct CatalogIdentity {
334 pub implementation_version: String,
336 pub catalog_version: String,
338 pub catalog_digest: Option<String>,
341 pub locale_coverage: Vec<LocaleCoverage>,
343 pub target: TargetMeta,
345 pub provenance: Provenance,
347}
348
349#[derive(Debug, Clone)]
351pub struct Catalog {
352 pub schema_version: u32,
353 pub locales: Vec<Locale>,
356 pub target: TargetMeta,
357 pub provenance: Provenance,
358 catalog_version: String,
360 catalog_digest: Option<String>,
363 entries: Vec<CatalogEntry>,
364 localized_strings: Vec<LocalizedStringEntry>,
365 enums: Vec<EnumDomain>,
366 by_id: HashMap<(Kind, String), usize>,
367 alias_to_entry: HashMap<(Kind, Locale, String), usize>,
368 localized_string_by_id: HashMap<String, usize>,
369 localized_string_alias: HashMap<(Locale, String), usize>,
370 enum_by_domain: HashMap<String, usize>,
371 enum_alias_to_domain: HashMap<(Locale, String), String>,
372 enum_alias_to_member: HashMap<(String, Locale, String), (usize, usize)>,
373}
374
375#[derive(Deserialize)]
376#[serde(rename_all = "camelCase")]
377struct CatalogFile {
378 schema_version: u32,
379 locales: Vec<String>,
380 target: TargetMeta,
381 provenance: Provenance,
382 #[serde(default)]
384 version: Option<String>,
385 #[serde(default)]
387 digest: Option<String>,
388 #[serde(default)]
389 structural: Vec<EntryFile>,
390 #[serde(default)]
391 actions: Vec<EntryFile>,
392 #[serde(default)]
393 values: Vec<EntryFile>,
394 #[serde(default)]
395 events: Vec<EntryFile>,
396 #[serde(default)]
397 operators: Vec<EntryFile>,
398 #[serde(default)]
399 settings: Vec<EntryFile>,
400 #[serde(default)]
401 localized_strings: Vec<LocalizedStringFile>,
402 #[serde(default)]
403 enums: Vec<EnumFile>,
404}
405
406#[derive(Deserialize)]
407#[serde(rename_all = "camelCase")]
408struct EntryFile {
409 id: String,
410 aliases: HashMap<String, AliasFile>,
411 #[serde(default)]
412 params: Vec<String>,
413 #[serde(default)]
416 param_domains: Vec<Option<String>>,
417 #[serde(default)]
424 param_defaults: Vec<Option<String>>,
425 #[serde(default)]
426 param_types: Vec<Option<String>>,
427 #[serde(default)]
428 param_coercions: Vec<Option<ParamCoercions>>,
429 #[serde(default)]
430 return_type: Option<String>,
431 #[serde(default)]
432 variadic: bool,
433}
434
435#[derive(Deserialize)]
436struct LocalizedStringFile {
437 id: String,
438 aliases: HashMap<String, AliasFile>,
439}
440
441#[derive(Deserialize)]
442struct EnumFile {
443 domain: String,
444 #[serde(default)]
445 aliases: HashMap<String, AliasFile>,
446 members: Vec<MemberFile>,
447}
448
449#[derive(Deserialize)]
450struct MemberFile {
451 id: String,
452 aliases: HashMap<String, AliasFile>,
453}
454
455#[derive(Debug, Deserialize)]
460#[serde(untagged)]
461enum AliasFile {
462 One(String),
463 Many(Vec<String>),
464}
465
466impl AliasFile {
467 fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
468 let spellings = match self {
469 AliasFile::One(spelling) => vec![spelling],
470 AliasFile::Many(spellings) => spellings,
471 };
472 if spellings.is_empty() || spellings.iter().any(String::is_empty) {
473 return Err(CatalogError::validation(format!(
474 "catalog entry '{}' declares an empty alias for locale '{}'",
475 id, locale
476 )));
477 }
478 Ok(spellings)
479 }
480}
481
482impl Catalog {
483 pub fn load(json: &str) -> Result<Catalog> {
486 let catalog = Self::load_unverified(json)?;
487 if let Some(declared) = &catalog.catalog_digest {
488 let computed = content_digest(json)?;
489 if declared != &computed {
490 return Err(CatalogError::validation(format!(
491 "catalog digest mismatch: declared '{declared}', content '{computed}' — \
492 run the catalog pipeline (workshop-catalog-gen build)"
493 )));
494 }
495 }
496 Ok(catalog)
497 }
498
499 pub fn load_unverified(json: &str) -> Result<Catalog> {
502 let file: CatalogFile = serde_json::from_str(json)
503 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
504 if file.schema_version != 1 {
505 return Err(CatalogError::malformed(format!(
506 "unsupported catalog schemaVersion {}",
507 file.schema_version
508 )));
509 }
510 let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
511 if locales.is_empty() {
512 return Err(CatalogError::malformed(
513 "catalog declares no locales".to_string(),
514 ));
515 }
516
517 let mut catalog = Catalog {
518 schema_version: file.schema_version,
519 locales,
520 target: file.target,
521 provenance: file.provenance,
522 catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
523 catalog_digest: file.digest,
524 entries: Vec::new(),
525 localized_strings: Vec::new(),
526 enums: Vec::new(),
527 by_id: HashMap::new(),
528 alias_to_entry: HashMap::new(),
529 localized_string_by_id: HashMap::new(),
530 localized_string_alias: HashMap::new(),
531 enum_by_domain: HashMap::new(),
532 enum_alias_to_domain: HashMap::new(),
533 enum_alias_to_member: HashMap::new(),
534 };
535
536 for (kind, items) in [
537 (Kind::Structural, file.structural),
538 (Kind::Action, file.actions),
539 (Kind::Value, file.values),
540 (Kind::Event, file.events),
541 (Kind::Operator, file.operators),
542 (Kind::Setting, file.settings),
543 ] {
544 for item in items {
545 catalog.insert_entry(kind, item)?;
546 }
547 }
548 for item in file.localized_strings {
549 catalog.insert_localized_string(item)?;
550 }
551 for domain in file.enums {
552 catalog.insert_enum(domain)?;
553 }
554 catalog.validate_param_domains()?;
555 Ok(catalog)
556 }
557
558 pub fn builtin() -> Result<Catalog> {
560 Self::load(CATALOG_DATA)
561 }
562
563 pub fn locales(&self) -> &[Locale] {
565 &self.locales
566 }
567
568 pub fn primary_locale(&self) -> &Locale {
571 &self.locales[0]
572 }
573
574 pub fn supports(&self, locale: &Locale) -> bool {
576 self.locales.contains(locale)
577 }
578
579 pub fn catalog_version(&self) -> &str {
581 &self.catalog_version
582 }
583
584 pub fn catalog_digest(&self) -> Option<&str> {
587 self.catalog_digest.as_deref()
588 }
589
590 pub fn implementation_version() -> &'static str {
592 env!("CARGO_PKG_VERSION")
593 }
594
595 pub fn identity(&self) -> CatalogIdentity {
598 CatalogIdentity {
599 implementation_version: Self::implementation_version().to_string(),
600 catalog_version: self.catalog_version.clone(),
601 catalog_digest: self.catalog_digest.clone(),
602 locale_coverage: self
603 .locales
604 .iter()
605 .map(|locale| self.locale_coverage(locale))
606 .collect(),
607 target: self.target.clone(),
608 provenance: self.provenance.clone(),
609 }
610 }
611
612 pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
617 let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
618 let total = self.entries.len() + self.localized_strings.len() + member_total;
619 let mapped = self
620 .entries
621 .iter()
622 .filter(|entry| entry.aliases.contains_key(locale))
623 .count()
624 + self
625 .localized_strings
626 .iter()
627 .filter(|entry| entry.aliases.contains_key(locale))
628 .count()
629 + self
630 .enums
631 .iter()
632 .flat_map(|domain| &domain.members)
633 .filter(|member| member.aliases.contains_key(locale))
634 .count();
635 LocaleCoverage {
636 locale: locale.clone(),
637 mapped,
638 total,
639 }
640 }
641
642 pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
644 self.locales
645 .iter()
646 .map(|locale| self.locale_coverage(locale))
647 .collect()
648 }
649
650 pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
652 self.by_id
653 .get(&(kind, id.to_string()))
654 .map(|i| &self.entries[*i])
655 }
656
657 pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
659 self.alias_to_entry
660 .get(&(kind, locale.clone(), spelling.to_string()))
661 .map(|i| &self.entries[*i])
662 }
663
664 pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
666 self.entry(kind, id)?.spelling(locale)
667 }
668
669 pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
671 self.entries.iter().filter(move |entry| entry.kind == kind)
672 }
673
674 pub fn resolve_localized_string(
676 &self,
677 locale: &Locale,
678 spelling: &str,
679 ) -> Option<&LocalizedStringEntry> {
680 self.localized_string_alias
681 .get(&(locale.clone(), spelling.to_string()))
682 .map(|index| &self.localized_strings[*index])
683 }
684
685 pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
687 self.localized_strings
688 .get(*self.localized_string_by_id.get(id)?)
689 .and_then(|entry| entry.spelling(locale))
690 }
691
692 pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
694 self.localized_strings.iter()
695 }
696
697 pub fn entry_count(&self) -> usize {
699 self.entries.len()
700 }
701
702 pub fn enum_domains_count(&self) -> usize {
704 self.enums.len()
705 }
706
707 pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
709 self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
710 }
711
712 pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
714 self.enum_by_domain
715 .get_key_value(spelling)
716 .map(|(domain, _)| domain.as_str())
717 .or_else(|| {
718 self.enum_alias_to_domain
719 .get(&(locale.clone(), spelling.to_string()))
720 .map(String::as_str)
721 })
722 }
723
724 pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
726 self.enums.iter()
727 }
728
729 pub fn resolve_enum_member(
731 &self,
732 domain: &str,
733 locale: &Locale,
734 spelling: &str,
735 ) -> Option<(String, String)> {
736 let (domain_index, member_index) = self.enum_alias_to_member.get(&(
737 domain.to_string(),
738 locale.clone(),
739 spelling.to_string(),
740 ))?;
741 Some((
742 domain.to_string(),
743 self.enums[*domain_index].members[*member_index]
744 .member
745 .clone(),
746 ))
747 }
748
749 pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
751 let domain_index = self.enum_by_domain.get(domain)?;
752 let domain = &self.enums[*domain_index];
753 domain
754 .members
755 .iter()
756 .find(|candidate| candidate.member == member)?
757 .spelling(locale)
758 }
759
760 pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
765 let mut matches = Vec::new();
766 for domain in &self.enums {
767 for member in &domain.members {
768 if member
769 .spellings(locale)
770 .iter()
771 .any(|alias| alias == spelling)
772 {
773 matches.push((domain.domain.clone(), member.member.clone()));
774 }
775 }
776 }
777 matches
778 }
779
780 fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
781 let index = self.entries.len();
782 let mut aliases = HashMap::new();
783 for (locale_str, alias_file) in item.aliases {
784 let locale = Locale::new(&locale_str);
785 if !self.locales.contains(&locale) {
786 return Err(CatalogError::validation(format!(
787 "entry '{}' declares alias for undeclared locale '{}'",
788 item.id, locale
789 )));
790 }
791 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
792 for spelling in &spellings {
793 let key = (kind, locale.clone(), spelling.clone());
794 if self.alias_to_entry.contains_key(&key) {
795 return Err(CatalogError::validation(format!(
796 "duplicate {} alias '{spelling}' for locale '{}'",
797 kind.as_str(),
798 locale
799 )));
800 }
801 self.alias_to_entry.insert(key, index);
802 }
803 aliases.insert(locale, spellings);
804 }
805 let id_key = (kind, item.id.clone());
806 if self.by_id.contains_key(&id_key) {
807 return Err(CatalogError::validation(format!(
808 "duplicate {} id '{}'",
809 kind.as_str(),
810 item.id
811 )));
812 }
813 let primary = self.locales[0].clone();
818 if !aliases.contains_key(&primary) {
819 return Err(CatalogError::validation(format!(
820 "{} '{}' is missing a '{}' alias",
821 kind.as_str(),
822 item.id,
823 primary
824 )));
825 }
826 self.by_id.insert(id_key, index);
827 self.entries.push(CatalogEntry {
828 id: item.id,
829 kind,
830 params: item.params,
831 param_domains: item.param_domains,
832 param_defaults: item.param_defaults,
833 param_types: item.param_types,
834 param_coercions: item.param_coercions,
835 return_type: item.return_type,
836 variadic: item.variadic,
837 aliases,
838 });
839 Ok(())
840 }
841
842 fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
843 if self.localized_string_by_id.contains_key(&item.id) {
844 return Err(CatalogError::validation(format!(
845 "duplicate localized string id '{}'",
846 item.id
847 )));
848 }
849 let index = self.localized_strings.len();
850 let mut aliases = HashMap::new();
851 for (locale_str, alias_file) in item.aliases {
852 let locale = Locale::new(&locale_str);
853 if !self.locales.contains(&locale) {
854 return Err(CatalogError::validation(format!(
855 "localized string '{}' declares alias for undeclared locale '{}'",
856 item.id, locale
857 )));
858 }
859 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
860 for spelling in &spellings {
861 let key = (locale.clone(), spelling.clone());
862 if self.localized_string_alias.contains_key(&key) {
863 return Err(CatalogError::validation(format!(
864 "duplicate localized string alias '{spelling}' for locale '{locale}'"
865 )));
866 }
867 self.localized_string_alias.insert(key, index);
868 }
869 aliases.insert(locale, spellings);
870 }
871 let primary = self.locales[0].clone();
872 if !aliases.contains_key(&primary) {
873 return Err(CatalogError::validation(format!(
874 "localized string '{}' is missing a '{}' alias",
875 item.id, primary
876 )));
877 }
878 self.localized_string_by_id.insert(item.id.clone(), index);
879 self.localized_strings.push(LocalizedStringEntry {
880 id: item.id,
881 aliases,
882 });
883 Ok(())
884 }
885
886 fn validate_param_domains(&self) -> Result<()> {
888 for entry in &self.entries {
889 if entry.param_domains.len() > entry.params.len() {
890 return Err(CatalogError::validation(format!(
891 "{} '{}' declares more param domains than params",
892 entry.kind.as_str(),
893 entry.id
894 )));
895 }
896 if entry.param_defaults.len() > entry.params.len() {
897 return Err(CatalogError::validation(format!(
898 "{} '{}' declares more param defaults than params",
899 entry.kind.as_str(),
900 entry.id
901 )));
902 }
903 if entry.param_types.len() > entry.params.len() {
904 return Err(CatalogError::validation(format!(
905 "{} '{}' declares more param types than params",
906 entry.kind.as_str(),
907 entry.id
908 )));
909 }
910 if entry.param_coercions.len() > entry.params.len() {
911 return Err(CatalogError::validation(format!(
912 "{} '{}' declares more param coercions than params",
913 entry.kind.as_str(),
914 entry.id
915 )));
916 }
917 if entry.kind != Kind::Value && entry.return_type.is_some() {
918 return Err(CatalogError::validation(format!(
919 "{} '{}' declares a return type but is not a value",
920 entry.kind.as_str(),
921 entry.id
922 )));
923 }
924 for domain in entry.param_domains.iter().flatten() {
925 if !self.enum_by_domain.contains_key(domain) {
926 return Err(CatalogError::validation(format!(
927 "{} '{}' declares undeclared enum domain '{domain}'",
928 entry.kind.as_str(),
929 entry.id
930 )));
931 }
932 }
933 }
934 Ok(())
935 }
936
937 fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
938 let domain_index = self.enums.len();
939 if self.enum_by_domain.contains_key(&domain.domain) {
940 return Err(CatalogError::validation(format!(
941 "duplicate enum domain '{}'",
942 domain.domain
943 )));
944 }
945 let primary = self.locales[0].clone();
946 let mut domain_aliases = HashMap::new();
947 for (locale_str, alias_file) in domain.aliases {
948 let locale = Locale::new(&locale_str);
949 if !self.locales.contains(&locale) {
950 return Err(CatalogError::validation(format!(
951 "enum domain '{}' declares alias for undeclared locale '{}'",
952 domain.domain, locale
953 )));
954 }
955 let spellings = alias_file.into_spellings(&domain.domain, locale.as_str())?;
956 for spelling in &spellings {
957 let key = (locale.clone(), spelling.clone());
958 if let Some(existing) = self.enum_alias_to_domain.get(&key) {
959 return Err(CatalogError::validation(format!(
960 "duplicate enum domain alias '{spelling}' for '{}' and '{}' in locale '{}'",
961 existing, domain.domain, locale
962 )));
963 }
964 self.enum_alias_to_domain.insert(key, domain.domain.clone());
965 }
966 domain_aliases.insert(locale, spellings);
967 }
968 domain_aliases
969 .entry(primary.clone())
970 .or_insert_with(|| vec![domain.domain.clone()]);
971 self.enum_alias_to_domain
972 .entry((primary.clone(), domain.domain.clone()))
973 .or_insert_with(|| domain.domain.clone());
974 let mut members = Vec::new();
975 for (member_index, member) in domain.members.into_iter().enumerate() {
976 let mut aliases = HashMap::new();
977 for (locale_str, alias_file) in member.aliases {
978 let locale = Locale::new(&locale_str);
979 if !self.locales.contains(&locale) {
980 return Err(CatalogError::validation(format!(
981 "enum {}::{} declares alias for undeclared locale '{}'",
982 domain.domain, member.id, locale
983 )));
984 }
985 let spellings = alias_file.into_spellings(&member.id, locale.as_str())?;
986 for spelling in &spellings {
987 let key = (domain.domain.clone(), locale.clone(), spelling.clone());
988 if self.enum_alias_to_member.contains_key(&key) {
989 return Err(CatalogError::validation(format!(
990 "duplicate enum alias '{spelling}' in '{}' for locale '{}'",
991 domain.domain, locale
992 )));
993 }
994 self.enum_alias_to_member
995 .insert(key, (domain_index, member_index));
996 }
997 aliases.insert(locale, spellings);
998 }
999 if !aliases.contains_key(&primary) {
1000 return Err(CatalogError::validation(format!(
1001 "enum {}::{} is missing a '{}' alias",
1002 domain.domain, member.id, primary
1003 )));
1004 }
1005 members.push(EnumMember {
1006 member: member.id,
1007 aliases,
1008 });
1009 }
1010 self.enum_by_domain
1011 .insert(domain.domain.clone(), domain_index);
1012 self.enums.push(EnumDomain {
1013 domain: domain.domain,
1014 aliases: domain_aliases,
1015 members,
1016 });
1017 Ok(())
1018 }
1019}
1020
1021impl ExpectedDomain for Catalog {
1028 fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
1029 for kind in [Kind::Action, Kind::Value] {
1030 if let Some(entry) = self.entry(kind, catalog_id) {
1031 if let Some(domain) = entry
1032 .param_domains
1033 .get(arg_index)
1034 .and_then(Option::as_deref)
1035 {
1036 return Some(domain);
1037 }
1038 }
1039 }
1040 None
1041 }
1042}
1043
1044pub fn canonicalize(json: &str) -> Result<String> {
1050 Catalog::load_unverified(json)?;
1052 let value: serde_json::Value = serde_json::from_str(json)
1053 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1054 serde_json::to_string_pretty(&value)
1055 .map(|mut out| {
1056 out.push('\n');
1057 out
1058 })
1059 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
1060}
1061
1062pub fn build_canonical(json: &str) -> Result<String> {
1066 let mut value: serde_json::Value = serde_json::from_str(json)
1067 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1068 let digest = content_digest(json)?;
1069 if let Some(object) = value.as_object_mut() {
1070 object.insert("digest".to_string(), serde_json::Value::String(digest));
1071 }
1072 let output = serde_json::to_string_pretty(&value)
1073 .map(|mut out| {
1074 out.push('\n');
1075 out
1076 })
1077 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1078 Catalog::load(&output)?;
1081 Ok(output)
1082}
1083
1084pub fn content_digest(json: &str) -> Result<String> {
1089 let mut value: serde_json::Value = serde_json::from_str(json)
1090 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1091 if let Some(object) = value.as_object_mut() {
1092 object.remove("digest");
1093 }
1094 let canonical = serde_json::to_string_pretty(&value)
1095 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1096 use sha2::{Digest, Sha256};
1097 let mut hasher = Sha256::new();
1098 hasher.update(canonical.as_bytes());
1099 Ok(format!("{:x}", hasher.finalize()))
1100}