tellaro-query-language 2.0.0

A flexible, human-friendly query language for searching and filtering structured data
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
//! Field accessor for nested field access in JSON-like data structures.
//!
//! Supports dot-notation field paths like `user.profile.name`.
//!
//! Also home to `field::hint` type-hint coercion ([`apply_type_hint`]), which
//! lives beside field access for the same reason it does in Python: a hint
//! describes what the field holds, and it is applied to the value the accessor
//! just produced.

use crate::error::{Result, TqlError};
use serde_json::Value as JsonValue;
use std::net::IpAddr;
use std::str::FromStr;

/// Access a field in a JSON value using dot notation
///
/// # Arguments
///
/// * `record` - The JSON record to access
/// * `field_path` - The field path (e.g., "user.profile.name")
///
/// # Returns
///
/// The field value if found, or None if the path doesn't exist
///
/// # Examples
///
/// ```ignore
/// use serde_json::json;
/// use tql::field_accessor::get_field;
///
/// let record = json!({
///     "user": {
///         "profile": {
///             "name": "John"
///         }
///     }
/// });
///
/// let value = get_field(&record, "user.profile.name").unwrap();
/// assert_eq!(value, Some(&json!("John")));
/// ```
pub fn get_field<'a>(record: &'a JsonValue, field_path: &str) -> Result<Option<&'a JsonValue>> {
    // Split the field path by dots
    let parts: Vec<&str> = field_path.split('.').collect();

    // Start with the root record
    let mut current = record;

    // Navigate through each path segment
    for part in parts {
        match current {
            JsonValue::Object(map) => {
                match map.get(part) {
                    Some(value) => current = value,
                    None => return Ok(None), // Field doesn't exist
                }
            }
            JsonValue::Array(arr) => {
                // If current is an array, try to parse part as index
                if let Ok(index) = part.parse::<usize>() {
                    match arr.get(index) {
                        Some(value) => current = value,
                        None => return Ok(None), // Index out of bounds
                    }
                } else {
                    // Not a valid index
                    return Ok(None);
                }
            }
            _ => {
                // Can't navigate further into non-object/non-array types
                return Ok(None);
            }
        }
    }

    Ok(Some(current))
}

/// Check if a field exists in a record
///
/// # Arguments
///
/// * `record` - The JSON record to check
/// * `field_path` - The field path (e.g., "user.profile.name")
///
/// # Returns
///
/// true if the field exists, false otherwise
pub fn field_exists(record: &JsonValue, field_path: &str) -> Result<bool> {
    Ok(get_field(record, field_path)?.is_some())
}

/// Get a field value as a specific type
///
/// # Arguments
///
/// * `record` - The JSON record to access
/// * `field_path` - The field path
///
/// # Returns
///
/// The field value converted to the requested type, or an error if conversion fails
pub fn get_field_as_string(record: &JsonValue, field_path: &str) -> Result<Option<String>> {
    match get_field(record, field_path)? {
        Some(JsonValue::String(s)) => Ok(Some(s.clone())),
        Some(JsonValue::Number(n)) => Ok(Some(n.to_string())),
        Some(JsonValue::Bool(b)) => Ok(Some(b.to_string())),
        Some(JsonValue::Null) => Ok(Some("null".to_string())),
        Some(_) => Ok(None), // Arrays and objects can't be converted to string directly
        None => Ok(None),
    }
}

/// Get a field value as an integer
pub fn get_field_as_i64(record: &JsonValue, field_path: &str) -> Result<Option<i64>> {
    match get_field(record, field_path)? {
        Some(JsonValue::Number(n)) => Ok(n.as_i64()),
        Some(JsonValue::String(s)) => Ok(s.parse::<i64>().ok()),
        Some(_) => Ok(None),
        None => Ok(None),
    }
}

/// Get a field value as a float
pub fn get_field_as_f64(record: &JsonValue, field_path: &str) -> Result<Option<f64>> {
    match get_field(record, field_path)? {
        Some(JsonValue::Number(n)) => Ok(n.as_f64()),
        Some(JsonValue::String(s)) => Ok(s.parse::<f64>().ok()),
        Some(_) => Ok(None),
        None => Ok(None),
    }
}

/// Get a field value as a boolean
pub fn get_field_as_bool(record: &JsonValue, field_path: &str) -> Result<Option<bool>> {
    match get_field(record, field_path)? {
        Some(JsonValue::Bool(b)) => Ok(Some(*b)),
        Some(JsonValue::String(s)) => {
            let lower = s.to_lowercase();
            match lower.as_str() {
                "true" | "yes" | "1" => Ok(Some(true)),
                "false" | "no" | "0" => Ok(Some(false)),
                _ => Ok(None),
            }
        }
        Some(JsonValue::Number(n)) => {
            if let Some(i) = n.as_i64() {
                Ok(Some(i != 0))
            } else {
                Ok(None)
            }
        }
        Some(_) => Ok(None),
        None => Ok(None),
    }
}

/// Get a field value as an array
pub fn get_field_as_array<'a>(
    record: &'a JsonValue,
    field_path: &str,
) -> Result<Option<&'a Vec<JsonValue>>> {
    match get_field(record, field_path)? {
        Some(JsonValue::Array(arr)) => Ok(Some(arr)),
        Some(_) => Ok(None),
        None => Ok(None),
    }
}

/// Set a field value in a record (mutable operation)
///
/// # Arguments
///
/// * `record` - The JSON record to modify
/// * `field_path` - The field path
/// * `value` - The new value to set
///
/// # Returns
///
/// Ok if successful, Error if the path can't be created
pub fn set_field(record: &mut JsonValue, field_path: &str, value: JsonValue) -> Result<()> {
    let parts: Vec<&str> = field_path.split('.').collect();

    if parts.is_empty() {
        return Err(TqlError::FieldError(format!(
            "Empty field path: {}",
            field_path
        )));
    }

    // Navigate to the parent object
    let mut current = record;
    for (i, part) in parts.iter().enumerate() {
        if i == parts.len() - 1 {
            // Last part - set the value
            match current {
                JsonValue::Object(map) => {
                    map.insert(part.to_string(), value);
                    return Ok(());
                }
                _ => {
                    return Err(TqlError::FieldError(format!(
                        "Cannot set field '{}' on non-object",
                        field_path
                    )));
                }
            }
        } else {
            // Navigate deeper, creating objects as needed
            match current {
                JsonValue::Object(map) => {
                    current = map
                        .entry(part.to_string())
                        .or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
                }
                _ => {
                    return Err(TqlError::FieldError(format!(
                        "Cannot navigate through non-object at '{}' in path '{}'",
                        part, field_path
                    )));
                }
            }
        }
    }

    Ok(())
}

// ===========================================================================
// Type hints (`field::hint`)
//
// TWO SETS, AND THEIR UNION MUST EQUAL THE GRAMMAR'S. That equality is asserted
// from both sides -- `tql/tests/type_hint_evaluation_tests.rs` scrapes
// `grammar.pest`, and Python's `tests/unit/test_type_hint_coercion.py` scrapes
// the same file -- because the failure mode when it drifts is silent in both
// directions:
//
//   * A hint the GRAMMAR accepts that no evaluator arm reads does NOTHING. That
//     was the state of ALL TWELVE Rust hints until this module existed:
//     `type_hint` was parsed, stored on the AST node, and read by no arm of
//     `evaluator.rs`, so `f::int eq 'Hello'` and `f::boolean eq 'Hello'` both
//     answered `true` -- identical to the un-hinted query. A user writing a hint
//     to constrain a comparison got no constraint and no error.
//   * A branch here that no hint can REACH is dead code that reads as a feature.
//     That is why `ip` is in the grammar now and not before: Python had a
//     complete `ip` branch -- validation, CIDR handling, its own message -- that
//     `f::ip` could not reach in either engine.
//
// Hence: no catch-all that returns the value unchanged. An unrecognised hint
// raises, so the next hint added to the grammar without a decision here is loud
// rather than inert.
//
// These are the Rust half of a cross-language contract. The Python half is
// `COERCING_TYPE_HINTS` / `STRUCTURAL_TYPE_HINTS` in
// `src/tql/evaluator_components/field_access.py`, and every conversion below was
// derived by RUNNING that implementation over a 22-value x 16-hint matrix rather
// than by reading it (see the module test `python_semantics_matrix`).
// ===========================================================================

/// Hints that convert the field value before comparison.
///
/// Sorted, because `unknown_type_hint_error` renders it into a message and a
/// message that reorders itself between builds is not diffable.
pub const COERCING_TYPE_HINTS: &[&str] = &[
    "bool", "boolean", "decimal", "double", "float", "int", "integer", "ip", "number", "str",
    "string",
];

/// Hints that assert a field's SHAPE and deliberately perform no value coercion.
///
/// Declared rather than defaulted -- each names why there is nothing to convert,
/// so "this hint does nothing" is a recorded decision instead of an omission.
/// Kept verbatim in step with the Python table of the same name.
pub const STRUCTURAL_TYPE_HINTS: &[(&str, &str)] = &[
    // The comparators already iterate array-valued fields, so wrapping a scalar
    // here would change which comparison arm runs, not just the value's type.
    (
        "array",
        "the comparators already iterate array-valued fields",
    ),
    (
        "date",
        "TQL has no date comparison semantics to coerce into",
    ),
    (
        "geo",
        "selects a geo-shaped field; no scalar conversion applies",
    ),
    ("list", "alias of `array`"),
    (
        "object",
        "selects an object-shaped field; no scalar conversion applies",
    ),
];

/// Every hint the grammar may produce, sorted. Pinned equal to the grammar by a test.
pub fn known_type_hints() -> Vec<&'static str> {
    let mut all: Vec<&'static str> = COERCING_TYPE_HINTS.to_vec();
    all.extend(STRUCTURAL_TYPE_HINTS.iter().map(|(name, _)| *name));
    all.sort_unstable();
    all
}

/// Render a JSON value the way Python's `str()` would.
///
/// Not cosmetic. What is defined in terms of it answers differently without it:
///
///   * `number` decides int-vs-float on whether `str(value)` contains a `.`,
///     which is how `42` becomes `42` and `42.0` stays `42.0`.
///   * Every coercion ERROR renders the offending value through this, and
///     `expected_error_contains` fixtures compare those strings ACROSS languages.
///     `str(True)` is `"True"`, so a bool that fails a hint must report `True`
///     here in both engines.
///
/// NOT used by the `string`/`str` hint any more. That arm renders a top-level
/// boolean as `true`/`false` -- see [`hint_str`] for the decision and its scope.
/// The two were the same function until 2026-09-03, and splitting them is what
/// keeps the lowercase spelling out of the error text above: `str(True)` is still
/// `"True"` HERE, on purpose.
///
/// `serde_json`'s own `Number` display is used for numbers precisely because it
/// renders an integral float as `42.0` (ryu always emits a point or an exponent)
/// where Rust's `{}` for `f64` renders it `42`, which is the Python spelling.
fn py_str(value: &JsonValue) -> String {
    match value {
        JsonValue::Null => "None".to_string(),
        JsonValue::Bool(true) => "True".to_string(),
        JsonValue::Bool(false) => "False".to_string(),
        JsonValue::Number(n) => n.to_string(),
        JsonValue::String(s) => s.clone(),
        JsonValue::Array(_) | JsonValue::Object(_) => py_repr(value),
    }
}

/// Render a JSON value the way Python's `repr()` would.
///
/// Only reachable for containers, and only from `py_str`. It exists so the error
/// text for `f::int eq 1` over `{"f": ["a","b"]}` reads
/// `... for field 'f': ['a', 'b']` in BOTH engines rather than
/// `... ["a","b"]` in one of them; `expected_error_contains` fixtures compare
/// those strings across languages.
///
/// Best-effort for exotic strings: Python's `repr` escapes non-printables with
/// `\xNN` / `\uNNNN` and this does not. Quoting choice and the common escapes
/// match, which covers every value a fixture can realistically carry.
fn py_repr(value: &JsonValue) -> String {
    match value {
        JsonValue::String(s) => {
            // Python prefers single quotes, switching to double only when the
            // string contains a single quote and no double quote.
            let (quote, escape_quote) = if s.contains('\'') && !s.contains('"') {
                ('"', false)
            } else {
                ('\'', true)
            };
            let mut out = String::with_capacity(s.len() + 2);
            out.push(quote);
            for ch in s.chars() {
                match ch {
                    '\\' => out.push_str("\\\\"),
                    '\n' => out.push_str("\\n"),
                    '\r' => out.push_str("\\r"),
                    '\t' => out.push_str("\\t"),
                    '\'' if escape_quote => out.push_str("\\'"),
                    other => out.push(other),
                }
            }
            out.push(quote);
            out
        }
        JsonValue::Array(items) => {
            let inner: Vec<String> = items.iter().map(py_repr).collect();
            format!("[{}]", inner.join(", "))
        }
        JsonValue::Object(map) => {
            let inner: Vec<String> = map
                .iter()
                .map(|(k, v)| format!("{}: {}", py_repr(&JsonValue::String(k.clone())), py_repr(v)))
                .collect();
            format!("{{{}}}", inner.join(", "))
        }
        // Scalars repr exactly as they str.
        other => py_str(other),
    }
}

/// The one error every failed coercion raises.
///
/// Python spells this `unconvertible()` and routes all eleven coercing hints
/// through it. Before that, each branch spelled its own message and seven of the
/// eleven raised nothing at all -- the message being shared is what makes "this
/// hint did not convert" a single observable event rather than eleven.
///
/// [`TqlError::TypeHintCoercion`], not [`TqlError::TypeError`]: the evaluator
/// SKIPS the record for this variant and aborts the query for an unknown hint
/// NAME, and those two are the only things this module raises. The rendered
/// message is byte-identical to what it was before the split.
fn unconvertible(what: &str, field_name: &str, value: &JsonValue) -> TqlError {
    TqlError::TypeHintCoercion(format!(
        "Cannot convert value to {} for field '{}': {}",
        what,
        field_name,
        py_str(value)
    ))
}

/// Python's `int(value)`, restricted to what a JSON value can hold.
///
/// Divergence, deliberate and bounded: Python integers are arbitrary precision
/// and `i64` is not. A decimal string wider than `i64` raises here and converts
/// in Python, and a float beyond `i64::MAX` saturates here. Both are outside the
/// range OpenSearch's `long` can store, so a record carrying one cannot round-trip
/// through the backend either way.
fn to_i64(value: &JsonValue) -> Option<i64> {
    match value {
        JsonValue::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f.trunc() as i64)),
        JsonValue::Bool(b) => Some(i64::from(*b)),
        // `trim()` because Python's `int()` accepts surrounding whitespace and
        // `str::parse` does not: `{"f": " 42 "}` converts in Python.
        JsonValue::String(s) => s.trim().parse::<i64>().ok(),
        _ => None,
    }
}

/// Python's `float(value)`, restricted to what a JSON value can hold.
fn to_f64(value: &JsonValue) -> Option<f64> {
    match value {
        JsonValue::Number(n) => n.as_f64(),
        JsonValue::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
        // See `to_i64` on `trim()`.
        JsonValue::String(s) => s.trim().parse::<f64>().ok(),
        _ => None,
    }
}

/// Python's `ipaddress.ip_network(value, strict=False)`, for the `cidr` arm only.
///
/// `strict=False` is what makes `10.0.0.1/8` acceptable -- host bits set. The
/// prefix bound is checked per family because `10.0.0.0/33` is a ValueError in
/// Python and `IpAddr::from_str` never sees the prefix at all.
fn is_ip_network(s: &str) -> bool {
    let Some((addr, prefix)) = s.split_once('/') else {
        // No prefix: Python accepts a bare address as a host network. Unreachable
        // in practice, since `apply_type_hint` only consults this after
        // `IpAddr::from_str` has already failed on the same string.
        return addr_ok(s);
    };
    let Ok(ip) = IpAddr::from_str(addr) else {
        return false;
    };
    let Ok(bits) = prefix.parse::<u8>() else {
        return false;
    };
    match ip {
        IpAddr::V4(_) => bits <= 32,
        IpAddr::V6(_) => bits <= 128,
    }
}

fn addr_ok(s: &str) -> bool {
    IpAddr::from_str(s).is_ok()
}

/// Apply a `field::hint` type hint to a field value.
///
/// A type hint is a READ INSTRUCTION, not an assertion. A value the hint cannot
/// read returns [`TqlError::TypeHintCoercion`] here, and `evaluate_comparison`
/// turns that into a SKIPPED record: the comparison answers `false`, so the
/// record matches nothing -- positively or negatively.
///
/// THE DECISION (product owner, 2026-09-03) and what it replaced. This returned
/// an error nothing caught, so ONE unreadable value aborted the WHOLE query:
/// `value::number > 75` over `[{v: 80}, {v: "abc"}, {v: 90}]` returned nothing
/// at all rather than 80 and 90. For a detection rule that means it stops firing
/// entirely instead of matching less -- the rule is not degraded, it is off, and
/// an empty result set is indistinguishable from a quiet network. The OpenSearch
/// execution path ignores type hints completely, so the same saved query already
/// answered two different ways depending on which backend ran it; skipping is
/// the answer the cluster path was giving all along.
///
/// STILL AN ERROR: an unknown hint NAME, returned as a plain
/// [`TqlError::TypeError`]. That is a defect in the query, or in a grammar that
/// gained a name nobody decided the semantics of -- the exact way all twelve
/// Rust hints shipped inert -- so it stays loud. The two are separated by
/// VARIANT, not by message text; the messages are deliberately unchanged.
///
/// Python mirrors both halves: `TQLTypeHintCoercionError` versus `TQLError`.
///
/// # Arguments
///
/// * `value` - the field value, AFTER any field mutators have run
/// * `type_hint` - the hint as parsed, already lowercased
/// * `field_name` - used only in error messages
/// * `operator` - the comparison operator; `ip` widens to accept CIDR under
///   `cidr` / `not_cidr`
pub fn apply_type_hint(
    value: &JsonValue,
    type_hint: &str,
    field_name: &str,
    operator: &str,
) -> Result<JsonValue> {
    // A field with no value is not a conversion failure. Python returns `None`
    // and `MISSING_FIELD` untouched for the same reason: the hint describes what
    // the field holds, and it holds nothing.
    if value.is_null() {
        return Ok(value.clone());
    }

    if STRUCTURAL_TYPE_HINTS
        .iter()
        .any(|(name, _)| *name == type_hint)
    {
        // Declared no-op, not a fallthrough. See STRUCTURAL_TYPE_HINTS.
        return Ok(value.clone());
    }

    match type_hint {
        "ip" => {
            let s = py_str(value);
            if addr_ok(&s) {
                return Ok(JsonValue::String(s));
            }
            if (operator == "cidr" || operator == "not_cidr") && is_ip_network(&s) {
                return Ok(JsonValue::String(s));
            }
            // Message deliberately distinct from `unconvertible` and identical to
            // Python's; callers and tests match on it. Same VARIANT as
            // `unconvertible`, though -- an address that will not parse is an
            // unreadable value, not a broken query.
            Err(TqlError::TypeHintCoercion(format!(
                "Invalid IP address format for field '{}': {}",
                field_name, s
            )))
        }
        "integer" | "int" => to_i64(value)
            .map(|i| JsonValue::Number(i.into()))
            .ok_or_else(|| unconvertible("integer", field_name, value)),
        "float" | "double" | "decimal" => {
            // `double` and `decimal` are spellings TQL accepts; both compare as
            // f64. There is no separate fixed-point comparison path to route to,
            // and silently doing nothing for two of the three spellings is what
            // this table exists to prevent.
            //
            // A NON-FINITE result is refused, and that is a PINNED divergence
            // rather than an oversight -- see `number_from_f64`.
            let f = to_f64(value).ok_or_else(|| unconvertible("float", field_name, value))?;
            number_from_f64(f).ok_or_else(|| unconvertible("float", field_name, value))
        }
        "number" => {
            // Integral where it can be, float otherwise, so `f::number eq 42`
            // matches the string "42" without turning 42.5 into 42. The `.`
            // test is Python's own and is what keeps a float-typed 42.0 a float.
            let f = to_f64(value).ok_or_else(|| unconvertible("number", field_name, value))?;
            let integral = f.is_finite() && f.fract() == 0.0 && !py_str(value).contains('.');
            let representable = f >= (i64::MIN as f64) && f <= (i64::MAX as f64);
            if integral && representable {
                Ok(JsonValue::Number((f as i64).into()))
            } else {
                number_from_f64(f).ok_or_else(|| unconvertible("number", field_name, value))
            }
        }
        "boolean" | "bool" => match value {
            JsonValue::Bool(b) => Ok(JsonValue::Bool(*b)),
            JsonValue::String(s) => {
                let lower = s.to_lowercase();
                if lower == "true" || s == "1" {
                    Ok(JsonValue::Bool(true))
                } else if lower == "false" || s == "0" {
                    Ok(JsonValue::Bool(false))
                } else {
                    Err(unconvertible("boolean", field_name, value))
                }
            }
            // A NUMBER does not convert. `{"f": 1}` with `f::bool` raises in
            // Python -- only the STRINGS "1"/"0" are accepted -- and the
            // asymmetry is deliberate on that side, so it is copied rather than
            // smoothed over.
            _ => Err(unconvertible("boolean", field_name, value)),
        },
        "string" | "str" => Ok(JsonValue::String(hint_str(value))),
        _ => {
            // NOT a fallthrough. Unreachable from the parser, which accepts only
            // `known_type_hints()` -- so reaching it means the grammar gained a
            // hint that no one decided the semantics of. Erroring is what stops
            // that hint from shipping as a silent no-op, which is how all twelve
            // Rust hints shipped.
            Err(unknown_type_hint_error(type_hint, field_name))
        }
    }
}

/// The error for a hint with no decision behind it. Split out so a test can
/// assert the fallthrough without needing an unparseable query to reach it.
pub fn unknown_type_hint_error(type_hint: &str, field_name: &str) -> TqlError {
    TqlError::TypeError(format!(
        "Unknown type hint '{}' for field '{}'. Known hints: {}",
        type_hint,
        field_name,
        known_type_hints().join(", ")
    ))
}

/// Render a value for the `string`/`str` HINT.
///
/// [`py_str`] everywhere except a top-level boolean, which renders `true`/`false`
/// rather than Python's `True`/`False`. Product decision, 2026-09-03, and the one
/// place in this module where the two engines deliberately do NOT reproduce
/// Python's `str()`.
///
/// WHY. `str(True)` is `"True"`, so `flag::string eq 'true'` was FALSE for every
/// boolean field. Python's `str()` is the only thing in this stack that writes
/// `True`: JSON writes `true`/`false`, TQL's own boolean literals are
/// `true`/`false`, and OpenSearch stores `true`/`false`. A user reading a record,
/// writing a query, or looking at an index sees the lowercase form everywhere and
/// then had to type the capitalised one here.
///
/// SCOPE, deliberately narrow, and the reason this is a separate function rather
/// than an edit to [`py_str`]:
///
/// * [`py_str`] and [`py_repr`] keep Python's rendering. They build the error
///   text that `expected_error_contains` fixtures compare ACROSS languages, and
///   Python's spelling is correct there.
/// * A bool nested in an array or object still renders `True` through
///   [`py_repr`]. Container rendering is a diagnostic artifact, not a value a
///   query compares against.
/// * `number` still decides int-vs-float on `py_str(value).contains('.')`, which
///   is unaffected -- neither "True" nor "true" contains a dot.
///
/// The Python twin is the `("string", "str")` arm of
/// `FieldAccessor.apply_type_hint`, and the matrix fixture records both.
fn hint_str(value: &JsonValue) -> String {
    match value {
        JsonValue::Bool(true) => "true".to_string(),
        JsonValue::Bool(false) => "false".to_string(),
        other => py_str(other),
    }
}

/// `serde_json` refuses to hold NaN/Infinity, which Python's `float()` produces
/// for the strings `"nan"` and `"inf"`. Those become a conversion failure rather
/// than a panic or a silent null.
/// A JSON number, or `None` when the value is not one.
///
/// # Pinned divergence: `inf` / `-inf` / `nan` under a numeric type hint
///
/// `serde_json::Number::from_f64` returns `None` for a non-finite `f64` because
/// JSON has no way to write one. There is no `JsonValue` to hand back, so the
/// hint reports the value unreadable and the record is skipped. Python's hint
/// path has no such constraint and converts it. Measured over
/// `[{num: "1.5"}, {inf: "inf"}, {nan: "nan"}, {small: "0.5"},
///   {Infinity: "Infinity"}, {txt: "abc"}]`:
///
/// ```text
///   f::float gt 1     Rust ["num"]           Python ["num", "inf", "Infinity"]
///   f::float exists   Rust ["num", "small"]  Python + inf, nan, Infinity
///   f::number gt 1    Rust ["num"]           Python ["num", "inf", "Infinity"]
/// ```
///
/// PINNED, not deferred, for three reasons:
///
/// 1. Closing it needs a value type that can carry a non-finite float through
///    the whole evaluator. That is an architectural change to `JsonValue`, not a
///    fix to this function.
/// 2. Rust's answer is the one the rest of the stack agrees with. Python's own
///    COMPARATOR refuses these spellings deliberately -- `_convert_numeric` in
///    `value_comparison.py` calls `math.isfinite` and names `"Inf"`,
///    `"Infinity"` and `"NaN"` as the reason -- so `f gt 1` and `f::float gt 1`
///    give different answers in Python for the same record. Rust is uniform:
///    `"inf"` is text that resembles a number, everywhere. (`finite` in
///    `comparator.rs` is the un-hinted half of that uniformity, and it was the
///    live defect: Rust used to read `"inf"` as a magnitude there.)
/// 3. OpenSearch cannot store one either, so no query pushed down can produce
///    the Python answer. Matching Python here would make the in-memory engine
///    disagree with the DSL it emits, which is the split every fix in this
///    campaign has been closing.
///
/// A `nan` is the sharper half: it compares false to everything including
/// itself, so reading one as a number would drop the record from a filter AND
/// from that filter's negation.
///
/// Pinned by `tql/tests/numeric_hint_non_finite_pin.rs`, which asserts Rust's
/// answers and records Python's alongside them, so the divergence is a recorded
/// decision rather than a latent one.
fn number_from_f64(f: f64) -> Option<JsonValue> {
    serde_json::Number::from_f64(f).map(JsonValue::Number)
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn test_get_field_simple() {
        let record = json!({
            "name": "John",
            "age": 30
        });

        let name = get_field(&record, "name").unwrap();
        assert_eq!(name, Some(&json!("John")));

        let age = get_field(&record, "age").unwrap();
        assert_eq!(age, Some(&json!(30)));
    }

    #[test]
    fn test_get_field_nested() {
        let record = json!({
            "user": {
                "profile": {
                    "name": "John",
                    "age": 30
                }
            }
        });

        let name = get_field(&record, "user.profile.name").unwrap();
        assert_eq!(name, Some(&json!("John")));
    }

    #[test]
    fn test_get_field_nonexistent() {
        let record = json!({
            "name": "John"
        });

        let result = get_field(&record, "nonexistent").unwrap();
        assert_eq!(result, None);

        let result = get_field(&record, "user.profile.name").unwrap();
        assert_eq!(result, None);
    }

    #[test]
    fn test_get_field_array_index() {
        let record = json!({
            "tags": ["rust", "tql", "parser"]
        });

        let tag = get_field(&record, "tags.1").unwrap();
        assert_eq!(tag, Some(&json!("tql")));
    }

    #[test]
    fn test_field_exists() {
        let record = json!({
            "user": {
                "name": "John"
            }
        });

        assert!(field_exists(&record, "user.name").unwrap());
        assert!(!field_exists(&record, "user.age").unwrap());
    }

    #[test]
    fn test_get_field_as_string() {
        let record = json!({
            "name": "John",
            "age": 30,
            "active": true
        });

        assert_eq!(
            get_field_as_string(&record, "name").unwrap(),
            Some("John".to_string())
        );
        assert_eq!(
            get_field_as_string(&record, "age").unwrap(),
            Some("30".to_string())
        );
        assert_eq!(
            get_field_as_string(&record, "active").unwrap(),
            Some("true".to_string())
        );
    }

    #[test]
    fn test_get_field_as_i64() {
        let record = json!({
            "age": 30,
            "count": "42"
        });

        assert_eq!(get_field_as_i64(&record, "age").unwrap(), Some(30));
        assert_eq!(get_field_as_i64(&record, "count").unwrap(), Some(42));
    }

    #[test]
    fn test_get_field_as_bool() {
        let record = json!({
            "active": true,
            "enabled": "yes",
            "disabled": "no"
        });

        assert_eq!(get_field_as_bool(&record, "active").unwrap(), Some(true));
        assert_eq!(get_field_as_bool(&record, "enabled").unwrap(), Some(true));
        assert_eq!(get_field_as_bool(&record, "disabled").unwrap(), Some(false));
    }

    #[test]
    fn test_set_field() {
        let mut record = json!({});

        set_field(&mut record, "name", json!("John")).unwrap();
        assert_eq!(get_field(&record, "name").unwrap(), Some(&json!("John")));

        set_field(&mut record, "user.profile.age", json!(30)).unwrap();
        assert_eq!(
            get_field(&record, "user.profile.age").unwrap(),
            Some(&json!(30))
        );
    }
}