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