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