Skip to main content

workshop_rs/catalog/
load.rs

1use std::collections::{BTreeMap, 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, 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}
42
43#[derive(Deserialize)]
44#[serde(rename_all = "camelCase")]
45struct EntryFile {
46    id: String,
47    aliases: HashMap<String, AliasFile>,
48    /// The declared parameter list. `None` means the entry declares no
49    /// signature at all; `Some` — even empty, as on the zero-argument
50    /// `else`/`end` constructs — declares a call signature.
51    #[serde(default)]
52    params: Option<Vec<String>>,
53    /// Reviewed semantic parameter names, parallel to `params`.
54    #[serde(default)]
55    param_names: Vec<String>,
56    #[serde(default)]
57    param_aliases: Vec<HashMap<String, AliasFile>>,
58    /// Canonical enum domain per parameter position (parallel to `params`);
59    /// empty when no parameter domains are documented.
60    #[serde(default)]
61    param_domains: Vec<Option<String>>,
62    /// Default value per parameter position (parallel to `params`),
63    /// resolved when a call omits the argument. `None` means no default is
64    /// declared. Default value syntax: `null`, a numeric literal, localized
65    /// string text, `Domain.MEMBER` (builtin enum member), or a catalog value
66    /// id resolved as a zero-argument call. Every default is pinned-reference
67    /// probe evidence, never copied from upstream game data.
68    #[serde(default)]
69    param_defaults: Vec<Option<String>>,
70    #[serde(default)]
71    param_types: Vec<Option<String>>,
72    #[serde(default)]
73    param_coercions: Vec<Option<ParamCoercions>>,
74    #[serde(default)]
75    return_type: Option<String>,
76    /// Reviewed reevaluation coverage keyed by enum member id (e.g.
77    /// `COLOR`), listing the parameter positions each member keeps
78    /// re-evaluating. Declared only on actions whose `*Reeval` parameter
79    /// selects the reevaluation mode; validated against that domain's
80    /// members and the parameter arity.
81    #[serde(default)]
82    reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
83    #[serde(default)]
84    variadic: bool,
85}
86
87#[derive(Deserialize)]
88struct LocalizedStringFile {
89    id: String,
90    aliases: HashMap<String, AliasFile>,
91}
92
93#[derive(Deserialize)]
94struct EnumFile {
95    domain: String,
96    #[serde(default)]
97    aliases: HashMap<String, AliasFile>,
98    members: Vec<MemberFile>,
99}
100
101#[derive(Deserialize)]
102struct MemberFile {
103    id: String,
104    aliases: HashMap<String, AliasFile>,
105}
106
107/// A locale may have one canonical emitter spelling or several reviewed
108/// spellings observed across current Workshop producers. The string form is
109/// retained for the common case; the array form makes conflicts explicit in
110/// the data instead of forcing parser branches or silently choosing one.
111#[derive(Debug, Deserialize)]
112#[serde(untagged)]
113enum AliasFile {
114    One(String),
115    Many(Vec<String>),
116}
117
118impl AliasFile {
119    fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
120        let spellings = match self {
121            AliasFile::One(spelling) => vec![spelling],
122            AliasFile::Many(spellings) => spellings,
123        };
124        if spellings.is_empty() || spellings.iter().any(String::is_empty) {
125            return Err(CatalogError::validation(format!(
126                "catalog entry '{}' declares an empty alias for locale '{}'",
127                id, locale
128            )));
129        }
130        Ok(spellings)
131    }
132}
133impl Catalog {
134    /// Parse and validate catalog data, verifying the declared content
135    /// digest when the data carries one.
136    pub fn load(json: &str) -> Result<Catalog> {
137        let catalog = Self::load_unverified(json)?;
138        if let Some(declared) = &catalog.catalog_digest {
139            let computed = content_digest(json)?;
140            if declared != &computed {
141                return Err(CatalogError::validation(format!(
142                    "catalog digest mismatch: declared '{declared}', content '{computed}' — \
143                     run the catalog pipeline (workshop-catalog-gen build)"
144                )));
145            }
146        }
147        Ok(catalog)
148    }
149
150    /// Parse and validate catalog data without digest verification. Used by
151    /// the catalog pipeline so a stale digest can be repaired by `build`.
152    pub fn load_unverified(json: &str) -> Result<Catalog> {
153        let file: CatalogFile = serde_json::from_str(json)
154            .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
155        if file.schema_version != 1 {
156            return Err(CatalogError::malformed(format!(
157                "unsupported catalog schemaVersion {}",
158                file.schema_version
159            )));
160        }
161        let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
162        if locales.is_empty() {
163            return Err(CatalogError::malformed(
164                "catalog declares no locales".to_string(),
165            ));
166        }
167
168        let mut catalog = Catalog {
169            detection_index: None,
170            schema_version: file.schema_version,
171            locales,
172            target: file.target,
173            provenance: file.provenance,
174            catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
175            catalog_digest: file.digest,
176            entries: Vec::new(),
177            localized_strings: Vec::new(),
178            enums: Vec::new(),
179            by_id: Default::default(),
180            alias_to_entry: HashMap::new(),
181            localized_string_by_id: HashMap::new(),
182            localized_string_alias: HashMap::new(),
183            enum_by_domain: HashMap::new(),
184            enum_alias_to_domain: HashMap::new(),
185            enum_alias_to_member: HashMap::new(),
186            bare_member_index: HashMap::new(),
187            normalized: NormalizedIndex::default(),
188        };
189
190        for (kind, items) in [
191            (Kind::Structural, file.structural),
192            (Kind::Action, file.actions),
193            (Kind::Value, file.values),
194            (Kind::Event, file.events),
195            (Kind::Operator, file.operators),
196            (Kind::Setting, file.settings),
197        ] {
198            for item in items {
199                catalog.insert_entry(kind, item)?;
200            }
201        }
202        for item in file.localized_strings {
203            catalog.insert_localized_string(item)?;
204        }
205        for domain in file.enums {
206            catalog.insert_enum(domain)?;
207        }
208        for domain in &catalog.enums {
209            for member in &domain.members {
210                for (locale, spellings) in &member.aliases {
211                    for spelling in spellings {
212                        catalog
213                            .bare_member_index
214                            .entry(locale.clone())
215                            .or_default()
216                            .entry(spelling.clone())
217                            .or_default()
218                            .push((domain.domain.clone(), member.member.clone()));
219                    }
220                }
221            }
222        }
223        catalog.build_normalized_index();
224        catalog.validate_param_domains()?;
225        catalog.detection_index =
226            Some(super::detect::AliasIndex::build(&catalog).map_err(|error| {
227                CatalogError::validation(format!("locale detection index: {error}"))
228            })?);
229        Ok(catalog)
230    }
231
232    /// The built-in catalog data.
233    pub fn builtin() -> Result<Catalog> {
234        Self::builtin_ref().cloned().map_err(Clone::clone)
235    }
236
237    /// The built-in catalog, parsed and indexed once per process.
238    pub fn builtin_ref() -> std::result::Result<&'static Catalog, &'static WorkshopError> {
239        static DATA: OnceLock<Result<Catalog>> = OnceLock::new();
240        match DATA.get_or_init(|| Self::load(CATALOG_DATA)) {
241            Ok(catalog) => Ok(catalog),
242            Err(error) => Err(error),
243        }
244    }
245    /// Mirror the verbatim spelling indexes under whitespace-collapsed
246    /// keys (#448). A normalized key claimed by distinct targets is poisoned
247    /// (`None`) so it resolves nothing rather than guessing; identical
248    /// targets (two declared spellings that differ only in whitespace, such
249    /// as zh-CN `66号公路`/`66 号公路`) keep their single meaning.
250    fn build_normalized_index(&mut self) {
251        fn insert<T: PartialEq + Clone>(
252            map: &mut HashMap<String, Option<T>>,
253            key: String,
254            value: T,
255        ) {
256            match map.get_mut(&key) {
257                Some(slot) if *slot != Some(value.clone()) => *slot = None,
258                Some(_) => {}
259                None => {
260                    map.insert(key, Some(value));
261                }
262            }
263        }
264
265        let mut normalized = NormalizedIndex::default();
266        for (locale, by_kind) in &self.alias_to_entry {
267            let by_kind_norm = normalized
268                .alias_to_entry
269                .entry(locale.clone())
270                .or_insert_with(|| std::array::from_fn(|_| HashMap::new()));
271            for (kind_index, map) in by_kind.iter().enumerate() {
272                for (spelling, &entry_index) in map {
273                    let key = normalize_spelling(spelling);
274                    if key != spelling.as_str() {
275                        insert(&mut by_kind_norm[kind_index], key.into_owned(), entry_index);
276                    }
277                }
278            }
279        }
280        for (locale, map) in &self.localized_string_alias {
281            let norm = normalized
282                .localized_string_alias
283                .entry(locale.clone())
284                .or_default();
285            for (spelling, &string_index) in map {
286                let key = normalize_spelling(spelling);
287                if key != spelling.as_str() {
288                    insert(norm, key.into_owned(), string_index);
289                }
290            }
291        }
292        for (locale, map) in &self.enum_alias_to_domain {
293            let norm = normalized
294                .enum_alias_to_domain
295                .entry(locale.clone())
296                .or_default();
297            for (spelling, domain) in map {
298                let key = normalize_spelling(spelling);
299                if key != spelling.as_str() {
300                    insert(norm, key.into_owned(), domain.clone());
301                }
302            }
303        }
304        for (locale, domains) in &self.enum_alias_to_member {
305            let norm = normalized
306                .enum_alias_to_member
307                .entry(locale.clone())
308                .or_default();
309            for (domain, map) in domains {
310                let norm_map = norm.entry(domain.clone()).or_default();
311                for (spelling, &indexes) in map {
312                    let key = normalize_spelling(spelling);
313                    if key != spelling.as_str() {
314                        insert(norm_map, key.into_owned(), indexes);
315                    }
316                }
317            }
318        }
319        for (locale, map) in &self.bare_member_index {
320            let norm = normalized.bare_member.entry(locale.clone()).or_default();
321            for (spelling, matches) in map {
322                let key = normalize_spelling(spelling);
323                if key != spelling.as_str() {
324                    let merged = norm.entry(key.into_owned()).or_default();
325                    for pair in matches {
326                        if !merged.contains(pair) {
327                            merged.push(pair.clone());
328                        }
329                    }
330                }
331            }
332        }
333        self.normalized = normalized;
334    }
335
336    fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
337        let index = self.entries.len();
338        let aliases = self.collect_aliases(
339            &item.id,
340            &format!("entry '{}'", item.id),
341            item.aliases,
342            true,
343            |catalog, locale, spelling| {
344                let locale_map = catalog.alias_to_entry.entry(locale.clone()).or_default();
345                if locale_map[kind.as_index()].contains_key(spelling) {
346                    return Err(CatalogError::validation(format!(
347                        "duplicate {} alias '{spelling}' for locale '{locale}'",
348                        kind.as_str()
349                    )));
350                }
351                locale_map[kind.as_index()].insert(spelling.to_string(), index);
352                Ok(())
353            },
354        )?;
355        if self.by_id[kind.as_index()].contains_key(&item.id) {
356            return Err(CatalogError::validation(format!(
357                "duplicate {} id '{}'",
358                kind.as_str(),
359                item.id
360            )));
361        }
362        // The primary locale's surface is complete: every builtin carries a
363        // primary-locale alias. Additional declared locales may be partially
364        // covered; missing target-locale mappings fail explicitly at
365        // conversion/emission time (ADR-0001 Decision 7).
366        // A signature-bearing entry is any action or value, or a structural
367        // entry that declares its parameter list — `"params": []` marks a
368        // zero-argument construct such as `else`/`end`, while section
369        // keywords declare no `params` field and stay without a signature.
370        let callable = matches!(kind, Kind::Action | Kind::Value)
371            || (kind == Kind::Structural && item.params.is_some());
372        let params = item.params.unwrap_or_default();
373        let param_names = match item.param_names {
374            names if names.is_empty() => None,
375            names if names.len() != params.len() => {
376                return Err(CatalogError::validation(format!(
377                    "{} '{}' declares {} param names for {} params",
378                    kind.as_str(),
379                    item.id,
380                    names.len(),
381                    params.len()
382                )));
383            }
384            names if names == params => None,
385            names => Some(names),
386        };
387        self.by_id[kind.as_index()].insert(item.id.clone(), index);
388        let item_id = item.id.clone();
389        self.entries.push(CatalogEntry {
390            id: item.id,
391            kind,
392            callable,
393            params,
394            param_names,
395            param_aliases: item
396                .param_aliases
397                .into_iter()
398                .map(|aliases| {
399                    aliases
400                        .into_iter()
401                        .map(|(locale, alias)| {
402                            let locale_key = Locale::new(&locale);
403                            let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
404                            Ok((locale_key, spellings))
405                        })
406                        .collect::<Result<HashMap<_, _>>>()
407                })
408                .collect::<Result<Vec<_>>>()?,
409            param_domains: item.param_domains,
410            param_defaults: item.param_defaults,
411            param_types: item.param_types,
412            param_coercions: item.param_coercions,
413            return_type: item.return_type,
414            reevaluation_coverage: item.reevaluation_coverage,
415            variadic: item.variadic,
416            aliases,
417        });
418        Ok(())
419    }
420
421    fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
422        if self.localized_string_by_id.contains_key(&item.id) {
423            return Err(CatalogError::validation(format!(
424                "duplicate localized string id '{}'",
425                item.id
426            )));
427        }
428        let index = self.localized_strings.len();
429        let aliases = self.collect_aliases(
430            &item.id,
431            &format!("localized string '{}'", item.id),
432            item.aliases,
433            true,
434            |catalog, locale, spelling| {
435                let locale_map = catalog
436                    .localized_string_alias
437                    .entry(locale.clone())
438                    .or_default();
439                if locale_map.contains_key(spelling) {
440                    return Err(CatalogError::validation(format!(
441                        "duplicate localized string alias '{spelling}' for locale '{locale}'"
442                    )));
443                }
444                locale_map.insert(spelling.to_string(), index);
445                Ok(())
446            },
447        )?;
448        self.localized_string_by_id.insert(item.id.clone(), index);
449        self.localized_strings.push(LocalizedStringEntry {
450            id: item.id,
451            aliases,
452        });
453        Ok(())
454    }
455
456    fn validate_param_domains(&self) -> Result<()> {
457        for entry in &self.entries {
458            for (name, count) in [
459                ("parameter alias sets", entry.param_aliases.len()),
460                ("param domains", entry.param_domains.len()),
461                ("param defaults", entry.param_defaults.len()),
462                ("param types", entry.param_types.len()),
463                ("param coercions", entry.param_coercions.len()),
464            ] {
465                if count > entry.params.len() {
466                    return Err(CatalogError::validation(format!(
467                        "{} '{}' declares more {name} than params",
468                        entry.kind.as_str(),
469                        entry.id
470                    )));
471                }
472            }
473            if entry.kind != Kind::Value && entry.return_type.is_some() {
474                return Err(CatalogError::validation(format!(
475                    "{} '{}' declares a return type but is not a value",
476                    entry.kind.as_str(),
477                    entry.id
478                )));
479            }
480            for domain in entry.param_domains.iter().flatten() {
481                if !self.enum_by_domain.contains_key(domain) {
482                    return Err(CatalogError::validation(format!(
483                        "{} '{}' declares undeclared enum domain '{domain}'",
484                        entry.kind.as_str(),
485                        entry.id
486                    )));
487                }
488            }
489            if let Some(coverage) = &entry.reevaluation_coverage {
490                let reeval_params: Vec<usize> = (0..entry.params.len())
491                    .filter(|index| {
492                        entry
493                            .param_domains
494                            .get(*index)
495                            .and_then(Option::as_deref)
496                            .is_some_and(|domain| domain.ends_with("Reeval"))
497                    })
498                    .collect();
499                if reeval_params.len() != 1 {
500                    return Err(CatalogError::validation(format!(
501                        "{} '{}' declares reevaluation coverage but has {} *Reeval parameters",
502                        entry.kind.as_str(),
503                        entry.id,
504                        reeval_params.len()
505                    )));
506                }
507                let domain_name = entry.param_domains[reeval_params[0]]
508                    .as_deref()
509                    .expect("filtered above");
510                let domain = self
511                    .enum_by_domain
512                    .get(domain_name)
513                    .map(|index| &self.enums[*index])
514                    .expect("param domain validated above");
515                for (member, positions) in coverage {
516                    if !domain.members.iter().any(|m| m.member == *member) {
517                        return Err(CatalogError::validation(format!(
518                            "{} '{}' declares reevaluation coverage for '{member}', which is not a member of '{domain_name}'",
519                            entry.kind.as_str(),
520                            entry.id
521                        )));
522                    }
523                    for position in positions {
524                        if *position >= entry.params.len() {
525                            return Err(CatalogError::validation(format!(
526                                "{} '{}' reevaluation coverage for '{member}' names parameter {position}, out of {} params",
527                                entry.kind.as_str(),
528                                entry.id,
529                                entry.params.len()
530                            )));
531                        }
532                        if *position == reeval_params[0] {
533                            return Err(CatalogError::validation(format!(
534                                "{} '{}' reevaluation coverage for '{member}' covers the reevaluation parameter itself",
535                                entry.kind.as_str(),
536                                entry.id
537                            )));
538                        }
539                    }
540                }
541                for member in &domain.members {
542                    if !coverage.contains_key(&member.member) {
543                        return Err(CatalogError::validation(format!(
544                            "{} '{}' reevaluation coverage does not review '{domain_name}' member '{}'",
545                            entry.kind.as_str(),
546                            entry.id,
547                            member.member
548                        )));
549                    }
550                }
551            }
552            for aliases in &entry.param_aliases {
553                for locale in aliases.keys() {
554                    if !self.locales.contains(locale) {
555                        return Err(CatalogError::validation(format!(
556                            "{} '{}' declares parameter alias for undeclared locale '{}'",
557                            entry.kind.as_str(),
558                            entry.id,
559                            locale
560                        )));
561                    }
562                }
563            }
564        }
565        Ok(())
566    }
567
568    fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
569        let domain_index = self.enums.len();
570        if self.enum_by_domain.contains_key(&domain.domain) {
571            return Err(CatalogError::validation(format!(
572                "duplicate enum domain '{}'",
573                domain.domain
574            )));
575        }
576        let primary = self.locales[0].clone();
577        let mut domain_aliases = self.collect_aliases(
578            &domain.domain,
579            &format!("enum domain '{}'", domain.domain),
580            domain.aliases,
581            false,
582            |catalog, locale, spelling| {
583                let locale_map = catalog
584                    .enum_alias_to_domain
585                    .entry(locale.clone())
586                    .or_default();
587                if let Some(existing) = locale_map.get(spelling) {
588                    return Err(CatalogError::validation(format!(
589                        "duplicate enum domain alias '{spelling}' for '{existing}' and '{}' in locale '{locale}'",
590                        domain.domain
591                    )));
592                }
593                locale_map.insert(spelling.to_string(), domain.domain.clone());
594                Ok(())
595            },
596        )?;
597        domain_aliases
598            .entry(primary.clone())
599            .or_insert_with(|| vec![domain.domain.clone()]);
600        self.enum_alias_to_domain
601            .entry(primary.clone())
602            .or_default()
603            .entry(domain.domain.clone())
604            .or_insert_with(|| domain.domain.clone());
605        let mut members = Vec::new();
606        for (member_index, member) in domain.members.into_iter().enumerate() {
607            let aliases = self.collect_aliases(
608                &member.id,
609                &format!("enum {}::{}", domain.domain, member.id),
610                member.aliases,
611                true,
612                |catalog, locale, spelling| {
613                    let domain_map = catalog
614                        .enum_alias_to_member
615                        .entry(locale.clone())
616                        .or_default()
617                        .entry(domain.domain.clone())
618                        .or_default();
619                    if domain_map.contains_key(spelling) {
620                        return Err(CatalogError::validation(format!(
621                            "duplicate enum alias '{spelling}' in '{}' for locale '{locale}'",
622                            domain.domain
623                        )));
624                    }
625                    domain_map.insert(spelling.to_string(), (domain_index, member_index));
626                    Ok(())
627                },
628            )?;
629            members.push(EnumMember {
630                member: member.id,
631                aliases,
632            });
633        }
634        self.enum_by_domain
635            .insert(domain.domain.clone(), domain_index);
636        self.enums.push(EnumDomain {
637            domain: domain.domain,
638            aliases: domain_aliases,
639            members,
640        });
641        Ok(())
642    }
643
644    fn collect_aliases(
645        &mut self,
646        id: &str,
647        label: &str,
648        aliases: HashMap<String, AliasFile>,
649        require_primary: bool,
650        mut register: impl FnMut(&mut Self, &Locale, &str) -> Result<()>,
651    ) -> Result<HashMap<Locale, Vec<String>>> {
652        let mut collected = HashMap::new();
653        for (locale_str, alias_file) in aliases {
654            let locale = Locale::new(&locale_str);
655            if !self.locales.contains(&locale) {
656                return Err(CatalogError::validation(format!(
657                    "{label} declares alias for undeclared locale '{locale}'"
658                )));
659            }
660            let spellings = alias_file.into_spellings(id, locale.as_str())?;
661            for spelling in &spellings {
662                register(self, &locale, spelling)?;
663            }
664            collected.insert(locale, spellings);
665        }
666        let primary = &self.locales[0];
667        if require_primary && !collected.contains_key(primary) {
668            return Err(CatalogError::validation(format!(
669                "{label} is missing a '{primary}' alias"
670            )));
671        }
672        Ok(collected)
673    }
674}
675
676/// Canonicalize catalog data: parse, validate, and re-serialize
677/// deterministically (object keys sorted, stable formatting). Re-running on
678/// the same input produces byte-identical output, so the data pipeline is
679/// reproducible. Validation intentionally skips digest verification so a
680/// stale digest can be repaired by [`build_canonical`].
681pub fn canonicalize(json: &str) -> Result<String> {
682    Catalog::load_unverified(json)?;
683    let value: serde_json::Value = serde_json::from_str(json)
684        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
685    pretty_json(&value)
686}
687
688/// Rebuild the canonical catalog form with a fresh content digest: validate,
689/// canonicalize, and (re)write the `digest` field. Byte-idempotent, so the
690/// committed dataset and its digest are reproducible from the data file.
691pub fn build_canonical(json: &str) -> Result<String> {
692    let mut value: serde_json::Value = serde_json::from_str(json)
693        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
694    let digest = content_digest(json)?;
695    if let Some(object) = value.as_object_mut() {
696        object.insert("digest".to_string(), serde_json::Value::String(digest));
697    }
698    let output = pretty_json(&value)?;
699    Catalog::load(&output)?;
700    Ok(output)
701}
702
703/// The deterministic content digest of catalog data: sha256 of the canonical
704/// (sorted-key, pretty) serialization of the parsed content with the
705/// self-referential `digest` field removed. Independent of file formatting;
706/// changes whenever any semantic content changes.
707pub fn content_digest(json: &str) -> Result<String> {
708    let mut value: serde_json::Value = serde_json::from_str(json)
709        .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
710    if let Some(object) = value.as_object_mut() {
711        object.remove("digest");
712    }
713    let canonical = pretty_json(&value)?;
714    use sha2::{Digest, Sha256};
715    let mut hasher = Sha256::new();
716    hasher.update(canonical.trim_end().as_bytes());
717    Ok(format!("{:x}", hasher.finalize()))
718}
719
720fn pretty_json(value: &serde_json::Value) -> Result<String> {
721    serde_json::to_string_pretty(value)
722        .map(|mut out| {
723            out.push('\n');
724            out
725        })
726        .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
727}