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}