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 pub params: Vec<String>,
139 pub param_names: Vec<String>,
142 pub param_aliases: Vec<HashMap<Locale, Vec<String>>>,
144 pub param_domains: Vec<Option<String>>,
152 pub param_defaults: Vec<Option<String>>,
156 pub param_types: Vec<Option<String>>,
159 pub param_coercions: Vec<Option<ParamCoercions>>,
161 pub return_type: Option<String>,
164 pub 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 param_count(&self) -> usize {
233 self.params.len()
234 }
235
236 pub fn param_name(&self, index: usize) -> Option<&str> {
238 self.param_names
239 .get(index)
240 .or_else(|| self.variadic.then(|| self.param_names.last()).flatten())
241 .map(String::as_str)
242 }
243
244 pub fn required_param_count(&self) -> usize {
248 (0..self.params.len())
249 .rev()
250 .find(|index| {
251 self.param_defaults
252 .get(*index)
253 .and_then(Option::as_ref)
254 .is_none()
255 })
256 .map_or(0, |index| index + 1)
257 }
258
259 pub fn param_domain(&self, index: usize) -> Option<&str> {
261 self.param_domains
262 .get(index)
263 .or_else(|| self.variadic.then(|| self.param_domains.last()).flatten())
264 .and_then(Option::as_deref)
265 }
266
267 pub fn param_type(&self, index: usize) -> Option<&str> {
270 self.param_types
271 .get(index)
272 .or_else(|| self.variadic.then(|| self.param_types.last()).flatten())
273 .and_then(Option::as_deref)
274 }
275
276 pub fn param_coercions(&self, index: usize) -> Option<&ParamCoercions> {
278 self.param_coercions
279 .get(index)
280 .or_else(|| self.variadic.then(|| self.param_coercions.last()).flatten())
281 .and_then(Option::as_ref)
282 }
283
284 pub fn return_type(&self) -> Option<&str> {
286 self.return_type.as_deref()
287 }
288}
289
290#[derive(Debug, Clone)]
292pub struct EnumMember {
293 pub member: String,
294 aliases: HashMap<Locale, Vec<String>>,
295}
296
297impl EnumMember {
298 pub fn spelling(&self, locale: &Locale) -> Option<&str> {
300 self.aliases
301 .get(locale)
302 .and_then(|spellings| spellings.first())
303 .map(String::as_str)
304 }
305
306 pub fn spellings(&self, locale: &Locale) -> &[String] {
309 self.aliases.get(locale).map(Vec::as_slice).unwrap_or(&[])
310 }
311}
312
313#[derive(Debug, Clone)]
315pub struct EnumDomain {
316 pub domain: String,
317 aliases: HashMap<Locale, Vec<String>>,
318 pub members: Vec<EnumMember>,
319}
320
321impl EnumDomain {
322 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
330#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
332pub struct TargetMeta {
333 pub game: String,
334 pub format: String,
335 pub surface: String,
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
340#[serde(rename_all = "camelCase")]
341pub struct Provenance {
342 pub generator: String,
343 pub generator_version: String,
344 pub source: String,
345 pub license: String,
346 pub reviewed: bool,
347 #[serde(default, skip_serializing_if = "Vec::is_empty")]
350 pub source_notes: Vec<String>,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
357pub struct LocaleCoverage {
358 pub locale: Locale,
359 pub mapped: usize,
361 pub total: usize,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
371#[serde(rename_all = "kebab-case")]
372pub struct CatalogIdentity {
373 pub implementation_version: String,
375 pub catalog_version: String,
377 pub catalog_digest: Option<String>,
380 pub locale_coverage: Vec<LocaleCoverage>,
382 pub target: TargetMeta,
384 pub provenance: Provenance,
386}
387
388type MemberIndexMap = HashMap<String, HashMap<String, (usize, usize)>>;
389
390#[derive(Debug, Clone)]
392pub struct Catalog {
393 pub schema_version: u32,
394 pub locales: Vec<Locale>,
397 pub target: TargetMeta,
398 pub provenance: Provenance,
399 catalog_version: String,
401 catalog_digest: Option<String>,
404 entries: Vec<CatalogEntry>,
405 localized_strings: Vec<LocalizedStringEntry>,
406 enums: Vec<EnumDomain>,
407 by_id: [HashMap<String, usize>; Kind::NUM_KINDS],
408 alias_to_entry: HashMap<Locale, [HashMap<String, usize>; Kind::NUM_KINDS]>,
409 localized_string_by_id: HashMap<String, usize>,
410 localized_string_alias: HashMap<Locale, HashMap<String, usize>>,
411 enum_by_domain: HashMap<String, usize>,
412 enum_alias_to_domain: HashMap<Locale, HashMap<String, String>>,
413 enum_alias_to_member: HashMap<Locale, MemberIndexMap>,
414 bare_member_index: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
415}
416
417#[derive(Deserialize)]
418#[serde(rename_all = "camelCase")]
419struct CatalogFile {
420 schema_version: u32,
421 locales: Vec<String>,
422 target: TargetMeta,
423 provenance: Provenance,
424 #[serde(default)]
426 version: Option<String>,
427 #[serde(default)]
429 digest: Option<String>,
430 #[serde(default)]
431 structural: Vec<EntryFile>,
432 #[serde(default)]
433 actions: Vec<EntryFile>,
434 #[serde(default)]
435 values: Vec<EntryFile>,
436 #[serde(default)]
437 events: Vec<EntryFile>,
438 #[serde(default)]
439 operators: Vec<EntryFile>,
440 #[serde(default)]
441 settings: Vec<EntryFile>,
442 #[serde(default)]
443 localized_strings: Vec<LocalizedStringFile>,
444 #[serde(default)]
445 enums: Vec<EnumFile>,
446}
447
448#[derive(Deserialize)]
449#[serde(rename_all = "camelCase")]
450struct EntryFile {
451 id: String,
452 aliases: HashMap<String, AliasFile>,
453 #[serde(default)]
454 params: Vec<String>,
455 #[serde(default)]
458 param_names: Vec<String>,
459 #[serde(default)]
460 param_aliases: Vec<HashMap<String, AliasFile>>,
461 #[serde(default)]
464 param_domains: Vec<Option<String>>,
465 #[serde(default)]
472 param_defaults: Vec<Option<String>>,
473 #[serde(default)]
474 param_types: Vec<Option<String>>,
475 #[serde(default)]
476 param_coercions: Vec<Option<ParamCoercions>>,
477 #[serde(default)]
478 return_type: Option<String>,
479 #[serde(default)]
480 variadic: bool,
481}
482
483#[derive(Deserialize)]
484struct LocalizedStringFile {
485 id: String,
486 aliases: HashMap<String, AliasFile>,
487}
488
489#[derive(Deserialize)]
490struct EnumFile {
491 domain: String,
492 #[serde(default)]
493 aliases: HashMap<String, AliasFile>,
494 members: Vec<MemberFile>,
495}
496
497#[derive(Deserialize)]
498struct MemberFile {
499 id: String,
500 aliases: HashMap<String, AliasFile>,
501}
502
503#[derive(Debug, Deserialize)]
508#[serde(untagged)]
509enum AliasFile {
510 One(String),
511 Many(Vec<String>),
512}
513
514impl AliasFile {
515 fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
516 let spellings = match self {
517 AliasFile::One(spelling) => vec![spelling],
518 AliasFile::Many(spellings) => spellings,
519 };
520 if spellings.is_empty() || spellings.iter().any(String::is_empty) {
521 return Err(CatalogError::validation(format!(
522 "catalog entry '{}' declares an empty alias for locale '{}'",
523 id, locale
524 )));
525 }
526 Ok(spellings)
527 }
528}
529
530impl Catalog {
531 pub fn load(json: &str) -> Result<Catalog> {
534 let catalog = Self::load_unverified(json)?;
535 if let Some(declared) = &catalog.catalog_digest {
536 let computed = content_digest(json)?;
537 if declared != &computed {
538 return Err(CatalogError::validation(format!(
539 "catalog digest mismatch: declared '{declared}', content '{computed}' — \
540 run the catalog pipeline (workshop-catalog-gen build)"
541 )));
542 }
543 }
544 Ok(catalog)
545 }
546
547 pub fn load_unverified(json: &str) -> Result<Catalog> {
550 let file: CatalogFile = serde_json::from_str(json)
551 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
552 if file.schema_version != 1 {
553 return Err(CatalogError::malformed(format!(
554 "unsupported catalog schemaVersion {}",
555 file.schema_version
556 )));
557 }
558 let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
559 if locales.is_empty() {
560 return Err(CatalogError::malformed(
561 "catalog declares no locales".to_string(),
562 ));
563 }
564
565 let mut catalog = Catalog {
566 schema_version: file.schema_version,
567 locales,
568 target: file.target,
569 provenance: file.provenance,
570 catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
571 catalog_digest: file.digest,
572 entries: Vec::new(),
573 localized_strings: Vec::new(),
574 enums: Vec::new(),
575 by_id: Default::default(),
576 alias_to_entry: HashMap::new(),
577 localized_string_by_id: HashMap::new(),
578 localized_string_alias: HashMap::new(),
579 enum_by_domain: HashMap::new(),
580 enum_alias_to_domain: HashMap::new(),
581 enum_alias_to_member: HashMap::new(),
582 bare_member_index: HashMap::new(),
583 };
584
585 for (kind, items) in [
586 (Kind::Structural, file.structural),
587 (Kind::Action, file.actions),
588 (Kind::Value, file.values),
589 (Kind::Event, file.events),
590 (Kind::Operator, file.operators),
591 (Kind::Setting, file.settings),
592 ] {
593 for item in items {
594 catalog.insert_entry(kind, item)?;
595 }
596 }
597 for item in file.localized_strings {
598 catalog.insert_localized_string(item)?;
599 }
600 for domain in file.enums {
601 catalog.insert_enum(domain)?;
602 }
603 for domain in &catalog.enums {
604 for member in &domain.members {
605 for (locale, spellings) in &member.aliases {
606 for spelling in spellings {
607 catalog
608 .bare_member_index
609 .entry(locale.clone())
610 .or_default()
611 .entry(spelling.clone())
612 .or_default()
613 .push((domain.domain.clone(), member.member.clone()));
614 }
615 }
616 }
617 }
618 catalog.validate_param_domains()?;
619 Ok(catalog)
620 }
621
622 pub fn builtin() -> Result<Catalog> {
624 Self::load(CATALOG_DATA)
625 }
626
627 pub fn locales(&self) -> &[Locale] {
629 &self.locales
630 }
631
632 pub fn primary_locale(&self) -> &Locale {
635 &self.locales[0]
636 }
637
638 pub fn supports(&self, locale: &Locale) -> bool {
640 self.locales.contains(locale)
641 }
642
643 pub fn catalog_version(&self) -> &str {
645 &self.catalog_version
646 }
647
648 pub fn catalog_digest(&self) -> Option<&str> {
651 self.catalog_digest.as_deref()
652 }
653
654 pub fn implementation_version() -> &'static str {
656 env!("CARGO_PKG_VERSION")
657 }
658
659 pub fn identity(&self) -> CatalogIdentity {
662 CatalogIdentity {
663 implementation_version: Self::implementation_version().to_string(),
664 catalog_version: self.catalog_version.clone(),
665 catalog_digest: self.catalog_digest.clone(),
666 locale_coverage: self
667 .locales
668 .iter()
669 .map(|locale| self.locale_coverage(locale))
670 .collect(),
671 target: self.target.clone(),
672 provenance: self.provenance.clone(),
673 }
674 }
675
676 pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
681 let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
682 let total = self.entries.len() + self.localized_strings.len() + member_total;
683 let mapped = self
684 .entries
685 .iter()
686 .filter(|entry| entry.aliases.contains_key(locale))
687 .count()
688 + self
689 .localized_strings
690 .iter()
691 .filter(|entry| entry.aliases.contains_key(locale))
692 .count()
693 + self
694 .enums
695 .iter()
696 .flat_map(|domain| &domain.members)
697 .filter(|member| member.aliases.contains_key(locale))
698 .count();
699 LocaleCoverage {
700 locale: locale.clone(),
701 mapped,
702 total,
703 }
704 }
705
706 pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
708 self.locales
709 .iter()
710 .map(|locale| self.locale_coverage(locale))
711 .collect()
712 }
713
714 pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
716 self.by_id[kind.as_index()]
717 .get(id)
718 .map(|i| &self.entries[*i])
719 }
720
721 pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
723 self.alias_to_entry
724 .get(locale)
725 .and_then(|by_kind| by_kind[kind.as_index()].get(spelling))
726 .map(|i| &self.entries[*i])
727 }
728
729 pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
731 self.entry(kind, id)?.spelling(locale)
732 }
733
734 pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
736 self.entries.iter().filter(move |entry| entry.kind == kind)
737 }
738
739 pub fn resolve_localized_string(
741 &self,
742 locale: &Locale,
743 spelling: &str,
744 ) -> Option<&LocalizedStringEntry> {
745 self.localized_string_alias
746 .get(locale)
747 .and_then(|map| map.get(spelling))
748 .map(|index| &self.localized_strings[*index])
749 }
750
751 pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
753 self.localized_string_by_id
754 .get(id)
755 .and_then(|i| self.localized_strings.get(*i))
756 .and_then(|entry| entry.spelling(locale))
757 }
758
759 pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
761 self.localized_strings.iter()
762 }
763
764 pub fn entry_count(&self) -> usize {
766 self.entries.len()
767 }
768
769 pub fn enum_domains_count(&self) -> usize {
771 self.enums.len()
772 }
773
774 pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
776 self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
777 }
778
779 pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
781 self.enum_by_domain
782 .get_key_value(spelling)
783 .map(|(domain, _)| domain.as_str())
784 .or_else(|| {
785 self.enum_alias_to_domain
786 .get(locale)
787 .and_then(|map| map.get(spelling))
788 .map(String::as_str)
789 })
790 }
791
792 pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
794 self.enums.iter()
795 }
796
797 pub fn resolve_enum_member(
799 &self,
800 domain: &str,
801 locale: &Locale,
802 spelling: &str,
803 ) -> Option<(String, String)> {
804 let &(domain_index, member_index) = self
805 .enum_alias_to_member
806 .get(locale)?
807 .get(domain)?
808 .get(spelling)?;
809 Some((
810 domain.to_string(),
811 self.enums[domain_index].members[member_index]
812 .member
813 .clone(),
814 ))
815 }
816
817 pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
819 let domain_index = self.enum_by_domain.get(domain)?;
820 let domain = &self.enums[*domain_index];
821 domain
822 .members
823 .iter()
824 .find(|candidate| candidate.member == member)?
825 .spelling(locale)
826 }
827
828 pub fn localized_enum_spelling(
832 &self,
833 domain: &str,
834 locale: &Locale,
835 member: &str,
836 ) -> Option<&str> {
837 self.enum_spelling(domain, locale, member).or_else(|| {
838 (domain == "Color" && member == "WHITE").then_some(match locale.as_str() {
839 "de-de" => "Weiß",
840 "es-es" | "es-mx" => "Blanco",
841 "fr-fr" => "Blanc",
842 "it-it" => "Bianco",
843 "ja-jp" => "白",
844 "ko-kr" => "흰색",
845 "pl-pl" => "Biały",
846 "pt-br" => "Branco",
847 "ru-ru" => "Белый",
848 "th-th" => "สีขาว",
849 "tr-tr" => "Beyaz",
850 "zh-tw" => "白色",
851 _ => return None,
852 })
853 })
854 }
855
856 pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
861 self.bare_member_index
862 .get(locale)
863 .and_then(|map| map.get(spelling))
864 .cloned()
865 .unwrap_or_default()
866 }
867
868 fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
869 let index = self.entries.len();
870 let mut aliases = HashMap::new();
871 for (locale_str, alias_file) in item.aliases {
872 let locale = Locale::new(&locale_str);
873 if !self.locales.contains(&locale) {
874 return Err(CatalogError::validation(format!(
875 "entry '{}' declares alias for undeclared locale '{}'",
876 item.id, locale
877 )));
878 }
879 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
880 let locale_map = self.alias_to_entry.entry(locale.clone()).or_default();
881 for spelling in &spellings {
882 if locale_map[kind.as_index()].contains_key(spelling) {
883 return Err(CatalogError::validation(format!(
884 "duplicate {} alias '{spelling}' for locale '{}'",
885 kind.as_str(),
886 locale
887 )));
888 }
889 locale_map[kind.as_index()].insert(spelling.clone(), index);
890 }
891 aliases.insert(locale, spellings);
892 }
893 if self.by_id[kind.as_index()].contains_key(&item.id) {
894 return Err(CatalogError::validation(format!(
895 "duplicate {} id '{}'",
896 kind.as_str(),
897 item.id
898 )));
899 }
900 let primary = self.locales[0].clone();
905 if !aliases.contains_key(&primary) {
906 return Err(CatalogError::validation(format!(
907 "{} '{}' is missing a '{}' alias",
908 kind.as_str(),
909 item.id,
910 primary
911 )));
912 }
913 let param_names = if item.param_names.is_empty() {
914 item.params.clone()
915 } else {
916 item.param_names.clone()
917 };
918 if param_names.len() != item.params.len() {
919 return Err(CatalogError::validation(format!(
920 "{} '{}' declares {} param names for {} params",
921 kind.as_str(),
922 item.id,
923 param_names.len(),
924 item.params.len()
925 )));
926 }
927 self.by_id[kind.as_index()].insert(item.id.clone(), index);
928 let item_id = item.id.clone();
929 self.entries.push(CatalogEntry {
930 id: item.id,
931 kind,
932 params: item.params,
933 param_names,
934 param_aliases: item
935 .param_aliases
936 .into_iter()
937 .map(|aliases| {
938 aliases
939 .into_iter()
940 .map(|(locale, alias)| {
941 let locale_key = Locale::new(&locale);
942 let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
943 Ok((locale_key, spellings))
944 })
945 .collect::<Result<HashMap<_, _>>>()
946 })
947 .collect::<Result<Vec<_>>>()?,
948 param_domains: item.param_domains,
949 param_defaults: item.param_defaults,
950 param_types: item.param_types,
951 param_coercions: item.param_coercions,
952 return_type: item.return_type,
953 variadic: item.variadic,
954 aliases,
955 });
956 Ok(())
957 }
958
959 fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
960 if self.localized_string_by_id.contains_key(&item.id) {
961 return Err(CatalogError::validation(format!(
962 "duplicate localized string id '{}'",
963 item.id
964 )));
965 }
966 let index = self.localized_strings.len();
967 let mut aliases = HashMap::new();
968 for (locale_str, alias_file) in item.aliases {
969 let locale = Locale::new(&locale_str);
970 if !self.locales.contains(&locale) {
971 return Err(CatalogError::validation(format!(
972 "localized string '{}' declares alias for undeclared locale '{}'",
973 item.id, locale
974 )));
975 }
976 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
977 let locale_map = self
978 .localized_string_alias
979 .entry(locale.clone())
980 .or_default();
981 for spelling in &spellings {
982 if locale_map.contains_key(spelling) {
983 return Err(CatalogError::validation(format!(
984 "duplicate localized string alias '{spelling}' for locale '{locale}'"
985 )));
986 }
987 locale_map.insert(spelling.clone(), index);
988 }
989 aliases.insert(locale, spellings);
990 }
991 let primary = self.locales[0].clone();
992 if !aliases.contains_key(&primary) {
993 return Err(CatalogError::validation(format!(
994 "localized string '{}' is missing a '{}' alias",
995 item.id, primary
996 )));
997 }
998 self.localized_string_by_id.insert(item.id.clone(), index);
999 self.localized_strings.push(LocalizedStringEntry {
1000 id: item.id,
1001 aliases,
1002 });
1003 Ok(())
1004 }
1005
1006 fn validate_param_domains(&self) -> Result<()> {
1008 for entry in &self.entries {
1009 if entry.param_names.len() != entry.params.len() {
1010 return Err(CatalogError::validation(format!(
1011 "{} '{}' declares param names that do not match params",
1012 entry.kind.as_str(),
1013 entry.id
1014 )));
1015 }
1016 if entry.param_aliases.len() > entry.params.len() {
1017 return Err(CatalogError::validation(format!(
1018 "{} '{}' declares more parameter alias sets than params",
1019 entry.kind.as_str(),
1020 entry.id
1021 )));
1022 }
1023 if entry.param_domains.len() > entry.params.len() {
1024 return Err(CatalogError::validation(format!(
1025 "{} '{}' declares more param domains than params",
1026 entry.kind.as_str(),
1027 entry.id
1028 )));
1029 }
1030 if entry.param_defaults.len() > entry.params.len() {
1031 return Err(CatalogError::validation(format!(
1032 "{} '{}' declares more param defaults than params",
1033 entry.kind.as_str(),
1034 entry.id
1035 )));
1036 }
1037 if entry.param_types.len() > entry.params.len() {
1038 return Err(CatalogError::validation(format!(
1039 "{} '{}' declares more param types than params",
1040 entry.kind.as_str(),
1041 entry.id
1042 )));
1043 }
1044 if entry.param_coercions.len() > entry.params.len() {
1045 return Err(CatalogError::validation(format!(
1046 "{} '{}' declares more param coercions than params",
1047 entry.kind.as_str(),
1048 entry.id
1049 )));
1050 }
1051 if entry.kind != Kind::Value && entry.return_type.is_some() {
1052 return Err(CatalogError::validation(format!(
1053 "{} '{}' declares a return type but is not a value",
1054 entry.kind.as_str(),
1055 entry.id
1056 )));
1057 }
1058 for domain in entry.param_domains.iter().flatten() {
1059 if !self.enum_by_domain.contains_key(domain) {
1060 return Err(CatalogError::validation(format!(
1061 "{} '{}' declares undeclared enum domain '{domain}'",
1062 entry.kind.as_str(),
1063 entry.id
1064 )));
1065 }
1066 }
1067 for aliases in &entry.param_aliases {
1068 for locale in aliases.keys() {
1069 if !self.locales.contains(locale) {
1070 return Err(CatalogError::validation(format!(
1071 "{} '{}' declares parameter alias for undeclared locale '{}'",
1072 entry.kind.as_str(),
1073 entry.id,
1074 locale
1075 )));
1076 }
1077 }
1078 }
1079 }
1080 Ok(())
1081 }
1082
1083 fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
1084 let domain_index = self.enums.len();
1085 if self.enum_by_domain.contains_key(&domain.domain) {
1086 return Err(CatalogError::validation(format!(
1087 "duplicate enum domain '{}'",
1088 domain.domain
1089 )));
1090 }
1091 let primary = self.locales[0].clone();
1092 let mut domain_aliases = HashMap::new();
1093 for (locale_str, alias_file) in domain.aliases {
1094 let locale = Locale::new(&locale_str);
1095 if !self.locales.contains(&locale) {
1096 return Err(CatalogError::validation(format!(
1097 "enum domain '{}' declares alias for undeclared locale '{}'",
1098 domain.domain, locale
1099 )));
1100 }
1101 let spellings = alias_file.into_spellings(&domain.domain, locale.as_str())?;
1102 let locale_map = self.enum_alias_to_domain.entry(locale.clone()).or_default();
1103 for spelling in &spellings {
1104 if let Some(existing) = locale_map.get(spelling) {
1105 return Err(CatalogError::validation(format!(
1106 "duplicate enum domain alias '{spelling}' for '{}' and '{}' in locale '{}'",
1107 existing, domain.domain, locale
1108 )));
1109 }
1110 locale_map.insert(spelling.clone(), domain.domain.clone());
1111 }
1112 domain_aliases.insert(locale, spellings);
1113 }
1114 domain_aliases
1115 .entry(primary.clone())
1116 .or_insert_with(|| vec![domain.domain.clone()]);
1117 self.enum_alias_to_domain
1118 .entry(primary.clone())
1119 .or_default()
1120 .entry(domain.domain.clone())
1121 .or_insert_with(|| domain.domain.clone());
1122 let mut members = Vec::new();
1123 for (member_index, member) in domain.members.into_iter().enumerate() {
1124 let mut aliases = HashMap::new();
1125 for (locale_str, alias_file) in member.aliases {
1126 let locale = Locale::new(&locale_str);
1127 if !self.locales.contains(&locale) {
1128 return Err(CatalogError::validation(format!(
1129 "enum {}::{} declares alias for undeclared locale '{}'",
1130 domain.domain, member.id, locale
1131 )));
1132 }
1133 let spellings = alias_file.into_spellings(&member.id, locale.as_str())?;
1134 let locale_map = self.enum_alias_to_member.entry(locale.clone()).or_default();
1135 let domain_map = locale_map.entry(domain.domain.clone()).or_default();
1136 for spelling in &spellings {
1137 if domain_map.contains_key(spelling) {
1138 return Err(CatalogError::validation(format!(
1139 "duplicate enum alias '{spelling}' in '{}' for locale '{}'",
1140 domain.domain, locale
1141 )));
1142 }
1143 domain_map.insert(spelling.clone(), (domain_index, member_index));
1144 }
1145 aliases.insert(locale, spellings);
1146 }
1147 if !aliases.contains_key(&primary) {
1148 return Err(CatalogError::validation(format!(
1149 "enum {}::{} is missing a '{}' alias",
1150 domain.domain, member.id, primary
1151 )));
1152 }
1153 members.push(EnumMember {
1154 member: member.id,
1155 aliases,
1156 });
1157 }
1158 self.enum_by_domain
1159 .insert(domain.domain.clone(), domain_index);
1160 self.enums.push(EnumDomain {
1161 domain: domain.domain,
1162 aliases: domain_aliases,
1163 members,
1164 });
1165 Ok(())
1166 }
1167}
1168
1169impl ExpectedDomain for Catalog {
1176 fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
1177 for kind in [Kind::Action, Kind::Value] {
1178 if let Some(entry) = self.entry(kind, catalog_id) {
1179 if let Some(domain) = entry
1180 .param_domains
1181 .get(arg_index)
1182 .and_then(Option::as_deref)
1183 {
1184 return Some(domain);
1185 }
1186 }
1187 }
1188 None
1189 }
1190}
1191
1192pub fn canonicalize(json: &str) -> Result<String> {
1198 Catalog::load_unverified(json)?;
1200 let value: serde_json::Value = serde_json::from_str(json)
1201 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1202 serde_json::to_string_pretty(&value)
1203 .map(|mut out| {
1204 out.push('\n');
1205 out
1206 })
1207 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
1208}
1209
1210pub fn build_canonical(json: &str) -> Result<String> {
1214 let mut value: serde_json::Value = serde_json::from_str(json)
1215 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1216 let digest = content_digest(json)?;
1217 if let Some(object) = value.as_object_mut() {
1218 object.insert("digest".to_string(), serde_json::Value::String(digest));
1219 }
1220 let output = serde_json::to_string_pretty(&value)
1221 .map(|mut out| {
1222 out.push('\n');
1223 out
1224 })
1225 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1226 Catalog::load(&output)?;
1229 Ok(output)
1230}
1231
1232pub fn content_digest(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 if let Some(object) = value.as_object_mut() {
1240 object.remove("digest");
1241 }
1242 let canonical = serde_json::to_string_pretty(&value)
1243 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1244 use sha2::{Digest, Sha256};
1245 let mut hasher = Sha256::new();
1246 hasher.update(canonical.as_bytes());
1247 Ok(format!("{:x}", hasher.finalize()))
1248}