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