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 = 0,
72 Action = 1,
74 Value = 2,
76 Event = 3,
78 Operator = 4,
80 Enum = 5,
82 Setting = 6,
84}
85
86impl Kind {
87 pub const NUM_KINDS: usize = 7;
88
89 pub const fn as_index(self) -> usize {
90 self as usize
91 }
92
93 pub fn as_str(self) -> &'static str {
94 match self {
95 Kind::Structural => "structural",
96 Kind::Action => "action",
97 Kind::Value => "value",
98 Kind::Event => "event",
99 Kind::Operator => "operator",
100 Kind::Enum => "enum",
101 Kind::Setting => "setting",
102 }
103 }
104}
105
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct ParamCoercions {
115 #[serde(default)]
117 pub false_as_number: bool,
118 #[serde(default)]
120 pub true_as_number: bool,
121 #[serde(default)]
123 pub zero_as_null: bool,
124 #[serde(default)]
126 pub null_vector_as_null: bool,
127 #[serde(default)]
129 pub empty_array_as_string: bool,
130}
131
132#[derive(Debug, Clone)]
134pub struct CatalogEntry {
135 pub id: String,
136 pub kind: Kind,
137 params: Vec<String>,
139 param_names: Vec<String>,
142 param_aliases: Vec<HashMap<Locale, Vec<String>>>,
144 param_domains: Vec<Option<String>>,
152 param_defaults: Vec<Option<String>>,
156 param_types: Vec<Option<String>>,
159 param_coercions: Vec<Option<ParamCoercions>>,
161 return_type: Option<String>,
164 variadic: bool,
166 aliases: HashMap<Locale, Vec<String>>,
167}
168
169#[derive(Debug, Clone)]
173pub struct LocalizedStringEntry {
174 pub id: String,
175 aliases: HashMap<Locale, Vec<String>>,
176}
177
178impl LocalizedStringEntry {
179 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
181 self.aliases
182 .get(locale)
183 .and_then(|spellings| spellings.first())
184 .map(String::as_str)
185 }
186
187 pub fn spellings(&self, locale: &Locale) -> &[String] {
189 self.aliases
190 .get(locale)
191 .map(Vec::as_slice)
192 .unwrap_or_default()
193 }
194}
195
196impl CatalogEntry {
197 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
199 self.aliases
200 .get(locale)
201 .and_then(|spellings| spellings.first())
202 .map(String::as_str)
203 }
204
205 pub fn spellings(&self, locale: &Locale) -> &[String] {
208 self.aliases.get(locale).map(Vec::as_slice).unwrap_or(&[])
209 }
210
211 pub fn resolve_param(&self, locale: &Locale, spelling: &str) -> Option<usize> {
214 let matches = self
215 .params
216 .iter()
217 .enumerate()
218 .filter(|(index, canonical)| {
219 canonical == &spelling
220 || self
221 .param_aliases
222 .get(*index)
223 .and_then(|aliases| aliases.get(locale))
224 .is_some_and(|aliases| aliases.iter().any(|alias| alias == spelling))
225 })
226 .map(|(index, _)| index)
227 .collect::<Vec<_>>();
228 (matches.len() == 1).then(|| matches[0])
229 }
230
231 pub fn params(&self) -> &[String] {
233 &self.params
234 }
235
236 pub fn param_count(&self) -> usize {
238 self.params.len()
239 }
240
241 pub fn param_name(&self, index: usize) -> Option<&str> {
243 self.param_names
244 .get(index)
245 .or_else(|| self.variadic.then(|| self.param_names.last()).flatten())
246 .map(String::as_str)
247 }
248
249 pub fn required_param_count(&self) -> usize {
253 (0..self.params.len())
254 .rev()
255 .find(|index| {
256 self.param_defaults
257 .get(*index)
258 .and_then(Option::as_ref)
259 .is_none()
260 })
261 .map_or(0, |index| index + 1)
262 }
263
264 pub fn has_param_defaults(&self) -> bool {
266 self.param_defaults.iter().any(Option::is_some)
267 }
268
269 pub fn param_default(&self, index: usize) -> Option<&str> {
271 self.param_defaults
272 .get(index)
273 .or_else(|| self.variadic.then(|| self.param_defaults.last()).flatten())
274 .and_then(Option::as_deref)
275 }
276
277 pub fn param_domain(&self, index: usize) -> Option<&str> {
279 self.param_domains
280 .get(index)
281 .or_else(|| self.variadic.then(|| self.param_domains.last()).flatten())
282 .and_then(Option::as_deref)
283 }
284
285 pub fn param_type(&self, index: usize) -> Option<&str> {
288 self.param_types
289 .get(index)
290 .or_else(|| self.variadic.then(|| self.param_types.last()).flatten())
291 .and_then(Option::as_deref)
292 }
293
294 pub fn param_coercions(&self, index: usize) -> Option<&ParamCoercions> {
296 self.param_coercions
297 .get(index)
298 .or_else(|| self.variadic.then(|| self.param_coercions.last()).flatten())
299 .and_then(Option::as_ref)
300 }
301
302 pub fn return_type(&self) -> Option<&str> {
304 self.return_type.as_deref()
305 }
306
307 pub fn is_variadic(&self) -> bool {
309 self.variadic
310 }
311}
312
313#[derive(Debug, Clone)]
315pub struct EnumMember {
316 pub member: String,
317 aliases: HashMap<Locale, Vec<String>>,
318}
319
320impl EnumMember {
321 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
323 self.aliases
324 .get(locale)
325 .and_then(|spellings| spellings.first())
326 .map(String::as_str)
327 }
328
329 pub fn spellings(&self, locale: &Locale) -> &[String] {
332 self.aliases.get(locale).map(Vec::as_slice).unwrap_or(&[])
333 }
334}
335
336#[derive(Debug, Clone)]
338pub struct EnumDomain {
339 pub domain: String,
340 aliases: HashMap<Locale, Vec<String>>,
341 pub members: Vec<EnumMember>,
342}
343
344impl EnumDomain {
345 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
346 self.aliases
347 .get(locale)
348 .and_then(|spellings| spellings.first())
349 .map(String::as_str)
350 }
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
355pub struct TargetMeta {
356 pub game: String,
357 pub format: String,
358 pub surface: String,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
363#[serde(rename_all = "camelCase")]
364pub struct Provenance {
365 pub generator: String,
366 pub generator_version: String,
367 pub source: String,
368 pub license: String,
369 pub reviewed: bool,
370 #[serde(default, skip_serializing_if = "Vec::is_empty")]
373 pub source_notes: Vec<String>,
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
380pub struct LocaleCoverage {
381 pub locale: Locale,
382 pub mapped: usize,
384 pub total: usize,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
394#[serde(rename_all = "kebab-case")]
395pub struct CatalogIdentity {
396 pub implementation_version: String,
398 pub catalog_version: String,
400 pub catalog_digest: Option<String>,
403 pub locale_coverage: Vec<LocaleCoverage>,
405 pub target: TargetMeta,
407 pub provenance: Provenance,
409}
410
411type MemberIndexMap = HashMap<String, HashMap<String, (usize, usize)>>;
412
413#[derive(Debug, Clone)]
415pub struct Catalog {
416 schema_version: u32,
417 locales: Vec<Locale>,
420 target: TargetMeta,
421 provenance: Provenance,
422 catalog_version: String,
424 catalog_digest: Option<String>,
427 entries: Vec<CatalogEntry>,
428 localized_strings: Vec<LocalizedStringEntry>,
429 enums: Vec<EnumDomain>,
430 by_id: [HashMap<String, usize>; Kind::NUM_KINDS],
431 alias_to_entry: HashMap<Locale, [HashMap<String, usize>; Kind::NUM_KINDS]>,
432 localized_string_by_id: HashMap<String, usize>,
433 localized_string_alias: HashMap<Locale, HashMap<String, usize>>,
434 enum_by_domain: HashMap<String, usize>,
435 enum_alias_to_domain: HashMap<Locale, HashMap<String, String>>,
436 enum_alias_to_member: HashMap<Locale, MemberIndexMap>,
437 bare_member_index: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
438}
439
440#[derive(Deserialize)]
441#[serde(rename_all = "camelCase")]
442struct CatalogFile {
443 schema_version: u32,
444 locales: Vec<String>,
445 target: TargetMeta,
446 provenance: Provenance,
447 #[serde(default)]
449 version: Option<String>,
450 #[serde(default)]
452 digest: Option<String>,
453 #[serde(default)]
454 structural: Vec<EntryFile>,
455 #[serde(default)]
456 actions: Vec<EntryFile>,
457 #[serde(default)]
458 values: Vec<EntryFile>,
459 #[serde(default)]
460 events: Vec<EntryFile>,
461 #[serde(default)]
462 operators: Vec<EntryFile>,
463 #[serde(default)]
464 settings: Vec<EntryFile>,
465 #[serde(default)]
466 localized_strings: Vec<LocalizedStringFile>,
467 #[serde(default)]
468 enums: Vec<EnumFile>,
469}
470
471#[derive(Deserialize)]
472#[serde(rename_all = "camelCase")]
473struct EntryFile {
474 id: String,
475 aliases: HashMap<String, AliasFile>,
476 #[serde(default)]
477 params: Vec<String>,
478 #[serde(default)]
481 param_names: Vec<String>,
482 #[serde(default)]
483 param_aliases: Vec<HashMap<String, AliasFile>>,
484 #[serde(default)]
487 param_domains: Vec<Option<String>>,
488 #[serde(default)]
495 param_defaults: Vec<Option<String>>,
496 #[serde(default)]
497 param_types: Vec<Option<String>>,
498 #[serde(default)]
499 param_coercions: Vec<Option<ParamCoercions>>,
500 #[serde(default)]
501 return_type: Option<String>,
502 #[serde(default)]
503 variadic: bool,
504}
505
506#[derive(Deserialize)]
507struct LocalizedStringFile {
508 id: String,
509 aliases: HashMap<String, AliasFile>,
510}
511
512#[derive(Deserialize)]
513struct EnumFile {
514 domain: String,
515 #[serde(default)]
516 aliases: HashMap<String, AliasFile>,
517 members: Vec<MemberFile>,
518}
519
520#[derive(Deserialize)]
521struct MemberFile {
522 id: String,
523 aliases: HashMap<String, AliasFile>,
524}
525
526#[derive(Debug, Deserialize)]
531#[serde(untagged)]
532enum AliasFile {
533 One(String),
534 Many(Vec<String>),
535}
536
537impl AliasFile {
538 fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
539 let spellings = match self {
540 AliasFile::One(spelling) => vec![spelling],
541 AliasFile::Many(spellings) => spellings,
542 };
543 if spellings.is_empty() || spellings.iter().any(String::is_empty) {
544 return Err(CatalogError::validation(format!(
545 "catalog entry '{}' declares an empty alias for locale '{}'",
546 id, locale
547 )));
548 }
549 Ok(spellings)
550 }
551}
552
553impl Catalog {
554 pub fn load(json: &str) -> Result<Catalog> {
557 let catalog = Self::load_unverified(json)?;
558 if let Some(declared) = &catalog.catalog_digest {
559 let computed = content_digest(json)?;
560 if declared != &computed {
561 return Err(CatalogError::validation(format!(
562 "catalog digest mismatch: declared '{declared}', content '{computed}' — \
563 run the catalog pipeline (workshop-catalog-gen build)"
564 )));
565 }
566 }
567 Ok(catalog)
568 }
569
570 pub fn load_unverified(json: &str) -> Result<Catalog> {
573 let file: CatalogFile = serde_json::from_str(json)
574 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
575 if file.schema_version != 1 {
576 return Err(CatalogError::malformed(format!(
577 "unsupported catalog schemaVersion {}",
578 file.schema_version
579 )));
580 }
581 let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
582 if locales.is_empty() {
583 return Err(CatalogError::malformed(
584 "catalog declares no locales".to_string(),
585 ));
586 }
587
588 let mut catalog = Catalog {
589 schema_version: file.schema_version,
590 locales,
591 target: file.target,
592 provenance: file.provenance,
593 catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
594 catalog_digest: file.digest,
595 entries: Vec::new(),
596 localized_strings: Vec::new(),
597 enums: Vec::new(),
598 by_id: Default::default(),
599 alias_to_entry: HashMap::new(),
600 localized_string_by_id: HashMap::new(),
601 localized_string_alias: HashMap::new(),
602 enum_by_domain: HashMap::new(),
603 enum_alias_to_domain: HashMap::new(),
604 enum_alias_to_member: HashMap::new(),
605 bare_member_index: HashMap::new(),
606 };
607
608 for (kind, items) in [
609 (Kind::Structural, file.structural),
610 (Kind::Action, file.actions),
611 (Kind::Value, file.values),
612 (Kind::Event, file.events),
613 (Kind::Operator, file.operators),
614 (Kind::Setting, file.settings),
615 ] {
616 for item in items {
617 catalog.insert_entry(kind, item)?;
618 }
619 }
620 for item in file.localized_strings {
621 catalog.insert_localized_string(item)?;
622 }
623 for domain in file.enums {
624 catalog.insert_enum(domain)?;
625 }
626 for domain in &catalog.enums {
627 for member in &domain.members {
628 for (locale, spellings) in &member.aliases {
629 for spelling in spellings {
630 catalog
631 .bare_member_index
632 .entry(locale.clone())
633 .or_default()
634 .entry(spelling.clone())
635 .or_default()
636 .push((domain.domain.clone(), member.member.clone()));
637 }
638 }
639 }
640 }
641 catalog.validate_param_domains()?;
642 Ok(catalog)
643 }
644
645 pub fn builtin() -> Result<Catalog> {
647 Self::load(CATALOG_DATA)
648 }
649
650 pub(crate) fn schema_version(&self) -> u32 {
651 self.schema_version
652 }
653
654 pub fn locales(&self) -> &[Locale] {
656 &self.locales
657 }
658
659 pub fn primary_locale(&self) -> &Locale {
662 &self.locales[0]
663 }
664
665 pub fn supports(&self, locale: &Locale) -> bool {
667 self.locales.contains(locale)
668 }
669
670 pub fn catalog_version(&self) -> &str {
672 &self.catalog_version
673 }
674
675 pub fn catalog_digest(&self) -> Option<&str> {
678 self.catalog_digest.as_deref()
679 }
680
681 pub fn implementation_version() -> &'static str {
683 env!("CARGO_PKG_VERSION")
684 }
685
686 pub fn identity(&self) -> CatalogIdentity {
689 CatalogIdentity {
690 implementation_version: Self::implementation_version().to_string(),
691 catalog_version: self.catalog_version.clone(),
692 catalog_digest: self.catalog_digest.clone(),
693 locale_coverage: self
694 .locales
695 .iter()
696 .map(|locale| self.locale_coverage(locale))
697 .collect(),
698 target: self.target.clone(),
699 provenance: self.provenance.clone(),
700 }
701 }
702
703 pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
708 let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
709 let total = self.entries.len() + self.localized_strings.len() + member_total;
710 let mapped = self
711 .entries
712 .iter()
713 .filter(|entry| entry.aliases.contains_key(locale))
714 .count()
715 + self
716 .localized_strings
717 .iter()
718 .filter(|entry| entry.aliases.contains_key(locale))
719 .count()
720 + self
721 .enums
722 .iter()
723 .flat_map(|domain| &domain.members)
724 .filter(|member| member.aliases.contains_key(locale))
725 .count();
726 LocaleCoverage {
727 locale: locale.clone(),
728 mapped,
729 total,
730 }
731 }
732
733 pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
735 self.locales
736 .iter()
737 .map(|locale| self.locale_coverage(locale))
738 .collect()
739 }
740
741 pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
743 self.by_id[kind.as_index()]
744 .get(id)
745 .map(|i| &self.entries[*i])
746 }
747
748 pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
750 self.alias_to_entry
751 .get(locale)
752 .and_then(|by_kind| by_kind[kind.as_index()].get(spelling))
753 .map(|i| &self.entries[*i])
754 }
755
756 pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
758 self.entry(kind, id)?.spelling(locale)
759 }
760
761 pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
763 self.entries.iter().filter(move |entry| entry.kind == kind)
764 }
765
766 pub fn resolve_localized_string(
768 &self,
769 locale: &Locale,
770 spelling: &str,
771 ) -> Option<&LocalizedStringEntry> {
772 self.localized_string_alias
773 .get(locale)
774 .and_then(|map| map.get(spelling))
775 .map(|index| &self.localized_strings[*index])
776 }
777
778 pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
780 self.localized_string_by_id
781 .get(id)
782 .and_then(|i| self.localized_strings.get(*i))
783 .and_then(|entry| entry.spelling(locale))
784 }
785
786 pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
788 self.localized_strings.iter()
789 }
790
791 pub fn entry_count(&self) -> usize {
793 self.entries.len()
794 }
795
796 pub fn enum_domains_count(&self) -> usize {
798 self.enums.len()
799 }
800
801 pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
803 self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
804 }
805
806 pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
808 self.enum_by_domain
809 .get_key_value(spelling)
810 .map(|(domain, _)| domain.as_str())
811 .or_else(|| {
812 self.enum_alias_to_domain
813 .get(locale)
814 .and_then(|map| map.get(spelling))
815 .map(String::as_str)
816 })
817 }
818
819 pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
821 self.enums.iter()
822 }
823
824 pub fn resolve_enum_member(
826 &self,
827 domain: &str,
828 locale: &Locale,
829 spelling: &str,
830 ) -> Option<(String, String)> {
831 let &(domain_index, member_index) = self
832 .enum_alias_to_member
833 .get(locale)?
834 .get(domain)?
835 .get(spelling)?;
836 Some((
837 domain.to_string(),
838 self.enums[domain_index].members[member_index]
839 .member
840 .clone(),
841 ))
842 }
843
844 pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
846 let domain_index = self.enum_by_domain.get(domain)?;
847 let domain = &self.enums[*domain_index];
848 domain
849 .members
850 .iter()
851 .find(|candidate| candidate.member == member)?
852 .spelling(locale)
853 }
854
855 pub fn localized_enum_spelling(
859 &self,
860 domain: &str,
861 locale: &Locale,
862 member: &str,
863 ) -> Option<&str> {
864 self.enum_spelling(domain, locale, member).or_else(|| {
865 (domain == "Color" && member == "WHITE").then_some(match locale.as_str() {
866 "de-de" => "Weiß",
867 "es-es" | "es-mx" => "Blanco",
868 "fr-fr" => "Blanc",
869 "it-it" => "Bianco",
870 "ja-jp" => "白",
871 "ko-kr" => "흰색",
872 "pl-pl" => "Biały",
873 "pt-br" => "Branco",
874 "ru-ru" => "Белый",
875 "th-th" => "สีขาว",
876 "tr-tr" => "Beyaz",
877 "zh-tw" => "白色",
878 _ => return None,
879 })
880 })
881 }
882
883 pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
888 self.bare_member_index
889 .get(locale)
890 .and_then(|map| map.get(spelling))
891 .cloned()
892 .unwrap_or_default()
893 }
894
895 fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
896 let index = self.entries.len();
897 let mut aliases = HashMap::new();
898 for (locale_str, alias_file) in item.aliases {
899 let locale = Locale::new(&locale_str);
900 if !self.locales.contains(&locale) {
901 return Err(CatalogError::validation(format!(
902 "entry '{}' declares alias for undeclared locale '{}'",
903 item.id, locale
904 )));
905 }
906 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
907 let locale_map = self.alias_to_entry.entry(locale.clone()).or_default();
908 for spelling in &spellings {
909 if locale_map[kind.as_index()].contains_key(spelling) {
910 return Err(CatalogError::validation(format!(
911 "duplicate {} alias '{spelling}' for locale '{}'",
912 kind.as_str(),
913 locale
914 )));
915 }
916 locale_map[kind.as_index()].insert(spelling.clone(), index);
917 }
918 aliases.insert(locale, spellings);
919 }
920 if self.by_id[kind.as_index()].contains_key(&item.id) {
921 return Err(CatalogError::validation(format!(
922 "duplicate {} id '{}'",
923 kind.as_str(),
924 item.id
925 )));
926 }
927 let primary = self.locales[0].clone();
932 if !aliases.contains_key(&primary) {
933 return Err(CatalogError::validation(format!(
934 "{} '{}' is missing a '{}' alias",
935 kind.as_str(),
936 item.id,
937 primary
938 )));
939 }
940 let param_names = if item.param_names.is_empty() {
941 item.params.clone()
942 } else {
943 item.param_names.clone()
944 };
945 if param_names.len() != item.params.len() {
946 return Err(CatalogError::validation(format!(
947 "{} '{}' declares {} param names for {} params",
948 kind.as_str(),
949 item.id,
950 param_names.len(),
951 item.params.len()
952 )));
953 }
954 self.by_id[kind.as_index()].insert(item.id.clone(), index);
955 let item_id = item.id.clone();
956 self.entries.push(CatalogEntry {
957 id: item.id,
958 kind,
959 params: item.params,
960 param_names,
961 param_aliases: item
962 .param_aliases
963 .into_iter()
964 .map(|aliases| {
965 aliases
966 .into_iter()
967 .map(|(locale, alias)| {
968 let locale_key = Locale::new(&locale);
969 let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
970 Ok((locale_key, spellings))
971 })
972 .collect::<Result<HashMap<_, _>>>()
973 })
974 .collect::<Result<Vec<_>>>()?,
975 param_domains: item.param_domains,
976 param_defaults: item.param_defaults,
977 param_types: item.param_types,
978 param_coercions: item.param_coercions,
979 return_type: item.return_type,
980 variadic: item.variadic,
981 aliases,
982 });
983 Ok(())
984 }
985
986 fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
987 if self.localized_string_by_id.contains_key(&item.id) {
988 return Err(CatalogError::validation(format!(
989 "duplicate localized string id '{}'",
990 item.id
991 )));
992 }
993 let index = self.localized_strings.len();
994 let mut aliases = HashMap::new();
995 for (locale_str, alias_file) in item.aliases {
996 let locale = Locale::new(&locale_str);
997 if !self.locales.contains(&locale) {
998 return Err(CatalogError::validation(format!(
999 "localized string '{}' declares alias for undeclared locale '{}'",
1000 item.id, locale
1001 )));
1002 }
1003 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
1004 let locale_map = self
1005 .localized_string_alias
1006 .entry(locale.clone())
1007 .or_default();
1008 for spelling in &spellings {
1009 if locale_map.contains_key(spelling) {
1010 return Err(CatalogError::validation(format!(
1011 "duplicate localized string alias '{spelling}' for locale '{locale}'"
1012 )));
1013 }
1014 locale_map.insert(spelling.clone(), index);
1015 }
1016 aliases.insert(locale, spellings);
1017 }
1018 let primary = self.locales[0].clone();
1019 if !aliases.contains_key(&primary) {
1020 return Err(CatalogError::validation(format!(
1021 "localized string '{}' is missing a '{}' alias",
1022 item.id, primary
1023 )));
1024 }
1025 self.localized_string_by_id.insert(item.id.clone(), index);
1026 self.localized_strings.push(LocalizedStringEntry {
1027 id: item.id,
1028 aliases,
1029 });
1030 Ok(())
1031 }
1032
1033 fn validate_param_domains(&self) -> Result<()> {
1035 for entry in &self.entries {
1036 if entry.param_names.len() != entry.params.len() {
1037 return Err(CatalogError::validation(format!(
1038 "{} '{}' declares param names that do not match params",
1039 entry.kind.as_str(),
1040 entry.id
1041 )));
1042 }
1043 if entry.param_aliases.len() > entry.params.len() {
1044 return Err(CatalogError::validation(format!(
1045 "{} '{}' declares more parameter alias sets than params",
1046 entry.kind.as_str(),
1047 entry.id
1048 )));
1049 }
1050 if entry.param_domains.len() > entry.params.len() {
1051 return Err(CatalogError::validation(format!(
1052 "{} '{}' declares more param domains than params",
1053 entry.kind.as_str(),
1054 entry.id
1055 )));
1056 }
1057 if entry.param_defaults.len() > entry.params.len() {
1058 return Err(CatalogError::validation(format!(
1059 "{} '{}' declares more param defaults than params",
1060 entry.kind.as_str(),
1061 entry.id
1062 )));
1063 }
1064 if entry.param_types.len() > entry.params.len() {
1065 return Err(CatalogError::validation(format!(
1066 "{} '{}' declares more param types than params",
1067 entry.kind.as_str(),
1068 entry.id
1069 )));
1070 }
1071 if entry.param_coercions.len() > entry.params.len() {
1072 return Err(CatalogError::validation(format!(
1073 "{} '{}' declares more param coercions than params",
1074 entry.kind.as_str(),
1075 entry.id
1076 )));
1077 }
1078 if entry.kind != Kind::Value && entry.return_type.is_some() {
1079 return Err(CatalogError::validation(format!(
1080 "{} '{}' declares a return type but is not a value",
1081 entry.kind.as_str(),
1082 entry.id
1083 )));
1084 }
1085 for domain in entry.param_domains.iter().flatten() {
1086 if !self.enum_by_domain.contains_key(domain) {
1087 return Err(CatalogError::validation(format!(
1088 "{} '{}' declares undeclared enum domain '{domain}'",
1089 entry.kind.as_str(),
1090 entry.id
1091 )));
1092 }
1093 }
1094 for aliases in &entry.param_aliases {
1095 for locale in aliases.keys() {
1096 if !self.locales.contains(locale) {
1097 return Err(CatalogError::validation(format!(
1098 "{} '{}' declares parameter alias for undeclared locale '{}'",
1099 entry.kind.as_str(),
1100 entry.id,
1101 locale
1102 )));
1103 }
1104 }
1105 }
1106 }
1107 Ok(())
1108 }
1109
1110 fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
1111 let domain_index = self.enums.len();
1112 if self.enum_by_domain.contains_key(&domain.domain) {
1113 return Err(CatalogError::validation(format!(
1114 "duplicate enum domain '{}'",
1115 domain.domain
1116 )));
1117 }
1118 let primary = self.locales[0].clone();
1119 let mut domain_aliases = HashMap::new();
1120 for (locale_str, alias_file) in domain.aliases {
1121 let locale = Locale::new(&locale_str);
1122 if !self.locales.contains(&locale) {
1123 return Err(CatalogError::validation(format!(
1124 "enum domain '{}' declares alias for undeclared locale '{}'",
1125 domain.domain, locale
1126 )));
1127 }
1128 let spellings = alias_file.into_spellings(&domain.domain, locale.as_str())?;
1129 let locale_map = self.enum_alias_to_domain.entry(locale.clone()).or_default();
1130 for spelling in &spellings {
1131 if let Some(existing) = locale_map.get(spelling) {
1132 return Err(CatalogError::validation(format!(
1133 "duplicate enum domain alias '{spelling}' for '{}' and '{}' in locale '{}'",
1134 existing, domain.domain, locale
1135 )));
1136 }
1137 locale_map.insert(spelling.clone(), domain.domain.clone());
1138 }
1139 domain_aliases.insert(locale, spellings);
1140 }
1141 domain_aliases
1142 .entry(primary.clone())
1143 .or_insert_with(|| vec![domain.domain.clone()]);
1144 self.enum_alias_to_domain
1145 .entry(primary.clone())
1146 .or_default()
1147 .entry(domain.domain.clone())
1148 .or_insert_with(|| domain.domain.clone());
1149 let mut members = Vec::new();
1150 for (member_index, member) in domain.members.into_iter().enumerate() {
1151 let mut aliases = HashMap::new();
1152 for (locale_str, alias_file) in member.aliases {
1153 let locale = Locale::new(&locale_str);
1154 if !self.locales.contains(&locale) {
1155 return Err(CatalogError::validation(format!(
1156 "enum {}::{} declares alias for undeclared locale '{}'",
1157 domain.domain, member.id, locale
1158 )));
1159 }
1160 let spellings = alias_file.into_spellings(&member.id, locale.as_str())?;
1161 let locale_map = self.enum_alias_to_member.entry(locale.clone()).or_default();
1162 let domain_map = locale_map.entry(domain.domain.clone()).or_default();
1163 for spelling in &spellings {
1164 if domain_map.contains_key(spelling) {
1165 return Err(CatalogError::validation(format!(
1166 "duplicate enum alias '{spelling}' in '{}' for locale '{}'",
1167 domain.domain, locale
1168 )));
1169 }
1170 domain_map.insert(spelling.clone(), (domain_index, member_index));
1171 }
1172 aliases.insert(locale, spellings);
1173 }
1174 if !aliases.contains_key(&primary) {
1175 return Err(CatalogError::validation(format!(
1176 "enum {}::{} is missing a '{}' alias",
1177 domain.domain, member.id, primary
1178 )));
1179 }
1180 members.push(EnumMember {
1181 member: member.id,
1182 aliases,
1183 });
1184 }
1185 self.enum_by_domain
1186 .insert(domain.domain.clone(), domain_index);
1187 self.enums.push(EnumDomain {
1188 domain: domain.domain,
1189 aliases: domain_aliases,
1190 members,
1191 });
1192 Ok(())
1193 }
1194}
1195
1196impl ExpectedDomain for Catalog {
1203 fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
1204 for kind in [Kind::Action, Kind::Value] {
1205 if let Some(entry) = self.entry(kind, catalog_id) {
1206 if let Some(domain) = entry.param_domain(arg_index) {
1207 return Some(domain);
1208 }
1209 }
1210 }
1211 None
1212 }
1213}
1214
1215pub fn canonicalize(json: &str) -> Result<String> {
1221 Catalog::load_unverified(json)?;
1223 let value: serde_json::Value = serde_json::from_str(json)
1224 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1225 serde_json::to_string_pretty(&value)
1226 .map(|mut out| {
1227 out.push('\n');
1228 out
1229 })
1230 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
1231}
1232
1233pub fn build_canonical(json: &str) -> Result<String> {
1237 let mut value: serde_json::Value = serde_json::from_str(json)
1238 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1239 let digest = content_digest(json)?;
1240 if let Some(object) = value.as_object_mut() {
1241 object.insert("digest".to_string(), serde_json::Value::String(digest));
1242 }
1243 let output = serde_json::to_string_pretty(&value)
1244 .map(|mut out| {
1245 out.push('\n');
1246 out
1247 })
1248 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1249 Catalog::load(&output)?;
1252 Ok(output)
1253}
1254
1255pub fn content_digest(json: &str) -> Result<String> {
1260 let mut value: serde_json::Value = serde_json::from_str(json)
1261 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1262 if let Some(object) = value.as_object_mut() {
1263 object.remove("digest");
1264 }
1265 let canonical = serde_json::to_string_pretty(&value)
1266 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1267 use sha2::{Digest, Sha256};
1268 let mut hasher = Sha256::new();
1269 hasher.update(canonical.as_bytes());
1270 Ok(format!("{:x}", hasher.finalize()))
1271}