neutralts/
utils.rs

1use crate::constants::*;
2use serde_json::Value;
3use std::borrow::Cow;
4
5/// Merges two JSON schemas represented as `serde_json::Value`.
6///
7/// This function performs a recursive merge between two JSON objects.
8/// If an object has common keys, the values are merged recursively.
9/// If the value is not an object, it is directly overwritten.
10///
11/// # Arguments
12///
13/// * `a` - A mutable reference to the first JSON object (`serde_json::Value::Object`).
14/// * `b` - A reference to the second JSON object (`serde_json::Value::Object`) that will be merged with the first.
15///
16/// # Example
17///
18/// ```text
19/// use serde_json::{json, Value};
20///
21/// let mut schema1 = json!({
22///     "name": "John",
23///     "age": 30,
24/// });
25///
26/// let schema2 = json!({
27///     "age": 31,
28///     "city": "New York"
29/// });
30///
31/// merge_schema(&mut schema1, &schema2);
32/// assert_eq!(schema1, json!({
33///     "name": "John",
34///     "age": 31,
35///     "city": "New York"
36/// }));
37/// ```
38pub fn merge_schema(a: &mut Value, b: &Value) {
39    match (a, b) {
40        (Value::Object(a_map), Value::Object(b_map)) => {
41            for (k, v) in b_map {
42                if let Some(va) = a_map.get_mut(k) {
43                    merge_schema(va, v);
44                } else {
45                    a_map.insert(k.clone(), v.clone());
46                }
47            }
48        }
49        (a, b) => *a = b.clone(),
50    }
51}
52
53/// Same as merge_schema but takes ownership of `b` to avoid clones.
54/// Use this when you don't need `b` after the merge.
55pub fn merge_schema_owned(a: &mut Value, b: Value) {
56    match (a, b) {
57        (Value::Object(a_map), Value::Object(b_map)) => {
58            for (k, v) in b_map {
59                match a_map.entry(k) {
60                    serde_json::map::Entry::Occupied(mut entry) => {
61                        merge_schema_owned(entry.get_mut(), v);
62                    }
63                    serde_json::map::Entry::Vacant(entry) => {
64                        entry.insert(v);
65                    }
66                }
67            }
68        }
69        (a, b) => *a = b,
70    }
71}
72
73/// Merge schema and update some keys
74///
75/// This is a thin wrapper around `merge_schema` that additionally:
76/// 1. Copies the value of the header key `requested-with-ajax` (all lower-case) into the
77///    variants `Requested-With-Ajax` (Pascal-Case) and `REQUESTED-WITH-AJAX` (upper-case),
78///    or vice-versa, depending on which variant is present in the incoming schema.
79/// 2. Overwrites the top-level `version` field with the compile-time constant `VERSION`.
80///
81/// The three header variants are created so that downstream code can read the header
82/// regardless of the casing rules enforced by the environment (HTTP servers, proxies, etc.).
83///
84/// # Arguments
85/// * `a` – the target `Value` (must be an `Object`) that will receive the merge result.
86/// * `b` – the source `Value` (must be an `Object`) whose contents are merged into `a`.
87///
88pub fn update_schema(a: &mut Value, b: &Value) {
89    merge_schema(a, b);
90
91    // Different environments may ignore or add capitalization in headers
92    if let Some(headers) = a
93        .get_mut("data")
94        .and_then(|d| d.get_mut("CONTEXT"))
95        .and_then(|c| c.get_mut("HEADERS"))
96        .and_then(|h| h.as_object_mut())
97    {
98        if let Some(val) = headers.get("requested-with-ajax").cloned() {
99            headers.insert("Requested-With-Ajax".to_string(), val.clone());
100            headers.insert("REQUESTED-WITH-AJAX".to_string(), val);
101        } else if let Some(val) = headers.get("Requested-With-Ajax").cloned() {
102            headers.insert("requested-with-ajax".to_string(), val.clone());
103            headers.insert("REQUESTED-WITH-AJAX".to_string(), val);
104        } else if let Some(val) = headers.get("REQUESTED-WITH-AJAX").cloned() {
105            headers.insert("requested-with-ajax".to_string(), val.clone());
106            headers.insert("Requested-With-Ajax".to_string(), val);
107        }
108    }
109
110    // Update version
111    if let Some(obj) = a.as_object_mut() {
112        obj.insert("version".to_string(), VERSION.into());
113    } else {
114        a["version"] = VERSION.into();
115    }
116}
117
118/// Same as update_schema but takes ownership of `b` to avoid clones.
119pub fn update_schema_owned(a: &mut Value, b: Value) {
120    merge_schema_owned(a, b);
121
122    // Different environments may ignore or add capitalization in headers
123    if let Some(headers) = a
124        .get_mut("data")
125        .and_then(|d| d.get_mut("CONTEXT"))
126        .and_then(|c| c.get_mut("HEADERS"))
127        .and_then(|h| h.as_object_mut())
128    {
129        if let Some(val) = headers.get("requested-with-ajax").cloned() {
130            headers.insert("Requested-With-Ajax".to_string(), val.clone());
131            headers.insert("REQUESTED-WITH-AJAX".to_string(), val);
132        } else if let Some(val) = headers.get("Requested-With-Ajax").cloned() {
133            headers.insert("requested-with-ajax".to_string(), val.clone());
134            headers.insert("REQUESTED-WITH-AJAX".to_string(), val);
135        } else if let Some(val) = headers.get("REQUESTED-WITH-AJAX").cloned() {
136            headers.insert("requested-with-ajax".to_string(), val.clone());
137            headers.insert("Requested-With-Ajax".to_string(), val);
138        }
139    }
140
141    // Update version
142    if let Some(obj) = a.as_object_mut() {
143        obj.insert("version".to_string(), VERSION.into());
144    } else {
145        a["version"] = VERSION.into();
146    }
147}
148
149/// Extract same level blocks positions.
150///
151/// ```text
152///
153///                  .-----> .-----> {:code:
154///                  |       |           {:code: ... :}
155///                  |       |           {:code: ... :}
156///                  |       |           {:code: ... :}
157///  Level block --> |       ·-----> :}
158///                  |        -----> {:code: ... :}
159///                  |       .-----> {:code:
160///                  |       |           {:code: ... :}
161///                  ·-----> ·-----> :}
162///
163/// # Arguments
164///
165/// * `raw_source` - A string slice containing the template source text.
166///
167/// # Returns
168///
169/// * `Ok(Vec<(usize, usize)>)`: A vector of tuples representing the start and end positions of each extracted block.
170/// * `Err(usize)`: An error position if there are unmatched closing tags or other issues
171/// ```
172pub fn extract_blocks(raw_source: &str) -> Result<Vec<(usize, usize)>, usize> {
173    let mut blocks = Vec::new();
174    let mut curr_pos: usize = 0;
175    let len_src = raw_source.len();
176    let bytes = raw_source.as_bytes();
177
178    while let Some(pos) = raw_source[curr_pos..].find(BIF_OPEN) {
179        let open_pos = curr_pos + pos;
180        let start_body = open_pos + BIF_OPEN.len();
181        curr_pos = start_body;
182
183        if curr_pos < len_src && bytes[curr_pos] == BIF_COMMENT_B {
184            let mut nested_comment = 0;
185            let mut search_pos = curr_pos;
186            while let Some(delim_pos_rel) = raw_source[search_pos..].find(':') {
187                let delim_pos = search_pos + delim_pos_rel;
188                if delim_pos > 0 && delim_pos + 1 < len_src {
189                    let prev = bytes[delim_pos - 1];
190                    let next = bytes[delim_pos + 1];
191
192                    if prev == BIF_OPEN0 && next == BIF_COMMENT_B {
193                        nested_comment += 1;
194                        search_pos = delim_pos + 1;
195                        continue;
196                    }
197                    if nested_comment > 0 && prev == BIF_COMMENT_B && next == BIF_CLOSE1 {
198                        nested_comment -= 1;
199                        search_pos = delim_pos + 1;
200                        continue;
201                    }
202                    if prev == BIF_COMMENT_B && next == BIF_CLOSE1 {
203                        curr_pos = delim_pos + BIF_CLOSE.len();
204                        blocks.push((open_pos, curr_pos));
205                        break;
206                    }
207                }
208                search_pos = delim_pos + 1;
209            }
210        } else {
211            let mut nested = 0;
212            let mut search_pos = curr_pos;
213            while let Some(delim_pos_rel) = raw_source[search_pos..].find(':') {
214                let delim_pos = search_pos + delim_pos_rel;
215                if delim_pos > 0 && delim_pos + 1 < len_src {
216                    let prev = bytes[delim_pos - 1];
217                    let next = bytes[delim_pos + 1];
218
219                    if prev == BIF_OPEN0 {
220                        nested += 1;
221                        search_pos = delim_pos + 1;
222                        continue;
223                    }
224                    if nested > 0 && next == BIF_CLOSE1 {
225                        nested -= 1;
226                        search_pos = delim_pos + 1;
227                        continue;
228                    }
229                    if next == BIF_CLOSE1 {
230                        curr_pos = delim_pos + BIF_CLOSE.len();
231                        blocks.push((open_pos, curr_pos));
232                        break;
233                    }
234                }
235                search_pos = delim_pos + 1;
236            }
237        }
238    }
239
240    let mut prev_end = 0;
241    for (start, end) in &blocks {
242        if let Some(pos) = raw_source[prev_end..*start].find(BIF_CLOSE) {
243            return Err(prev_end + pos);
244        }
245        prev_end = *end;
246    }
247
248    if let Some(pos) = raw_source[prev_end..].find(BIF_CLOSE) {
249        return Err(prev_end + pos);
250    }
251
252    Ok(blocks)
253}
254
255/// Removes a prefix and suffix from a string slice.
256///
257/// # Arguments
258///
259/// * `str`: The input string slice.
260/// * `prefix`: The prefix to remove.
261/// * `suffix`: The suffix to remove.
262///
263/// # Returns
264///
265/// * A new string slice with the prefix and suffix removed, or the original string if not found.
266pub fn strip_prefix_suffix<'a>(str: &'a str, prefix: &'a str, suffix: &'a str) -> &'a str {
267    let start = match str.strip_prefix(prefix) {
268        Some(striped) => striped,
269        None => return str,
270    };
271    let end = match start.strip_suffix(suffix) {
272        Some(striped) => striped,
273        None => return str,
274    };
275
276    end
277}
278
279/// Retrieves a value from a JSON schema using a specified key.
280///
281/// # Arguments
282///
283/// * `schema`: A reference to the JSON schema as a `Value`.
284/// * `key`: The key used to retrieve the value from the schema.
285///
286/// # Returns
287///
288/// * A `String` containing the retrieved value, or an empty string if the key is not found.
289pub fn get_from_key(schema: &Value, key: &str) -> String {
290    if let Some(v) = resolve_pointer(schema, key) {
291        match v {
292            Value::Null => String::new(),
293            Value::Bool(b) => b.to_string(),
294            Value::Number(n) => n.to_string(),
295            Value::String(s) => s.clone(),
296            _ => String::new(),
297        }
298    } else {
299        String::new()
300    }
301}
302
303/// Checks if the value associated with a key in the schema is considered empty.
304///
305/// # Arguments
306///
307/// * `schema`: A reference to the JSON schema as a `Value`.
308/// * `key`: The key used to check the value in the schema.
309///
310/// # Returns
311///
312/// * `true` if the value is considered empty, otherwise `false`.
313pub fn is_empty_key(schema: &Value, key: &str) -> bool {
314    if let Some(value) = resolve_pointer(schema, key) {
315        match value {
316            Value::Object(map) => map.is_empty(),
317            Value::Array(arr) => arr.is_empty(),
318            Value::String(s) => s.is_empty(),
319            Value::Null => true,
320            Value::Number(_) => false,
321            Value::Bool(_) => false,
322        }
323    } else {
324        true
325    }
326}
327
328/// Checks if the value associated with a key in the schema is considered a boolean true.
329///
330/// # Arguments
331///
332/// * `schema`: A reference to the JSON schema as a `Value`.
333/// * `key`: The key used to check the value in the schema.
334///
335/// # Returns
336///
337/// * `true` if the value is considered a boolean true, otherwise `false`.
338pub fn is_bool_key(schema: &Value, key: &str) -> bool {
339    if let Some(value) = resolve_pointer(schema, key) {
340        match value {
341            Value::Object(obj) => !obj.is_empty(),
342            Value::Array(arr) => !arr.is_empty(),
343            Value::String(s) if s.is_empty() || s == "false" => false,
344            Value::String(s) => s.parse::<f64>().ok().map_or(true, |n| n > 0.0),
345            Value::Null => false,
346            Value::Number(n) => n.as_f64().map_or(false, |f| f > 0.0),
347            Value::Bool(b) => *b,
348        }
349    } else {
350        false
351    }
352}
353
354/// Checks if the value associated with a key in the schema is considered an array.
355///
356/// # Arguments
357///
358/// * `schema`: A reference to the JSON schema as a `Value`.
359/// * `key`: The key used to check the value in the schema.
360///
361/// # Returns
362///
363/// * `true` if the value is an array, otherwise `false`.
364pub fn is_array_key(schema: &Value, key: &str) -> bool {
365    if let Some(value) = resolve_pointer(schema, key) {
366        match value {
367            Value::Object(_) => true,
368            Value::Array(_) => true,
369            _ => false,
370        }
371    } else {
372        false
373    }
374}
375
376/// Checks if the value associated with a key in the schema is considered defined.
377///
378/// # Arguments
379///
380/// * `schema`: A reference to the JSON schema as a `Value`.
381/// * `key`: The key used to check the value in the schema.
382///
383/// # Returns
384///
385/// * `true` if the value is defined and not null, otherwise `false`.
386pub fn is_defined_key(schema: &Value, key: &str) -> bool {
387    match resolve_pointer(schema, key) {
388        Some(value) => !value.is_null(),
389        None => false,
390    }
391}
392
393/// Helper function to resolve a pointer-like key (e.g., "a->b->0") in a JSON Value.
394pub(crate) fn resolve_pointer<'a>(schema: &'a Value, key: &str) -> Option<&'a Value> {
395    if !key.contains(BIF_ARRAY) && !key.contains('/') {
396        return schema.get(key);
397    }
398
399    let mut current = schema;
400    let mut start = 0;
401    let bytes = key.as_bytes();
402    let len = bytes.len();
403
404    let bif_array_bytes = BIF_ARRAY.as_bytes();
405    let delim_len = bif_array_bytes.len();
406
407    let mut i = 0;
408    while i < len {
409        let is_slash = bytes[i] == b'/';
410        let is_arrow =
411            !is_slash && i + delim_len <= len && &bytes[i..i + delim_len] == bif_array_bytes;
412
413        if is_slash || is_arrow {
414            let part = &key[start..i];
415            if !part.is_empty() {
416                current = match current {
417                    Value::Object(map) => map.get(part)?,
418                    Value::Array(arr) => {
419                        let idx = part.parse::<usize>().ok()?;
420                        arr.get(idx)?
421                    }
422                    _ => return None,
423                };
424            }
425            if is_slash {
426                i += 1;
427                start = i;
428            } else {
429                i += delim_len;
430                start = i;
431            }
432        } else {
433            i += 1;
434        }
435    }
436
437    if start < len {
438        let part = &key[start..];
439        current = match current {
440            Value::Object(map) => map.get(part)?,
441            Value::Array(arr) => {
442                let idx = part.parse::<usize>().ok()?;
443                arr.get(idx)?
444            }
445            _ => return None,
446        };
447    }
448
449    Some(current)
450}
451
452/// Finds the position of the first occurrence of BIF_CODE_B in the source string,
453/// but only when it is not inside any nested brackets.
454///
455/// ```text
456///                   .------------------------------> params
457///                   |       .----------------------> this
458///                   |       |
459///                   |       |                 .----> code
460///                   |       |                 |
461///                   v       v                 v
462///              ------------ -- ------------------------------
463///  {:!snippet; snippet_name >> <div>... {:* ... *:} ...</div> :}
464pub fn get_code_position(src: &str) -> Option<usize> {
465    if !src.contains(BIF_CODE) {
466        return None;
467    }
468
469    let mut level = 0;
470    let bytes = src.as_bytes();
471    let len = bytes.len();
472    let mut i = 0;
473
474    while i + 1 < len {
475        let b0 = bytes[i];
476        let b1 = bytes[i + 1];
477
478        if b0 == BIF_OPEN_B[0] && b1 == BIF_OPEN_B[1] {
479            level += 1;
480            i += 2;
481        } else if b0 == BIF_CLOSE_B[0] && b1 == BIF_CLOSE_B[1] {
482            level -= 1;
483            i += 2;
484        } else if b0 == BIF_CODE_B[0] && b1 == BIF_CODE_B[1] {
485            if level == 0 {
486                return Some(i);
487            }
488            i += 2;
489        } else {
490            i += 1;
491        }
492    }
493
494    None
495}
496
497/// Removes comments from the template source.
498pub fn remove_comments(raw_source: &str) -> String {
499    let mut result = String::new();
500    let mut blocks = Vec::new();
501    let bytes = raw_source.as_bytes();
502    let mut curr_pos: usize = 0;
503    let mut open_pos: usize;
504    let mut nested_comment = 0;
505    let len_open = BIF_COMMENT_OPEN_B.len();
506    let len_close = BIF_CLOSE_B.len();
507    let len_src = bytes.len();
508
509    while let Some(rel_pos) = raw_source[curr_pos..].find(BIF_COMMENT_OPEN) {
510        let absolute_pos = curr_pos + rel_pos;
511        curr_pos = absolute_pos + len_open;
512        open_pos = absolute_pos;
513
514        while let Some(delim_pos_rel) = raw_source[curr_pos..].find(BIF_DELIM) {
515            curr_pos += delim_pos_rel;
516
517            if curr_pos >= len_src {
518                break;
519            }
520
521            if bytes[curr_pos - 1] == BIF_OPEN0 && bytes[curr_pos + 1] == BIF_COMMENT_B {
522                nested_comment += 1;
523                curr_pos += 1;
524                continue;
525            }
526            if nested_comment > 0
527                && bytes[curr_pos + 1] == BIF_CLOSE1
528                && bytes[curr_pos - 1] == BIF_COMMENT_B
529            {
530                nested_comment -= 1;
531                curr_pos += 1;
532                continue;
533            }
534            if bytes[curr_pos + 1] == BIF_CLOSE1 && bytes[curr_pos - 1] == BIF_COMMENT_B {
535                curr_pos += len_close;
536                blocks.push((open_pos, curr_pos));
537                break;
538            } else {
539                curr_pos += 1;
540            }
541        }
542    }
543
544    let mut prev_end = 0;
545    for (start, end) in &blocks {
546        result.push_str(&raw_source[prev_end..*start]);
547        prev_end = *end;
548    }
549    result.push_str(&raw_source[curr_pos..]);
550
551    result
552}
553
554/// Performs a wildcard matching between a text and a pattern.
555///
556/// Used in bif "allow" and "declare"
557///
558/// # Arguments
559///
560/// * `text`: The text to match against the pattern.
561/// * `pattern`: The pattern containing wildcards ('.', '?', '*', '~').
562///
563/// # Returns
564///
565/// * `true` if the text matches the pattern, otherwise `false`.
566pub fn wildcard_match(text: &str, pattern: &str) -> bool {
567    let text_chars: Vec<char> = text.chars().collect();
568    let pattern_chars: Vec<char> = pattern.chars().collect();
569
570    fn match_recursive(text: &[char], pattern: &[char]) -> bool {
571        if pattern.is_empty() {
572            return text.is_empty();
573        }
574
575        let first_char = *pattern.first().unwrap();
576        let rest_pattern = &pattern[1..];
577
578        match first_char {
579            '\\' => {
580                if rest_pattern.is_empty() || text.is_empty() {
581                    return false;
582                }
583                let escaped_char = rest_pattern.first().unwrap();
584                match_recursive(&text[1..], &rest_pattern[1..])
585                    && *text.first().unwrap() == *escaped_char
586            }
587            '.' => {
588                match_recursive(text, rest_pattern)
589                    || (!text.is_empty() && match_recursive(&text[1..], rest_pattern))
590            }
591            '?' => !text.is_empty() && match_recursive(&text[1..], rest_pattern),
592            '*' => {
593                match_recursive(text, rest_pattern)
594                    || (!text.is_empty() && match_recursive(&text[1..], pattern))
595            }
596            '~' => text.is_empty(),
597            _ => {
598                if text.is_empty() || first_char != *text.first().unwrap() {
599                    false
600                } else {
601                    match_recursive(&text[1..], rest_pattern)
602                }
603            }
604        }
605    }
606
607    match_recursive(&text_chars, &pattern_chars)
608}
609
610/// Finds the position of a tag in the text.
611///
612/// It is used in the bif "moveto".
613///
614/// # Arguments
615///
616/// * `text`: The text to search for the tag.
617/// * `tag`: The tag to find.
618///
619/// # Returns
620///
621/// * `Some(usize)`: The position of the end of the tag, or None if the tag is not found.
622pub fn find_tag_position(text: &str, tag: &str) -> Option<usize> {
623    if let Some(start_pos) = text.find(tag) {
624        if !tag.starts_with("</") {
625            if let Some(end_tag_pos) = text[start_pos..].find('>') {
626                return Some(start_pos + end_tag_pos + 1);
627            }
628        } else {
629            return Some(start_pos);
630        }
631    }
632
633    None
634}
635
636/// Escapes special characters in a given input string.
637///
638/// This function replaces specific ASCII characters with their corresponding HTML entities.
639/// It is designed to handle both general HTML escaping and optional escaping of curly braces (`{` and `}`).
640///
641/// # Arguments
642///
643/// * `input` - The input string to escape.
644/// * `escape_braces` - A boolean flag indicating whether to escape curly braces (`{` and `}`).
645///   - If `true`, curly braces are escaped as `&#123;` and `&#125;`.
646///   - If `false`, curly braces are left unchanged.
647///
648/// # Escaped Characters
649///
650/// The following characters are always escaped:
651/// - `&` → `&amp;`
652/// - `<` → `&lt;`
653/// - `>` → `&gt;`
654/// - `"` → `&quot;`
655/// - `'` → `&#x27;`
656/// - `/` → `&#x2F;`
657///
658/// If `escape_braces` is `true`, the following characters are also escaped:
659/// - `{` → `&#123;`
660/// - `}` → `&#125;`
661///
662/// # Examples
663///
664/// Basic usage without escaping curly braces:
665/// ```text
666/// let input = r#"Hello, <world> & "friends"! {example}"#;
667/// let escaped = escape_chars(input, false);
668/// assert_eq!(escaped, r#"Hello, &lt;world&gt; &amp; &quot;friends&quot;! {example}"#);
669/// ```
670///
671/// Escaping curly braces:
672/// ```text
673/// let input = r#"Hello, <world> & "friends"! {example}"#;
674/// let escaped = escape_chars(input, true);
675/// assert_eq!(escaped, r#"Hello, &lt;world&gt; &amp; &quot;friends&quot;! &#123;example&#125;"#);
676/// ```
677pub fn escape_chars<'a>(input: &'a str, escape_braces: bool) -> Cow<'a, str> {
678    let needs_escape = input.chars().any(|c| match c {
679        '&' | '<' | '>' | '"' | '\'' | '/' => true,
680        '{' | '}' if escape_braces => true,
681        _ => false,
682    });
683
684    if !needs_escape {
685        return Cow::Borrowed(input);
686    }
687
688    let mut result = String::with_capacity(input.len() * 2);
689
690    for c in input.chars() {
691        if c.is_ascii() {
692            match c {
693                '&' => result.push_str("&amp;"),
694                '<' => result.push_str("&lt;"),
695                '>' => result.push_str("&gt;"),
696                '"' => result.push_str("&quot;"),
697                '\'' => result.push_str("&#x27;"),
698                '/' => result.push_str("&#x2F;"),
699                '{' if escape_braces => result.push_str("&#123;"),
700                '}' if escape_braces => result.push_str("&#125;"),
701                _ => result.push(c),
702            }
703        } else {
704            result.push(c);
705        }
706    }
707    Cow::Owned(result)
708}
709
710/// Unescapes HTML entities in a given input string.
711///
712/// This function is designed specifically to reverse the escaping performed by `escape_chars`.
713/// It is not intended to be a general-purpose HTML decoder. It replaces the following HTML
714/// entities with their corresponding characters:
715/// - `&amp;` → `&`
716/// - `&lt;` → `<`
717/// - `&gt;` → `>`
718/// - `&quot;` → `"`
719/// - `&#x27;` → `'`
720/// - `&#x2F;` → `/`
721///
722/// If `escape_braces` is `true`, it also replaces:
723/// - `&#123;` → `{`
724/// - `&#125;` → `}`
725///
726/// If an unrecognized entity is encountered, it is left unchanged in the output.
727///
728/// # Arguments
729///
730/// * `input` - The input string containing HTML entities to unescape.
731/// * `escape_braces` - A boolean flag indicating whether to unescape curly braces (`{` and `}`).
732///   - If `true`, `&#123;` and `&#125;` are unescaped to `{` and `}`.
733///   - If `false`, `&#123;` and `&#125;` are left unchanged.
734///
735/// # Examples
736///
737/// Basic usage:
738/// ```text
739/// let input = "&lt;script&gt;alert(&quot;Hello &amp; &#x27;World&#x27;&quot;);&lt;/script&gt;";
740/// let unescaped = unescape_chars(input, false);
741/// assert_eq!(unescaped, r#"<script>alert("Hello & 'World'");</script>"#);
742/// ```
743///
744/// Unescaping curly braces:
745/// ```text
746/// let input = "&#123;example&#125;";
747/// let unescaped = unescape_chars(input, true);
748/// assert_eq!(unescaped, "{example}");
749/// ```
750///
751/// Unrecognized entities are preserved:
752/// ```text
753/// let input = "This is an &unknown; entity.";
754/// let unescaped = unescape_chars(input, false);
755/// assert_eq!(unescaped, "This is an &unknown; entity.");
756/// ```
757pub fn unescape_chars<'a>(input: &'a str, escape_braces: bool) -> Cow<'a, str> {
758    if !input.contains('&') {
759        return Cow::Borrowed(input);
760    }
761    let mut result = String::with_capacity(input.len());
762    let mut chars = input.chars().peekable();
763    while let Some(c) = chars.next() {
764        if c == '&' {
765            let mut entity = String::new();
766            let mut has_semicolon = false;
767            while let Some(&next_char) = chars.peek() {
768                if next_char == ';' {
769                    chars.next();
770                    has_semicolon = true;
771                    break;
772                }
773                entity.push(chars.next().unwrap());
774            }
775            match (entity.as_str(), has_semicolon) {
776                ("amp", true) => result.push('&'),
777                ("lt", true) => result.push('<'),
778                ("gt", true) => result.push('>'),
779                ("quot", true) => result.push('"'),
780                ("#x27", true) => result.push('\''),
781                ("#x2F", true) => result.push('/'),
782                ("#123", true) if escape_braces => result.push('{'),
783                ("#125", true) if escape_braces => result.push('}'),
784                _ => {
785                    result.push('&');
786                    result.push_str(&entity);
787                    if has_semicolon {
788                        result.push(';');
789                    }
790                }
791            }
792        } else {
793            result.push(c);
794        }
795    }
796    Cow::Owned(result)
797}
798
799/// Recursively filter a Value with the function escape_chars
800///
801/// # Arguments
802/// * `value` - A mutable reference to a JSON `Value`. It can be a string (`String`),
803///             an object (`Object`), or an array (`Array`).
804///
805pub fn filter_value(value: &mut Value) {
806    match value {
807        Value::String(s) => {
808            // First unescape, then escape - only allocate if changes are needed
809            let unescaped = unescape_chars(s, true);
810            let processed = match unescaped {
811                Cow::Borrowed(_) => escape_chars(s, true),
812                Cow::Owned(ref u) => escape_chars(u, true),
813            };
814            if let Cow::Owned(new_s) = processed {
815                *s = new_s;
816            }
817        }
818        Value::Object(obj) => {
819            for v in obj.values_mut() {
820                filter_value(v);
821            }
822        }
823        Value::Array(arr) => {
824            for item in arr.iter_mut() {
825                filter_value(item);
826            }
827        }
828        _ => {}
829    }
830}
831
832/// Recursively filters the keys (names) of a Value with the function escape_chars
833///
834/// # Arguments
835/// * `value` - A mutable reference to a JSON `Value`. It can be a string (`String`),
836///             an object (`Object`), or an array (`Array`).
837///
838pub fn filter_value_keys(value: &mut Value) {
839    match value {
840        Value::Object(obj) => {
841            // Check if any key needs escaping
842            let needs_change = obj.keys().any(|k| {
843                k.contains('&')
844                    || k.chars()
845                        .any(|c| matches!(c, '&' | '<' | '>' | '"' | '\'' | '/' | '{' | '}'))
846            });
847
848            if !needs_change {
849                // No key changes needed, just recurse into values
850                for val in obj.values_mut() {
851                    filter_value_keys(val);
852                }
853                return;
854            }
855
856            // Keys need changes, create new Map with escaped keys
857            let mut new_obj = serde_json::Map::with_capacity(obj.len());
858            for (key, val) in obj.iter_mut() {
859                let unescaped = unescape_chars(key, true);
860                let processed = match unescaped {
861                    Cow::Borrowed(_) => escape_chars(key, true),
862                    Cow::Owned(ref u) => escape_chars(u, true),
863                };
864                let new_key = match processed {
865                    Cow::Borrowed(b) => b.to_string(),
866                    Cow::Owned(o) => o,
867                };
868                filter_value_keys(val);
869                new_obj.insert(new_key, std::mem::take(val));
870            }
871            *obj = new_obj;
872        }
873        Value::Array(arr) => {
874            for item in arr.iter_mut() {
875                filter_value_keys(item);
876            }
877        }
878        _ => {}
879    }
880}