Skip to main content

rustlavel_flags/
scope.rs

1//! Who a flag is being checked *for*.
2//!
3//! A flag on its own is not a question anybody can answer. "Is the new checkout
4//! on?" only has a meaning once you say for whom — this customer, that tenant,
5//! or everybody at once — and the scope is that second half of the question.
6
7use std::str::FromStr;
8
9/// The subject of a flag check.
10///
11/// Three kinds, because three are what applications actually roll out to:
12///
13/// * a **user**, by whatever identifier they log in with;
14/// * a **tenant**, when a feature is bought or enabled per customer rather than
15///   per person;
16/// * **nobody in particular** ([`Scope::none`]), for a switch that is on or off
17///   for the whole installation.
18///
19/// The identifier is a `String` and not a generic parameter on purpose. A flag
20/// store has to write it down, an operator has to type it into a console, and a
21/// percentage rollout has to hash it — all three want text, and a type
22/// parameter here would spread through every signature in the crate to buy
23/// nothing.
24///
25/// ```
26/// use rustlavel_flags::Scope;
27///
28/// assert_eq!(Scope::user("ada").id(), "ada");
29/// assert_eq!(Scope::user_id(41).id(), "41");
30/// assert_eq!(Scope::tenant("acme").key(), "tenant:acme");
31/// assert_eq!(Scope::none().id(), "");
32/// ```
33#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
34pub enum Scope {
35    /// Everybody. Built with [`Scope::none`].
36    #[default]
37    None,
38    /// One logged-in user, by their authentication identifier.
39    User(String),
40    /// One tenant, team, organisation — whatever the application calls it.
41    Tenant(String),
42}
43
44impl Scope {
45    /// The global scope: a flag that is simply on or off, for everyone.
46    ///
47    /// Named `none` rather than `global` because of how it reads at the call
48    /// site — `flags.active("maintenance", &Scope::none())` says "there is
49    /// nobody in particular here", which is the fact the caller has.
50    pub fn none() -> Self {
51        Scope::None
52    }
53
54    /// One user, by the identifier they log in with.
55    pub fn user(id: impl Into<String>) -> Self {
56        Scope::User(id.into())
57    }
58
59    /// One user, by a numeric primary key — the common case in an application
60    /// whose `users` table is keyed by `bigint`.
61    pub fn user_id(id: i64) -> Self {
62        Scope::User(id.to_string())
63    }
64
65    /// One tenant, by name or by id.
66    pub fn tenant(name: impl Into<String>) -> Self {
67        Scope::Tenant(name.into())
68    }
69
70    /// The identifier, or `""` for [`Scope::none`].
71    ///
72    /// The empty string is deliberate: a resolver that does not care which kind
73    /// of scope it was given can read `scope.id()` unconditionally, and a
74    /// global check falls through whatever test it applies rather than
75    /// panicking or matching some sentinel value.
76    pub fn id(&self) -> &str {
77        match self {
78            Scope::None => "",
79            Scope::User(id) | Scope::Tenant(id) => id,
80        }
81    }
82
83    /// The identifier parsed into a type: `scope.id_as::<i64>()`.
84    ///
85    /// `None` when the scope is global, or when the identifier is not that
86    /// type — a resolver keyed on a numeric user id gets one answer for both,
87    /// which is right, because it cannot serve either.
88    pub fn id_as<T: FromStr>(&self) -> Option<T> {
89        self.id().parse().ok()
90    }
91
92    /// `"global"`, `"user"` or `"tenant"` — for logs and for the store key.
93    pub fn kind(&self) -> &'static str {
94        match self {
95            Scope::None => "global",
96            Scope::User(_) => "user",
97            Scope::Tenant(_) => "tenant",
98        }
99    }
100
101    /// Whether this is the global scope.
102    pub fn is_none(&self) -> bool {
103        matches!(self, Scope::None)
104    }
105
106    /// The string a [`FlagStore`](crate::FlagStore) writes an override under.
107    ///
108    /// The kind is part of the key, so a user called `acme` and a tenant called
109    /// `acme` are two different subjects and cannot inherit each other's
110    /// overrides. An identifier containing a colon is left alone rather than
111    /// escaped — the prefix already separates the namespaces, and the only way
112    /// two keys can still collide is if one subject's identifier is literally
113    /// another kind's prefix plus another subject's identifier, which is a
114    /// collision between two identifiers the application itself issued.
115    pub fn key(&self) -> String {
116        match self {
117            Scope::None => "global".to_string(),
118            Scope::User(id) => format!("user:{id}"),
119            Scope::Tenant(id) => format!("tenant:{id}"),
120        }
121    }
122}
123
124impl std::fmt::Display for Scope {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.write_str(&self.key())
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn a_scope_carries_its_identifier_and_its_kind() {
136        assert_eq!(Scope::user("ada").kind(), "user");
137        assert_eq!(Scope::user("ada").id(), "ada");
138        assert_eq!(Scope::tenant("acme").kind(), "tenant");
139        assert_eq!(Scope::none().kind(), "global");
140        assert!(Scope::none().is_none());
141        assert!(!Scope::user("ada").is_none());
142    }
143
144    #[test]
145    fn a_numeric_identifier_survives_the_round_trip() {
146        assert_eq!(Scope::user_id(41).id(), "41");
147        assert_eq!(Scope::user_id(41).id_as::<i64>(), Some(41));
148        assert_eq!(Scope::user("ada").id_as::<i64>(), None);
149        assert_eq!(Scope::none().id_as::<i64>(), None);
150    }
151
152    #[test]
153    fn the_kind_is_part_of_the_key_so_two_namespaces_cannot_collide() {
154        // The bug this prevents: a tenant called `acme` inheriting the
155        // overrides an operator set for a *user* called `acme`.
156        assert_ne!(Scope::user("acme").key(), Scope::tenant("acme").key());
157        assert_eq!(Scope::user("acme").key(), "user:acme");
158        assert_eq!(Scope::none().key(), "global");
159        assert_eq!(Scope::none().to_string(), "global");
160    }
161
162    #[test]
163    fn the_default_scope_is_the_global_one() {
164        assert_eq!(Scope::default(), Scope::none());
165    }
166}