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::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    #[serde(default)]
78    variadic: bool,
79}
80
81#[derive(Deserialize)]
82struct LocalizedStringFile {
83    id: String,
84    aliases: HashMap<String, AliasFile>,
85}
86
87#[derive(Deserialize)]
88struct EnumFile {
89    domain: String,
90    #[serde(default)]
91    aliases: HashMap<String, AliasFile>,
92    members: Vec<MemberFile>,
93}
94
95#[derive(Deserialize)]
96struct MemberFile {
97    id: String,
98    aliases: HashMap<String, AliasFile>,
99}
100
101/// A locale may have one canonical emitter spelling or several reviewed
102/// spellings observed across current Workshop producers. The string form is
103/// retained for the common case; the array form makes conflicts explicit in
104/// the data instead of forcing parser branches or silently choosing one.
105#[derive(Debug, Deserialize)]
106#[serde(untagged)]
107enum AliasFile {
108    One(String),
109    Many(Vec<String>),
110}
111
112impl AliasFile {
113    fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
114        let spellings = match self {
115            AliasFile::One(spelling) => vec![spelling],
116            AliasFile::Many(spellings) => spellings,
117        };
118        if spellings.is_empty() || spellings.iter().any(String::is_empty) {
119            return Err(CatalogError::validation(format!(
120                "catalog entry '{}' declares an empty alias for locale '{}'",
121                id, locale
122            )));
123        }
124        Ok(spellings)
125    }
126}
127impl Catalog {
128    /// Parse and validate catalog data, verifying the declared content
129    /// digest when the data carries one.
130    pub fn load(json: &str) -> Result<Catalog> {
131        let catalog = Self::load_unverified(json)?;
132        if let Some(declared) = &catalog.catalog_digest {
133            let computed = content_digest(json)?;
134            if declared != &computed {
135                return Err(CatalogError::validation(format!(
136                    "catalog digest mismatch: declared '{declared}', content '{computed}' — \
137                     run the catalog pipeline (workshop-catalog-gen build)"
138                )));
139            }
140        }
141        Ok(catalog)
142    }
143
144    /// Parse and validate catalog data without digest verification. Used by
145    /// the catalog pipeline so a stale digest can be repaired by `build`.
146    pub fn load_unverified(json: &str) -> Result<Catalog> {
147        let file: CatalogFile = serde_json::from_str(json)
148            .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
149        if file.schema_version != 1 {
150            return Err(CatalogError::malformed(format!(
151                "unsupported catalog schemaVersion {}",
152                file.schema_version
153            )));
154        }
155        let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
156        if locales.is_empty() {
157            return Err(CatalogError::malformed(
158                "catalog declares no locales".to_string(),
159            ));
160        }
161
162        let mut catalog = Catalog {
163            detection_index: None,
164            schema_version: file.schema_version,
165            locales,
166            target: file.target,
167            provenance: file.provenance,
168            catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
169            catalog_digest: file.digest,
170            entries: Vec::new(),
171            localized_strings: Vec::new(),
172            enums: Vec::new(),
173            by_id: Default::default(),
174            alias_to_entry: HashMap::new(),
175            localized_string_by_id: HashMap::new(),
176            localized_string_alias: HashMap::new(),
177            enum_by_domain: HashMap::new(),
178            enum_alias_to_domain: HashMap::new(),
179            enum_alias_to_member: HashMap::new(),
180            bare_member_index: HashMap::new(),
181        };
182
183        for (kind, items) in [
184            (Kind::Structural, file.structural),
185            (Kind::Action, file.actions),
186            (Kind::Value, file.values),
187            (Kind::Event, file.events),
188            (Kind::Operator, file.operators),
189            (Kind::Setting, file.settings),
190        ] {
191            for item in items {
192                catalog.insert_entry(kind, item)?;
193            }
194        }
195        for item in file.localized_strings {
196            catalog.insert_localized_string(item)?;
197        }
198        for domain in file.enums {
199            catalog.insert_enum(domain)?;
200        }
201        for domain in &catalog.enums {
202            for member in &domain.members {
203                for (locale, spellings) in &member.aliases {
204                    for spelling in spellings {
205                        catalog
206                            .bare_member_index
207                            .entry(locale.clone())
208                            .or_default()
209                            .entry(spelling.clone())
210                            .or_default()
211                            .push((domain.domain.clone(), member.member.clone()));
212                    }
213                }
214            }
215        }
216        catalog.validate_param_domains()?;
217        catalog.detection_index =
218            Some(super::detect::AliasIndex::build(&catalog).map_err(|error| {
219                CatalogError::validation(format!("locale detection index: {error}"))
220            })?);
221        Ok(catalog)
222    }
223
224    /// The built-in catalog data.
225    pub fn builtin() -> Result<Catalog> {
226        Self::load(CATALOG_DATA)
227    }
228    fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
229        let index = self.entries.len();
230        let aliases = self.collect_aliases(
231            &item.id,
232            &format!("entry '{}'", item.id),
233            item.aliases,
234            true,
235            |catalog, locale, spelling| {
236                let locale_map = catalog.alias_to_entry.entry(locale.clone()).or_default();
237                if locale_map[kind.as_index()].contains_key(spelling) {
238                    return Err(CatalogError::validation(format!(
239                        "duplicate {} alias '{spelling}' for locale '{locale}'",
240                        kind.as_str()
241                    )));
242                }
243                locale_map[kind.as_index()].insert(spelling.to_string(), index);
244                Ok(())
245            },
246        )?;
247        if self.by_id[kind.as_index()].contains_key(&item.id) {
248            return Err(CatalogError::validation(format!(
249                "duplicate {} id '{}'",
250                kind.as_str(),
251                item.id
252            )));
253        }
254        // The primary locale's surface is complete: every builtin carries a
255        // primary-locale alias. Additional declared locales may be partially
256        // covered; missing target-locale mappings fail explicitly at
257        // conversion/emission time (ADR-0001 Decision 7).
258        let param_names = match item.param_names {
259            names if names.is_empty() => None,
260            names if names.len() != item.params.len() => {
261                return Err(CatalogError::validation(format!(
262                    "{} '{}' declares {} param names for {} params",
263                    kind.as_str(),
264                    item.id,
265                    names.len(),
266                    item.params.len()
267                )));
268            }
269            names if names == item.params => None,
270            names => Some(names),
271        };
272        self.by_id[kind.as_index()].insert(item.id.clone(), index);
273        let item_id = item.id.clone();
274        self.entries.push(CatalogEntry {
275            id: item.id,
276            kind,
277            params: item.params,
278            param_names,
279            param_aliases: item
280                .param_aliases
281                .into_iter()
282                .map(|aliases| {
283                    aliases
284                        .into_iter()
285                        .map(|(locale, alias)| {
286                            let locale_key = Locale::new(&locale);
287                            let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
288                            Ok((locale_key, spellings))
289                        })
290                        .collect::<Result<HashMap<_, _>>>()
291                })
292                .collect::<Result<Vec<_>>>()?,
293            param_domains: item.param_domains,
294            param_defaults: item.param_defaults,
295            param_types: item.param_types,
296            param_coercions: item.param_coercions,
297            return_type: item.return_type,
298            variadic: item.variadic,
299            aliases,
300        });
301        Ok(())
302    }
303
304    fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
305        if self.localized_string_by_id.contains_key(&item.id) {
306            return Err(CatalogError::validation(format!(
307                "duplicate localized string id '{}'",
308                item.id
309            )));
310        }
311        let index = self.localized_strings.len();
312        let aliases = self.collect_aliases(
313            &item.id,
314            &format!("localized string '{}'", item.id),
315            item.aliases,
316            true,
317            |catalog, locale, spelling| {
318                let locale_map = catalog
319                    .localized_string_alias
320                    .entry(locale.clone())
321                    .or_default();
322                if locale_map.contains_key(spelling) {
323                    return Err(CatalogError::validation(format!(
324                        "duplicate localized string alias '{spelling}' for locale '{locale}'"
325                    )));
326                }
327                locale_map.insert(spelling.to_string(), index);
328                Ok(())
329            },
330        )?;
331        self.localized_string_by_id.insert(item.id.clone(), index);
332        self.localized_strings.push(LocalizedStringEntry {
333            id: item.id,
334            aliases,
335        });
336        Ok(())
337    }
338
339    /// Every declared `paramDomains` domain must name a declared enum domain.
340    fn validate_param_domains(&self) -> Result<()> {
341        for entry in &self.entries {
342            for (name, count) in [
343                ("parameter alias sets", entry.param_aliases.len()),
344                ("param domains", entry.param_domains.len()),
345                ("param defaults", entry.param_defaults.len()),
346                ("param types", entry.param_types.len()),
347                ("param coercions", entry.param_coercions.len()),
348            ] {
349                if count > entry.params.len() {
350                    return Err(CatalogError::validation(format!(
351                        "{} '{}' declares more {name} than params",
352                        entry.kind.as_str(),
353                        entry.id
354                    )));
355                }
356            }
357            if entry.kind != Kind::Value && entry.return_type.is_some() {
358                return Err(CatalogError::validation(format!(
359                    "{} '{}' declares a return type but is not a value",
360                    entry.kind.as_str(),
361                    entry.id
362                )));
363            }
364            for domain in entry.param_domains.iter().flatten() {
365                if !self.enum_by_domain.contains_key(domain) {
366                    return Err(CatalogError::validation(format!(
367                        "{} '{}' declares undeclared enum domain '{domain}'",
368                        entry.kind.as_str(),
369                        entry.id
370                    )));
371                }
372            }
373            for aliases in &entry.param_aliases {
374                for locale in aliases.keys() {
375                    if !self.locales.contains(locale) {
376                        return Err(CatalogError::validation(format!(
377                            "{} '{}' declares parameter alias for undeclared locale '{}'",
378                            entry.kind.as_str(),
379                            entry.id,
380                            locale
381                        )));
382                    }
383                }
384            }
385        }
386        Ok(())
387    }
388
389    fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
390        let domain_index = self.enums.len();
391        if self.enum_by_domain.contains_key(&domain.domain) {
392            return Err(CatalogError::validation(format!(
393                "duplicate enum domain '{}'",
394                domain.domain
395            )));
396        }
397        let primary = self.locales[0].clone();
398        let mut domain_aliases = self.collect_aliases(
399            &domain.domain,
400            &format!("enum domain '{}'", domain.domain),
401            domain.aliases,
402            false,
403            |catalog, locale, spelling| {
404                let locale_map = catalog
405                    .enum_alias_to_domain
406                    .entry(locale.clone())
407                    .or_default();
408                if let Some(existing) = locale_map.get(spelling) {
409                    return Err(CatalogError::validation(format!(
410                        "duplicate enum domain alias '{spelling}' for '{existing}' and '{}' in locale '{locale}'",
411                        domain.domain
412                    )));
413                }
414                locale_map.insert(spelling.to_string(), domain.domain.clone());
415                Ok(())
416            },
417        )?;
418        domain_aliases
419            .entry(primary.clone())
420            .or_insert_with(|| vec![domain.domain.clone()]);
421        self.enum_alias_to_domain
422            .entry(primary.clone())
423            .or_default()
424            .entry(domain.domain.clone())
425            .or_insert_with(|| domain.domain.clone());
426        let mut members = Vec::new();
427        for (member_index, member) in domain.members.into_iter().enumerate() {
428            let aliases = self.collect_aliases(
429                &member.id,
430                &format!("enum {}::{}", domain.domain, member.id),
431                member.aliases,
432                true,
433                |catalog, locale, spelling| {
434                    let domain_map = catalog
435                        .enum_alias_to_member
436                        .entry(locale.clone())
437                        .or_default()
438                        .entry(domain.domain.clone())
439                        .or_default();
440                    if domain_map.contains_key(spelling) {
441                        return Err(CatalogError::validation(format!(
442                            "duplicate enum alias '{spelling}' in '{}' for locale '{locale}'",
443                            domain.domain
444                        )));
445                    }
446                    domain_map.insert(spelling.to_string(), (domain_index, member_index));
447                    Ok(())
448                },
449            )?;
450            members.push(EnumMember {
451                member: member.id,
452                aliases,
453            });
454        }
455        self.enum_by_domain
456            .insert(domain.domain.clone(), domain_index);
457        self.enums.push(EnumDomain {
458            domain: domain.domain,
459            aliases: domain_aliases,
460            members,
461        });
462        Ok(())
463    }
464
465    /// Collect per-locale alias spellings for one catalog item: reject
466    /// undeclared locales, normalize each spelling list, hand every spelling
467    /// to `register` for the caller's index/dup check, and — when
468    /// `require_primary` — require coverage of the primary locale.
469    fn collect_aliases(
470        &mut self,
471        id: &str,
472        label: &str,
473        aliases: HashMap<String, AliasFile>,
474        require_primary: bool,
475        mut register: impl FnMut(&mut Self, &Locale, &str) -> Result<()>,
476    ) -> Result<HashMap<Locale, Vec<String>>> {
477        let mut collected = HashMap::new();
478        for (locale_str, alias_file) in aliases {
479            let locale = Locale::new(&locale_str);
480            if !self.locales.contains(&locale) {
481                return Err(CatalogError::validation(format!(
482                    "{label} declares alias for undeclared locale '{locale}'"
483                )));
484            }
485            let spellings = alias_file.into_spellings(id, locale.as_str())?;
486            for spelling in &spellings {
487                register(self, &locale, spelling)?;
488            }
489            collected.insert(locale, spellings);
490        }
491        let primary = &self.locales[0];
492        if require_primary && !collected.contains_key(primary) {
493            return Err(CatalogError::validation(format!(
494                "{label} is missing a '{primary}' alias"
495            )));
496        }
497        Ok(collected)
498    }
499}
500
501/// Canonicalize catalog data: parse, validate, and re-serialize
502/// deterministically (object keys sorted, stable formatting). Re-running on
503/// the same input produces byte-identical output, so the data pipeline is
504/// reproducible. Validation intentionally skips digest verification so a
505/// stale digest can be repaired by [`build_canonical`].
506pub fn canonicalize(json: &str) -> Result<String> {
507    // Validate the semantic content first.
508    Catalog::load_unverified(json)?;
509    let value: serde_json::Value = serde_json::from_str(json)
510        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
511    pretty_json(&value)
512}
513
514/// Rebuild the canonical catalog form with a fresh content digest: validate,
515/// canonicalize, and (re)write the `digest` field. Byte-idempotent, so the
516/// committed dataset and its digest are reproducible from the data file.
517pub fn build_canonical(json: &str) -> Result<String> {
518    let mut value: serde_json::Value = serde_json::from_str(json)
519        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
520    let digest = content_digest(json)?;
521    if let Some(object) = value.as_object_mut() {
522        object.insert("digest".to_string(), serde_json::Value::String(digest));
523    }
524    let output = pretty_json(&value)?;
525    // Validate the semantic content (including the fresh digest) before
526    // returning the rebuilt file.
527    Catalog::load(&output)?;
528    Ok(output)
529}
530
531/// The deterministic content digest of catalog data: sha256 of the canonical
532/// (sorted-key, pretty) serialization of the parsed content with the
533/// self-referential `digest` field removed. Independent of file formatting;
534/// changes whenever any semantic content changes.
535pub fn content_digest(json: &str) -> Result<String> {
536    let mut value: serde_json::Value = serde_json::from_str(json)
537        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
538    if let Some(object) = value.as_object_mut() {
539        object.remove("digest");
540    }
541    let canonical = pretty_json(&value)?;
542    use sha2::{Digest, Sha256};
543    let mut hasher = Sha256::new();
544    hasher.update(canonical.trim_end().as_bytes());
545    Ok(format!("{:x}", hasher.finalize()))
546}
547
548/// Sorted-key pretty JSON with a trailing newline — the canonical catalog
549/// byte form all digest and rebuild paths share.
550fn pretty_json(value: &serde_json::Value) -> Result<String> {
551    serde_json::to_string_pretty(value)
552        .map(|mut out| {
553            out.push('\n');
554            out
555        })
556        .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
557}