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