use std::collections::HashMap;
use std::sync::Mutex;
use crate::Plugin;
use pylon_auth::AuthContext;
#[derive(Debug, Clone)]
pub enum FlagRule {
Boolean(bool),
UserList(Vec<String>),
Percentage(u8),
}
#[derive(Debug, Clone)]
pub struct FeatureFlag {
pub name: String,
pub description: String,
pub rule: FlagRule,
pub enabled: bool,
}
pub struct FeatureFlagsPlugin {
flags: Mutex<HashMap<String, FeatureFlag>>,
}
impl FeatureFlagsPlugin {
pub fn new() -> Self {
Self {
flags: Mutex::new(HashMap::new()),
}
}
pub fn add_boolean(&self, name: &str, description: &str, enabled: bool) {
self.flags.lock().unwrap().insert(
name.to_string(),
FeatureFlag {
name: name.to_string(),
description: description.to_string(),
rule: FlagRule::Boolean(enabled),
enabled,
},
);
}
pub fn add_user_list(&self, name: &str, description: &str, users: Vec<String>) {
self.flags.lock().unwrap().insert(
name.to_string(),
FeatureFlag {
name: name.to_string(),
description: description.to_string(),
rule: FlagRule::UserList(users),
enabled: true,
},
);
}
pub fn add_percentage(&self, name: &str, description: &str, percent: u8) {
self.flags.lock().unwrap().insert(
name.to_string(),
FeatureFlag {
name: name.to_string(),
description: description.to_string(),
rule: FlagRule::Percentage(percent.min(100)),
enabled: true,
},
);
}
pub fn is_enabled(&self, flag_name: &str, auth: &AuthContext) -> bool {
let flags = self.flags.lock().unwrap();
let flag = match flags.get(flag_name) {
Some(f) => f,
None => return false, };
if !flag.enabled {
return false;
}
match &flag.rule {
FlagRule::Boolean(on) => *on,
FlagRule::UserList(users) => auth
.user_id
.as_ref()
.map(|id| users.contains(id))
.unwrap_or(false),
FlagRule::Percentage(pct) => {
let hash = auth
.user_id
.as_ref()
.map(|id| {
id.bytes()
.fold(0u64, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u64))
})
.unwrap_or(0);
(hash % 100) < (*pct as u64)
}
}
}
pub fn set_enabled(&self, flag_name: &str, enabled: bool) -> bool {
let mut flags = self.flags.lock().unwrap();
if let Some(flag) = flags.get_mut(flag_name) {
flag.enabled = enabled;
true
} else {
false
}
}
pub fn list_flags(&self) -> Vec<FeatureFlag> {
self.flags.lock().unwrap().values().cloned().collect()
}
pub fn remove(&self, flag_name: &str) -> bool {
self.flags.lock().unwrap().remove(flag_name).is_some()
}
}
impl Plugin for FeatureFlagsPlugin {
fn name(&self) -> &str {
"feature-flags"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn boolean_flag() {
let plugin = FeatureFlagsPlugin::new();
plugin.add_boolean("dark-mode", "Enable dark mode", true);
assert!(plugin.is_enabled("dark-mode", &AuthContext::anonymous()));
plugin.set_enabled("dark-mode", false);
assert!(!plugin.is_enabled("dark-mode", &AuthContext::anonymous()));
}
#[test]
fn user_list_flag() {
let plugin = FeatureFlagsPlugin::new();
plugin.add_user_list(
"beta",
"Beta features",
vec!["user-1".into(), "user-2".into()],
);
assert!(plugin.is_enabled("beta", &AuthContext::authenticated("user-1".into())));
assert!(plugin.is_enabled("beta", &AuthContext::authenticated("user-2".into())));
assert!(!plugin.is_enabled("beta", &AuthContext::authenticated("user-3".into())));
assert!(!plugin.is_enabled("beta", &AuthContext::anonymous()));
}
#[test]
fn percentage_flag() {
let plugin = FeatureFlagsPlugin::new();
plugin.add_percentage("new-ui", "New UI experiment", 50);
let auth = AuthContext::authenticated("test-user".into());
let result1 = plugin.is_enabled("new-ui", &auth);
let result2 = plugin.is_enabled("new-ui", &auth);
assert_eq!(result1, result2);
}
#[test]
fn percentage_zero_always_off() {
let plugin = FeatureFlagsPlugin::new();
plugin.add_percentage("disabled", "Always off", 0);
assert!(!plugin.is_enabled("disabled", &AuthContext::authenticated("user-1".into())));
}
#[test]
fn percentage_100_always_on() {
let plugin = FeatureFlagsPlugin::new();
plugin.add_percentage("enabled", "Always on", 100);
assert!(plugin.is_enabled("enabled", &AuthContext::authenticated("user-1".into())));
assert!(plugin.is_enabled("enabled", &AuthContext::authenticated("user-2".into())));
}
#[test]
fn unknown_flag_returns_false() {
let plugin = FeatureFlagsPlugin::new();
assert!(!plugin.is_enabled("nonexistent", &AuthContext::anonymous()));
}
#[test]
fn remove_flag() {
let plugin = FeatureFlagsPlugin::new();
plugin.add_boolean("test", "Test", true);
assert!(plugin.remove("test"));
assert!(!plugin.is_enabled("test", &AuthContext::anonymous()));
}
#[test]
fn list_flags() {
let plugin = FeatureFlagsPlugin::new();
plugin.add_boolean("a", "Flag A", true);
plugin.add_boolean("b", "Flag B", false);
let flags = plugin.list_flags();
assert_eq!(flags.len(), 2);
}
#[test]
fn disabled_flag_ignores_rules() {
let plugin = FeatureFlagsPlugin::new();
plugin.add_user_list("beta", "Beta", vec!["user-1".into()]);
plugin.set_enabled("beta", false);
assert!(!plugin.is_enabled("beta", &AuthContext::authenticated("user-1".into())));
}
}