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`]): three levels — a list of objects whose fields may be
107/// lists of scalars (`[{"query": "…", "words": ["a", "b"]}]`), no deeper.
108///
109/// It was 2 until 0.4.0, and that refused the first natural shape a real card
110/// needed: the OpenCitations full-text examples, a list of objects each
111/// holding a word list, had to be flattened into joined strings. Raising it to
112/// 3 is safe on every axis the cap exists for:
113///
114/// * **size** is bounded by [`CARD_EXTRA_MAX_BYTES`] and [`CARD_EXTRA_MAX_KEYS`],
115///   unchanged — depth never was the binding constraint on how much a card
116///   carries, bytes are;
117/// * **records** still do not fit: three levels holds a list of small
118///   structured examples, not a table, and 8 KiB holds no table worth the name
119///   (Parquet companions exist for records);
120/// * **recursion** stays hard-bounded at a depth no parser notices (browser
121///   wasm and iOS stacks are far smaller than a build machine's, and far larger
122///   than three);
123/// * **compatibility**: the cap is enforced when a card is *written*, never
124///   when it is read, so every reader already accepts a depth-3 bag.
125pub const CARD_EXTRA_MAX_DEPTH: usize = 3;
126
127/// Canonicalize a curated string list (`keywords`, `theme`) — the write-time
128/// gate every card-writing command funnels through (like [`normalize_extra`]).
129///
130/// Whitespace is trimmed and duplicates dropped; an entry that is empty after
131/// trimming **rejects the build loudly** (it is always an authoring slip — a
132/// stray comma in a hand-written list — and an empty `dcat:keyword` literal
133/// would be projected as agreed-upon nothing). Sorting is canonicalization,
134/// not editing: both fields project to unordered repeated RDF properties, so
135/// no information is lost — and it keeps the card's serialization (hence the
136/// reproducible content hash) independent of the order the entries were
137/// authored in.
138pub fn normalize_string_list(field: &str, values: Vec<String>) -> Result<Vec<String>, String> {
139    let mut out: Vec<String> = Vec::with_capacity(values.len());
140    for v in values {
141        let trimmed = v.trim();
142        if trimmed.is_empty() {
143            return Err(format!("card `{field}` entries must be non-empty"));
144        }
145        out.push(trimmed.to_string());
146    }
147    out.sort();
148    out.dedup();
149    Ok(out)
150}
151
152/// Accept a `description` written either way, and bound it — the write-time
153/// gate every card-writing path funnels through.
154///
155/// **Two input shapes, one stored value.** A description is now allowed to be
156/// Markdown, and Markdown needs line breaks, which a JSON string can only carry
157/// as `\n` escapes — miserable to hand-write and worse to review. So a card
158/// *file* may also give an **array of lines**, joined with `\n`:
159///
160/// ```json
161/// "description": ["# What's inside", "", "- 4.4M classes", "- 5.1M edges"]
162/// ```
163///
164/// The array is input sugar only: the card always stores one string, so a card
165/// round-trips through `rete card --json` → `--card-file` unchanged, and no
166/// reader ever has to know the array shape existed.
167///
168/// On overflow the build is **rejected loudly**, never truncated — the same
169/// rule as `extra`, for the same reason: the text is authored, it folds into
170/// the content hash, and a silent cut would ship a card that no longer says
171/// what the publisher wrote.
172pub fn normalize_description(v: &Value) -> Result<String, String> {
173    let text = match v {
174        Value::String(s) => s.trim().to_string(),
175        Value::Array(lines) => {
176            let mut out = Vec::with_capacity(lines.len());
177            for l in lines {
178                out.push(l.as_str().ok_or_else(|| {
179                    "card `description` must be a string, or an array of strings \
180                     (one per line, joined with newlines)"
181                        .to_string()
182                })?);
183            }
184            out.join("\n").trim().to_string()
185        }
186        _ => {
187            return Err(
188                "card `description` must be a string, or an array of strings \
189                 (one per line, joined with newlines)"
190                    .to_string(),
191            )
192        }
193    };
194    check_description_len(&text)?;
195    Ok(text)
196}
197
198/// The size half of [`normalize_description`], split out so the CLI's
199/// serde-typed path — where `--description` may override whatever the file
200/// said — can apply it to the final text without re-parsing anything.
201pub fn check_description_len(text: &str) -> Result<(), String> {
202    if text.len() > CARD_DESCRIPTION_MAX_BYTES {
203        return Err(format!(
204            "card `description` is {} bytes, over the {CARD_DESCRIPTION_MAX_BYTES}-byte cap; \
205             every reader fetches the card on every open — a description is the dataset's \
206             abstract, not its documentation, so link out to the long form \
207             (`source` / `canonical_url`) or put it in the graph",
208            text.len()
209        ));
210    }
211    Ok(())
212}
213
214/// [`normalize_string_list`] plus the `theme` requirement: every entry must be
215/// an **IRI into a controlled vocabulary**. This is what keeps `theme` from
216/// becoming a second keywords field — a free-text theme carries no more
217/// meaning than a keyword, and `keywords` already holds those; the agreed
218/// concept scheme behind the IRI is the whole value `dcat:theme` adds.
219pub fn normalize_themes(themes: Vec<String>) -> Result<Vec<String>, String> {
220    let themes = normalize_string_list("theme", themes)?;
221    for t in &themes {
222        if !(t.starts_with("http://") || t.starts_with("https://")) {
223            return Err(format!(
224                "card `theme` entry {t:?} is not an IRI: a theme is an IRI into a \
225                 controlled vocabulary (e.g. the EU data-theme authority, \
226                 http://publications.europa.eu/resource/authority/data-theme/…); \
227                 free-text subjects belong in `keywords`"
228            ));
229        }
230    }
231    Ok(themes)
232}
233
234/// Canonicalize and bounds-check the `extra` bag — the write-time gate.
235///
236/// On overflow the build is **rejected loudly** rather than truncated quietly:
237/// `extra` is authored (unlike the derived lists, which are capped with
238/// `truncated` set, because they can be re-derived), so cutting it would
239/// silently ship a card that no longer says what the publisher wrote — and,
240/// since the bag folds into the content hash, "what I wrote" and "what hashed"
241/// would diverge invisibly. Only the author can decide what to trim.
242pub fn normalize_extra(extra: Map<String, Value>) -> Result<Map<String, Value>, String> {
243    if extra.is_empty() {
244        return Ok(extra);
245    }
246    if extra.len() > CARD_EXTRA_MAX_KEYS {
247        return Err(format!(
248            "card `extra` has {} keys, over the {CARD_EXTRA_MAX_KEYS}-key cap",
249            extra.len()
250        ));
251    }
252    let extra: Map<String, Value> = extra
253        .into_iter()
254        .map(|(k, v)| (k, canonicalize_json(v)))
255        .collect();
256    for (k, v) in &extra {
257        if k.is_empty() {
258            return Err("card `extra` keys must be non-empty".to_string());
259        }
260        if k == "@context" {
261            // Reserved TODAY so it can mean something LATER: a future release
262            // may honour an author-supplied JSON-LD mapping here (turning the
263            // bag's projection from opaque values into the author's own
264            // vocabulary). Rejecting it now keeps that door open without ever
265            // breaking a published card.
266            return Err("card `extra` key \"@context\" is reserved for a future \
267                 author-supplied JSON-LD mapping"
268                .to_string());
269        }
270        if k.len() > CARD_EXTRA_MAX_KEY_BYTES {
271            return Err(format!(
272                "card `extra` key {k:?} is {} bytes, over the {CARD_EXTRA_MAX_KEY_BYTES}-byte cap",
273                k.len()
274            ));
275        }
276        let depth = json_depth(v);
277        if depth > CARD_EXTRA_MAX_DEPTH {
278            return Err(format!(
279                "card `extra` field {k:?} nests {depth} container levels deep (at {}), \
280                 over the {CARD_EXTRA_MAX_DEPTH}-level cap — an array or an object is one \
281                 level, so `[{{\"words\": [\"a\"]}}]` is three; flatten the innermost \
282                 level (join a list into one string), or put records in the graph itself",
283                deepest_path(k, v)
284            ));
285        }
286    }
287    let bytes = serde_json::to_vec(&extra)
288        .map_err(|e| format!("card `extra` does not serialize: {e}"))?
289        .len();
290    if bytes > CARD_EXTRA_MAX_BYTES {
291        return Err(format!(
292            "card `extra` serializes to {bytes} bytes, over the {CARD_EXTRA_MAX_BYTES}-byte cap; \
293             every reader fetches the card on every open — trim the bag, \
294             or put bulk data in the graph itself"
295        ));
296    }
297    Ok(extra)
298}
299
300/// A JSON-path-like spelling of the deepest point inside `v`
301/// (`examples[0].words`) — where an over-deep bag needs flattening.
302fn deepest_path(key: &str, v: &Value) -> String {
303    let mut path = key.to_string();
304    let mut cur = v;
305    while json_depth(cur) > 1 {
306        let next = match cur {
307            Value::Array(a) => a
308                .iter()
309                .enumerate()
310                .max_by_key(|(i, c)| (json_depth(c), std::cmp::Reverse(*i)))
311                .map(|(i, c)| (format!("[{i}]"), c)),
312            Value::Object(o) => o
313                .iter()
314                .max_by_key(|(_, c)| json_depth(c))
315                .map(|(k, c)| (format!(".{k}"), c)),
316            _ => None,
317        };
318        let Some((step, child)) = next else {
319            break;
320        };
321        path.push_str(&step);
322        cur = child;
323    }
324    path
325}
326
327/// Container-nesting depth of a JSON value: scalars are 0, an array/object is
328/// 1 + its deepest child. (Recursion here is bounded by serde_json's own
329/// 128-level parse limit.)
330pub fn json_depth(v: &Value) -> usize {
331    match v {
332        Value::Array(a) => 1 + a.iter().map(json_depth).max().unwrap_or(0),
333        Value::Object(o) => 1 + o.values().map(json_depth).max().unwrap_or(0),
334        _ => 0,
335    }
336}
337
338/// Rebuild every nested object with its keys inserted in sorted order, so the
339/// bag's bytes — which fold into the reproducible content hash — never depend
340/// on author key order. With today's serde_json (no `preserve_order`) maps are
341/// BTree-backed and this is a no-op in effect; it is insurance that a future
342/// feature unification can't quietly make map order mean insertion order.
343pub fn canonicalize_json(v: Value) -> Value {
344    match v {
345        Value::Array(a) => Value::Array(a.into_iter().map(canonicalize_json).collect()),
346        Value::Object(o) => {
347            let mut sorted: Vec<(String, Value)> = o.into_iter().collect();
348            sorted.sort_by(|a, b| a.0.cmp(&b.0));
349            let mut out = Map::new();
350            for (k, v) in sorted {
351                out.insert(k, canonicalize_json(v));
352            }
353            Value::Object(out)
354        }
355        scalar => scalar,
356    }
357}
358
359/// Validate and canonicalize a **whole curated card document** — the JSON a
360/// publisher writes for `--card-file`, or types into the playground's builder.
361///
362/// This is the schema-free twin of the CLI's serde-typed path: same reserved
363/// top level, same field types, same `keywords`/`theme`/`extra` rules, same
364/// wording. Returns the canonicalized document (lists sorted and deduplicated,
365/// bag keys sorted) ready to merge with the derived counts.
366///
367/// It deliberately does **not** invent validation the CLI does not do: DOIs,
368/// URLs, ORCIDs, RORs and dates travel as the publisher wrote them, because
369/// the CLI accepts them that way and a client that rejected more would build
370/// files the CLI could not reproduce — the exact drift this module exists to
371/// prevent.
372pub fn validate_curated_card(doc: &Value) -> Result<Value, String> {
373    let obj = doc
374        .as_object()
375        .ok_or_else(|| "a card document must be a JSON object".to_string())?;
376
377    for k in obj.keys() {
378        if !CURATED_CARD_FIELDS.contains(&k.as_str()) {
379            let expected = CURATED_CARD_FIELDS
380                .iter()
381                .map(|f| format!("`{f}`"))
382                .collect::<Vec<_>>()
383                .join(", ");
384            return Err(format!(
385                "unknown field `{k}`, expected one of {expected}{UNKNOWN_FIELD_HINT}"
386            ));
387        }
388    }
389
390    let mut out = Map::new();
391    for (k, v) in obj {
392        if v.is_null() {
393            continue; // an explicit null is "unset", not a value
394        }
395        let normalized = match k.as_str() {
396            // The one field that may also arrive as an array of lines, and the
397            // one with a size cap — it is the field Markdown made open-ended.
398            "description" => Value::String(normalize_description(v)?),
399            "title" | "license" | "source" | "created" | "version" | "canonical_url"
400            | "sparql_endpoint" | "source_date" | "doi" | "cite_as" => {
401                Value::String(expect_string(k, v)?)
402            }
403            "derived_from" | "example_queries" => Value::from(expect_string_array(k, v)?),
404            "keywords" => Value::from(normalize_string_list(
405                "keywords",
406                expect_string_array(k, v)?,
407            )?),
408            "theme" => Value::from(normalize_themes(expect_string_array(k, v)?)?),
409            "creators" => {
410                let arr = v.as_array().ok_or_else(|| {
411                    format!("card `{k}` must be an array of {{name, orcid}} objects")
412                })?;
413                let mut people = Vec::with_capacity(arr.len());
414                for c in arr {
415                    people.push(named_agent("creators", c, "orcid")?);
416                }
417                Value::Array(people)
418            }
419            "publisher" => named_agent("publisher", v, "ror")?,
420            "extra" => {
421                let bag = v
422                    .as_object()
423                    .ok_or_else(|| "card `extra` must be a JSON object".to_string())?;
424                Value::Object(normalize_extra(bag.clone())?)
425            }
426            _ => unreachable!("field list and match arms are kept in step"),
427        };
428        // Drop what serializes away anyway, so an empty list authored by hand
429        // reads the same as one never written — absence, not an empty shell.
430        let empty = match &normalized {
431            Value::String(s) => s.is_empty(),
432            Value::Array(a) => a.is_empty(),
433            Value::Object(o) => o.is_empty(),
434            _ => false,
435        };
436        if !empty {
437            out.insert(k.clone(), normalized);
438        }
439    }
440    Ok(Value::Object(out))
441}
442
443/// Finish a validated curated document into a **complete card**, by adding the
444/// four counts the build just measured and the format version the writer
445/// stamps.
446///
447/// This is the whole card a writer without the CLI's derivation can honestly
448/// produce. The counts are not optional in the card schema — a document
449/// missing them is not a card any reader can deserialize — so they are added
450/// here rather than left to each caller to remember. Everything else the CLI
451/// derives (predicates, classes, vocabularies, signals, the starter-query
452/// library) is simply **absent**, which is what a reader should see: no key,
453/// rather than an empty list that reads like a measured zero.
454pub fn compose_curated_card(
455    curated: Value,
456    triple_count: u64,
457    quad_count: u64,
458    named_graph_count: u64,
459    term_count: u64,
460    format_version: u8,
461) -> Value {
462    let mut card = curated;
463    let obj = match card.as_object_mut() {
464        Some(o) => o,
465        None => return card,
466    };
467    obj.insert("triple_count".into(), triple_count.into());
468    obj.insert("quad_count".into(), quad_count.into());
469    obj.insert("named_graph_count".into(), named_graph_count.into());
470    obj.insert("term_count".into(), term_count.into());
471    obj.insert("format_version".into(), format_version.into());
472    card
473}
474
475fn expect_string(field: &str, v: &Value) -> Result<String, String> {
476    v.as_str()
477        .map(|s| s.trim().to_string())
478        .ok_or_else(|| format!("card `{field}` must be a string"))
479}
480
481fn expect_string_array(field: &str, v: &Value) -> Result<Vec<String>, String> {
482    let arr = v
483        .as_array()
484        .ok_or_else(|| format!("card `{field}` must be an array of strings"))?;
485    arr.iter()
486        .map(|e| {
487            e.as_str()
488                .map(|s| s.to_string())
489                .ok_or_else(|| format!("card `{field}` must be an array of strings"))
490        })
491        .collect()
492}
493
494/// A `{name, <id>}` object — `creators` entries (`orcid`) and `publisher`
495/// (`ror`). `name` is required, matching the CLI's non-optional field; the
496/// identifier is optional and unvalidated, as there.
497fn named_agent(field: &str, v: &Value, id_key: &str) -> Result<Value, String> {
498    let o = v
499        .as_object()
500        .ok_or_else(|| format!("card `{field}` entries must be objects with a `name`"))?;
501    for k in o.keys() {
502        if k != "name" && k != id_key {
503            return Err(format!(
504                "unknown field `{k}` in card `{field}`, expected one of `name`, `{id_key}`"
505            ));
506        }
507    }
508    let name = o
509        .get("name")
510        .and_then(|n| n.as_str())
511        .ok_or_else(|| format!("card `{field}` entries need a `name`"))?;
512    let mut out = Map::new();
513    out.insert("name".into(), Value::String(name.trim().to_string()));
514    if let Some(id) = o.get(id_key).and_then(|i| i.as_str()) {
515        if !id.trim().is_empty() {
516            out.insert(id_key.into(), Value::String(id.trim().to_string()));
517        }
518    }
519    Ok(Value::Object(out))
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use serde_json::json;
526
527    #[test]
528    fn unknown_top_level_key_is_rejected_with_the_bag_pointer() {
529        let e = validate_curated_card(&json!({"title": "t", "region": "CH"})).unwrap_err();
530        assert!(e.contains("unknown field `region`"), "{e}");
531        assert!(e.contains("\"extra\""), "{e}");
532        // The "expected one of" list must name the real fields, so a reader can
533        // fix a typo without opening the docs.
534        assert!(e.contains("`canonical_url`"), "{e}");
535    }
536
537    #[test]
538    fn theme_must_be_an_iri_and_points_at_keywords() {
539        let e = validate_curated_card(&json!({"theme": ["physics"]})).unwrap_err();
540        assert!(e.contains("not an IRI"), "{e}");
541        assert!(e.contains("keywords"), "{e}");
542        let ok = validate_curated_card(&json!({"theme": ["https://www.wikidata.org/entity/Q413"]}))
543            .unwrap();
544        assert_eq!(ok["theme"][0], "https://www.wikidata.org/entity/Q413");
545    }
546
547    #[test]
548    fn description_takes_a_string_or_an_array_of_lines() {
549        // The two shapes must produce the SAME stored string, because the card
550        // stores one string either way — the array is authoring sugar, not a
551        // second representation a reader would have to know about.
552        let md = "# What's inside\n\n- 4.4M classes\n- 5.1M edges";
553        let from_string = validate_curated_card(&json!({ "description": md })).unwrap();
554        let from_lines = validate_curated_card(&json!({
555            "description": ["# What's inside", "", "- 4.4M classes", "- 5.1M edges"]
556        }))
557        .unwrap();
558        assert_eq!(from_string["description"], json!(md));
559        assert_eq!(from_lines["description"], json!(md));
560
561        // A non-string entry is an authoring slip, not a value to stringify.
562        let e = validate_curated_card(&json!({"description": ["ok", 7]})).unwrap_err();
563        assert!(e.contains("array of strings"), "{e}");
564        let e = validate_curated_card(&json!({"description": 7})).unwrap_err();
565        assert!(e.contains("array of strings"), "{e}");
566    }
567
568    #[test]
569    fn description_markdown_survives_validation_untouched() {
570        // Validation must not "helpfully" rewrite prose: what the publisher
571        // wrote is what hashes, so every marker has to come out the far side.
572        let md = "## Contents\n\n1. one\n2. two\n\n> quoted\n\n`code` and [a link](https://example.org/x)";
573        let ok = validate_curated_card(&json!({ "description": md })).unwrap();
574        assert_eq!(ok["description"], json!(md));
575    }
576
577    #[test]
578    fn description_over_the_cap_is_rejected_rather_than_truncated() {
579        let at = "x".repeat(CARD_DESCRIPTION_MAX_BYTES);
580        validate_curated_card(&json!({ "description": at })).expect("exactly at the cap");
581        let over = "x".repeat(CARD_DESCRIPTION_MAX_BYTES + 1);
582        let e = validate_curated_card(&json!({ "description": over })).unwrap_err();
583        assert!(
584            e.contains(&format!("{} bytes", CARD_DESCRIPTION_MAX_BYTES + 1)),
585            "{e}"
586        );
587        // The message has to say WHY, or the only fix a publisher can imagine
588        // is "make the text shorter" rather than "link out to the long form".
589        assert!(
590            e.contains("every reader fetches the card on every open"),
591            "{e}"
592        );
593        // The array shape is bounded on the JOINED text, not per line.
594        let lines: Vec<String> = (0..200).map(|_| "y".repeat(100)).collect();
595        let e = validate_curated_card(&json!({ "description": lines })).unwrap_err();
596        assert!(e.contains("over the"), "{e}");
597    }
598
599    #[test]
600    fn keywords_and_theme_are_sorted_and_deduplicated() {
601        let ok = validate_curated_card(&json!({"keywords": ["zeta", "alpha", "zeta"]})).unwrap();
602        assert_eq!(ok["keywords"], json!(["alpha", "zeta"]));
603    }
604
605    #[test]
606    fn extra_limits_are_enforced_and_nested_keys_sorted() {
607        // Depth 2 is allowed, and so is depth 3 since 0.4.0 — the list of
608        // objects holding a word list that the OpenCitations full-text
609        // examples needed. Depth 4 is not.
610        let ok = validate_curated_card(&json!({"extra": {"review": {"z": 1, "a": 2}}})).unwrap();
611        assert_eq!(
612            serde_json::to_string(&ok["extra"]).unwrap(),
613            r#"{"review":{"a":2,"z":1}}"#
614        );
615        let ok = validate_curated_card(&json!({"extra": {"full_text_search": [
616            {"query": "svobodová", "words": ["svobodová", "agriculturae"]},
617            {"query": "neural", "words": ["neural"]}
618        ]}}))
619        .unwrap();
620        assert_eq!(json_depth(&ok["extra"]["full_text_search"]), 3);
621        let e = validate_curated_card(&json!({"extra": {"deep": {"a": {"b": {"c": [1]}}}}}))
622            .unwrap_err();
623        assert!(e.contains("nests 4 container levels"), "{e}");
624        assert!(e.contains("over the 3-level cap"), "{e}");
625        assert!(e.contains("(at deep.a.b.c)"), "{e}");
626        assert!(e.contains("flatten the innermost"), "{e}");
627
628        let e = validate_curated_card(&json!({"extra": {"@context": "x"}})).unwrap_err();
629        assert!(e.contains("reserved"), "{e}");
630
631        let big = "x".repeat(CARD_EXTRA_MAX_BYTES);
632        let e = validate_curated_card(&json!({"extra": {"blob": big}})).unwrap_err();
633        assert!(e.contains("over the 8192-byte cap"), "{e}");
634    }
635
636    #[test]
637    fn creators_and_publisher_keep_their_identifiers() {
638        let ok = validate_curated_card(&json!({
639            "creators": [{"name": " Ada ", "orcid": "https://orcid.org/0000-0002-1825-0097"}],
640            "publisher": {"name": "CERN", "ror": "https://ror.org/01ggx4157"},
641        }))
642        .unwrap();
643        assert_eq!(ok["creators"][0]["name"], "Ada");
644        assert_eq!(
645            ok["creators"][0]["orcid"],
646            "https://orcid.org/0000-0002-1825-0097"
647        );
648        assert_eq!(ok["publisher"]["ror"], "https://ror.org/01ggx4157");
649
650        let e = validate_curated_card(&json!({"creators": [{"orcid": "x"}]})).unwrap_err();
651        assert!(e.contains("need a `name`"), "{e}");
652        let e =
653            validate_curated_card(&json!({"publisher": {"name": "X", "isni": "y"}})).unwrap_err();
654        assert!(e.contains("unknown field `isni`"), "{e}");
655    }
656
657    #[test]
658    fn empty_and_null_curated_values_are_absent_not_empty() {
659        let ok = validate_curated_card(&json!({
660            "title": "t", "description": "", "keywords": [], "doi": null, "extra": {}
661        }))
662        .unwrap();
663        let o = ok.as_object().unwrap();
664        assert!(o.contains_key("title"));
665        for absent in ["description", "keywords", "doi", "extra"] {
666            assert!(
667                !o.contains_key(absent),
668                "{absent} should be absent, not empty"
669            );
670        }
671    }
672}