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