Skip to main content

workshop_rs/catalog/
mod.rs

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