Skip to main content

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}