tui_dispatch_core/
features.rs

1//! Runtime feature flags for TUI applications
2//!
3//! Feature flags allow you to toggle functionality at runtime, useful for:
4//! - Gradual feature rollouts
5//! - A/B testing
6//! - Debug-only features
7//! - User preferences
8//!
9//! # Quick Start
10//!
11//! ```ignore
12//! use tui_dispatch::FeatureFlags;
13//!
14//! #[derive(FeatureFlags)]
15//! struct Features {
16//!     #[flag(default = false)]
17//!     new_search_ui: bool,
18//!
19//!     #[flag(default = true)]
20//!     vim_bindings: bool,
21//! }
22//!
23//! let mut features = Features::default();
24//! assert!(!features.new_search_ui);
25//! assert!(features.vim_bindings);
26//!
27//! features.enable("new_search_ui");
28//! assert!(features.new_search_ui);
29//! ```
30//!
31//! # Usage in Reducers
32//!
33//! ```ignore
34//! fn reducer(state: &mut AppState, action: Action, features: &Features) -> bool {
35//!     match action {
36//!         Action::ShowSuggestions(s) if features.new_search_ui => {
37//!             state.suggestions = s;
38//!             true
39//!         }
40//!         Action::ShowSuggestions(_) => false, // Feature disabled
41//!         // ...
42//!     }
43//! }
44//! ```
45//!
46//! # Usage in Render
47//!
48//! ```ignore
49//! if features.new_search_ui {
50//!     render_new_search(frame, area, state);
51//! } else {
52//!     render_legacy_search(frame, area, state);
53//! }
54//! ```
55
56use std::collections::HashMap;
57
58/// Trait for feature flag containers
59///
60/// Implement this trait to create a type-safe feature flag system.
61/// Use `#[derive(FeatureFlags)]` for automatic implementation.
62///
63/// # Example
64///
65/// ```
66/// use tui_dispatch_core::FeatureFlags;
67///
68/// struct MyFeatures {
69///     dark_mode: bool,
70///     experimental: bool,
71/// }
72///
73/// impl FeatureFlags for MyFeatures {
74///     fn is_enabled(&self, name: &str) -> Option<bool> {
75///         match name {
76///             "dark_mode" => Some(self.dark_mode),
77///             "experimental" => Some(self.experimental),
78///             _ => None,
79///         }
80///     }
81///
82///     fn set(&mut self, name: &str, enabled: bool) -> bool {
83///         match name {
84///             "dark_mode" => { self.dark_mode = enabled; true }
85///             "experimental" => { self.experimental = enabled; true }
86///             _ => false,
87///         }
88///     }
89///
90///     fn all_flags() -> &'static [&'static str] {
91///         &["dark_mode", "experimental"]
92///     }
93/// }
94/// ```
95pub trait FeatureFlags {
96    /// Check if a feature is enabled by name
97    ///
98    /// Returns `None` if the feature doesn't exist.
99    fn is_enabled(&self, name: &str) -> Option<bool>;
100
101    /// Set a feature's enabled state
102    ///
103    /// Returns `false` if the feature doesn't exist.
104    fn set(&mut self, name: &str, enabled: bool) -> bool;
105
106    /// Get all available flag names
107    fn all_flags() -> &'static [&'static str]
108    where
109        Self: Sized;
110
111    /// Enable a feature by name
112    ///
113    /// Returns `false` if the feature doesn't exist.
114    fn enable(&mut self, name: &str) -> bool {
115        self.set(name, true)
116    }
117
118    /// Disable a feature by name
119    ///
120    /// Returns `false` if the feature doesn't exist.
121    fn disable(&mut self, name: &str) -> bool {
122        self.set(name, false)
123    }
124
125    /// Toggle a feature by name
126    ///
127    /// Returns the new state, or `None` if the feature doesn't exist.
128    fn toggle(&mut self, name: &str) -> Option<bool> {
129        let current = self.is_enabled(name)?;
130        let new_state = !current;
131        self.set(name, new_state);
132        Some(new_state)
133    }
134
135    /// Get all flags as a map of name -> enabled
136    fn to_map(&self) -> HashMap<String, bool>
137    where
138        Self: Sized,
139    {
140        Self::all_flags()
141            .iter()
142            .filter_map(|name| self.is_enabled(name).map(|v| ((*name).to_string(), v)))
143            .collect()
144    }
145
146    /// Load flags from a map (e.g., from config file)
147    ///
148    /// Unknown flags are ignored. Returns the number of flags that were set.
149    fn load_from_map(&mut self, map: &HashMap<String, bool>) -> usize {
150        let mut count = 0;
151        for (name, enabled) in map {
152            if self.set(name, *enabled) {
153                count += 1;
154            }
155        }
156        count
157    }
158}
159
160/// A dynamic feature flag store for cases where compile-time flags aren't needed
161///
162/// Use this when you want to define flags at runtime or load them from configuration.
163///
164/// # Example
165///
166/// ```
167/// use tui_dispatch_core::DynamicFeatures;
168///
169/// let mut features = DynamicFeatures::new();
170/// features.register("dark_mode", true);
171/// features.register("experimental", false);
172///
173/// assert!(features.get("dark_mode"));
174/// assert!(!features.get("experimental"));
175///
176/// features.toggle("experimental");
177/// assert!(features.get("experimental"));
178/// ```
179#[derive(Debug, Clone, Default)]
180pub struct DynamicFeatures {
181    flags: HashMap<String, bool>,
182}
183
184impl DynamicFeatures {
185    /// Create a new empty feature store
186    pub fn new() -> Self {
187        Self::default()
188    }
189
190    /// Register a new feature with a default value
191    pub fn register(&mut self, name: impl Into<String>, default: bool) {
192        self.flags.insert(name.into(), default);
193    }
194
195    /// Get a feature's value, returns `false` if not registered
196    pub fn get(&self, name: &str) -> bool {
197        self.flags.get(name).copied().unwrap_or(false)
198    }
199
200    /// Check if a feature is registered
201    pub fn has(&self, name: &str) -> bool {
202        self.flags.contains_key(name)
203    }
204
205    /// Get all registered flag names
206    pub fn flag_names(&self) -> impl Iterator<Item = &str> {
207        self.flags.keys().map(|s| s.as_str())
208    }
209
210    /// Enable a feature
211    pub fn enable(&mut self, name: &str) -> bool {
212        if let Some(v) = self.flags.get_mut(name) {
213            *v = true;
214            true
215        } else {
216            false
217        }
218    }
219
220    /// Disable a feature
221    pub fn disable(&mut self, name: &str) -> bool {
222        if let Some(v) = self.flags.get_mut(name) {
223            *v = false;
224            true
225        } else {
226            false
227        }
228    }
229
230    /// Toggle a feature
231    pub fn toggle(&mut self, name: &str) -> Option<bool> {
232        if let Some(v) = self.flags.get_mut(name) {
233            *v = !*v;
234            Some(*v)
235        } else {
236            None
237        }
238    }
239
240    /// Load flags from a map, registering new ones if they don't exist
241    pub fn load(&mut self, map: HashMap<String, bool>) {
242        for (name, enabled) in map {
243            self.flags.insert(name, enabled);
244        }
245    }
246
247    /// Export all flags as a map
248    pub fn export(&self) -> HashMap<String, bool> {
249        self.flags.clone()
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    // Manual implementation for testing
258    #[derive(Default)]
259    struct TestFeatures {
260        dark_mode: bool,
261        vim_bindings: bool,
262    }
263
264    impl FeatureFlags for TestFeatures {
265        fn is_enabled(&self, name: &str) -> Option<bool> {
266            match name {
267                "dark_mode" => Some(self.dark_mode),
268                "vim_bindings" => Some(self.vim_bindings),
269                _ => None,
270            }
271        }
272
273        fn set(&mut self, name: &str, enabled: bool) -> bool {
274            match name {
275                "dark_mode" => {
276                    self.dark_mode = enabled;
277                    true
278                }
279                "vim_bindings" => {
280                    self.vim_bindings = enabled;
281                    true
282                }
283                _ => false,
284            }
285        }
286
287        fn all_flags() -> &'static [&'static str] {
288            &["dark_mode", "vim_bindings"]
289        }
290    }
291
292    #[test]
293    fn test_feature_flags_trait() {
294        let mut features = TestFeatures::default();
295
296        assert_eq!(features.is_enabled("dark_mode"), Some(false));
297        assert_eq!(features.is_enabled("vim_bindings"), Some(false));
298        assert_eq!(features.is_enabled("unknown"), None);
299
300        features.enable("dark_mode");
301        assert_eq!(features.is_enabled("dark_mode"), Some(true));
302
303        features.disable("dark_mode");
304        assert_eq!(features.is_enabled("dark_mode"), Some(false));
305
306        let new_state = features.toggle("vim_bindings");
307        assert_eq!(new_state, Some(true));
308        assert_eq!(features.is_enabled("vim_bindings"), Some(true));
309    }
310
311    #[test]
312    fn test_feature_flags_to_map() {
313        let features = TestFeatures {
314            dark_mode: true,
315            ..Default::default()
316        };
317
318        let map = features.to_map();
319        assert_eq!(map.get("dark_mode"), Some(&true));
320        assert_eq!(map.get("vim_bindings"), Some(&false));
321    }
322
323    #[test]
324    fn test_feature_flags_load_from_map() {
325        let mut features = TestFeatures::default();
326        let mut map = HashMap::new();
327        map.insert("dark_mode".to_string(), true);
328        map.insert("vim_bindings".to_string(), true);
329        map.insert("unknown".to_string(), true); // Should be ignored
330
331        let count = features.load_from_map(&map);
332        assert_eq!(count, 2);
333        assert!(features.dark_mode);
334        assert!(features.vim_bindings);
335    }
336
337    #[test]
338    fn test_dynamic_features() {
339        let mut features = DynamicFeatures::new();
340        features.register("dark_mode", true);
341        features.register("experimental", false);
342
343        assert!(features.get("dark_mode"));
344        assert!(!features.get("experimental"));
345        assert!(!features.get("unknown")); // Returns false for unregistered
346
347        features.toggle("experimental");
348        assert!(features.get("experimental"));
349
350        features.disable("dark_mode");
351        assert!(!features.get("dark_mode"));
352    }
353
354    #[test]
355    fn test_dynamic_features_load() {
356        let mut features = DynamicFeatures::new();
357        let mut map = HashMap::new();
358        map.insert("feature_a".to_string(), true);
359        map.insert("feature_b".to_string(), false);
360
361        features.load(map);
362
363        assert!(features.get("feature_a"));
364        assert!(!features.get("feature_b"));
365        assert!(features.has("feature_a"));
366        assert!(features.has("feature_b"));
367    }
368
369    #[test]
370    fn test_dynamic_features_export() {
371        let mut features = DynamicFeatures::new();
372        features.register("a", true);
373        features.register("b", false);
374
375        let exported = features.export();
376        assert_eq!(exported.len(), 2);
377        assert_eq!(exported.get("a"), Some(&true));
378        assert_eq!(exported.get("b"), Some(&false));
379    }
380}