Skip to main content

workshop_rs/catalog/
load.rs

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    /// Reviewed semantic parameter names, parallel to `params`.
50    #[serde(default)]
51    param_names: Vec<String>,
52    #[serde(default)]
53    param_aliases: Vec<HashMap<String, AliasFile>>,
54    /// Canonical enum domain per parameter position (parallel to `params`);
55    /// empty when no parameter domains are documented.
56    #[serde(default)]
57    param_domains: Vec<Option<String>>,
58    /// Default value per parameter position (parallel to `params`),
59    /// resolved when a call omits the argument. `None` means no default is
60    /// declared. Default value syntax: `null`, a numeric literal, localized
61    /// string text, `Domain.MEMBER` (builtin enum member), or a catalog value
62    /// id resolved as a zero-argument call. Every default is pinned-reference
63    /// probe evidence, never copied from upstream game data.
64    #[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    /// Reviewed reevaluation coverage keyed by enum member id (e.g.
73    /// `COLOR`), listing the parameter positions each member keeps
74    /// re-evaluating. Declared only on actions whose `*Reeval` parameter
75    /// selects the reevaluation mode; validated against that domain's
76    /// members and the parameter arity.
77    #[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/// A locale may have one canonical emitter spelling or several reviewed
104/// spellings observed across current Workshop producers. The string form is
105/// retained for the common case; the array form makes conflicts explicit in
106/// the data instead of forcing parser branches or silently choosing one.
107#[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    /// Parse and validate catalog data, verifying the declared content
131    /// digest when the data carries one.
132    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    /// Parse and validate catalog data without digest verification. Used by
147    /// the catalog pipeline so a stale digest can be repaired by `build`.
148    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    /// The built-in catalog data.
229    pub fn builtin() -> Result<Catalog> {
230        Self::load(CATALOG_DATA)
231    }
232    /// Mirror the verbatim spelling indexes under whitespace-collapsed
233    /// keys (#448). A normalized key claimed by distinct targets is poisoned
234    /// (`None`) so it resolves nothing rather than guessing; identical
235    /// targets (two declared spellings that differ only in whitespace, such
236    /// as zh-CN `66号公路`/`66 号公路`) keep their single meaning.
237    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        // The primary locale's surface is complete: every builtin carries a
350        // primary-locale alias. Additional declared locales may be partially
351        // covered; missing target-locale mappings fail explicitly at
352        // conversion/emission time (ADR-0001 Decision 7).
353        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
655/// Canonicalize catalog data: parse, validate, and re-serialize
656/// deterministically (object keys sorted, stable formatting). Re-running on
657/// the same input produces byte-identical output, so the data pipeline is
658/// reproducible. Validation intentionally skips digest verification so a
659/// stale digest can be repaired by [`build_canonical`].
660pub 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
667/// Rebuild the canonical catalog form with a fresh content digest: validate,
668/// canonicalize, and (re)write the `digest` field. Byte-idempotent, so the
669/// committed dataset and its digest are reproducible from the data file.
670pub 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
682/// The deterministic content digest of catalog data: sha256 of the canonical
683/// (sorted-key, pretty) serialization of the parsed content with the
684/// self-referential `digest` field removed. Independent of file formatting;
685/// changes whenever any semantic content changes.
686pub 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}