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