Skip to main content

workshop_rs/catalog/
load.rs

1//! Catalog data pipeline: the checked-in JSON schema, loading, and
2//! deterministic digest. [`Catalog`] accessors live in `mod.rs`; this file
3//! owns how catalog bytes become the validated structure.
4
5use 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    /// The catalog dataset version; absent in ad-hoc test data.
24    #[serde(default)]
25    version: Option<String>,
26    /// The declared content digest; absent in ad-hoc test data.
27    #[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    /// Reviewed semantic parameter names, parallel to `params`.
55    #[serde(default)]
56    param_names: Vec<String>,
57    #[serde(default)]
58    param_aliases: Vec<HashMap<String, AliasFile>>,
59    /// Canonical enum domain per parameter position (parallel to `params`);
60    /// empty when no parameter domains are documented.
61    #[serde(default)]
62    param_domains: Vec<Option<String>>,
63    /// Default value per parameter position (parallel to `params`),
64    /// resolved when a call omits the argument. `None` means no default is
65    /// declared. Default value syntax: `null`, a numeric literal, localized
66    /// string text, `Domain.MEMBER` (builtin enum member), or a catalog value
67    /// id resolved as a zero-argument call. Every default is pinned-reference
68    /// probe evidence, never copied from upstream game data.
69    #[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    /// Reviewed reevaluation coverage keyed by enum member id (e.g.
78    /// `COLOR`), listing the parameter positions each member keeps
79    /// re-evaluating. Declared only on actions whose `*Reeval` parameter
80    /// selects the reevaluation mode; validated against that domain's
81    /// members and the parameter arity.
82    #[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/// A locale may have one canonical emitter spelling or several reviewed
109/// spellings observed across current Workshop producers. The string form is
110/// retained for the common case; the array form makes conflicts explicit in
111/// the data instead of forcing parser branches or silently choosing one.
112#[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    /// Parse and validate catalog data, verifying the declared content
136    /// digest when the data carries one.
137    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    /// Parse and validate catalog data without digest verification. Used by
152    /// the catalog pipeline so a stale digest can be repaired by `build`.
153    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    /// The built-in catalog data.
232    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        // The primary locale's surface is complete: every builtin carries a
262        // primary-locale alias. Additional declared locales may be partially
263        // covered; missing target-locale mappings fail explicitly at
264        // conversion/emission time (ADR-0001 Decision 7).
265        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    /// Every declared `paramDomains` domain must name a declared enum domain.
348    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    /// Collect per-locale alias spellings for one catalog item: reject
537    /// undeclared locales, normalize each spelling list, hand every spelling
538    /// to `register` for the caller's index/dup check, and — when
539    /// `require_primary` — require coverage of the primary locale.
540    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
572/// Canonicalize catalog data: parse, validate, and re-serialize
573/// deterministically (object keys sorted, stable formatting). Re-running on
574/// the same input produces byte-identical output, so the data pipeline is
575/// reproducible. Validation intentionally skips digest verification so a
576/// stale digest can be repaired by [`build_canonical`].
577pub fn canonicalize(json: &str) -> Result<String> {
578    // Validate the semantic content first.
579    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
585/// Rebuild the canonical catalog form with a fresh content digest: validate,
586/// canonicalize, and (re)write the `digest` field. Byte-idempotent, so the
587/// committed dataset and its digest are reproducible from the data file.
588pub 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    // Validate the semantic content (including the fresh digest) before
597    // returning the rebuilt file.
598    Catalog::load(&output)?;
599    Ok(output)
600}
601
602/// The deterministic content digest of catalog data: sha256 of the canonical
603/// (sorted-key, pretty) serialization of the parsed content with the
604/// self-referential `digest` field removed. Independent of file formatting;
605/// changes whenever any semantic content changes.
606pub 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
619/// Sorted-key pretty JSON with a trailing newline — the canonical catalog
620/// byte form all digest and rebuild paths share.
621fn 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}