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}