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. The authored
107/// literal is kept in WIR; these facts only decide acceptance.
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")]
114#[non_exhaustive]
115pub struct ParamCoercions {
116    /// Accept `False` as numeric zero.
117    #[serde(default)]
118    pub false_as_number: bool,
119    /// Accept `True` as numeric one.
120    #[serde(default)]
121    pub true_as_number: bool,
122    /// Accept numeric zero as `Null`.
123    #[serde(default)]
124    pub zero_as_null: bool,
125    /// Accept `Vector(0, 0, 0)` as `Null`.
126    #[serde(default)]
127    pub null_vector_as_null: bool,
128    /// Accept `Empty Array` as an empty string.
129    #[serde(default)]
130    pub empty_array_as_string: bool,
131}
132
133/// One catalog builtin.
134#[derive(Debug, Clone)]
135pub struct CatalogEntry {
136    pub id: String,
137    pub kind: Kind,
138    /// Parameter names, when the catalog documents them.
139    params: Vec<String>,
140    /// Reviewed semantic parameter names, 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)]
355#[non_exhaustive]
356pub struct TargetMeta {
357    pub game: String,
358    pub format: String,
359    pub surface: String,
360}
361
362/// Provenance of the catalog data.
363#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
364#[serde(rename_all = "camelCase")]
365#[non_exhaustive]
366pub struct Provenance {
367    pub generator: String,
368    pub generator_version: String,
369    pub source: String,
370    pub license: String,
371    pub reviewed: bool,
372    /// Additional immutable observations that qualify the dataset source,
373    /// including reviewed spelling conflicts retained as parse aliases.
374    #[serde(default, skip_serializing_if = "Vec::is_empty")]
375    pub source_notes: Vec<String>,
376}
377
378/// Per-locale mapping coverage: how many canonical entries (builtins,
379/// localized preset identities, and enum members) carry a mapping for the
380/// locale out of the declared total.
381#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
382#[non_exhaustive]
383pub struct LocaleCoverage {
384    pub locale: Locale,
385    /// Canonical entries with a declared mapping in this locale.
386    pub mapped: usize,
387    /// Canonical entries (builtins and enum members) declared by the catalog.
388    pub total: usize,
389}
390
391/// The machine-readable catalog identity (ADR-0001 Decision 5): the four
392/// identities that evolve independently — implementation version, catalog
393/// dataset version plus content digest, locale coverage, and target evidence
394/// — plus the data provenance record. Serialized with the ADR's kebab-case
395/// identity names.
396#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
397#[serde(rename_all = "kebab-case")]
398#[non_exhaustive]
399pub struct CatalogIdentity {
400    /// The `workshop-rs` package version (semver); bumped by code changes.
401    pub implementation_version: String,
402    /// The catalog dataset version; bumped by any dataset change.
403    pub catalog_version: String,
404    /// The deterministic content digest (sha256 hex) computed by the
405    /// pipeline; `None` when the data does not declare one.
406    pub catalog_digest: Option<String>,
407    /// Declared locales with per-locale mapping counts.
408    pub locale_coverage: Vec<LocaleCoverage>,
409    /// The declared target surface.
410    pub target: TargetMeta,
411    /// The provenance record of the catalog data.
412    pub provenance: Provenance,
413}
414
415type MemberIndexMap = HashMap<String, HashMap<String, (usize, usize)>>;
416
417/// The validated canonical Workshop catalog.
418#[derive(Debug, Clone)]
419pub struct Catalog {
420    schema_version: u32,
421    /// The declared locales, normalized; the first one is the primary
422    /// locale and must be fully covered.
423    locales: Vec<Locale>,
424    target: TargetMeta,
425    provenance: Provenance,
426    /// The catalog dataset version (ADR-0001 `catalog-version`).
427    catalog_version: String,
428    /// The declared content digest (sha256 hex), verified at load when
429    /// present (ADR-0001 `catalog-version`).
430    catalog_digest: Option<String>,
431    entries: Vec<CatalogEntry>,
432    localized_strings: Vec<LocalizedStringEntry>,
433    enums: Vec<EnumDomain>,
434    by_id: [HashMap<String, usize>; Kind::NUM_KINDS],
435    alias_to_entry: HashMap<Locale, [HashMap<String, usize>; Kind::NUM_KINDS]>,
436    localized_string_by_id: HashMap<String, usize>,
437    localized_string_alias: HashMap<Locale, HashMap<String, usize>>,
438    enum_by_domain: HashMap<String, usize>,
439    enum_alias_to_domain: HashMap<Locale, HashMap<String, String>>,
440    enum_alias_to_member: HashMap<Locale, MemberIndexMap>,
441    bare_member_index: HashMap<Locale, HashMap<String, Vec<(String, String)>>>,
442}
443
444#[derive(Deserialize)]
445#[serde(rename_all = "camelCase")]
446struct CatalogFile {
447    schema_version: u32,
448    locales: Vec<String>,
449    target: TargetMeta,
450    provenance: Provenance,
451    /// The catalog dataset version; absent in ad-hoc test data.
452    #[serde(default)]
453    version: Option<String>,
454    /// The declared content digest; absent in ad-hoc test data.
455    #[serde(default)]
456    digest: Option<String>,
457    #[serde(default)]
458    structural: Vec<EntryFile>,
459    #[serde(default)]
460    actions: Vec<EntryFile>,
461    #[serde(default)]
462    values: Vec<EntryFile>,
463    #[serde(default)]
464    events: Vec<EntryFile>,
465    #[serde(default)]
466    operators: Vec<EntryFile>,
467    #[serde(default)]
468    settings: Vec<EntryFile>,
469    #[serde(default)]
470    localized_strings: Vec<LocalizedStringFile>,
471    #[serde(default)]
472    enums: Vec<EnumFile>,
473}
474
475#[derive(Deserialize)]
476#[serde(rename_all = "camelCase")]
477struct EntryFile {
478    id: String,
479    aliases: HashMap<String, AliasFile>,
480    #[serde(default)]
481    params: Vec<String>,
482    /// Reviewed semantic parameter names, parallel to `params`.
483    #[serde(default)]
484    param_names: Vec<String>,
485    #[serde(default)]
486    param_aliases: Vec<HashMap<String, AliasFile>>,
487    /// Canonical enum domain per parameter position (parallel to `params`);
488    /// empty when no parameter domains are documented.
489    #[serde(default)]
490    param_domains: Vec<Option<String>>,
491    /// Default value per parameter position (parallel to `params`),
492    /// resolved when a call omits the argument. `None` means no default is
493    /// declared. Default value syntax: `null`, a numeric literal, localized
494    /// string text, `Domain.MEMBER` (builtin enum member), or a catalog value
495    /// id resolved as a zero-argument call. Every default is pinned-reference
496    /// probe evidence, never copied from upstream game data.
497    #[serde(default)]
498    param_defaults: Vec<Option<String>>,
499    #[serde(default)]
500    param_types: Vec<Option<String>>,
501    #[serde(default)]
502    param_coercions: Vec<Option<ParamCoercions>>,
503    #[serde(default)]
504    return_type: Option<String>,
505    #[serde(default)]
506    variadic: bool,
507}
508
509#[derive(Deserialize)]
510struct LocalizedStringFile {
511    id: String,
512    aliases: HashMap<String, AliasFile>,
513}
514
515#[derive(Deserialize)]
516struct EnumFile {
517    domain: String,
518    #[serde(default)]
519    aliases: HashMap<String, AliasFile>,
520    members: Vec<MemberFile>,
521}
522
523#[derive(Deserialize)]
524struct MemberFile {
525    id: String,
526    aliases: HashMap<String, AliasFile>,
527}
528
529/// A locale may have one canonical emitter spelling or several reviewed
530/// spellings observed across current Workshop producers. The string form is
531/// retained for the common case; the array form makes conflicts explicit in
532/// the data instead of forcing parser branches or silently choosing one.
533#[derive(Debug, Deserialize)]
534#[serde(untagged)]
535enum AliasFile {
536    One(String),
537    Many(Vec<String>),
538}
539
540impl AliasFile {
541    fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
542        let spellings = match self {
543            AliasFile::One(spelling) => vec![spelling],
544            AliasFile::Many(spellings) => spellings,
545        };
546        if spellings.is_empty() || spellings.iter().any(String::is_empty) {
547            return Err(CatalogError::validation(format!(
548                "catalog entry '{}' declares an empty alias for locale '{}'",
549                id, locale
550            )));
551        }
552        Ok(spellings)
553    }
554}
555
556impl Catalog {
557    /// Parse and validate catalog data, verifying the declared content
558    /// digest when the data carries one.
559    pub fn load(json: &str) -> Result<Catalog> {
560        let catalog = Self::load_unverified(json)?;
561        if let Some(declared) = &catalog.catalog_digest {
562            let computed = content_digest(json)?;
563            if declared != &computed {
564                return Err(CatalogError::validation(format!(
565                    "catalog digest mismatch: declared '{declared}', content '{computed}' — \
566                     run the catalog pipeline (workshop-catalog-gen build)"
567                )));
568            }
569        }
570        Ok(catalog)
571    }
572
573    /// Parse and validate catalog data without digest verification. Used by
574    /// the catalog pipeline so a stale digest can be repaired by `build`.
575    pub fn load_unverified(json: &str) -> Result<Catalog> {
576        let file: CatalogFile = serde_json::from_str(json)
577            .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
578        if file.schema_version != 1 {
579            return Err(CatalogError::malformed(format!(
580                "unsupported catalog schemaVersion {}",
581                file.schema_version
582            )));
583        }
584        let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
585        if locales.is_empty() {
586            return Err(CatalogError::malformed(
587                "catalog declares no locales".to_string(),
588            ));
589        }
590
591        let mut catalog = Catalog {
592            schema_version: file.schema_version,
593            locales,
594            target: file.target,
595            provenance: file.provenance,
596            catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
597            catalog_digest: file.digest,
598            entries: Vec::new(),
599            localized_strings: Vec::new(),
600            enums: Vec::new(),
601            by_id: Default::default(),
602            alias_to_entry: HashMap::new(),
603            localized_string_by_id: HashMap::new(),
604            localized_string_alias: HashMap::new(),
605            enum_by_domain: HashMap::new(),
606            enum_alias_to_domain: HashMap::new(),
607            enum_alias_to_member: HashMap::new(),
608            bare_member_index: HashMap::new(),
609        };
610
611        for (kind, items) in [
612            (Kind::Structural, file.structural),
613            (Kind::Action, file.actions),
614            (Kind::Value, file.values),
615            (Kind::Event, file.events),
616            (Kind::Operator, file.operators),
617            (Kind::Setting, file.settings),
618        ] {
619            for item in items {
620                catalog.insert_entry(kind, item)?;
621            }
622        }
623        for item in file.localized_strings {
624            catalog.insert_localized_string(item)?;
625        }
626        for domain in file.enums {
627            catalog.insert_enum(domain)?;
628        }
629        for domain in &catalog.enums {
630            for member in &domain.members {
631                for (locale, spellings) in &member.aliases {
632                    for spelling in spellings {
633                        catalog
634                            .bare_member_index
635                            .entry(locale.clone())
636                            .or_default()
637                            .entry(spelling.clone())
638                            .or_default()
639                            .push((domain.domain.clone(), member.member.clone()));
640                    }
641                }
642            }
643        }
644        catalog.validate_param_domains()?;
645        Ok(catalog)
646    }
647
648    /// The built-in catalog data.
649    pub fn builtin() -> Result<Catalog> {
650        Self::load(CATALOG_DATA)
651    }
652
653    pub(crate) fn schema_version(&self) -> u32 {
654        self.schema_version
655    }
656
657    /// The declared locales, normalized; the first one is the primary locale.
658    pub fn locales(&self) -> &[Locale] {
659        &self.locales
660    }
661
662    /// The primary locale: the first declared one, whose mapping surface is
663    /// complete (`en-US` in the committed catalog).
664    pub fn primary_locale(&self) -> &Locale {
665        &self.locales[0]
666    }
667
668    /// Whether a locale is declared by the catalog.
669    pub fn supports(&self, locale: &Locale) -> bool {
670        self.locales.contains(locale)
671    }
672
673    /// The catalog dataset version (ADR-0001 `catalog-version`).
674    pub fn catalog_version(&self) -> &str {
675        &self.catalog_version
676    }
677
678    /// The declared content digest (sha256 hex) of the catalog dataset,
679    /// verified at load; `None` for data that declares none.
680    pub fn catalog_digest(&self) -> Option<&str> {
681        self.catalog_digest.as_deref()
682    }
683
684    /// The `workshop-rs` package version (ADR-0001 `implementation-version`).
685    pub fn implementation_version() -> &'static str {
686        env!("CARGO_PKG_VERSION")
687    }
688
689    /// The machine-readable catalog identity: implementation version, catalog
690    /// version + digest, locale coverage, target evidence, and provenance.
691    pub fn identity(&self) -> CatalogIdentity {
692        CatalogIdentity {
693            implementation_version: Self::implementation_version().to_string(),
694            catalog_version: self.catalog_version.clone(),
695            catalog_digest: self.catalog_digest.clone(),
696            locale_coverage: self
697                .locales
698                .iter()
699                .map(|locale| self.locale_coverage(locale))
700                .collect(),
701            target: self.target.clone(),
702            provenance: self.provenance.clone(),
703        }
704    }
705
706    /// The mapping coverage of one declared locale: mapped entries out of the
707    /// declared total (builtins, localized preset identities, and enum members).
708    /// The primary locale is
709    /// always complete; other locales may be partially covered.
710    pub fn locale_coverage(&self, locale: &Locale) -> LocaleCoverage {
711        let member_total: usize = self.enums.iter().map(|domain| domain.members.len()).sum();
712        let total = self.entries.len() + self.localized_strings.len() + member_total;
713        let mapped = self
714            .entries
715            .iter()
716            .filter(|entry| entry.aliases.contains_key(locale))
717            .count()
718            + self
719                .localized_strings
720                .iter()
721                .filter(|entry| entry.aliases.contains_key(locale))
722                .count()
723            + self
724                .enums
725                .iter()
726                .flat_map(|domain| &domain.members)
727                .filter(|member| member.aliases.contains_key(locale))
728                .count();
729        LocaleCoverage {
730            locale: locale.clone(),
731            mapped,
732            total,
733        }
734    }
735
736    /// The mapping coverage of every declared locale, in declaration order.
737    pub fn locale_coverage_all(&self) -> Vec<LocaleCoverage> {
738        self.locales
739            .iter()
740            .map(|locale| self.locale_coverage(locale))
741            .collect()
742    }
743
744    /// The builtin with the given canonical id and kind.
745    pub fn entry(&self, kind: Kind, id: &str) -> Option<&CatalogEntry> {
746        self.by_id[kind.as_index()]
747            .get(id)
748            .map(|i| &self.entries[*i])
749    }
750
751    /// Resolve a localized spelling to its canonical builtin.
752    pub fn resolve(&self, kind: Kind, locale: &Locale, spelling: &str) -> Option<&CatalogEntry> {
753        self.alias_to_entry
754            .get(locale)
755            .and_then(|by_kind| by_kind[kind.as_index()].get(spelling))
756            .map(|i| &self.entries[*i])
757    }
758
759    /// The localized spelling of a canonical builtin id.
760    pub fn spelling(&self, kind: Kind, locale: &Locale, id: &str) -> Option<&str> {
761        self.entry(kind, id)?.spelling(locale)
762    }
763
764    /// Every entry of a kind, in catalog order.
765    pub fn entries_of(&self, kind: Kind) -> impl Iterator<Item = &CatalogEntry> {
766        self.entries.iter().filter(move |entry| entry.kind == kind)
767    }
768
769    /// Resolve a localized preset spelling to its stable identity.
770    pub fn resolve_localized_string(
771        &self,
772        locale: &Locale,
773        spelling: &str,
774    ) -> Option<&LocalizedStringEntry> {
775        self.localized_string_alias
776            .get(locale)
777            .and_then(|map| map.get(spelling))
778            .map(|index| &self.localized_strings[*index])
779    }
780
781    /// Resolve the emitted spelling of a localized preset identity.
782    pub fn localized_string_spelling(&self, locale: &Locale, id: &str) -> Option<&str> {
783        self.localized_string_by_id
784            .get(id)
785            .and_then(|i| self.localized_strings.get(*i))
786            .and_then(|entry| entry.spelling(locale))
787    }
788
789    /// Every reviewed localized preset identity, in catalog order.
790    pub fn localized_strings(&self) -> impl Iterator<Item = &LocalizedStringEntry> {
791        self.localized_strings.iter()
792    }
793
794    /// The total number of builtin entries.
795    pub fn entry_count(&self) -> usize {
796        self.entries.len()
797    }
798
799    /// The number of enum domains.
800    pub fn enum_domains_count(&self) -> usize {
801        self.enums.len()
802    }
803
804    /// The enum domain with the given name.
805    pub fn enum_domain(&self, domain: &str) -> Option<&EnumDomain> {
806        self.enum_by_domain.get(domain).map(|i| &self.enums[*i])
807    }
808
809    /// Resolve a localized enum-domain spelling to its canonical domain id.
810    pub fn resolve_enum_domain(&self, locale: &Locale, spelling: &str) -> Option<&str> {
811        self.enum_by_domain
812            .get_key_value(spelling)
813            .map(|(domain, _)| domain.as_str())
814            .or_else(|| {
815                self.enum_alias_to_domain
816                    .get(locale)
817                    .and_then(|map| map.get(spelling))
818                    .map(String::as_str)
819            })
820    }
821
822    /// Every enum domain, in catalog order.
823    pub fn enum_domains(&self) -> impl Iterator<Item = &EnumDomain> {
824        self.enums.iter()
825    }
826
827    /// Resolve a localized enum member spelling to `(domain, canonical member)`.
828    pub fn resolve_enum_member(
829        &self,
830        domain: &str,
831        locale: &Locale,
832        spelling: &str,
833    ) -> Option<(String, String)> {
834        let &(domain_index, member_index) = self
835            .enum_alias_to_member
836            .get(locale)?
837            .get(domain)?
838            .get(spelling)?;
839        Some((
840            domain.to_string(),
841            self.enums[domain_index].members[member_index]
842                .member
843                .clone(),
844        ))
845    }
846
847    /// The localized spelling of a canonical enum member.
848    pub fn enum_spelling(&self, domain: &str, locale: &Locale, member: &str) -> Option<&str> {
849        let domain_index = self.enum_by_domain.get(domain)?;
850        let domain = &self.enums[*domain_index];
851        domain
852            .members
853            .iter()
854            .find(|candidate| candidate.member == member)?
855            .spelling(locale)
856    }
857
858    /// Resolve a canonical enum member through the locale boundary, including
859    /// reviewed partial locale spellings that are not yet part of the full
860    /// catalog locale set.
861    pub fn localized_enum_spelling(
862        &self,
863        domain: &str,
864        locale: &Locale,
865        member: &str,
866    ) -> Option<&str> {
867        self.enum_spelling(domain, locale, member).or_else(|| {
868            (domain == "Color" && member == "WHITE").then_some(match locale.as_str() {
869                "de-de" => "Weiß",
870                "es-es" | "es-mx" => "Blanco",
871                "fr-fr" => "Blanc",
872                "it-it" => "Bianco",
873                "ja-jp" => "白",
874                "ko-kr" => "흰색",
875                "pl-pl" => "Biały",
876                "pt-br" => "Branco",
877                "ru-ru" => "Белый",
878                "th-th" => "สีขาว",
879                "tr-tr" => "Beyaz",
880                "zh-tw" => "白色",
881                _ => return None,
882            })
883        })
884    }
885
886    /// Every `(domain, canonical member)` match for a bare (domain-less)
887    /// localized member spelling. Returns all matches so callers can report
888    /// ambiguity; a well-formed catalog has at most one meaningful match for
889    /// a given spelling.
890    pub fn bare_member_matches(&self, locale: &Locale, spelling: &str) -> Vec<(String, String)> {
891        self.bare_member_index
892            .get(locale)
893            .and_then(|map| map.get(spelling))
894            .cloned()
895            .unwrap_or_default()
896    }
897
898    fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
899        let index = self.entries.len();
900        let mut aliases = HashMap::new();
901        for (locale_str, alias_file) in item.aliases {
902            let locale = Locale::new(&locale_str);
903            if !self.locales.contains(&locale) {
904                return Err(CatalogError::validation(format!(
905                    "entry '{}' declares alias for undeclared locale '{}'",
906                    item.id, locale
907                )));
908            }
909            let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
910            let locale_map = self.alias_to_entry.entry(locale.clone()).or_default();
911            for spelling in &spellings {
912                if locale_map[kind.as_index()].contains_key(spelling) {
913                    return Err(CatalogError::validation(format!(
914                        "duplicate {} alias '{spelling}' for locale '{}'",
915                        kind.as_str(),
916                        locale
917                    )));
918                }
919                locale_map[kind.as_index()].insert(spelling.clone(), index);
920            }
921            aliases.insert(locale, spellings);
922        }
923        if self.by_id[kind.as_index()].contains_key(&item.id) {
924            return Err(CatalogError::validation(format!(
925                "duplicate {} id '{}'",
926                kind.as_str(),
927                item.id
928            )));
929        }
930        // The primary locale's surface is complete: every builtin carries a
931        // primary-locale alias. Additional declared locales may be partially
932        // covered; missing target-locale mappings fail explicitly at
933        // conversion/emission time (ADR-0001 Decision 7).
934        let primary = self.locales[0].clone();
935        if !aliases.contains_key(&primary) {
936            return Err(CatalogError::validation(format!(
937                "{} '{}' is missing a '{}' alias",
938                kind.as_str(),
939                item.id,
940                primary
941            )));
942        }
943        let param_names = if item.param_names.is_empty() {
944            item.params.clone()
945        } else {
946            item.param_names.clone()
947        };
948        if param_names.len() != item.params.len() {
949            return Err(CatalogError::validation(format!(
950                "{} '{}' declares {} param names for {} params",
951                kind.as_str(),
952                item.id,
953                param_names.len(),
954                item.params.len()
955            )));
956        }
957        self.by_id[kind.as_index()].insert(item.id.clone(), index);
958        let item_id = item.id.clone();
959        self.entries.push(CatalogEntry {
960            id: item.id,
961            kind,
962            params: item.params,
963            param_names,
964            param_aliases: item
965                .param_aliases
966                .into_iter()
967                .map(|aliases| {
968                    aliases
969                        .into_iter()
970                        .map(|(locale, alias)| {
971                            let locale_key = Locale::new(&locale);
972                            let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
973                            Ok((locale_key, spellings))
974                        })
975                        .collect::<Result<HashMap<_, _>>>()
976                })
977                .collect::<Result<Vec<_>>>()?,
978            param_domains: item.param_domains,
979            param_defaults: item.param_defaults,
980            param_types: item.param_types,
981            param_coercions: item.param_coercions,
982            return_type: item.return_type,
983            variadic: item.variadic,
984            aliases,
985        });
986        Ok(())
987    }
988
989    fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
990        if self.localized_string_by_id.contains_key(&item.id) {
991            return Err(CatalogError::validation(format!(
992                "duplicate localized string id '{}'",
993                item.id
994            )));
995        }
996        let index = self.localized_strings.len();
997        let mut aliases = HashMap::new();
998        for (locale_str, alias_file) in item.aliases {
999            let locale = Locale::new(&locale_str);
1000            if !self.locales.contains(&locale) {
1001                return Err(CatalogError::validation(format!(
1002                    "localized string '{}' declares alias for undeclared locale '{}'",
1003                    item.id, locale
1004                )));
1005            }
1006            let spellings = alias_file.into_spellings(&item.id, locale.as_str())?;
1007            let locale_map = self
1008                .localized_string_alias
1009                .entry(locale.clone())
1010                .or_default();
1011            for spelling in &spellings {
1012                if locale_map.contains_key(spelling) {
1013                    return Err(CatalogError::validation(format!(
1014                        "duplicate localized string alias '{spelling}' for locale '{locale}'"
1015                    )));
1016                }
1017                locale_map.insert(spelling.clone(), index);
1018            }
1019            aliases.insert(locale, spellings);
1020        }
1021        let primary = self.locales[0].clone();
1022        if !aliases.contains_key(&primary) {
1023            return Err(CatalogError::validation(format!(
1024                "localized string '{}' is missing a '{}' alias",
1025                item.id, primary
1026            )));
1027        }
1028        self.localized_string_by_id.insert(item.id.clone(), index);
1029        self.localized_strings.push(LocalizedStringEntry {
1030            id: item.id,
1031            aliases,
1032        });
1033        Ok(())
1034    }
1035
1036    /// Every declared `paramDomains` domain must name a declared enum domain.
1037    fn validate_param_domains(&self) -> Result<()> {
1038        for entry in &self.entries {
1039            if entry.param_names.len() != entry.params.len() {
1040                return Err(CatalogError::validation(format!(
1041                    "{} '{}' declares param names that do not match params",
1042                    entry.kind.as_str(),
1043                    entry.id
1044                )));
1045            }
1046            if entry.param_aliases.len() > entry.params.len() {
1047                return Err(CatalogError::validation(format!(
1048                    "{} '{}' declares more parameter alias sets than params",
1049                    entry.kind.as_str(),
1050                    entry.id
1051                )));
1052            }
1053            if entry.param_domains.len() > entry.params.len() {
1054                return Err(CatalogError::validation(format!(
1055                    "{} '{}' declares more param domains than params",
1056                    entry.kind.as_str(),
1057                    entry.id
1058                )));
1059            }
1060            if entry.param_defaults.len() > entry.params.len() {
1061                return Err(CatalogError::validation(format!(
1062                    "{} '{}' declares more param defaults than params",
1063                    entry.kind.as_str(),
1064                    entry.id
1065                )));
1066            }
1067            if entry.param_types.len() > entry.params.len() {
1068                return Err(CatalogError::validation(format!(
1069                    "{} '{}' declares more param types than params",
1070                    entry.kind.as_str(),
1071                    entry.id
1072                )));
1073            }
1074            if entry.param_coercions.len() > entry.params.len() {
1075                return Err(CatalogError::validation(format!(
1076                    "{} '{}' declares more param coercions than params",
1077                    entry.kind.as_str(),
1078                    entry.id
1079                )));
1080            }
1081            if entry.kind != Kind::Value && entry.return_type.is_some() {
1082                return Err(CatalogError::validation(format!(
1083                    "{} '{}' declares a return type but is not a value",
1084                    entry.kind.as_str(),
1085                    entry.id
1086                )));
1087            }
1088            for domain in entry.param_domains.iter().flatten() {
1089                if !self.enum_by_domain.contains_key(domain) {
1090                    return Err(CatalogError::validation(format!(
1091                        "{} '{}' declares undeclared enum domain '{domain}'",
1092                        entry.kind.as_str(),
1093                        entry.id
1094                    )));
1095                }
1096            }
1097            for aliases in &entry.param_aliases {
1098                for locale in aliases.keys() {
1099                    if !self.locales.contains(locale) {
1100                        return Err(CatalogError::validation(format!(
1101                            "{} '{}' declares parameter alias for undeclared locale '{}'",
1102                            entry.kind.as_str(),
1103                            entry.id,
1104                            locale
1105                        )));
1106                    }
1107                }
1108            }
1109        }
1110        Ok(())
1111    }
1112
1113    fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
1114        let domain_index = self.enums.len();
1115        if self.enum_by_domain.contains_key(&domain.domain) {
1116            return Err(CatalogError::validation(format!(
1117                "duplicate enum domain '{}'",
1118                domain.domain
1119            )));
1120        }
1121        let primary = self.locales[0].clone();
1122        let mut domain_aliases = HashMap::new();
1123        for (locale_str, alias_file) in domain.aliases {
1124            let locale = Locale::new(&locale_str);
1125            if !self.locales.contains(&locale) {
1126                return Err(CatalogError::validation(format!(
1127                    "enum domain '{}' declares alias for undeclared locale '{}'",
1128                    domain.domain, locale
1129                )));
1130            }
1131            let spellings = alias_file.into_spellings(&domain.domain, locale.as_str())?;
1132            let locale_map = self.enum_alias_to_domain.entry(locale.clone()).or_default();
1133            for spelling in &spellings {
1134                if let Some(existing) = locale_map.get(spelling) {
1135                    return Err(CatalogError::validation(format!(
1136                        "duplicate enum domain alias '{spelling}' for '{}' and '{}' in locale '{}'",
1137                        existing, domain.domain, locale
1138                    )));
1139                }
1140                locale_map.insert(spelling.clone(), domain.domain.clone());
1141            }
1142            domain_aliases.insert(locale, spellings);
1143        }
1144        domain_aliases
1145            .entry(primary.clone())
1146            .or_insert_with(|| vec![domain.domain.clone()]);
1147        self.enum_alias_to_domain
1148            .entry(primary.clone())
1149            .or_default()
1150            .entry(domain.domain.clone())
1151            .or_insert_with(|| domain.domain.clone());
1152        let mut members = Vec::new();
1153        for (member_index, member) in domain.members.into_iter().enumerate() {
1154            let mut aliases = HashMap::new();
1155            for (locale_str, alias_file) in member.aliases {
1156                let locale = Locale::new(&locale_str);
1157                if !self.locales.contains(&locale) {
1158                    return Err(CatalogError::validation(format!(
1159                        "enum {}::{} declares alias for undeclared locale '{}'",
1160                        domain.domain, member.id, locale
1161                    )));
1162                }
1163                let spellings = alias_file.into_spellings(&member.id, locale.as_str())?;
1164                let locale_map = self.enum_alias_to_member.entry(locale.clone()).or_default();
1165                let domain_map = locale_map.entry(domain.domain.clone()).or_default();
1166                for spelling in &spellings {
1167                    if domain_map.contains_key(spelling) {
1168                        return Err(CatalogError::validation(format!(
1169                            "duplicate enum alias '{spelling}' in '{}' for locale '{}'",
1170                            domain.domain, locale
1171                        )));
1172                    }
1173                    domain_map.insert(spelling.clone(), (domain_index, member_index));
1174                }
1175                aliases.insert(locale, spellings);
1176            }
1177            if !aliases.contains_key(&primary) {
1178                return Err(CatalogError::validation(format!(
1179                    "enum {}::{} is missing a '{}' alias",
1180                    domain.domain, member.id, primary
1181                )));
1182            }
1183            members.push(EnumMember {
1184                member: member.id,
1185                aliases,
1186            });
1187        }
1188        self.enum_by_domain
1189            .insert(domain.domain.clone(), domain_index);
1190        self.enums.push(EnumDomain {
1191            domain: domain.domain,
1192            aliases: domain_aliases,
1193            members,
1194        });
1195        Ok(())
1196    }
1197}
1198
1199/// The catalog is the canonical source of expected enum domains for the
1200/// Workshop surface it documents: `expected_domain(catalog_id, arg_index)`
1201/// answers the domain declared for that parameter position (e.g. `createHudText`
1202/// argument 9 is `HudReeval`), so the Workshop parser can resolve bare enum
1203/// members that are ambiguous across domains (e.g. `Visible To and String`).
1204/// Positions without a documented domain answer `None`.
1205impl ExpectedDomain for Catalog {
1206    fn expected_domain(&self, catalog_id: &str, arg_index: usize) -> Option<&str> {
1207        for kind in [Kind::Action, Kind::Value] {
1208            if let Some(entry) = self.entry(kind, catalog_id) {
1209                if let Some(domain) = entry.param_domain(arg_index) {
1210                    return Some(domain);
1211                }
1212            }
1213        }
1214        None
1215    }
1216}
1217
1218/// Canonicalize catalog data: parse, validate, and re-serialize
1219/// deterministically (object keys sorted, stable formatting). Re-running on
1220/// the same input produces byte-identical output, so the data pipeline is
1221/// reproducible. Validation intentionally skips digest verification so a
1222/// stale digest can be repaired by [`build_canonical`].
1223pub fn canonicalize(json: &str) -> Result<String> {
1224    // Validate the semantic content first.
1225    Catalog::load_unverified(json)?;
1226    let value: serde_json::Value = serde_json::from_str(json)
1227        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1228    serde_json::to_string_pretty(&value)
1229        .map(|mut out| {
1230            out.push('\n');
1231            out
1232        })
1233        .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
1234}
1235
1236/// Rebuild the canonical catalog form with a fresh content digest: validate,
1237/// canonicalize, and (re)write the `digest` field. Byte-idempotent, so the
1238/// committed dataset and its digest are reproducible from the data file.
1239pub fn build_canonical(json: &str) -> Result<String> {
1240    let mut value: serde_json::Value = serde_json::from_str(json)
1241        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1242    let digest = content_digest(json)?;
1243    if let Some(object) = value.as_object_mut() {
1244        object.insert("digest".to_string(), serde_json::Value::String(digest));
1245    }
1246    let output = serde_json::to_string_pretty(&value)
1247        .map(|mut out| {
1248            out.push('\n');
1249            out
1250        })
1251        .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1252    // Validate the semantic content (including the fresh digest) before
1253    // returning the rebuilt file.
1254    Catalog::load(&output)?;
1255    Ok(output)
1256}
1257
1258/// The deterministic content digest of catalog data: sha256 of the canonical
1259/// (sorted-key, pretty) serialization of the parsed content with the
1260/// self-referential `digest` field removed. Independent of file formatting;
1261/// changes whenever any semantic content changes.
1262pub fn content_digest(json: &str) -> Result<String> {
1263    let mut value: serde_json::Value = serde_json::from_str(json)
1264        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1265    if let Some(object) = value.as_object_mut() {
1266        object.remove("digest");
1267    }
1268    let canonical = serde_json::to_string_pretty(&value)
1269        .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))?;
1270    use sha2::{Digest, Sha256};
1271    let mut hasher = Sha256::new();
1272    hasher.update(canonical.as_bytes());
1273    Ok(format!("{:x}", hasher.finalize()))
1274}