Skip to main content

workshop_rs/catalog/
mod.rs

1//! The canonical Workshop catalog.
2//!
3//! The catalog is the locale-independent semantic identity layer between
4//! textual Workshop spellings and WIR. Every builtin has a canonical `id` and
5//! a [`Kind`]; locale tables map canonical identities to client spellings and
6//! back, so parser, emitter, analyzer, and tooling never embed
7//! locale-specific strings as identity.
8//!
9//! Locale coverage is data ([`docs/adr/0001-catalog-boundaries.md`]):
10//! the primary locale (the first declared one, `en-US`) is complete — every
11//! entry and enum member carries a primary-locale alias — while additional
12//! declared locales may be partially covered. Missing target-locale mappings
13//! fail explicitly at conversion/emission time; the catalog reports exact
14//! per-locale coverage machine-readably ([`Catalog::locale_coverage`],
15//! [`Catalog::identity`]).
16//!
17//! The catalog dataset declares its own `version` and a deterministic content
18//! `digest` (sha256) recomputed by the catalog pipeline
19//! (`workshop-catalog-gen build`); [`Catalog::load`] rejects a digest
20//! mismatch, so dataset changes are deliberate and reproducible.
21
22use std::collections::HashMap;
23
24use serde::{Deserialize, Deserializer, Serialize};
25
26use crate::signatures::ExpectedDomain;
27
28use crate::error::{CatalogError, Result};
29
30/// The embedded catalog data.
31pub const CATALOG_DATA: &str = include_str!("data/catalog.json");
32
33/// A normalized Workshop client locale, e.g. `en-US`.
34#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
35pub struct Locale(String);
36
37impl Locale {
38    /// Build a locale from a client spelling, normalized to lowercase.
39    pub fn new(value: &str) -> Locale {
40        Locale(value.trim().to_ascii_lowercase())
41    }
42
43    /// The normalized locale string.
44    pub fn as_str(&self) -> &str {
45        &self.0
46    }
47}
48
49impl<'de> Deserialize<'de> for Locale {
50    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
51    where
52        D: Deserializer<'de>,
53    {
54        let value = String::deserialize(deserializer)?;
55        Ok(Self::new(&value))
56    }
57}
58
59impl std::fmt::Display for Locale {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.write_str(&self.0)
62    }
63}
64
65/// The kind of a catalog builtin.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum Kind {
68    /// A structural keyword (If, End, Set Global Variable, …).
69    Structural,
70    /// An action function.
71    Action,
72    /// A value function.
73    Value,
74    /// An event.
75    Event,
76    /// An operator token (comparison operators).
77    Operator,
78    /// An enumerated value domain.
79    Enum,
80    /// A settings entry.
81    Setting,
82}
83
84impl Kind {
85    pub fn as_str(self) -> &'static str {
86        match self {
87            Kind::Structural => "structural",
88            Kind::Action => "action",
89            Kind::Value => "value",
90            Kind::Event => "event",
91            Kind::Operator => "operator",
92            Kind::Enum => "enum",
93            Kind::Setting => "setting",
94        }
95    }
96}
97
98/// One catalog builtin.
99#[derive(Debug, Clone)]
100pub struct CatalogEntry {
101    pub id: String,
102    pub kind: Kind,
103    /// Parameter names, when the catalog documents them.
104    pub params: Vec<String>,
105    /// The canonical enum domain expected at each parameter position, when
106    /// the parameter takes an enumerated value (parallel to `params`).
107    /// `None` for non-enum parameters and for parameters whose accepted
108    /// values span multiple canonical domains. In particular, a filtered
109    /// rule event's `Player` parameter accepts `EventPlayer` members or
110    /// canonical `Hero` members; the WIR [`crate::wir::EventTarget`] carries
111    /// that union explicitly.
112    pub param_domains: Vec<Option<String>>,
113    /// Default value per parameter position (parallel to `params`),
114    /// resolved when a call omits the argument. See the catalog data
115    /// provenance for the value syntax and evidence.
116    pub param_defaults: Vec<Option<String>>,
117    /// Evidence-backed semantic type per parameter position. `None` means
118    /// the available sources do not prove a narrower type.
119    pub param_types: Vec<Option<String>>,
120    /// Evidence-backed return type for Value entries. Actions must leave this
121    /// unset; an absent value is intentionally evidence-insufficient.
122    pub return_type: Option<String>,
123    /// Whether the final declared parameter repeats for additional arguments.
124    pub variadic: bool,
125    aliases: HashMap<Locale, Vec<String>>,
126}
127
128/// A locale-independent identity for a preset used by the Workshop `String`
129/// value. Unlike a custom `Value::String`, this identity must resolve through
130/// reviewed client-locale aliases before it can be parsed or emitted.
131#[derive(Debug, Clone)]
132pub struct LocalizedStringEntry {
133    pub id: String,
134    aliases: HashMap<Locale, Vec<String>>,
135}
136
137impl LocalizedStringEntry {
138    /// The deterministic emitted spelling in `locale`, when mapped.
139    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
140        self.aliases
141            .get(locale)
142            .and_then(|spellings| spellings.first())
143            .map(String::as_str)
144    }
145
146    /// All reviewed spellings accepted for this locale.
147    pub fn spellings(&self, locale: &Locale) -> &[String] {
148        self.aliases
149            .get(locale)
150            .map(Vec::as_slice)
151            .unwrap_or_default()
152    }
153}
154
155impl CatalogEntry {
156    /// The localized spelling of this builtin in `locale`, when declared.
157    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
158        self.aliases
159            .get(locale)
160            .and_then(|spellings| spellings.first())
161            .map(String::as_str)
162    }
163
164    /// Every reviewed localized spelling of this builtin, with the first
165    /// spelling reserved for deterministic emission.
166    pub fn spellings(&self, locale: &Locale) -> &[String] {
167        self.aliases.get(locale).map(Vec::as_slice).unwrap_or(&[])
168    }
169
170    /// The number of declared arguments for this builtin.
171    pub fn param_count(&self) -> usize {
172        self.params.len()
173    }
174
175    /// The number of arguments that must be present when trailing defaults
176    /// are applied. A missing default in the middle of a signature remains a
177    /// required position; defaults only make the suffix optional.
178    pub fn required_param_count(&self) -> usize {
179        (0..self.params.len())
180            .rev()
181            .find(|index| {
182                self.param_defaults
183                    .get(*index)
184                    .and_then(Option::as_ref)
185                    .is_none()
186            })
187            .map_or(0, |index| index + 1)
188    }
189
190    /// The declared enum domain for an argument position, when one exists.
191    pub fn param_domain(&self, index: usize) -> Option<&str> {
192        self.param_domains
193            .get(index)
194            .or_else(|| self.variadic.then(|| self.param_domains.last()).flatten())
195            .and_then(Option::as_deref)
196    }
197
198    /// The evidence-backed semantic type for an argument position, when
199    /// available. Enum domains remain exposed separately by `param_domain`.
200    pub fn param_type(&self, index: usize) -> Option<&str> {
201        self.param_types
202            .get(index)
203            .or_else(|| self.variadic.then(|| self.param_types.last()).flatten())
204            .and_then(Option::as_deref)
205    }
206
207    /// The evidence-backed return type of a Value, when available.
208    pub fn return_type(&self) -> Option<&str> {
209        self.return_type.as_deref()
210    }
211}
212
213/// One enum member within a domain.
214#[derive(Debug, Clone)]
215pub struct EnumMember {
216    pub member: String,
217    aliases: HashMap<Locale, Vec<String>>,
218}
219
220impl EnumMember {
221    /// The localized spelling of this member in `locale`, when declared.
222    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
223        self.aliases
224            .get(locale)
225            .and_then(|spellings| spellings.first())
226            .map(String::as_str)
227    }
228
229    /// Every reviewed localized spelling of this enum member, with the first
230    /// spelling reserved for deterministic emission.
231    pub fn spellings(&self, locale: &Locale) -> &[String] {
232        self.aliases.get(locale).map(Vec::as_slice).unwrap_or(&[])
233    }
234}
235
236/// One enum value domain (e.g. `Color`, `Beam`).
237#[derive(Debug, Clone)]
238pub struct EnumDomain {
239    pub domain: String,
240    aliases: HashMap<Locale, Vec<String>>,
241    pub members: Vec<EnumMember>,
242}
243
244impl EnumDomain {
245    pub fn spelling(&self, locale: &Locale) -> Option<&str> {
246        self.aliases
247            .get(locale)
248            .and_then(|spellings| spellings.first())
249            .map(String::as_str)
250    }
251}
252
253/// Target-format metadata recorded in the catalog.
254#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
255pub struct TargetMeta {
256    pub game: String,
257    pub format: String,
258    pub surface: String,
259}
260
261/// Provenance of the catalog data.
262#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
263#[serde(rename_all = "camelCase")]
264pub struct Provenance {
265    pub generator: String,
266    pub generator_version: String,
267    pub source: String,
268    pub license: String,
269    pub reviewed: bool,
270    /// Additional immutable observations that qualify the dataset source,
271    /// including reviewed spelling conflicts retained as parse aliases.
272    #[serde(default, skip_serializing_if = "Vec::is_empty")]
273    pub source_notes: Vec<String>,
274}
275
276/// Per-locale mapping coverage: how many canonical entries (builtins,
277/// localized preset identities, and enum members) carry a mapping for the
278/// locale out of the declared total.
279#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
280pub struct LocaleCoverage {
281    pub locale: Locale,
282    /// Canonical entries with a declared mapping in this locale.
283    pub mapped: usize,
284    /// Canonical entries (builtins and enum members) declared by the catalog.
285    pub total: usize,
286}
287
288/// The machine-readable catalog identity (ADR-0001 Decision 5): the four
289/// identities that evolve independently — implementation version, catalog
290/// dataset version plus content digest, locale coverage, and target evidence
291/// — plus the data provenance record. Serialized with the ADR's kebab-case
292/// identity names.
293#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
294#[serde(rename_all = "kebab-case")]
295pub struct CatalogIdentity {
296    /// The `workshop-rs` package version (semver); bumped by code changes.
297    pub implementation_version: String,
298    /// The catalog dataset version; bumped by any dataset change.
299    pub catalog_version: String,
300    /// The deterministic content digest (sha256 hex) computed by the
301    /// pipeline; `None` when the data does not declare one.
302    pub catalog_digest: Option<String>,
303    /// Declared locales with per-locale mapping counts.
304    pub locale_coverage: Vec<LocaleCoverage>,
305    /// The declared target surface.
306    pub target: TargetMeta,
307    /// The provenance record of the catalog data.
308    pub provenance: Provenance,
309}
310
311/// The validated canonical Workshop catalog.
312#[derive(Debug, Clone)]
313pub struct Catalog {
314    pub schema_version: u32,
315    /// The declared locales, normalized; the first one is the primary
316    /// locale and must be fully covered.
317    pub locales: Vec<Locale>,
318    pub target: TargetMeta,
319    pub provenance: Provenance,
320    /// The catalog dataset version (ADR-0001 `catalog-version`).
321    catalog_version: String,
322    /// The declared content digest (sha256 hex), verified at load when
323    /// present (ADR-0001 `catalog-version`).
324    catalog_digest: Option<String>,
325    entries: Vec<CatalogEntry>,
326    localized_strings: Vec<LocalizedStringEntry>,
327    enums: Vec<EnumDomain>,
328    by_id: HashMap<(Kind, String), usize>,
329    alias_to_entry: HashMap<(Kind, Locale, String), usize>,
330    localized_string_by_id: HashMap<String, usize>,
331    localized_string_alias: HashMap<(Locale, String), usize>,
332    enum_by_domain: HashMap<String, usize>,
333    enum_alias_to_domain: HashMap<(Locale, String), String>,
334    enum_alias_to_member: HashMap<(String, Locale, String), (usize, usize)>,
335}
336
337#[derive(Deserialize)]
338#[serde(rename_all = "camelCase")]
339struct CatalogFile {
340    schema_version: u32,
341    locales: Vec<String>,
342    target: TargetMeta,
343    provenance: Provenance,
344    /// The catalog dataset version; absent in ad-hoc test data.
345    #[serde(default)]
346    version: Option<String>,
347    /// The declared content digest; absent in ad-hoc test data.
348    #[serde(default)]
349    digest: Option<String>,
350    #[serde(default)]
351    structural: Vec<EntryFile>,
352    #[serde(default)]
353    actions: Vec<EntryFile>,
354    #[serde(default)]
355    values: Vec<EntryFile>,
356    #[serde(default)]
357    events: Vec<EntryFile>,
358    #[serde(default)]
359    operators: Vec<EntryFile>,
360    #[serde(default)]
361    settings: Vec<EntryFile>,
362    #[serde(default)]
363    localized_strings: Vec<LocalizedStringFile>,
364    #[serde(default)]
365    enums: Vec<EnumFile>,
366}
367
368#[derive(Deserialize)]
369#[serde(rename_all = "camelCase")]
370struct EntryFile {
371    id: String,
372    aliases: HashMap<String, AliasFile>,
373    #[serde(default)]
374    params: Vec<String>,
375    /// Canonical enum domain per parameter position (parallel to `params`);
376    /// empty when no parameter domains are documented.
377    #[serde(default)]
378    param_domains: Vec<Option<String>>,
379    /// Default value per parameter position (parallel to `params`),
380    /// resolved when a call omits the argument. `None` means no default is
381    /// declared. Default value syntax: `null`, a numeric literal, localized
382    /// string text, `Domain.MEMBER` (builtin enum member), or a catalog value
383    /// id resolved as a zero-argument call. Every default is pinned-reference
384    /// probe evidence, never copied from upstream game data.
385    #[serde(default)]
386    param_defaults: Vec<Option<String>>,
387    #[serde(default)]
388    param_types: Vec<Option<String>>,
389    #[serde(default)]
390    return_type: Option<String>,
391    #[serde(default)]
392    variadic: bool,
393}
394
395#[derive(Deserialize)]
396struct LocalizedStringFile {
397    id: String,
398    aliases: HashMap<String, AliasFile>,
399}
400
401#[derive(Deserialize)]
402struct EnumFile {
403    domain: String,
404    #[serde(default)]
405    aliases: HashMap<String, AliasFile>,
406    members: Vec<MemberFile>,
407}
408
409#[derive(Deserialize)]
410struct MemberFile {
411    id: String,
412    aliases: HashMap<String, AliasFile>,
413}
414
415/// A locale may have one canonical emitter spelling or several reviewed
416/// spellings observed across current Workshop producers. The string form is
417/// retained for the common case; the array form makes conflicts explicit in
418/// the data instead of forcing parser branches or silently choosing one.
419#[derive(Debug, Deserialize)]
420#[serde(untagged)]
421enum AliasFile {
422    One(String),
423    Many(Vec<String>),
424}
425
426impl AliasFile {
427    fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
428        let spellings = match self {
429            AliasFile::One(spelling) => vec![spelling],
430            AliasFile::Many(spellings) => spellings,
431        };
432        if spellings.is_empty() || spellings.iter().any(String::is_empty) {
433            return Err(CatalogError::validation(format!(
434                "catalog entry '{}' declares an empty alias for locale '{}'",
435                id, locale
436            )));
437        }
438        Ok(spellings)
439    }
440}
441
442impl Catalog {
443    /// Parse and validate catalog data, verifying the declared content
444    /// digest when the data carries one.
445    pub fn load(json: &str) -> Result<Catalog> {
446        let catalog = Self::load_unverified(json)?;
447        if let Some(declared) = &catalog.catalog_digest {
448            let computed = content_digest(json)?;
449            if declared != &computed {
450                return Err(CatalogError::validation(format!(
451                    "catalog digest mismatch: declared '{declared}', content '{computed}' — \
452                     run the catalog pipeline (workshop-catalog-gen build)"
453                )));
454            }
455        }
456        Ok(catalog)
457    }
458
459    /// Parse and validate catalog data without digest verification. Used by
460    /// the catalog pipeline so a stale digest can be repaired by `build`.
461    pub fn load_unverified(json: &str) -> Result<Catalog> {
462        let file: CatalogFile = serde_json::from_str(json)
463            .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
464        if file.schema_version != 1 {
465            return Err(CatalogError::malformed(format!(
466                "unsupported catalog schemaVersion {}",
467                file.schema_version
468            )));
469        }
470        let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
471        if locales.is_empty() {
472            return Err(CatalogError::malformed(
473                "catalog declares no locales".to_string(),
474            ));
475        }
476
477        let mut catalog = Catalog {
478            schema_version: file.schema_version,
479            locales,
480            target: file.target,
481            provenance: file.provenance,
482            catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
483            catalog_digest: file.digest,
484            entries: Vec::new(),
485            localized_strings: Vec::new(),
486            enums: Vec::new(),
487            by_id: HashMap::new(),
488            alias_to_entry: HashMap::new(),
489            localized_string_by_id: HashMap::new(),
490            localized_string_alias: HashMap::new(),
491            enum_by_domain: HashMap::new(),
492            enum_alias_to_domain: HashMap::new(),
493            enum_alias_to_member: HashMap::new(),
494        };
495
496        for (kind, items) in [
497            (Kind::Structural, file.structural),
498            (Kind::Action, file.actions),
499            (Kind::Value, file.values),
500            (Kind::Event, file.events),
501            (Kind::Operator, file.operators),
502            (Kind::Setting, file.settings),
503        ] {
504            for item in items {
505                catalog.insert_entry(kind, item)?;
506            }
507        }
508        for item in file.localized_strings {
509            catalog.insert_localized_string(item)?;
510        }
511        for domain in file.enums {
512            catalog.insert_enum(domain)?;
513        }
514        catalog.validate_param_domains()?;
515        Ok(catalog)
516    }
517
518    /// The built-in catalog data.
519    pub fn builtin() -> Result<Catalog> {
520        Self::load(CATALOG_DATA)
521    }
522
523    /// The declared locales, normalized; the first one is the primary locale.
524    pub fn locales(&self) -> &[Locale] {
525        &self.locales
526    }
527
528    /// The primary locale: the first declared one, whose mapping surface is
529    /// complete (`en-US` in the committed catalog).
530    pub fn primary_locale(&self) -> &Locale {
531        &self.locales[0]
532    }
533
534    /// Whether a locale is declared by the catalog.
535    pub fn supports(&self, locale: &Locale) -> bool {
536        self.locales.contains(locale)
537    }
538
539    /// The catalog dataset version (ADR-0001 `catalog-version`).
540    pub fn catalog_version(&self) -> &str {
541        &self.catalog_version
542    }
543
544    /// The declared content digest (sha256 hex) of the catalog dataset,
545    /// verified at load; `None` for data that declares none.
546    pub fn catalog_digest(&self) -> Option<&str> {
547        self.catalog_digest.as_deref()
548    }
549
550    /// The `workshop-rs` package version (ADR-0001 `implementation-version`).
551    pub fn implementation_version() -> &'static str {
552        env!("CARGO_PKG_VERSION")
553    }
554
555    /// The machine-readable catalog identity: implementation version, catalog
556    /// version + digest, locale coverage, target evidence, and provenance.
557    pub fn identity(&self) -> CatalogIdentity {
558        CatalogIdentity {
559            implementation_version: Self::implementation_version().to_string(),
560            catalog_version: self.catalog_version.clone(),
561            catalog_digest: self.catalog_digest.clone(),
562            locale_coverage: self
563                .locales
564                .iter()
565                .map(|locale| self.locale_coverage(locale))
566                .collect(),
567            target: self.target.clone(),
568            provenance: self.provenance.clone(),
569        }
570    }
571
572    /// The mapping coverage of one declared locale: mapped entries out of the
573    /// declared total (builtins, localized preset identities, and enum members).
574    /// The primary locale is
575    /// always complete; other locales may be partially covered.
576    pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
577        let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
578        let total = self.entries.len() + self.localized_strings.len() + member_total;
579        let mapped = self
580            .entries
581            .iter()
582            .filter(|entry| entry.aliases.contains_key(locale))
583            .count()
584            + self
585                .localized_strings
586                .iter()
587                .filter(|entry| entry.aliases.contains_key(locale))
588                .count()
589            + self
590                .enums
591                .iter()
592                .flat_map(|domain| &domain.members)
593                .filter(|member| member.aliases.contains_key(locale))
594                .count();
595        LocaleCoverage {
596            locale: locale.clone(),
597            mapped,
598            total,
599        }
600    }
601
602    /// The mapping coverage of every declared locale, in declaration order.
603    pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
604        self.locales
605            .iter()
606            .map(|locale| self.locale_coverage(locale))
607            .collect()
608    }
609
610    /// The builtin with the given canonical id and kind.
611    pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
612        self.by_id
613            .get(&(kind, id.to_string()))
614            .map(|i| &self.entries[*i])
615    }
616
617    /// Resolve a localized spelling to its canonical builtin.
618    pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
619        self.alias_to_entry
620            .get(&(kind, locale.clone(), spelling.to_string()))
621            .map(|i| &self.entries[*i])
622    }
623
624    /// The localized spelling of a canonical builtin id.
625    pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
626        self.entry(kind, id)?.spelling(locale)
627    }
628
629    /// Every entry of a kind, in catalog order.
630    pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
631        self.entries.iter().filter(move |entry| entry.kind == kind)
632    }
633
634    /// Resolve a localized preset spelling to its stable identity.
635    pub fn resolve_localized_string(
636        &self,
637        locale: &Locale,
638        spelling: &str,
639    ) -> Option<&LocalizedStringEntry> {
640        self.localized_string_alias
641            .get(&(locale.clone(), spelling.to_string()))
642            .map(|index| &self.localized_strings[*index])
643    }
644
645    /// Resolve the emitted spelling of a localized preset identity.
646    pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
647        self.localized_strings
648            .get(*self.localized_string_by_id.get(id)?)
649            .and_then(|entry| entry.spelling(locale))
650    }
651
652    /// Every reviewed localized preset identity, in catalog order.
653    pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
654        self.localized_strings.iter()
655    }
656
657    /// The total number of builtin entries.
658    pub fn entry_count(&self) -> usize {
659        self.entries.len()
660    }
661
662    /// The number of enum domains.
663    pub fn enum_domains_count(&self) -> usize {
664        self.enums.len()
665    }
666
667    /// The enum domain with the given name.
668    pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
669        self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
670    }
671
672    /// Resolve a localized enum-domain spelling to its canonical domain id.
673    pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
674        self.enum_by_domain
675            .get_key_value(spelling)
676            .map(|(domain, _)| domain.as_str())
677            .or_else(|| {
678                self.enum_alias_to_domain
679                    .get(&(locale.clone(), spelling.to_string()))
680                    .map(String::as_str)
681            })
682    }
683
684    /// Every enum domain, in catalog order.
685    pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
686        self.enums.iter()
687    }
688
689    /// Resolve a localized enum member spelling to `(domain, canonical member)`.
690    pub fn resolve_enum_member(
691        &self,
692        domain: &str,
693        locale: &Locale,
694        spelling: &str,
695    ) -> Option<(String, String)> {
696        let (domain_index, member_index) = self.enum_alias_to_member.get(&(
697            domain.to_string(),
698            locale.clone(),
699            spelling.to_string(),
700        ))?;
701        Some((
702            domain.to_string(),
703            self.enums[*domain_index].members[*member_index]
704                .member
705                .clone(),
706        ))
707    }
708
709    /// The localized spelling of a canonical enum member.
710    pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
711        let domain_index = self.enum_by_domain.get(domain)?;
712        let domain = &self.enums[*domain_index];
713        domain
714            .members
715            .iter()
716            .find(|candidate| candidate.member == member)?
717            .spelling(locale)
718    }
719
720    /// Every `(domain, canonical member)` match for a bare (domain-less)
721    /// localized member spelling. Returns all matches so callers can report
722    /// ambiguity; a well-formed catalog has at most one meaningful match for
723    /// a given spelling.
724    pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
725        let mut matches = Vec::new();
726        for domain in &self.enums {
727            for member in &domain.members {
728                if member
729                    .spellings(locale)
730                    .iter()
731                    .any(|alias| alias == spelling)
732                {
733                    matches.push((domain.domain.clone(), member.member.clone()));
734                }
735            }
736        }
737        matches
738    }
739
740    fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
741        let index = self.entries.len();
742        let mut aliases = HashMap::new();
743        for (locale_str, alias_file) in item.aliases {
744            let locale = Locale::new(&locale_str);
745            if !self.locales.contains(&locale) {
746                return Err(CatalogError::validation(format!(
747                    "entry '{}' declares alias for undeclared locale '{}'",
748                    item.id, locale
749                )));
750            }
751            let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
752            for spelling in &spellings {
753                let key = (kind, locale.clone(), spelling.clone());
754                if self.alias_to_entry.contains_key(&key) {
755                    return Err(CatalogError::validation(format!(
756                        "duplicate {} alias '{spelling}' for locale '{}'",
757                        kind.as_str(),
758                        locale
759                    )));
760                }
761                self.alias_to_entry.insert(key, index);
762            }
763            aliases.insert(locale, spellings);
764        }
765        let id_key = (kind, item.id.clone());
766        if self.by_id.contains_key(&id_key) {
767            return Err(CatalogError::validation(format!(
768                "duplicate {} id '{}'",
769                kind.as_str(),
770                item.id
771            )));
772        }
773        // The primary locale's surface is complete: every builtin carries a
774        // primary-locale alias. Additional declared locales may be partially
775        // covered; missing target-locale mappings fail explicitly at
776        // conversion/emission time (ADR-0001 Decision 7).
777        let primary = self.locales[0].clone();
778        if !aliases.contains_key(&primary) {
779            return Err(CatalogError::validation(format!(
780                "{} '{}' is missing a '{}' alias",
781                kind.as_str(),
782                item.id,
783                primary
784            )));
785        }
786        self.by_id.insert(id_key, index);
787        self.entries.push(CatalogEntry {
788            id: item.id,
789            kind,
790            params: item.params,
791            param_domains: item.param_domains,
792            param_defaults: item.param_defaults,
793            param_types: item.param_types,
794            return_type: item.return_type,
795            variadic: item.variadic,
796            aliases,
797        });
798        Ok(())
799    }
800
801    fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
802        if self.localized_string_by_id.contains_key(&item.id) {
803            return Err(CatalogError::validation(format!(
804                "duplicate localized string id '{}'",
805                item.id
806            )));
807        }
808        let index = self.localized_strings.len();
809        let mut aliases = HashMap::new();
810        for (locale_str, alias_file) in item.aliases {
811            let locale = Locale::new(&locale_str);
812            if !self.locales.contains(&locale) {
813                return Err(CatalogError::validation(format!(
814                    "localized string '{}' declares alias for undeclared locale '{}'",
815                    item.id, locale
816                )));
817            }
818            let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
819            for spelling in &spellings {
820                let key = (locale.clone(), spelling.clone());
821                if self.localized_string_alias.contains_key(&key) {
822                    return Err(CatalogError::validation(format!(
823                        "duplicate localized string alias '{spelling}' for locale '{locale}'"
824                    )));
825                }
826                self.localized_string_alias.insert(key, index);
827            }
828            aliases.insert(locale, spellings);
829        }
830        let primary = self.locales[0].clone();
831        if !aliases.contains_key(&primary) {
832            return Err(CatalogError::validation(format!(
833                "localized string '{}' is missing a '{}' alias",
834                item.id, primary
835            )));
836        }
837        self.localized_string_by_id.insert(item.id.clone(), index);
838        self.localized_strings.push(LocalizedStringEntry {
839            id: item.id,
840            aliases,
841        });
842        Ok(())
843    }
844
845    /// Every declared `paramDomains` domain must name a declared enum domain.
846    fn validate_param_domains(&self) -> Result<()> {
847        for entry in &self.entries {
848            if entry.param_domains.len() > entry.params.len() {
849                return Err(CatalogError::validation(format!(
850                    "{} '{}' declares more param domains than params",
851                    entry.kind.as_str(),
852                    entry.id
853                )));
854            }
855            if entry.param_defaults.len() > entry.params.len() {
856                return Err(CatalogError::validation(format!(
857                    "{} '{}' declares more param defaults than params",
858                    entry.kind.as_str(),
859                    entry.id
860                )));
861            }
862            if entry.param_types.len() > entry.params.len() {
863                return Err(CatalogError::validation(format!(
864                    "{} '{}' declares more param types than params",
865                    entry.kind.as_str(),
866                    entry.id
867                )));
868            }
869            if entry.kind != Kind::Value && entry.return_type.is_some() {
870                return Err(CatalogError::validation(format!(
871                    "{} '{}' declares a return type but is not a value",
872                    entry.kind.as_str(),
873                    entry.id
874                )));
875            }
876            for domain in entry.param_domains.iter().flatten() {
877                if !self.enum_by_domain.contains_key(domain) {
878                    return Err(CatalogError::validation(format!(
879                        "{} '{}' declares undeclared enum domain '{domain}'",
880                        entry.kind.as_str(),
881                        entry.id
882                    )));
883                }
884            }
885        }
886        Ok(())
887    }
888
889    fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
890        let domain_index = self.enums.len();
891        if self.enum_by_domain.contains_key(&domain.domain) {
892            return Err(CatalogError::validation(format!(
893                "duplicate enum domain '{}'",
894                domain.domain
895            )));
896        }
897        let primary = self.locales[0].clone();
898        let mut domain_aliases = HashMap::new();
899        for (locale_str, alias_file) in domain.aliases {
900            let locale = Locale::new(&locale_str);
901            if !self.locales.contains(&locale) {
902                return Err(CatalogError::validation(format!(
903                    "enum domain '{}' declares alias for undeclared locale '{}'",
904                    domain.domain, locale
905                )));
906            }
907            let spellings = alias_file.into_spellings(&domain.domain, locale.as_str())?;
908            for spelling in &spellings {
909                let key = (locale.clone(), spelling.clone());
910                if let Some(existing) = self.enum_alias_to_domain.get(&key) {
911                    return Err(CatalogError::validation(format!(
912                        "duplicate enum domain alias '{spelling}' for '{}' and '{}' in locale '{}'",
913                        existing, domain.domain, locale
914                    )));
915                }
916                self.enum_alias_to_domain.insert(key, domain.domain.clone());
917            }
918            domain_aliases.insert(locale, spellings);
919        }
920        domain_aliases
921            .entry(primary.clone())
922            .or_insert_with(|| vec![domain.domain.clone()]);
923        self.enum_alias_to_domain
924            .entry((primary.clone(), domain.domain.clone()))
925            .or_insert_with(|| domain.domain.clone());
926        let mut members = Vec::new();
927        for (member_index, member) in domain.members.into_iter().enumerate() {
928            let mut aliases = HashMap::new();
929            for (locale_str, alias_file) in member.aliases {
930                let locale = Locale::new(&locale_str);
931                if !self.locales.contains(&locale) {
932                    return Err(CatalogError::validation(format!(
933                        "enum {}::{} declares alias for undeclared locale '{}'",
934                        domain.domain, member.id, locale
935                    )));
936                }
937                let spellings = alias_file.into_spellings(&member.id, locale.as_str())?;
938                for spelling in &spellings {
939                    let key = (domain.domain.clone(), locale.clone(), spelling.clone());
940                    if self.enum_alias_to_member.contains_key(&key) {
941                        return Err(CatalogError::validation(format!(
942                            "duplicate enum alias '{spelling}' in '{}' for locale '{}'",
943                            domain.domain, locale
944                        )));
945                    }
946                    self.enum_alias_to_member
947                        .insert(key, (domain_index, member_index));
948                }
949                aliases.insert(locale, spellings);
950            }
951            if !aliases.contains_key(&primary) {
952                return Err(CatalogError::validation(format!(
953                    "enum {}::{} is missing a '{}' alias",
954                    domain.domain, member.id, primary
955                )));
956            }
957            members.push(EnumMember {
958                member: member.id,
959                aliases,
960            });
961        }
962        self.enum_by_domain
963            .insert(domain.domain.clone(), domain_index);
964        self.enums.push(EnumDomain {
965            domain: domain.domain,
966            aliases: domain_aliases,
967            members,
968        });
969        Ok(())
970    }
971}
972
973/// The catalog is the canonical source of expected enum domains for the
974/// Workshop surface it documents: `expected_domain(catalog_id, arg_index)`
975/// answers the domain declared for that parameter position (e.g. `createHudText`
976/// argument 9 is `HudReeval`), so the Workshop parser can resolve bare enum
977/// members that are ambiguous across domains (e.g. `Visible To and String`).
978/// Positions without a documented domain answer `None`.
979impl ExpectedDomain for Catalog {
980    fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
981        for kind in [Kind::Action, Kind::Value] {
982            if let Some(entry) = self.entry(kind, catalog_id) {
983                if let Some(domain) = entry
984                    .param_domains
985                    .get(arg_index)
986                    .and_then(Option::as_deref)
987                {
988                    return Some(domain);
989                }
990            }
991        }
992        None
993    }
994}
995
996/// Canonicalize catalog data: parse, validate, and re-serialize
997/// deterministically (object keys sorted, stable formatting). Re-running on
998/// the same input produces byte-identical output, so the data pipeline is
999/// reproducible. Validation intentionally skips digest verification so a
1000/// stale digest can be repaired by [`build_canonical`].
1001pub fn canonicalize(json: &str) -> Result<String> {
1002    // Validate the semantic content first.
1003    Catalog::load_unverified(json)?;
1004    let value: serde_json::Value = serde_json::from_str(json)
1005        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1006    serde_json::to_string_pretty(&value)
1007        .map(|mut out| {
1008            out.push('\n');
1009            out
1010        })
1011        .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
1012}
1013
1014/// Rebuild the canonical catalog form with a fresh content digest: validate,
1015/// canonicalize, and (re)write the `digest` field. Byte-idempotent, so the
1016/// committed dataset and its digest are reproducible from the data file.
1017pub fn build_canonical(json: &str) -> Result<String> {
1018    let mut value: serde_json::Value = serde_json::from_str(json)
1019        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1020    let digest = content_digest(json)?;
1021    if let Some(object) = value.as_object_mut() {
1022        object.insert("digest".to_string(), serde_json::Value::String(digest));
1023    }
1024    let output = serde_json::to_string_pretty(&value)
1025        .map(|mut out| {
1026            out.push('\n');
1027            out
1028        })
1029        .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1030    // Validate the semantic content (including the fresh digest) before
1031    // returning the rebuilt file.
1032    Catalog::load(&output)?;
1033    Ok(output)
1034}
1035
1036/// The deterministic content digest of catalog data: sha256 of the canonical
1037/// (sorted-key, pretty) serialization of the parsed content with the
1038/// self-referential `digest` field removed. Independent of file formatting;
1039/// changes whenever any semantic content changes.
1040pub fn content_digest(json: &str) -> Result<String> {
1041    let mut value: serde_json::Value = serde_json::from_str(json)
1042        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1043    if let Some(object) = value.as_object_mut() {
1044        object.remove("digest");
1045    }
1046    let canonical = serde_json::to_string_pretty(&value)
1047        .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1048    use sha2::{Digest, Sha256};
1049    let mut hasher = Sha256::new();
1050    hasher.update(canonical.as_bytes());
1051    Ok(format!("{:x}", hasher.finalize()))
1052}