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