use crate::store::Store;
use leptos::prelude::*;
use std::collections::HashMap;
use thiserror::Error;
#[cfg(feature = "hydrate")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "hydrate", derive(Serialize, Deserialize))]
pub struct FeatureFlag {
pub key: String,
pub enabled: bool,
pub variant: Option<String>,
#[cfg_attr(feature = "hydrate", serde(default))]
pub metadata: HashMap<String, String>,
pub description: Option<String>,
}
impl FeatureFlag {
pub fn new(key: impl Into<String>, enabled: bool) -> Self {
Self {
key: key.into(),
enabled,
variant: None,
metadata: HashMap::new(),
description: None,
}
}
pub fn with_variant(key: impl Into<String>, enabled: bool, variant: impl Into<String>) -> Self {
Self {
key: key.into(),
enabled,
variant: Some(variant.into()),
metadata: HashMap::new(),
description: None,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "hydrate", derive(Serialize, Deserialize))]
pub struct UserContext {
pub user_id: Option<String>,
pub email: Option<String>,
pub attributes: HashMap<String, String>,
pub environment: Option<String>,
}
impl UserContext {
pub fn new() -> Self {
Self::default()
}
pub fn authenticated(user_id: impl Into<String>) -> Self {
Self {
user_id: Some(user_id.into()),
..Default::default()
}
}
pub fn with_user_id(mut self, id: impl Into<String>) -> Self {
self.user_id = Some(id.into());
self
}
pub fn with_email(mut self, email: impl Into<String>) -> Self {
self.email = Some(email.into());
self
}
pub fn with_environment(mut self, env: impl Into<String>) -> Self {
self.environment = Some(env.into());
self
}
pub fn with_attribute(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.attributes.insert(key.into(), value.into());
self
}
}
#[derive(Debug, Error, Clone)]
pub enum FlagError {
#[error("Flag not found: {0}")]
NotFound(String),
#[error("Failed to fetch flags: {0}")]
FetchFailed(String),
#[error("Invalid flag configuration: {0}")]
InvalidConfig(String),
#[error("Network error: {0}")]
Network(String),
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "hydrate", derive(Serialize, Deserialize))]
pub struct FeatureFlagState {
flags: HashMap<String, FeatureFlag>,
#[cfg_attr(feature = "hydrate", serde(skip))]
overrides: HashMap<String, bool>,
loaded: bool,
#[cfg_attr(feature = "hydrate", serde(skip))]
error: Option<FlagError>,
#[cfg_attr(feature = "hydrate", serde(skip))]
loading: bool,
}
impl FeatureFlagState {
pub fn is_enabled(&self, key: &str) -> bool {
if let Some(&override_value) = self.overrides.get(key) {
return override_value;
}
self.flags.get(key).map(|f| f.enabled).unwrap_or(false)
}
pub fn get_variant(&self, key: &str) -> Option<String> {
self.flags.get(key).and_then(|f| f.variant.clone())
}
pub fn get_flag(&self, key: &str) -> Option<&FeatureFlag> {
self.flags.get(key)
}
}
#[derive(Clone)]
pub struct FeatureFlagStore {
state: RwSignal<FeatureFlagState>,
}
impl Default for FeatureFlagStore {
fn default() -> Self {
Self::new()
}
}
impl FeatureFlagStore {
pub fn new() -> Self {
Self {
state: RwSignal::new(FeatureFlagState::default()),
}
}
pub fn with_flags(flags: Vec<FeatureFlag>) -> Self {
let store = Self::new();
store.set_flags(flags);
store
}
pub fn is_enabled(&self, key: &str) -> bool {
self.state.with(|s| s.is_enabled(key))
}
pub fn get_variant(&self, key: &str) -> Option<String> {
self.state.with(|s| s.get_variant(key))
}
pub fn all_flags(&self) -> Vec<FeatureFlag> {
self.state.with(|s| s.flags.values().cloned().collect())
}
pub fn get_flag(&self, key: &str) -> Option<FeatureFlag> {
self.state.with(|s| s.flags.get(key).cloned())
}
pub fn flag_keys(&self) -> Vec<String> {
self.state.with(|s| s.flags.keys().cloned().collect())
}
pub fn is_loaded(&self) -> bool {
self.state.with(|s| s.loaded)
}
pub fn is_loading(&self) -> bool {
self.state.with(|s| s.loading)
}
pub fn error(&self) -> Option<FlagError> {
self.state.with(|s| s.error.clone())
}
pub fn overrides(&self) -> HashMap<String, bool> {
self.state.with(|s| s.overrides.clone())
}
pub fn set_flags(&self, flags: Vec<FeatureFlag>) {
self.state.update(|s| {
s.flags = flags.into_iter().map(|f| (f.key.clone(), f)).collect();
s.loaded = true;
s.error = None;
});
}
pub fn set_flag(&self, flag: FeatureFlag) {
self.state.update(|s| {
s.flags.insert(flag.key.clone(), flag);
});
}
pub fn remove_flag(&self, key: &str) {
self.state.update(|s| {
s.flags.remove(key);
});
}
pub fn set_override(&self, key: impl Into<String>, enabled: bool) {
self.state.update(|s| {
s.overrides.insert(key.into(), enabled);
});
}
pub fn remove_override(&self, key: &str) {
self.state.update(|s| {
s.overrides.remove(key);
});
}
pub fn clear_overrides(&self) {
self.state.update(|s| {
s.overrides.clear();
});
}
pub fn set_loading(&self, loading: bool) {
self.state.update(|s| {
s.loading = loading;
});
}
pub fn set_error(&self, error: Option<FlagError>) {
self.state.update(|s| {
s.error = error;
s.loading = false;
});
}
pub fn clear(&self) {
self.state.update(|s| {
s.flags.clear();
s.loaded = false;
});
}
pub fn enable(&self, key: &str) {
self.state.update(|s| {
if let Some(flag) = s.flags.get_mut(key) {
flag.enabled = true;
}
});
}
pub fn disable(&self, key: &str) {
self.state.update(|s| {
if let Some(flag) = s.flags.get_mut(key) {
flag.enabled = false;
}
});
}
pub fn toggle(&self, key: &str) {
self.state.update(|s| {
if let Some(flag) = s.flags.get_mut(key) {
flag.enabled = !flag.enabled;
}
});
}
}
impl Store for FeatureFlagStore {
type State = FeatureFlagState;
fn state(&self) -> ReadSignal<Self::State> {
self.state.read_only()
}
}
#[cfg(feature = "hydrate")]
impl crate::hydration::HydratableStore for FeatureFlagStore {
fn serialize_state(&self) -> Result<String, crate::hydration::StoreHydrationError> {
let state = self.state.get_untracked();
serde_json::to_string(&state)
.map_err(|e| crate::hydration::StoreHydrationError::Serialization(e.to_string()))
}
fn from_hydrated_state(data: &str) -> Result<Self, crate::hydration::StoreHydrationError> {
let state: FeatureFlagState = serde_json::from_str(data)
.map_err(|e| crate::hydration::StoreHydrationError::Deserialization(e.to_string()))?;
Ok(Self {
state: RwSignal::new(state),
})
}
fn store_key() -> &'static str {
"feature_flags"
}
}
#[component]
pub fn Feature(
flag: &'static str,
#[prop(optional)]
invert: bool,
children: ChildrenFn,
) -> impl IntoView {
let store = use_context::<FeatureFlagStore>();
let is_enabled = move || {
store
.as_ref()
.map(|s| {
let enabled = s.is_enabled(flag);
if invert { !enabled } else { enabled }
})
.unwrap_or(false)
};
move || {
if is_enabled() {
children().into_any()
} else {
().into_any()
}
}
}
#[component]
pub fn FeatureVariant(
flag: &'static str,
variant: &'static str,
children: ChildrenFn,
) -> impl IntoView {
let store = use_context::<FeatureFlagStore>();
let matches = move || {
store
.as_ref()
.and_then(|s| s.get_variant(flag))
.map(|v| v == variant)
.unwrap_or(false)
};
move || {
if matches() {
children().into_any()
} else {
().into_any()
}
}
}
pub fn provide_feature_flags(store: FeatureFlagStore) {
provide_context(store);
}
pub fn use_feature_flags() -> FeatureFlagStore {
use_context::<FeatureFlagStore>().expect("FeatureFlagStore not found in context")
}
pub fn use_feature(flag: &'static str) -> impl Fn() -> bool + Clone {
let store = use_context::<FeatureFlagStore>();
move || store.as_ref().map(|s| s.is_enabled(flag)).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_feature_flag_creation() {
let flag = FeatureFlag::new("test", true);
assert_eq!(flag.key, "test");
assert!(flag.enabled);
assert!(flag.variant.is_none());
}
#[test]
fn test_feature_flag_with_variant() {
let flag = FeatureFlag::with_variant("ab_test", true, "variant_a");
assert_eq!(flag.key, "ab_test");
assert_eq!(flag.variant, Some("variant_a".to_string()));
}
#[test]
fn test_feature_flag_with_metadata() {
let flag = FeatureFlag::new("test", true)
.with_description("Test flag")
.with_metadata("owner", "team-a");
assert_eq!(flag.description, Some("Test flag".to_string()));
assert_eq!(flag.metadata.get("owner"), Some(&"team-a".to_string()));
}
#[test]
fn test_user_context() {
let ctx = UserContext::authenticated("user123")
.with_email("test@example.com")
.with_environment("production")
.with_attribute("plan", "premium");
assert_eq!(ctx.user_id, Some("user123".to_string()));
assert_eq!(ctx.email, Some("test@example.com".to_string()));
assert_eq!(ctx.environment, Some("production".to_string()));
assert_eq!(ctx.attributes.get("plan"), Some(&"premium".to_string()));
}
#[test]
fn test_feature_flag_store_creation() {
let store = FeatureFlagStore::new();
assert!(!store.is_loaded());
assert!(!store.is_loading());
}
#[test]
fn test_feature_flag_store_set_flags() {
let store = FeatureFlagStore::new();
store.set_flags(vec![
FeatureFlag::new("flag1", true),
FeatureFlag::new("flag2", false),
]);
assert!(store.is_loaded());
assert!(store.is_enabled("flag1"));
assert!(!store.is_enabled("flag2"));
assert!(!store.is_enabled("flag3")); }
#[test]
fn test_feature_flag_store_overrides() {
let store = FeatureFlagStore::new();
store.set_flags(vec![FeatureFlag::new("feature", false)]);
assert!(!store.is_enabled("feature"));
store.set_override("feature", true);
assert!(store.is_enabled("feature"));
store.remove_override("feature");
assert!(!store.is_enabled("feature"));
store.set_override("feature", true);
store.clear_overrides();
assert!(!store.is_enabled("feature"));
}
#[test]
fn test_feature_flag_store_variants() {
let store = FeatureFlagStore::new();
store.set_flags(vec![FeatureFlag::with_variant("hero", true, "modern")]);
assert_eq!(store.get_variant("hero"), Some("modern".to_string()));
assert_eq!(store.get_variant("unknown"), None);
}
#[test]
fn test_feature_flag_store_toggle() {
let store = FeatureFlagStore::new();
store.set_flags(vec![FeatureFlag::new("test", false)]);
assert!(!store.is_enabled("test"));
store.toggle("test");
assert!(store.is_enabled("test"));
store.toggle("test");
assert!(!store.is_enabled("test"));
}
#[test]
fn test_feature_flag_store_enable_disable() {
let store = FeatureFlagStore::new();
store.set_flags(vec![FeatureFlag::new("test", false)]);
store.enable("test");
assert!(store.is_enabled("test"));
store.disable("test");
assert!(!store.is_enabled("test"));
}
#[test]
fn test_feature_flag_store_with_flags() {
let store = FeatureFlagStore::with_flags(vec![
FeatureFlag::new("a", true),
FeatureFlag::new("b", false),
]);
assert!(store.is_loaded());
assert_eq!(store.flag_keys().len(), 2);
}
#[test]
fn test_feature_flag_store_remove_flag() {
let store = FeatureFlagStore::with_flags(vec![
FeatureFlag::new("a", true),
FeatureFlag::new("b", true),
]);
assert_eq!(store.all_flags().len(), 2);
store.remove_flag("a");
assert_eq!(store.all_flags().len(), 1);
assert!(store.get_flag("a").is_none());
}
#[test]
fn test_flag_error_display() {
assert!(
FlagError::NotFound("test".to_string())
.to_string()
.contains("not found")
);
assert!(
FlagError::FetchFailed("timeout".to_string())
.to_string()
.contains("fetch")
);
}
}