Skip to main content

workshop_rs/catalog/
descriptions.rs

1//! Localized descriptions for catalog actions and values.
2//!
3//! Descriptions are catalog data, not Rust items
4//! ([ADR-0016](https://github.com/wrightkit/workshop-rs/blob/main/docs/adr/0016-catalog-content-outside-rust-api.md)):
5//! they live in `data/descriptions.json`, a separate dataset indexed by
6//! catalog id next to `catalog.json`. An entry carries a description and
7//! per-parameter descriptions as locale maps; a requested locale with no
8//! text falls back to the primary locale (`en-US`) with the fallback
9//! marked on the returned [`Description`].
10//!
11//! Coverage is explicit: for every shipped locale, every action, every
12//! value, and every parameter position either carries text or appears on
13//! the dataset's `gaps` list, enforced at load time. A locale declared in
14//! `pendingLocales` is an announced future stage — it carries no text and
15//! needs no per-entry gaps.
16
17use std::collections::{BTreeMap, HashMap};
18
19use serde::Deserialize;
20
21use crate::core::error::{CatalogError, Result};
22
23use super::load::pretty_json;
24use super::{Catalog, Kind, Locale, Provenance, content_digest};
25
26/// The embedded description data, next to [`super::CATALOG_DATA`].
27pub const DESCRIPTIONS_DATA: &str = include_str!("data/descriptions.json");
28
29/// A description text resolved for a requested locale.
30#[derive(Debug, Clone, PartialEq, Eq)]
31#[non_exhaustive]
32pub struct Description {
33    /// The description text.
34    pub text: String,
35    /// The locale the text is written in — the requested locale, or the
36    /// primary locale when the request fell back.
37    pub locale: Locale,
38    /// Whether the requested locale had no text and the primary locale's
39    /// text was returned instead.
40    pub fallback: bool,
41}
42
43/// The description data of one catalog entry: the entry description plus
44/// per-parameter descriptions, each a locale map.
45#[derive(Debug, Clone)]
46pub(crate) struct EntryDescriptions {
47    pub(crate) description: HashMap<Locale, String>,
48    /// Parallel to `CatalogEntry::params`; `None` where the position has
49    /// no description.
50    pub(crate) params: Vec<Option<HashMap<Locale, String>>>,
51}
52
53/// The validated description dataset attached to a [`Catalog`].
54#[derive(Debug, Clone, Default)]
55pub(crate) struct Descriptions {
56    /// The dataset version; `"dev"` when the data declares none.
57    pub(crate) version: String,
58    /// The declared content digest (sha256 hex), when the data carries one.
59    pub(crate) digest: Option<String>,
60    /// The locales the dataset ships text for.
61    pub(crate) locales: Vec<Locale>,
62    /// Declared locales whose coverage is a pending future stage; they
63    /// carry no text and need no gap entries.
64    pub(crate) pending: Vec<Locale>,
65    /// Entry index → description data.
66    pub(crate) by_entry: HashMap<usize, EntryDescriptions>,
67}
68
69#[derive(Deserialize)]
70#[serde(rename_all = "camelCase")]
71struct DescriptionsFile {
72    schema_version: u32,
73    /// The locales this dataset ships text for.
74    locales: Vec<String>,
75    /// Declared catalog locales whose description coverage is a pending
76    /// stage.
77    #[serde(default)]
78    pending_locales: Vec<String>,
79    #[allow(dead_code)]
80    provenance: Provenance,
81    #[serde(default)]
82    version: Option<String>,
83    #[serde(default)]
84    digest: Option<String>,
85    #[serde(default)]
86    entries: DescriptionSections,
87    #[serde(default)]
88    gaps: BTreeMap<String, Vec<String>>,
89}
90
91/// Per-kind sections of `entries`; kinds without reviewed descriptions
92/// (structural constructs, events, operators, settings) are rejected so
93/// the file cannot silently carry undescribed-scope data.
94#[derive(Deserialize, Default)]
95#[serde(deny_unknown_fields)]
96struct DescriptionSections {
97    #[serde(default)]
98    actions: BTreeMap<String, EntryDescriptionsFile>,
99    #[serde(default)]
100    values: BTreeMap<String, EntryDescriptionsFile>,
101}
102
103#[derive(Deserialize)]
104#[serde(deny_unknown_fields)]
105struct EntryDescriptionsFile {
106    /// The entry's own description as a locale map.
107    #[serde(default)]
108    description: HashMap<String, String>,
109    /// Per-parameter descriptions as locale maps, parallel to the
110    /// entry's catalog parameter list; `null` marks a position with no
111    /// description.
112    #[serde(default)]
113    params: Option<Vec<Option<HashMap<String, String>>>>,
114}
115
116/// One declared coverage gap: `<id>` for an entry description, or
117/// `<id>.params[<index>]` for a parameter position.
118#[derive(Debug, Clone, PartialEq, Eq)]
119enum Gap {
120    Entry(String),
121    Param(String, usize),
122}
123
124fn parse_gap(key: &str) -> Option<Gap> {
125    if let Some(without_bracket) = key.strip_suffix(']') {
126        let (id, index) = without_bracket.split_once(".params[")?;
127        return index
128            .parse()
129            .ok()
130            .map(|index| Gap::Param(id.to_string(), index));
131    }
132    (!key.contains(['.', '[', ']'])).then(|| Gap::Entry(key.to_string()))
133}
134
135fn parse_file(json: &str) -> Result<DescriptionsFile> {
136    serde_json::from_str(json)
137        .map_err(|error| CatalogError::malformed(format!("descriptions data: {error}")))
138}
139
140fn verify_digest(json: &str, file: &DescriptionsFile) -> Result<()> {
141    if let Some(declared) = &file.digest {
142        let computed = content_digest(json)?;
143        if declared != &computed {
144            return Err(CatalogError::validation(format!(
145                "descriptions digest mismatch: declared '{declared}', content '{computed}' — \
146                 run the catalog pipeline (workshop-catalog-gen build)"
147            )));
148        }
149    }
150    Ok(())
151}
152
153fn check_text(owner: &str, locale: &str, text: &str) -> Result<()> {
154    if text.is_empty() {
155        return Err(CatalogError::validation(format!(
156            "descriptions entry {owner} declares an empty '{locale}' text"
157        )));
158    }
159    if text.trim() != text || text.contains('\r') {
160        return Err(CatalogError::validation(format!(
161            "descriptions entry {owner} declares an untrimmed '{locale}' text"
162        )));
163    }
164    Ok(())
165}
166
167/// Validate `file` against `catalog` into a [`Descriptions`] dataset.
168fn validated(catalog: &Catalog, file: DescriptionsFile) -> Result<Descriptions> {
169    if file.schema_version != 1 {
170        return Err(CatalogError::malformed(format!(
171            "unsupported descriptions schemaVersion {}",
172            file.schema_version
173        )));
174    }
175    let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
176    for locale in &locales {
177        if !catalog.locales.contains(locale) {
178            return Err(CatalogError::validation(format!(
179                "descriptions data ships undeclared catalog locale '{locale}'"
180            )));
181        }
182    }
183    let mut pending = Vec::new();
184    for pending_locale in &file.pending_locales {
185        let locale = Locale::new(pending_locale);
186        if !catalog.locales.contains(&locale) {
187            return Err(CatalogError::validation(format!(
188                "descriptions data pends undeclared catalog locale '{locale}'"
189            )));
190        }
191        if locales.contains(&locale) || pending.contains(&locale) {
192            return Err(CatalogError::validation(format!(
193                "descriptions data declares locale '{locale}' as both shipped and pending"
194            )));
195        }
196        pending.push(locale);
197    }
198    let shipped = |locale: &Locale| locales.contains(locale);
199
200    // Parse the gap list: every key must name a real entry or parameter
201    // position, and every gap locale must be a shipped locale.
202    let mut gaps: HashMap<Locale, Vec<Gap>> = HashMap::new();
203    for (locale_str, keys) in &file.gaps {
204        let locale = Locale::new(locale_str);
205        if !shipped(&locale) {
206            return Err(CatalogError::validation(format!(
207                "descriptions gaps list locale '{locale}', which the dataset does not ship"
208            )));
209        }
210        let parsed = gaps.entry(locale.clone()).or_default();
211        for key in keys {
212            let gap = parse_gap(key).ok_or_else(|| {
213                CatalogError::validation(format!(
214                    "descriptions gap '{key}' is not '<id>' or '<id>.params[<index>]'"
215                ))
216            })?;
217            let id = match &gap {
218                Gap::Entry(id) | Gap::Param(id, _) => id,
219            };
220            let index = catalog.by_id[Kind::Action.as_index()]
221                .get(id)
222                .or_else(|| catalog.by_id[Kind::Value.as_index()].get(id))
223                .copied()
224                .ok_or_else(|| {
225                    CatalogError::validation(format!(
226                        "descriptions gap '{key}' names no catalog action or value"
227                    ))
228                })?;
229            if let Gap::Param(_, position) = &gap
230                && *position >= catalog.entries[index].params.len()
231            {
232                return Err(CatalogError::validation(format!(
233                    "descriptions gap '{key}' names parameter {position}, \
234                         out of {} params",
235                    catalog.entries[index].params.len()
236                )));
237            }
238            parsed.push(gap);
239        }
240    }
241
242    // Attach entry descriptions: ids must exist, texts must be non-empty
243    // shipped-locale maps, and a declared params list must be parallel to
244    // the catalog parameter list.
245    let mut by_entry: HashMap<usize, EntryDescriptions> = HashMap::new();
246    for (kind, section) in [
247        (Kind::Action, &file.entries.actions),
248        (Kind::Value, &file.entries.values),
249    ] {
250        for (id, entry_file) in section {
251            let index = *catalog.by_id[kind.as_index()].get(id).ok_or_else(|| {
252                CatalogError::validation(format!(
253                    "descriptions entry '{id}' is not a catalog {} id",
254                    kind.as_str()
255                ))
256            })?;
257            let entry = &catalog.entries[index];
258            let mut description = HashMap::new();
259            for (locale_str, text) in &entry_file.description {
260                let locale = Locale::new(locale_str);
261                if !shipped(&locale) {
262                    return Err(CatalogError::validation(format!(
263                        "descriptions entry '{id}' declares '{locale}' text, \
264                         which the dataset does not ship"
265                    )));
266                }
267                check_text(&format!("'{id}'"), locale_str, text)?;
268                description.insert(locale, text.clone());
269            }
270            let mut params: Vec<Option<HashMap<Locale, String>>> = Vec::new();
271            if let Some(param_files) = &entry_file.params {
272                if param_files.len() != entry.params.len() {
273                    return Err(CatalogError::validation(format!(
274                        "{} '{id}' declares {} parameter descriptions for {} params",
275                        kind.as_str(),
276                        param_files.len(),
277                        entry.params.len()
278                    )));
279                }
280                for (position, param_file) in param_files.iter().enumerate() {
281                    let mut texts = HashMap::new();
282                    if let Some(param_file) = param_file {
283                        if param_file.is_empty() {
284                            return Err(CatalogError::validation(format!(
285                                "{} '{id}' declares an empty locale map at parameter {position}",
286                                kind.as_str()
287                            )));
288                        }
289                        for (locale_str, text) in param_file {
290                            let locale = Locale::new(locale_str);
291                            if !shipped(&locale) {
292                                return Err(CatalogError::validation(format!(
293                                    "{} '{id}' parameter {position} declares '{locale}' text, \
294                                     which the dataset does not ship",
295                                    kind.as_str()
296                                )));
297                            }
298                            check_text(&format!("'{id}' parameter {position}"), locale_str, text)?;
299                            texts.insert(locale, text.clone());
300                        }
301                    }
302                    params.push(if texts.is_empty() { None } else { Some(texts) });
303                }
304            }
305            if description.is_empty() && params.iter().all(Option::is_none) {
306                return Err(CatalogError::validation(format!(
307                    "descriptions entry '{id}' carries no description text"
308                )));
309            }
310            by_entry.insert(
311                index,
312                EntryDescriptions {
313                    description,
314                    params,
315                },
316            );
317        }
318    }
319
320    // Coverage is explicit: for every shipped locale each action, each
321    // value, and every parameter position either carries text or names a
322    // gap — never both, never neither.
323    for locale in &locales {
324        let declared: Vec<&Gap> = gaps
325            .get(locale)
326            .map(|gaps| gaps.iter().collect())
327            .unwrap_or_default();
328        for entry in catalog
329            .entries
330            .iter()
331            .filter(|entry| matches!(entry.kind, Kind::Action | Kind::Value))
332        {
333            let index = catalog.by_id[entry.kind.as_index()][&entry.id];
334            let has_text = by_entry
335                .get(&index)
336                .is_some_and(|data| data.description.contains_key(locale));
337            if has_text == declared.contains(&&Gap::Entry(entry.id.clone())) {
338                return Err(CatalogError::validation(format!(
339                    "descriptions coverage for '{}' '{locale}' must be text or a gap, \
340                     not both or neither",
341                    entry.id
342                )));
343            }
344            for position in 0..entry.params.len() {
345                let has_text = by_entry
346                    .get(&index)
347                    .and_then(|data| data.params.get(position))
348                    .and_then(Option::as_ref)
349                    .is_some_and(|texts| texts.contains_key(locale));
350                if has_text == declared.contains(&&Gap::Param(entry.id.clone(), position)) {
351                    return Err(CatalogError::validation(format!(
352                        "descriptions coverage for '{}' parameter {position} '{locale}' \
353                         must be text or a gap, not both or neither",
354                        entry.id
355                    )));
356                }
357            }
358        }
359    }
360
361    Ok(Descriptions {
362        version: file.version.unwrap_or_else(|| "dev".to_string()),
363        digest: file.digest,
364        locales,
365        pending,
366        by_entry,
367    })
368}
369
370impl Catalog {
371    /// Validate and attach localized description data (`descriptions.json`),
372    /// verifying the declared content digest when present.
373    ///
374    /// The dataset must cover the catalog it attaches to: for every shipped
375    /// locale each action, each value, and every parameter position either
376    /// carries text or names an explicit `gaps` entry. `pendingLocales`
377    /// declares catalog locales whose coverage is a planned stage; they
378    /// carry no text.
379    pub fn load_descriptions(&mut self, json: &str) -> Result<()> {
380        let file = parse_file(json)?;
381        verify_digest(json, &file)?;
382        self.descriptions = validated(self, file)?;
383        Ok(())
384    }
385
386    /// Parse and validate description data without digest verification, so
387    /// the catalog pipeline can repair a stale digest (`build`).
388    pub fn load_descriptions_unverified(&mut self, json: &str) -> Result<()> {
389        self.descriptions = validated(self, parse_file(json)?)?;
390        Ok(())
391    }
392
393    /// The description dataset version; `"dev"` when none is declared or
394    /// no descriptions are attached.
395    pub fn descriptions_version(&self) -> &str {
396        &self.descriptions.version
397    }
398
399    /// The declared content digest (sha256 hex) of the attached description
400    /// dataset, verified at load; `None` when none is declared or no
401    /// descriptions are attached.
402    pub fn descriptions_digest(&self) -> Option<&str> {
403        self.descriptions.digest.as_deref()
404    }
405
406    /// The locales the description dataset ships text for.
407    pub fn description_locales(&self) -> &[Locale] {
408        &self.descriptions.locales
409    }
410
411    /// Declared catalog locales whose description coverage is a pending
412    /// future stage; they carry no text.
413    pub fn pending_description_locales(&self) -> &[Locale] {
414        &self.descriptions.pending
415    }
416
417    /// The description of an action or value in `locale` — the locale's own
418    /// text when declared, or the primary locale's text with `fallback`
419    /// marked, or `None` when neither exists.
420    pub fn description(&self, kind: Kind, id: &str, locale: &Locale) -> Option<Description> {
421        let index = *self.by_id[kind.as_index()].get(id)?;
422        self.descriptions
423            .by_entry
424            .get(&index)
425            .and_then(|data| self.resolve_text(&data.description, locale))
426    }
427
428    /// The description of one declared parameter position of an action or
429    /// value in `locale`, with the same marked primary-locale fallback as
430    /// [`Catalog::description`].
431    pub fn param_description(
432        &self,
433        kind: Kind,
434        id: &str,
435        position: usize,
436        locale: &Locale,
437    ) -> Option<Description> {
438        let index = *self.by_id[kind.as_index()].get(id)?;
439        self.descriptions
440            .by_entry
441            .get(&index)
442            .and_then(|data| data.params.get(position))
443            .and_then(Option::as_ref)
444            .and_then(|texts| self.resolve_text(texts, locale))
445    }
446
447    fn resolve_text(
448        &self,
449        texts: &HashMap<Locale, String>,
450        locale: &Locale,
451    ) -> Option<Description> {
452        if let Some(text) = texts.get(locale) {
453            return Some(Description {
454                text: text.clone(),
455                locale: locale.clone(),
456                fallback: false,
457            });
458        }
459        let primary = self.primary_locale();
460        texts.get(primary).map(|text| Description {
461            text: text.clone(),
462            locale: primary.clone(),
463            fallback: true,
464        })
465    }
466}
467
468/// Canonicalize description data against `catalog`: validate and re-serialize
469/// deterministically (sorted keys, stable formatting). Byte-idempotent.
470pub fn canonicalize_descriptions(catalog: &Catalog, json: &str) -> Result<String> {
471    validated(catalog, parse_file(json)?)?;
472    let value: serde_json::Value = serde_json::from_str(json)
473        .map_err(|error| CatalogError::malformed(format!("descriptions data: {error}")))?;
474    pretty_json(&value)
475}
476
477/// Rebuild the canonical description form with a fresh content digest:
478/// validate against `catalog`, canonicalize, and (re)write the `digest`
479/// field. Byte-idempotent, mirroring [`super::build_canonical`].
480pub fn build_canonical_descriptions(catalog: &Catalog, json: &str) -> Result<String> {
481    let mut value: serde_json::Value = serde_json::from_str(json)
482        .map_err(|error| CatalogError::malformed(format!("descriptions data: {error}")))?;
483    let digest = content_digest(json)?;
484    if let Some(object) = value.as_object_mut() {
485        object.insert("digest".to_string(), serde_json::Value::String(digest));
486    }
487    let output = pretty_json(&value)?;
488    let file = parse_file(&output)?;
489    verify_digest(&output, &file)?;
490    validated(catalog, file)?;
491    Ok(output)
492}