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")]
114#[non_exhaustive]
115pub struct ParamCoercions {
116 #[serde(default)]
118 pub false_as_number: bool,
119 #[serde(default)]
121 pub true_as_number: bool,
122 #[serde(default)]
124 pub zero_as_null: bool,
125 #[serde(default)]
127 pub null_vector_as_null: bool,
128 #[serde(default)]
130 pub empty_array_as_string: bool,
131}
132
133#[derive(Debug, Clone)]
135pub struct CatalogEntry {
136 pub id: String,
137 pub kind: Kind,
138 params: Vec<String>,
140 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)]
355#[non_exhaustive]
356pub struct TargetMeta {
357 pub game: String,
358 pub format: String,
359 pub surface: String,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
364#[serde(rename_all = "camelCase")]
365#[non_exhaustive]
366pub struct Provenance {
367 pub generator: String,
368 pub generator_version: String,
369 pub source: String,
370 pub license: String,
371 pub reviewed: bool,
372 #[serde(default, skip_serializing_if = "Vec::is_empty")]
375 pub source_notes: Vec<String>,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
382#[non_exhaustive]
383pub struct LocaleCoverage {
384 pub locale: Locale,
385 pub mapped: usize,
387 pub total: usize,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
397#[serde(rename_all = "kebab-case")]
398#[non_exhaustive]
399pub struct CatalogIdentity {
400 pub implementation_version: String,
402 pub catalog_version: String,
404 pub catalog_digest: Option<String>,
407 pub locale_coverage: Vec<LocaleCoverage>,
409 pub target: TargetMeta,
411 pub provenance: Provenance,
413}
414
415type MemberIndexMap = HashMap<String, HashMap<String, (usize, usize)>>;
416
417#[derive(Debug, Clone)]
419pub struct Catalog {
420 schema_version: u32,
421 locales: Vec<Locale>,
424 target: TargetMeta,
425 provenance: Provenance,
426 catalog_version: String,
428 catalog_digest: Option<String>,
431 entries: Vec<CatalogEntry>,
432 localized_strings: Vec<LocalizedStringEntry>,
433 enums: Vec<EnumDomain>,
434 by_id: [HashMap<String, usize>; Kind::NUM_KINDS],
435 alias_to_entry: HashMap<Locale, [HashMap<String, usize>; Kind::NUM_KINDS]>,
436 localized_string_by_id: HashMap<String, usize>,
437 localized_string_alias: HashMap<Locale, HashMap<String, usize>>,
438 enum_by_domain: HashMap<String, usize>,
439 enum_alias_to_domain: HashMap<Locale, HashMap<String, String>>,
440 enum_alias_to_member: HashMap<Locale, MemberIndexMap>,
441 bare_member_index: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
442}
443
444#[derive(Deserialize)]
445#[serde(rename_all = "camelCase")]
446struct CatalogFile {
447 schema_version: u32,
448 locales: Vec<String>,
449 target: TargetMeta,
450 provenance: Provenance,
451 #[serde(default)]
453 version: Option<String>,
454 #[serde(default)]
456 digest: Option<String>,
457 #[serde(default)]
458 structural: Vec<EntryFile>,
459 #[serde(default)]
460 actions: Vec<EntryFile>,
461 #[serde(default)]
462 values: Vec<EntryFile>,
463 #[serde(default)]
464 events: Vec<EntryFile>,
465 #[serde(default)]
466 operators: Vec<EntryFile>,
467 #[serde(default)]
468 settings: Vec<EntryFile>,
469 #[serde(default)]
470 localized_strings: Vec<LocalizedStringFile>,
471 #[serde(default)]
472 enums: Vec<EnumFile>,
473}
474
475#[derive(Deserialize)]
476#[serde(rename_all = "camelCase")]
477struct EntryFile {
478 id: String,
479 aliases: HashMap<String, AliasFile>,
480 #[serde(default)]
481 params: Vec<String>,
482 #[serde(default)]
484 param_names: Vec<String>,
485 #[serde(default)]
486 param_aliases: Vec<HashMap<String, AliasFile>>,
487 #[serde(default)]
490 param_domains: Vec<Option<String>>,
491 #[serde(default)]
498 param_defaults: Vec<Option<String>>,
499 #[serde(default)]
500 param_types: Vec<Option<String>>,
501 #[serde(default)]
502 param_coercions: Vec<Option<ParamCoercions>>,
503 #[serde(default)]
504 return_type: Option<String>,
505 #[serde(default)]
506 variadic: bool,
507}
508
509#[derive(Deserialize)]
510struct LocalizedStringFile {
511 id: String,
512 aliases: HashMap<String, AliasFile>,
513}
514
515#[derive(Deserialize)]
516struct EnumFile {
517 domain: String,
518 #[serde(default)]
519 aliases: HashMap<String, AliasFile>,
520 members: Vec<MemberFile>,
521}
522
523#[derive(Deserialize)]
524struct MemberFile {
525 id: String,
526 aliases: HashMap<String, AliasFile>,
527}
528
529#[derive(Debug, Deserialize)]
534#[serde(untagged)]
535enum AliasFile {
536 One(String),
537 Many(Vec<String>),
538}
539
540impl AliasFile {
541 fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
542 let spellings = match self {
543 AliasFile::One(spelling) => vec![spelling],
544 AliasFile::Many(spellings) => spellings,
545 };
546 if spellings.is_empty() || spellings.iter().any(String::is_empty) {
547 return Err(CatalogError::validation(format!(
548 "catalog entry '{}' declares an empty alias for locale '{}'",
549 id, locale
550 )));
551 }
552 Ok(spellings)
553 }
554}
555
556impl Catalog {
557 pub fn load(json: &str) -> Result<Catalog> {
560 let catalog = Self::load_unverified(json)?;
561 if let Some(declared) = &catalog.catalog_digest {
562 let computed = content_digest(json)?;
563 if declared != &computed {
564 return Err(CatalogError::validation(format!(
565 "catalog digest mismatch: declared '{declared}', content '{computed}' — \
566 run the catalog pipeline (workshop-catalog-gen build)"
567 )));
568 }
569 }
570 Ok(catalog)
571 }
572
573 pub fn load_unverified(json: &str) -> Result<Catalog> {
576 let file: CatalogFile = serde_json::from_str(json)
577 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
578 if file.schema_version != 1 {
579 return Err(CatalogError::malformed(format!(
580 "unsupported catalog schemaVersion {}",
581 file.schema_version
582 )));
583 }
584 let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
585 if locales.is_empty() {
586 return Err(CatalogError::malformed(
587 "catalog declares no locales".to_string(),
588 ));
589 }
590
591 let mut catalog = Catalog {
592 schema_version: file.schema_version,
593 locales,
594 target: file.target,
595 provenance: file.provenance,
596 catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
597 catalog_digest: file.digest,
598 entries: Vec::new(),
599 localized_strings: Vec::new(),
600 enums: Vec::new(),
601 by_id: Default::default(),
602 alias_to_entry: HashMap::new(),
603 localized_string_by_id: HashMap::new(),
604 localized_string_alias: HashMap::new(),
605 enum_by_domain: HashMap::new(),
606 enum_alias_to_domain: HashMap::new(),
607 enum_alias_to_member: HashMap::new(),
608 bare_member_index: HashMap::new(),
609 };
610
611 for (kind, items) in [
612 (Kind::Structural, file.structural),
613 (Kind::Action, file.actions),
614 (Kind::Value, file.values),
615 (Kind::Event, file.events),
616 (Kind::Operator, file.operators),
617 (Kind::Setting, file.settings),
618 ] {
619 for item in items {
620 catalog.insert_entry(kind, item)?;
621 }
622 }
623 for item in file.localized_strings {
624 catalog.insert_localized_string(item)?;
625 }
626 for domain in file.enums {
627 catalog.insert_enum(domain)?;
628 }
629 for domain in &catalog.enums {
630 for member in &domain.members {
631 for (locale, spellings) in &member.aliases {
632 for spelling in spellings {
633 catalog
634 .bare_member_index
635 .entry(locale.clone())
636 .or_default()
637 .entry(spelling.clone())
638 .or_default()
639 .push((domain.domain.clone(), member.member.clone()));
640 }
641 }
642 }
643 }
644 catalog.validate_param_domains()?;
645 Ok(catalog)
646 }
647
648 pub fn builtin() -> Result<Catalog> {
650 Self::load(CATALOG_DATA)
651 }
652
653 pub(crate) fn schema_version(&self) -> u32 {
654 self.schema_version
655 }
656
657 pub fn locales(&self) -> &[Locale] {
659 &self.locales
660 }
661
662 pub fn primary_locale(&self) -> &Locale {
665 &self.locales[0]
666 }
667
668 pub fn supports(&self, locale: &Locale) -> bool {
670 self.locales.contains(locale)
671 }
672
673 pub fn catalog_version(&self) -> &str {
675 &self.catalog_version
676 }
677
678 pub fn catalog_digest(&self) -> Option<&str> {
681 self.catalog_digest.as_deref()
682 }
683
684 pub fn implementation_version() -> &'static str {
686 env!("CARGO_PKG_VERSION")
687 }
688
689 pub fn identity(&self) -> CatalogIdentity {
692 CatalogIdentity {
693 implementation_version: Self::implementation_version().to_string(),
694 catalog_version: self.catalog_version.clone(),
695 catalog_digest: self.catalog_digest.clone(),
696 locale_coverage: self
697 .locales
698 .iter()
699 .map(|locale| self.locale_coverage(locale))
700 .collect(),
701 target: self.target.clone(),
702 provenance: self.provenance.clone(),
703 }
704 }
705
706 pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
711 let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
712 let total = self.entries.len() + self.localized_strings.len() + member_total;
713 let mapped = self
714 .entries
715 .iter()
716 .filter(|entry| entry.aliases.contains_key(locale))
717 .count()
718 + self
719 .localized_strings
720 .iter()
721 .filter(|entry| entry.aliases.contains_key(locale))
722 .count()
723 + self
724 .enums
725 .iter()
726 .flat_map(|domain| &domain.members)
727 .filter(|member| member.aliases.contains_key(locale))
728 .count();
729 LocaleCoverage {
730 locale: locale.clone(),
731 mapped,
732 total,
733 }
734 }
735
736 pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
738 self.locales
739 .iter()
740 .map(|locale| self.locale_coverage(locale))
741 .collect()
742 }
743
744 pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
746 self.by_id[kind.as_index()]
747 .get(id)
748 .map(|i| &self.entries[*i])
749 }
750
751 pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
753 self.alias_to_entry
754 .get(locale)
755 .and_then(|by_kind| by_kind[kind.as_index()].get(spelling))
756 .map(|i| &self.entries[*i])
757 }
758
759 pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
761 self.entry(kind, id)?.spelling(locale)
762 }
763
764 pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
766 self.entries.iter().filter(move |entry| entry.kind == kind)
767 }
768
769 pub fn resolve_localized_string(
771 &self,
772 locale: &Locale,
773 spelling: &str,
774 ) -> Option<&LocalizedStringEntry> {
775 self.localized_string_alias
776 .get(locale)
777 .and_then(|map| map.get(spelling))
778 .map(|index| &self.localized_strings[*index])
779 }
780
781 pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
783 self.localized_string_by_id
784 .get(id)
785 .and_then(|i| self.localized_strings.get(*i))
786 .and_then(|entry| entry.spelling(locale))
787 }
788
789 pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
791 self.localized_strings.iter()
792 }
793
794 pub fn entry_count(&self) -> usize {
796 self.entries.len()
797 }
798
799 pub fn enum_domains_count(&self) -> usize {
801 self.enums.len()
802 }
803
804 pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
806 self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
807 }
808
809 pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
811 self.enum_by_domain
812 .get_key_value(spelling)
813 .map(|(domain, _)| domain.as_str())
814 .or_else(|| {
815 self.enum_alias_to_domain
816 .get(locale)
817 .and_then(|map| map.get(spelling))
818 .map(String::as_str)
819 })
820 }
821
822 pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
824 self.enums.iter()
825 }
826
827 pub fn resolve_enum_member(
829 &self,
830 domain: &str,
831 locale: &Locale,
832 spelling: &str,
833 ) -> Option<(String, String)> {
834 let &(domain_index, member_index) = self
835 .enum_alias_to_member
836 .get(locale)?
837 .get(domain)?
838 .get(spelling)?;
839 Some((
840 domain.to_string(),
841 self.enums[domain_index].members[member_index]
842 .member
843 .clone(),
844 ))
845 }
846
847 pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
849 let domain_index = self.enum_by_domain.get(domain)?;
850 let domain = &self.enums[*domain_index];
851 domain
852 .members
853 .iter()
854 .find(|candidate| candidate.member == member)?
855 .spelling(locale)
856 }
857
858 pub fn localized_enum_spelling(
862 &self,
863 domain: &str,
864 locale: &Locale,
865 member: &str,
866 ) -> Option<&str> {
867 self.enum_spelling(domain, locale, member).or_else(|| {
868 (domain == "Color" && member == "WHITE").then_some(match locale.as_str() {
869 "de-de" => "Weiß",
870 "es-es" | "es-mx" => "Blanco",
871 "fr-fr" => "Blanc",
872 "it-it" => "Bianco",
873 "ja-jp" => "白",
874 "ko-kr" => "흰색",
875 "pl-pl" => "Biały",
876 "pt-br" => "Branco",
877 "ru-ru" => "Белый",
878 "th-th" => "สีขาว",
879 "tr-tr" => "Beyaz",
880 "zh-tw" => "白色",
881 _ => return None,
882 })
883 })
884 }
885
886 pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
891 self.bare_member_index
892 .get(locale)
893 .and_then(|map| map.get(spelling))
894 .cloned()
895 .unwrap_or_default()
896 }
897
898 fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
899 let index = self.entries.len();
900 let mut aliases = HashMap::new();
901 for (locale_str, alias_file) in item.aliases {
902 let locale = Locale::new(&locale_str);
903 if !self.locales.contains(&locale) {
904 return Err(CatalogError::validation(format!(
905 "entry '{}' declares alias for undeclared locale '{}'",
906 item.id, locale
907 )));
908 }
909 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
910 let locale_map = self.alias_to_entry.entry(locale.clone()).or_default();
911 for spelling in &spellings {
912 if locale_map[kind.as_index()].contains_key(spelling) {
913 return Err(CatalogError::validation(format!(
914 "duplicate {} alias '{spelling}' for locale '{}'",
915 kind.as_str(),
916 locale
917 )));
918 }
919 locale_map[kind.as_index()].insert(spelling.clone(), index);
920 }
921 aliases.insert(locale, spellings);
922 }
923 if self.by_id[kind.as_index()].contains_key(&item.id) {
924 return Err(CatalogError::validation(format!(
925 "duplicate {} id '{}'",
926 kind.as_str(),
927 item.id
928 )));
929 }
930 let primary = self.locales[0].clone();
935 if !aliases.contains_key(&primary) {
936 return Err(CatalogError::validation(format!(
937 "{} '{}' is missing a '{}' alias",
938 kind.as_str(),
939 item.id,
940 primary
941 )));
942 }
943 let param_names = if item.param_names.is_empty() {
944 item.params.clone()
945 } else {
946 item.param_names.clone()
947 };
948 if param_names.len() != item.params.len() {
949 return Err(CatalogError::validation(format!(
950 "{} '{}' declares {} param names for {} params",
951 kind.as_str(),
952 item.id,
953 param_names.len(),
954 item.params.len()
955 )));
956 }
957 self.by_id[kind.as_index()].insert(item.id.clone(), index);
958 let item_id = item.id.clone();
959 self.entries.push(CatalogEntry {
960 id: item.id,
961 kind,
962 params: item.params,
963 param_names,
964 param_aliases: item
965 .param_aliases
966 .into_iter()
967 .map(|aliases| {
968 aliases
969 .into_iter()
970 .map(|(locale, alias)| {
971 let locale_key = Locale::new(&locale);
972 let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
973 Ok((locale_key, spellings))
974 })
975 .collect::<Result<HashMap<_, _>>>()
976 })
977 .collect::<Result<Vec<_>>>()?,
978 param_domains: item.param_domains,
979 param_defaults: item.param_defaults,
980 param_types: item.param_types,
981 param_coercions: item.param_coercions,
982 return_type: item.return_type,
983 variadic: item.variadic,
984 aliases,
985 });
986 Ok(())
987 }
988
989 fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
990 if self.localized_string_by_id.contains_key(&item.id) {
991 return Err(CatalogError::validation(format!(
992 "duplicate localized string id '{}'",
993 item.id
994 )));
995 }
996 let index = self.localized_strings.len();
997 let mut aliases = HashMap::new();
998 for (locale_str, alias_file) in item.aliases {
999 let locale = Locale::new(&locale_str);
1000 if !self.locales.contains(&locale) {
1001 return Err(CatalogError::validation(format!(
1002 "localized string '{}' declares alias for undeclared locale '{}'",
1003 item.id, locale
1004 )));
1005 }
1006 let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
1007 let locale_map = self
1008 .localized_string_alias
1009 .entry(locale.clone())
1010 .or_default();
1011 for spelling in &spellings {
1012 if locale_map.contains_key(spelling) {
1013 return Err(CatalogError::validation(format!(
1014 "duplicate localized string alias '{spelling}' for locale '{locale}'"
1015 )));
1016 }
1017 locale_map.insert(spelling.clone(), index);
1018 }
1019 aliases.insert(locale, spellings);
1020 }
1021 let primary = self.locales[0].clone();
1022 if !aliases.contains_key(&primary) {
1023 return Err(CatalogError::validation(format!(
1024 "localized string '{}' is missing a '{}' alias",
1025 item.id, primary
1026 )));
1027 }
1028 self.localized_string_by_id.insert(item.id.clone(), index);
1029 self.localized_strings.push(LocalizedStringEntry {
1030 id: item.id,
1031 aliases,
1032 });
1033 Ok(())
1034 }
1035
1036 fn validate_param_domains(&self) -> Result<()> {
1038 for entry in &self.entries {
1039 if entry.param_names.len() != entry.params.len() {
1040 return Err(CatalogError::validation(format!(
1041 "{} '{}' declares param names that do not match params",
1042 entry.kind.as_str(),
1043 entry.id
1044 )));
1045 }
1046 if entry.param_aliases.len() > entry.params.len() {
1047 return Err(CatalogError::validation(format!(
1048 "{} '{}' declares more parameter alias sets than params",
1049 entry.kind.as_str(),
1050 entry.id
1051 )));
1052 }
1053 if entry.param_domains.len() > entry.params.len() {
1054 return Err(CatalogError::validation(format!(
1055 "{} '{}' declares more param domains than params",
1056 entry.kind.as_str(),
1057 entry.id
1058 )));
1059 }
1060 if entry.param_defaults.len() > entry.params.len() {
1061 return Err(CatalogError::validation(format!(
1062 "{} '{}' declares more param defaults than params",
1063 entry.kind.as_str(),
1064 entry.id
1065 )));
1066 }
1067 if entry.param_types.len() > entry.params.len() {
1068 return Err(CatalogError::validation(format!(
1069 "{} '{}' declares more param types than params",
1070 entry.kind.as_str(),
1071 entry.id
1072 )));
1073 }
1074 if entry.param_coercions.len() > entry.params.len() {
1075 return Err(CatalogError::validation(format!(
1076 "{} '{}' declares more param coercions than params",
1077 entry.kind.as_str(),
1078 entry.id
1079 )));
1080 }
1081 if entry.kind != Kind::Value && entry.return_type.is_some() {
1082 return Err(CatalogError::validation(format!(
1083 "{} '{}' declares a return type but is not a value",
1084 entry.kind.as_str(),
1085 entry.id
1086 )));
1087 }
1088 for domain in entry.param_domains.iter().flatten() {
1089 if !self.enum_by_domain.contains_key(domain) {
1090 return Err(CatalogError::validation(format!(
1091 "{} '{}' declares undeclared enum domain '{domain}'",
1092 entry.kind.as_str(),
1093 entry.id
1094 )));
1095 }
1096 }
1097 for aliases in &entry.param_aliases {
1098 for locale in aliases.keys() {
1099 if !self.locales.contains(locale) {
1100 return Err(CatalogError::validation(format!(
1101 "{} '{}' declares parameter alias for undeclared locale '{}'",
1102 entry.kind.as_str(),
1103 entry.id,
1104 locale
1105 )));
1106 }
1107 }
1108 }
1109 }
1110 Ok(())
1111 }
1112
1113 fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
1114 let domain_index = self.enums.len();
1115 if self.enum_by_domain.contains_key(&domain.domain) {
1116 return Err(CatalogError::validation(format!(
1117 "duplicate enum domain '{}'",
1118 domain.domain
1119 )));
1120 }
1121 let primary = self.locales[0].clone();
1122 let mut domain_aliases = HashMap::new();
1123 for (locale_str, alias_file) in domain.aliases {
1124 let locale = Locale::new(&locale_str);
1125 if !self.locales.contains(&locale) {
1126 return Err(CatalogError::validation(format!(
1127 "enum domain '{}' declares alias for undeclared locale '{}'",
1128 domain.domain, locale
1129 )));
1130 }
1131 let spellings = alias_file.into_spellings(&domain.domain, locale.as_str())?;
1132 let locale_map = self.enum_alias_to_domain.entry(locale.clone()).or_default();
1133 for spelling in &spellings {
1134 if let Some(existing) = locale_map.get(spelling) {
1135 return Err(CatalogError::validation(format!(
1136 "duplicate enum domain alias '{spelling}' for '{}' and '{}' in locale '{}'",
1137 existing, domain.domain, locale
1138 )));
1139 }
1140 locale_map.insert(spelling.clone(), domain.domain.clone());
1141 }
1142 domain_aliases.insert(locale, spellings);
1143 }
1144 domain_aliases
1145 .entry(primary.clone())
1146 .or_insert_with(|| vec![domain.domain.clone()]);
1147 self.enum_alias_to_domain
1148 .entry(primary.clone())
1149 .or_default()
1150 .entry(domain.domain.clone())
1151 .or_insert_with(|| domain.domain.clone());
1152 let mut members = Vec::new();
1153 for (member_index, member) in domain.members.into_iter().enumerate() {
1154 let mut aliases = HashMap::new();
1155 for (locale_str, alias_file) in member.aliases {
1156 let locale = Locale::new(&locale_str);
1157 if !self.locales.contains(&locale) {
1158 return Err(CatalogError::validation(format!(
1159 "enum {}::{} declares alias for undeclared locale '{}'",
1160 domain.domain, member.id, locale
1161 )));
1162 }
1163 let spellings = alias_file.into_spellings(&member.id, locale.as_str())?;
1164 let locale_map = self.enum_alias_to_member.entry(locale.clone()).or_default();
1165 let domain_map = locale_map.entry(domain.domain.clone()).or_default();
1166 for spelling in &spellings {
1167 if domain_map.contains_key(spelling) {
1168 return Err(CatalogError::validation(format!(
1169 "duplicate enum alias '{spelling}' in '{}' for locale '{}'",
1170 domain.domain, locale
1171 )));
1172 }
1173 domain_map.insert(spelling.clone(), (domain_index, member_index));
1174 }
1175 aliases.insert(locale, spellings);
1176 }
1177 if !aliases.contains_key(&primary) {
1178 return Err(CatalogError::validation(format!(
1179 "enum {}::{} is missing a '{}' alias",
1180 domain.domain, member.id, primary
1181 )));
1182 }
1183 members.push(EnumMember {
1184 member: member.id,
1185 aliases,
1186 });
1187 }
1188 self.enum_by_domain
1189 .insert(domain.domain.clone(), domain_index);
1190 self.enums.push(EnumDomain {
1191 domain: domain.domain,
1192 aliases: domain_aliases,
1193 members,
1194 });
1195 Ok(())
1196 }
1197}
1198
1199impl ExpectedDomain for Catalog {
1206 fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
1207 for kind in [Kind::Action, Kind::Value] {
1208 if let Some(entry) = self.entry(kind, catalog_id) {
1209 if let Some(domain) = entry.param_domain(arg_index) {
1210 return Some(domain);
1211 }
1212 }
1213 }
1214 None
1215 }
1216}
1217
1218pub fn canonicalize(json: &str) -> Result<String> {
1224 Catalog::load_unverified(json)?;
1226 let value: serde_json::Value = serde_json::from_str(json)
1227 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1228 serde_json::to_string_pretty(&value)
1229 .map(|mut out| {
1230 out.push('\n');
1231 out
1232 })
1233 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
1234}
1235
1236pub fn build_canonical(json: &str) -> Result<String> {
1240 let mut value: serde_json::Value = serde_json::from_str(json)
1241 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1242 let digest = content_digest(json)?;
1243 if let Some(object) = value.as_object_mut() {
1244 object.insert("digest".to_string(), serde_json::Value::String(digest));
1245 }
1246 let output = serde_json::to_string_pretty(&value)
1247 .map(|mut out| {
1248 out.push('\n');
1249 out
1250 })
1251 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1252 Catalog::load(&output)?;
1255 Ok(output)
1256}
1257
1258pub fn content_digest(json: &str) -> Result<String> {
1263 let mut value: serde_json::Value = serde_json::from_str(json)
1264 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1265 if let Some(object) = value.as_object_mut() {
1266 object.remove("digest");
1267 }
1268 let canonical = serde_json::to_string_pretty(&value)
1269 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1270 use sha2::{Digest, Sha256};
1271 let mut hasher = Sha256::new();
1272 hasher.update(canonical.as_bytes());
1273 Ok(format!("{:x}", hasher.finalize()))
1274}