1use 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
26pub const DESCRIPTIONS_DATA: &str = include_str!("data/descriptions.json");
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31#[non_exhaustive]
32pub struct Description {
33 pub text: String,
35 pub locale: Locale,
38 pub fallback: bool,
41}
42
43#[derive(Debug, Clone)]
46pub(crate) struct EntryDescriptions {
47 pub(crate) description: HashMap<Locale, String>,
48 pub(crate) params: Vec<Option<HashMap<Locale, String>>>,
51}
52
53#[derive(Debug, Clone, Default)]
55pub(crate) struct Descriptions {
56 pub(crate) version: String,
58 pub(crate) digest: Option<String>,
60 pub(crate) locales: Vec<Locale>,
62 pub(crate) pending: Vec<Locale>,
65 pub(crate) by_entry: HashMap<usize, EntryDescriptions>,
67}
68
69#[derive(Deserialize)]
70#[serde(rename_all = "camelCase")]
71struct DescriptionsFile {
72 schema_version: u32,
73 locales: Vec<String>,
75 #[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#[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 #[serde(default)]
108 description: HashMap<String, String>,
109 #[serde(default)]
113 params: Option<Vec<Option<HashMap<String, String>>>>,
114}
115
116#[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
167fn 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 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 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 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 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 pub fn load_descriptions_unverified(&mut self, json: &str) -> Result<()> {
389 self.descriptions = validated(self, parse_file(json)?)?;
390 Ok(())
391 }
392
393 pub fn descriptions_version(&self) -> &str {
396 &self.descriptions.version
397 }
398
399 pub fn descriptions_digest(&self) -> Option<&str> {
403 self.descriptions.digest.as_deref()
404 }
405
406 pub fn description_locales(&self) -> &[Locale] {
408 &self.descriptions.locales
409 }
410
411 pub fn pending_description_locales(&self) -> &[Locale] {
414 &self.descriptions.pending
415 }
416
417 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 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
468pub 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
477pub 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}