1use std::{collections::BTreeMap, fmt, str::FromStr};
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12#[derive(
15 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
16)]
17#[serde(rename_all = "lowercase")]
18pub enum Stage {
19 Experimental,
21 Beta,
23 Ga,
25}
26
27impl Stage {
28 #[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 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#[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#[derive(Debug, Clone)]
77pub struct FeatureFlag {
78 pub key: String,
80 pub stage: Stage,
82}
83
84impl FeatureFlag {
85 #[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#[derive(Debug, Clone)]
99pub struct FlagPolicy {
100 pub min_stage: Stage,
102 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 #[must_use]
120 pub fn new() -> Self {
121 Self::default()
122 }
123
124 #[must_use]
126 pub fn with_min_stage(mut self, stage: Stage) -> Self {
127 self.min_stage = stage;
128 self
129 }
130
131 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct FlagEntry {
170 pub path: String,
172 pub key: String,
174 pub stage: Stage,
176 pub visible: bool,
178}
179
180#[derive(Debug, Clone, Default)]
188pub struct FlagRegistry {
189 entries: Vec<FlagEntry>,
190}
191
192impl FlagRegistry {
193 #[must_use]
195 pub fn new() -> Self {
196 Self::default()
197 }
198
199 pub fn record(&mut self, entry: FlagEntry) {
201 self.entries.push(entry);
202 }
203
204 #[must_use]
206 pub fn entries(&self) -> &[FlagEntry] {
207 &self.entries
208 }
209
210 #[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 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}