use serde_json::Value;
use crate::{
prelude::*,
rules::{ExtractMode, FieldRule, MAX_JSONPATH_NODES, Selector},
};
#[derive(Debug)]
pub struct TextSink {
buf: String,
len: usize,
budget: usize,
truncated: bool,
}
impl TextSink {
pub fn new(budget: usize) -> Self {
Self { buf: String::new(), len: 0, budget, truncated: false }
}
pub fn is_empty(&self) -> bool {
self.buf.is_empty()
}
pub fn len_chars(&self) -> usize {
self.len
}
pub fn truncated(&self) -> bool {
self.truncated
}
pub fn remaining(&self) -> usize {
self.budget.saturating_sub(self.len)
}
pub fn into_string(self) -> String {
self.buf
}
fn push(&mut self, prefix: &str, text: &str) {
let text = text.trim();
if text.is_empty() {
return;
}
let sep = usize::from(!self.buf.is_empty());
let prefix_len = prefix.chars().count();
let text_len = text.chars().count();
let want = sep + prefix_len + text_len;
let left = self.remaining();
if left <= sep {
self.truncated = true;
return;
}
if want <= left {
self.push_sep(sep);
self.buf.push_str(prefix);
self.buf.push_str(text);
self.len += prefix_len + text_len;
} else {
let room = left.saturating_sub(sep + prefix_len);
if room > 0 {
self.push_sep(sep);
self.buf.push_str(prefix);
self.buf.extend(text.chars().take(room));
self.len += prefix_len + room;
}
self.truncated = true;
}
}
fn push_sep(&mut self, sep: usize) {
if sep == 1 {
self.buf.push(' ');
self.len += 1;
}
}
}
pub fn extract_field(doc: &Value, rule: &FieldRule, sink: &mut TextSink) {
match &rule.selector {
Selector::Dotted(path) => {
let Some(value) = resolve_path(doc, path) else { return };
emit(value, rule, sink);
}
Selector::JsonPath(query) => {
let nodes = match jsonpath_rust::query::js_path_process(query, doc) {
Ok(nodes) => nodes,
Err(e) => {
warn!(error = %e, "Search extraction: JSONPath query failed");
return;
}
};
if nodes.len() > MAX_JSONPATH_NODES {
sink.truncated = true;
}
for node in nodes.into_iter().take(MAX_JSONPATH_NODES) {
emit(node.val(), rule, sink);
}
}
}
}
fn emit(value: &Value, rule: &FieldRule, sink: &mut TextSink) {
match rule.mode {
ExtractMode::Text => walk(value, rule, None, &rule.prefix, rule.max_depth, sink),
ExtractMode::String => {
if let Value::String(s) = value {
sink.push(&rule.prefix, s);
}
}
}
}
pub fn extract_fields(doc: &Value, rules: &[FieldRule], sink: &mut TextSink) {
for rule in rules {
extract_field(doc, rule, sink);
}
}
pub fn resolve_path<'a>(doc: &'a Value, path: &[String]) -> Option<&'a Value> {
let mut cur = doc;
for segment in path {
cur = match cur {
Value::Object(map) => map.get(segment)?,
Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(cur)
}
pub fn resolve_str(doc: &Value, path: &str) -> Option<String> {
let segments: Vec<String> =
path.split('.').filter(|s| !s.is_empty()).map(ToOwned::to_owned).collect();
match resolve_path(doc, &segments)? {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn walk(
value: &Value,
rule: &FieldRule,
key: Option<&str>,
prefix: &str,
depth: usize,
sink: &mut TextSink,
) {
if depth == 0 || sink.remaining() == 0 {
if depth == 0 {
sink.truncated = true;
}
return;
}
match value {
Value::String(s) => {
if rule.keys.is_empty()
|| key.is_none_or(|k| rule.keys.iter().any(|allowed| allowed.as_str() == k))
{
sink.push(prefix, s);
}
}
Value::Array(items) => {
for item in items {
walk(item, rule, key, prefix, depth - 1, sink);
}
}
Value::Object(map) => {
for (child_key, child) in map {
if rule.exclude_keys.iter().any(|k| k == child_key) {
continue;
}
let child_prefix =
rule.prefix_keys.get(child_key).map_or(prefix, std::string::String::as_str);
walk(child, rule, Some(child_key), child_prefix, depth - 1, sink);
}
}
Value::Number(_) | Value::Bool(_) | Value::Null => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(field: &str) -> FieldRule {
FieldRule::dotted(field)
}
fn json_rule(json: serde_json::Value) -> FieldRule {
serde_json::from_value::<crate::rules::RawField>(json)
.expect("field shape")
.validate()
.expect("valid field rule")
}
fn extract(doc: &serde_json::Value, rule: &FieldRule) -> String {
let mut sink = TextSink::new(10_000);
extract_field(doc, rule, &mut sink);
sink.into_string()
}
#[test]
fn collects_string_leaves_from_nested_structures() {
let doc = serde_json::json!({
"c": ["Hello", ["world", "b"], { "l": "https://example.com", "c": "link text" }]
});
assert_eq!(extract(&doc, &rule("c")), "Hello world b https://example.com link text");
assert_eq!(
extract(&doc, &json_rule(serde_json::json!({ "path": "c", "keys": [] }))),
"Hello world b https://example.com link text"
);
}
#[test]
fn keys_gate_string_leaves_under_dynamic_object_keys() {
let doc = serde_json::json!({
"rows": { "r1": { "c1": {
"v": "bevétel",
"f": "=SUM(A1:A9)",
"bg": "#ff0000",
"ct": { "t": "n", "fa": "General", "s": [{ "v": "árbevétel", "ff": "Arial" }] }
} } }
});
let out = extract(&doc, &json_rule(serde_json::json!({ "path": "rows", "keys": ["v"] })));
assert_eq!(out, "bevétel árbevétel");
}
#[test]
fn object_leaves_follow_source_order() {
let doc = serde_json::json!({ "c": { "b": "második", "a": "első" } });
assert_eq!(
extract(&doc, &rule("c")),
"második első",
"serde_json/preserve_order is off — cloudillo-search's Cargo.toml must keep it; \
check with `cargo tree -p cloudillo-search -e features -i serde_json`"
);
}
#[test]
fn keys_keep_strings_that_have_no_enclosing_key() {
let doc = serde_json::json!({ "c": ["csupasz", { "wt": "Oldalcím" }], "ti": "Cím" });
assert_eq!(
extract(&doc, &json_rule(serde_json::json!({ "path": "c", "keys": ["wt"] }))),
"csupasz Oldalcím"
);
assert_eq!(
extract(&doc, &json_rule(serde_json::json!({ "path": "ti", "keys": ["wt"] }))),
"Cím"
);
}
#[test]
fn keys_survive_tables_and_nested_links() {
let doc = serde_json::json!({
"c": { "type": "tableContent",
"rows": [{ "cells": [
{ "pr": { "backgroundColor": "#ff0000" },
"c": ["Alma", ["Szia", "b"],
{ "l": "https://pelda.hu", "c": ["hivatkozás"] }] },
{ "c": ["Körte"] }
] }] }
});
let out = extract(
&doc,
&json_rule(serde_json::json!({ "path": "c", "keys": ["c", "cells", "wt"] })),
);
for text in ["Alma", "Körte", "hivatkozás"] {
assert!(out.contains(text), "missing {text} in {out}");
}
for noise in ["tableContent", "pelda.hu", "#ff0000"] {
assert!(!out.contains(noise), "{noise} must not be indexed: {out}");
}
assert!(out.split_whitespace().any(|t| t == "b"), "got {out}");
}
#[test]
fn exclude_keys_win_over_keys() {
let doc = serde_json::json!({ "x": { "drop": { "c": "nem" }, "keep": { "c": "igen" } } });
let out = extract(
&doc,
&json_rule(serde_json::json!({ "path": "x", "keys": ["c"], "excludeKeys": ["drop"] })),
);
assert_eq!(out, "igen");
}
#[test]
fn the_empty_object_key_is_gated_like_any_other() {
let doc = serde_json::json!({ "": "üres", "c": "tartalom" });
assert_eq!(
extract(&doc, &json_rule(serde_json::json!({ "path": "", "keys": ["c"] }))),
"tartalom"
);
assert_eq!(
extract(&doc, &json_rule(serde_json::json!({ "path": "", "keys": [""] }))),
"üres"
);
}
#[test]
fn keys_are_inert_in_string_mode() {
let doc = serde_json::json!({ "ti": "Cím" });
let out = extract(
&doc,
&json_rule(
serde_json::json!({ "path": "ti", "extract": "string", "keys": ["nincs-ilyen"] }),
),
);
assert_eq!(out, "Cím", "string mode takes the node verbatim, allowlist or not");
}
#[test]
fn a_prefix_key_outside_the_allowlist_emits_nothing() {
let doc = serde_json::json!({ "c": [{ "tg": "projekt" }, "sima"] });
let out = extract(
&doc,
&json_rule(
serde_json::json!({ "path": "c", "keys": ["c"], "prefixKeys": { "tg": "#" } }),
),
);
assert_eq!(out, "sima");
}
#[test]
fn a_constant_prefix_applies_in_both_extract_modes() {
let doc = serde_json::json!({ "c": [{ "tg": "projekt" }, { "tg": "jegyzet" }] });
assert_eq!(
extract(&doc, &json_rule(serde_json::json!({ "path": "c", "prefix": "#" }))),
"#projekt #jegyzet"
);
assert_eq!(
extract(
&doc,
&json_rule(
serde_json::json!({ "path": "$..tg", "extract": "string", "prefix": "#" })
)
),
"#projekt #jegyzet"
);
}
#[test]
fn exclude_keys_drop_whole_subtrees() {
let doc = serde_json::json!({
"c": [{ "l": "https://example.com", "c": "link text" }, { "tc": "#ff0000" }]
});
let mut r = rule("c");
r.exclude_keys = vec!["l".into(), "tc".into()];
assert_eq!(extract(&doc, &r), "link text");
}
#[test]
fn prefix_keys_turn_tag_nodes_into_hash_tokens() {
let doc = serde_json::json!({ "c": [{ "tg": "projekt" }, "plain"] });
let mut r = rule("c");
r.prefix_keys.insert("tg".into(), "#".into());
let out = extract(&doc, &r);
assert!(out.contains("#projekt"), "got {out}");
assert!(out.contains("plain"));
}
#[test]
fn numbers_and_booleans_are_not_indexed() {
let doc = serde_json::json!({ "c": ["text", 42, true, null] });
assert_eq!(extract(&doc, &rule("c")), "text");
}
#[test]
fn a_missing_path_yields_nothing() {
let doc = serde_json::json!({ "c": "text" });
assert_eq!(extract(&doc, &rule("nope.deeper")), "");
}
#[test]
fn budget_truncates_on_a_char_boundary() {
let doc = serde_json::json!({ "c": ["áéíóú", "második"] });
let mut sink = TextSink::new(8);
extract_field(&doc, &rule("c"), &mut sink);
assert!(sink.truncated());
let out = sink.into_string();
assert!(out.chars().count() <= 8, "got {out}");
assert!(out.starts_with("áéíóú"));
}
#[test]
fn sink_length_tracks_the_buffer() {
const BUDGET: usize = 20;
let mut sink = TextSink::new(BUDGET);
sink.push("", "áéíóú"); sink.push("#", "őű"); sink.push("", "árvíztűrő"); sink.push("", "túl"); assert!(sink.truncated());
let remaining = sink.remaining();
let out = sink.into_string();
assert_eq!(remaining, BUDGET - out.chars().count(), "got {out:?}");
}
#[test]
fn a_truncating_push_never_leaves_a_trailing_separator() {
let mut sink = TextSink::new(6);
sink.push("", "árvíz"); sink.push("", "tűrő"); assert!(sink.truncated());
assert_eq!(sink.into_string(), "árvíz");
let mut sink = TextSink::new(8);
sink.push("", "árvíz"); sink.push("##", "tűrő"); assert!(sink.truncated());
assert_eq!(sink.into_string(), "árvíz");
}
#[test]
fn depth_limit_stops_runaway_nesting() {
let mut doc = serde_json::json!("deep");
for _ in 0..40 {
doc = serde_json::json!([doc]);
}
let mut r = rule("");
r.max_depth = 4;
let mut sink = TextSink::new(1000);
extract_field(&doc, &r, &mut sink);
assert!(sink.truncated());
assert!(sink.is_empty());
}
#[test]
fn a_jsonpath_filter_selects_only_matching_nodes() {
let doc = serde_json::json!({
"c": [
{ "t": "p", "text": "bevezető" },
{ "t": "img", "text": "kep.png", "l": "https://example.com" },
{ "t": "p", "text": "folytatás" }
]
});
let out = extract(&doc, &json_rule(serde_json::json!({ "field": "$.c[?@.t=='p'].text" })));
assert_eq!(out, "bevezető folytatás");
}
#[test]
fn string_mode_takes_the_node_verbatim_and_skips_non_strings() {
let doc = serde_json::json!({ "ti": "Cím", "c": ["nem", "ez"] });
let string_mode =
|field: &str| json_rule(serde_json::json!({ "field": field, "extract": "string" }));
assert_eq!(extract(&doc, &string_mode("ti")), "Cím");
assert_eq!(extract(&doc, &string_mode("c")), "");
assert_eq!(extract(&doc, &rule("c")), "nem ez");
}
#[test]
fn the_node_cap_truncates_a_descendant_query() {
let items: Vec<serde_json::Value> = (0..MAX_JSONPATH_NODES + 10)
.map(|i| serde_json::json!(format!("t{i}")))
.collect();
let doc = serde_json::json!({ "c": items });
let mut sink = TextSink::new(100_000_000);
extract_field(&doc, &json_rule(serde_json::json!({ "field": "$.c[*]" })), &mut sink);
assert!(sink.truncated(), "selecting past the node cap must report truncation");
assert!(!sink.is_empty(), "everything up to the cap must still be indexed");
}
#[test]
fn a_real_sized_spreadsheet_stays_inside_the_node_cap() {
let mut rows = serde_json::Map::new();
for r in 0..40 {
let mut cols = serde_json::Map::new();
for c in 0..30 {
cols.insert(format!("c{c}"), serde_json::json!({ "v": format!("cella{r}x{c}") }));
}
rows.insert(format!("r{r}"), serde_json::Value::Object(cols));
}
let doc = serde_json::json!({ "rows": rows });
let mut sink = TextSink::new(1_000_000);
extract_field(&doc, &json_rule(serde_json::json!({ "path": "$.rows..v" })), &mut sink);
assert!(!sink.truncated(), "a 1200-cell sheet must index in full");
assert_eq!(sink.into_string().split_whitespace().count(), 40 * 30);
}
#[test]
fn resolve_str_reads_scalars_only() {
let doc = serde_json::json!({ "pp": "parent-id", "o": 3, "c": ["x"] });
assert_eq!(resolve_str(&doc, "pp").as_deref(), Some("parent-id"));
assert_eq!(resolve_str(&doc, "o").as_deref(), Some("3"));
assert_eq!(resolve_str(&doc, "c"), None);
}
}