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_names: Vec<String>,
136 pub param_aliases: Vec<HashMap<Locale, Vec<String>>>,
138 pub param_domains: Vec<Option<String>>,
146 pub param_defaults: Vec<Option<String>>,
150 pub param_types: Vec<Option<String>>,
153 pub param_coercions: Vec<Option<ParamCoercions>>,
155 pub return_type: Option<String>,
158 pub variadic: bool,
160 aliases: HashMap<Locale, Vec<String>>,
161}
162
163#[derive(Debug, Clone)]
167pub struct LocalizedStringEntry {
168 pub id: String,
169 aliases: HashMap<Locale, Vec<String>>,
170}
171
172impl LocalizedStringEntry {
173 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
175 self.aliases
176 .get(locale)
177 .and_then(|spellings| spellings.first())
178 .map(String::as_str)
179 }
180
181 pub fn spellings(&self, locale: &Locale) -> &[String] {
183 self.aliases
184 .get(locale)
185 .map(Vec::as_slice)
186 .unwrap_or_default()
187 }
188}
189
190impl CatalogEntry {
191 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
193 self.aliases
194 .get(locale)
195 .and_then(|spellings| spellings.first())
196 .map(String::as_str)
197 }
198
199 pub fn spellings(&self, locale: &Locale) -> &[String] {
202 self.aliases.get(locale).map(Vec::as_slice).unwrap_or(&[])
203 }
204
205 pub fn resolve_param(&self, locale: &Locale, spelling: &str) -> Option<usize> {
208 let matches = self
209 .params
210 .iter()
211 .enumerate()
212 .filter(|(index, canonical)| {
213 canonical == &spelling
214 || self
215 .param_aliases
216 .get(*index)
217 .and_then(|aliases| aliases.get(locale))
218 .is_some_and(|aliases| aliases.iter().any(|alias| alias == spelling))
219 })
220 .map(|(index, _)| index)
221 .collect::<Vec<_>>();
222 (matches.len() == 1).then(|| matches[0])
223 }
224
225 pub fn param_count(&self) -> usize {
227 self.params.len()
228 }
229
230 pub fn param_name(&self, index: usize) -> Option<&str> {
232 self.param_names
233 .get(index)
234 .or_else(|| self.variadic.then(|| self.param_names.last()).flatten())
235 .map(String::as_str)
236 }
237
238 pub fn required_param_count(&self) -> usize {
242 (0..self.params.len())
243 .rev()
244 .find(|index| {
245 self.param_defaults
246 .get(*index)
247 .and_then(Option::as_ref)
248 .is_none()
249 })
250 .map_or(0, |index| index + 1)
251 }
252
253 pub fn param_domain(&self, index: usize) -> Option<&str> {
255 self.param_domains
256 .get(index)
257 .or_else(|| self.variadic.then(|| self.param_domains.last()).flatten())
258 .and_then(Option::as_deref)
259 }
260
261 pub fn param_type(&self, index: usize) -> Option<&str> {
264 self.param_types
265 .get(index)
266 .or_else(|| self.variadic.then(|| self.param_types.last()).flatten())
267 .and_then(Option::as_deref)
268 }
269
270 pub fn param_coercions(&self, index: usize) -> Option<&ParamCoercions> {
272 self.param_coercions
273 .get(index)
274 .or_else(|| self.variadic.then(|| self.param_coercions.last()).flatten())
275 .and_then(Option::as_ref)
276 }
277
278 pub fn return_type(&self) -> Option<&str> {
280 self.return_type.as_deref()
281 }
282}
283
284#[derive(Debug, Clone)]
286pub struct EnumMember {
287 pub member: String,
288 aliases: HashMap<Locale, Vec<String>>,
289}
290
291impl EnumMember {
292 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
294 self.aliases
295 .get(locale)
296 .and_then(|spellings| spellings.first())
297 .map(String::as_str)
298 }
299
300 pub fn spellings(&self, locale: &Locale) -> &[String] {
303 self.aliases.get(locale).map(Vec::as_slice).unwrap_or(&[])
304 }
305}
306
307#[derive(Debug, Clone)]
309pub struct EnumDomain {
310 pub domain: String,
311 aliases: HashMap<Locale, Vec<String>>,
312 pub members: Vec<EnumMember>,
313}
314
315impl EnumDomain {
316 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
317 self.aliases
318 .get(locale)
319 .and_then(|spellings| spellings.first())
320 .map(String::as_str)
321 }
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
326pub struct TargetMeta {
327 pub game: String,
328 pub format: String,
329 pub surface: String,
330}
331
332#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
334#[serde(rename_all = "camelCase")]
335pub struct Provenance {
336 pub generator: String,
337 pub generator_version: String,
338 pub source: String,
339 pub license: String,
340 pub reviewed: bool,
341 #[serde(default, skip_serializing_if = "Vec::is_empty")]
344 pub source_notes: Vec<String>,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
351pub struct LocaleCoverage {
352 pub locale: Locale,
353 pub mapped: usize,
355 pub total: usize,
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
365#[serde(rename_all = "kebab-case")]
366pub struct CatalogIdentity {
367 pub implementation_version: String,
369 pub catalog_version: String,
371 pub catalog_digest: Option<String>,
374 pub locale_coverage: Vec<LocaleCoverage>,
376 pub target: TargetMeta,
378 pub provenance: Provenance,
380}
381
382#[derive(Debug, Clone)]
384pub struct Catalog {
385 pub schema_version: u32,
386 pub locales: Vec<Locale>,
389 pub target: TargetMeta,
390 pub provenance: Provenance,
391 catalog_version: String,
393 catalog_digest: Option<String>,
396 entries: Vec<CatalogEntry>,
397 localized_strings: Vec<LocalizedStringEntry>,
398 enums: Vec<EnumDomain>,
399 by_id: HashMap<(Kind, String), usize>,
400 alias_to_entry: HashMap<(Kind, Locale, String), usize>,
401 localized_string_by_id: HashMap<String, usize>,
402 localized_string_alias: HashMap<(Locale, String), usize>,
403 enum_by_domain: HashMap<String, usize>,
404 enum_alias_to_domain: HashMap<(Locale, String), String>,
405 enum_alias_to_member: HashMap<(String, Locale, String), (usize, usize)>,
406}
407
408#[derive(Deserialize)]
409#[serde(rename_all = "camelCase")]
410struct CatalogFile {
411 schema_version: u32,
412 locales: Vec<String>,
413 target: TargetMeta,
414 provenance: Provenance,
415 #[serde(default)]
417 version: Option<String>,
418 #[serde(default)]
420 digest: Option<String>,
421 #[serde(default)]
422 structural: Vec<EntryFile>,
423 #[serde(default)]
424 actions: Vec<EntryFile>,
425 #[serde(default)]
426 values: Vec<EntryFile>,
427 #[serde(default)]
428 events: Vec<EntryFile>,
429 #[serde(default)]
430 operators: Vec<EntryFile>,
431 #[serde(default)]
432 settings: Vec<EntryFile>,
433 #[serde(default)]
434 localized_strings: Vec<LocalizedStringFile>,
435 #[serde(default)]
436 enums: Vec<EnumFile>,
437}
438
439#[derive(Deserialize)]
440#[serde(rename_all = "camelCase")]
441struct EntryFile {
442 id: String,
443 aliases: HashMap<String, AliasFile>,
444 #[serde(default)]
445 params: Vec<String>,
446 #[serde(default)]
449 param_names: Vec<String>,
450 #[serde(default)]
451 param_aliases: Vec<HashMap<String, AliasFile>>,
452 #[serde(default)]
455 param_domains: Vec<Option<String>>,
456 #[serde(default)]
463 param_defaults: Vec<Option<String>>,
464 #[serde(default)]
465 param_types: Vec<Option<String>>,
466 #[serde(default)]
467 param_coercions: Vec<Option<ParamCoercions>>,
468 #[serde(default)]
469 return_type: Option<String>,
470 #[serde(default)]
471 variadic: bool,
472}
473
474#[derive(Deserialize)]
475struct LocalizedStringFile {
476 id: String,
477 aliases: HashMap<String, AliasFile>,
478}
479
480#[derive(Deserialize)]
481struct EnumFile {
482 domain: String,
483 #[serde(default)]
484 aliases: HashMap<String, AliasFile>,
485 members: Vec<MemberFile>,
486}
487
488#[derive(Deserialize)]
489struct MemberFile {
490 id: String,
491 aliases: HashMap<String, AliasFile>,
492}
493
494#[derive(Debug, Deserialize)]
499#[serde(untagged)]
500enum AliasFile {
501 One(String),
502 Many(Vec<String>),
503}
504
505impl AliasFile {
506 fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
507 let spellings = match self {
508 AliasFile::One(spelling) => vec![spelling],
509 AliasFile::Many(spellings) => spellings,
510 };
511 if spellings.is_empty() || spellings.iter().any(String::is_empty) {
512 return Err(CatalogError::validation(format!(
513 "catalog entry '{}' declares an empty alias for locale '{}'",
514 id, locale
515 )));
516 }
517 Ok(spellings)
518 }
519}
520
521impl Catalog {
522 pub fn load(json: &str) -> Result<Catalog> {
525 let catalog = Self::load_unverified(json)?;
526 if let Some(declared) = &catalog.catalog_digest {
527 let computed = content_digest(json)?;
528 if declared != &computed {
529 return Err(CatalogError::validation(format!(
530 "catalog digest mismatch: declared '{declared}', content '{computed}' — \
531 run the catalog pipeline (workshop-catalog-gen build)"
532 )));
533 }
534 }
535 Ok(catalog)
536 }
537
538 pub fn load_unverified(json: &str) -> Result<Catalog> {
541 let file: CatalogFile = serde_json::from_str(json)
542 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
543 if file.schema_version != 1 {
544 return Err(CatalogError::malformed(format!(
545 "unsupported catalog schemaVersion {}",
546 file.schema_version
547 )));
548 }
549 let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
550 if locales.is_empty() {
551 return Err(CatalogError::malformed(
552 "catalog declares no locales".to_string(),
553 ));
554 }
555
556 let mut catalog = Catalog {
557 schema_version: file.schema_version,
558 locales,
559 target: file.target,
560 provenance: file.provenance,
561 catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
562 catalog_digest: file.digest,
563 entries: Vec::new(),
564 localized_strings: Vec::new(),
565 enums: Vec::new(),
566 by_id: HashMap::new(),
567 alias_to_entry: HashMap::new(),
568 localized_string_by_id: HashMap::new(),
569 localized_string_alias: HashMap::new(),
570 enum_by_domain: HashMap::new(),
571 enum_alias_to_domain: HashMap::new(),
572 enum_alias_to_member: HashMap::new(),
573 };
574
575 for (kind, items) in [
576 (Kind::Structural, file.structural),
577 (Kind::Action, file.actions),
578 (Kind::Value, file.values),
579 (Kind::Event, file.events),
580 (Kind::Operator, file.operators),
581 (Kind::Setting, file.settings),
582 ] {
583 for item in items {
584 catalog.insert_entry(kind, item)?;
585 }
586 }
587 for item in file.localized_strings {
588 catalog.insert_localized_string(item)?;
589 }
590 for domain in file.enums {
591 catalog.insert_enum(domain)?;
592 }
593 catalog.validate_param_domains()?;
594 Ok(catalog)
595 }
596
597 pub fn builtin() -> Result<Catalog> {
599 Self::load(CATALOG_DATA)
600 }
601
602 pub fn locales(&self) -> &[Locale] {
604 &self.locales
605 }
606
607 pub fn primary_locale(&self) -> &Locale {
610 &self.locales[0]
611 }
612
613 pub fn supports(&self, locale: &Locale) -> bool {
615 self.locales.contains(locale)
616 }
617
618 pub fn catalog_version(&self) -> &str {
620 &self.catalog_version
621 }
622
623 pub fn catalog_digest(&self) -> Option<&str> {
626 self.catalog_digest.as_deref()
627 }
628
629 pub fn implementation_version() -> &'static str {
631 env!("CARGO_PKG_VERSION")
632 }
633
634 pub fn identity(&self) -> CatalogIdentity {
637 CatalogIdentity {
638 implementation_version: Self::implementation_version().to_string(),
639 catalog_version: self.catalog_version.clone(),
640 catalog_digest: self.catalog_digest.clone(),
641 locale_coverage: self
642 .locales
643 .iter()
644 .map(|locale| self.locale_coverage(locale))
645 .collect(),
646 target: self.target.clone(),
647 provenance: self.provenance.clone(),
648 }
649 }
650
651 pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
656 let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
657 let total = self.entries.len() + self.localized_strings.len() + member_total;
658 let mapped = self
659 .entries
660 .iter()
661 .filter(|entry| entry.aliases.contains_key(locale))
662 .count()
663 + self
664 .localized_strings
665 .iter()
666 .filter(|entry| entry.aliases.contains_key(locale))
667 .count()
668 + self
669 .enums
670 .iter()
671 .flat_map(|domain| &domain.members)
672 .filter(|member| member.aliases.contains_key(locale))
673 .count();
674 LocaleCoverage {
675 locale: locale.clone(),
676 mapped,
677 total,
678 }
679 }
680
681 pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
683 self.locales
684 .iter()
685 .map(|locale| self.locale_coverage(locale))
686 .collect()
687 }
688
689 pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
691 self.by_id
692 .get(&(kind, id.to_string()))
693 .map(|i| &self.entries[*i])
694 }
695
696 pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
698 self.alias_to_entry
699 .get(&(kind, locale.clone(), spelling.to_string()))
700 .map(|i| &self.entries[*i])
701 }
702
703 pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
705 self.entry(kind, id)?.spelling(locale)
706 }
707
708 pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
710 self.entries.iter().filter(move |entry| entry.kind == kind)
711 }
712
713 pub fn resolve_localized_string(
715 &self,
716 locale: &Locale,
717 spelling: &str,
718 ) -> Option<&LocalizedStringEntry> {
719 self.localized_string_alias
720 .get(&(locale.clone(), spelling.to_string()))
721 .map(|index| &self.localized_strings[*index])
722 }
723
724 pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
726 self.localized_strings
727 .get(*self.localized_string_by_id.get(id)?)
728 .and_then(|entry| entry.spelling(locale))
729 }
730
731 pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
733 self.localized_strings.iter()
734 }
735
736 pub fn entry_count(&self) -> usize {
738 self.entries.len()
739 }
740
741 pub fn enum_domains_count(&self) -> usize {
743 self.enums.len()
744 }
745
746 pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
748 self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
749 }
750
751 pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
753 self.enum_by_domain
754 .get_key_value(spelling)
755 .map(|(domain, _)| domain.as_str())
756 .or_else(|| {
757 self.enum_alias_to_domain
758 .get(&(locale.clone(), spelling.to_string()))
759 .map(String::as_str)
760 })
761 }
762
763 pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
765 self.enums.iter()
766 }
767
768 pub fn resolve_enum_member(
770 &self,
771 domain: &str,
772 locale: &Locale,
773 spelling: &str,
774 ) -> Option<(String, String)> {
775 let (domain_index, member_index) = self.enum_alias_to_member.get(&(
776 domain.to_string(),
777 locale.clone(),
778 spelling.to_string(),
779 ))?;
780 Some((
781 domain.to_string(),
782 self.enums[*domain_index].members[*member_index]
783 .member
784 .clone(),
785 ))
786 }
787
788 pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
790 let domain_index = self.enum_by_domain.get(domain)?;
791 let domain = &self.enums[*domain_index];
792 domain
793 .members
794 .iter()
795 .find(|candidate| candidate.member == member)?
796 .spelling(locale)
797 }
798
799 pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
804 let mut matches = Vec::new();
805 for domain in &self.enums {
806 for member in &domain.members {
807 if member
808 .spellings(locale)
809 .iter()
810 .any(|alias| alias == spelling)
811 {
812 matches.push((domain.domain.clone(), member.member.clone()));
813 }
814 }
815 }
816 matches
817 }
818
819 fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
820 let index = self.entries.len();
821 let mut aliases = HashMap::new();
822 for (locale_str, alias_file) in item.aliases {
823 let locale = Locale::new(&locale_str);
824 if !self.locales.contains(&locale) {
825 return Err(CatalogError::validation(format!(
826 "entry '{}' declares alias for undeclared locale '{}'",
827 item.id, locale
828 )));
829 }
830 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
831 for spelling in &spellings {
832 let key = (kind, locale.clone(), spelling.clone());
833 if self.alias_to_entry.contains_key(&key) {
834 return Err(CatalogError::validation(format!(
835 "duplicate {} alias '{spelling}' for locale '{}'",
836 kind.as_str(),
837 locale
838 )));
839 }
840 self.alias_to_entry.insert(key, index);
841 }
842 aliases.insert(locale, spellings);
843 }
844 let id_key = (kind, item.id.clone());
845 if self.by_id.contains_key(&id_key) {
846 return Err(CatalogError::validation(format!(
847 "duplicate {} id '{}'",
848 kind.as_str(),
849 item.id
850 )));
851 }
852 let primary = self.locales[0].clone();
857 if !aliases.contains_key(&primary) {
858 return Err(CatalogError::validation(format!(
859 "{} '{}' is missing a '{}' alias",
860 kind.as_str(),
861 item.id,
862 primary
863 )));
864 }
865 let param_names = if item.param_names.is_empty() {
866 item.params.clone()
867 } else {
868 item.param_names.clone()
869 };
870 if param_names.len() != item.params.len() {
871 return Err(CatalogError::validation(format!(
872 "{} '{}' declares {} param names for {} params",
873 kind.as_str(),
874 item.id,
875 param_names.len(),
876 item.params.len()
877 )));
878 }
879 self.by_id.insert(id_key, index);
880 let item_id = item.id.clone();
881 self.entries.push(CatalogEntry {
882 id: item.id,
883 kind,
884 params: item.params,
885 param_names,
886 param_aliases: item
887 .param_aliases
888 .into_iter()
889 .map(|aliases| {
890 aliases
891 .into_iter()
892 .map(|(locale, alias)| {
893 let locale_key = Locale::new(&locale);
894 let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
895 Ok((locale_key, spellings))
896 })
897 .collect::<Result<HashMap<_, _>>>()
898 })
899 .collect::<Result<Vec<_>>>()?,
900 param_domains: item.param_domains,
901 param_defaults: item.param_defaults,
902 param_types: item.param_types,
903 param_coercions: item.param_coercions,
904 return_type: item.return_type,
905 variadic: item.variadic,
906 aliases,
907 });
908 Ok(())
909 }
910
911 fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
912 if self.localized_string_by_id.contains_key(&item.id) {
913 return Err(CatalogError::validation(format!(
914 "duplicate localized string id '{}'",
915 item.id
916 )));
917 }
918 let index = self.localized_strings.len();
919 let mut aliases = HashMap::new();
920 for (locale_str, alias_file) in item.aliases {
921 let locale = Locale::new(&locale_str);
922 if !self.locales.contains(&locale) {
923 return Err(CatalogError::validation(format!(
924 "localized string '{}' declares alias for undeclared locale '{}'",
925 item.id, locale
926 )));
927 }
928 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
929 for spelling in &spellings {
930 let key = (locale.clone(), spelling.clone());
931 if self.localized_string_alias.contains_key(&key) {
932 return Err(CatalogError::validation(format!(
933 "duplicate localized string alias '{spelling}' for locale '{locale}'"
934 )));
935 }
936 self.localized_string_alias.insert(key, index);
937 }
938 aliases.insert(locale, spellings);
939 }
940 let primary = self.locales[0].clone();
941 if !aliases.contains_key(&primary) {
942 return Err(CatalogError::validation(format!(
943 "localized string '{}' is missing a '{}' alias",
944 item.id, primary
945 )));
946 }
947 self.localized_string_by_id.insert(item.id.clone(), index);
948 self.localized_strings.push(LocalizedStringEntry {
949 id: item.id,
950 aliases,
951 });
952 Ok(())
953 }
954
955 fn validate_param_domains(&self) -> Result<()> {
957 for entry in &self.entries {
958 if entry.param_names.len() != entry.params.len() {
959 return Err(CatalogError::validation(format!(
960 "{} '{}' declares param names that do not match params",
961 entry.kind.as_str(),
962 entry.id
963 )));
964 }
965 if entry.param_aliases.len() > entry.params.len() {
966 return Err(CatalogError::validation(format!(
967 "{} '{}' declares more parameter alias sets than params",
968 entry.kind.as_str(),
969 entry.id
970 )));
971 }
972 if entry.param_domains.len() > entry.params.len() {
973 return Err(CatalogError::validation(format!(
974 "{} '{}' declares more param domains than params",
975 entry.kind.as_str(),
976 entry.id
977 )));
978 }
979 if entry.param_defaults.len() > entry.params.len() {
980 return Err(CatalogError::validation(format!(
981 "{} '{}' declares more param defaults than params",
982 entry.kind.as_str(),
983 entry.id
984 )));
985 }
986 if entry.param_types.len() > entry.params.len() {
987 return Err(CatalogError::validation(format!(
988 "{} '{}' declares more param types than params",
989 entry.kind.as_str(),
990 entry.id
991 )));
992 }
993 if entry.param_coercions.len() > entry.params.len() {
994 return Err(CatalogError::validation(format!(
995 "{} '{}' declares more param coercions than params",
996 entry.kind.as_str(),
997 entry.id
998 )));
999 }
1000 if entry.kind != Kind::Value && entry.return_type.is_some() {
1001 return Err(CatalogError::validation(format!(
1002 "{} '{}' declares a return type but is not a value",
1003 entry.kind.as_str(),
1004 entry.id
1005 )));
1006 }
1007 for domain in entry.param_domains.iter().flatten() {
1008 if !self.enum_by_domain.contains_key(domain) {
1009 return Err(CatalogError::validation(format!(
1010 "{} '{}' declares undeclared enum domain '{domain}'",
1011 entry.kind.as_str(),
1012 entry.id
1013 )));
1014 }
1015 }
1016 for aliases in &entry.param_aliases {
1017 for locale in aliases.keys() {
1018 if !self.locales.contains(locale) {
1019 return Err(CatalogError::validation(format!(
1020 "{} '{}' declares parameter alias for undeclared locale '{}'",
1021 entry.kind.as_str(),
1022 entry.id,
1023 locale
1024 )));
1025 }
1026 }
1027 }
1028 }
1029 Ok(())
1030 }
1031
1032 fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
1033 let domain_index = self.enums.len();
1034 if self.enum_by_domain.contains_key(&domain.domain) {
1035 return Err(CatalogError::validation(format!(
1036 "duplicate enum domain '{}'",
1037 domain.domain
1038 )));
1039 }
1040 let primary = self.locales[0].clone();
1041 let mut domain_aliases = HashMap::new();
1042 for (locale_str, alias_file) in domain.aliases {
1043 let locale = Locale::new(&locale_str);
1044 if !self.locales.contains(&locale) {
1045 return Err(CatalogError::validation(format!(
1046 "enum domain '{}' declares alias for undeclared locale '{}'",
1047 domain.domain, locale
1048 )));
1049 }
1050 let spellings = alias_file.into_spellings(&domain.domain, locale.as_str())?;
1051 for spelling in &spellings {
1052 let key = (locale.clone(), spelling.clone());
1053 if let Some(existing) = self.enum_alias_to_domain.get(&key) {
1054 return Err(CatalogError::validation(format!(
1055 "duplicate enum domain alias '{spelling}' for '{}' and '{}' in locale '{}'",
1056 existing, domain.domain, locale
1057 )));
1058 }
1059 self.enum_alias_to_domain.insert(key, domain.domain.clone());
1060 }
1061 domain_aliases.insert(locale, spellings);
1062 }
1063 domain_aliases
1064 .entry(primary.clone())
1065 .or_insert_with(|| vec![domain.domain.clone()]);
1066 self.enum_alias_to_domain
1067 .entry((primary.clone(), domain.domain.clone()))
1068 .or_insert_with(|| domain.domain.clone());
1069 let mut members = Vec::new();
1070 for (member_index, member) in domain.members.into_iter().enumerate() {
1071 let mut aliases = HashMap::new();
1072 for (locale_str, alias_file) in member.aliases {
1073 let locale = Locale::new(&locale_str);
1074 if !self.locales.contains(&locale) {
1075 return Err(CatalogError::validation(format!(
1076 "enum {}::{} declares alias for undeclared locale '{}'",
1077 domain.domain, member.id, locale
1078 )));
1079 }
1080 let spellings = alias_file.into_spellings(&member.id, locale.as_str())?;
1081 for spelling in &spellings {
1082 let key = (domain.domain.clone(), locale.clone(), spelling.clone());
1083 if self.enum_alias_to_member.contains_key(&key) {
1084 return Err(CatalogError::validation(format!(
1085 "duplicate enum alias '{spelling}' in '{}' for locale '{}'",
1086 domain.domain, locale
1087 )));
1088 }
1089 self.enum_alias_to_member
1090 .insert(key, (domain_index, member_index));
1091 }
1092 aliases.insert(locale, spellings);
1093 }
1094 if !aliases.contains_key(&primary) {
1095 return Err(CatalogError::validation(format!(
1096 "enum {}::{} is missing a '{}' alias",
1097 domain.domain, member.id, primary
1098 )));
1099 }
1100 members.push(EnumMember {
1101 member: member.id,
1102 aliases,
1103 });
1104 }
1105 self.enum_by_domain
1106 .insert(domain.domain.clone(), domain_index);
1107 self.enums.push(EnumDomain {
1108 domain: domain.domain,
1109 aliases: domain_aliases,
1110 members,
1111 });
1112 Ok(())
1113 }
1114}
1115
1116impl ExpectedDomain for Catalog {
1123 fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
1124 for kind in [Kind::Action, Kind::Value] {
1125 if let Some(entry) = self.entry(kind, catalog_id) {
1126 if let Some(domain) = entry
1127 .param_domains
1128 .get(arg_index)
1129 .and_then(Option::as_deref)
1130 {
1131 return Some(domain);
1132 }
1133 }
1134 }
1135 None
1136 }
1137}
1138
1139pub fn canonicalize(json: &str) -> Result<String> {
1145 Catalog::load_unverified(json)?;
1147 let value: serde_json::Value = serde_json::from_str(json)
1148 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1149 serde_json::to_string_pretty(&value)
1150 .map(|mut out| {
1151 out.push('\n');
1152 out
1153 })
1154 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
1155}
1156
1157pub fn build_canonical(json: &str) -> Result<String> {
1161 let mut value: serde_json::Value = serde_json::from_str(json)
1162 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1163 let digest = content_digest(json)?;
1164 if let Some(object) = value.as_object_mut() {
1165 object.insert("digest".to_string(), serde_json::Value::String(digest));
1166 }
1167 let output = serde_json::to_string_pretty(&value)
1168 .map(|mut out| {
1169 out.push('\n');
1170 out
1171 })
1172 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1173 Catalog::load(&output)?;
1176 Ok(output)
1177}
1178
1179pub fn content_digest(json: &str) -> Result<String> {
1184 let mut value: serde_json::Value = serde_json::from_str(json)
1185 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1186 if let Some(object) = value.as_object_mut() {
1187 object.remove("digest");
1188 }
1189 let canonical = serde_json::to_string_pretty(&value)
1190 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1191 use sha2::{Digest, Sha256};
1192 let mut hasher = Sha256::new();
1193 hasher.update(canonical.as_bytes());
1194 Ok(format!("{:x}", hasher.finalize()))
1195}