Skip to main content

feature_flag/
model.rs

1//! Flag / Rule / FlagSet types.
2
3use std::collections::{HashMap, HashSet};
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::FeatureFlagError;
8use crate::predicate::Predicate;
9
10/// One of a flag's named outcomes (`"on"`, `"off"`, `"experiment-a"`, ...).
11pub type Variant = String;
12
13/// A flag's targeting rule. Evaluated in declared order; first match wins.
14///
15/// The matching rule yields either:
16///
17/// - a fixed `variant` (e.g. `"on"`), or
18/// - a `rollout` mapping subject → variant via sticky bucketing.
19#[derive(Clone, Debug, Deserialize, Serialize)]
20pub struct Rule {
21    /// Stable identifier for telemetry.
22    pub id: String,
23    /// Predicate that must match for this rule to fire.
24    pub when: Predicate,
25    /// Outcome when this rule fires.
26    pub outcome: Outcome,
27    /// Free-form description for operators.
28    #[serde(default)]
29    pub description: Option<String>,
30}
31
32/// What a matching rule produces.
33#[derive(Clone, Debug, Deserialize, Serialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35pub enum Outcome {
36    /// Use a single variant unconditionally.
37    Variant {
38        /// Variant name.
39        variant: Variant,
40    },
41    /// Bucket the subject's id into one of these variants.
42    /// Weights must sum to 100. Bucketing is sticky (SHA-256 mod 100).
43    Rollout {
44        /// `(variant, weight)` pairs. Weights are percentages and must sum to 100.
45        variants: Vec<RolloutEntry>,
46    },
47}
48
49/// One slot in a `Rollout` outcome.
50#[derive(Clone, Debug, Deserialize, Serialize)]
51pub struct RolloutEntry {
52    /// Variant name.
53    pub variant: Variant,
54    /// Weight in percentage points (0..=100). Sum of all entries must be 100.
55    pub weight: u32,
56}
57
58/// A single flag.
59#[derive(Clone, Debug, Deserialize, Serialize)]
60pub struct Flag {
61    /// Stable flag identifier (also the public name).
62    pub id: String,
63    /// Free-form description.
64    #[serde(default)]
65    pub description: Option<String>,
66    /// All possible variant names. Rules + the default must reference values
67    /// from this list.
68    pub variants: Vec<Variant>,
69    /// Variant returned when no rule matches.
70    pub default_variant: Variant,
71    /// Ordered targeting rules.
72    #[serde(default)]
73    pub rules: Vec<Rule>,
74    /// When `false`, the evaluator skips all rules and returns
75    /// `default_variant`. Useful as a kill switch.
76    #[serde(default = "default_true")]
77    pub enabled: bool,
78}
79
80fn default_true() -> bool {
81    true
82}
83
84impl Flag {
85    /// Validate cross-field invariants. Called by [`FlagSet::validate`].
86    pub fn validate(&self) -> Result<(), FeatureFlagError> {
87        if self.id.is_empty() {
88            return Err(FeatureFlagError::Invalid(
89                "flag.id must be non-empty".into(),
90            ));
91        }
92        if self.variants.is_empty() {
93            return Err(FeatureFlagError::Invalid(format!(
94                "flag {}: variants must be non-empty",
95                self.id
96            )));
97        }
98        let known: HashSet<&str> = self.variants.iter().map(String::as_str).collect();
99        if !known.contains(self.default_variant.as_str()) {
100            return Err(FeatureFlagError::Invalid(format!(
101                "flag {}: default_variant {:?} not in variants",
102                self.id, self.default_variant
103            )));
104        }
105        for rule in &self.rules {
106            match &rule.outcome {
107                Outcome::Variant { variant } => {
108                    if !known.contains(variant.as_str()) {
109                        return Err(FeatureFlagError::Invalid(format!(
110                            "flag {}: rule {:?} references unknown variant {:?}",
111                            self.id, rule.id, variant
112                        )));
113                    }
114                }
115                Outcome::Rollout { variants } => {
116                    let total: u32 = variants.iter().map(|e| e.weight).sum();
117                    if total != 100 {
118                        return Err(FeatureFlagError::Invalid(format!(
119                            "flag {}: rule {:?} rollout weights total {} (must be 100)",
120                            self.id, rule.id, total
121                        )));
122                    }
123                    for entry in variants {
124                        if !known.contains(entry.variant.as_str()) {
125                            return Err(FeatureFlagError::Invalid(format!(
126                                "flag {}: rule {:?} references unknown variant {:?}",
127                                self.id, rule.id, entry.variant
128                            )));
129                        }
130                    }
131                }
132            }
133        }
134        Ok(())
135    }
136}
137
138/// A collection of flags loaded from JSON. Construct via `FlagSet::from_json`
139/// or build in code for tests.
140#[derive(Clone, Debug, Default, Deserialize, Serialize)]
141pub struct FlagSet {
142    /// Bundle version. Free-form; logged by [`crate::FlagEvaluator`].
143    #[serde(default)]
144    pub version: String,
145    /// The flags.
146    pub flags: Vec<Flag>,
147}
148
149impl FlagSet {
150    /// Parse a JSON document.
151    pub fn from_json(raw: &str) -> Result<Self, FeatureFlagError> {
152        let parsed: Self = serde_json::from_str(raw)?;
153        parsed.validate()?;
154        Ok(parsed)
155    }
156
157    /// Check every flag's invariants and detect duplicate flag ids.
158    pub fn validate(&self) -> Result<(), FeatureFlagError> {
159        let mut seen: HashSet<&str> = HashSet::new();
160        for flag in &self.flags {
161            if !seen.insert(flag.id.as_str()) {
162                return Err(FeatureFlagError::Invalid(format!(
163                    "duplicate flag id: {}",
164                    flag.id
165                )));
166            }
167            flag.validate()?;
168        }
169        Ok(())
170    }
171
172    /// Build a `HashMap<flag_id, &Flag>` for O(1) lookup. Used internally by
173    /// [`crate::FlagEvaluator`].
174    #[must_use]
175    pub fn index(&self) -> HashMap<&str, &Flag> {
176        self.flags.iter().map(|f| (f.id.as_str(), f)).collect()
177    }
178}