datalogic-rs 5.0.0

A fast, type-safe Rust implementation of JSONLogic for evaluating logical rules as JSON. Perfect for business rules engines and dynamic filtering in Rust applications.
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
# datalogic-rs

[![Crates.io](https://img.shields.io/crates/v/datalogic-rs.svg)](https://crates.io/crates/datalogic-rs)
[![Documentation](https://docs.rs/datalogic-rs/badge.svg)](https://docs.rs/datalogic-rs)
[![Rust 1.85+](https://img.shields.io/badge/rust-1.85+-orange.svg)](https://www.rust-lang.org)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)

A fast, type-safe Rust implementation of [JSONLogic](http://jsonlogic.com)
for evaluating logical rules as JSON. Compile a rule once, evaluate it
millions of times across threads with zero overhead — same engine powers
a **rule engine**, a **JSON template engine**, or a **safe expression
evaluator**.

This is the **Rust core** of the
[`datalogic-rs` monorepo](https://github.com/GoPlasmatic/datalogic-rs).
The repo also ships [WASM](https://www.npmjs.com/package/@goplasmatic/datalogic-wasm),
[Python](https://pypi.org/project/datalogic-py/), Go, and
[React](https://www.npmjs.com/package/@goplasmatic/datalogic-ui)
bindings — same rules, same semantics. For the cross-runtime overview
and the per-binding READMEs, see the
[repo README](https://github.com/GoPlasmatic/datalogic-rs#readme).

## Install

```bash
cargo add datalogic-rs
```

The default build is `serde_json`-free and ships only the JSONLogic
baseline operators. Opt in to feature flags as needed — see the
[feature flag reference](#feature-flags) below.

## Hello, JSONLogic

```rust
let result = datalogic_rs::eval_str(r#"{"+": [1, 2, 3]}"#, r#"{}"#).unwrap();
assert_eq!(result, "6");
```

That's it. `eval_str` parses the rule, parses the data, evaluates, and
hands you back a JSON string. The free functions on the crate root
wrap a shared default `Engine` — explicit construction lets you add
custom operators, change config, or amortise compilation. The rest of
this README walks through **when to use which API**.

## Choosing your API: five tiers, one engine

The crate exposes five evaluation tiers in increasing order of control.
Pick by use case, not by curiosity — most callers want **Tier 0** for
ad-hoc work or **Tier 2** for repeated evaluation.

| Tier | Entry point                                              | Arena owner             | Returns                              | Use when                                              |
|------|----------------------------------------------------------|-------------------------|--------------------------------------|-------------------------------------------------------|
| **0** | `datalogic_rs::eval_str` / `eval` / `eval_into` / `compile` | lazy static `Engine`   | `String` / `OwnedDataValue` / `T` / `Logic` | One-shot scripts, ad-hoc evaluation, no custom config |
| **1** | `Engine::eval_str` / `eval` / `eval_into`                 | per-call `Bump`         | `String` / `OwnedDataValue` / `T`    | You need custom operators, config, or templating mode |
| **2** | `Engine::session()``Session::eval*`                    | session-owned `Bump`    | owned **or** `&DataValue<'a>` borrow | Hot loops, services, batch jobs                       |
| **3** | `Engine::evaluate(&Logic, data, &Bump)`                   | caller-owned `Bump`     | `&'a DataValue<'a>`                  | Zero-copy result pipelines, custom pool strategies    |
| **4** | `Engine::trace()``TracedSession::*`                    | session-owned + buffer  | `TracedRun<R>` (result + steps)      | Debugging, visualisation, instrumentation             |

### Tier 0 — Module-level one-shot

The free functions wrap a static default `Engine`. No construction,
no configuration. Three result shapes:

```rust
use datalogic_rs::{compile, eval, eval_str};

// JSON string in, JSON string out
let s = eval_str(r#"{">": [{"var": "x"}, 10]}"#, r#"{"x": 42}"#).unwrap();
assert_eq!(s, "true");

// JSON string in, OwnedDataValue out
let v = eval(r#"{"+": [1, 2]}"#, r#"{}"#).unwrap();
assert_eq!(v.as_i64(), Some(3));

// Compile once at the module level when the rule is fixed
let logic = compile(r#"{"==": [{"var": "status"}, "active"]}"#).unwrap();
```

With the `serde_json` feature, `eval_into::<T>` returns any
`T: DeserializeOwned`:

```rust
// Cargo.toml: datalogic-rs = { version = "5", features = ["serde_json"] }
let n: i64 = datalogic_rs::eval_into(r#"{"+": [1, 2, 3]}"#, r#"{}"#).unwrap();
assert_eq!(n, 6);
```

### Tier 1 — Engine one-shot

Construct an `Engine` when you need anything beyond defaults: custom
operators, a non-default `EvaluationConfig`, or templating mode.

```rust
use datalogic_rs::Engine;

let engine = Engine::new();
let result = engine.eval_str(
    r#"{"==": [{"var": "status"}, "active"]}"#,
    r#"{"status": "active"}"#,
).unwrap();
assert_eq!(result, "true");
```

Use `Engine::builder()` to register operators and tweak behaviour:

```rust
use datalogic_rs::{Engine, EvaluationConfig};

let engine = Engine::builder()
    .with_config(EvaluationConfig::safe_arithmetic())
    .with_templating(true)              // requires `templating` feature
    .build();
```

### Tier 2 — Session (the right default for repeated evaluation)

`Session` owns a reusable `bumpalo::Bump` and resets it between calls,
so peak memory tracks the largest single evaluation, not the sum.

```rust
use datalogic_rs::Engine;

let engine = Engine::new();
let compiled = engine.compile(r#"{"+": [{"var": "x"}, 1]}"#).unwrap();
let mut session = engine.session();

for x in 0..1_000 {
    let payload = format!(r#"{{"x": {x}}}"#);
    let result = session.eval_str(&compiled, &payload).unwrap();
    // ...consume `result`...
    session.reset();          // O(1); keeps chunks for the next iteration
}
```

`Session::eval_borrowed` returns a `&'a DataValue<'a>` borrow into the
session's own arena — skips the owned deep-clone when the result is
consumed before the next session call. For pre-sizing the arena after
a warm-up pass, use `session.allocated_bytes()` +
`session.reset_with_capacity(bytes)`.

**Tokio idiom:** `Arc<Engine>` shared across worker threads (it's
`Send + Sync`), one `Session` per task (it's `Send` but `!Sync`, moves
with the task across `.await` points).

Full pattern: [`examples/compile_once_evaluate_many.rs`](./examples/compile_once_evaluate_many.rs).

### Tier 3 — Zero-copy `evaluate(&Bump)`

When the result borrow can stay scoped to a caller-managed arena,
skip the owned deep-clone and use `Engine::evaluate` directly. The
caller owns the `Bump`; the library never resets it.

```rust
use bumpalo::Bump;
use datalogic_rs::Engine;

let engine = Engine::new();
let compiled = engine.compile(r#"{"==": [{"var": "status"}, "active"]}"#).unwrap();

let arena = Bump::new();
let result = engine.evaluate(&compiled, r#"{"status": "active"}"#, &arena).unwrap();
assert_eq!(result.as_bool(), Some(true));
```

Reach for this tier when you have a pool-managed arena, are
pipelining values across stages without crossing the value boundary,
or want maximum control over when memory is reclaimed.

### Tier 4 — Traced evaluation (`trace` feature)

Enable the `trace` feature, then ask the engine for a `TracedSession`.
Each call records the expression tree + per-node execution steps.

```rust
// Cargo.toml: datalogic-rs = { version = "5", features = ["trace"] }
use datalogic_rs::Engine;

let engine = Engine::new();
let traced = engine.trace().eval_str(
    r#"{"and": [true, {"var": "x"}]}"#,
    r#"{"x": true}"#,
);
let result_string = traced.result.unwrap();
// `traced.steps` is the per-node execution trace
// (drop into the React debugger or process programmatically).
```

Full pattern: [`examples/tracing.rs`](./examples/tracing.rs).

## Input shapes

`Engine::evaluate` and `Session::eval_borrowed` accept any input the
caller is likely to have on hand, via the sealed [`EvalInput`] trait.
Per-call cost differs:

| Shape                                     | Cost per call                       |
|-------------------------------------------|-------------------------------------|
| `&str` (JSON literal)                     | parse + arena alloc                 |
| `&serde_json::Value` (`serde_json` feature) | deep-convert into the arena       |
| `&OwnedDataValue`                         | deep-borrow into the arena          |
| `DataValue<'a>` (by value)                | one arena alloc for the top node    |
| `&'a DataValue<'a>` (by reference)        | **zero** — pass-through             |

For the same-input-many-rules case, or when upstream stages already
produced an arena value, prefer the `&'a DataValue<'a>` path — it's
genuinely allocation-free.

The Tier 0 / Tier 1 one-shot methods (`eval`, `eval_str`,
`eval_into`) accept a similar set via the [`OwnedInput`] trait, which
omits the `DataValue<'a>` shapes (no caller arena to borrow from).

Runnable example: [`examples/zero_copy_input.rs`](./examples/zero_copy_input.rs).

[`EvalInput`]: https://docs.rs/datalogic-rs/latest/datalogic_rs/trait.EvalInput.html
[`OwnedInput`]: https://docs.rs/datalogic-rs/latest/datalogic_rs/trait.OwnedInput.html

## Working with `DataValue`

Evaluation returns `&'a DataValue<'a>` — an arena-allocated, borrowed
JSON-shaped value tree. The type lives in the sibling `datavalue`
crate (re-exported at the root and as `datalogic_rs::datavalue`).
Most callers only need a handful of accessors:

```rust
use datalogic_rs::Engine;

let engine = Engine::new();
let compiled = engine.compile(r#"{"var": "user.score"}"#).unwrap();
let mut session = engine.session();
let result = session.eval_borrowed(&compiled, r#"{"user": {"score": 42}}"#).unwrap();

assert_eq!(result.as_i64(), Some(42));
// Other accessors: .as_f64(), .as_str(), .as_bool(), .as_array(), .as_object().
```

Conversion to other shapes:

- **To a JSON string:** `value.to_string()``DataValue` and
  `OwnedDataValue` both implement `Display`.
- **To `serde_json::Value`** (requires `serde_json`): use
  `eval_into::<serde_json::Value>(...)`.
- **To a typed Rust struct** (requires `serde_json`): use
  `eval_into::<T>(...)` where `T: DeserializeOwned`.
- **Owned vs borrowed:** `DataValue<'a>` borrows from a `Bump`;
  `OwnedDataValue` is the heap-owned counterpart for crossing arena
  lifetimes. Convert via `.to_owned()` (borrowed → owned) and
  `.to_arena(&bump)` (owned → borrowed).

## Public types at a glance

| Type                           | Role                                                        |
|--------------------------------|-------------------------------------------------------------|
| [`Engine`]                     | Immutable evaluation engine; entry point for every tier     |
| [`EngineBuilder`]              | Builder for engines with custom config, operators, modes    |
| [`Logic`]                      | Compiled, thread-safe rule snapshot                         |
| [`Session`]                    | Arena-reusing handle for hot loops; caller resets           |
| [`DataValue`]                  | Arena-borrowed JSON-shaped value (returned from `evaluate`) |
| `OwnedDataValue`               | Heap-owned counterpart of `DataValue` (via `datavalue`)     |
| [`EvaluationConfig`]           | Behaviour knobs: NaN, division by zero, truthiness, coercion |
| [`CustomOperator`]             | Trait you implement to extend the engine                    |
| `operator::EvalContext`        | Opaque engine context passed to `CustomOperator::evaluate`  |
| [`Error`] / [`ErrorKind`]      | Unified error type with operator + node-id breadcrumbs      |
| [`TracedRun`] / [`TracedSession`] | Tracing types (`trace` feature)                          |

[`Engine`]: https://docs.rs/datalogic-rs/latest/datalogic_rs/struct.Engine.html
[`EngineBuilder`]: https://docs.rs/datalogic-rs/latest/datalogic_rs/struct.EngineBuilder.html
[`Logic`]: https://docs.rs/datalogic-rs/latest/datalogic_rs/struct.Logic.html
[`Session`]: https://docs.rs/datalogic-rs/latest/datalogic_rs/struct.Session.html
[`DataValue`]: https://docs.rs/datalogic-rs/latest/datalogic_rs/enum.DataValue.html
[`EvaluationConfig`]: https://docs.rs/datalogic-rs/latest/datalogic_rs/struct.EvaluationConfig.html
[`CustomOperator`]: https://docs.rs/datalogic-rs/latest/datalogic_rs/trait.CustomOperator.html
[`Error`]: https://docs.rs/datalogic-rs/latest/datalogic_rs/struct.Error.html
[`ErrorKind`]: https://docs.rs/datalogic-rs/latest/datalogic_rs/enum.ErrorKind.html
[`TracedRun`]: https://docs.rs/datalogic-rs/latest/datalogic_rs/struct.TracedRun.html
[`TracedSession`]: https://docs.rs/datalogic-rs/latest/datalogic_rs/struct.TracedSession.html

## Custom operators

Register custom operators on `Engine::builder()` and call them from
rules just like the built-ins. Args arrive **pre-evaluated** as
arena-resident `&DataValue<'a>` borrows; you allocate the result back
into the arena.

```rust
use bumpalo::Bump;
use datalogic_rs::{CustomOperator, DataValue, Engine, Result, operator::EvalContext};

struct Double;
impl CustomOperator for Double {
    fn evaluate<'a>(
        &self,
        args: &[&'a DataValue<'a>],
        _ctx: &mut EvalContext<'_, 'a>,
        arena: &'a Bump,
    ) -> Result<&'a DataValue<'a>> {
        let n = args.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
        Ok(arena.alloc(DataValue::from_f64(n * 2.0)))
    }
}

let engine = Engine::builder().add_operator("double", Double).build();
let result = engine.eval_str(r#"{"double": 21}"#, "null").unwrap();
assert_eq!(result, "42");
```

Runnable example: [`examples/custom_operator.rs`](./examples/custom_operator.rs).
Full guide: [Custom Operators](https://goplasmatic.github.io/datalogic-rs/advanced/custom-operators.html).

The `CustomOperator` trait is the headline extension point and is
**stable for the 5.x series** — no required-method additions, no
signature changes.

## Configuration

`EvaluationConfig` controls edge-case behaviour:

- **NaN handling** (`NanHandling`) — what happens when arithmetic
  receives a non-number
- **Division by zero** (`DivisionByZeroHandling`)
- **Truthiness** (`TruthyEvaluator`) — JavaScript, Python, strict
  boolean, or a custom closure
- **Numeric coercion** (`NumericCoercionConfig`) — null-to-zero,
  bool-to-number, empty-string-to-zero, etc.
- **Recursion depth** — guards against pathological inputs

Presets like `EvaluationConfig::safe_arithmetic()` and
`EvaluationConfig::strict()` cover common postures. See the
[Configuration guide](https://goplasmatic.github.io/datalogic-rs/advanced/configuration.html)
and the runnable [`examples/configuration.rs`](./examples/configuration.rs).

## Templating mode

With `Engine::builder().with_templating(true)` (requires the
`templating` feature), multi-key objects in a compiled rule become
output-shaping templates — keys flow through to the output and
operator values become computed fields.

```rust
// Cargo.toml: datalogic-rs = { version = "5", features = ["templating"] }
use datalogic_rs::Engine;

let engine = Engine::builder().with_templating(true).build();
let result = engine.eval_str(
    r#"{"greeting": {"cat": ["Hello ", {"var": "name"}]},
        "isAdult": {">=": [{"var": "age"}, 18]}}"#,
    r#"{"name": "Jane", "age": 25}"#,
).unwrap();
// {"greeting":"Hello Jane","isAdult":true}
```

The 4.x JSONLogic `preserve` *operator* was removed in v5: literal
scalars / arrays work inline already; templated objects belong in
templating mode. Runnable example:
[`examples/structured_objects.rs`](./examples/structured_objects.rs).

## Error model

Every fallible path returns `Result<T, Error>`. `Error` carries:

- `kind: ErrorKind` — the discriminant (`ParseError`, `Thrown`,
  `VariableNotFound`, `TypeError`, `ArithmeticError`, `Custom`, …)
- `operator: Option<&'static str>` — the outermost failing operator
- `node_ids: Vec<u32>` — breadcrumbs from the compiled tree; resolve
  to a JSON path via `Error::resolve_path(&logic)` which returns a
  `Vec<PathStep>` you can print or serialise

```rust
use datalogic_rs::{Engine, ErrorKind};

let engine = Engine::new();
let err = engine.eval_str(r#"{"var": "missing"}"#, r#"{}"#);
// Default config: variable misses return null, not an error.
// Switch to a strict config to surface them as `VariableNotFound`.
```

Runnable example: [`examples/error_handling.rs`](./examples/error_handling.rs).

## Thread safety

`Engine`, `Logic`, and your `CustomOperator` types are all `Send + Sync`
— construct the `Engine` once, compile the rule once, share both via
`Arc` across as many threads or tokio tasks as you want. `Session` is
the per-task workhorse: each task opens its own. It owns a
`bumpalo::Bump` that can't be shared across threads (the same way a
database connection is per-task in a connection-pool model), and Rust
enforces this at compile time — there's no runtime hazard.

| Type                           | Pattern                                                                        |
|--------------------------------|--------------------------------------------------------------------------------|
| `Engine`                       | One per process; share via `Arc`                                               |
| `Logic`                        | Compile once; share via `Arc` (or use `Engine::compile_arc`)                   |
| `CustomOperator` implementors  | Register on the builder; live inside the shared `Engine` (`Send + Sync` bound) |
| `Session`                      | One per task / per goroutine — the per-task workhorse                          |

Runnable example: [`examples/thread_safety.rs`](./examples/thread_safety.rs).

## Feature flags

| Feature           | Effect                                                                    |
|-------------------|---------------------------------------------------------------------------|
| `serde_json`      | `&serde_json::Value` interop and `eval_into::<T>` typed deserialisation   |
| `templating`      | Structure-preservation (templating) mode                                  |
| `datetime`        | Date / time operators (pulls in `chrono`)                                 |
| `trace`           | Execution-step recording for the debugger (implies `serde_json`)          |
| `error-handling`  | `try` / `throw` operators                                                 |
| `ext-string`, `ext-array`, `ext-control`, `ext-math` | Optional operator families             |
| `flagd`           | flagd-compat operators (`fractional`, `sem_ver`); pulls in `semver`       |

The default build is `serde_json`-free; opt in via
`features = ["serde_json"]` when you need the value boundary.

### `flagd` — OpenFeature flagd-compatible operators

Enables two operators specified by the
[OpenFeature flagd in-process provider](https://flagd.dev/reference/custom-operations/),
implemented to match the canonical
[Go evaluator](https://github.com/open-feature/flagd/tree/main/core/pkg/evaluator)
byte-for-byte:

- **[`fractional`]https://flagd.dev/reference/custom-operations/fractional-operation/**  deterministic percentage bucketing for A/B tests and rollouts. Uses
  MurmurHash3 x86-32 of a bucketing key (explicit string, or implicit
  `flagKey + targetingKey` from the root `$flagd` envelope) plus
  `(hash * total_weight) >> 32` integer distribution, identical to the
  Go evaluator's algorithm. The hash is vendored inline (~30 LOC, no
  external dep) for portability across every target.
- **[`sem_ver`]https://flagd.dev/reference/custom-operations/semver-operation/**  semantic-version comparison with the spec's four input normalizations:
  strip leading `v`/`V`, pad partial versions (`1.0``1.0.0`), coerce
  numeric input to string, and drop SemVer build metadata. Backed by
  the [`semver`]https://docs.rs/semver crate (optional dep). Operators:
  `=`, `!=`, `<`, `<=`, `>`, `>=`, `^` (same major), `~` (same major+minor).

Both operators return `null` on malformed input rather than raising — the
flagd evaluator observes the `null` and falls back to the flag's default
variant; non-flagd callers can compose with `??` or `if` for the same
effect.

```rust,no_run
// Cargo.toml: datalogic-rs = { version = "5", features = ["flagd"] }
use datalogic_rs::Engine;

let engine = Engine::new();

// A typical flagd targeting rule: ship "new-ui" to 50 % of @example.com users.
let result: String = engine.eval_str(
    r#"{
        "fractional": [
            { "cat": [{ "var": "$flagd.flagKey" }, { "var": "email" }] },
            ["new-ui", 50],
            ["old-ui", 50]
        ]
    }"#,
    r#"{"email": "alice@example.com", "$flagd": {"flagKey": "header-color"}}"#,
)?;
// result is one of "\"new-ui\"" or "\"old-ui\"" — sticky per email.
# Ok::<(), datalogic_rs::Error>(())
```

Conformance test suites under
[`tests/suites/flagd/`](./tests/suites/) mirror the canonical Go test
files in `open-feature/flagd` so every release is checked against the
upstream behaviour.

## Performance

Compiled rules dispatch through a single `OpCode` enum (no string
lookups), values live in a `bumpalo::Bump` arena (no per-result heap
allocation), and read-through operators like `var` borrow zero-copy
from the caller's input. Geomean ~9.7 ns/op across 44 operator suites
on Apple M2 Pro — see the cross-library comparison in
[`tools/benchmark/BENCHMARK.md`](../../tools/benchmark/BENCHMARK.md).

## Migrating from v4

v5 is a breaking release with a hard cliff — no `compat` feature, no
deprecated method shims. Headline renames: `DataLogic` → `Engine`,
`evaluate_json` → `eval_str` / `eval_into::<T>`, `Operator` →
`CustomOperator`, `with_config(...)` →
`Engine::builder().with_config(...).build()`. See
[`MIGRATION.md`](../../MIGRATION.md) for the full v4 → v5 cookbook and
[`CHANGELOG.md`](CHANGELOG.md) for the chronological breakage list.

## Learn more

- [Repo README]https://github.com/GoPlasmatic/datalogic-rs#readme — cross-runtime overview, per-binding READMEs
- [Documentation site]https://goplasmatic.github.io/datalogic-rs/ — long-form guide, operator reference, advanced topics
- [Online playground]https://goplasmatic.github.io/datalogic-rs/playground/ — try rules live in the visual debugger
- [`docs.rs/datalogic-rs`]https://docs.rs/datalogic-rs — Rust API reference
- [`examples/README.md`]./examples/README.md — index of runnable examples
- [`tests/README.md`]./tests/README.md — JSONLogic suite format

## License

Apache 2.0 — see [LICENSE](../../LICENSE).