use crate::rollout;
use crate::scope::Scope;
use crate::store::{FlagStore, MemoryStore};
use rustlavel_core::{Config, Error, Result};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
type ResolverFuture = Pin<Box<dyn Future<Output = Result<bool>> + Send + 'static>>;
type Resolver = Arc<dyn Fn(Scope) -> ResolverFuture + Send + Sync>;
pub trait FlagAnswer {
fn into_answer(self) -> Result<bool>;
}
impl FlagAnswer for bool {
fn into_answer(self) -> Result<bool> {
Ok(self)
}
}
impl FlagAnswer for Result<bool> {
fn into_answer(self) -> Result<bool> {
self
}
}
#[derive(Clone)]
pub struct Flags {
definitions: Arc<HashMap<String, Resolver>>,
store: Arc<dyn FlagStore>,
forced_on: Arc<BTreeSet<String>>,
forced_off: Arc<BTreeSet<String>>,
}
impl Default for Flags {
fn default() -> Self {
Flags::new()
}
}
impl Flags {
pub fn new() -> Self {
Flags {
definitions: Arc::new(HashMap::new()),
store: Arc::new(MemoryStore::new()),
forced_on: Arc::new(BTreeSet::new()),
forced_off: Arc::new(BTreeSet::new()),
}
}
pub fn from_config(config: &Config) -> Self {
Flags::new().configured(config)
}
pub fn configured(self, config: &Config) -> Self {
let listed = |key: &str, variable: &str| {
let from_config = config.list(key);
if !from_config.is_empty() {
return from_config;
}
rustlavel_core::env::env_or(variable, "")
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.collect()
};
self.force_on(listed("flags.on", "FLAGS_ON"))
.force_off(listed("flags.off", "FLAGS_OFF"))
}
pub fn store(mut self, store: impl FlagStore) -> Self {
self.store = Arc::new(store);
self
}
pub fn with_store(mut self, store: Arc<dyn FlagStore>) -> Self {
self.store = store;
self
}
pub fn define<F, Fut, A>(mut self, name: impl Into<String>, resolver: F) -> Self
where
F: Fn(Scope) -> Fut + Send + Sync + 'static,
Fut: Future<Output = A> + Send + 'static,
A: FlagAnswer,
{
let boxed: Resolver = Arc::new(move |scope| {
let future = resolver(scope);
Box::pin(async move { future.await.into_answer() })
});
Arc::make_mut(&mut self.definitions).insert(name.into(), boxed);
self
}
pub fn rollout(self, name: impl Into<String>, percent: u8) -> Self {
let name = name.into();
let flag = name.clone();
self.define(name, move |scope: Scope| {
let flag = flag.clone();
async move { rollout::in_rollout(&flag, &scope, percent) }
})
}
pub fn force_on<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Arc::make_mut(&mut self.forced_on).extend(names.into_iter().map(Into::into));
self
}
pub fn force_off<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Arc::make_mut(&mut self.forced_off).extend(names.into_iter().map(Into::into));
self
}
pub fn defined(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.definitions.keys().map(String::as_str).collect();
names.sort_unstable();
names
}
pub fn is_defined(&self, flag: &str) -> bool {
self.definitions.contains_key(flag)
}
pub fn store_handle(&self) -> &Arc<dyn FlagStore> {
&self.store
}
pub async fn active(&self, flag: &str, scope: &Scope) -> Result<bool> {
self.resolve(flag, scope).await
}
pub async fn inactive(&self, flag: &str, scope: &Scope) -> Result<bool> {
Ok(!self.active(flag, scope).await?)
}
pub fn for_scope(&self, scope: Scope) -> ScopedFlags {
ScopedFlags {
flags: self.clone(),
scope,
resolved: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn activate_for(&self, flag: &str, scope: &Scope) -> Result<()> {
self.store.set(flag, scope, true).await
}
pub async fn deactivate_for(&self, flag: &str, scope: &Scope) -> Result<()> {
self.store.set(flag, scope, false).await
}
pub async fn forget_for(&self, flag: &str, scope: &Scope) -> Result<()> {
self.store.forget(flag, scope).await
}
pub async fn activate(&self, flag: &str) -> Result<()> {
self.activate_for(flag, &Scope::none()).await
}
pub async fn deactivate(&self, flag: &str) -> Result<()> {
self.deactivate_for(flag, &Scope::none()).await
}
pub async fn forget(&self, flag: &str) -> Result<()> {
self.forget_for(flag, &Scope::none()).await
}
pub async fn purge(&self) -> Result<()> {
self.store.flush().await
}
async fn resolve(&self, flag: &str, scope: &Scope) -> Result<bool> {
if self.forced_off.contains(flag) {
return Ok(false);
}
let scoped = self.store.get(flag, scope).await?;
let global = if scope.is_none() {
scoped
} else {
self.store.get(flag, &Scope::none()).await?
};
if scoped == Some(false) || global == Some(false) {
return Ok(false);
}
if scoped == Some(true) || global == Some(true) {
return Ok(true);
}
if self.forced_on.contains(flag) {
return Ok(true);
}
match self.definitions.get(flag) {
Some(resolver) => resolver(scope.clone()).await,
None => Ok(false),
}
}
}
impl std::fmt::Debug for Flags {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Flags")
.field("defined", &self.defined())
.field("forced_on", &self.forced_on)
.field("forced_off", &self.forced_off)
.field("store", &self.store.name())
.finish()
}
}
#[derive(Clone)]
pub struct ScopedFlags {
flags: Flags,
scope: Scope,
resolved: Arc<Mutex<HashMap<String, bool>>>,
}
impl ScopedFlags {
pub fn scope(&self) -> &Scope {
&self.scope
}
pub fn flags(&self) -> &Flags {
&self.flags
}
pub async fn active(&self, flag: &str) -> Result<bool> {
if let Some(answer) = self.remembered(flag) {
return Ok(answer);
}
let answer = self.flags.resolve(flag, &self.scope).await?;
self.remember(flag, answer);
Ok(answer)
}
pub async fn inactive(&self, flag: &str) -> Result<bool> {
Ok(!self.active(flag).await?)
}
pub async fn values<I, S>(&self, flags: I) -> Result<BTreeMap<String, bool>>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let names: Vec<String> = flags.into_iter().map(Into::into).collect();
let mut answers = BTreeMap::new();
for name in names {
let answer = self.active(&name).await?;
answers.insert(name, answer);
}
Ok(answers)
}
pub async fn all(&self) -> Result<BTreeMap<String, bool>> {
let names: Vec<String> = self.flags.defined().into_iter().map(str::to_string).collect();
self.values(names).await
}
pub async fn any<I, S>(&self, flags: I) -> Result<bool>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let names: Vec<String> = flags.into_iter().map(Into::into).collect();
for name in names {
if self.active(&name).await? {
return Ok(true);
}
}
Ok(false)
}
pub fn refresh(&self) {
self.lock().clear();
}
pub fn resolved_count(&self) -> usize {
self.lock().len()
}
fn remembered(&self, flag: &str) -> Option<bool> {
self.lock().get(flag).copied()
}
fn remember(&self, flag: &str, answer: bool) {
self.lock().insert(flag.to_string(), answer);
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, bool>> {
self.resolved.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
}
impl std::fmt::Debug for ScopedFlags {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ScopedFlags")
.field("scope", &self.scope)
.field("resolved", &*self.lock())
.finish()
}
}
pub(crate) fn missing_registry() -> Error {
Error::msg(
"feature flags were checked, but there is no `Flags` in application state. Register them \
with `App::new().plugin(FeatureFlags::new(flags))`, or put one there yourself with \
`Context::builder().state(flags)`. Refusing to guess whether the flag is on.",
)
}
#[cfg(test)]
mod tests {
static ENVIRONMENT: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn the_environment_is_read_when_no_config_file_maps_it() {
let _guard = ENVIRONMENT.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("FLAGS_ON", "beta, later");
std::env::set_var("FLAGS_OFF", "broken");
}
let flags = Flags::from_config(&Config::new()).define("broken", |_| async { true });
assert!(flags.forced_on.contains("beta"), "FLAGS_ON was not read");
assert!(flags.forced_on.contains("later"), "the comma-separated spelling was not split");
assert!(flags.forced_off.contains("broken"), "FLAGS_OFF was not read");
unsafe {
std::env::remove_var("FLAGS_ON");
std::env::remove_var("FLAGS_OFF");
}
}
#[test]
fn a_configuration_file_wins_over_the_environment() {
let _guard = ENVIRONMENT.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("FLAGS_ON", "from-environment");
}
let config = Config::new();
config.set("flags.on", "from-config");
let flags = Flags::from_config(&config);
assert!(flags.forced_on.contains("from-config"));
assert!(!flags.forced_on.contains("from-environment"));
unsafe {
std::env::remove_var("FLAGS_ON");
}
}
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn flags() -> Flags {
Flags::new()
.define("new-checkout", |scope: Scope| async move { scope.id().ends_with('7') })
.define("beta-search", |_| async { false })
}
fn config(on: &str, off: &str) -> Config {
let config = Config::new();
config.set("flags.on", on);
config.set("flags.off", off);
config
}
#[tokio::test]
async fn a_resolver_decides_when_nothing_else_has_an_opinion() {
let flags = flags();
assert!(flags.active("new-checkout", &Scope::user_id(17)).await.unwrap());
assert!(flags.inactive("new-checkout", &Scope::user_id(18)).await.unwrap());
assert!(flags.inactive("beta-search", &Scope::user_id(17)).await.unwrap());
}
#[tokio::test]
async fn a_flag_nobody_defined_is_off() {
assert!(flags().inactive("never-existed", &Scope::none()).await.unwrap());
assert!(!flags().is_defined("never-existed"));
assert_eq!(flags().defined(), ["beta-search", "new-checkout"]);
}
#[tokio::test]
async fn a_resolver_may_fail_and_the_failure_is_not_a_no() {
let flags = Flags::new()
.define("flaky", |_| async { Result::<bool>::Err(Error::msg("the database is down")) });
let error = flags.active("flaky", &Scope::none()).await.unwrap_err();
assert!(error.to_string().contains("the database is down"), "{error}");
}
#[tokio::test]
async fn an_override_for_a_scope_beats_the_resolver() {
let flags = flags();
let seven = Scope::user_id(7);
let eight = Scope::user_id(8);
flags.deactivate_for("new-checkout", &seven).await.unwrap();
flags.activate_for("beta-search", &eight).await.unwrap();
assert!(flags.inactive("new-checkout", &seven).await.unwrap());
assert!(flags.active("beta-search", &eight).await.unwrap());
assert!(flags.active("new-checkout", &Scope::user_id(27)).await.unwrap());
assert!(flags.inactive("beta-search", &Scope::user_id(9)).await.unwrap());
}
#[tokio::test]
async fn forgetting_an_override_gives_the_flag_back_to_the_resolver() {
let flags = flags();
let seven = Scope::user_id(7);
flags.deactivate_for("new-checkout", &seven).await.unwrap();
assert!(flags.inactive("new-checkout", &seven).await.unwrap());
flags.forget_for("new-checkout", &seven).await.unwrap();
assert!(flags.active("new-checkout", &seven).await.unwrap());
}
#[tokio::test]
async fn a_global_override_reaches_every_scope() {
let flags = flags();
flags.activate("beta-search").await.unwrap();
assert!(flags.active("beta-search", &Scope::user_id(1)).await.unwrap());
assert!(flags.active("beta-search", &Scope::tenant("acme")).await.unwrap());
assert!(flags.active("beta-search", &Scope::none()).await.unwrap());
}
#[tokio::test]
async fn a_scope_override_and_a_global_one_agreeing_is_uneventful() {
let flags = flags();
flags.activate("beta-search").await.unwrap();
flags.activate_for("beta-search", &Scope::user_id(1)).await.unwrap();
assert!(flags.active("beta-search", &Scope::user_id(1)).await.unwrap());
flags.deactivate("new-checkout").await.unwrap();
flags.deactivate_for("new-checkout", &Scope::user_id(17)).await.unwrap();
assert!(flags.inactive("new-checkout", &Scope::user_id(17)).await.unwrap());
}
#[tokio::test]
async fn a_global_off_beats_a_scope_on() {
let flags = flags();
flags.activate_for("beta-search", &Scope::user_id(1)).await.unwrap();
assert!(flags.active("beta-search", &Scope::user_id(1)).await.unwrap());
flags.deactivate("beta-search").await.unwrap();
assert!(flags.inactive("beta-search", &Scope::user_id(1)).await.unwrap());
flags.forget("beta-search").await.unwrap();
assert!(flags.active("beta-search", &Scope::user_id(1)).await.unwrap());
}
#[tokio::test]
async fn a_scope_off_beats_a_global_on() {
let flags = flags();
flags.activate("beta-search").await.unwrap();
flags.deactivate_for("beta-search", &Scope::user_id(1)).await.unwrap();
assert!(flags.inactive("beta-search", &Scope::user_id(1)).await.unwrap());
assert!(flags.active("beta-search", &Scope::user_id(2)).await.unwrap());
}
#[tokio::test]
async fn config_off_beats_every_override_there_is() {
let flags = flags().configured(&config("", "new-checkout,beta-search"));
flags.activate("new-checkout").await.unwrap();
flags.activate_for("new-checkout", &Scope::user_id(17)).await.unwrap();
flags.activate_for("beta-search", &Scope::tenant("acme")).await.unwrap();
assert!(flags.inactive("new-checkout", &Scope::user_id(17)).await.unwrap());
assert!(flags.inactive("new-checkout", &Scope::none()).await.unwrap());
assert!(flags.inactive("beta-search", &Scope::tenant("acme")).await.unwrap());
}
#[tokio::test]
async fn config_on_beats_the_resolver_but_not_an_override() {
let flags = flags().configured(&config("beta-search", ""));
assert!(flags.active("beta-search", &Scope::user_id(1)).await.unwrap());
flags.deactivate_for("beta-search", &Scope::user_id(1)).await.unwrap();
assert!(flags.inactive("beta-search", &Scope::user_id(1)).await.unwrap());
assert!(flags.active("beta-search", &Scope::user_id(2)).await.unwrap());
}
#[tokio::test]
async fn a_name_in_both_config_lists_is_off() {
let flags = flags().configured(&config("beta-search", "beta-search"));
assert!(flags.inactive("beta-search", &Scope::user_id(1)).await.unwrap());
}
#[tokio::test]
async fn config_can_force_a_flag_nobody_defined() {
let flags = flags().configured(&config("unshipped", ""));
assert!(flags.active("unshipped", &Scope::none()).await.unwrap());
}
#[tokio::test]
async fn config_lists_accept_a_json_array_as_well_as_a_comma_separated_string() {
let config = Config::new();
config.set(
"flags.off",
rustlavel_core::Json::Array(vec![
rustlavel_core::Json::from("new-checkout"),
rustlavel_core::Json::from("beta-search"),
]),
);
let flags = flags().configured(&config);
assert!(flags.inactive("new-checkout", &Scope::user_id(17)).await.unwrap());
assert!(flags.inactive("beta-search", &Scope::user_id(17)).await.unwrap());
}
#[tokio::test]
async fn config_off_survives_a_store_that_cannot_answer() {
let flags = Flags::new().store(BrokenStore).configured(&config("", "new-checkout"));
assert!(flags.inactive("new-checkout", &Scope::user_id(17)).await.unwrap());
assert!(flags.active("beta-search", &Scope::user_id(17)).await.is_err());
}
struct BrokenStore;
impl FlagStore for BrokenStore {
fn name(&self) -> &'static str {
"broken"
}
fn get<'a>(
&'a self,
_flag: &'a str,
_scope: &'a Scope,
) -> crate::store::BoxFuture<'a, Result<Option<bool>>> {
Box::pin(async { Err(Error::msg("the flag store is unreachable")) })
}
fn set<'a>(
&'a self,
_flag: &'a str,
_scope: &'a Scope,
_value: bool,
) -> crate::store::BoxFuture<'a, Result<()>> {
Box::pin(async { Err(Error::msg("the flag store is unreachable")) })
}
fn forget<'a>(
&'a self,
_flag: &'a str,
_scope: &'a Scope,
) -> crate::store::BoxFuture<'a, Result<()>> {
Box::pin(async { Err(Error::msg("the flag store is unreachable")) })
}
fn flush(&self) -> crate::store::BoxFuture<'_, Result<()>> {
Box::pin(async { Err(Error::msg("the flag store is unreachable")) })
}
}
#[tokio::test]
async fn a_rollout_puts_the_same_scope_in_the_same_bucket_across_instances() {
let first: Vec<bool> = {
let flags = Flags::new().rollout("dark-mode", 25);
let mut answers = Vec::new();
for id in 0..500 {
answers.push(flags.active("dark-mode", &Scope::user_id(id)).await.unwrap());
}
answers
};
let second: Vec<bool> = {
let flags = Flags::new().rollout("dark-mode", 25);
let mut answers = Vec::new();
for id in 0..500 {
answers.push(flags.active("dark-mode", &Scope::user_id(id)).await.unwrap());
}
answers
};
assert_eq!(first, second);
assert!(first.iter().any(|on| *on), "a 25% rollout reached nobody at all");
assert!(first.iter().any(|on| !*on), "a 25% rollout reached everybody");
}
#[tokio::test]
async fn a_rollout_is_roughly_the_proportion_it_says_over_ten_thousand_scopes() {
let flags = Flags::new().rollout("dark-mode", 25);
let mut inside = 0;
for id in 0..10_000 {
if flags.active("dark-mode", &Scope::user_id(id)).await.unwrap() {
inside += 1;
}
}
assert!((2_300..=2_700).contains(&inside), "25% of 10,000 scopes came out as {inside}");
}
#[tokio::test]
async fn a_rollout_is_still_overridable() {
let flags = Flags::new().rollout("dark-mode", 0);
assert!(flags.inactive("dark-mode", &Scope::user_id(41)).await.unwrap());
flags.activate_for("dark-mode", &Scope::user_id(41)).await.unwrap();
assert!(flags.active("dark-mode", &Scope::user_id(41)).await.unwrap());
}
fn counted() -> (Flags, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let flags = Flags::new().define("slow", move |_| {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, Ordering::SeqCst);
true
}
});
(flags, calls)
}
#[tokio::test]
async fn a_scoped_view_runs_a_resolver_once_however_often_it_is_asked() {
let (flags, calls) = counted();
let view = flags.for_scope(Scope::user_id(41));
for _ in 0..10 {
assert!(view.active("slow").await.unwrap());
}
assert!(!view.inactive("slow").await.unwrap());
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(view.resolved_count(), 1);
}
#[tokio::test]
async fn asking_the_registry_directly_resolves_every_time() {
let (flags, calls) = counted();
for _ in 0..10 {
flags.active("slow", &Scope::user_id(41)).await.unwrap();
}
assert_eq!(calls.load(Ordering::SeqCst), 10);
}
#[tokio::test]
async fn two_scopes_are_two_answers_and_two_calls() {
let (flags, calls) = counted();
flags.for_scope(Scope::user_id(1)).active("slow").await.unwrap();
flags.for_scope(Scope::user_id(2)).active("slow").await.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn a_batch_of_flags_costs_one_call_each() {
let (flags, calls) = counted();
let flags = flags.define("other", |_| async { false });
let view = flags.for_scope(Scope::user_id(41));
let values = view.values(["slow", "other", "slow", "other"]).await.unwrap();
assert_eq!(values.len(), 2);
assert!(values["slow"]);
assert!(!values["other"]);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn refreshing_a_view_makes_it_ask_again() {
let (flags, calls) = counted();
let view = flags.for_scope(Scope::user_id(41));
view.active("slow").await.unwrap();
view.refresh();
view.active("slow").await.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert_eq!(view.scope(), &Scope::user_id(41));
}
#[tokio::test]
async fn a_view_answers_for_every_defined_flag_at_once() {
let view = flags().for_scope(Scope::user_id(17));
let all = view.all().await.unwrap();
assert_eq!(all.len(), 2);
assert!(all["new-checkout"]);
assert!(!all["beta-search"]);
assert!(view.any(["beta-search", "new-checkout"]).await.unwrap());
assert!(!view.any(["beta-search", "never-existed"]).await.unwrap());
}
#[tokio::test]
async fn a_view_carries_the_failure_of_a_resolver_rather_than_swallowing_it() {
let flags =
Flags::new().define("flaky", |_| async { Result::<bool>::Err(Error::msg("nope")) });
assert!(flags.for_scope(Scope::none()).values(["flaky"]).await.is_err());
}
#[tokio::test]
async fn purge_drops_every_override() {
let flags = flags();
flags.deactivate_for("new-checkout", &Scope::user_id(17)).await.unwrap();
flags.activate("beta-search").await.unwrap();
flags.purge().await.unwrap();
assert!(flags.active("new-checkout", &Scope::user_id(17)).await.unwrap());
assert!(flags.inactive("beta-search", &Scope::user_id(17)).await.unwrap());
}
#[tokio::test]
async fn a_clone_shares_the_store_and_the_definitions() {
let flags = flags();
let handle = flags.clone();
handle.activate("beta-search").await.unwrap();
assert!(flags.active("beta-search", &Scope::none()).await.unwrap());
}
#[tokio::test]
async fn defining_a_name_twice_replaces_it() {
let flags = flags().define("beta-search", |_| async { true });
assert!(flags.active("beta-search", &Scope::none()).await.unwrap());
assert_eq!(flags.defined().len(), 2);
}
#[tokio::test]
async fn a_store_can_be_supplied_as_a_shared_handle() {
let store = MemoryStore::new();
let flags = Flags::new().with_store(Arc::new(store.clone()) as Arc<dyn FlagStore>);
flags.activate("beta-search").await.unwrap();
assert_eq!(store.get("beta-search", &Scope::none()).await.unwrap(), Some(true));
assert_eq!(flags.store_handle().name(), "memory");
}
#[test]
fn the_debug_output_names_the_flags() {
let rendered = format!("{:?}", flags().configured(&config("a", "b")));
assert!(rendered.contains("new-checkout"), "{rendered}");
assert!(rendered.contains("forced_off"), "{rendered}");
assert!(rendered.contains("memory"), "{rendered}");
}
#[test]
fn the_missing_registry_error_says_how_to_fix_it() {
let message = missing_registry().to_string();
assert!(message.contains("FeatureFlags::new"), "{message}");
assert!(message.contains("Refusing to guess"), "{message}");
}
}