1use std::collections::{BTreeMap, HashMap};
6
7use serde::Deserialize;
8
9use crate::core::error::{CatalogError, Result};
10
11use super::{
12 CATALOG_DATA, Catalog, CatalogEntry, EnumDomain, EnumMember, Kind, Locale,
13 LocalizedStringEntry, ParamCoercions, Provenance, TargetMeta,
14};
15
16#[derive(Deserialize)]
17#[serde(rename_all = "camelCase")]
18struct CatalogFile {
19 schema_version: u32,
20 locales: Vec<String>,
21 target: TargetMeta,
22 provenance: Provenance,
23 #[serde(default)]
25 version: Option<String>,
26 #[serde(default)]
28 digest: Option<String>,
29 #[serde(default)]
30 structural: Vec<EntryFile>,
31 #[serde(default)]
32 actions: Vec<EntryFile>,
33 #[serde(default)]
34 values: Vec<EntryFile>,
35 #[serde(default)]
36 events: Vec<EntryFile>,
37 #[serde(default)]
38 operators: Vec<EntryFile>,
39 #[serde(default)]
40 settings: Vec<EntryFile>,
41 #[serde(default)]
42 localized_strings: Vec<LocalizedStringFile>,
43 #[serde(default)]
44 enums: Vec<EnumFile>,
45}
46
47#[derive(Deserialize)]
48#[serde(rename_all = "camelCase")]
49struct EntryFile {
50 id: String,
51 aliases: HashMap<String, AliasFile>,
52 #[serde(default)]
53 params: Vec<String>,
54 #[serde(default)]
56 param_names: Vec<String>,
57 #[serde(default)]
58 param_aliases: Vec<HashMap<String, AliasFile>>,
59 #[serde(default)]
62 param_domains: Vec<Option<String>>,
63 #[serde(default)]
70 param_defaults: Vec<Option<String>>,
71 #[serde(default)]
72 param_types: Vec<Option<String>>,
73 #[serde(default)]
74 param_coercions: Vec<Option<ParamCoercions>>,
75 #[serde(default)]
76 return_type: Option<String>,
77 #[serde(default)]
83 reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
84 #[serde(default)]
85 variadic: bool,
86}
87
88#[derive(Deserialize)]
89struct LocalizedStringFile {
90 id: String,
91 aliases: HashMap<String, AliasFile>,
92}
93
94#[derive(Deserialize)]
95struct EnumFile {
96 domain: String,
97 #[serde(default)]
98 aliases: HashMap<String, AliasFile>,
99 members: Vec<MemberFile>,
100}
101
102#[derive(Deserialize)]
103struct MemberFile {
104 id: String,
105 aliases: HashMap<String, AliasFile>,
106}
107
108#[derive(Debug, Deserialize)]
113#[serde(untagged)]
114enum AliasFile {
115 One(String),
116 Many(Vec<String>),
117}
118
119impl AliasFile {
120 fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
121 let spellings = match self {
122 AliasFile::One(spelling) => vec![spelling],
123 AliasFile::Many(spellings) => spellings,
124 };
125 if spellings.is_empty() || spellings.iter().any(String::is_empty) {
126 return Err(CatalogError::validation(format!(
127 "catalog entry '{}' declares an empty alias for locale '{}'",
128 id, locale
129 )));
130 }
131 Ok(spellings)
132 }
133}
134impl Catalog {
135 pub fn load(json: &str) -> Result<Catalog> {
138 let catalog = Self::load_unverified(json)?;
139 if let Some(declared) = &catalog.catalog_digest {
140 let computed = content_digest(json)?;
141 if declared != &computed {
142 return Err(CatalogError::validation(format!(
143 "catalog digest mismatch: declared '{declared}', content '{computed}' — \
144 run the catalog pipeline (workshop-catalog-gen build)"
145 )));
146 }
147 }
148 Ok(catalog)
149 }
150
151 pub fn load_unverified(json: &str) -> Result<Catalog> {
154 let file: CatalogFile = serde_json::from_str(json)
155 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
156 if file.schema_version != 1 {
157 return Err(CatalogError::malformed(format!(
158 "unsupported catalog schemaVersion {}",
159 file.schema_version
160 )));
161 }
162 let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
163 if locales.is_empty() {
164 return Err(CatalogError::malformed(
165 "catalog declares no locales".to_string(),
166 ));
167 }
168
169 let mut catalog = Catalog {
170 detection_index: None,
171 schema_version: file.schema_version,
172 locales,
173 target: file.target,
174 provenance: file.provenance,
175 catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
176 catalog_digest: file.digest,
177 entries: Vec::new(),
178 localized_strings: Vec::new(),
179 enums: Vec::new(),
180 by_id: Default::default(),
181 alias_to_entry: HashMap::new(),
182 localized_string_by_id: HashMap::new(),
183 localized_string_alias: HashMap::new(),
184 enum_by_domain: HashMap::new(),
185 enum_alias_to_domain: HashMap::new(),
186 enum_alias_to_member: HashMap::new(),
187 bare_member_index: HashMap::new(),
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.validate_param_domains()?;
224 catalog.detection_index =
225 Some(super::detect::AliasIndex::build(&catalog).map_err(|error| {
226 CatalogError::validation(format!("locale detection index: {error}"))
227 })?);
228 Ok(catalog)
229 }
230
231 pub fn builtin() -> Result<Catalog> {
233 Self::load(CATALOG_DATA)
234 }
235 fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
236 let index = self.entries.len();
237 let aliases = self.collect_aliases(
238 &item.id,
239 &format!("entry '{}'", item.id),
240 item.aliases,
241 true,
242 |catalog, locale, spelling| {
243 let locale_map = catalog.alias_to_entry.entry(locale.clone()).or_default();
244 if locale_map[kind.as_index()].contains_key(spelling) {
245 return Err(CatalogError::validation(format!(
246 "duplicate {} alias '{spelling}' for locale '{locale}'",
247 kind.as_str()
248 )));
249 }
250 locale_map[kind.as_index()].insert(spelling.to_string(), index);
251 Ok(())
252 },
253 )?;
254 if self.by_id[kind.as_index()].contains_key(&item.id) {
255 return Err(CatalogError::validation(format!(
256 "duplicate {} id '{}'",
257 kind.as_str(),
258 item.id
259 )));
260 }
261 let param_names = match item.param_names {
266 names if names.is_empty() => None,
267 names if names.len() != item.params.len() => {
268 return Err(CatalogError::validation(format!(
269 "{} '{}' declares {} param names for {} params",
270 kind.as_str(),
271 item.id,
272 names.len(),
273 item.params.len()
274 )));
275 }
276 names if names == item.params => None,
277 names => Some(names),
278 };
279 self.by_id[kind.as_index()].insert(item.id.clone(), index);
280 let item_id = item.id.clone();
281 self.entries.push(CatalogEntry {
282 id: item.id,
283 kind,
284 params: item.params,
285 param_names,
286 param_aliases: item
287 .param_aliases
288 .into_iter()
289 .map(|aliases| {
290 aliases
291 .into_iter()
292 .map(|(locale, alias)| {
293 let locale_key = Locale::new(&locale);
294 let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
295 Ok((locale_key, spellings))
296 })
297 .collect::<Result<HashMap<_, _>>>()
298 })
299 .collect::<Result<Vec<_>>>()?,
300 param_domains: item.param_domains,
301 param_defaults: item.param_defaults,
302 param_types: item.param_types,
303 param_coercions: item.param_coercions,
304 return_type: item.return_type,
305 reevaluation_coverage: item.reevaluation_coverage,
306 variadic: item.variadic,
307 aliases,
308 });
309 Ok(())
310 }
311
312 fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
313 if self.localized_string_by_id.contains_key(&item.id) {
314 return Err(CatalogError::validation(format!(
315 "duplicate localized string id '{}'",
316 item.id
317 )));
318 }
319 let index = self.localized_strings.len();
320 let aliases = self.collect_aliases(
321 &item.id,
322 &format!("localized string '{}'", item.id),
323 item.aliases,
324 true,
325 |catalog, locale, spelling| {
326 let locale_map = catalog
327 .localized_string_alias
328 .entry(locale.clone())
329 .or_default();
330 if locale_map.contains_key(spelling) {
331 return Err(CatalogError::validation(format!(
332 "duplicate localized string alias '{spelling}' for locale '{locale}'"
333 )));
334 }
335 locale_map.insert(spelling.to_string(), index);
336 Ok(())
337 },
338 )?;
339 self.localized_string_by_id.insert(item.id.clone(), index);
340 self.localized_strings.push(LocalizedStringEntry {
341 id: item.id,
342 aliases,
343 });
344 Ok(())
345 }
346
347 fn validate_param_domains(&self) -> Result<()> {
349 for entry in &self.entries {
350 for (name, count) in [
351 ("parameter alias sets", entry.param_aliases.len()),
352 ("param domains", entry.param_domains.len()),
353 ("param defaults", entry.param_defaults.len()),
354 ("param types", entry.param_types.len()),
355 ("param coercions", entry.param_coercions.len()),
356 ] {
357 if count > entry.params.len() {
358 return Err(CatalogError::validation(format!(
359 "{} '{}' declares more {name} than params",
360 entry.kind.as_str(),
361 entry.id
362 )));
363 }
364 }
365 if entry.kind != Kind::Value && entry.return_type.is_some() {
366 return Err(CatalogError::validation(format!(
367 "{} '{}' declares a return type but is not a value",
368 entry.kind.as_str(),
369 entry.id
370 )));
371 }
372 for domain in entry.param_domains.iter().flatten() {
373 if !self.enum_by_domain.contains_key(domain) {
374 return Err(CatalogError::validation(format!(
375 "{} '{}' declares undeclared enum domain '{domain}'",
376 entry.kind.as_str(),
377 entry.id
378 )));
379 }
380 }
381 if let Some(coverage) = &entry.reevaluation_coverage {
382 let reeval_params: Vec<usize> = (0..entry.params.len())
383 .filter(|index| {
384 entry
385 .param_domains
386 .get(*index)
387 .and_then(Option::as_deref)
388 .is_some_and(|domain| domain.ends_with("Reeval"))
389 })
390 .collect();
391 if reeval_params.len() != 1 {
392 return Err(CatalogError::validation(format!(
393 "{} '{}' declares reevaluation coverage but has {} *Reeval parameters",
394 entry.kind.as_str(),
395 entry.id,
396 reeval_params.len()
397 )));
398 }
399 let domain_name = entry.param_domains[reeval_params[0]]
400 .as_deref()
401 .expect("filtered above");
402 let domain = self
403 .enum_by_domain
404 .get(domain_name)
405 .map(|index| &self.enums[*index])
406 .expect("param domain validated above");
407 for (member, positions) in coverage {
408 if !domain.members.iter().any(|m| m.member == *member) {
409 return Err(CatalogError::validation(format!(
410 "{} '{}' declares reevaluation coverage for '{member}', which is not a member of '{domain_name}'",
411 entry.kind.as_str(),
412 entry.id
413 )));
414 }
415 for position in positions {
416 if *position >= entry.params.len() {
417 return Err(CatalogError::validation(format!(
418 "{} '{}' reevaluation coverage for '{member}' names parameter {position}, out of {} params",
419 entry.kind.as_str(),
420 entry.id,
421 entry.params.len()
422 )));
423 }
424 if *position == reeval_params[0] {
425 return Err(CatalogError::validation(format!(
426 "{} '{}' reevaluation coverage for '{member}' covers the reevaluation parameter itself",
427 entry.kind.as_str(),
428 entry.id
429 )));
430 }
431 }
432 }
433 for member in &domain.members {
434 if !coverage.contains_key(&member.member) {
435 return Err(CatalogError::validation(format!(
436 "{} '{}' reevaluation coverage does not review '{domain_name}' member '{}'",
437 entry.kind.as_str(),
438 entry.id,
439 member.member
440 )));
441 }
442 }
443 }
444 for aliases in &entry.param_aliases {
445 for locale in aliases.keys() {
446 if !self.locales.contains(locale) {
447 return Err(CatalogError::validation(format!(
448 "{} '{}' declares parameter alias for undeclared locale '{}'",
449 entry.kind.as_str(),
450 entry.id,
451 locale
452 )));
453 }
454 }
455 }
456 }
457 Ok(())
458 }
459
460 fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
461 let domain_index = self.enums.len();
462 if self.enum_by_domain.contains_key(&domain.domain) {
463 return Err(CatalogError::validation(format!(
464 "duplicate enum domain '{}'",
465 domain.domain
466 )));
467 }
468 let primary = self.locales[0].clone();
469 let mut domain_aliases = self.collect_aliases(
470 &domain.domain,
471 &format!("enum domain '{}'", domain.domain),
472 domain.aliases,
473 false,
474 |catalog, locale, spelling| {
475 let locale_map = catalog
476 .enum_alias_to_domain
477 .entry(locale.clone())
478 .or_default();
479 if let Some(existing) = locale_map.get(spelling) {
480 return Err(CatalogError::validation(format!(
481 "duplicate enum domain alias '{spelling}' for '{existing}' and '{}' in locale '{locale}'",
482 domain.domain
483 )));
484 }
485 locale_map.insert(spelling.to_string(), domain.domain.clone());
486 Ok(())
487 },
488 )?;
489 domain_aliases
490 .entry(primary.clone())
491 .or_insert_with(|| vec![domain.domain.clone()]);
492 self.enum_alias_to_domain
493 .entry(primary.clone())
494 .or_default()
495 .entry(domain.domain.clone())
496 .or_insert_with(|| domain.domain.clone());
497 let mut members = Vec::new();
498 for (member_index, member) in domain.members.into_iter().enumerate() {
499 let aliases = self.collect_aliases(
500 &member.id,
501 &format!("enum {}::{}", domain.domain, member.id),
502 member.aliases,
503 true,
504 |catalog, locale, spelling| {
505 let domain_map = catalog
506 .enum_alias_to_member
507 .entry(locale.clone())
508 .or_default()
509 .entry(domain.domain.clone())
510 .or_default();
511 if domain_map.contains_key(spelling) {
512 return Err(CatalogError::validation(format!(
513 "duplicate enum alias '{spelling}' in '{}' for locale '{locale}'",
514 domain.domain
515 )));
516 }
517 domain_map.insert(spelling.to_string(), (domain_index, member_index));
518 Ok(())
519 },
520 )?;
521 members.push(EnumMember {
522 member: member.id,
523 aliases,
524 });
525 }
526 self.enum_by_domain
527 .insert(domain.domain.clone(), domain_index);
528 self.enums.push(EnumDomain {
529 domain: domain.domain,
530 aliases: domain_aliases,
531 members,
532 });
533 Ok(())
534 }
535
536 fn collect_aliases(
541 &mut self,
542 id: &str,
543 label: &str,
544 aliases: HashMap<String, AliasFile>,
545 require_primary: bool,
546 mut register: impl FnMut(&mut Self, &Locale, &str) -> Result<()>,
547 ) -> Result<HashMap<Locale, Vec<String>>> {
548 let mut collected = HashMap::new();
549 for (locale_str, alias_file) in aliases {
550 let locale = Locale::new(&locale_str);
551 if !self.locales.contains(&locale) {
552 return Err(CatalogError::validation(format!(
553 "{label} declares alias for undeclared locale '{locale}'"
554 )));
555 }
556 let spellings = alias_file.into_spellings(id, locale.as_str())?;
557 for spelling in &spellings {
558 register(self, &locale, spelling)?;
559 }
560 collected.insert(locale, spellings);
561 }
562 let primary = &self.locales[0];
563 if require_primary && !collected.contains_key(primary) {
564 return Err(CatalogError::validation(format!(
565 "{label} is missing a '{primary}' alias"
566 )));
567 }
568 Ok(collected)
569 }
570}
571
572pub fn canonicalize(json: &str) -> Result<String> {
578 Catalog::load_unverified(json)?;
580 let value: serde_json::Value = serde_json::from_str(json)
581 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
582 pretty_json(&value)
583}
584
585pub fn build_canonical(json: &str) -> Result<String> {
589 let mut value: serde_json::Value = serde_json::from_str(json)
590 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
591 let digest = content_digest(json)?;
592 if let Some(object) = value.as_object_mut() {
593 object.insert("digest".to_string(), serde_json::Value::String(digest));
594 }
595 let output = pretty_json(&value)?;
596 Catalog::load(&output)?;
599 Ok(output)
600}
601
602pub fn content_digest(json: &str) -> Result<String> {
607 let mut value: serde_json::Value = serde_json::from_str(json)
608 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
609 if let Some(object) = value.as_object_mut() {
610 object.remove("digest");
611 }
612 let canonical = pretty_json(&value)?;
613 use sha2::{Digest, Sha256};
614 let mut hasher = Sha256::new();
615 hasher.update(canonical.trim_end().as_bytes());
616 Ok(format!("{:x}", hasher.finalize()))
617}
618
619fn pretty_json(value: &serde_json::Value) -> Result<String> {
622 serde_json::to_string_pretty(value)
623 .map(|mut out| {
624 out.push('\n');
625 out
626 })
627 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
628}