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