Skip to main content

cloudillo_search/
extract.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Turning arbitrary document JSON into indexable plain text.
5//!
6//! The extractor is a **recursive string-leaf walk**: it descends a
7//! [`serde_json::Value`], concatenates every string it finds, skips object keys
8//! the rule excludes, and prefixes the values of keys the rule marks. It is
9//! deliberately typed on `serde_json::Value` and not on any app's schema, so
10//! the same code serves RTDB documents today and Yjs/CRDT documents converted
11//! to JSON later.
12//!
13//! # Why a generic walk, and what it costs
14//!
15//! notillo's inline content is a union:
16//! `string | [text, styleFlags] | [text, styleFlags, colors] | {l,c} | {wl,wt} | {tg}`.
17//! The walk reproduces the app's own `extractBlockText()` output, table cells
18//! included, but it *also* picks up style-flag codes (`"b"`, `"bi"`) as tokens,
19//! because nothing in the JSON distinguishes a styled-text tuple `["Hi","b"]`
20//! from a table row `["Hi","there"]`.
21//!
22//! The walk cannot infer that distinction, but a manifest can *state* it: a
23//! [`Selector::JsonPath`] filter picks exactly the nodes that carry prose
24//! (`$.c[?@.t=='p'].text`), and `extract: "string"` takes one verbatim instead of
25//! descending into its siblings. Where a manifest says nothing the walk stays the
26//! default and the trade stands — those flags are one- and two-character tokens
27//! that barely move `bm25()`, whereas a typed node-union DSL would have to guess
28//! at the same ambiguity and would silently truncate real table text when it
29//! guessed wrong. Genuinely harmful values — link targets, color codes — are
30//! removed by name, either by listing them in `excludeKeys` or, better, by
31//! listing the keys that *do* carry prose in `keys`: a denylist fails silently
32//! when the document schema grows a key nobody thought to add to it.
33//!
34//! For the positional case itself — the style flag that no name can reach — the
35//! preferred lever is a part rule's `prune` list, which deletes the tuple's tail
36//! *before* this walk runs, so the walk stays one ordered rule and sees prose
37//! only. See [`crate::prune`].
38
39use serde_json::Value;
40
41use crate::{
42	prelude::*,
43	rules::{ExtractMode, FieldRule, MAX_JSONPATH_NODES, Selector},
44};
45
46/// Accumulates extracted text under a hard character budget.
47///
48/// The budget is checked before every push, so a pathological document
49/// truncates instead of exhausting memory. [`TextSink::truncated`] reports
50/// whether anything was dropped, which callers surface as a `warn!`.
51#[derive(Debug)]
52pub struct TextSink {
53	buf: String,
54	/// `buf.chars().count()`, maintained incrementally. Recomputing it per push
55	/// makes accumulation quadratic in the buffer length, which at a six-figure
56	/// budget is the difference between linear and unusable. **Invariant:
57	/// `len == buf.chars().count()` after every push** — every branch that grows
58	/// `buf` must add exactly the chars it appended.
59	len: usize,
60	budget: usize,
61	truncated: bool,
62}
63
64impl TextSink {
65	pub fn new(budget: usize) -> Self {
66		Self { buf: String::new(), len: 0, budget, truncated: false }
67	}
68
69	pub fn is_empty(&self) -> bool {
70		self.buf.is_empty()
71	}
72
73	/// Chars accumulated so far. Maintained incrementally, so callers folding
74	/// many sinks into one document-wide total pay nothing for asking.
75	pub fn len_chars(&self) -> usize {
76		self.len
77	}
78
79	pub fn truncated(&self) -> bool {
80		self.truncated
81	}
82
83	/// Remaining budget — lets a caller stop walking documents entirely once
84	/// the total cap is reached.
85	pub fn remaining(&self) -> usize {
86		self.budget.saturating_sub(self.len)
87	}
88
89	pub fn into_string(self) -> String {
90		self.buf
91	}
92
93	/// Append a token, space-separated from what came before.
94	///
95	/// Truncation happens on a `char` boundary — never mid-code-point — so the
96	/// result is always valid UTF-8 and FTS5 can tokenize it.
97	fn push(&mut self, prefix: &str, text: &str) {
98		let text = text.trim();
99		if text.is_empty() {
100			return;
101		}
102		let sep = usize::from(!self.buf.is_empty());
103		let prefix_len = prefix.chars().count();
104		let text_len = text.chars().count();
105		let want = sep + prefix_len + text_len;
106		let left = self.remaining();
107		// Nothing useful fits: a lone separator is not worth the budget, and the
108		// buffer must never end on one. Hence the separator is pushed only inside
109		// the two branches that go on to append content.
110		if left <= sep {
111			self.truncated = true;
112			return;
113		}
114
115		if want <= left {
116			self.push_sep(sep);
117			self.buf.push_str(prefix);
118			self.buf.push_str(text);
119			self.len += prefix_len + text_len;
120		} else {
121			// Keep the prefix intact if it fits at all; a bare '#' is useless.
122			let room = left.saturating_sub(sep + prefix_len);
123			if room > 0 {
124				self.push_sep(sep);
125				self.buf.push_str(prefix);
126				// `room < text_len` here (that is what put us in this branch), so
127				// `take(room)` appends exactly `room` chars.
128				self.buf.extend(text.chars().take(room));
129				self.len += prefix_len + room;
130			}
131			self.truncated = true;
132		}
133	}
134
135	/// Append the token separator, keeping [`TextSink::len`] in step with it.
136	fn push_sep(&mut self, sep: usize) {
137		if sep == 1 {
138			self.buf.push(' ');
139			self.len += 1;
140		}
141	}
142}
143
144/// Extract the text a single [`FieldRule`] selects out of one document.
145///
146/// A JSONPath selector may match many nodes; each is emitted in match order,
147/// and the count is capped by [`MAX_JSONPATH_NODES`] — a backstop set past any
148/// real document, since the match set is already materialised here and the walk
149/// of each match is bounded by `max_depth`. A query that fails to evaluate
150/// yields nothing and warns — it was accepted at registration, so a failure here
151/// is about this one document, not about the rule.
152pub fn extract_field(doc: &Value, rule: &FieldRule, sink: &mut TextSink) {
153	match &rule.selector {
154		Selector::Dotted(path) => {
155			let Some(value) = resolve_path(doc, path) else { return };
156			emit(value, rule, sink);
157		}
158		Selector::JsonPath(query) => {
159			let nodes = match jsonpath_rust::query::js_path_process(query, doc) {
160				Ok(nodes) => nodes,
161				Err(e) => {
162					warn!(error = %e, "Search extraction: JSONPath query failed");
163					return;
164				}
165			};
166			if nodes.len() > MAX_JSONPATH_NODES {
167				sink.truncated = true;
168			}
169			for node in nodes.into_iter().take(MAX_JSONPATH_NODES) {
170				emit(node.val(), rule, sink);
171			}
172		}
173	}
174}
175
176/// Turn one selected node into text according to the rule's [`ExtractMode`].
177fn emit(value: &Value, rule: &FieldRule, sink: &mut TextSink) {
178	match rule.mode {
179		ExtractMode::Text => walk(value, rule, None, &rule.prefix, rule.max_depth, sink),
180		// Deliberately silent on a non-string: the point of this mode is to take
181		// the one node that is prose and ignore everything structural around it.
182		ExtractMode::String => {
183			if let Value::String(s) = value {
184				sink.push(&rule.prefix, s);
185			}
186		}
187	}
188}
189
190/// Extract every rule in `rules` into one sink, in declaration order.
191pub fn extract_fields(doc: &Value, rules: &[FieldRule], sink: &mut TextSink) {
192	for rule in rules {
193		extract_field(doc, rule, sink);
194	}
195}
196
197/// Follow a pre-split dotted path. An empty path is the document itself.
198///
199/// Numeric segments index into arrays, so `"rows.0.cells"` works; everything
200/// else is an object key.
201pub fn resolve_path<'a>(doc: &'a Value, path: &[String]) -> Option<&'a Value> {
202	let mut cur = doc;
203	for segment in path {
204		cur = match cur {
205			Value::Object(map) => map.get(segment)?,
206			Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
207			_ => return None,
208		};
209	}
210	Some(cur)
211}
212
213/// Read a path as a plain scalar string — used for ids, parent links and sort
214/// keys, where a recursive walk would be wrong.
215pub fn resolve_str(doc: &Value, path: &str) -> Option<String> {
216	let segments: Vec<String> =
217		path.split('.').filter(|s| !s.is_empty()).map(ToOwned::to_owned).collect();
218	match resolve_path(doc, &segments)? {
219		Value::String(s) => Some(s.clone()),
220		Value::Number(n) => Some(n.to_string()),
221		Value::Bool(b) => Some(b.to_string()),
222		_ => None,
223	}
224}
225
226/// `key` is the object key the value sits under, `None` where nothing names it —
227/// an array element, or the node the selector landed on. It is an `Option` and
228/// not a `""` sentinel so that a key which is literally the empty string stays a
229/// key like any other and is gated normally.
230fn walk(
231	value: &Value,
232	rule: &FieldRule,
233	key: Option<&str>,
234	prefix: &str,
235	depth: usize,
236	sink: &mut TextSink,
237) {
238	if depth == 0 || sink.remaining() == 0 {
239		if depth == 0 {
240			sink.truncated = true;
241		}
242		return;
243	}
244	match value {
245		// An allowlist gates strings, not containers. The documents that need one
246		// have dynamic keys — calcillo's `rows.<rowId>.<colId>` — which no manifest
247		// could enumerate, so descent stays unconditional and only the leaf is
248		// filtered. A string with no enclosing key (an element of the selected
249		// array, or the selected node itself) is always text: nothing names it.
250		Value::String(s) => {
251			if rule.keys.is_empty()
252				|| key.is_none_or(|k| rule.keys.iter().any(|allowed| allowed.as_str() == k))
253			{
254				sink.push(prefix, s);
255			}
256		}
257		// Numbers and booleans are structural noise (offsets, flags, counts),
258		// not prose. Indexing them would flood the index with `0`/`1` tokens.
259		Value::Array(items) => {
260			// Arrays are transparent: an element keeps its parent's key, so
261			// `keys: ["cells"]` reaches strings two array levels down.
262			for item in items {
263				walk(item, rule, key, prefix, depth - 1, sink);
264			}
265		}
266		// Object leaves come out in *source* order: this crate enables
267		// `serde_json/preserve_order` (see its `Cargo.toml`), which backs a map with
268		// an IndexMap, not a BTreeMap. Arrays keep source order too, so the whole
269		// document reads in document order.
270		Value::Object(map) => {
271			for (child_key, child) in map {
272				if rule.exclude_keys.iter().any(|k| k == child_key) {
273					continue;
274				}
275				let child_prefix =
276					rule.prefix_keys.get(child_key).map_or(prefix, std::string::String::as_str);
277				walk(child, rule, Some(child_key), child_prefix, depth - 1, sink);
278			}
279		}
280		Value::Number(_) | Value::Bool(_) | Value::Null => {}
281	}
282}
283
284#[cfg(test)]
285mod tests {
286	use super::*;
287
288	fn rule(field: &str) -> FieldRule {
289		FieldRule::dotted(field)
290	}
291
292	/// A rule built the way a manifest builds one, so the tests exercise the
293	/// same validation path a registration does.
294	fn json_rule(json: serde_json::Value) -> FieldRule {
295		serde_json::from_value::<crate::rules::RawField>(json)
296			.expect("field shape")
297			.validate()
298			.expect("valid field rule")
299	}
300
301	fn extract(doc: &serde_json::Value, rule: &FieldRule) -> String {
302		let mut sink = TextSink::new(10_000);
303		extract_field(doc, rule, &mut sink);
304		sink.into_string()
305	}
306
307	#[test]
308	fn collects_string_leaves_from_nested_structures() {
309		let doc = serde_json::json!({
310			"c": ["Hello", ["world", "b"], { "l": "https://example.com", "c": "link text" }]
311		});
312		// Source order throughout — the object's leaves as written, "l" before "c".
313		assert_eq!(extract(&doc, &rule("c")), "Hello world b https://example.com link text");
314		// An empty allowlist means "every key", so it changes nothing.
315		assert_eq!(
316			extract(&doc, &json_rule(serde_json::json!({ "path": "c", "keys": [] }))),
317			"Hello world b https://example.com link text"
318		);
319	}
320
321	#[test]
322	fn keys_gate_string_leaves_under_dynamic_object_keys() {
323		// calcillo's shape: the row and column ids are data, so no allowlist could
324		// name them — only the leaf keys inside a cell are nameable.
325		let doc = serde_json::json!({
326			"rows": { "r1": { "c1": {
327				"v": "bevétel",
328				"f": "=SUM(A1:A9)",
329				"bg": "#ff0000",
330				"ct": { "t": "n", "fa": "General", "s": [{ "v": "árbevétel", "ff": "Arial" }] }
331			} } }
332		});
333		let out = extract(&doc, &json_rule(serde_json::json!({ "path": "rows", "keys": ["v"] })));
334		// Source order: the cell's own `v` is written before `ct.s[].v`.
335		assert_eq!(out, "bevétel árbevétel");
336	}
337
338	/// The crate's text order rests on `serde_json/preserve_order`, a global and
339	/// additive Cargo feature: one `default-features = false` in the wrong place
340	/// silently reverts every object to sorted-key order.
341	#[test]
342	fn object_leaves_follow_source_order() {
343		let doc = serde_json::json!({ "c": { "b": "második", "a": "első" } });
344		assert_eq!(
345			extract(&doc, &rule("c")),
346			"második első",
347			"serde_json/preserve_order is off — cloudillo-search's Cargo.toml must keep it; \
348			 check with `cargo tree -p cloudillo-search -e features -i serde_json`"
349		);
350	}
351
352	#[test]
353	fn keys_keep_strings_that_have_no_enclosing_key() {
354		let doc = serde_json::json!({ "c": ["csupasz", { "wt": "Oldalcím" }], "ti": "Cím" });
355		// An element of the selected array is named by nothing, so it is text
356		// whatever the allowlist says; the object leaf next to it is gated.
357		assert_eq!(
358			extract(&doc, &json_rule(serde_json::json!({ "path": "c", "keys": ["wt"] }))),
359			"csupasz Oldalcím"
360		);
361		// Same for the node the selector itself landed on.
362		assert_eq!(
363			extract(&doc, &json_rule(serde_json::json!({ "path": "ti", "keys": ["wt"] }))),
364			"Cím"
365		);
366	}
367
368	#[test]
369	fn keys_survive_tables_and_nested_links() {
370		let doc = serde_json::json!({
371			"c": { "type": "tableContent",
372				   "rows": [{ "cells": [
373					   { "pr": { "backgroundColor": "#ff0000" },
374						 "c": ["Alma", ["Szia", "b"],
375							   { "l": "https://pelda.hu", "c": ["hivatkozás"] }] },
376					   { "c": ["Körte"] }
377				   ] }] }
378		});
379		let out = extract(
380			&doc,
381			&json_rule(serde_json::json!({ "path": "c", "keys": ["c", "cells", "wt"] })),
382		);
383		for text in ["Alma", "Körte", "hivatkozás"] {
384			assert!(out.contains(text), "missing {text} in {out}");
385		}
386		for noise in ["tableContent", "pelda.hu", "#ff0000"] {
387			assert!(!out.contains(noise), "{noise} must not be indexed: {out}");
388		}
389		// Extraction alone cannot drop a style flag: it is positional, so it shares
390		// its text's enclosing key and no allowlist can tell the two apart. That is
391		// what [`crate::prune`] is for — it deletes the tuple's tail before this
392		// walk ever runs, so in production the flag is already gone by here.
393		assert!(out.split_whitespace().any(|t| t == "b"), "got {out}");
394	}
395
396	#[test]
397	fn exclude_keys_win_over_keys() {
398		let doc = serde_json::json!({ "x": { "drop": { "c": "nem" }, "keep": { "c": "igen" } } });
399		let out = extract(
400			&doc,
401			&json_rule(serde_json::json!({ "path": "x", "keys": ["c"], "excludeKeys": ["drop"] })),
402		);
403		assert_eq!(out, "igen");
404	}
405
406	#[test]
407	fn the_empty_object_key_is_gated_like_any_other() {
408		let doc = serde_json::json!({ "": "üres", "c": "tartalom" });
409		assert_eq!(
410			extract(&doc, &json_rule(serde_json::json!({ "path": "", "keys": ["c"] }))),
411			"tartalom"
412		);
413		assert_eq!(
414			extract(&doc, &json_rule(serde_json::json!({ "path": "", "keys": [""] }))),
415			"üres"
416		);
417	}
418
419	#[test]
420	fn keys_are_inert_in_string_mode() {
421		let doc = serde_json::json!({ "ti": "Cím" });
422		let out = extract(
423			&doc,
424			&json_rule(
425				serde_json::json!({ "path": "ti", "extract": "string", "keys": ["nincs-ilyen"] }),
426			),
427		);
428		assert_eq!(out, "Cím", "string mode takes the node verbatim, allowlist or not");
429	}
430
431	#[test]
432	fn a_prefix_key_outside_the_allowlist_emits_nothing() {
433		// The two modifiers can cancel: a prefix is computed for `tg` and then the
434		// leaf is gated away. Deliberate — a `prefixKeys` entry on a *container*
435		// key is legitimate, because prefixes are sticky.
436		let doc = serde_json::json!({ "c": [{ "tg": "projekt" }, "sima"] });
437		let out = extract(
438			&doc,
439			&json_rule(
440				serde_json::json!({ "path": "c", "keys": ["c"], "prefixKeys": { "tg": "#" } }),
441			),
442		);
443		assert_eq!(out, "sima");
444	}
445
446	#[test]
447	fn a_constant_prefix_applies_in_both_extract_modes() {
448		let doc = serde_json::json!({ "c": [{ "tg": "projekt" }, { "tg": "jegyzet" }] });
449		assert_eq!(
450			extract(&doc, &json_rule(serde_json::json!({ "path": "c", "prefix": "#" }))),
451			"#projekt #jegyzet"
452		);
453		// The shape notillo uses: a JSONPath landing on the value itself, where
454		// `prefixKeys` would have no key left to match.
455		assert_eq!(
456			extract(
457				&doc,
458				&json_rule(
459					serde_json::json!({ "path": "$..tg", "extract": "string", "prefix": "#" })
460				)
461			),
462			"#projekt #jegyzet"
463		);
464	}
465
466	#[test]
467	fn exclude_keys_drop_whole_subtrees() {
468		let doc = serde_json::json!({
469			"c": [{ "l": "https://example.com", "c": "link text" }, { "tc": "#ff0000" }]
470		});
471		let mut r = rule("c");
472		r.exclude_keys = vec!["l".into(), "tc".into()];
473		assert_eq!(extract(&doc, &r), "link text");
474	}
475
476	#[test]
477	fn prefix_keys_turn_tag_nodes_into_hash_tokens() {
478		let doc = serde_json::json!({ "c": [{ "tg": "projekt" }, "plain"] });
479		let mut r = rule("c");
480		r.prefix_keys.insert("tg".into(), "#".into());
481		let out = extract(&doc, &r);
482		assert!(out.contains("#projekt"), "got {out}");
483		assert!(out.contains("plain"));
484	}
485
486	#[test]
487	fn numbers_and_booleans_are_not_indexed() {
488		let doc = serde_json::json!({ "c": ["text", 42, true, null] });
489		assert_eq!(extract(&doc, &rule("c")), "text");
490	}
491
492	#[test]
493	fn a_missing_path_yields_nothing() {
494		let doc = serde_json::json!({ "c": "text" });
495		assert_eq!(extract(&doc, &rule("nope.deeper")), "");
496	}
497
498	#[test]
499	fn budget_truncates_on_a_char_boundary() {
500		let doc = serde_json::json!({ "c": ["áéíóú", "második"] });
501		let mut sink = TextSink::new(8);
502		extract_field(&doc, &rule("c"), &mut sink);
503		assert!(sink.truncated());
504		let out = sink.into_string();
505		assert!(out.chars().count() <= 8, "got {out}");
506		assert!(out.starts_with("áéíóú"));
507	}
508
509	#[test]
510	fn sink_length_tracks_the_buffer() {
511		// Multi-byte throughout, so a byte-vs-char slip in the incremental
512		// counter shows up as a mismatch rather than passing by accident.
513		const BUDGET: usize = 20;
514		let mut sink = TextSink::new(BUDGET);
515		sink.push("", "áéíóú"); // 5
516		sink.push("#", "őű"); // sep + 1 + 2 = 4 -> 9
517		sink.push("", "árvíztűrő"); // sep + 9 = 10 -> 19, still fits
518		sink.push("", "túl"); // no room left for the whole token: truncates
519		assert!(sink.truncated());
520
521		let remaining = sink.remaining();
522		let out = sink.into_string();
523		assert_eq!(remaining, BUDGET - out.chars().count(), "got {out:?}");
524	}
525
526	#[test]
527	fn a_truncating_push_never_leaves_a_trailing_separator() {
528		// One char of budget left: the separator alone would consume it and emit
529		// nothing searchable.
530		let mut sink = TextSink::new(6);
531		sink.push("", "árvíz"); // 5
532		sink.push("", "tűrő"); // sep would take the 6th char, the text none of it
533		assert!(sink.truncated());
534		assert_eq!(sink.into_string(), "árvíz");
535
536		// Same for a prefix that cannot fit either.
537		let mut sink = TextSink::new(8);
538		sink.push("", "árvíz"); // 5
539		sink.push("##", "tűrő"); // sep + 2 prefix chars = 8, no room for text
540		assert!(sink.truncated());
541		assert_eq!(sink.into_string(), "árvíz");
542	}
543
544	#[test]
545	fn depth_limit_stops_runaway_nesting() {
546		// 40 levels of single-element arrays around one string.
547		let mut doc = serde_json::json!("deep");
548		for _ in 0..40 {
549			doc = serde_json::json!([doc]);
550		}
551		let mut r = rule("");
552		r.max_depth = 4;
553		let mut sink = TextSink::new(1000);
554		extract_field(&doc, &r, &mut sink);
555		assert!(sink.truncated());
556		assert!(sink.is_empty());
557	}
558
559	#[test]
560	fn a_jsonpath_filter_selects_only_matching_nodes() {
561		// The ambiguity the module docs describe: a styled-text tuple and a table
562		// row look alike to the walk, but a filter names the prose nodes outright.
563		let doc = serde_json::json!({
564			"c": [
565				{ "t": "p", "text": "bevezető" },
566				{ "t": "img", "text": "kep.png", "l": "https://example.com" },
567				{ "t": "p", "text": "folytatás" }
568			]
569		});
570		let out = extract(&doc, &json_rule(serde_json::json!({ "field": "$.c[?@.t=='p'].text" })));
571		assert_eq!(out, "bevezető folytatás");
572	}
573
574	#[test]
575	fn string_mode_takes_the_node_verbatim_and_skips_non_strings() {
576		let doc = serde_json::json!({ "ti": "Cím", "c": ["nem", "ez"] });
577		let string_mode =
578			|field: &str| json_rule(serde_json::json!({ "field": field, "extract": "string" }));
579		assert_eq!(extract(&doc, &string_mode("ti")), "Cím");
580		// An array is not a string, so `string` mode yields nothing where `text`
581		// would have walked into it.
582		assert_eq!(extract(&doc, &string_mode("c")), "");
583		assert_eq!(extract(&doc, &rule("c")), "nem ez");
584	}
585
586	#[test]
587	fn the_node_cap_truncates_a_descendant_query() {
588		let items: Vec<serde_json::Value> = (0..MAX_JSONPATH_NODES + 10)
589			.map(|i| serde_json::json!(format!("t{i}")))
590			.collect();
591		let doc = serde_json::json!({ "c": items });
592		// Budget far past what the tokens need, so truncation can only come from
593		// the node cap and not from the sink running out.
594		let mut sink = TextSink::new(100_000_000);
595		extract_field(&doc, &json_rule(serde_json::json!({ "field": "$.c[*]" })), &mut sink);
596		assert!(sink.truncated(), "selecting past the node cap must report truncation");
597		assert!(!sink.is_empty(), "everything up to the cap must still be indexed");
598	}
599
600	#[test]
601	fn a_real_sized_spreadsheet_stays_inside_the_node_cap() {
602		// The case that made 1024 the wrong number: one match per non-empty cell.
603		// A 40×30 sheet is ordinary, and losing its text would be silent.
604		let mut rows = serde_json::Map::new();
605		for r in 0..40 {
606			let mut cols = serde_json::Map::new();
607			for c in 0..30 {
608				cols.insert(format!("c{c}"), serde_json::json!({ "v": format!("cella{r}x{c}") }));
609			}
610			rows.insert(format!("r{r}"), serde_json::Value::Object(cols));
611		}
612		let doc = serde_json::json!({ "rows": rows });
613		let mut sink = TextSink::new(1_000_000);
614		extract_field(&doc, &json_rule(serde_json::json!({ "path": "$.rows..v" })), &mut sink);
615		assert!(!sink.truncated(), "a 1200-cell sheet must index in full");
616		assert_eq!(sink.into_string().split_whitespace().count(), 40 * 30);
617	}
618
619	#[test]
620	fn resolve_str_reads_scalars_only() {
621		let doc = serde_json::json!({ "pp": "parent-id", "o": 3, "c": ["x"] });
622		assert_eq!(resolve_str(&doc, "pp").as_deref(), Some("parent-id"));
623		assert_eq!(resolve_str(&doc, "o").as_deref(), Some("3"));
624		assert_eq!(resolve_str(&doc, "c"), None);
625	}
626}
627
628// vim: ts=4