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) -> ReducerResult {
35//! match action {
36//! Action::ShowSuggestions(s) if features.new_search_ui => {
37//! state.suggestions = s;
38//! ReducerResult::changed()
39//! }
40//! Action::ShowSuggestions(_) => ReducerResult::unchanged(), // 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}