rete_core/card_input.rs
1//! The **curated** half of a Dataset Card, and the write-time rules that guard
2//! it — shared by every writer, native or wasm.
3//!
4//! A card has two halves. The *derived* profile (predicates, classes,
5//! vocabularies, the starter-query library) is computed from the data by
6//! `rete-cli` and lives there. The *curated* half is whatever a publisher
7//! hands the builder — and it is the half with rules: `theme` must be an IRI
8//! into a controlled vocabulary, the `extra` bag is bounded, and the top level
9//! is reserved for rete-defined fields.
10//!
11//! Those rules used to live only in the CLI, which meant a browser build could
12//! write a card the CLI would have refused. They live here so **one**
13//! implementation answers for both: `rete build --card-file` and the
14//! playground's in-browser builder reject the same documents with the same
15//! words. [`validate_curated_card`] is the whole-document entry point;
16//! [`normalize_string_list`] / [`normalize_themes`] / [`normalize_extra`] are
17//! the per-field gates the CLI calls directly after serde has typed the input.
18//!
19//! Errors are plain `String`s: the CLI wraps them in `anyhow`, the wasm build
20//! hands them to JavaScript, and neither needs a dependency on the other's
21//! error type.
22
23use serde_json::{Map, Value};
24
25/// Every field a card *file* may carry at its top level, in the order they are
26/// declared on the CLI's `CardInput` — which is also the order serde names
27/// them in its "expected one of" message, so [`validate_curated_card`] can
28/// reproduce that message verbatim.
29///
30/// The top level is **reserved**: a key not on this list is a loud error, not
31/// a silent drop. That is what makes the `extra` bag's collision guarantee
32/// structural rather than a naming promise — a publisher's own field can only
33/// ever live inside `extra`, so a future official field can never capture one.
34/// `rete-cli` pins this list against its own `deny_unknown_fields` struct by
35/// test, so the two can never drift.
36pub const CURATED_CARD_FIELDS: &[&str] = &[
37 "title",
38 "description",
39 "license",
40 "source",
41 "created",
42 "version",
43 "creators",
44 "publisher",
45 "canonical_url",
46 "sparql_endpoint",
47 "source_date",
48 "derived_from",
49 "doi",
50 "cite_as",
51 "keywords",
52 "theme",
53 "extra",
54 "example_queries",
55];
56
57/// The pointer appended to an unknown-top-level-key error. A stray key is
58/// usually a typo — or a custom field that belongs in the bag — and saying so
59/// costs one line and saves a round trip to the docs.
60pub const UNKNOWN_FIELD_HINT: &str =
61 "\n (the card file's top level is reserved for rete-defined fields; \
62 publisher-defined fields go inside the \"extra\" object — \
63 see docs/dataset-cards.md)";
64
65// --- Bounds for the publisher-defined `extra` bag, enforced by
66// [`normalize_extra`] at build time (readers never validate — a bag written
67// oversized by an external tool must not make the card unreadable). The
68// binding constraint is SERIALIZED BYTES: custom fields ride in the metadata
69// section, which every CARD-tier reader fetches on every open. ---
70
71/// Maximum serialized size (compact JSON bytes) of the whole `extra` object.
72///
73/// Sized against the published catalog: 8 KiB exceeds the smallest whole card
74/// (NKOD, 6,649 B stored) — generous for *metadata* — and the worst realistic
75/// case, the largest card (Hugging Face, 53,580 B) + a maxed bag + its ~1 KB
76/// build info ≈ 62.8 KB, still travels in the same single coalesced range
77/// (the 2-request CARD tier is about request *count*, which the bag cannot
78/// change). The one cost worth knowing: on the smallest cards a maxed bag can
79/// push the coalesced range past a conservative TCP initial window (~14.6 KB
80/// = 10 segments), i.e. one extra round trip, never an extra request.
81pub const CARD_EXTRA_MAX_BYTES: usize = 8192;
82/// Maximum number of keys in `extra`. A key costs ≥ 8 serialized bytes, so 64
83/// typical entries sit comfortably inside [`CARD_EXTRA_MAX_BYTES`]; needing
84/// more means the bag is being used as a data store — the graph itself is the
85/// place for data.
86pub const CARD_EXTRA_MAX_KEYS: usize = 64;
87/// Maximum bytes per `extra` key. Keys are identifiers, not values.
88pub const CARD_EXTRA_MAX_KEY_BYTES: usize = 128;
89/// Maximum size (UTF-8 bytes) of the curated `description`.
90///
91/// `description` is the one curated field invited to grow: it may be
92/// **Markdown** (headings, lists, links — see `docs/dataset-cards.md`), and
93/// prose with structure in it has no natural stopping point. It rides in the
94/// same metadata section as `extra`, which every CARD-tier reader fetches on
95/// every open, so it gets the same budget for the same reason —
96/// [`CARD_EXTRA_MAX_BYTES`]. Measured against the corpus: the longest
97/// description on the published catalog is miRBase's at 813 bytes, and the
98/// longest in this repo's own build scripts is ~1.1 KB, so 8 KiB is roughly
99/// ten times the widest real case and still ~1,300 words of Markdown.
100///
101/// Enforced at **write** time only, like every other card rule. Readers never
102/// validate: a card written oversized by some other tool must still open.
103pub const CARD_DESCRIPTION_MAX_BYTES: usize = CARD_EXTRA_MAX_BYTES;
104
105/// Maximum container-nesting depth inside an `extra` value (see
106/// [`json_depth`]): an object of objects-of-scalars, no deeper. Deliberate —
107/// deep structures invite storing *records* in the card, and Parquet
108/// companions exist for records; it also hard-bounds recursion for every
109/// parser that will ever read the bag (browser wasm and iOS stacks are far
110/// smaller than a build machine's).
111pub const CARD_EXTRA_MAX_DEPTH: usize = 2;
112
113/// Canonicalize a curated string list (`keywords`, `theme`) — the write-time
114/// gate every card-writing command funnels through (like [`normalize_extra`]).
115///
116/// Whitespace is trimmed and duplicates dropped; an entry that is empty after
117/// trimming **rejects the build loudly** (it is always an authoring slip — a
118/// stray comma in a hand-written list — and an empty `dcat:keyword` literal
119/// would be projected as agreed-upon nothing). Sorting is canonicalization,
120/// not editing: both fields project to unordered repeated RDF properties, so
121/// no information is lost — and it keeps the card's serialization (hence the
122/// reproducible content hash) independent of the order the entries were
123/// authored in.
124pub fn normalize_string_list(field: &str, values: Vec<String>) -> Result<Vec<String>, String> {
125 let mut out: Vec<String> = Vec::with_capacity(values.len());
126 for v in values {
127 let trimmed = v.trim();
128 if trimmed.is_empty() {
129 return Err(format!("card `{field}` entries must be non-empty"));
130 }
131 out.push(trimmed.to_string());
132 }
133 out.sort();
134 out.dedup();
135 Ok(out)
136}
137
138/// Accept a `description` written either way, and bound it — the write-time
139/// gate every card-writing path funnels through.
140///
141/// **Two input shapes, one stored value.** A description is now allowed to be
142/// Markdown, and Markdown needs line breaks, which a JSON string can only carry
143/// as `\n` escapes — miserable to hand-write and worse to review. So a card
144/// *file* may also give an **array of lines**, joined with `\n`:
145///
146/// ```json
147/// "description": ["# What's inside", "", "- 4.4M classes", "- 5.1M edges"]
148/// ```
149///
150/// The array is input sugar only: the card always stores one string, so a card
151/// round-trips through `rete card --json` → `--card-file` unchanged, and no
152/// reader ever has to know the array shape existed.
153///
154/// On overflow the build is **rejected loudly**, never truncated — the same
155/// rule as `extra`, for the same reason: the text is authored, it folds into
156/// the content hash, and a silent cut would ship a card that no longer says
157/// what the publisher wrote.
158pub fn normalize_description(v: &Value) -> Result<String, String> {
159 let text = match v {
160 Value::String(s) => s.trim().to_string(),
161 Value::Array(lines) => {
162 let mut out = Vec::with_capacity(lines.len());
163 for l in lines {
164 out.push(l.as_str().ok_or_else(|| {
165 "card `description` must be a string, or an array of strings \
166 (one per line, joined with newlines)"
167 .to_string()
168 })?);
169 }
170 out.join("\n").trim().to_string()
171 }
172 _ => {
173 return Err(
174 "card `description` must be a string, or an array of strings \
175 (one per line, joined with newlines)"
176 .to_string(),
177 )
178 }
179 };
180 check_description_len(&text)?;
181 Ok(text)
182}
183
184/// The size half of [`normalize_description`], split out so the CLI's
185/// serde-typed path — where `--description` may override whatever the file
186/// said — can apply it to the final text without re-parsing anything.
187pub fn check_description_len(text: &str) -> Result<(), String> {
188 if text.len() > CARD_DESCRIPTION_MAX_BYTES {
189 return Err(format!(
190 "card `description` is {} bytes, over the {CARD_DESCRIPTION_MAX_BYTES}-byte cap; \
191 every reader fetches the card on every open — a description is the dataset's \
192 abstract, not its documentation, so link out to the long form \
193 (`source` / `canonical_url`) or put it in the graph",
194 text.len()
195 ));
196 }
197 Ok(())
198}
199
200/// [`normalize_string_list`] plus the `theme` requirement: every entry must be
201/// an **IRI into a controlled vocabulary**. This is what keeps `theme` from
202/// becoming a second keywords field — a free-text theme carries no more
203/// meaning than a keyword, and `keywords` already holds those; the agreed
204/// concept scheme behind the IRI is the whole value `dcat:theme` adds.
205pub fn normalize_themes(themes: Vec<String>) -> Result<Vec<String>, String> {
206 let themes = normalize_string_list("theme", themes)?;
207 for t in &themes {
208 if !(t.starts_with("http://") || t.starts_with("https://")) {
209 return Err(format!(
210 "card `theme` entry {t:?} is not an IRI: a theme is an IRI into a \
211 controlled vocabulary (e.g. the EU data-theme authority, \
212 http://publications.europa.eu/resource/authority/data-theme/…); \
213 free-text subjects belong in `keywords`"
214 ));
215 }
216 }
217 Ok(themes)
218}
219
220/// Canonicalize and bounds-check the `extra` bag — the write-time gate.
221///
222/// On overflow the build is **rejected loudly** rather than truncated quietly:
223/// `extra` is authored (unlike the derived lists, which are capped with
224/// `truncated` set, because they can be re-derived), so cutting it would
225/// silently ship a card that no longer says what the publisher wrote — and,
226/// since the bag folds into the content hash, "what I wrote" and "what hashed"
227/// would diverge invisibly. Only the author can decide what to trim.
228pub fn normalize_extra(extra: Map<String, Value>) -> Result<Map<String, Value>, String> {
229 if extra.is_empty() {
230 return Ok(extra);
231 }
232 if extra.len() > CARD_EXTRA_MAX_KEYS {
233 return Err(format!(
234 "card `extra` has {} keys, over the {CARD_EXTRA_MAX_KEYS}-key cap",
235 extra.len()
236 ));
237 }
238 let extra: Map<String, Value> = extra
239 .into_iter()
240 .map(|(k, v)| (k, canonicalize_json(v)))
241 .collect();
242 for (k, v) in &extra {
243 if k.is_empty() {
244 return Err("card `extra` keys must be non-empty".to_string());
245 }
246 if k == "@context" {
247 // Reserved TODAY so it can mean something LATER: a future release
248 // may honour an author-supplied JSON-LD mapping here (turning the
249 // bag's projection from opaque values into the author's own
250 // vocabulary). Rejecting it now keeps that door open without ever
251 // breaking a published card.
252 return Err("card `extra` key \"@context\" is reserved for a future \
253 author-supplied JSON-LD mapping"
254 .to_string());
255 }
256 if k.len() > CARD_EXTRA_MAX_KEY_BYTES {
257 return Err(format!(
258 "card `extra` key {k:?} is {} bytes, over the {CARD_EXTRA_MAX_KEY_BYTES}-byte cap",
259 k.len()
260 ));
261 }
262 let depth = json_depth(v);
263 if depth > CARD_EXTRA_MAX_DEPTH {
264 return Err(format!(
265 "card `extra` field {k:?} nests {depth} container levels deep, \
266 over the {CARD_EXTRA_MAX_DEPTH}-level cap"
267 ));
268 }
269 }
270 let bytes = serde_json::to_vec(&extra)
271 .map_err(|e| format!("card `extra` does not serialize: {e}"))?
272 .len();
273 if bytes > CARD_EXTRA_MAX_BYTES {
274 return Err(format!(
275 "card `extra` serializes to {bytes} bytes, over the {CARD_EXTRA_MAX_BYTES}-byte cap; \
276 every reader fetches the card on every open — trim the bag, \
277 or put bulk data in the graph itself"
278 ));
279 }
280 Ok(extra)
281}
282
283/// Container-nesting depth of a JSON value: scalars are 0, an array/object is
284/// 1 + its deepest child. (Recursion here is bounded by serde_json's own
285/// 128-level parse limit.)
286pub fn json_depth(v: &Value) -> usize {
287 match v {
288 Value::Array(a) => 1 + a.iter().map(json_depth).max().unwrap_or(0),
289 Value::Object(o) => 1 + o.values().map(json_depth).max().unwrap_or(0),
290 _ => 0,
291 }
292}
293
294/// Rebuild every nested object with its keys inserted in sorted order, so the
295/// bag's bytes — which fold into the reproducible content hash — never depend
296/// on author key order. With today's serde_json (no `preserve_order`) maps are
297/// BTree-backed and this is a no-op in effect; it is insurance that a future
298/// feature unification can't quietly make map order mean insertion order.
299pub fn canonicalize_json(v: Value) -> Value {
300 match v {
301 Value::Array(a) => Value::Array(a.into_iter().map(canonicalize_json).collect()),
302 Value::Object(o) => {
303 let mut sorted: Vec<(String, Value)> = o.into_iter().collect();
304 sorted.sort_by(|a, b| a.0.cmp(&b.0));
305 let mut out = Map::new();
306 for (k, v) in sorted {
307 out.insert(k, canonicalize_json(v));
308 }
309 Value::Object(out)
310 }
311 scalar => scalar,
312 }
313}
314
315/// Validate and canonicalize a **whole curated card document** — the JSON a
316/// publisher writes for `--card-file`, or types into the playground's builder.
317///
318/// This is the schema-free twin of the CLI's serde-typed path: same reserved
319/// top level, same field types, same `keywords`/`theme`/`extra` rules, same
320/// wording. Returns the canonicalized document (lists sorted and deduplicated,
321/// bag keys sorted) ready to merge with the derived counts.
322///
323/// It deliberately does **not** invent validation the CLI does not do: DOIs,
324/// URLs, ORCIDs, RORs and dates travel as the publisher wrote them, because
325/// the CLI accepts them that way and a client that rejected more would build
326/// files the CLI could not reproduce — the exact drift this module exists to
327/// prevent.
328pub fn validate_curated_card(doc: &Value) -> Result<Value, String> {
329 let obj = doc
330 .as_object()
331 .ok_or_else(|| "a card document must be a JSON object".to_string())?;
332
333 for k in obj.keys() {
334 if !CURATED_CARD_FIELDS.contains(&k.as_str()) {
335 let expected = CURATED_CARD_FIELDS
336 .iter()
337 .map(|f| format!("`{f}`"))
338 .collect::<Vec<_>>()
339 .join(", ");
340 return Err(format!(
341 "unknown field `{k}`, expected one of {expected}{UNKNOWN_FIELD_HINT}"
342 ));
343 }
344 }
345
346 let mut out = Map::new();
347 for (k, v) in obj {
348 if v.is_null() {
349 continue; // an explicit null is "unset", not a value
350 }
351 let normalized = match k.as_str() {
352 // The one field that may also arrive as an array of lines, and the
353 // one with a size cap — it is the field Markdown made open-ended.
354 "description" => Value::String(normalize_description(v)?),
355 "title" | "license" | "source" | "created" | "version" | "canonical_url"
356 | "sparql_endpoint" | "source_date" | "doi" | "cite_as" => {
357 Value::String(expect_string(k, v)?)
358 }
359 "derived_from" | "example_queries" => Value::from(expect_string_array(k, v)?),
360 "keywords" => Value::from(normalize_string_list(
361 "keywords",
362 expect_string_array(k, v)?,
363 )?),
364 "theme" => Value::from(normalize_themes(expect_string_array(k, v)?)?),
365 "creators" => {
366 let arr = v.as_array().ok_or_else(|| {
367 format!("card `{k}` must be an array of {{name, orcid}} objects")
368 })?;
369 let mut people = Vec::with_capacity(arr.len());
370 for c in arr {
371 people.push(named_agent("creators", c, "orcid")?);
372 }
373 Value::Array(people)
374 }
375 "publisher" => named_agent("publisher", v, "ror")?,
376 "extra" => {
377 let bag = v
378 .as_object()
379 .ok_or_else(|| "card `extra` must be a JSON object".to_string())?;
380 Value::Object(normalize_extra(bag.clone())?)
381 }
382 _ => unreachable!("field list and match arms are kept in step"),
383 };
384 // Drop what serializes away anyway, so an empty list authored by hand
385 // reads the same as one never written — absence, not an empty shell.
386 let empty = match &normalized {
387 Value::String(s) => s.is_empty(),
388 Value::Array(a) => a.is_empty(),
389 Value::Object(o) => o.is_empty(),
390 _ => false,
391 };
392 if !empty {
393 out.insert(k.clone(), normalized);
394 }
395 }
396 Ok(Value::Object(out))
397}
398
399/// Finish a validated curated document into a **complete card**, by adding the
400/// four counts the build just measured and the format version the writer
401/// stamps.
402///
403/// This is the whole card a writer without the CLI's derivation can honestly
404/// produce. The counts are not optional in the card schema — a document
405/// missing them is not a card any reader can deserialize — so they are added
406/// here rather than left to each caller to remember. Everything else the CLI
407/// derives (predicates, classes, vocabularies, signals, the starter-query
408/// library) is simply **absent**, which is what a reader should see: no key,
409/// rather than an empty list that reads like a measured zero.
410pub fn compose_curated_card(
411 curated: Value,
412 triple_count: u64,
413 quad_count: u64,
414 named_graph_count: u64,
415 term_count: u64,
416 format_version: u8,
417) -> Value {
418 let mut card = curated;
419 let obj = match card.as_object_mut() {
420 Some(o) => o,
421 None => return card,
422 };
423 obj.insert("triple_count".into(), triple_count.into());
424 obj.insert("quad_count".into(), quad_count.into());
425 obj.insert("named_graph_count".into(), named_graph_count.into());
426 obj.insert("term_count".into(), term_count.into());
427 obj.insert("format_version".into(), format_version.into());
428 card
429}
430
431fn expect_string(field: &str, v: &Value) -> Result<String, String> {
432 v.as_str()
433 .map(|s| s.trim().to_string())
434 .ok_or_else(|| format!("card `{field}` must be a string"))
435}
436
437fn expect_string_array(field: &str, v: &Value) -> Result<Vec<String>, String> {
438 let arr = v
439 .as_array()
440 .ok_or_else(|| format!("card `{field}` must be an array of strings"))?;
441 arr.iter()
442 .map(|e| {
443 e.as_str()
444 .map(|s| s.to_string())
445 .ok_or_else(|| format!("card `{field}` must be an array of strings"))
446 })
447 .collect()
448}
449
450/// A `{name, <id>}` object — `creators` entries (`orcid`) and `publisher`
451/// (`ror`). `name` is required, matching the CLI's non-optional field; the
452/// identifier is optional and unvalidated, as there.
453fn named_agent(field: &str, v: &Value, id_key: &str) -> Result<Value, String> {
454 let o = v
455 .as_object()
456 .ok_or_else(|| format!("card `{field}` entries must be objects with a `name`"))?;
457 for k in o.keys() {
458 if k != "name" && k != id_key {
459 return Err(format!(
460 "unknown field `{k}` in card `{field}`, expected one of `name`, `{id_key}`"
461 ));
462 }
463 }
464 let name = o
465 .get("name")
466 .and_then(|n| n.as_str())
467 .ok_or_else(|| format!("card `{field}` entries need a `name`"))?;
468 let mut out = Map::new();
469 out.insert("name".into(), Value::String(name.trim().to_string()));
470 if let Some(id) = o.get(id_key).and_then(|i| i.as_str()) {
471 if !id.trim().is_empty() {
472 out.insert(id_key.into(), Value::String(id.trim().to_string()));
473 }
474 }
475 Ok(Value::Object(out))
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use serde_json::json;
482
483 #[test]
484 fn unknown_top_level_key_is_rejected_with_the_bag_pointer() {
485 let e = validate_curated_card(&json!({"title": "t", "region": "CH"})).unwrap_err();
486 assert!(e.contains("unknown field `region`"), "{e}");
487 assert!(e.contains("\"extra\""), "{e}");
488 // The "expected one of" list must name the real fields, so a reader can
489 // fix a typo without opening the docs.
490 assert!(e.contains("`canonical_url`"), "{e}");
491 }
492
493 #[test]
494 fn theme_must_be_an_iri_and_points_at_keywords() {
495 let e = validate_curated_card(&json!({"theme": ["physics"]})).unwrap_err();
496 assert!(e.contains("not an IRI"), "{e}");
497 assert!(e.contains("keywords"), "{e}");
498 let ok = validate_curated_card(&json!({"theme": ["https://www.wikidata.org/entity/Q413"]}))
499 .unwrap();
500 assert_eq!(ok["theme"][0], "https://www.wikidata.org/entity/Q413");
501 }
502
503 #[test]
504 fn description_takes_a_string_or_an_array_of_lines() {
505 // The two shapes must produce the SAME stored string, because the card
506 // stores one string either way — the array is authoring sugar, not a
507 // second representation a reader would have to know about.
508 let md = "# What's inside\n\n- 4.4M classes\n- 5.1M edges";
509 let from_string = validate_curated_card(&json!({ "description": md })).unwrap();
510 let from_lines = validate_curated_card(&json!({
511 "description": ["# What's inside", "", "- 4.4M classes", "- 5.1M edges"]
512 }))
513 .unwrap();
514 assert_eq!(from_string["description"], json!(md));
515 assert_eq!(from_lines["description"], json!(md));
516
517 // A non-string entry is an authoring slip, not a value to stringify.
518 let e = validate_curated_card(&json!({"description": ["ok", 7]})).unwrap_err();
519 assert!(e.contains("array of strings"), "{e}");
520 let e = validate_curated_card(&json!({"description": 7})).unwrap_err();
521 assert!(e.contains("array of strings"), "{e}");
522 }
523
524 #[test]
525 fn description_markdown_survives_validation_untouched() {
526 // Validation must not "helpfully" rewrite prose: what the publisher
527 // wrote is what hashes, so every marker has to come out the far side.
528 let md = "## Contents\n\n1. one\n2. two\n\n> quoted\n\n`code` and [a link](https://example.org/x)";
529 let ok = validate_curated_card(&json!({ "description": md })).unwrap();
530 assert_eq!(ok["description"], json!(md));
531 }
532
533 #[test]
534 fn description_over_the_cap_is_rejected_rather_than_truncated() {
535 let at = "x".repeat(CARD_DESCRIPTION_MAX_BYTES);
536 validate_curated_card(&json!({ "description": at })).expect("exactly at the cap");
537 let over = "x".repeat(CARD_DESCRIPTION_MAX_BYTES + 1);
538 let e = validate_curated_card(&json!({ "description": over })).unwrap_err();
539 assert!(
540 e.contains(&format!("{} bytes", CARD_DESCRIPTION_MAX_BYTES + 1)),
541 "{e}"
542 );
543 // The message has to say WHY, or the only fix a publisher can imagine
544 // is "make the text shorter" rather than "link out to the long form".
545 assert!(
546 e.contains("every reader fetches the card on every open"),
547 "{e}"
548 );
549 // The array shape is bounded on the JOINED text, not per line.
550 let lines: Vec<String> = (0..200).map(|_| "y".repeat(100)).collect();
551 let e = validate_curated_card(&json!({ "description": lines })).unwrap_err();
552 assert!(e.contains("over the"), "{e}");
553 }
554
555 #[test]
556 fn keywords_and_theme_are_sorted_and_deduplicated() {
557 let ok = validate_curated_card(&json!({"keywords": ["zeta", "alpha", "zeta"]})).unwrap();
558 assert_eq!(ok["keywords"], json!(["alpha", "zeta"]));
559 }
560
561 #[test]
562 fn extra_limits_are_enforced_and_nested_keys_sorted() {
563 // Depth 2 is allowed, depth 3 is not.
564 let ok = validate_curated_card(&json!({"extra": {"review": {"z": 1, "a": 2}}})).unwrap();
565 assert_eq!(
566 serde_json::to_string(&ok["extra"]).unwrap(),
567 r#"{"review":{"a":2,"z":1}}"#
568 );
569 let e =
570 validate_curated_card(&json!({"extra": {"deep": {"a": {"b": {"c": 1}}}}})).unwrap_err();
571 assert!(e.contains("over the 2-level cap"), "{e}");
572
573 let e = validate_curated_card(&json!({"extra": {"@context": "x"}})).unwrap_err();
574 assert!(e.contains("reserved"), "{e}");
575
576 let big = "x".repeat(CARD_EXTRA_MAX_BYTES);
577 let e = validate_curated_card(&json!({"extra": {"blob": big}})).unwrap_err();
578 assert!(e.contains("over the 8192-byte cap"), "{e}");
579 }
580
581 #[test]
582 fn creators_and_publisher_keep_their_identifiers() {
583 let ok = validate_curated_card(&json!({
584 "creators": [{"name": " Ada ", "orcid": "https://orcid.org/0000-0002-1825-0097"}],
585 "publisher": {"name": "CERN", "ror": "https://ror.org/01ggx4157"},
586 }))
587 .unwrap();
588 assert_eq!(ok["creators"][0]["name"], "Ada");
589 assert_eq!(
590 ok["creators"][0]["orcid"],
591 "https://orcid.org/0000-0002-1825-0097"
592 );
593 assert_eq!(ok["publisher"]["ror"], "https://ror.org/01ggx4157");
594
595 let e = validate_curated_card(&json!({"creators": [{"orcid": "x"}]})).unwrap_err();
596 assert!(e.contains("need a `name`"), "{e}");
597 let e =
598 validate_curated_card(&json!({"publisher": {"name": "X", "isni": "y"}})).unwrap_err();
599 assert!(e.contains("unknown field `isni`"), "{e}");
600 }
601
602 #[test]
603 fn empty_and_null_curated_values_are_absent_not_empty() {
604 let ok = validate_curated_card(&json!({
605 "title": "t", "description": "", "keywords": [], "doi": null, "extra": {}
606 }))
607 .unwrap();
608 let o = ok.as_object().unwrap();
609 assert!(o.contains_key("title"));
610 for absent in ["description", "keywords", "doi", "extra"] {
611 assert!(
612 !o.contains_key(absent),
613 "{absent} should be absent, not empty"
614 );
615 }
616 }
617}