1use serde_json::Value;
40
41use crate::{
42 prelude::*,
43 rules::{ExtractMode, FieldRule, MAX_JSONPATH_NODES, Selector},
44};
45
46#[derive(Debug)]
52pub struct TextSink {
53 buf: String,
54 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 pub fn len_chars(&self) -> usize {
76 self.len
77 }
78
79 pub fn truncated(&self) -> bool {
80 self.truncated
81 }
82
83 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 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 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 let room = left.saturating_sub(sep + prefix_len);
123 if room > 0 {
124 self.push_sep(sep);
125 self.buf.push_str(prefix);
126 self.buf.extend(text.chars().take(room));
129 self.len += prefix_len + room;
130 }
131 self.truncated = true;
132 }
133 }
134
135 fn push_sep(&mut self, sep: usize) {
137 if sep == 1 {
138 self.buf.push(' ');
139 self.len += 1;
140 }
141 }
142}
143
144pub 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
176fn 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 ExtractMode::String => {
183 if let Value::String(s) = value {
184 sink.push(&rule.prefix, s);
185 }
186 }
187 }
188}
189
190pub fn extract_fields(doc: &Value, rules: &[FieldRule], sink: &mut TextSink) {
192 for rule in rules {
193 extract_field(doc, rule, sink);
194 }
195}
196
197pub 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
213pub 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
226fn 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 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 Value::Array(items) => {
260 for item in items {
263 walk(item, rule, key, prefix, depth - 1, sink);
264 }
265 }
266 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 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 assert_eq!(extract(&doc, &rule("c")), "Hello world b https://example.com link text");
314 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 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 assert_eq!(out, "bevétel árbevétel");
336 }
337
338 #[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 assert_eq!(
358 extract(&doc, &json_rule(serde_json::json!({ "path": "c", "keys": ["wt"] }))),
359 "csupasz Oldalcím"
360 );
361 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 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 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 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 const BUDGET: usize = 20;
514 let mut sink = TextSink::new(BUDGET);
515 sink.push("", "áéíóú"); sink.push("#", "őű"); sink.push("", "árvíztűrő"); sink.push("", "túl"); 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 let mut sink = TextSink::new(6);
531 sink.push("", "árvíz"); sink.push("", "tűrő"); assert!(sink.truncated());
534 assert_eq!(sink.into_string(), "árvíz");
535
536 let mut sink = TextSink::new(8);
538 sink.push("", "árvíz"); sink.push("##", "tűrő"); assert!(sink.truncated());
541 assert_eq!(sink.into_string(), "árvíz");
542 }
543
544 #[test]
545 fn depth_limit_stops_runaway_nesting() {
546 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 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 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 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 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