Skip to main content

cli_engine/
feature_flags.rs

1//! Stage-based feature flagging primitives.
2//!
3//! These types describe *readiness gating*: a command, group, or module can declare
4//! the [`Stage`] at which it becomes visible, and a run-wide [`FlagPolicy`] decides
5//! whether that stage (or an override for a specific flag key) is currently enabled.
6
7use std::{collections::BTreeMap, fmt, str::FromStr};
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12/// Feature readiness stage, used to gate commands/groups/modules before they are
13/// fully promoted to general availability.
14#[derive(
15    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
16)]
17#[serde(rename_all = "lowercase")]
18pub enum Stage {
19    /// Early, unstable functionality; visible only when explicitly opted in.
20    Experimental,
21    /// Functionally complete but still gathering feedback before general availability.
22    Beta,
23    /// Fully promoted and visible by default.
24    Ga,
25}
26
27impl Stage {
28    /// Returns the wire string for the stage.
29    #[must_use]
30    pub const fn as_str(self) -> &'static str {
31        match self {
32            Self::Experimental => "experimental",
33            Self::Beta => "beta",
34            Self::Ga => "ga",
35        }
36    }
37}
38
39impl Default for Stage {
40    /// Every command with no explicit stage declaration is implicitly [`Stage::Ga`].
41    fn default() -> Self {
42        Self::Ga
43    }
44}
45
46impl fmt::Display for Stage {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        f.write_str(self.as_str())
49    }
50}
51
52impl FromStr for Stage {
53    type Err = ParseStageError;
54
55    fn from_str(value: &str) -> Result<Self, Self::Err> {
56        match value {
57            "experimental" => Ok(Self::Experimental),
58            "beta" => Ok(Self::Beta),
59            "ga" => Ok(Self::Ga),
60            other => Err(ParseStageError {
61                value: other.to_owned(),
62            }),
63        }
64    }
65}
66
67/// Error returned when parsing an unknown feature stage.
68#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
69#[error("invalid stage {value:?}: must be one of experimental, beta, ga")]
70pub struct ParseStageError {
71    value: String,
72}
73
74/// A named feature flag: a key (used for policy overrides and introspection) paired
75/// with the stage at which the flagged node becomes visible.
76#[derive(Debug, Clone)]
77pub struct FeatureFlag {
78    /// Stable identifier used for policy overrides and introspection.
79    pub key: String,
80    /// Stage at which the flagged node becomes visible.
81    pub stage: Stage,
82}
83
84impl FeatureFlag {
85    /// Creates a new feature flag with the given key and stage.
86    #[must_use]
87    pub fn new(key: impl Into<String>, stage: Stage) -> Self {
88        Self {
89            key: key.into(),
90            stage,
91        }
92    }
93}
94
95/// The fully-merged decision inputs for one CLI run: the minimum stage required for
96/// a node to be visible, plus any per-key overrides that force a specific effective
97/// stage regardless of the node's declared stage.
98#[derive(Debug, Clone)]
99pub struct FlagPolicy {
100    /// Minimum stage a node must meet (or exceed) to be visible.
101    pub min_stage: Stage,
102    /// Per-key overrides that substitute a forced effective stage for a flag key,
103    /// in place of the node's own declared stage, when checking visibility.
104    pub overrides: BTreeMap<String, Stage>,
105}
106
107impl Default for FlagPolicy {
108    fn default() -> Self {
109        Self {
110            min_stage: Stage::Ga,
111            overrides: BTreeMap::new(),
112        }
113    }
114}
115
116impl FlagPolicy {
117    /// Creates a new policy with the default minimum stage ([`Stage::Ga`]) and no
118    /// overrides.
119    #[must_use]
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// Sets the minimum stage required for a node to be visible.
125    #[must_use]
126    pub fn with_min_stage(mut self, stage: Stage) -> Self {
127        self.min_stage = stage;
128        self
129    }
130
131    /// Adds (or replaces) a per-key override that forces an effective stage for the
132    /// given flag key, regardless of the node's own declared stage.
133    #[must_use]
134    pub fn with_override(mut self, key: impl Into<String>, stage: Stage) -> Self {
135        self.overrides.insert(key.into(), stage);
136        self
137    }
138
139    /// Returns whether a node is visible under this policy.
140    ///
141    /// If `key` is `Some` and an override is registered for it, the override's stage
142    /// substitutes for `stage` in the comparison against [`Self::min_stage`].
143    /// Otherwise, the node's own `stage` is compared directly against
144    /// [`Self::min_stage`].
145    #[must_use]
146    pub fn visible(&self, key: Option<&str>, stage: Stage) -> bool {
147        let effective = key
148            .and_then(|key| self.overrides.get(key))
149            .copied()
150            .unwrap_or(stage);
151        effective >= self.min_stage
152    }
153}
154
155/// One flagged node discovered while pruning a command tree.
156///
157/// `path` is the colon-separated command path of the node (module/group/
158/// command name chain), matching the same convention used elsewhere in this
159/// crate for command paths — e.g. a `list` command nested under a `project`
160/// group records `"project:list"`. `key` and `stage` are the flag that
161/// resolved for this node (its own declaration, or the nearest ancestor's,
162/// per cascading resolution). `visible` is whether the policy that produced
163/// this entry judged the node visible.
164///
165/// Only nodes that resolve to a *named* flag are recorded; a node with no
166/// flag anywhere in its ancestor chain implicitly resolves to [`Stage::Ga`]
167/// with no key and is not recorded (nothing to introspect).
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct FlagEntry {
170    /// Colon-separated command path of the flagged node.
171    pub path: String,
172    /// Flag key that resolved for this node (own declaration or inherited).
173    pub key: String,
174    /// Stage the resolved flag key declared.
175    pub stage: Stage,
176    /// Whether the node was judged visible under the policy that produced it.
177    pub visible: bool,
178}
179
180/// Every flagged module/group/command path discovered while pruning a
181/// command tree, in registration order.
182///
183/// Populated once, when a [`Cli`](crate::Cli) mounts a module or group and
184/// resolves cascading feature flags across its tree. Powers `flags
185/// list`/`flags info` introspection; stored on [`Middleware`](crate::Middleware)
186/// and populated as a side effect of pruning.
187#[derive(Debug, Clone, Default)]
188pub struct FlagRegistry {
189    entries: Vec<FlagEntry>,
190}
191
192impl FlagRegistry {
193    /// Creates an empty registry.
194    #[must_use]
195    pub fn new() -> Self {
196        Self::default()
197    }
198
199    /// Records one flagged node.
200    pub fn record(&mut self, entry: FlagEntry) {
201        self.entries.push(entry);
202    }
203
204    /// Returns every recorded entry, in the order they were recorded.
205    #[must_use]
206    pub fn entries(&self) -> &[FlagEntry] {
207        &self.entries
208    }
209
210    /// Returns every recorded entry whose flag key matches `key`.
211    #[must_use]
212    pub fn by_key(&self, key: &str) -> Vec<&FlagEntry> {
213        self.entries
214            .iter()
215            .filter(|entry| entry.key == key)
216            .collect()
217    }
218}
219
220#[cfg(test)]
221#[allow(clippy::unwrap_used, clippy::expect_used)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn stage_ordering() {
227        assert!(Stage::Experimental < Stage::Beta);
228        assert!(Stage::Beta < Stage::Ga);
229        assert!(Stage::Experimental < Stage::Ga);
230    }
231
232    #[test]
233    fn stage_default_is_ga() {
234        assert_eq!(Stage::default(), Stage::Ga);
235    }
236
237    #[test]
238    fn stage_from_str_round_trips() {
239        assert_eq!(
240            "experimental".parse::<Stage>().unwrap(),
241            Stage::Experimental
242        );
243        assert_eq!("beta".parse::<Stage>().unwrap(), Stage::Beta);
244        assert_eq!("ga".parse::<Stage>().unwrap(), Stage::Ga);
245    }
246
247    #[test]
248    fn stage_from_str_rejects_unknown() {
249        let err = "nightly".parse::<Stage>().unwrap_err();
250        assert_eq!(
251            err,
252            ParseStageError {
253                value: "nightly".to_owned(),
254            }
255        );
256    }
257
258    #[test]
259    fn flag_policy_default_is_ga_with_no_overrides() {
260        let policy = FlagPolicy::default();
261        assert_eq!(policy.min_stage, Stage::Ga);
262        assert!(policy.overrides.is_empty());
263        assert!(!policy.visible(None, Stage::Beta));
264        assert!(policy.visible(None, Stage::Ga));
265    }
266
267    #[test]
268    fn flag_policy_override_precedence() {
269        let policy = FlagPolicy::new()
270            .with_min_stage(Stage::Ga)
271            .with_override("my-flag", Stage::Beta);
272        // Override stage (Beta) is compared against min_stage (Ga), not the node's
273        // own declared stage (Experimental).
274        assert!(!policy.visible(Some("my-flag"), Stage::Experimental));
275
276        let policy = FlagPolicy::new()
277            .with_min_stage(Stage::Beta)
278            .with_override("my-flag", Stage::Beta);
279        assert!(policy.visible(Some("my-flag"), Stage::Experimental));
280    }
281
282    #[test]
283    fn flag_policy_no_override_falls_back_to_node_stage() {
284        let policy = FlagPolicy::new().with_min_stage(Stage::Beta);
285        assert!(!policy.visible(Some("other-flag"), Stage::Experimental));
286        assert!(policy.visible(Some("other-flag"), Stage::Beta));
287        assert!(policy.visible(None, Stage::Ga));
288    }
289
290    #[test]
291    fn flag_registry_starts_empty() {
292        let registry = FlagRegistry::new();
293        assert!(registry.entries().is_empty());
294        assert!(registry.by_key("anything").is_empty());
295    }
296
297    #[test]
298    fn flag_registry_records_entries_in_order() {
299        let mut registry = FlagRegistry::new();
300        registry.record(FlagEntry {
301            path: "project".to_owned(),
302            key: "flag-a".to_owned(),
303            stage: Stage::Beta,
304            visible: true,
305        });
306        registry.record(FlagEntry {
307            path: "project:list".to_owned(),
308            key: "flag-b".to_owned(),
309            stage: Stage::Experimental,
310            visible: false,
311        });
312
313        let entries = registry.entries();
314        assert_eq!(entries.len(), 2);
315        assert_eq!(entries[0].path, "project");
316        assert_eq!(entries[1].path, "project:list");
317    }
318
319    #[test]
320    fn flag_registry_by_key_filters() {
321        let mut registry = FlagRegistry::new();
322        registry.record(FlagEntry {
323            path: "project".to_owned(),
324            key: "flag-a".to_owned(),
325            stage: Stage::Beta,
326            visible: true,
327        });
328        registry.record(FlagEntry {
329            path: "project:list".to_owned(),
330            key: "flag-a".to_owned(),
331            stage: Stage::Beta,
332            visible: true,
333        });
334        registry.record(FlagEntry {
335            path: "domain".to_owned(),
336            key: "flag-b".to_owned(),
337            stage: Stage::Experimental,
338            visible: false,
339        });
340
341        let matches = registry.by_key("flag-a");
342        assert_eq!(matches.len(), 2);
343        assert!(matches.iter().all(|entry| entry.key == "flag-a"));
344
345        assert!(registry.by_key("no-such-flag").is_empty());
346    }
347}