Skip to main content

workshop_rs/catalog/
load.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::sync::OnceLock;
3
4use serde::Deserialize;
5
6use crate::core::error::{CatalogError, Result, WorkshopError};
7
8use super::{
9    CATALOG_DATA, Catalog, CatalogEntry, EnumDomain, EnumMember, Kind, Locale,
10    LocalizedStringEntry, NormalizedIndex, ParamCoercions, ParamContent, Provenance, TargetMeta,
11    normalize_spelling,
12};
13
14#[derive(Deserialize)]
15#[serde(rename_all = "camelCase")]
16struct CatalogFile {
17    schema_version: u32,
18    locales: Vec<String>,
19    target: TargetMeta,
20    provenance: Provenance,
21    #[serde(default)]
22    version: Option<String>,
23    #[serde(default)]
24    digest: Option<String>,
25    #[serde(default)]
26    structural: Vec<EntryFile>,
27    #[serde(default)]
28    actions: Vec<EntryFile>,
29    #[serde(default)]
30    values: Vec<EntryFile>,
31    #[serde(default)]
32    events: Vec<EntryFile>,
33    #[serde(default)]
34    operators: Vec<EntryFile>,
35    #[serde(default)]
36    settings: Vec<EntryFile>,
37    #[serde(default)]
38    localized_strings: Vec<LocalizedStringFile>,
39    #[serde(default)]
40    enums: Vec<EnumFile>,
41    /// The localized parameter display names the Workshop editor shows,
42    /// keyed by entry id then parameter position — their own catalog data
43    /// section, separate from `paramAliases` (additional accepted parse
44    /// spellings) and `paramDefaults` (the value applied when an argument
45    /// is omitted). Only source-confirmed positions and locales are
46    /// recorded; everything else is listed in `paramDisplayNameGaps`.
47    #[serde(default)]
48    param_display_names: BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>,
49    /// The explicit coverage gap list for parameter display names: the
50    /// declared locales with no recorded names at all, and every parameter
51    /// position (`entryId[index]`) that does not record a name in every
52    /// supported (non-gap) locale. A gapped position may still carry the
53    /// names that are confirmed. When the section is declared, a position
54    /// must appear on the gap list exactly when its recorded names do not
55    /// cover every supported locale.
56    param_display_name_gaps: Option<ParamDisplayNameGapsFile>,
57}
58
59/// The `paramDisplayNameGaps` section: the catalog's explicit record of
60/// where localized parameter display names are *not* recorded.
61#[derive(Deserialize, Default)]
62#[serde(rename_all = "camelCase")]
63struct ParamDisplayNameGapsFile {
64    /// Declared locales with no parameter display names recorded.
65    #[serde(default)]
66    locales: Vec<String>,
67    /// Parameter positions lacking a source-confirmed name in at least one
68    /// supported locale, each written `entryId[position]` with `position` a
69    /// zero-based index into the entry's declared `params`. A listed
70    /// position may still record names for the locales that are confirmed.
71    #[serde(default)]
72    positions: Vec<String>,
73}
74
75#[derive(Deserialize)]
76#[serde(rename_all = "camelCase")]
77struct EntryFile {
78    id: String,
79    aliases: HashMap<String, AliasFile>,
80    /// The declared parameter list. `None` means the entry declares no
81    /// signature at all; `Some` — even empty, as on the zero-argument
82    /// `else`/`end` constructs — declares a call signature.
83    #[serde(default)]
84    params: Option<Vec<String>>,
85    /// Reviewed semantic parameter names, parallel to `params`.
86    #[serde(default)]
87    param_names: Vec<String>,
88    #[serde(default)]
89    param_aliases: Vec<HashMap<String, AliasFile>>,
90    /// Canonical enum domain per parameter position (parallel to `params`);
91    /// empty when no parameter domains are documented.
92    #[serde(default)]
93    param_domains: Vec<Option<String>>,
94    /// Default value per parameter position (parallel to `params`),
95    /// resolved when a call omits the argument. `None` means no default is
96    /// declared. Default value syntax: `null`, a numeric literal, localized
97    /// string text, `Domain.MEMBER` (builtin enum member), or a catalog value
98    /// id resolved as a zero-argument call. Every default is pinned-reference
99    /// probe evidence, never copied from upstream game data.
100    #[serde(default)]
101    param_defaults: Vec<Option<String>>,
102    #[serde(default)]
103    param_types: Vec<Option<String>>,
104    #[serde(default)]
105    param_coercions: Vec<Option<ParamCoercions>>,
106    /// `rule-content-v1` facts per parameter position (parallel to
107    /// `params`), declared where the surface parameter list and the content
108    /// argument list differ.
109    #[serde(default)]
110    param_content: Vec<Option<ParamContent>>,
111    #[serde(default)]
112    return_type: Option<String>,
113    /// Reviewed reevaluation coverage keyed by enum member id (e.g.
114    /// `COLOR`), listing the parameter positions each member keeps
115    /// re-evaluating. Declared only on actions whose `*Reeval` parameter
116    /// selects the reevaluation mode; validated against that domain's
117    /// members and the parameter arity.
118    #[serde(default)]
119    reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
120    #[serde(default)]
121    variadic: bool,
122}
123
124#[derive(Deserialize)]
125struct LocalizedStringFile {
126    id: String,
127    aliases: HashMap<String, AliasFile>,
128}
129
130#[derive(Deserialize)]
131struct EnumFile {
132    domain: String,
133    #[serde(default)]
134    aliases: HashMap<String, AliasFile>,
135    members: Vec<MemberFile>,
136}
137
138#[derive(Deserialize)]
139struct MemberFile {
140    id: String,
141    aliases: HashMap<String, AliasFile>,
142}
143
144/// A locale may have one canonical emitter spelling or several reviewed
145/// spellings observed across current Workshop producers. The string form is
146/// retained for the common case; the array form makes conflicts explicit in
147/// the data instead of forcing parser branches or silently choosing one.
148#[derive(Debug, Deserialize)]
149#[serde(untagged)]
150enum AliasFile {
151    One(String),
152    Many(Vec<String>),
153}
154
155impl AliasFile {
156    fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
157        let spellings = match self {
158            AliasFile::One(spelling) => vec![spelling],
159            AliasFile::Many(spellings) => spellings,
160        };
161        if spellings.is_empty() || spellings.iter().any(String::is_empty) {
162            return Err(CatalogError::validation(format!(
163                "catalog entry '{id}' declares an empty alias for locale '{locale}'"
164            )));
165        }
166        Ok(spellings)
167    }
168}
169impl Catalog {
170    /// Parse and validate catalog data, verifying the declared content
171    /// digest when the data carries one.
172    pub fn load(json: &str) -> Result<Catalog> {
173        let catalog = Self::load_unverified(json)?;
174        if let Some(declared) = &catalog.catalog_digest {
175            let computed = content_digest(json)?;
176            if declared != &computed {
177                return Err(CatalogError::validation(format!(
178                    "catalog digest mismatch: declared '{declared}', content '{computed}' — \
179                     run the catalog pipeline (workshop-catalog-gen build)"
180                )));
181            }
182        }
183        Ok(catalog)
184    }
185
186    /// Parse and validate catalog data without digest verification. Used by
187    /// the catalog pipeline so a stale digest can be repaired by `build`.
188    pub fn load_unverified(json: &str) -> Result<Catalog> {
189        let file: CatalogFile = serde_json::from_str(json)
190            .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
191        if file.schema_version != 1 {
192            return Err(CatalogError::malformed(format!(
193                "unsupported catalog schemaVersion {}",
194                file.schema_version
195            )));
196        }
197        let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
198        if locales.is_empty() {
199            return Err(CatalogError::malformed(
200                "catalog declares no locales".to_string(),
201            ));
202        }
203
204        let mut catalog = Catalog {
205            detection_index: None,
206            schema_version: file.schema_version,
207            locales,
208            target: file.target,
209            provenance: file.provenance,
210            catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
211            catalog_digest: file.digest,
212            entries: Vec::new(),
213            localized_strings: Vec::new(),
214            enums: Vec::new(),
215            by_id: Default::default(),
216            alias_to_entry: HashMap::new(),
217            localized_string_by_id: HashMap::new(),
218            localized_string_alias: HashMap::new(),
219            enum_by_domain: HashMap::new(),
220            enum_alias_to_domain: HashMap::new(),
221            enum_alias_to_member: HashMap::new(),
222            bare_member_index: HashMap::new(),
223            normalized: NormalizedIndex::default(),
224            descriptions: Default::default(),
225        };
226
227        for (kind, items) in [
228            (Kind::Structural, file.structural),
229            (Kind::Action, file.actions),
230            (Kind::Value, file.values),
231            (Kind::Event, file.events),
232            (Kind::Operator, file.operators),
233            (Kind::Setting, file.settings),
234        ] {
235            for item in items {
236                catalog.insert_entry(kind, item)?;
237            }
238        }
239        for item in file.localized_strings {
240            catalog.insert_localized_string(item)?;
241        }
242        for domain in file.enums {
243            catalog.insert_enum(domain)?;
244        }
245        catalog
246            .insert_param_display_names(file.param_display_names, file.param_display_name_gaps)?;
247        for domain in &catalog.enums {
248            for member in &domain.members {
249                for (locale, spellings) in &member.aliases {
250                    for spelling in spellings {
251                        catalog
252                            .bare_member_index
253                            .entry(locale.clone())
254                            .or_default()
255                            .entry(spelling.clone())
256                            .or_default()
257                            .push((domain.domain.clone(), member.member.clone()));
258                    }
259                }
260            }
261        }
262        catalog.build_normalized_index();
263        catalog.validate_param_domains()?;
264        catalog.validate_param_content()?;
265        catalog.detection_index =
266            Some(super::detect::AliasIndex::build(&catalog).map_err(|error| {
267                CatalogError::validation(format!("locale detection index: {error}"))
268            })?);
269        Ok(catalog)
270    }
271
272    /// The built-in catalog data.
273    pub fn builtin() -> Result<Catalog> {
274        Self::builtin_ref().cloned().map_err(Clone::clone)
275    }
276
277    /// The built-in catalog, parsed and indexed once per process. The
278    /// embedded description dataset (`descriptions.json`) is attached and
279    /// verified with it.
280    pub fn builtin_ref() -> std::result::Result<&'static Catalog, &'static WorkshopError> {
281        static DATA: OnceLock<Result<Catalog>> = OnceLock::new();
282        match DATA.get_or_init(|| {
283            let mut catalog = Self::load(CATALOG_DATA)?;
284            catalog.load_descriptions(super::descriptions::DESCRIPTIONS_DATA)?;
285            Ok(catalog)
286        }) {
287            Ok(catalog) => Ok(catalog),
288            Err(error) => Err(error),
289        }
290    }
291    /// Mirror the verbatim spelling indexes under whitespace-collapsed
292    /// keys (#448). A normalized key claimed by distinct targets is poisoned
293    /// (`None`) so it resolves nothing rather than guessing; identical
294    /// targets (two declared spellings that differ only in whitespace, such
295    /// as zh-CN `66号公路`/`66 号公路`) keep their single meaning.
296    fn build_normalized_index(&mut self) {
297        fn insert<T: PartialEq + Clone>(
298            map: &mut HashMap<String, Option<T>>,
299            key: String,
300            value: T,
301        ) {
302            match map.get_mut(&key) {
303                Some(slot) if *slot != Some(value.clone()) => *slot = None,
304                Some(_) => {}
305                None => {
306                    map.insert(key, Some(value));
307                }
308            }
309        }
310
311        let mut normalized = NormalizedIndex::default();
312        for (locale, by_kind) in &self.alias_to_entry {
313            let by_kind_norm = normalized
314                .alias_to_entry
315                .entry(locale.clone())
316                .or_insert_with(|| std::array::from_fn(|_| HashMap::new()));
317            for (kind_index, map) in by_kind.iter().enumerate() {
318                for (spelling, &entry_index) in map {
319                    let key = normalize_spelling(spelling);
320                    if key != spelling.as_str() {
321                        insert(&mut by_kind_norm[kind_index], key.into_owned(), entry_index);
322                    }
323                }
324            }
325        }
326        for (locale, map) in &self.localized_string_alias {
327            let norm = normalized
328                .localized_string_alias
329                .entry(locale.clone())
330                .or_default();
331            for (spelling, &string_index) in map {
332                let key = normalize_spelling(spelling);
333                if key != spelling.as_str() {
334                    insert(norm, key.into_owned(), string_index);
335                }
336            }
337        }
338        for (locale, map) in &self.enum_alias_to_domain {
339            let norm = normalized
340                .enum_alias_to_domain
341                .entry(locale.clone())
342                .or_default();
343            for (spelling, domain) in map {
344                let key = normalize_spelling(spelling);
345                if key != spelling.as_str() {
346                    insert(norm, key.into_owned(), domain.clone());
347                }
348            }
349        }
350        for (locale, domains) in &self.enum_alias_to_member {
351            let norm = normalized
352                .enum_alias_to_member
353                .entry(locale.clone())
354                .or_default();
355            for (domain, map) in domains {
356                let norm_map = norm.entry(domain.clone()).or_default();
357                for (spelling, &indexes) in map {
358                    let key = normalize_spelling(spelling);
359                    if key != spelling.as_str() {
360                        insert(norm_map, key.into_owned(), indexes);
361                    }
362                }
363            }
364        }
365        for (locale, map) in &self.bare_member_index {
366            let norm = normalized.bare_member.entry(locale.clone()).or_default();
367            for (spelling, matches) in map {
368                let key = normalize_spelling(spelling);
369                if key != spelling.as_str() {
370                    let merged = norm.entry(key.into_owned()).or_default();
371                    for pair in matches {
372                        if !merged.contains(pair) {
373                            merged.push(pair.clone());
374                        }
375                    }
376                }
377            }
378        }
379        self.normalized = normalized;
380    }
381
382    fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
383        let index = self.entries.len();
384        let aliases = self.collect_aliases(
385            &item.id,
386            &format!("entry '{}'", item.id),
387            item.aliases,
388            true,
389            |catalog, locale, spelling| {
390                let locale_map = catalog.alias_to_entry.entry(locale.clone()).or_default();
391                if locale_map[kind.as_index()].contains_key(spelling) {
392                    return Err(CatalogError::validation(format!(
393                        "duplicate {} alias '{spelling}' for locale '{locale}'",
394                        kind.as_str()
395                    )));
396                }
397                locale_map[kind.as_index()].insert(spelling.to_string(), index);
398                Ok(())
399            },
400        )?;
401        if self.by_id[kind.as_index()].contains_key(&item.id) {
402            return Err(CatalogError::validation(format!(
403                "duplicate {} id '{}'",
404                kind.as_str(),
405                item.id
406            )));
407        }
408        // The primary locale's surface is complete: every builtin carries a
409        // primary-locale alias. Additional declared locales may be partially
410        // covered; missing target-locale mappings fail explicitly at
411        // conversion/emission time (ADR-0001 Decision 7).
412        // A signature-bearing entry is any action or value, or a structural
413        // entry that declares its parameter list — `"params": []` marks a
414        // zero-argument construct such as `else`/`end`, while section
415        // keywords declare no `params` field and stay without a signature.
416        let callable = matches!(kind, Kind::Action | Kind::Value)
417            || (kind == Kind::Structural && item.params.is_some());
418        let params = item.params.unwrap_or_default();
419        let param_names = match item.param_names {
420            names if names.is_empty() => None,
421            names if names.len() != params.len() => {
422                return Err(CatalogError::validation(format!(
423                    "{} '{}' declares {} param names for {} params",
424                    kind.as_str(),
425                    item.id,
426                    names.len(),
427                    params.len()
428                )));
429            }
430            names if names == params => None,
431            names => Some(names),
432        };
433        self.by_id[kind.as_index()].insert(item.id.clone(), index);
434        let item_id = item.id.clone();
435        self.entries.push(CatalogEntry {
436            id: item.id,
437            kind,
438            callable,
439            params,
440            param_names,
441            param_aliases: item
442                .param_aliases
443                .into_iter()
444                .map(|aliases| {
445                    aliases
446                        .into_iter()
447                        .map(|(locale, alias)| {
448                            let locale_key = Locale::new(&locale);
449                            let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
450                            Ok((locale_key, spellings))
451                        })
452                        .collect::<Result<HashMap<_, _>>>()
453                })
454                .collect::<Result<Vec<_>>>()?,
455            param_domains: item.param_domains,
456            param_display_names: Vec::new(),
457            param_defaults: item.param_defaults,
458            param_types: item.param_types,
459            param_coercions: item.param_coercions,
460            param_content: item.param_content,
461            return_type: item.return_type,
462            reevaluation_coverage: item.reevaluation_coverage,
463            variadic: item.variadic,
464            aliases,
465        });
466        Ok(())
467    }
468
469    fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
470        if self.localized_string_by_id.contains_key(&item.id) {
471            return Err(CatalogError::validation(format!(
472                "duplicate localized string id '{}'",
473                item.id
474            )));
475        }
476        let index = self.localized_strings.len();
477        let aliases = self.collect_aliases(
478            &item.id,
479            &format!("localized string '{}'", item.id),
480            item.aliases,
481            true,
482            |catalog, locale, spelling| {
483                let locale_map = catalog
484                    .localized_string_alias
485                    .entry(locale.clone())
486                    .or_default();
487                if locale_map.contains_key(spelling) {
488                    return Err(CatalogError::validation(format!(
489                        "duplicate localized string alias '{spelling}' for locale '{locale}'"
490                    )));
491                }
492                locale_map.insert(spelling.to_string(), index);
493                Ok(())
494            },
495        )?;
496        self.localized_string_by_id.insert(item.id.clone(), index);
497        self.localized_strings.push(LocalizedStringEntry {
498            id: item.id,
499            aliases,
500        });
501        Ok(())
502    }
503
504    fn validate_param_domains(&self) -> Result<()> {
505        for entry in &self.entries {
506            for (name, count) in [
507                ("parameter alias sets", entry.param_aliases.len()),
508                ("param domains", entry.param_domains.len()),
509                ("param defaults", entry.param_defaults.len()),
510                ("param types", entry.param_types.len()),
511                ("param coercions", entry.param_coercions.len()),
512                ("param content facts", entry.param_content.len()),
513            ] {
514                if count > entry.params.len() {
515                    return Err(CatalogError::validation(format!(
516                        "{} '{}' declares more {name} than params",
517                        entry.kind.as_str(),
518                        entry.id
519                    )));
520                }
521            }
522            if entry.kind != Kind::Value && entry.return_type.is_some() {
523                return Err(CatalogError::validation(format!(
524                    "{} '{}' declares a return type but is not a value",
525                    entry.kind.as_str(),
526                    entry.id
527                )));
528            }
529            for domain in entry.param_domains.iter().flatten() {
530                if !self.enum_by_domain.contains_key(domain) {
531                    return Err(CatalogError::validation(format!(
532                        "{} '{}' declares undeclared enum domain '{domain}'",
533                        entry.kind.as_str(),
534                        entry.id
535                    )));
536                }
537            }
538            if let Some(coverage) = &entry.reevaluation_coverage {
539                let reeval_params: Vec<usize> = (0..entry.params.len())
540                    .filter(|index| {
541                        entry
542                            .param_domains
543                            .get(*index)
544                            .and_then(Option::as_deref)
545                            .is_some_and(|domain| domain.ends_with("Reeval"))
546                    })
547                    .collect();
548                if reeval_params.len() != 1 {
549                    return Err(CatalogError::validation(format!(
550                        "{} '{}' declares reevaluation coverage but has {} *Reeval parameters",
551                        entry.kind.as_str(),
552                        entry.id,
553                        reeval_params.len()
554                    )));
555                }
556                let domain_name = entry.param_domains[reeval_params[0]]
557                    .as_deref()
558                    .expect("filtered above");
559                let domain = self
560                    .enum_by_domain
561                    .get(domain_name)
562                    .map(|index| &self.enums[*index])
563                    .expect("param domain validated above");
564                for (member, positions) in coverage {
565                    if !domain.members.iter().any(|m| m.member == *member) {
566                        return Err(CatalogError::validation(format!(
567                            "{} '{}' declares reevaluation coverage for '{member}', which is not a member of '{domain_name}'",
568                            entry.kind.as_str(),
569                            entry.id
570                        )));
571                    }
572                    for position in positions {
573                        if *position >= entry.params.len() {
574                            return Err(CatalogError::validation(format!(
575                                "{} '{}' reevaluation coverage for '{member}' names parameter {position}, out of {} params",
576                                entry.kind.as_str(),
577                                entry.id,
578                                entry.params.len()
579                            )));
580                        }
581                        if *position == reeval_params[0] {
582                            return Err(CatalogError::validation(format!(
583                                "{} '{}' reevaluation coverage for '{member}' covers the reevaluation parameter itself",
584                                entry.kind.as_str(),
585                                entry.id
586                            )));
587                        }
588                    }
589                }
590                for member in &domain.members {
591                    if !coverage.contains_key(&member.member) {
592                        return Err(CatalogError::validation(format!(
593                            "{} '{}' reevaluation coverage does not review '{domain_name}' member '{}'",
594                            entry.kind.as_str(),
595                            entry.id,
596                            member.member
597                        )));
598                    }
599                }
600            }
601            for aliases in &entry.param_aliases {
602                for locale in aliases.keys() {
603                    if !self.locales.contains(locale) {
604                        return Err(CatalogError::validation(format!(
605                            "{} '{}' declares parameter alias for undeclared locale '{}'",
606                            entry.kind.as_str(),
607                            entry.id,
608                            locale
609                        )));
610                    }
611                }
612            }
613        }
614        Ok(())
615    }
616
617    /// The `rule-content-v1` facts declared per parameter position must
618    /// describe a well-formed content layout: a declared `paramContent`
619    /// element carries at least one fact, every `fold` names a reserved
620    /// variable-target call at a valid argument, and the members of one
621    /// fold group are contiguous parameters covering every argument of the
622    /// folded call exactly once — a `Variable`-typed member at the tagged
623    /// `{"variable"}` position.
624    fn validate_param_content(&self) -> Result<()> {
625        for entry in &self.entries {
626            // `call` -> per-member (surface position, inner `arg`) pairs.
627            let mut groups: BTreeMap<&str, Vec<(usize, usize)>> = BTreeMap::new();
628            for (index, content) in entry.param_content.iter().enumerate() {
629                let Some(content) = content else {
630                    continue;
631                };
632                if content.fold.is_none() && content.operations.is_empty() {
633                    return Err(CatalogError::validation(format!(
634                        "{} '{}' parameter {} declares an empty paramContent fact",
635                        entry.kind.as_str(),
636                        entry.id,
637                        index + 1
638                    )));
639                }
640                for operation in &content.operations {
641                    if !self.by_id[Kind::Operator.as_index()].contains_key(operation) {
642                        return Err(CatalogError::validation(format!(
643                            "{} '{}' parameter {} accepts undeclared operator '{operation}'",
644                            entry.kind.as_str(),
645                            entry.id,
646                            index + 1
647                        )));
648                    }
649                }
650                let Some(fold) = &content.fold else {
651                    continue;
652                };
653                // The reserved variable-target calls fix both their arity
654                // and which argument is the tagged `{"variable"}` node: the
655                // member supplying it must be `Variable`-typed.
656                let (arity, variable_arg) = match fold.call.as_str() {
657                    "globalVariable" => (1, 0),
658                    "playerVariable" => (2, 1),
659                    _ => {
660                        return Err(CatalogError::validation(format!(
661                            "{} '{}' parameter {} declares a fold into unknown \
662                             call '{}'",
663                            entry.kind.as_str(),
664                            entry.id,
665                            index + 1,
666                            fold.call
667                        )));
668                    }
669                };
670                if fold.arg >= arity {
671                    return Err(CatalogError::validation(format!(
672                        "{} '{}' parameter {} declares a fold into '{}' \
673                         argument {}, out of {}",
674                        entry.kind.as_str(),
675                        entry.id,
676                        index + 1,
677                        fold.call,
678                        fold.arg,
679                        arity
680                    )));
681                }
682                let variable_typed = matches!(
683                    entry.param_type(index),
684                    Some("Variable") | Some("Global Variable")
685                );
686                if fold.arg == variable_arg && !variable_typed {
687                    return Err(CatalogError::validation(format!(
688                        "{} '{}' parameter {} supplies '{}' argument {}, the tagged \
689                         variable node, but is not Variable-typed",
690                        entry.kind.as_str(),
691                        entry.id,
692                        index + 1,
693                        fold.call,
694                        fold.arg
695                    )));
696                }
697                groups
698                    .entry(fold.call.as_str())
699                    .or_default()
700                    .push((index, fold.arg));
701            }
702            for (call, members) in groups {
703                let arity = if call == "globalVariable" { 1 } else { 2 };
704                let positions: Vec<usize> = members.iter().map(|(index, _)| *index).collect();
705                let contiguous = positions.windows(2).all(|pair| pair[1] == pair[0] + 1);
706                let mut args: Vec<usize> = members.iter().map(|(_, arg)| *arg).collect();
707                args.sort_unstable();
708                if !contiguous || args != (0..arity).collect::<Vec<_>>() {
709                    return Err(CatalogError::validation(format!(
710                        "{} '{}' declares an incomplete '{}' fold group: members must be \
711                         contiguous parameters covering all {} argument(s)",
712                        entry.kind.as_str(),
713                        entry.id,
714                        call,
715                        arity
716                    )));
717                }
718            }
719        }
720        Ok(())
721    }
722
723    fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
724        let domain_index = self.enums.len();
725        if self.enum_by_domain.contains_key(&domain.domain) {
726            return Err(CatalogError::validation(format!(
727                "duplicate enum domain '{}'",
728                domain.domain
729            )));
730        }
731        let primary = self.locales[0].clone();
732        let mut domain_aliases = self.collect_aliases(
733            &domain.domain,
734            &format!("enum domain '{}'", domain.domain),
735            domain.aliases,
736            false,
737            |catalog, locale, spelling| {
738                let locale_map = catalog
739                    .enum_alias_to_domain
740                    .entry(locale.clone())
741                    .or_default();
742                if let Some(existing) = locale_map.get(spelling) {
743                    return Err(CatalogError::validation(format!(
744                        "duplicate enum domain alias '{spelling}' for '{existing}' and '{}' in locale '{locale}'",
745                        domain.domain
746                    )));
747                }
748                locale_map.insert(spelling.to_string(), domain.domain.clone());
749                Ok(())
750            },
751        )?;
752        domain_aliases
753            .entry(primary.clone())
754            .or_insert_with(|| vec![domain.domain.clone()]);
755        self.enum_alias_to_domain
756            .entry(primary.clone())
757            .or_default()
758            .entry(domain.domain.clone())
759            .or_insert_with(|| domain.domain.clone());
760        let mut members = Vec::new();
761        for (member_index, member) in domain.members.into_iter().enumerate() {
762            let aliases = self.collect_aliases(
763                &member.id,
764                &format!("enum {}::{}", domain.domain, member.id),
765                member.aliases,
766                true,
767                |catalog, locale, spelling| {
768                    let domain_map = catalog
769                        .enum_alias_to_member
770                        .entry(locale.clone())
771                        .or_default()
772                        .entry(domain.domain.clone())
773                        .or_default();
774                    if domain_map.contains_key(spelling) {
775                        return Err(CatalogError::validation(format!(
776                            "duplicate enum alias '{spelling}' in '{}' for locale '{locale}'",
777                            domain.domain
778                        )));
779                    }
780                    domain_map.insert(spelling.to_string(), (domain_index, member_index));
781                    Ok(())
782                },
783            )?;
784            members.push(EnumMember {
785                member: member.id,
786                aliases,
787            });
788        }
789        self.enum_by_domain
790            .insert(domain.domain.clone(), domain_index);
791        self.enums.push(EnumDomain {
792            domain: domain.domain,
793            aliases: domain_aliases,
794            members,
795        });
796        Ok(())
797    }
798
799    /// Distribute the top-level `paramDisplayNames` section onto the
800    /// entries it names and validate it against `paramDisplayNameGaps`.
801    /// Recorded locales must be declared and not on the gap-locale list;
802    /// when the gap section is declared, a parameter position must be on
803    /// the gap list exactly when it lacks a recorded name in at least one
804    /// supported (non-gap) locale, so the explicit gaps stay honest as the
805    /// data changes.
806    fn insert_param_display_names(
807        &mut self,
808        names: BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>,
809        gaps: Option<ParamDisplayNameGapsFile>,
810    ) -> Result<()> {
811        let mut gap_locales = BTreeSet::new();
812        let mut gap_positions = BTreeSet::new();
813        if let Some(gaps) = &gaps {
814            for gap_locale in &gaps.locales {
815                let locale = Locale::new(gap_locale);
816                if !self.locales.contains(&locale) {
817                    return Err(CatalogError::validation(format!(
818                        "param display name gap locale '{locale}' is not a declared locale"
819                    )));
820                }
821                if !gap_locales.insert(locale) {
822                    return Err(CatalogError::validation(format!(
823                        "duplicate param display name gap locale '{gap_locale}'"
824                    )));
825                }
826            }
827            for position in &gaps.positions {
828                let (id, index) = position
829                    .strip_suffix(']')
830                    .and_then(|text| text.split_once('['))
831                    .ok_or_else(|| {
832                        CatalogError::validation(format!(
833                            "param display name gap '{position}' is not written 'entryId[index]'"
834                        ))
835                    })?;
836                let index: usize = index.parse().map_err(|_| {
837                    CatalogError::validation(format!(
838                        "param display name gap '{position}' has a non-numeric index"
839                    ))
840                })?;
841                let param_counts = self
842                    .by_id
843                    .iter()
844                    .filter_map(|by_id| by_id.get(id))
845                    .map(|&entry_index| self.entries[entry_index].params.len())
846                    .collect::<Vec<_>>();
847                if param_counts.is_empty() {
848                    return Err(CatalogError::validation(format!(
849                        "param display name gap '{position}' names undeclared entry '{id}'"
850                    )));
851                }
852                if !param_counts.iter().any(|&len| index < len) {
853                    return Err(CatalogError::validation(format!(
854                        "param display name gap '{position}' exceeds the parameters of '{id}'"
855                    )));
856                }
857                if !gap_positions.insert((id.to_string(), index)) {
858                    return Err(CatalogError::validation(format!(
859                        "duplicate param display name gap '{position}'"
860                    )));
861                }
862            }
863        }
864
865        for (id, positions) in names {
866            // An id can name more than one kind — for example
867            // `modifyGlobalVariable` is declared under both `structural`
868            // and `actions` with the same parameters — so display names
869            // distribute onto every entry the id resolves to.
870            let entry_indices = self
871                .by_id
872                .iter()
873                .filter_map(|by_id| by_id.get(&id))
874                .copied()
875                .collect::<Vec<_>>();
876            if entry_indices.is_empty() {
877                return Err(CatalogError::validation(format!(
878                    "param display names name undeclared entry '{id}'"
879                )));
880            }
881            for (position, map) in positions {
882                let index: usize = position.parse().map_err(|_| {
883                    CatalogError::validation(format!(
884                        "'{id}' param display name position '{position}' is not a zero-based index"
885                    ))
886                })?;
887                let mut recorded = HashMap::new();
888                for (name_locale, name) in map {
889                    let locale = Locale::new(&name_locale);
890                    if !self.locales.contains(&locale) {
891                        return Err(CatalogError::validation(format!(
892                            "'{id}' declares a parameter display name for undeclared locale '{locale}'"
893                        )));
894                    }
895                    if gap_locales.contains(&locale) {
896                        return Err(CatalogError::validation(format!(
897                            "'{id}' declares a parameter display name for gap locale '{locale}'"
898                        )));
899                    }
900                    if name.is_empty() {
901                        return Err(CatalogError::validation(format!(
902                            "'{id}' declares an empty parameter display name for locale '{locale}'"
903                        )));
904                    }
905                    recorded.insert(locale, name);
906                }
907                if recorded.is_empty() {
908                    return Err(CatalogError::validation(format!(
909                        "'{id}' declares no display names for parameter {index}"
910                    )));
911                }
912                // The position names every entry under the id that declares
913                // it; entries without the parameter (a same-id `operators`
914                // or parameterless sibling) are not display-name carriers.
915                let mut applied = false;
916                for &entry_index in &entry_indices {
917                    let entry = &mut self.entries[entry_index];
918                    if index >= entry.params.len() {
919                        continue;
920                    }
921                    entry.param_display_names.resize(entry.params.len(), None);
922                    entry.param_display_names[index] = Some(recorded.clone());
923                    applied = true;
924                }
925                if !applied {
926                    return Err(CatalogError::validation(format!(
927                        "'{id}' declares a display name for parameter {index}, which no entry of that id has"
928                    )));
929                }
930            }
931        }
932
933        if gaps.is_some() {
934            let supported = self
935                .locales
936                .iter()
937                .filter(|locale| !gap_locales.contains(*locale))
938                .collect::<Vec<_>>();
939            for entry in &self.entries {
940                for index in 0..entry.params.len() {
941                    let names = entry
942                        .param_display_names
943                        .get(index)
944                        .and_then(Option::as_ref);
945                    let complete = supported
946                        .iter()
947                        .all(|locale| names.is_some_and(|map| map.contains_key(*locale)));
948                    let gapped = gap_positions.contains(&(entry.id.clone(), index));
949                    if complete == gapped {
950                        return Err(CatalogError::validation(format!(
951                            "{} '{}' parameter {index} must be listed in paramDisplayNameGaps exactly when it lacks a display name in a supported locale",
952                            entry.kind.as_str(),
953                            entry.id
954                        )));
955                    }
956                }
957            }
958        }
959        Ok(())
960    }
961
962    fn collect_aliases(
963        &mut self,
964        id: &str,
965        label: &str,
966        aliases: HashMap<String, AliasFile>,
967        require_primary: bool,
968        mut register: impl FnMut(&mut Self, &Locale, &str) -> Result<()>,
969    ) -> Result<HashMap<Locale, Vec<String>>> {
970        let mut collected = HashMap::new();
971        for (locale_str, alias_file) in aliases {
972            let locale = Locale::new(&locale_str);
973            if !self.locales.contains(&locale) {
974                return Err(CatalogError::validation(format!(
975                    "{label} declares alias for undeclared locale '{locale}'"
976                )));
977            }
978            let spellings = alias_file.into_spellings(id, locale.as_str())?;
979            for spelling in &spellings {
980                register(self, &locale, spelling)?;
981            }
982            collected.insert(locale, spellings);
983        }
984        let primary = &self.locales[0];
985        if require_primary && !collected.contains_key(primary) {
986            return Err(CatalogError::validation(format!(
987                "{label} is missing a '{primary}' alias"
988            )));
989        }
990        Ok(collected)
991    }
992}
993
994/// Canonicalize catalog data: parse, validate, and re-serialize
995/// deterministically (object keys sorted, stable formatting). Re-running on
996/// the same input produces byte-identical output, so the data pipeline is
997/// reproducible. Validation intentionally skips digest verification so a
998/// stale digest can be repaired by [`build_canonical`].
999pub fn canonicalize(json: &str) -> Result<String> {
1000    Catalog::load_unverified(json)?;
1001    let value: serde_json::Value = serde_json::from_str(json)
1002        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1003    pretty_json(&value)
1004}
1005
1006/// Rebuild the canonical catalog form with a fresh content digest: validate,
1007/// canonicalize, and (re)write the `digest` field. Byte-idempotent, so the
1008/// committed dataset and its digest are reproducible from the data file.
1009pub fn build_canonical(json: &str) -> Result<String> {
1010    let mut value: serde_json::Value = serde_json::from_str(json)
1011        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1012    let digest = content_digest(json)?;
1013    if let Some(object) = value.as_object_mut() {
1014        object.insert("digest".to_string(), serde_json::Value::String(digest));
1015    }
1016    let output = pretty_json(&value)?;
1017    Catalog::load(&output)?;
1018    Ok(output)
1019}
1020
1021/// The deterministic content digest of catalog data: sha256 of the canonical
1022/// (sorted-key, pretty) serialization of the parsed content with the
1023/// self-referential `digest` field removed. Independent of file formatting;
1024/// changes whenever any semantic content changes.
1025pub fn content_digest(json: &str) -> Result<String> {
1026    let mut value: serde_json::Value = serde_json::from_str(json)
1027        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1028    if let Some(object) = value.as_object_mut() {
1029        object.remove("digest");
1030    }
1031    let canonical = pretty_json(&value)?;
1032    use sha2::{Digest, Sha256};
1033    let mut hasher = Sha256::new();
1034    hasher.update(canonical.trim_end().as_bytes());
1035    Ok(format!("{:x}", hasher.finalize()))
1036}
1037
1038pub(crate) fn pretty_json(value: &serde_json::Value) -> Result<String> {
1039    serde_json::to_string_pretty(value)
1040        .map(|mut out| {
1041            out.push('\n');
1042            out
1043        })
1044        .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
1045}