1use std::collections::{BTreeMap, HashMap};
2
3use serde::Deserialize;
4
5use crate::core::error::{CatalogError, Result};
6
7use super::{
8 CATALOG_DATA, Catalog, CatalogEntry, EnumDomain, EnumMember, Kind, Locale,
9 LocalizedStringEntry, NormalizedIndex, ParamCoercions, Provenance, TargetMeta,
10 normalize_spelling,
11};
12
13#[derive(Deserialize)]
14#[serde(rename_all = "camelCase")]
15struct CatalogFile {
16 schema_version: u32,
17 locales: Vec<String>,
18 target: TargetMeta,
19 provenance: Provenance,
20 #[serde(default)]
21 version: Option<String>,
22 #[serde(default)]
23 digest: Option<String>,
24 #[serde(default)]
25 structural: Vec<EntryFile>,
26 #[serde(default)]
27 actions: Vec<EntryFile>,
28 #[serde(default)]
29 values: Vec<EntryFile>,
30 #[serde(default)]
31 events: Vec<EntryFile>,
32 #[serde(default)]
33 operators: Vec<EntryFile>,
34 #[serde(default)]
35 settings: Vec<EntryFile>,
36 #[serde(default)]
37 localized_strings: Vec<LocalizedStringFile>,
38 #[serde(default)]
39 enums: Vec<EnumFile>,
40}
41
42#[derive(Deserialize)]
43#[serde(rename_all = "camelCase")]
44struct EntryFile {
45 id: String,
46 aliases: HashMap<String, AliasFile>,
47 #[serde(default)]
48 params: Vec<String>,
49 #[serde(default)]
51 param_names: Vec<String>,
52 #[serde(default)]
53 param_aliases: Vec<HashMap<String, AliasFile>>,
54 #[serde(default)]
57 param_domains: Vec<Option<String>>,
58 #[serde(default)]
65 param_defaults: Vec<Option<String>>,
66 #[serde(default)]
67 param_types: Vec<Option<String>>,
68 #[serde(default)]
69 param_coercions: Vec<Option<ParamCoercions>>,
70 #[serde(default)]
71 return_type: Option<String>,
72 #[serde(default)]
78 reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
79 #[serde(default)]
80 variadic: bool,
81}
82
83#[derive(Deserialize)]
84struct LocalizedStringFile {
85 id: String,
86 aliases: HashMap<String, AliasFile>,
87}
88
89#[derive(Deserialize)]
90struct EnumFile {
91 domain: String,
92 #[serde(default)]
93 aliases: HashMap<String, AliasFile>,
94 members: Vec<MemberFile>,
95}
96
97#[derive(Deserialize)]
98struct MemberFile {
99 id: String,
100 aliases: HashMap<String, AliasFile>,
101}
102
103#[derive(Debug, Deserialize)]
108#[serde(untagged)]
109enum AliasFile {
110 One(String),
111 Many(Vec<String>),
112}
113
114impl AliasFile {
115 fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
116 let spellings = match self {
117 AliasFile::One(spelling) => vec![spelling],
118 AliasFile::Many(spellings) => spellings,
119 };
120 if spellings.is_empty() || spellings.iter().any(String::is_empty) {
121 return Err(CatalogError::validation(format!(
122 "catalog entry '{}' declares an empty alias for locale '{}'",
123 id, locale
124 )));
125 }
126 Ok(spellings)
127 }
128}
129impl Catalog {
130 pub fn load(json: &str) -> Result<Catalog> {
133 let catalog = Self::load_unverified(json)?;
134 if let Some(declared) = &catalog.catalog_digest {
135 let computed = content_digest(json)?;
136 if declared != &computed {
137 return Err(CatalogError::validation(format!(
138 "catalog digest mismatch: declared '{declared}', content '{computed}' — \
139 run the catalog pipeline (workshop-catalog-gen build)"
140 )));
141 }
142 }
143 Ok(catalog)
144 }
145
146 pub fn load_unverified(json: &str) -> Result<Catalog> {
149 let file: CatalogFile = serde_json::from_str(json)
150 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
151 if file.schema_version != 1 {
152 return Err(CatalogError::malformed(format!(
153 "unsupported catalog schemaVersion {}",
154 file.schema_version
155 )));
156 }
157 let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
158 if locales.is_empty() {
159 return Err(CatalogError::malformed(
160 "catalog declares no locales".to_string(),
161 ));
162 }
163
164 let mut catalog = Catalog {
165 detection_index: None,
166 schema_version: file.schema_version,
167 locales,
168 target: file.target,
169 provenance: file.provenance,
170 catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
171 catalog_digest: file.digest,
172 entries: Vec::new(),
173 localized_strings: Vec::new(),
174 enums: Vec::new(),
175 by_id: Default::default(),
176 alias_to_entry: HashMap::new(),
177 localized_string_by_id: HashMap::new(),
178 localized_string_alias: HashMap::new(),
179 enum_by_domain: HashMap::new(),
180 enum_alias_to_domain: HashMap::new(),
181 enum_alias_to_member: HashMap::new(),
182 bare_member_index: HashMap::new(),
183 normalized: NormalizedIndex::default(),
184 };
185
186 for (kind, items) in [
187 (Kind::Structural, file.structural),
188 (Kind::Action, file.actions),
189 (Kind::Value, file.values),
190 (Kind::Event, file.events),
191 (Kind::Operator, file.operators),
192 (Kind::Setting, file.settings),
193 ] {
194 for item in items {
195 catalog.insert_entry(kind, item)?;
196 }
197 }
198 for item in file.localized_strings {
199 catalog.insert_localized_string(item)?;
200 }
201 for domain in file.enums {
202 catalog.insert_enum(domain)?;
203 }
204 for domain in &catalog.enums {
205 for member in &domain.members {
206 for (locale, spellings) in &member.aliases {
207 for spelling in spellings {
208 catalog
209 .bare_member_index
210 .entry(locale.clone())
211 .or_default()
212 .entry(spelling.clone())
213 .or_default()
214 .push((domain.domain.clone(), member.member.clone()));
215 }
216 }
217 }
218 }
219 catalog.build_normalized_index();
220 catalog.validate_param_domains()?;
221 catalog.detection_index =
222 Some(super::detect::AliasIndex::build(&catalog).map_err(|error| {
223 CatalogError::validation(format!("locale detection index: {error}"))
224 })?);
225 Ok(catalog)
226 }
227
228 pub fn builtin() -> Result<Catalog> {
230 Self::load(CATALOG_DATA)
231 }
232 fn build_normalized_index(&mut self) {
238 fn insert<T: PartialEq + Clone>(
239 map: &mut HashMap<String, Option<T>>,
240 key: String,
241 value: T,
242 ) {
243 match map.get_mut(&key) {
244 Some(slot) if *slot != Some(value.clone()) => *slot = None,
245 Some(_) => {}
246 None => {
247 map.insert(key, Some(value));
248 }
249 }
250 }
251
252 let mut normalized = NormalizedIndex::default();
253 for (locale, by_kind) in &self.alias_to_entry {
254 let by_kind_norm = normalized
255 .alias_to_entry
256 .entry(locale.clone())
257 .or_insert_with(|| std::array::from_fn(|_| HashMap::new()));
258 for (kind_index, map) in by_kind.iter().enumerate() {
259 for (spelling, &entry_index) in map {
260 let key = normalize_spelling(spelling);
261 if &key != spelling {
262 insert(&mut by_kind_norm[kind_index], key, entry_index);
263 }
264 }
265 }
266 }
267 for (locale, map) in &self.localized_string_alias {
268 let norm = normalized
269 .localized_string_alias
270 .entry(locale.clone())
271 .or_default();
272 for (spelling, &string_index) in map {
273 let key = normalize_spelling(spelling);
274 if &key != spelling {
275 insert(norm, key, string_index);
276 }
277 }
278 }
279 for (locale, map) in &self.enum_alias_to_domain {
280 let norm = normalized
281 .enum_alias_to_domain
282 .entry(locale.clone())
283 .or_default();
284 for (spelling, domain) in map {
285 let key = normalize_spelling(spelling);
286 if &key != spelling {
287 insert(norm, key, domain.clone());
288 }
289 }
290 }
291 for (locale, domains) in &self.enum_alias_to_member {
292 let norm = normalized
293 .enum_alias_to_member
294 .entry(locale.clone())
295 .or_default();
296 for (domain, map) in domains {
297 let norm_map = norm.entry(domain.clone()).or_default();
298 for (spelling, &indexes) in map {
299 let key = normalize_spelling(spelling);
300 if &key != spelling {
301 insert(norm_map, key, indexes);
302 }
303 }
304 }
305 }
306 for (locale, map) in &self.bare_member_index {
307 let norm = normalized.bare_member.entry(locale.clone()).or_default();
308 for (spelling, matches) in map {
309 let key = normalize_spelling(spelling);
310 if &key != spelling {
311 let merged = norm.entry(key).or_default();
312 for pair in matches {
313 if !merged.contains(pair) {
314 merged.push(pair.clone());
315 }
316 }
317 }
318 }
319 }
320 self.normalized = normalized;
321 }
322
323 fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
324 let index = self.entries.len();
325 let aliases = self.collect_aliases(
326 &item.id,
327 &format!("entry '{}'", item.id),
328 item.aliases,
329 true,
330 |catalog, locale, spelling| {
331 let locale_map = catalog.alias_to_entry.entry(locale.clone()).or_default();
332 if locale_map[kind.as_index()].contains_key(spelling) {
333 return Err(CatalogError::validation(format!(
334 "duplicate {} alias '{spelling}' for locale '{locale}'",
335 kind.as_str()
336 )));
337 }
338 locale_map[kind.as_index()].insert(spelling.to_string(), index);
339 Ok(())
340 },
341 )?;
342 if self.by_id[kind.as_index()].contains_key(&item.id) {
343 return Err(CatalogError::validation(format!(
344 "duplicate {} id '{}'",
345 kind.as_str(),
346 item.id
347 )));
348 }
349 let param_names = match item.param_names {
354 names if names.is_empty() => None,
355 names if names.len() != item.params.len() => {
356 return Err(CatalogError::validation(format!(
357 "{} '{}' declares {} param names for {} params",
358 kind.as_str(),
359 item.id,
360 names.len(),
361 item.params.len()
362 )));
363 }
364 names if names == item.params => None,
365 names => Some(names),
366 };
367 self.by_id[kind.as_index()].insert(item.id.clone(), index);
368 let item_id = item.id.clone();
369 self.entries.push(CatalogEntry {
370 id: item.id,
371 kind,
372 params: item.params,
373 param_names,
374 param_aliases: item
375 .param_aliases
376 .into_iter()
377 .map(|aliases| {
378 aliases
379 .into_iter()
380 .map(|(locale, alias)| {
381 let locale_key = Locale::new(&locale);
382 let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
383 Ok((locale_key, spellings))
384 })
385 .collect::<Result<HashMap<_, _>>>()
386 })
387 .collect::<Result<Vec<_>>>()?,
388 param_domains: item.param_domains,
389 param_defaults: item.param_defaults,
390 param_types: item.param_types,
391 param_coercions: item.param_coercions,
392 return_type: item.return_type,
393 reevaluation_coverage: item.reevaluation_coverage,
394 variadic: item.variadic,
395 aliases,
396 });
397 Ok(())
398 }
399
400 fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
401 if self.localized_string_by_id.contains_key(&item.id) {
402 return Err(CatalogError::validation(format!(
403 "duplicate localized string id '{}'",
404 item.id
405 )));
406 }
407 let index = self.localized_strings.len();
408 let aliases = self.collect_aliases(
409 &item.id,
410 &format!("localized string '{}'", item.id),
411 item.aliases,
412 true,
413 |catalog, locale, spelling| {
414 let locale_map = catalog
415 .localized_string_alias
416 .entry(locale.clone())
417 .or_default();
418 if locale_map.contains_key(spelling) {
419 return Err(CatalogError::validation(format!(
420 "duplicate localized string alias '{spelling}' for locale '{locale}'"
421 )));
422 }
423 locale_map.insert(spelling.to_string(), index);
424 Ok(())
425 },
426 )?;
427 self.localized_string_by_id.insert(item.id.clone(), index);
428 self.localized_strings.push(LocalizedStringEntry {
429 id: item.id,
430 aliases,
431 });
432 Ok(())
433 }
434
435 fn validate_param_domains(&self) -> Result<()> {
436 for entry in &self.entries {
437 for (name, count) in [
438 ("parameter alias sets", entry.param_aliases.len()),
439 ("param domains", entry.param_domains.len()),
440 ("param defaults", entry.param_defaults.len()),
441 ("param types", entry.param_types.len()),
442 ("param coercions", entry.param_coercions.len()),
443 ] {
444 if count > entry.params.len() {
445 return Err(CatalogError::validation(format!(
446 "{} '{}' declares more {name} than params",
447 entry.kind.as_str(),
448 entry.id
449 )));
450 }
451 }
452 if entry.kind != Kind::Value && entry.return_type.is_some() {
453 return Err(CatalogError::validation(format!(
454 "{} '{}' declares a return type but is not a value",
455 entry.kind.as_str(),
456 entry.id
457 )));
458 }
459 for domain in entry.param_domains.iter().flatten() {
460 if !self.enum_by_domain.contains_key(domain) {
461 return Err(CatalogError::validation(format!(
462 "{} '{}' declares undeclared enum domain '{domain}'",
463 entry.kind.as_str(),
464 entry.id
465 )));
466 }
467 }
468 if let Some(coverage) = &entry.reevaluation_coverage {
469 let reeval_params: Vec<usize> = (0..entry.params.len())
470 .filter(|index| {
471 entry
472 .param_domains
473 .get(*index)
474 .and_then(Option::as_deref)
475 .is_some_and(|domain| domain.ends_with("Reeval"))
476 })
477 .collect();
478 if reeval_params.len() != 1 {
479 return Err(CatalogError::validation(format!(
480 "{} '{}' declares reevaluation coverage but has {} *Reeval parameters",
481 entry.kind.as_str(),
482 entry.id,
483 reeval_params.len()
484 )));
485 }
486 let domain_name = entry.param_domains[reeval_params[0]]
487 .as_deref()
488 .expect("filtered above");
489 let domain = self
490 .enum_by_domain
491 .get(domain_name)
492 .map(|index| &self.enums[*index])
493 .expect("param domain validated above");
494 for (member, positions) in coverage {
495 if !domain.members.iter().any(|m| m.member == *member) {
496 return Err(CatalogError::validation(format!(
497 "{} '{}' declares reevaluation coverage for '{member}', which is not a member of '{domain_name}'",
498 entry.kind.as_str(),
499 entry.id
500 )));
501 }
502 for position in positions {
503 if *position >= entry.params.len() {
504 return Err(CatalogError::validation(format!(
505 "{} '{}' reevaluation coverage for '{member}' names parameter {position}, out of {} params",
506 entry.kind.as_str(),
507 entry.id,
508 entry.params.len()
509 )));
510 }
511 if *position == reeval_params[0] {
512 return Err(CatalogError::validation(format!(
513 "{} '{}' reevaluation coverage for '{member}' covers the reevaluation parameter itself",
514 entry.kind.as_str(),
515 entry.id
516 )));
517 }
518 }
519 }
520 for member in &domain.members {
521 if !coverage.contains_key(&member.member) {
522 return Err(CatalogError::validation(format!(
523 "{} '{}' reevaluation coverage does not review '{domain_name}' member '{}'",
524 entry.kind.as_str(),
525 entry.id,
526 member.member
527 )));
528 }
529 }
530 }
531 for aliases in &entry.param_aliases {
532 for locale in aliases.keys() {
533 if !self.locales.contains(locale) {
534 return Err(CatalogError::validation(format!(
535 "{} '{}' declares parameter alias for undeclared locale '{}'",
536 entry.kind.as_str(),
537 entry.id,
538 locale
539 )));
540 }
541 }
542 }
543 }
544 Ok(())
545 }
546
547 fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
548 let domain_index = self.enums.len();
549 if self.enum_by_domain.contains_key(&domain.domain) {
550 return Err(CatalogError::validation(format!(
551 "duplicate enum domain '{}'",
552 domain.domain
553 )));
554 }
555 let primary = self.locales[0].clone();
556 let mut domain_aliases = self.collect_aliases(
557 &domain.domain,
558 &format!("enum domain '{}'", domain.domain),
559 domain.aliases,
560 false,
561 |catalog, locale, spelling| {
562 let locale_map = catalog
563 .enum_alias_to_domain
564 .entry(locale.clone())
565 .or_default();
566 if let Some(existing) = locale_map.get(spelling) {
567 return Err(CatalogError::validation(format!(
568 "duplicate enum domain alias '{spelling}' for '{existing}' and '{}' in locale '{locale}'",
569 domain.domain
570 )));
571 }
572 locale_map.insert(spelling.to_string(), domain.domain.clone());
573 Ok(())
574 },
575 )?;
576 domain_aliases
577 .entry(primary.clone())
578 .or_insert_with(|| vec![domain.domain.clone()]);
579 self.enum_alias_to_domain
580 .entry(primary.clone())
581 .or_default()
582 .entry(domain.domain.clone())
583 .or_insert_with(|| domain.domain.clone());
584 let mut members = Vec::new();
585 for (member_index, member) in domain.members.into_iter().enumerate() {
586 let aliases = self.collect_aliases(
587 &member.id,
588 &format!("enum {}::{}", domain.domain, member.id),
589 member.aliases,
590 true,
591 |catalog, locale, spelling| {
592 let domain_map = catalog
593 .enum_alias_to_member
594 .entry(locale.clone())
595 .or_default()
596 .entry(domain.domain.clone())
597 .or_default();
598 if domain_map.contains_key(spelling) {
599 return Err(CatalogError::validation(format!(
600 "duplicate enum alias '{spelling}' in '{}' for locale '{locale}'",
601 domain.domain
602 )));
603 }
604 domain_map.insert(spelling.to_string(), (domain_index, member_index));
605 Ok(())
606 },
607 )?;
608 members.push(EnumMember {
609 member: member.id,
610 aliases,
611 });
612 }
613 self.enum_by_domain
614 .insert(domain.domain.clone(), domain_index);
615 self.enums.push(EnumDomain {
616 domain: domain.domain,
617 aliases: domain_aliases,
618 members,
619 });
620 Ok(())
621 }
622
623 fn collect_aliases(
624 &mut self,
625 id: &str,
626 label: &str,
627 aliases: HashMap<String, AliasFile>,
628 require_primary: bool,
629 mut register: impl FnMut(&mut Self, &Locale, &str) -> Result<()>,
630 ) -> Result<HashMap<Locale, Vec<String>>> {
631 let mut collected = HashMap::new();
632 for (locale_str, alias_file) in aliases {
633 let locale = Locale::new(&locale_str);
634 if !self.locales.contains(&locale) {
635 return Err(CatalogError::validation(format!(
636 "{label} declares alias for undeclared locale '{locale}'"
637 )));
638 }
639 let spellings = alias_file.into_spellings(id, locale.as_str())?;
640 for spelling in &spellings {
641 register(self, &locale, spelling)?;
642 }
643 collected.insert(locale, spellings);
644 }
645 let primary = &self.locales[0];
646 if require_primary && !collected.contains_key(primary) {
647 return Err(CatalogError::validation(format!(
648 "{label} is missing a '{primary}' alias"
649 )));
650 }
651 Ok(collected)
652 }
653}
654
655pub fn canonicalize(json: &str) -> Result<String> {
661 Catalog::load_unverified(json)?;
662 let value: serde_json::Value = serde_json::from_str(json)
663 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
664 pretty_json(&value)
665}
666
667pub fn build_canonical(json: &str) -> Result<String> {
671 let mut value: serde_json::Value = serde_json::from_str(json)
672 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
673 let digest = content_digest(json)?;
674 if let Some(object) = value.as_object_mut() {
675 object.insert("digest".to_string(), serde_json::Value::String(digest));
676 }
677 let output = pretty_json(&value)?;
678 Catalog::load(&output)?;
679 Ok(output)
680}
681
682pub fn content_digest(json: &str) -> Result<String> {
687 let mut value: serde_json::Value = serde_json::from_str(json)
688 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
689 if let Some(object) = value.as_object_mut() {
690 object.remove("digest");
691 }
692 let canonical = pretty_json(&value)?;
693 use sha2::{Digest, Sha256};
694 let mut hasher = Sha256::new();
695 hasher.update(canonical.trim_end().as_bytes());
696 Ok(format!("{:x}", hasher.finalize()))
697}
698
699fn pretty_json(value: &serde_json::Value) -> Result<String> {
700 serde_json::to_string_pretty(value)
701 .map(|mut out| {
702 out.push('\n');
703 out
704 })
705 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
706}