use std::{collections::BTreeMap, fmt, str::FromStr};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum Stage {
Experimental,
Beta,
Ga,
}
impl Stage {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Experimental => "experimental",
Self::Beta => "beta",
Self::Ga => "ga",
}
}
}
impl Default for Stage {
fn default() -> Self {
Self::Ga
}
}
impl fmt::Display for Stage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Stage {
type Err = ParseStageError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"experimental" => Ok(Self::Experimental),
"beta" => Ok(Self::Beta),
"ga" => Ok(Self::Ga),
other => Err(ParseStageError {
value: other.to_owned(),
}),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[error("invalid stage {value:?}: must be one of experimental, beta, ga")]
pub struct ParseStageError {
value: String,
}
#[derive(Debug, Clone)]
pub struct FeatureFlag {
pub key: String,
pub stage: Stage,
}
impl FeatureFlag {
#[must_use]
pub fn new(key: impl Into<String>, stage: Stage) -> Self {
Self {
key: key.into(),
stage,
}
}
}
#[derive(Debug, Clone)]
pub struct FlagPolicy {
pub min_stage: Stage,
pub overrides: BTreeMap<String, Stage>,
}
impl Default for FlagPolicy {
fn default() -> Self {
Self {
min_stage: Stage::Ga,
overrides: BTreeMap::new(),
}
}
}
impl FlagPolicy {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_min_stage(mut self, stage: Stage) -> Self {
self.min_stage = stage;
self
}
#[must_use]
pub fn with_override(mut self, key: impl Into<String>, stage: Stage) -> Self {
self.overrides.insert(key.into(), stage);
self
}
#[must_use]
pub fn visible(&self, key: Option<&str>, stage: Stage) -> bool {
let effective = key
.and_then(|key| self.overrides.get(key))
.copied()
.unwrap_or(stage);
effective >= self.min_stage
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FlagEntry {
pub path: String,
pub key: String,
pub stage: Stage,
pub visible: bool,
}
#[derive(Debug, Clone, Default)]
pub struct FlagRegistry {
entries: Vec<FlagEntry>,
}
impl FlagRegistry {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, entry: FlagEntry) {
self.entries.push(entry);
}
#[must_use]
pub fn entries(&self) -> &[FlagEntry] {
&self.entries
}
#[must_use]
pub fn by_key(&self, key: &str) -> Vec<&FlagEntry> {
self.entries
.iter()
.filter(|entry| entry.key == key)
.collect()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn stage_ordering() {
assert!(Stage::Experimental < Stage::Beta);
assert!(Stage::Beta < Stage::Ga);
assert!(Stage::Experimental < Stage::Ga);
}
#[test]
fn stage_default_is_ga() {
assert_eq!(Stage::default(), Stage::Ga);
}
#[test]
fn stage_from_str_round_trips() {
assert_eq!(
"experimental".parse::<Stage>().unwrap(),
Stage::Experimental
);
assert_eq!("beta".parse::<Stage>().unwrap(), Stage::Beta);
assert_eq!("ga".parse::<Stage>().unwrap(), Stage::Ga);
}
#[test]
fn stage_from_str_rejects_unknown() {
let err = "nightly".parse::<Stage>().unwrap_err();
assert_eq!(
err,
ParseStageError {
value: "nightly".to_owned(),
}
);
}
#[test]
fn flag_policy_default_is_ga_with_no_overrides() {
let policy = FlagPolicy::default();
assert_eq!(policy.min_stage, Stage::Ga);
assert!(policy.overrides.is_empty());
assert!(!policy.visible(None, Stage::Beta));
assert!(policy.visible(None, Stage::Ga));
}
#[test]
fn flag_policy_override_precedence() {
let policy = FlagPolicy::new()
.with_min_stage(Stage::Ga)
.with_override("my-flag", Stage::Beta);
assert!(!policy.visible(Some("my-flag"), Stage::Experimental));
let policy = FlagPolicy::new()
.with_min_stage(Stage::Beta)
.with_override("my-flag", Stage::Beta);
assert!(policy.visible(Some("my-flag"), Stage::Experimental));
}
#[test]
fn flag_policy_no_override_falls_back_to_node_stage() {
let policy = FlagPolicy::new().with_min_stage(Stage::Beta);
assert!(!policy.visible(Some("other-flag"), Stage::Experimental));
assert!(policy.visible(Some("other-flag"), Stage::Beta));
assert!(policy.visible(None, Stage::Ga));
}
#[test]
fn flag_registry_starts_empty() {
let registry = FlagRegistry::new();
assert!(registry.entries().is_empty());
assert!(registry.by_key("anything").is_empty());
}
#[test]
fn flag_registry_records_entries_in_order() {
let mut registry = FlagRegistry::new();
registry.record(FlagEntry {
path: "project".to_owned(),
key: "flag-a".to_owned(),
stage: Stage::Beta,
visible: true,
});
registry.record(FlagEntry {
path: "project:list".to_owned(),
key: "flag-b".to_owned(),
stage: Stage::Experimental,
visible: false,
});
let entries = registry.entries();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].path, "project");
assert_eq!(entries[1].path, "project:list");
}
#[test]
fn flag_registry_by_key_filters() {
let mut registry = FlagRegistry::new();
registry.record(FlagEntry {
path: "project".to_owned(),
key: "flag-a".to_owned(),
stage: Stage::Beta,
visible: true,
});
registry.record(FlagEntry {
path: "project:list".to_owned(),
key: "flag-a".to_owned(),
stage: Stage::Beta,
visible: true,
});
registry.record(FlagEntry {
path: "domain".to_owned(),
key: "flag-b".to_owned(),
stage: Stage::Experimental,
visible: false,
});
let matches = registry.by_key("flag-a");
assert_eq!(matches.len(), 2);
assert!(matches.iter().all(|entry| entry.key == "flag-a"));
assert!(registry.by_key("no-such-flag").is_empty());
}
}