# feature-flag
[](https://github.com/mizcausevic-dev/feature-flag-rs/actions/workflows/ci.yml)
[](https://www.rust-lang.org/)
[](LICENSE)
**Server-side feature flag evaluation for async Rust services.** Targeting rules, sticky percentage rollouts, hot reload from a JSON file, zero RNG. Built around Tokio, but the evaluation path is sync — flags don't need to await.
```rust
use feature_flag::{FlagEvaluator, FlagSet, Subject};
let flagset = FlagSet::from_json(include_str!("../examples/flagset.json"))?;
let evaluator = FlagEvaluator::new(flagset);
let subject = Subject::new("user-42")
.with_attr("country", "US")
.with_attr("plan", "pro");
let result = evaluator.evaluate("new-checkout-flow", &subject)?;
println!("variant: {} (rule: {:?})", result.variant, result.matched_rule_id);
```
---
## Design rules
1. **No external RNG.** Sticky bucketing is `SHA-256(flag_id ‖ "/" ‖ subject_id) mod 100`. Deterministic; no entropy source needed; the same user always lands in the same slot.
2. **Tokio is the assumed runtime** for I/O paths (hot reload). The evaluation hot path is plain sync — flags don't need an executor.
3. **Cheap clones.** `FlagEvaluator` is `Arc`-shaped, so handing it across tasks is free.
4. **Snapshot for tests.** Build a `FlagSet` in code, evaluate, assert. No I/O required.
5. **Strict validation up-front.** Rollout weights must sum to 100. Rules can't reference variants the flag doesn't declare. `FlagSet::from_json` runs both checks before returning.
---
## The DSL
A `FlagSet` is JSON, like this:
```json
{
"version": "demo-1",
"flags": [
{
"id": "new-checkout-flow",
"variants": ["control", "treatment"],
"default_variant": "control",
"rules": [
{
"id": "internal-employees",
"when": {
"kind": "compare",
"attr": "email",
"op": "ends_with",
"value": "@kineticgain.com"
},
"outcome": {"kind": "variant", "variant": "treatment"}
},
{
"id": "us-ca-30pct",
"when": {
"kind": "compare",
"attr": "country",
"op": "in",
"value": ["US", "CA"]
},
"outcome": {
"kind": "rollout",
"variants": [
{"variant": "control", "weight": 70},
{"variant": "treatment", "weight": 30}
]
}
}
]
}
]
}
```
Rules are evaluated in declared order. The first matching rule wins; its outcome is either a fixed variant or a sticky rollout.
### Predicate operators
| `always` | Always matches. Useful as a default. |
| `compare` + `op: eq` / `ne` | Strict JSON-value equality. |
| `compare` + `op: gt` / `gte` / `lt` / `lte` | Numeric. `false` on non-numeric types. |
| `compare` + `op: in` / `not_in` | `value` must be an array. |
| `compare` + `op: starts_with` / `ends_with` | String-only. |
| `all_of` / `any_of` / `not` | Combinators. Recursive. |
### `enabled: false` is the kill switch
When `enabled` is `false` the evaluator skips all rules and returns `default_variant` with `reason: "disabled"`. Set it from a remote config push to cut traffic to a feature without redeploying.
---
## Hot reload
Watch a JSON file and atomically swap the loaded `FlagSet` whenever the mtime advances. Bad pushes don't kill the watcher — they log and stick with the previously-good set.
```rust
use std::time::Duration;
use feature_flag::{FlagEvaluator, FlagSet, HotReloader};
let evaluator = FlagEvaluator::new(FlagSet::default());
let reloader = HotReloader::spawn(
"/etc/feature-flags.json",
evaluator.clone(),
Duration::from_secs(15),
).await?;
// ... your service runs ...
reloader.shutdown().await;
```
Polling cadence is intentional: no platform-specific code, no extra dep tree, and flag reload doesn't need millisecond latency.
---
## Sticky rollout — why SHA-256 instead of a hash like FNV
Two reasons. First, the keyspace is open (user-supplied flag ids + subject ids), so we want a hash that doesn't accidentally collide on adversarial inputs. Second, SHA-256 is already in every dependency tree, the cost (~hundreds of ns) is invisible next to a feature-flag dispatch path. If you ever need to migrate to a different hash, the bucketing function is one private helper — replace and re-run the tests.
---
## Composes with
- **[`reliability-toolkit-rs`](https://github.com/mizcausevic-dev/reliability-toolkit-rs)** — wrap a rollout-controlled call in a circuit breaker + retry.
- **[`slo-budget-tracker`](https://github.com/mizcausevic-dev/slo-budget-tracker)** — flip a flag to `enabled: false` from a remote config push when your SLO starts burning.
- **[`policy-as-code-engine`](https://github.com/mizcausevic-dev/policy-as-code-engine)** — for the *policy* side of "should this happen" (allow/deny on identity + action), pair with this crate which answers "which behaviour should we run."
---
## Examples
```bash
cargo run --example evaluate
# subject=alice country=US -> "control" (rule="us-ca-rollout-30pct", reason=rule_match)
# subject=bob country=DE -> "control" (rule="-", reason=default)
# subject=carla country=US -> "treatment" (rule="internal-employees", reason=rule_match)
```
---
## Tests + bench
```bash
cargo test --all-targets
cargo test --doc
cargo clippy --all-targets -- -Dwarnings
cargo fmt --all -- --check
cargo bench
```
The CI matrix is `stable`, `beta`, and `1.85.0` (MSRV).
---
## License
MIT. See [LICENSE](LICENSE).