rete-core 0.3.4

Core format types for the Rete cloud-native RDF graph file.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
//! The **curated** half of a Dataset Card, and the write-time rules that guard
//! it — shared by every writer, native or wasm.
//!
//! A card has two halves. The *derived* profile (predicates, classes,
//! vocabularies, the starter-query library) is computed from the data by
//! `rete-cli` and lives there. The *curated* half is whatever a publisher
//! hands the builder — and it is the half with rules: `theme` must be an IRI
//! into a controlled vocabulary, the `extra` bag is bounded, and the top level
//! is reserved for rete-defined fields.
//!
//! Those rules used to live only in the CLI, which meant a browser build could
//! write a card the CLI would have refused. They live here so **one**
//! implementation answers for both: `rete build --card-file` and the
//! playground's in-browser builder reject the same documents with the same
//! words. [`validate_curated_card`] is the whole-document entry point;
//! [`normalize_string_list`] / [`normalize_themes`] / [`normalize_extra`] are
//! the per-field gates the CLI calls directly after serde has typed the input.
//!
//! Errors are plain `String`s: the CLI wraps them in `anyhow`, the wasm build
//! hands them to JavaScript, and neither needs a dependency on the other's
//! error type.

use serde_json::{Map, Value};

/// Every field a card *file* may carry at its top level, in the order they are
/// declared on the CLI's `CardInput` — which is also the order serde names
/// them in its "expected one of" message, so [`validate_curated_card`] can
/// reproduce that message verbatim.
///
/// The top level is **reserved**: a key not on this list is a loud error, not
/// a silent drop. That is what makes the `extra` bag's collision guarantee
/// structural rather than a naming promise — a publisher's own field can only
/// ever live inside `extra`, so a future official field can never capture one.
/// `rete-cli` pins this list against its own `deny_unknown_fields` struct by
/// test, so the two can never drift.
pub const CURATED_CARD_FIELDS: &[&str] = &[
    "title",
    "description",
    "license",
    "source",
    "created",
    "version",
    "creators",
    "publisher",
    "canonical_url",
    "sparql_endpoint",
    "source_date",
    "derived_from",
    "doi",
    "cite_as",
    "keywords",
    "theme",
    "extra",
    "example_queries",
];

/// The pointer appended to an unknown-top-level-key error. A stray key is
/// usually a typo — or a custom field that belongs in the bag — and saying so
/// costs one line and saves a round trip to the docs.
pub const UNKNOWN_FIELD_HINT: &str =
    "\n  (the card file's top level is reserved for rete-defined fields; \
     publisher-defined fields go inside the \"extra\" object — \
     see docs/dataset-cards.md)";

// --- Bounds for the publisher-defined `extra` bag, enforced by
// [`normalize_extra`] at build time (readers never validate — a bag written
// oversized by an external tool must not make the card unreadable). The
// binding constraint is SERIALIZED BYTES: custom fields ride in the metadata
// section, which every CARD-tier reader fetches on every open. ---

/// Maximum serialized size (compact JSON bytes) of the whole `extra` object.
///
/// Sized against the published catalog: 8 KiB exceeds the smallest whole card
/// (NKOD, 6,649 B stored) — generous for *metadata* — and the worst realistic
/// case, the largest card (Hugging Face, 53,580 B) + a maxed bag + its ~1 KB
/// build info ≈ 62.8 KB, still travels in the same single coalesced range
/// (the 2-request CARD tier is about request *count*, which the bag cannot
/// change). The one cost worth knowing: on the smallest cards a maxed bag can
/// push the coalesced range past a conservative TCP initial window (~14.6 KB
/// = 10 segments), i.e. one extra round trip, never an extra request.
pub const CARD_EXTRA_MAX_BYTES: usize = 8192;
/// Maximum number of keys in `extra`. A key costs ≥ 8 serialized bytes, so 64
/// typical entries sit comfortably inside [`CARD_EXTRA_MAX_BYTES`]; needing
/// more means the bag is being used as a data store — the graph itself is the
/// place for data.
pub const CARD_EXTRA_MAX_KEYS: usize = 64;
/// Maximum bytes per `extra` key. Keys are identifiers, not values.
pub const CARD_EXTRA_MAX_KEY_BYTES: usize = 128;
/// Maximum size (UTF-8 bytes) of the curated `description`.
///
/// `description` is the one curated field invited to grow: it may be
/// **Markdown** (headings, lists, links — see `docs/dataset-cards.md`), and
/// prose with structure in it has no natural stopping point. It rides in the
/// same metadata section as `extra`, which every CARD-tier reader fetches on
/// every open, so it gets the same budget for the same reason —
/// [`CARD_EXTRA_MAX_BYTES`]. Measured against the corpus: the longest
/// description on the published catalog is miRBase's at 813 bytes, and the
/// longest in this repo's own build scripts is ~1.1 KB, so 8 KiB is roughly
/// ten times the widest real case and still ~1,300 words of Markdown.
///
/// Enforced at **write** time only, like every other card rule. Readers never
/// validate: a card written oversized by some other tool must still open.
pub const CARD_DESCRIPTION_MAX_BYTES: usize = CARD_EXTRA_MAX_BYTES;

/// Maximum container-nesting depth inside an `extra` value (see
/// [`json_depth`]): an object of objects-of-scalars, no deeper. Deliberate —
/// deep structures invite storing *records* in the card, and Parquet
/// companions exist for records; it also hard-bounds recursion for every
/// parser that will ever read the bag (browser wasm and iOS stacks are far
/// smaller than a build machine's).
pub const CARD_EXTRA_MAX_DEPTH: usize = 2;

/// Canonicalize a curated string list (`keywords`, `theme`) — the write-time
/// gate every card-writing command funnels through (like [`normalize_extra`]).
///
/// Whitespace is trimmed and duplicates dropped; an entry that is empty after
/// trimming **rejects the build loudly** (it is always an authoring slip — a
/// stray comma in a hand-written list — and an empty `dcat:keyword` literal
/// would be projected as agreed-upon nothing). Sorting is canonicalization,
/// not editing: both fields project to unordered repeated RDF properties, so
/// no information is lost — and it keeps the card's serialization (hence the
/// reproducible content hash) independent of the order the entries were
/// authored in.
pub fn normalize_string_list(field: &str, values: Vec<String>) -> Result<Vec<String>, String> {
    let mut out: Vec<String> = Vec::with_capacity(values.len());
    for v in values {
        let trimmed = v.trim();
        if trimmed.is_empty() {
            return Err(format!("card `{field}` entries must be non-empty"));
        }
        out.push(trimmed.to_string());
    }
    out.sort();
    out.dedup();
    Ok(out)
}

/// Accept a `description` written either way, and bound it — the write-time
/// gate every card-writing path funnels through.
///
/// **Two input shapes, one stored value.** A description is now allowed to be
/// Markdown, and Markdown needs line breaks, which a JSON string can only carry
/// as `\n` escapes — miserable to hand-write and worse to review. So a card
/// *file* may also give an **array of lines**, joined with `\n`:
///
/// ```json
/// "description": ["# What's inside", "", "- 4.4M classes", "- 5.1M edges"]
/// ```
///
/// The array is input sugar only: the card always stores one string, so a card
/// round-trips through `rete card --json` → `--card-file` unchanged, and no
/// reader ever has to know the array shape existed.
///
/// On overflow the build is **rejected loudly**, never truncated — the same
/// rule as `extra`, for the same reason: the text is authored, it folds into
/// the content hash, and a silent cut would ship a card that no longer says
/// what the publisher wrote.
pub fn normalize_description(v: &Value) -> Result<String, String> {
    let text = match v {
        Value::String(s) => s.trim().to_string(),
        Value::Array(lines) => {
            let mut out = Vec::with_capacity(lines.len());
            for l in lines {
                out.push(l.as_str().ok_or_else(|| {
                    "card `description` must be a string, or an array of strings \
                     (one per line, joined with newlines)"
                        .to_string()
                })?);
            }
            out.join("\n").trim().to_string()
        }
        _ => {
            return Err(
                "card `description` must be a string, or an array of strings \
                 (one per line, joined with newlines)"
                    .to_string(),
            )
        }
    };
    check_description_len(&text)?;
    Ok(text)
}

/// The size half of [`normalize_description`], split out so the CLI's
/// serde-typed path — where `--description` may override whatever the file
/// said — can apply it to the final text without re-parsing anything.
pub fn check_description_len(text: &str) -> Result<(), String> {
    if text.len() > CARD_DESCRIPTION_MAX_BYTES {
        return Err(format!(
            "card `description` is {} bytes, over the {CARD_DESCRIPTION_MAX_BYTES}-byte cap; \
             every reader fetches the card on every open — a description is the dataset's \
             abstract, not its documentation, so link out to the long form \
             (`source` / `canonical_url`) or put it in the graph",
            text.len()
        ));
    }
    Ok(())
}

/// [`normalize_string_list`] plus the `theme` requirement: every entry must be
/// an **IRI into a controlled vocabulary**. This is what keeps `theme` from
/// becoming a second keywords field — a free-text theme carries no more
/// meaning than a keyword, and `keywords` already holds those; the agreed
/// concept scheme behind the IRI is the whole value `dcat:theme` adds.
pub fn normalize_themes(themes: Vec<String>) -> Result<Vec<String>, String> {
    let themes = normalize_string_list("theme", themes)?;
    for t in &themes {
        if !(t.starts_with("http://") || t.starts_with("https://")) {
            return Err(format!(
                "card `theme` entry {t:?} is not an IRI: a theme is an IRI into a \
                 controlled vocabulary (e.g. the EU data-theme authority, \
                 http://publications.europa.eu/resource/authority/data-theme/…); \
                 free-text subjects belong in `keywords`"
            ));
        }
    }
    Ok(themes)
}

/// Canonicalize and bounds-check the `extra` bag — the write-time gate.
///
/// On overflow the build is **rejected loudly** rather than truncated quietly:
/// `extra` is authored (unlike the derived lists, which are capped with
/// `truncated` set, because they can be re-derived), so cutting it would
/// silently ship a card that no longer says what the publisher wrote — and,
/// since the bag folds into the content hash, "what I wrote" and "what hashed"
/// would diverge invisibly. Only the author can decide what to trim.
pub fn normalize_extra(extra: Map<String, Value>) -> Result<Map<String, Value>, String> {
    if extra.is_empty() {
        return Ok(extra);
    }
    if extra.len() > CARD_EXTRA_MAX_KEYS {
        return Err(format!(
            "card `extra` has {} keys, over the {CARD_EXTRA_MAX_KEYS}-key cap",
            extra.len()
        ));
    }
    let extra: Map<String, Value> = extra
        .into_iter()
        .map(|(k, v)| (k, canonicalize_json(v)))
        .collect();
    for (k, v) in &extra {
        if k.is_empty() {
            return Err("card `extra` keys must be non-empty".to_string());
        }
        if k == "@context" {
            // Reserved TODAY so it can mean something LATER: a future release
            // may honour an author-supplied JSON-LD mapping here (turning the
            // bag's projection from opaque values into the author's own
            // vocabulary). Rejecting it now keeps that door open without ever
            // breaking a published card.
            return Err("card `extra` key \"@context\" is reserved for a future \
                 author-supplied JSON-LD mapping"
                .to_string());
        }
        if k.len() > CARD_EXTRA_MAX_KEY_BYTES {
            return Err(format!(
                "card `extra` key {k:?} is {} bytes, over the {CARD_EXTRA_MAX_KEY_BYTES}-byte cap",
                k.len()
            ));
        }
        let depth = json_depth(v);
        if depth > CARD_EXTRA_MAX_DEPTH {
            return Err(format!(
                "card `extra` field {k:?} nests {depth} container levels deep, \
                 over the {CARD_EXTRA_MAX_DEPTH}-level cap"
            ));
        }
    }
    let bytes = serde_json::to_vec(&extra)
        .map_err(|e| format!("card `extra` does not serialize: {e}"))?
        .len();
    if bytes > CARD_EXTRA_MAX_BYTES {
        return Err(format!(
            "card `extra` serializes to {bytes} bytes, over the {CARD_EXTRA_MAX_BYTES}-byte cap; \
             every reader fetches the card on every open — trim the bag, \
             or put bulk data in the graph itself"
        ));
    }
    Ok(extra)
}

/// Container-nesting depth of a JSON value: scalars are 0, an array/object is
/// 1 + its deepest child. (Recursion here is bounded by serde_json's own
/// 128-level parse limit.)
pub fn json_depth(v: &Value) -> usize {
    match v {
        Value::Array(a) => 1 + a.iter().map(json_depth).max().unwrap_or(0),
        Value::Object(o) => 1 + o.values().map(json_depth).max().unwrap_or(0),
        _ => 0,
    }
}

/// Rebuild every nested object with its keys inserted in sorted order, so the
/// bag's bytes — which fold into the reproducible content hash — never depend
/// on author key order. With today's serde_json (no `preserve_order`) maps are
/// BTree-backed and this is a no-op in effect; it is insurance that a future
/// feature unification can't quietly make map order mean insertion order.
pub fn canonicalize_json(v: Value) -> Value {
    match v {
        Value::Array(a) => Value::Array(a.into_iter().map(canonicalize_json).collect()),
        Value::Object(o) => {
            let mut sorted: Vec<(String, Value)> = o.into_iter().collect();
            sorted.sort_by(|a, b| a.0.cmp(&b.0));
            let mut out = Map::new();
            for (k, v) in sorted {
                out.insert(k, canonicalize_json(v));
            }
            Value::Object(out)
        }
        scalar => scalar,
    }
}

/// Validate and canonicalize a **whole curated card document** — the JSON a
/// publisher writes for `--card-file`, or types into the playground's builder.
///
/// This is the schema-free twin of the CLI's serde-typed path: same reserved
/// top level, same field types, same `keywords`/`theme`/`extra` rules, same
/// wording. Returns the canonicalized document (lists sorted and deduplicated,
/// bag keys sorted) ready to merge with the derived counts.
///
/// It deliberately does **not** invent validation the CLI does not do: DOIs,
/// URLs, ORCIDs, RORs and dates travel as the publisher wrote them, because
/// the CLI accepts them that way and a client that rejected more would build
/// files the CLI could not reproduce — the exact drift this module exists to
/// prevent.
pub fn validate_curated_card(doc: &Value) -> Result<Value, String> {
    let obj = doc
        .as_object()
        .ok_or_else(|| "a card document must be a JSON object".to_string())?;

    for k in obj.keys() {
        if !CURATED_CARD_FIELDS.contains(&k.as_str()) {
            let expected = CURATED_CARD_FIELDS
                .iter()
                .map(|f| format!("`{f}`"))
                .collect::<Vec<_>>()
                .join(", ");
            return Err(format!(
                "unknown field `{k}`, expected one of {expected}{UNKNOWN_FIELD_HINT}"
            ));
        }
    }

    let mut out = Map::new();
    for (k, v) in obj {
        if v.is_null() {
            continue; // an explicit null is "unset", not a value
        }
        let normalized = match k.as_str() {
            // The one field that may also arrive as an array of lines, and the
            // one with a size cap — it is the field Markdown made open-ended.
            "description" => Value::String(normalize_description(v)?),
            "title" | "license" | "source" | "created" | "version" | "canonical_url"
            | "sparql_endpoint" | "source_date" | "doi" | "cite_as" => {
                Value::String(expect_string(k, v)?)
            }
            "derived_from" | "example_queries" => Value::from(expect_string_array(k, v)?),
            "keywords" => Value::from(normalize_string_list(
                "keywords",
                expect_string_array(k, v)?,
            )?),
            "theme" => Value::from(normalize_themes(expect_string_array(k, v)?)?),
            "creators" => {
                let arr = v.as_array().ok_or_else(|| {
                    format!("card `{k}` must be an array of {{name, orcid}} objects")
                })?;
                let mut people = Vec::with_capacity(arr.len());
                for c in arr {
                    people.push(named_agent("creators", c, "orcid")?);
                }
                Value::Array(people)
            }
            "publisher" => named_agent("publisher", v, "ror")?,
            "extra" => {
                let bag = v
                    .as_object()
                    .ok_or_else(|| "card `extra` must be a JSON object".to_string())?;
                Value::Object(normalize_extra(bag.clone())?)
            }
            _ => unreachable!("field list and match arms are kept in step"),
        };
        // Drop what serializes away anyway, so an empty list authored by hand
        // reads the same as one never written — absence, not an empty shell.
        let empty = match &normalized {
            Value::String(s) => s.is_empty(),
            Value::Array(a) => a.is_empty(),
            Value::Object(o) => o.is_empty(),
            _ => false,
        };
        if !empty {
            out.insert(k.clone(), normalized);
        }
    }
    Ok(Value::Object(out))
}

/// Finish a validated curated document into a **complete card**, by adding the
/// four counts the build just measured and the format version the writer
/// stamps.
///
/// This is the whole card a writer without the CLI's derivation can honestly
/// produce. The counts are not optional in the card schema — a document
/// missing them is not a card any reader can deserialize — so they are added
/// here rather than left to each caller to remember. Everything else the CLI
/// derives (predicates, classes, vocabularies, signals, the starter-query
/// library) is simply **absent**, which is what a reader should see: no key,
/// rather than an empty list that reads like a measured zero.
pub fn compose_curated_card(
    curated: Value,
    triple_count: u64,
    quad_count: u64,
    named_graph_count: u64,
    term_count: u64,
    format_version: u8,
) -> Value {
    let mut card = curated;
    let obj = match card.as_object_mut() {
        Some(o) => o,
        None => return card,
    };
    obj.insert("triple_count".into(), triple_count.into());
    obj.insert("quad_count".into(), quad_count.into());
    obj.insert("named_graph_count".into(), named_graph_count.into());
    obj.insert("term_count".into(), term_count.into());
    obj.insert("format_version".into(), format_version.into());
    card
}

fn expect_string(field: &str, v: &Value) -> Result<String, String> {
    v.as_str()
        .map(|s| s.trim().to_string())
        .ok_or_else(|| format!("card `{field}` must be a string"))
}

fn expect_string_array(field: &str, v: &Value) -> Result<Vec<String>, String> {
    let arr = v
        .as_array()
        .ok_or_else(|| format!("card `{field}` must be an array of strings"))?;
    arr.iter()
        .map(|e| {
            e.as_str()
                .map(|s| s.to_string())
                .ok_or_else(|| format!("card `{field}` must be an array of strings"))
        })
        .collect()
}

/// A `{name, <id>}` object — `creators` entries (`orcid`) and `publisher`
/// (`ror`). `name` is required, matching the CLI's non-optional field; the
/// identifier is optional and unvalidated, as there.
fn named_agent(field: &str, v: &Value, id_key: &str) -> Result<Value, String> {
    let o = v
        .as_object()
        .ok_or_else(|| format!("card `{field}` entries must be objects with a `name`"))?;
    for k in o.keys() {
        if k != "name" && k != id_key {
            return Err(format!(
                "unknown field `{k}` in card `{field}`, expected one of `name`, `{id_key}`"
            ));
        }
    }
    let name = o
        .get("name")
        .and_then(|n| n.as_str())
        .ok_or_else(|| format!("card `{field}` entries need a `name`"))?;
    let mut out = Map::new();
    out.insert("name".into(), Value::String(name.trim().to_string()));
    if let Some(id) = o.get(id_key).and_then(|i| i.as_str()) {
        if !id.trim().is_empty() {
            out.insert(id_key.into(), Value::String(id.trim().to_string()));
        }
    }
    Ok(Value::Object(out))
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn unknown_top_level_key_is_rejected_with_the_bag_pointer() {
        let e = validate_curated_card(&json!({"title": "t", "region": "CH"})).unwrap_err();
        assert!(e.contains("unknown field `region`"), "{e}");
        assert!(e.contains("\"extra\""), "{e}");
        // The "expected one of" list must name the real fields, so a reader can
        // fix a typo without opening the docs.
        assert!(e.contains("`canonical_url`"), "{e}");
    }

    #[test]
    fn theme_must_be_an_iri_and_points_at_keywords() {
        let e = validate_curated_card(&json!({"theme": ["physics"]})).unwrap_err();
        assert!(e.contains("not an IRI"), "{e}");
        assert!(e.contains("keywords"), "{e}");
        let ok = validate_curated_card(&json!({"theme": ["https://www.wikidata.org/entity/Q413"]}))
            .unwrap();
        assert_eq!(ok["theme"][0], "https://www.wikidata.org/entity/Q413");
    }

    #[test]
    fn description_takes_a_string_or_an_array_of_lines() {
        // The two shapes must produce the SAME stored string, because the card
        // stores one string either way — the array is authoring sugar, not a
        // second representation a reader would have to know about.
        let md = "# What's inside\n\n- 4.4M classes\n- 5.1M edges";
        let from_string = validate_curated_card(&json!({ "description": md })).unwrap();
        let from_lines = validate_curated_card(&json!({
            "description": ["# What's inside", "", "- 4.4M classes", "- 5.1M edges"]
        }))
        .unwrap();
        assert_eq!(from_string["description"], json!(md));
        assert_eq!(from_lines["description"], json!(md));

        // A non-string entry is an authoring slip, not a value to stringify.
        let e = validate_curated_card(&json!({"description": ["ok", 7]})).unwrap_err();
        assert!(e.contains("array of strings"), "{e}");
        let e = validate_curated_card(&json!({"description": 7})).unwrap_err();
        assert!(e.contains("array of strings"), "{e}");
    }

    #[test]
    fn description_markdown_survives_validation_untouched() {
        // Validation must not "helpfully" rewrite prose: what the publisher
        // wrote is what hashes, so every marker has to come out the far side.
        let md = "## Contents\n\n1. one\n2. two\n\n> quoted\n\n`code` and [a link](https://example.org/x)";
        let ok = validate_curated_card(&json!({ "description": md })).unwrap();
        assert_eq!(ok["description"], json!(md));
    }

    #[test]
    fn description_over_the_cap_is_rejected_rather_than_truncated() {
        let at = "x".repeat(CARD_DESCRIPTION_MAX_BYTES);
        validate_curated_card(&json!({ "description": at })).expect("exactly at the cap");
        let over = "x".repeat(CARD_DESCRIPTION_MAX_BYTES + 1);
        let e = validate_curated_card(&json!({ "description": over })).unwrap_err();
        assert!(
            e.contains(&format!("{} bytes", CARD_DESCRIPTION_MAX_BYTES + 1)),
            "{e}"
        );
        // The message has to say WHY, or the only fix a publisher can imagine
        // is "make the text shorter" rather than "link out to the long form".
        assert!(
            e.contains("every reader fetches the card on every open"),
            "{e}"
        );
        // The array shape is bounded on the JOINED text, not per line.
        let lines: Vec<String> = (0..200).map(|_| "y".repeat(100)).collect();
        let e = validate_curated_card(&json!({ "description": lines })).unwrap_err();
        assert!(e.contains("over the"), "{e}");
    }

    #[test]
    fn keywords_and_theme_are_sorted_and_deduplicated() {
        let ok = validate_curated_card(&json!({"keywords": ["zeta", "alpha", "zeta"]})).unwrap();
        assert_eq!(ok["keywords"], json!(["alpha", "zeta"]));
    }

    #[test]
    fn extra_limits_are_enforced_and_nested_keys_sorted() {
        // Depth 2 is allowed, depth 3 is not.
        let ok = validate_curated_card(&json!({"extra": {"review": {"z": 1, "a": 2}}})).unwrap();
        assert_eq!(
            serde_json::to_string(&ok["extra"]).unwrap(),
            r#"{"review":{"a":2,"z":1}}"#
        );
        let e =
            validate_curated_card(&json!({"extra": {"deep": {"a": {"b": {"c": 1}}}}})).unwrap_err();
        assert!(e.contains("over the 2-level cap"), "{e}");

        let e = validate_curated_card(&json!({"extra": {"@context": "x"}})).unwrap_err();
        assert!(e.contains("reserved"), "{e}");

        let big = "x".repeat(CARD_EXTRA_MAX_BYTES);
        let e = validate_curated_card(&json!({"extra": {"blob": big}})).unwrap_err();
        assert!(e.contains("over the 8192-byte cap"), "{e}");
    }

    #[test]
    fn creators_and_publisher_keep_their_identifiers() {
        let ok = validate_curated_card(&json!({
            "creators": [{"name": " Ada ", "orcid": "https://orcid.org/0000-0002-1825-0097"}],
            "publisher": {"name": "CERN", "ror": "https://ror.org/01ggx4157"},
        }))
        .unwrap();
        assert_eq!(ok["creators"][0]["name"], "Ada");
        assert_eq!(
            ok["creators"][0]["orcid"],
            "https://orcid.org/0000-0002-1825-0097"
        );
        assert_eq!(ok["publisher"]["ror"], "https://ror.org/01ggx4157");

        let e = validate_curated_card(&json!({"creators": [{"orcid": "x"}]})).unwrap_err();
        assert!(e.contains("need a `name`"), "{e}");
        let e =
            validate_curated_card(&json!({"publisher": {"name": "X", "isni": "y"}})).unwrap_err();
        assert!(e.contains("unknown field `isni`"), "{e}");
    }

    #[test]
    fn empty_and_null_curated_values_are_absent_not_empty() {
        let ok = validate_curated_card(&json!({
            "title": "t", "description": "", "keywords": [], "doi": null, "extra": {}
        }))
        .unwrap();
        let o = ok.as_object().unwrap();
        assert!(o.contains_key("title"));
        for absent in ["description", "keywords", "doi", "extra"] {
            assert!(
                !o.contains_key(absent),
                "{absent} should be absent, not empty"
            );
        }
    }
}