Skip to main content

rustlavel_flags/
flags.rs

1//! The registry: defining flags, checking them, and overriding the answer.
2//!
3//! ```
4//! use rustlavel_flags::{Flags, Scope};
5//!
6//! # tokio::runtime::Runtime::new().unwrap().block_on(async {
7//! let flags = Flags::new()
8//!     .define("new-checkout", |scope| async move { scope.id().ends_with('7') })
9//!     .define("beta-search", |_| async { false })
10//!     .rollout("dark-mode", 25);
11//!
12//! assert!(flags.active("new-checkout", &Scope::user_id(17)).await.unwrap());
13//! assert!(flags.inactive("beta-search", &Scope::user_id(17)).await.unwrap());
14//!
15//! // ...except for this one customer, who asked.
16//! flags.activate_for("beta-search", &Scope::user_id(17)).await.unwrap();
17//! assert!(flags.active("beta-search", &Scope::user_id(17)).await.unwrap());
18//! # });
19//! ```
20
21use crate::rollout;
22use crate::scope::Scope;
23use crate::store::{FlagStore, MemoryStore};
24use rustlavel_core::{Config, Error, Result};
25use std::collections::{BTreeMap, BTreeSet, HashMap};
26use std::future::Future;
27use std::pin::Pin;
28use std::sync::{Arc, Mutex};
29
30/// What a resolver hands back once it has been boxed.
31type ResolverFuture = Pin<Box<dyn Future<Output = Result<bool>> + Send + 'static>>;
32
33/// A defined flag's code, ready to be called with a scope.
34type Resolver = Arc<dyn Fn(Scope) -> ResolverFuture + Send + Sync>;
35
36/// What a resolver may return.
37///
38/// A resolver that just looks at the scope returns `bool`, and one that has to
39/// go and ask a database returns `Result<bool>` — both are written the obvious
40/// way and this trait is what lets [`Flags::define`] take either. There is no
41/// third implementation and no reason for one: a flag is on or off, or the
42/// question could not be answered.
43pub trait FlagAnswer {
44    fn into_answer(self) -> Result<bool>;
45}
46
47impl FlagAnswer for bool {
48    fn into_answer(self) -> Result<bool> {
49        Ok(self)
50    }
51}
52
53impl FlagAnswer for Result<bool> {
54    fn into_answer(self) -> Result<bool> {
55        self
56    }
57}
58
59/// Every flag the application knows about, and the store behind them.
60///
61/// Cheap to clone — the definitions and the store are behind `Arc` — so it goes
62/// into application state once and every request gets a handle to the same one.
63///
64/// # Precedence
65///
66/// Five things can have an opinion about one flag, and the question this type
67/// exists to answer is "why is this on for this user?". So the answer is one
68/// short list, applied in order:
69///
70/// 1. **`flags.off` in configuration** — off, for everybody, full stop. This is
71///    the incident switch and nothing below it can reopen the flag.
72/// 2. **A stored override of `false`**, for this scope or globally — off.
73/// 3. **A stored override of `true`**, for this scope or globally — on.
74/// 4. **`flags.on` in configuration** — on.
75/// 5. **The resolver**, if the flag was defined.
76/// 6. Otherwise off. A flag nobody defined is not a flag.
77///
78/// Two of those deserve their reasons written down.
79///
80/// **Off beats on among stored overrides**, at every level, and a *global* off
81/// therefore beats a per-scope on. This is the same rule
82/// [`rustlavel_rbac`](https://docs.rs/rustlavel-rbac) applies to a deny, for
83/// the same reason: an off switch that something else can quietly overrule is
84/// not a switch, and the moment somebody reaches for one is the moment they
85/// cannot afford to audit what else might be set. The cost is real and worth
86/// knowing — you cannot exempt one customer from a global off, you have to
87/// clear the global off first — and it is the cheaper half of the trade.
88///
89/// **Configuration outranks the store in both directions.** `flags.off` is read
90/// from the environment, which means it survives a database being down, a
91/// store being unreachable, and a cache that has not caught up. That is exactly
92/// the situation you reach for it in.
93#[derive(Clone)]
94pub struct Flags {
95    definitions: Arc<HashMap<String, Resolver>>,
96    store: Arc<dyn FlagStore>,
97    forced_on: Arc<BTreeSet<String>>,
98    forced_off: Arc<BTreeSet<String>>,
99}
100
101impl Default for Flags {
102    fn default() -> Self {
103        Flags::new()
104    }
105}
106
107impl Flags {
108    /// An empty registry over an in-memory store.
109    pub fn new() -> Self {
110        Flags {
111            definitions: Arc::new(HashMap::new()),
112            store: Arc::new(MemoryStore::new()),
113            forced_on: Arc::new(BTreeSet::new()),
114            forced_off: Arc::new(BTreeSet::new()),
115        }
116    }
117
118    /// An empty registry, with `flags.on` and `flags.off` already applied.
119    ///
120    /// Define the flags on top of it: `Flags::from_config(&config).define(...)`.
121    pub fn from_config(config: &Config) -> Self {
122        Flags::new().configured(config)
123    }
124
125    /// Read `flags.on` and `flags.off` and add them to what is already forced.
126    ///
127    /// Both are lists, which in `.env` means the comma-separated spelling —
128    /// `FLAGS_OFF=new-checkout,beta-search` — and in a configuration file may
129    /// be a JSON array. [`Config::list`] accepts either.
130    ///
131    /// It *adds*: calling it twice, or calling it after [`Flags::force_off`],
132    /// leaves both sets of names forced. There is deliberately no way to
133    /// un-force a name from configuration, because the only reason to want one
134    /// is to undo an incident switch, and undoing an incident switch should
135    /// take an edit to the thing that set it.
136    ///
137    /// A name in both lists is off. It has to be — see the precedence chain on
138    /// [`Flags`] — and a deployment that has contradicted itself is one where
139    /// the safe reading of the contradiction is the closed one.
140    /// The environment is read directly when the configuration key is absent,
141    /// which is what makes the claim above true. `flags.off` is described as
142    /// the switch that survives a database being down; it would not survive an
143    /// application with no `config/flags.json` in it, and an application that
144    /// added this crate by hand has no reason to have one. So `FLAGS_OFF` in
145    /// the environment works whether or not anything maps it into config.
146    pub fn configured(self, config: &Config) -> Self {
147        let listed = |key: &str, variable: &str| {
148            let from_config = config.list(key);
149            if !from_config.is_empty() {
150                return from_config;
151            }
152            rustlavel_core::env::env_or(variable, "")
153                .split(',')
154                .map(str::trim)
155                .filter(|name| !name.is_empty())
156                .map(str::to_string)
157                .collect()
158        };
159
160        self.force_on(listed("flags.on", "FLAGS_ON"))
161            .force_off(listed("flags.off", "FLAGS_OFF"))
162    }
163
164    /// Put a store behind the flags: a database table, Redis, anything that
165    /// implements [`FlagStore`].
166    pub fn store(mut self, store: impl FlagStore) -> Self {
167        self.store = Arc::new(store);
168        self
169    }
170
171    /// Use a store somebody else already built and holds a handle to.
172    pub fn with_store(mut self, store: Arc<dyn FlagStore>) -> Self {
173        self.store = store;
174        self
175    }
176
177    /// Define a flag: a name, and code that decides it for a scope.
178    ///
179    /// The resolver is given the [`Scope`] by value and may do anything an
180    /// async function may do, including go to the database. It is called at
181    /// most once per scope inside a [`ScopedFlags`] batch, so a slow resolver
182    /// costs its slowness once per request rather than once per question.
183    ///
184    /// ```
185    /// # use rustlavel_flags::{Flags, Scope};
186    /// let flags = Flags::new()
187    ///     .define("new-checkout", |scope: Scope| async move { scope.id().ends_with('7') })
188    ///     .define("beta-search", |_| async { false });
189    /// # let _ = flags;
190    /// ```
191    ///
192    /// Defining a name twice replaces the first definition rather than
193    /// complaining. That is what makes a test able to say "this application,
194    /// but with `new-checkout` on" in one line.
195    pub fn define<F, Fut, A>(mut self, name: impl Into<String>, resolver: F) -> Self
196    where
197        F: Fn(Scope) -> Fut + Send + Sync + 'static,
198        Fut: Future<Output = A> + Send + 'static,
199        A: FlagAnswer,
200    {
201        let boxed: Resolver = Arc::new(move |scope| {
202            let future = resolver(scope);
203            Box::pin(async move { future.await.into_answer() })
204        });
205
206        // `make_mut` clones the map only while somebody else is holding a
207        // handle to it, which during the builder chain in `main.rs` nobody is.
208        Arc::make_mut(&mut self.definitions).insert(name.into(), boxed);
209        self
210    }
211
212    /// Define a flag as a stable percentage rollout.
213    ///
214    /// Shorthand for a resolver calling [`rollout::in_rollout`]; read that
215    /// module for why the bucket is hashed rather than drawn, and for what
216    /// widening a rollout does and does not do to the people already in it.
217    ///
218    /// Checked against [`Scope::none`] a rollout is not a percentage of
219    /// anything — there is one scope, so it lands in one bucket and stays
220    /// there, on or off for the whole installation. Percentages need a subject.
221    pub fn rollout(self, name: impl Into<String>, percent: u8) -> Self {
222        let name = name.into();
223        let flag = name.clone();
224
225        self.define(name, move |scope: Scope| {
226            let flag = flag.clone();
227            async move { rollout::in_rollout(&flag, &scope, percent) }
228        })
229    }
230
231    /// Force these flags on, below any stored override. See [`Flags::configured`].
232    pub fn force_on<I, S>(mut self, names: I) -> Self
233    where
234        I: IntoIterator<Item = S>,
235        S: Into<String>,
236    {
237        Arc::make_mut(&mut self.forced_on).extend(names.into_iter().map(Into::into));
238        self
239    }
240
241    /// Force these flags off, above everything. See [`Flags::configured`].
242    pub fn force_off<I, S>(mut self, names: I) -> Self
243    where
244        I: IntoIterator<Item = S>,
245        S: Into<String>,
246    {
247        Arc::make_mut(&mut self.forced_off).extend(names.into_iter().map(Into::into));
248        self
249    }
250
251    /// Every defined flag's name, sorted. Does not include names that only
252    /// exist as a forced value or a stored override.
253    pub fn defined(&self) -> Vec<&str> {
254        let mut names: Vec<&str> = self.definitions.keys().map(String::as_str).collect();
255        names.sort_unstable();
256        names
257    }
258
259    /// Whether a resolver was registered under this name.
260    ///
261    /// Worth checking in a boot-time assertion: an undefined flag is off, and a
262    /// misspelled one is indistinguishable from a feature that never shipped.
263    pub fn is_defined(&self, flag: &str) -> bool {
264        self.definitions.contains_key(flag)
265    }
266
267    /// The store behind the flags, for an admin route that writes overrides.
268    pub fn store_handle(&self) -> &Arc<dyn FlagStore> {
269        &self.store
270    }
271
272    /// Is this flag on for this scope?
273    ///
274    /// `Result`, and not `bool`, deliberately. "I could not find out" is a
275    /// third answer — a store that is down, a resolver whose query failed — and
276    /// collapsing it into `false` would silently turn a feature off for
277    /// everybody the moment something unrelated broke, which is exactly the
278    /// kind of outage nobody thinks to look for.
279    ///
280    /// This call does not remember anything. Use [`Flags::for_scope`] when a
281    /// request asks more than one question.
282    pub async fn active(&self, flag: &str, scope: &Scope) -> Result<bool> {
283        self.resolve(flag, scope).await
284    }
285
286    /// The negation, for the reading that comes out shorter.
287    pub async fn inactive(&self, flag: &str, scope: &Scope) -> Result<bool> {
288        Ok(!self.active(flag, scope).await?)
289    }
290
291    /// A view fixed on one scope, which remembers what it has already resolved.
292    ///
293    /// This is what a request should hold. Ten checks against one
294    /// [`ScopedFlags`] run each resolver at most once; ten checks against
295    /// [`Flags::active`] run each of them ten times.
296    pub fn for_scope(&self, scope: Scope) -> ScopedFlags {
297        ScopedFlags {
298            flags: self.clone(),
299            scope,
300            resolved: Arc::new(Mutex::new(HashMap::new())),
301        }
302    }
303
304    /// Turn a flag on for one scope, overriding whatever the resolver says.
305    pub async fn activate_for(&self, flag: &str, scope: &Scope) -> Result<()> {
306        self.store.set(flag, scope, true).await
307    }
308
309    /// Turn a flag off for one scope, overriding whatever the resolver says.
310    pub async fn deactivate_for(&self, flag: &str, scope: &Scope) -> Result<()> {
311        self.store.set(flag, scope, false).await
312    }
313
314    /// Drop one scope's override and let the resolver decide again.
315    pub async fn forget_for(&self, flag: &str, scope: &Scope) -> Result<()> {
316        self.store.forget(flag, scope).await
317    }
318
319    /// Turn a flag on for everybody who has no override of their own.
320    pub async fn activate(&self, flag: &str) -> Result<()> {
321        self.activate_for(flag, &Scope::none()).await
322    }
323
324    /// Turn a flag off for everybody, including scopes that have an override
325    /// of their own saying otherwise — see the precedence chain on [`Flags`].
326    pub async fn deactivate(&self, flag: &str) -> Result<()> {
327        self.deactivate_for(flag, &Scope::none()).await
328    }
329
330    /// Drop the global override. Per-scope overrides are left alone.
331    pub async fn forget(&self, flag: &str) -> Result<()> {
332        self.forget_for(flag, &Scope::none()).await
333    }
334
335    /// Drop every override in the store, for every flag and every scope.
336    pub async fn purge(&self) -> Result<()> {
337        self.store.flush().await
338    }
339
340    /// The precedence chain, in the order the doc comment on [`Flags`] lists it.
341    async fn resolve(&self, flag: &str, scope: &Scope) -> Result<bool> {
342        // 1. The incident switch. Read before the store is touched, so it works
343        //    when the store is the thing that is broken.
344        if self.forced_off.contains(flag) {
345            return Ok(false);
346        }
347
348        let scoped = self.store.get(flag, scope).await?;
349        // A global scope is its own global override; asking twice would be the
350        // same lookup and, on a store that charges for one, the same round trip.
351        let global = if scope.is_none() {
352            scoped
353        } else {
354            self.store.get(flag, &Scope::none()).await?
355        };
356
357        // 2 and 3. An override decides it, and among overrides, off wins.
358        if scoped == Some(false) || global == Some(false) {
359            return Ok(false);
360        }
361        if scoped == Some(true) || global == Some(true) {
362            return Ok(true);
363        }
364
365        // 4. Forced on by configuration.
366        if self.forced_on.contains(flag) {
367            return Ok(true);
368        }
369
370        // 5 and 6. The resolver, or nothing.
371        match self.definitions.get(flag) {
372            Some(resolver) => resolver(scope.clone()).await,
373            None => Ok(false),
374        }
375    }
376}
377
378impl std::fmt::Debug for Flags {
379    /// The names and the forced lists. Resolvers are closures and have nothing
380    /// to show, but which flags exist is the thing you actually want in a log
381    /// line when a check came back with an answer you did not expect.
382    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        f.debug_struct("Flags")
384            .field("defined", &self.defined())
385            .field("forced_on", &self.forced_on)
386            .field("forced_off", &self.forced_off)
387            .field("store", &self.store.name())
388            .finish()
389    }
390}
391
392/// [`Flags`] fixed on one scope, remembering what it has already resolved.
393///
394/// Build one per request — [`Flags::for_scope`], or
395/// [`FlagsExt::scoped_flags`](crate::FlagsExt) inside a handler — and ask it
396/// everything. Each flag is resolved at most once for as long as the view
397/// lives, so a resolver that costs a query costs it once however many times the
398/// page asks.
399///
400/// The memory is a memo, not a lock: two tasks checking the same unresolved
401/// flag on one clone may both run the resolver, and one of the two answers is
402/// kept. Serialising them would mean holding a lock across the resolver's
403/// `await` — turning every slow resolver into a queue — to save a duplicated
404/// call that produces the same answer anyway.
405///
406/// It is also *not* a cache with a lifetime. An override written after a
407/// [`ScopedFlags`] has resolved a flag is not seen by that view; call
408/// [`ScopedFlags::refresh`] or, more usually, let the request end.
409#[derive(Clone)]
410pub struct ScopedFlags {
411    flags: Flags,
412    scope: Scope,
413    resolved: Arc<Mutex<HashMap<String, bool>>>,
414}
415
416impl ScopedFlags {
417    /// Who this view is answering for.
418    pub fn scope(&self) -> &Scope {
419        &self.scope
420    }
421
422    /// The registry behind the view.
423    pub fn flags(&self) -> &Flags {
424        &self.flags
425    }
426
427    /// Is this flag on for the view's scope?
428    pub async fn active(&self, flag: &str) -> Result<bool> {
429        if let Some(answer) = self.remembered(flag) {
430            return Ok(answer);
431        }
432
433        let answer = self.flags.resolve(flag, &self.scope).await?;
434        self.remember(flag, answer);
435        Ok(answer)
436    }
437
438    /// The negation.
439    pub async fn inactive(&self, flag: &str) -> Result<bool> {
440        Ok(!self.active(flag).await?)
441    }
442
443    /// Several flags at once, in one pass.
444    ///
445    /// The shape a template wants: hand it a map and let it ask by name,
446    /// rather than threading a `bool` per feature through the view model.
447    ///
448    /// ```
449    /// # use rustlavel_flags::{Flags, Scope};
450    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
451    /// let flags = Flags::new().define("a", |_| async { true }).define("b", |_| async { false });
452    /// let values = flags.for_scope(Scope::user_id(41)).values(["a", "b"]).await.unwrap();
453    ///
454    /// assert!(values["a"]);
455    /// assert!(!values["b"]);
456    /// # });
457    /// ```
458    ///
459    /// Resolved one after another rather than concurrently: a resolver that
460    /// takes a database connection would otherwise take as many as there are
461    /// flags on the page, all at once, which is how a feature-flag call turns
462    /// into a connection-pool outage.
463    pub async fn values<I, S>(&self, flags: I) -> Result<BTreeMap<String, bool>>
464    where
465        I: IntoIterator<Item = S>,
466        S: Into<String>,
467    {
468        let names: Vec<String> = flags.into_iter().map(Into::into).collect();
469        let mut answers = BTreeMap::new();
470
471        for name in names {
472            let answer = self.active(&name).await?;
473            answers.insert(name, answer);
474        }
475
476        Ok(answers)
477    }
478
479    /// Every *defined* flag, for this scope.
480    ///
481    /// Note the word: a flag that only exists as a stored override or a
482    /// `flags.on` entry is not in the list, because nothing has told this
483    /// process it exists. Use it for a debugging endpoint, not as the truth
484    /// about what the installation has switched on.
485    pub async fn all(&self) -> Result<BTreeMap<String, bool>> {
486        let names: Vec<String> = self.flags.defined().into_iter().map(str::to_string).collect();
487        self.values(names).await
488    }
489
490    /// Whether any of these flags is on. Stops at the first one that is.
491    pub async fn any<I, S>(&self, flags: I) -> Result<bool>
492    where
493        I: IntoIterator<Item = S>,
494        S: Into<String>,
495    {
496        let names: Vec<String> = flags.into_iter().map(Into::into).collect();
497
498        for name in names {
499            if self.active(&name).await? {
500                return Ok(true);
501            }
502        }
503
504        Ok(false)
505    }
506
507    /// Forget what has been resolved, so the next check asks again.
508    ///
509    /// For the one case that needs it: code that writes an override and then
510    /// wants to see its own change through the same view.
511    pub fn refresh(&self) {
512        self.lock().clear();
513    }
514
515    /// How many flags this view has resolved so far. Tests use it to prove the
516    /// memo exists; a debugging endpoint can show it.
517    pub fn resolved_count(&self) -> usize {
518        self.lock().len()
519    }
520
521    fn remembered(&self, flag: &str) -> Option<bool> {
522        self.lock().get(flag).copied()
523    }
524
525    fn remember(&self, flag: &str, answer: bool) {
526        self.lock().insert(flag.to_string(), answer);
527    }
528
529    /// A poisoned lock means a thread panicked while holding it. What is inside
530    /// is a map of answers already computed — nothing can be half-written — so
531    /// it is recovered rather than turning one panic into a panic per request.
532    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, bool>> {
533        self.resolved.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
534    }
535}
536
537impl std::fmt::Debug for ScopedFlags {
538    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
539        f.debug_struct("ScopedFlags")
540            .field("scope", &self.scope)
541            .field("resolved", &*self.lock())
542            .finish()
543    }
544}
545
546/// The error a handler gets when the package was never registered.
547///
548/// Lives here so [`FlagsExt`](crate::FlagsExt) and the middleware say the same
549/// thing, and so the sentence is written once.
550pub(crate) fn missing_registry() -> Error {
551    Error::msg(
552        "feature flags were checked, but there is no `Flags` in application state. Register them \
553         with `App::new().plugin(FeatureFlags::new(flags))`, or put one there yourself with \
554         `Context::builder().state(flags)`. Refusing to guess whether the flag is on.",
555    )
556}
557
558#[cfg(test)]
559mod tests {
560    /// A guard so two tests never hold the same process-wide variable at once.
561    ///
562    /// `std::env` is global, and the suite runs concurrently — rule six in
563    /// CLAUDE.md is about exactly this.
564    static ENVIRONMENT: std::sync::Mutex<()> = std::sync::Mutex::new(());
565
566    #[test]
567    fn the_environment_is_read_when_no_config_file_maps_it() {
568        // The documented incident switch is `FLAGS_OFF=name` in `.env`. An
569        // application that added this crate by hand has no `config/flags.json`
570        // to map that into configuration, and the switch has to work anyway.
571        let _guard = ENVIRONMENT.lock().unwrap_or_else(|e| e.into_inner());
572        unsafe {
573            std::env::set_var("FLAGS_ON", "beta, later");
574            std::env::set_var("FLAGS_OFF", "broken");
575        }
576
577        let flags = Flags::from_config(&Config::new()).define("broken", |_| async { true });
578
579        assert!(flags.forced_on.contains("beta"), "FLAGS_ON was not read");
580        assert!(flags.forced_on.contains("later"), "the comma-separated spelling was not split");
581        assert!(flags.forced_off.contains("broken"), "FLAGS_OFF was not read");
582
583        unsafe {
584            std::env::remove_var("FLAGS_ON");
585            std::env::remove_var("FLAGS_OFF");
586        }
587    }
588
589    #[test]
590    fn a_configuration_file_wins_over_the_environment() {
591        // Config is the more specific statement: somebody wrote a file for this
592        // application, and an inherited shell variable must not quietly replace
593        // it.
594        let _guard = ENVIRONMENT.lock().unwrap_or_else(|e| e.into_inner());
595        unsafe {
596            std::env::set_var("FLAGS_ON", "from-environment");
597        }
598
599        let config = Config::new();
600        config.set("flags.on", "from-config");
601        let flags = Flags::from_config(&config);
602
603        assert!(flags.forced_on.contains("from-config"));
604        assert!(!flags.forced_on.contains("from-environment"));
605
606        unsafe {
607            std::env::remove_var("FLAGS_ON");
608        }
609    }
610
611    use super::*;
612    use std::sync::atomic::{AtomicUsize, Ordering};
613
614    /// A registry where `new-checkout` is on for user ids ending in 7, and
615    /// `beta-search` is off for everybody.
616    fn flags() -> Flags {
617        Flags::new()
618            .define("new-checkout", |scope: Scope| async move { scope.id().ends_with('7') })
619            .define("beta-search", |_| async { false })
620    }
621
622    fn config(on: &str, off: &str) -> Config {
623        let config = Config::new();
624        config.set("flags.on", on);
625        config.set("flags.off", off);
626        config
627    }
628
629    #[tokio::test]
630    async fn a_resolver_decides_when_nothing_else_has_an_opinion() {
631        let flags = flags();
632
633        assert!(flags.active("new-checkout", &Scope::user_id(17)).await.unwrap());
634        assert!(flags.inactive("new-checkout", &Scope::user_id(18)).await.unwrap());
635        assert!(flags.inactive("beta-search", &Scope::user_id(17)).await.unwrap());
636    }
637
638    #[tokio::test]
639    async fn a_flag_nobody_defined_is_off() {
640        // And not an error. A check for a flag that was deleted in the last
641        // release must not take the page down.
642        assert!(flags().inactive("never-existed", &Scope::none()).await.unwrap());
643        assert!(!flags().is_defined("never-existed"));
644        assert_eq!(flags().defined(), ["beta-search", "new-checkout"]);
645    }
646
647    #[tokio::test]
648    async fn a_resolver_may_fail_and_the_failure_is_not_a_no() {
649        let flags = Flags::new()
650            .define("flaky", |_| async { Result::<bool>::Err(Error::msg("the database is down")) });
651
652        let error = flags.active("flaky", &Scope::none()).await.unwrap_err();
653
654        assert!(error.to_string().contains("the database is down"), "{error}");
655    }
656
657    // --- The precedence chain, one rung at a time. ---
658
659    #[tokio::test]
660    async fn an_override_for_a_scope_beats_the_resolver() {
661        let flags = flags();
662        let seven = Scope::user_id(7);
663        let eight = Scope::user_id(8);
664
665        flags.deactivate_for("new-checkout", &seven).await.unwrap();
666        flags.activate_for("beta-search", &eight).await.unwrap();
667
668        assert!(flags.inactive("new-checkout", &seven).await.unwrap());
669        assert!(flags.active("beta-search", &eight).await.unwrap());
670
671        // And only for that scope.
672        assert!(flags.active("new-checkout", &Scope::user_id(27)).await.unwrap());
673        assert!(flags.inactive("beta-search", &Scope::user_id(9)).await.unwrap());
674    }
675
676    #[tokio::test]
677    async fn forgetting_an_override_gives_the_flag_back_to_the_resolver() {
678        let flags = flags();
679        let seven = Scope::user_id(7);
680
681        flags.deactivate_for("new-checkout", &seven).await.unwrap();
682        assert!(flags.inactive("new-checkout", &seven).await.unwrap());
683
684        flags.forget_for("new-checkout", &seven).await.unwrap();
685        assert!(flags.active("new-checkout", &seven).await.unwrap());
686    }
687
688    #[tokio::test]
689    async fn a_global_override_reaches_every_scope() {
690        let flags = flags();
691
692        flags.activate("beta-search").await.unwrap();
693
694        assert!(flags.active("beta-search", &Scope::user_id(1)).await.unwrap());
695        assert!(flags.active("beta-search", &Scope::tenant("acme")).await.unwrap());
696        assert!(flags.active("beta-search", &Scope::none()).await.unwrap());
697    }
698
699    #[tokio::test]
700    async fn a_scope_override_and_a_global_one_agreeing_is_uneventful() {
701        let flags = flags();
702
703        // Global on, one customer explicitly on as well: nothing surprising.
704        flags.activate("beta-search").await.unwrap();
705        flags.activate_for("beta-search", &Scope::user_id(1)).await.unwrap();
706        assert!(flags.active("beta-search", &Scope::user_id(1)).await.unwrap());
707
708        // Global off, one customer explicitly off: also nothing surprising.
709        flags.deactivate("new-checkout").await.unwrap();
710        flags.deactivate_for("new-checkout", &Scope::user_id(17)).await.unwrap();
711        assert!(flags.inactive("new-checkout", &Scope::user_id(17)).await.unwrap());
712    }
713
714    #[tokio::test]
715    async fn a_global_off_beats_a_scope_on() {
716        // The rule that makes `deactivate` an actual switch: an operator
717        // pulling a feature during an incident does not have to know which
718        // customers somebody enabled it for by hand.
719        let flags = flags();
720
721        flags.activate_for("beta-search", &Scope::user_id(1)).await.unwrap();
722        assert!(flags.active("beta-search", &Scope::user_id(1)).await.unwrap());
723
724        flags.deactivate("beta-search").await.unwrap();
725        assert!(flags.inactive("beta-search", &Scope::user_id(1)).await.unwrap());
726
727        // ...and clearing the global off gives the per-customer one back.
728        flags.forget("beta-search").await.unwrap();
729        assert!(flags.active("beta-search", &Scope::user_id(1)).await.unwrap());
730    }
731
732    #[tokio::test]
733    async fn a_scope_off_beats_a_global_on() {
734        let flags = flags();
735
736        flags.activate("beta-search").await.unwrap();
737        flags.deactivate_for("beta-search", &Scope::user_id(1)).await.unwrap();
738
739        assert!(flags.inactive("beta-search", &Scope::user_id(1)).await.unwrap());
740        assert!(flags.active("beta-search", &Scope::user_id(2)).await.unwrap());
741    }
742
743    #[tokio::test]
744    async fn config_off_beats_every_override_there_is() {
745        let flags = flags().configured(&config("", "new-checkout,beta-search"));
746
747        flags.activate("new-checkout").await.unwrap();
748        flags.activate_for("new-checkout", &Scope::user_id(17)).await.unwrap();
749        flags.activate_for("beta-search", &Scope::tenant("acme")).await.unwrap();
750
751        // Nothing gets past it: not the resolver, not a global override, not a
752        // per-scope one set specifically for this user.
753        assert!(flags.inactive("new-checkout", &Scope::user_id(17)).await.unwrap());
754        assert!(flags.inactive("new-checkout", &Scope::none()).await.unwrap());
755        assert!(flags.inactive("beta-search", &Scope::tenant("acme")).await.unwrap());
756    }
757
758    #[tokio::test]
759    async fn config_on_beats_the_resolver_but_not_an_override() {
760        let flags = flags().configured(&config("beta-search", ""));
761
762        assert!(flags.active("beta-search", &Scope::user_id(1)).await.unwrap());
763
764        // An operator can still take it away from one customer without a
765        // deploy, which is the whole reason the store outranks `flags.on`.
766        flags.deactivate_for("beta-search", &Scope::user_id(1)).await.unwrap();
767        assert!(flags.inactive("beta-search", &Scope::user_id(1)).await.unwrap());
768        assert!(flags.active("beta-search", &Scope::user_id(2)).await.unwrap());
769    }
770
771    #[tokio::test]
772    async fn a_name_in_both_config_lists_is_off() {
773        let flags = flags().configured(&config("beta-search", "beta-search"));
774
775        assert!(flags.inactive("beta-search", &Scope::user_id(1)).await.unwrap());
776    }
777
778    #[tokio::test]
779    async fn config_can_force_a_flag_nobody_defined() {
780        // Useful during a rename: the new name works from `.env` before the
781        // code that defines it has shipped.
782        let flags = flags().configured(&config("unshipped", ""));
783
784        assert!(flags.active("unshipped", &Scope::none()).await.unwrap());
785    }
786
787    #[tokio::test]
788    async fn config_lists_accept_a_json_array_as_well_as_a_comma_separated_string() {
789        let config = Config::new();
790        config.set(
791            "flags.off",
792            rustlavel_core::Json::Array(vec![
793                rustlavel_core::Json::from("new-checkout"),
794                rustlavel_core::Json::from("beta-search"),
795            ]),
796        );
797
798        let flags = flags().configured(&config);
799
800        assert!(flags.inactive("new-checkout", &Scope::user_id(17)).await.unwrap());
801        assert!(flags.inactive("beta-search", &Scope::user_id(17)).await.unwrap());
802    }
803
804    #[tokio::test]
805    async fn config_off_survives_a_store_that_cannot_answer() {
806        // The point of reading it first: an incident switch that needs a
807        // working store is no use during the incident that broke the store.
808        let flags = Flags::new().store(BrokenStore).configured(&config("", "new-checkout"));
809
810        assert!(flags.inactive("new-checkout", &Scope::user_id(17)).await.unwrap());
811        // Any other flag does surface the failure rather than guessing.
812        assert!(flags.active("beta-search", &Scope::user_id(17)).await.is_err());
813    }
814
815    /// A store that fails every read, standing in for one that is down.
816    struct BrokenStore;
817
818    impl FlagStore for BrokenStore {
819        fn name(&self) -> &'static str {
820            "broken"
821        }
822
823        fn get<'a>(
824            &'a self,
825            _flag: &'a str,
826            _scope: &'a Scope,
827        ) -> crate::store::BoxFuture<'a, Result<Option<bool>>> {
828            Box::pin(async { Err(Error::msg("the flag store is unreachable")) })
829        }
830
831        fn set<'a>(
832            &'a self,
833            _flag: &'a str,
834            _scope: &'a Scope,
835            _value: bool,
836        ) -> crate::store::BoxFuture<'a, Result<()>> {
837            Box::pin(async { Err(Error::msg("the flag store is unreachable")) })
838        }
839
840        fn forget<'a>(
841            &'a self,
842            _flag: &'a str,
843            _scope: &'a Scope,
844        ) -> crate::store::BoxFuture<'a, Result<()>> {
845            Box::pin(async { Err(Error::msg("the flag store is unreachable")) })
846        }
847
848        fn flush(&self) -> crate::store::BoxFuture<'_, Result<()>> {
849            Box::pin(async { Err(Error::msg("the flag store is unreachable")) })
850        }
851    }
852
853    // --- Rollouts. ---
854
855    #[tokio::test]
856    async fn a_rollout_puts_the_same_scope_in_the_same_bucket_across_instances() {
857        // A fresh registry each time, as a new process would have. If the
858        // bucket came from a random draw or from anything held in memory, the
859        // two runs would disagree.
860        let first: Vec<bool> = {
861            let flags = Flags::new().rollout("dark-mode", 25);
862            let mut answers = Vec::new();
863            for id in 0..500 {
864                answers.push(flags.active("dark-mode", &Scope::user_id(id)).await.unwrap());
865            }
866            answers
867        };
868
869        let second: Vec<bool> = {
870            let flags = Flags::new().rollout("dark-mode", 25);
871            let mut answers = Vec::new();
872            for id in 0..500 {
873                answers.push(flags.active("dark-mode", &Scope::user_id(id)).await.unwrap());
874            }
875            answers
876        };
877
878        assert_eq!(first, second);
879        assert!(first.iter().any(|on| *on), "a 25% rollout reached nobody at all");
880        assert!(first.iter().any(|on| !*on), "a 25% rollout reached everybody");
881    }
882
883    #[tokio::test]
884    async fn a_rollout_is_roughly_the_proportion_it_says_over_ten_thousand_scopes() {
885        let flags = Flags::new().rollout("dark-mode", 25);
886        let mut inside = 0;
887
888        for id in 0..10_000 {
889            if flags.active("dark-mode", &Scope::user_id(id)).await.unwrap() {
890                inside += 1;
891            }
892        }
893
894        assert!((2_300..=2_700).contains(&inside), "25% of 10,000 scopes came out as {inside}");
895    }
896
897    #[tokio::test]
898    async fn a_rollout_is_still_overridable() {
899        let flags = Flags::new().rollout("dark-mode", 0);
900
901        assert!(flags.inactive("dark-mode", &Scope::user_id(41)).await.unwrap());
902
903        flags.activate_for("dark-mode", &Scope::user_id(41)).await.unwrap();
904        assert!(flags.active("dark-mode", &Scope::user_id(41)).await.unwrap());
905    }
906
907    // --- The per-scope view. ---
908
909    /// A registry whose resolver counts how many times it ran.
910    fn counted() -> (Flags, Arc<AtomicUsize>) {
911        let calls = Arc::new(AtomicUsize::new(0));
912        let counter = Arc::clone(&calls);
913
914        let flags = Flags::new().define("slow", move |_| {
915            let counter = Arc::clone(&counter);
916            async move {
917                counter.fetch_add(1, Ordering::SeqCst);
918                true
919            }
920        });
921
922        (flags, calls)
923    }
924
925    #[tokio::test]
926    async fn a_scoped_view_runs_a_resolver_once_however_often_it_is_asked() {
927        let (flags, calls) = counted();
928        let view = flags.for_scope(Scope::user_id(41));
929
930        for _ in 0..10 {
931            assert!(view.active("slow").await.unwrap());
932        }
933        assert!(!view.inactive("slow").await.unwrap());
934
935        assert_eq!(calls.load(Ordering::SeqCst), 1);
936        assert_eq!(view.resolved_count(), 1);
937    }
938
939    #[tokio::test]
940    async fn asking_the_registry_directly_resolves_every_time() {
941        // The other half of the promise: `Flags::active` is not a cache, and a
942        // caller who wants the memo has to say so by taking a view.
943        let (flags, calls) = counted();
944
945        for _ in 0..10 {
946            flags.active("slow", &Scope::user_id(41)).await.unwrap();
947        }
948
949        assert_eq!(calls.load(Ordering::SeqCst), 10);
950    }
951
952    #[tokio::test]
953    async fn two_scopes_are_two_answers_and_two_calls() {
954        let (flags, calls) = counted();
955
956        flags.for_scope(Scope::user_id(1)).active("slow").await.unwrap();
957        flags.for_scope(Scope::user_id(2)).active("slow").await.unwrap();
958
959        assert_eq!(calls.load(Ordering::SeqCst), 2);
960    }
961
962    #[tokio::test]
963    async fn a_batch_of_flags_costs_one_call_each() {
964        let (flags, calls) = counted();
965        let flags = flags.define("other", |_| async { false });
966        let view = flags.for_scope(Scope::user_id(41));
967
968        let values = view.values(["slow", "other", "slow", "other"]).await.unwrap();
969
970        assert_eq!(values.len(), 2);
971        assert!(values["slow"]);
972        assert!(!values["other"]);
973        assert_eq!(calls.load(Ordering::SeqCst), 1);
974    }
975
976    #[tokio::test]
977    async fn refreshing_a_view_makes_it_ask_again() {
978        let (flags, calls) = counted();
979        let view = flags.for_scope(Scope::user_id(41));
980
981        view.active("slow").await.unwrap();
982        view.refresh();
983        view.active("slow").await.unwrap();
984
985        assert_eq!(calls.load(Ordering::SeqCst), 2);
986        assert_eq!(view.scope(), &Scope::user_id(41));
987    }
988
989    #[tokio::test]
990    async fn a_view_answers_for_every_defined_flag_at_once() {
991        let view = flags().for_scope(Scope::user_id(17));
992        let all = view.all().await.unwrap();
993
994        assert_eq!(all.len(), 2);
995        assert!(all["new-checkout"]);
996        assert!(!all["beta-search"]);
997        assert!(view.any(["beta-search", "new-checkout"]).await.unwrap());
998        assert!(!view.any(["beta-search", "never-existed"]).await.unwrap());
999    }
1000
1001    #[tokio::test]
1002    async fn a_view_carries_the_failure_of_a_resolver_rather_than_swallowing_it() {
1003        let flags =
1004            Flags::new().define("flaky", |_| async { Result::<bool>::Err(Error::msg("nope")) });
1005
1006        assert!(flags.for_scope(Scope::none()).values(["flaky"]).await.is_err());
1007    }
1008
1009    // --- Housekeeping. ---
1010
1011    #[tokio::test]
1012    async fn purge_drops_every_override() {
1013        let flags = flags();
1014        flags.deactivate_for("new-checkout", &Scope::user_id(17)).await.unwrap();
1015        flags.activate("beta-search").await.unwrap();
1016
1017        flags.purge().await.unwrap();
1018
1019        assert!(flags.active("new-checkout", &Scope::user_id(17)).await.unwrap());
1020        assert!(flags.inactive("beta-search", &Scope::user_id(17)).await.unwrap());
1021    }
1022
1023    #[tokio::test]
1024    async fn a_clone_shares_the_store_and_the_definitions() {
1025        let flags = flags();
1026        let handle = flags.clone();
1027
1028        handle.activate("beta-search").await.unwrap();
1029
1030        assert!(flags.active("beta-search", &Scope::none()).await.unwrap());
1031    }
1032
1033    #[tokio::test]
1034    async fn defining_a_name_twice_replaces_it() {
1035        let flags = flags().define("beta-search", |_| async { true });
1036
1037        assert!(flags.active("beta-search", &Scope::none()).await.unwrap());
1038        assert_eq!(flags.defined().len(), 2);
1039    }
1040
1041    #[tokio::test]
1042    async fn a_store_can_be_supplied_as_a_shared_handle() {
1043        let store = MemoryStore::new();
1044        let flags = Flags::new().with_store(Arc::new(store.clone()) as Arc<dyn FlagStore>);
1045
1046        flags.activate("beta-search").await.unwrap();
1047
1048        // Written through the registry, readable through the handle the
1049        // application kept — which is how an admin screen lists overrides.
1050        assert_eq!(store.get("beta-search", &Scope::none()).await.unwrap(), Some(true));
1051        assert_eq!(flags.store_handle().name(), "memory");
1052    }
1053
1054    #[test]
1055    fn the_debug_output_names_the_flags() {
1056        let rendered = format!("{:?}", flags().configured(&config("a", "b")));
1057
1058        assert!(rendered.contains("new-checkout"), "{rendered}");
1059        assert!(rendered.contains("forced_off"), "{rendered}");
1060        assert!(rendered.contains("memory"), "{rendered}");
1061    }
1062
1063    #[test]
1064    fn the_missing_registry_error_says_how_to_fix_it() {
1065        let message = missing_registry().to_string();
1066
1067        assert!(message.contains("FeatureFlags::new"), "{message}");
1068        assert!(message.contains("Refusing to guess"), "{message}");
1069    }
1070}