Skip to main content

datalogic_rs/
config.rs

1//! Engine evaluation knobs: NaN/divbyzero handling, truthiness rules,
2//! numeric coercion, and the recursion-depth cap.
3//!
4//! The default configuration matches the JSONLogic reference behaviour
5//! (JavaScript-flavoured truthiness, NaN errors on bad arithmetic input,
6//! `±f64::MAX` on division by zero). Use [`EvaluationConfig::default`]
7//! and tweak from there, or pick [`EvaluationConfig::safe_arithmetic`] /
8//! [`EvaluationConfig::strict`] as alternative starting points. Apply
9//! via [`Engine::builder().with_config(...)`](crate::EngineBuilder::with_config).
10
11use datavalue::OwnedDataValue;
12use std::sync::Arc;
13
14/// Knobs that change how an [`Engine`](crate::Engine) treats edge cases
15/// during evaluation — non-numeric arguments to arithmetic, division by
16/// zero, loose equality across types, truthiness rules, numeric coercion,
17/// and a recursion-depth cap.
18///
19/// Construct via [`Self::default`] for JavaScript-flavoured semantics
20/// (matches the JSONLogic reference behaviour), or use
21/// [`Self::safe_arithmetic`] / [`Self::strict`] as starting points and
22/// tweak from there. Pass to the engine via
23/// [`Engine::builder().with_config(...)`](crate::EngineBuilder::with_config).
24///
25/// # Example
26///
27/// ```rust
28/// use datalogic_rs::{Engine, EvaluationConfig, NanHandling};
29///
30/// // Chainable setters — only one import needed beyond the enum value.
31/// let config = EvaluationConfig::default()
32///     .with_arithmetic_nan_handling(NanHandling::IgnoreValue);
33/// let engine = Engine::builder().with_config(config).build();
34///
35/// // "skipped" can't coerce to a number; with `IgnoreValue` the
36/// // arithmetic continues with the remaining operands.
37/// let result = engine.eval_str(r#"{"+": [1, "skipped", 2]}"#, "null").unwrap();
38/// assert_eq!(result, "3");
39/// ```
40///
41/// The struct is `#[non_exhaustive]` — fields can be added in 5.x
42/// without breaking downstream. Use [`Self::default`] (or the presets)
43/// followed by the `with_*` setters; struct-literal construction from
44/// outside the crate is intentionally not supported.
45#[derive(Clone, Debug)]
46#[non_exhaustive]
47pub struct EvaluationConfig {
48    /// What `+` / `-` / `*` / `/` / `%` (and the variadic `min` / `max`)
49    /// do when an argument can't be coerced to a number. Default:
50    /// [`NanHandling::ThrowError`] — return an `ErrorKind::Thrown`
51    /// carrying `{"type": "NaN"}`. The other variants let arithmetic
52    /// continue (skip the bad value, treat it as 0, or short-circuit
53    /// to `null`).
54    pub arithmetic_nan_handling: NanHandling,
55
56    /// What `/` and `%` do when the divisor is zero, on the **float** path.
57    /// Default: [`DivisionByZeroHandling::ReturnSaturated`] (the
58    /// JavaScript-style `±f64::MAX` / `±f64::MIN` per dividend sign). Switch
59    /// to [`DivisionByZeroHandling::ThrowError`] if you'd rather see a
60    /// surface error than a sentinel value. Applies uniformly to the 2-arg,
61    /// array-fold, and variadic forms.
62    ///
63    /// Carve-out: an **integer** dividend divided by an integer zero always
64    /// errors with `{"type": "NaN"}`, regardless of this setting, since
65    /// there is no in-range integer sentinel to return. Only genuinely
66    /// fractional operands take the configurable float path.
67    pub division_by_zero: DivisionByZeroHandling,
68
69    /// Whether `==` / `!=` (loose equality) raise an error on values
70    /// that can't be sensibly compared (e.g. an object compared to a
71    /// number). Default: `true` (raise). Set to `false` for the
72    /// JavaScript-classic behaviour where any cross-type compare
73    /// returns `false` silently.
74    pub loose_equality_errors: bool,
75
76    /// How values are coerced to booleans by `if`, `and`, `or`, `!`,
77    /// `!!`, and the predicate slot of array operators. Default:
78    /// [`TruthyEvaluator::JavaScript`] (the reference JSONLogic rule:
79    /// false for `null` / `false` / `0` / `NaN` / `""` / empty
80    /// array / empty object, true for everything else). Pick
81    /// [`TruthyEvaluator::Python`], [`TruthyEvaluator::StrictBoolean`],
82    /// or supply a [`TruthyEvaluator::Custom`] closure for full control.
83    pub truthy_evaluator: TruthyEvaluator,
84
85    /// Knobs for the implicit string→number / null→number / bool→number
86    /// coercions used by arithmetic and comparison. Default:
87    /// [`NumericCoercionConfig::default`] (matches the JSONLogic
88    /// reference behaviour). When more than one flag could fire on the
89    /// same value, the precedence is:
90    ///
91    /// 1. `reject_non_numeric` — if `true`, no coercion at all happens;
92    ///    the value either parses as a number or the engine reports a
93    ///    type error. Overrides every other flag.
94    /// 2. `null_to_zero` — only consulted on `null` values.
95    /// 3. `bool_to_number` — only consulted on `true` / `false`.
96    /// 4. `empty_string_to_zero` — consulted on empty strings.
97    ///
98    /// Each path is independent in practice (the type filters above
99    /// don't overlap), so the precedence only matters when reasoning
100    /// about `reject_non_numeric` vs the rest.
101    pub numeric_coercion: NumericCoercionConfig,
102
103    /// Maximum number of nested [`Engine::evaluate`](crate::Engine::evaluate)
104    /// boundary calls before the engine bails with
105    /// [`ErrorKind::ConfigurationError`](crate::ErrorKind::ConfigurationError).
106    /// Tracked per-thread, so it
107    /// catches `CustomOperator` impls that hold `Arc<Engine>` and
108    /// re-enter via `engine.evaluate(...)` from inside their
109    /// `evaluate(...)`.
110    ///
111    /// Default: `256` — generous for legitimate nested rules, tight
112    /// enough to bail well before a stack overflow on typical
113    /// platforms. The check is skipped entirely when the engine has no
114    /// custom operators registered (built-ins can't recurse via
115    /// boundary re-entry), so pure-built-in workloads pay nothing.
116    pub max_recursion_depth: u32,
117
118    /// Ceiling on the operations one evaluation may charge, or `None`
119    /// (the default) for unbounded. Available with the `budget` feature.
120    ///
121    /// Where [`Self::max_recursion_depth`] bounds *boundary re-entry*
122    /// only, this bounds the work itself: a `map` over a large input
123    /// nested inside another `map` is unbounded under the depth cap and
124    /// bounded under this one. The count is deterministic for a pinned
125    /// crate version, which a wall-clock timeout is not, and the
126    /// evaluation is refused before the work rather than reported after
127    /// it.
128    ///
129    /// One operation is charged per dispatched node, one per item an
130    /// iterator examines, and whatever an operator charges for its own
131    /// data movement — see
132    /// [`Engine::evaluate_metered`](crate::Engine::evaluate_metered) for
133    /// exactly what the number counts. Crossing the ceiling raises
134    /// [`ErrorKind::BudgetExceeded`](crate::ErrorKind::BudgetExceeded),
135    /// which `try` observes but cannot recover from.
136    #[cfg(feature = "budget")]
137    pub ops_budget: Option<u64>,
138}
139
140/// Defines how to handle NaN (Not a Number) scenarios in arithmetic operations
141#[derive(Clone, Debug, PartialEq)]
142pub enum NanHandling {
143    /// Throw an error when encountering non-numeric values (default)
144    ThrowError,
145    /// Ignore non-numeric values and continue with remaining values
146    IgnoreValue,
147    /// Treat non-numeric values as zero
148    CoerceToZero,
149    /// Return null when encountering non-numeric values
150    ReturnNull,
151}
152
153/// Defines how to handle division by zero
154#[derive(Clone, Debug, PartialEq)]
155pub enum DivisionByZeroHandling {
156    /// Saturating division: clamp the result to the f64 extreme rather
157    /// than throw or null. Returns `f64::MAX` for a positive dividend,
158    /// `f64::MIN` for a negative dividend, and `0.0` for `0 / 0` (the
159    /// indeterminate form saturates to neutral). Default.
160    ReturnSaturated,
161    /// Throw an error
162    ThrowError,
163    /// Return null
164    ReturnNull,
165    /// Return infinity (positive or negative based on dividend sign)
166    ReturnInfinity,
167}
168
169/// Defines how to evaluate truthiness of values
170#[derive(Clone)]
171pub enum TruthyEvaluator {
172    /// JavaScript-style truthiness (default)
173    /// - false: null, false, 0, NaN, "", empty array, empty object
174    /// - true: everything else
175    JavaScript,
176
177    /// Python-style truthiness
178    /// - false: None/null, False, 0, 0.0, "", empty collections
179    /// - true: everything else
180    ///
181    /// Differs from [`Self::JavaScript`] on exactly one value: `NaN`.
182    /// Python's `float('nan')` is truthy, JavaScript's `NaN` is falsy.
183    /// Every other rule coincides. `NaN` is not expressible as a JSON
184    /// literal, so this only shows up on values arithmetic produced —
185    /// e.g. `inf * 0` under
186    /// [`DivisionByZeroHandling::ReturnInfinity`].
187    Python,
188
189    /// Strict boolean truthiness
190    /// - false: null, false
191    /// - true: everything else
192    StrictBoolean,
193
194    /// Custom truthiness evaluator. Receives the value as an
195    /// [`OwnedDataValue`] — the canonical v5 owned value type — so the
196    /// callback works without enabling `serde_json` interop.
197    ///
198    /// Note: this variant cannot participate in `PartialEq` or in a
199    /// derived [`Debug`] (the closure is opaque). The hand-rolled `Debug`
200    /// impl prints `Custom(<fn>)` so the surrounding [`EvaluationConfig`]
201    /// stays debug-printable.
202    Custom(Arc<dyn Fn(&OwnedDataValue) -> bool + Send + Sync>),
203}
204
205impl std::fmt::Debug for TruthyEvaluator {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        match self {
208            Self::JavaScript => f.write_str("JavaScript"),
209            Self::Python => f.write_str("Python"),
210            Self::StrictBoolean => f.write_str("StrictBoolean"),
211            Self::Custom(_) => f.write_str("Custom(<fn>)"),
212        }
213    }
214}
215
216impl TruthyEvaluator {
217    /// Wrap a closure as a custom truthiness evaluator without typing
218    /// `Arc::new(...)` at the call site.
219    ///
220    /// The `Arc` wrapping on [`TruthyEvaluator::Custom`] is structurally
221    /// required (it keeps [`EvaluationConfig`] `Clone`), so this helper
222    /// is purely ergonomic — the `Custom` variant remains public for
223    /// callers that already hold an `Arc`.
224    ///
225    /// # Example
226    ///
227    /// ```rust
228    /// use datalogic_rs::{Engine, EvaluationConfig, TruthyEvaluator};
229    /// use datalogic_rs::datavalue::OwnedDataValue;
230    ///
231    /// // Even integers are truthy.
232    /// let config = EvaluationConfig::default().with_truthy_evaluator(
233    ///     TruthyEvaluator::custom(|v: &OwnedDataValue| {
234    ///         v.as_i64().map(|n| n % 2 == 0).unwrap_or(false)
235    ///     }),
236    /// );
237    /// let engine = Engine::builder().with_config(config).build();
238    /// let result = engine.eval_str(r#"{"if": [2, "even", "odd"]}"#, "null").unwrap();
239    /// assert_eq!(result, "\"even\"");
240    /// ```
241    pub fn custom<F>(f: F) -> Self
242    where
243        F: Fn(&OwnedDataValue) -> bool + Send + Sync + 'static,
244    {
245        Self::Custom(Arc::new(f))
246    }
247}
248
249/// Knobs for the implicit value→number coercions arithmetic and
250/// comparison perform on non-numeric arguments.
251///
252/// See [`EvaluationConfig::numeric_coercion`] for how these flags
253/// interact when more than one would fire on the same value (short
254/// answer: `reject_non_numeric` overrides everything else; the rest are
255/// type-disjoint so they don't conflict in practice).
256#[derive(Clone, Debug)]
257#[non_exhaustive]
258pub struct NumericCoercionConfig {
259    /// `""` → `0` in numeric context. Default: `true`. Disable to make
260    /// `{"+": ["", 1]}` fail with a NaN error instead of returning `1`.
261    pub empty_string_to_zero: bool,
262
263    /// `null` → `0` in numeric context. Default: `true`.
264    pub null_to_zero: bool,
265
266    /// `true` → `1`, `false` → `0` in numeric context. Default: `true`.
267    pub bool_to_number: bool,
268
269    /// Reject non-numeric values: a non-numeric value is a type error.
270    /// Default: `false`. When `true`, this flag overrides every other
271    /// flag in this struct — empty strings, nulls, and booleans all
272    /// raise rather than coerce. Acts as a kill switch for the rest of
273    /// the coercion knobs in this struct.
274    ///
275    /// Note: earlier versions also declared a reserved `undefined_to_zero`
276    /// flag here. It never had an effect (JSONLogic does not distinguish a
277    /// missing key from an explicit `null` — the reference `missing`
278    /// operator treats `{"a": null}` exactly like `{}`) and it has been
279    /// removed. A missing var already coerces to `0` under the default
280    /// [`Self::null_to_zero`]` = true`.
281    pub reject_non_numeric: bool,
282}
283
284impl Default for EvaluationConfig {
285    fn default() -> Self {
286        Self {
287            arithmetic_nan_handling: NanHandling::ThrowError,
288            division_by_zero: DivisionByZeroHandling::ReturnSaturated,
289            loose_equality_errors: true,
290            truthy_evaluator: TruthyEvaluator::JavaScript,
291            numeric_coercion: NumericCoercionConfig::default(),
292            max_recursion_depth: 256,
293            #[cfg(feature = "budget")]
294            ops_budget: None,
295        }
296    }
297}
298
299impl Default for NumericCoercionConfig {
300    fn default() -> Self {
301        Self {
302            empty_string_to_zero: true,
303            null_to_zero: true,
304            bool_to_number: true,
305            reject_non_numeric: false,
306        }
307    }
308}
309
310impl NumericCoercionConfig {
311    /// Set [`Self::empty_string_to_zero`].
312    #[must_use]
313    pub fn with_empty_string_to_zero(mut self, value: bool) -> Self {
314        self.empty_string_to_zero = value;
315        self
316    }
317
318    /// Set [`Self::null_to_zero`].
319    #[must_use]
320    pub fn with_null_to_zero(mut self, value: bool) -> Self {
321        self.null_to_zero = value;
322        self
323    }
324
325    /// Set [`Self::bool_to_number`].
326    #[must_use]
327    pub fn with_bool_to_number(mut self, value: bool) -> Self {
328        self.bool_to_number = value;
329        self
330    }
331
332    /// Set [`Self::reject_non_numeric`]. When `true`, this flag
333    /// overrides every other flag — empty strings, nulls, and booleans
334    /// all raise rather than coerce.
335    #[must_use]
336    pub fn with_reject_non_numeric(mut self, value: bool) -> Self {
337        self.reject_non_numeric = value;
338        self
339    }
340}
341
342impl EvaluationConfig {
343    /// Set [`Self::arithmetic_nan_handling`].
344    #[must_use]
345    pub fn with_arithmetic_nan_handling(mut self, value: NanHandling) -> Self {
346        self.arithmetic_nan_handling = value;
347        self
348    }
349
350    /// Set [`Self::division_by_zero`].
351    #[must_use]
352    pub fn with_division_by_zero(mut self, value: DivisionByZeroHandling) -> Self {
353        self.division_by_zero = value;
354        self
355    }
356
357    /// Set [`Self::loose_equality_errors`].
358    #[must_use]
359    pub fn with_loose_equality_errors(mut self, value: bool) -> Self {
360        self.loose_equality_errors = value;
361        self
362    }
363
364    /// Set [`Self::truthy_evaluator`].
365    #[must_use]
366    pub fn with_truthy_evaluator(mut self, value: TruthyEvaluator) -> Self {
367        self.truthy_evaluator = value;
368        self
369    }
370
371    /// Set [`Self::numeric_coercion`].
372    #[must_use]
373    pub fn with_numeric_coercion(mut self, value: NumericCoercionConfig) -> Self {
374        self.numeric_coercion = value;
375        self
376    }
377
378    /// Set [`Self::max_recursion_depth`].
379    #[must_use]
380    pub fn with_max_recursion_depth(mut self, value: u32) -> Self {
381        self.max_recursion_depth = value;
382        self
383    }
384
385    /// Set [`Self::ops_budget`]. Pass `None` for unbounded.
386    ///
387    /// ```rust
388    /// # #[cfg(feature = "budget")] {
389    /// use datalogic_rs::{Engine, EvaluationConfig};
390    ///
391    /// let engine = Engine::builder()
392    ///     .with_config(EvaluationConfig::default().with_ops_budget(Some(16)))
393    ///     .build();
394    /// let rule = r#"{"map": [{"var": "xs"}, {"*": [{"var": ""}, 2]}]}"#;
395    /// // Two nodes and three items: well inside 16.
396    /// assert_eq!(engine.eval_str(rule, r#"{"xs": [1, 2, 3]}"#).unwrap(), "[2,4,6]");
397    /// // The same rule over 100 items is refused.
398    /// let xs: Vec<String> = (0..100).map(|n| n.to_string()).collect();
399    /// let data = format!(r#"{{"xs": [{}]}}"#, xs.join(","));
400    /// assert_eq!(engine.eval_str(rule, &data).unwrap_err().tag(), "BudgetExceeded");
401    /// # }
402    /// ```
403    #[cfg(feature = "budget")]
404    #[must_use]
405    pub fn with_ops_budget(mut self, value: Option<u64>) -> Self {
406        self.ops_budget = value;
407        self
408    }
409
410    /// Create a configuration with safe arithmetic (ignores non-numeric values)
411    pub fn safe_arithmetic() -> Self {
412        Self {
413            arithmetic_nan_handling: NanHandling::IgnoreValue,
414            division_by_zero: DivisionByZeroHandling::ReturnNull,
415            loose_equality_errors: false,
416            ..Default::default()
417        }
418    }
419
420    /// Create a configuration with strict behavior (more errors)
421    pub fn strict() -> Self {
422        Self {
423            arithmetic_nan_handling: NanHandling::ThrowError,
424            division_by_zero: DivisionByZeroHandling::ThrowError,
425            loose_equality_errors: true,
426            numeric_coercion: NumericCoercionConfig {
427                empty_string_to_zero: false,
428                null_to_zero: false,
429                bool_to_number: false,
430                reject_non_numeric: true,
431            },
432            ..Default::default()
433        }
434    }
435}
436
437#[cfg(feature = "serde_json")]
438impl EvaluationConfig {
439    /// Build a configuration from a JSON object (string form).
440    ///
441    /// This is the wire format the language bindings use to pass engine
442    /// configuration across FFI boundaries through one shared parser;
443    /// Rust callers normally use the typed `with_*` setters instead.
444    ///
445    /// All keys are optional. An optional `"preset"` key (`"default"`,
446    /// `"safe_arithmetic"`, or `"strict"`) selects the starting point;
447    /// the remaining keys override individual fields on top of it.
448    /// Unknown keys, unknown enum strings, and type mismatches are
449    /// rejected with
450    /// [`ErrorKind::ConfigurationError`](crate::ErrorKind::ConfigurationError)
451    /// so typos fail loudly instead of being silently ignored.
452    /// [`TruthyEvaluator::Custom`] cannot be expressed in JSON — custom
453    /// truthiness is only available through the Rust API.
454    ///
455    /// Accepted keys and values (all enum strings are snake_case):
456    ///
457    /// | Key | Value |
458    /// |-----|-------|
459    /// | `preset` | `"default"` \| `"safe_arithmetic"` \| `"strict"` |
460    /// | `arithmetic_nan_handling` | `"throw_error"` \| `"ignore_value"` \| `"coerce_to_zero"` \| `"return_null"` |
461    /// | `division_by_zero` | `"return_saturated"` \| `"throw_error"` \| `"return_null"` \| `"return_infinity"` |
462    /// | `loose_equality_errors` | bool |
463    /// | `truthy_evaluator` | `"javascript"` \| `"python"` \| `"strict_boolean"` |
464    /// | `numeric_coercion` | object with bool keys `empty_string_to_zero`, `null_to_zero`, `bool_to_number`, `reject_non_numeric` |
465    /// | `max_recursion_depth` | integer ≥ 1 |
466    /// | `ops_budget` | integer ≥ 1, or `null` for unbounded (`budget` feature) |
467    ///
468    /// # Example
469    ///
470    /// ```rust
471    /// use datalogic_rs::{Engine, EvaluationConfig};
472    ///
473    /// let config = EvaluationConfig::from_json_str(r#"{
474    ///     "preset": "strict",
475    ///     "division_by_zero": "return_null",
476    ///     "numeric_coercion": {"null_to_zero": true},
477    ///     "max_recursion_depth": 64
478    /// }"#).unwrap();
479    /// let engine = Engine::builder().with_config(config).build();
480    /// // 1.5 keeps this on the configurable float path (an integer
481    /// // dividend over an integer zero always errors).
482    /// let result = engine.eval_str(r#"{"/": [1.5, 0]}"#, "null").unwrap();
483    /// assert_eq!(result, "null");
484    /// ```
485    ///
486    /// # Errors
487    ///
488    /// [`ErrorKind::ConfigurationError`](crate::ErrorKind::ConfigurationError)
489    /// if the string is not a JSON object or any key or value is
490    /// unrecognized.
491    pub fn from_json_str(json: &str) -> crate::Result<Self> {
492        use serde_json::Value;
493
494        fn cfg_err(msg: String) -> crate::Error {
495            crate::Error::configuration_error(msg)
496        }
497        fn expect_str<'v>(key: &str, value: &'v Value) -> crate::Result<&'v str> {
498            value
499                .as_str()
500                .ok_or_else(|| cfg_err(format!("config key {key:?} must be a string")))
501        }
502        fn expect_bool(key: &str, value: &Value) -> crate::Result<bool> {
503            value
504                .as_bool()
505                .ok_or_else(|| cfg_err(format!("config key {key:?} must be a boolean")))
506        }
507
508        let root: Value = serde_json::from_str(json)
509            .map_err(|e| cfg_err(format!("config is not valid JSON: {e}")))?;
510        let Value::Object(map) = root else {
511            return Err(cfg_err("config must be a JSON object".to_string()));
512        };
513
514        let mut config = match map.get("preset") {
515            None => Self::default(),
516            Some(preset) => match expect_str("preset", preset)? {
517                "default" => Self::default(),
518                "safe_arithmetic" => Self::safe_arithmetic(),
519                "strict" => Self::strict(),
520                other => {
521                    return Err(cfg_err(format!(
522                        "unknown preset {other:?} (expected \"default\", \"safe_arithmetic\", or \"strict\")"
523                    )));
524                }
525            },
526        };
527
528        for (key, value) in &map {
529            match key.as_str() {
530                "preset" => {} // applied above, before the overrides
531                "arithmetic_nan_handling" => {
532                    config.arithmetic_nan_handling = match expect_str(key, value)? {
533                        "throw_error" => NanHandling::ThrowError,
534                        "ignore_value" => NanHandling::IgnoreValue,
535                        "coerce_to_zero" => NanHandling::CoerceToZero,
536                        "return_null" => NanHandling::ReturnNull,
537                        other => {
538                            return Err(cfg_err(format!(
539                                "unknown arithmetic_nan_handling {other:?} (expected \"throw_error\", \"ignore_value\", \"coerce_to_zero\", or \"return_null\")"
540                            )));
541                        }
542                    };
543                }
544                "division_by_zero" => {
545                    config.division_by_zero = match expect_str(key, value)? {
546                        "return_saturated" => DivisionByZeroHandling::ReturnSaturated,
547                        "throw_error" => DivisionByZeroHandling::ThrowError,
548                        "return_null" => DivisionByZeroHandling::ReturnNull,
549                        "return_infinity" => DivisionByZeroHandling::ReturnInfinity,
550                        other => {
551                            return Err(cfg_err(format!(
552                                "unknown division_by_zero {other:?} (expected \"return_saturated\", \"throw_error\", \"return_null\", or \"return_infinity\")"
553                            )));
554                        }
555                    };
556                }
557                "loose_equality_errors" => {
558                    config.loose_equality_errors = expect_bool(key, value)?;
559                }
560                "truthy_evaluator" => {
561                    config.truthy_evaluator = match expect_str(key, value)? {
562                        "javascript" => TruthyEvaluator::JavaScript,
563                        "python" => TruthyEvaluator::Python,
564                        "strict_boolean" => TruthyEvaluator::StrictBoolean,
565                        other => {
566                            return Err(cfg_err(format!(
567                                "unknown truthy_evaluator {other:?} (expected \"javascript\", \"python\", or \"strict_boolean\"; custom evaluators are Rust-only)"
568                            )));
569                        }
570                    };
571                }
572                "numeric_coercion" => {
573                    let Value::Object(coercion) = value else {
574                        return Err(cfg_err(
575                            "config key \"numeric_coercion\" must be an object".to_string(),
576                        ));
577                    };
578                    for (ck, cv) in coercion {
579                        match ck.as_str() {
580                            "empty_string_to_zero" => {
581                                config.numeric_coercion.empty_string_to_zero = expect_bool(ck, cv)?;
582                            }
583                            "null_to_zero" => {
584                                config.numeric_coercion.null_to_zero = expect_bool(ck, cv)?;
585                            }
586                            "bool_to_number" => {
587                                config.numeric_coercion.bool_to_number = expect_bool(ck, cv)?;
588                            }
589                            "reject_non_numeric" => {
590                                config.numeric_coercion.reject_non_numeric = expect_bool(ck, cv)?;
591                            }
592                            other => {
593                                return Err(cfg_err(format!(
594                                    "unknown numeric_coercion key {other:?}"
595                                )));
596                            }
597                        }
598                    }
599                }
600                "max_recursion_depth" => {
601                    let depth = value
602                        .as_u64()
603                        .filter(|n| (1..=u64::from(u32::MAX)).contains(n))
604                        .ok_or_else(|| {
605                            cfg_err(format!(
606                                "config key \"max_recursion_depth\" must be an integer between 1 and {}",
607                                u32::MAX
608                            ))
609                        })?;
610                    config.max_recursion_depth = depth as u32;
611                }
612                #[cfg(feature = "budget")]
613                "ops_budget" => {
614                    config.ops_budget = if value.is_null() {
615                        None
616                    } else {
617                        Some(value.as_u64().filter(|n| *n >= 1).ok_or_else(|| {
618                            cfg_err(
619                                "config key \"ops_budget\" must be a positive integer or null"
620                                    .to_string(),
621                            )
622                        })?)
623                    };
624                }
625                other => {
626                    return Err(cfg_err(format!("unknown config key {other:?}")));
627                }
628            }
629        }
630
631        Ok(config)
632    }
633}