datalogic-rs
A fast, type-safe Rust implementation of JSONLogic 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.
The repo also ships WASM,
Python, Go, and
React
bindings — same rules, same semantics. For the cross-runtime overview
and the per-binding READMEs, see the
repo README.
Install
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 below.
Hello, JSONLogic
let result = eval_str.unwrap;
assert_eq!;
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:
use ;
// JSON string in, JSON string out
let s = eval_str.unwrap;
assert_eq!;
// JSON string in, OwnedDataValue out
let v = eval.unwrap;
assert_eq!;
// Compile once at the module level when the rule is fixed
let logic = compile.unwrap;
With the serde_json feature, eval_into::<T> returns any
T: DeserializeOwned:
// Cargo.toml: datalogic-rs = { version = "5", features = ["serde_json"] }
let n: i64 = eval_into.unwrap;
assert_eq!;
Tier 1 — Engine one-shot
Construct an Engine when you need anything beyond defaults: custom
operators, a non-default EvaluationConfig, or templating mode.
use Engine;
let engine = new;
let result = engine.eval_str.unwrap;
assert_eq!;
Use Engine::builder() to register operators and tweak behaviour:
use ;
let engine = builder
.with_config
.with_templating // 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.
use Engine;
let engine = new;
let compiled = engine.compile.unwrap;
let mut session = engine.session;
for x in 0..1_000
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.
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.
use Bump;
use Engine;
let engine = new;
let compiled = engine.compile.unwrap;
let arena = new;
let result = engine.evaluate.unwrap;
assert_eq!;
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.
// Cargo.toml: datalogic-rs = { version = "5", features = ["trace"] }
use Engine;
let engine = new;
let traced = engine.trace.eval_str;
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.
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.
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:
use Engine;
let engine = new;
let compiled = engine.compile.unwrap;
let mut session = engine.session;
let result = session.eval_borrowed.unwrap;
assert_eq!;
// Other accessors: .as_f64(), .as_str(), .as_bool(), .as_array(), .as_object().
Conversion to other shapes:
- To a JSON string:
value.to_string()—DataValueandOwnedDataValueboth implementDisplay. - To
serde_json::Value(requiresserde_json): useeval_into::<serde_json::Value>(...). - To a typed Rust struct (requires
serde_json): useeval_into::<T>(...)whereT: DeserializeOwned. - Owned vs borrowed:
DataValue<'a>borrows from aBump;OwnedDataValueis 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) |
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.
use Bump;
use ;
;
let engine = builder.add_operator.build;
let result = engine.eval_str.unwrap;
assert_eq!;
Runnable example: examples/custom_operator.rs.
Full guide: Custom Operators.
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
and the runnable 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.
// Cargo.toml: datalogic-rs = { version = "5", features = ["templating"] }
use Engine;
let engine = builder.with_templating.build;
let result = engine.eval_str.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.
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 operatornode_ids: Vec<u32>— breadcrumbs from the compiled tree; resolve to a JSON path viaError::resolve_path(&logic)which returns aVec<PathStep>you can print or serialise
use ;
let engine = new;
let err = engine.eval_str;
// 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.
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.
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, implemented to match the canonical Go evaluator byte-for-byte:
fractional— deterministic percentage bucketing for A/B tests and rollouts. Uses MurmurHash3 x86-32 of a bucketing key (explicit string, or implicitflagKey + targetingKeyfrom the root$flagdenvelope) plus(hash * total_weight) >> 32integer 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— semantic-version comparison with the spec's four input normalizations: strip leadingv/V, pad partial versions (1.0→1.0.0), coerce numeric input to string, and drop SemVer build metadata. Backed by thesemvercrate (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.
// Cargo.toml: datalogic-rs = { version = "5", features = ["flagd"] }
use Engine;
let engine = new;
// A typical flagd targeting rule: ship "new-ui" to 50 % of @example.com users.
let result: String = engine.eval_str?;
// result is one of "\"new-ui\"" or "\"old-ui\"" — sticky per email.
# Ok::
Conformance test suites under
tests/suites/flagd/ 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.
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 for the full v4 → v5 cookbook and
CHANGELOG.md for the chronological breakage list.
Learn more
- Repo README — cross-runtime overview, per-binding READMEs
- Documentation site — long-form guide, operator reference, advanced topics
- Online playground — try rules live in the visual debugger
docs.rs/datalogic-rs— Rust API referenceexamples/README.md— index of runnable examplestests/README.md— JSONLogic suite format
License
Apache 2.0 — see LICENSE.