Skip to main content

rullst_core/feature/
toml.rs

1use async_trait::async_trait;
2use std::collections::HashMap;
3use std::sync::{PoisonError, RwLock};
4
5use super::driver::FeatureDriver;
6use super::resolvers::parse_feature_string_value;
7
8// ─── TOML Driver ────────────────────────────────────────────────────────────
9
10/// Driver that parses feature flags defined in `Rullst.toml`.
11///
12/// Looks for keys under a `[features]` block:
13/// ```toml
14/// [features]
15/// new-ui = true
16/// ab-signup = "30%"
17/// pricing-ab = "control:50,treatment:50"
18/// ```
19///
20/// [`reload`](Self::reload) parses the file into a new map and swaps it in at
21/// once, so a concurrent evaluation sees either the previous or the new flags,
22/// never a partially loaded set. A section header may carry a trailing
23/// `# comment`.
24#[non_exhaustive]
25pub struct TomlFeatureDriver {
26    pub(crate) config: RwLock<HashMap<String, String>>,
27    config_path: std::path::PathBuf,
28}
29
30impl TomlFeatureDriver {
31    /// Creates a new `TomlFeatureDriver` and parses `Rullst.toml` if present.
32    pub fn new() -> Self {
33        let config_path = std::env::current_dir()
34            .unwrap_or_else(|_| std::path::PathBuf::from("."))
35            .join("Rullst.toml");
36        let driver = Self {
37            config: RwLock::new(HashMap::new()),
38            config_path,
39        };
40
41        if let Ok(content) = std::fs::read_to_string(&driver.config_path) {
42            driver.load_from_str(&content);
43        }
44
45        driver
46    }
47
48    /// Reloads the features section from `Rullst.toml`.
49    #[cfg_attr(mutants, mutants::skip)]
50    pub async fn reload(&self) -> Result<(), Box<dyn std::error::Error>> {
51        let content = tokio::fs::read_to_string(&self.config_path).await?;
52        self.load_from_str(&content);
53        Ok(())
54    }
55
56    pub(crate) fn load_from_str(&self, content: &str) {
57        let parsed = parse_features(content);
58        *self.config.write().unwrap_or_else(PoisonError::into_inner) = parsed;
59    }
60
61    pub(crate) fn value(&self, flag: &str) -> Option<String> {
62        self.config
63            .read()
64            .unwrap_or_else(PoisonError::into_inner)
65            .get(flag)
66            .cloned()
67    }
68
69    fn evaluate(&self, value: &str, flag: &str, identifier: Option<&str>) -> Option<String> {
70        parse_feature_string_value(value, flag, identifier)
71    }
72}
73
74/// Reads the `[features]` table of `Rullst.toml` into a new map.
75fn parse_features(content: &str) -> HashMap<String, String> {
76    let mut config = HashMap::new();
77    let mut in_features = false;
78    for line in content.lines() {
79        let trimmed = line.trim();
80        #[cfg_attr(mutants, mutants::skip)]
81        if trimmed.is_empty() || trimmed.starts_with('#') {
82            continue;
83        }
84        if trimmed.starts_with('[') {
85            // `[features] # comment` is still the features table.
86            let header = trimmed.split('#').next().unwrap_or(trimmed).trim();
87            in_features = header == "[features]";
88            continue;
89        }
90
91        if in_features {
92            let mut parts = trimmed.splitn(2, '=');
93            if let (Some(key), Some(val)) = (parts.next(), parts.next()) {
94                let k = key.trim().to_string();
95                let clean_val = val.split('#').next().unwrap_or(val).trim();
96                let v = clean_val.trim_matches('"').trim_matches('\'').to_string();
97                config.insert(k, v);
98            }
99        }
100    }
101    config
102}
103
104impl Default for TomlFeatureDriver {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110#[async_trait]
111impl FeatureDriver for TomlFeatureDriver {
112    #[cfg_attr(mutants, mutants::skip)]
113    async fn enabled(&self, flag: &str) -> Option<bool> {
114        let val = self.value(flag)?;
115        let evaluated = self.evaluate(&val, flag, None)?;
116        Some(evaluated == "enabled")
117    }
118
119    #[cfg_attr(mutants, mutants::skip)]
120    async fn enabled_for(&self, flag: &str, identifier: &str) -> Option<bool> {
121        let val = self.value(flag)?;
122        let evaluated = self.evaluate(&val, flag, Some(identifier))?;
123        Some(evaluated == "enabled")
124    }
125
126    #[cfg_attr(mutants, mutants::skip)]
127    async fn variant(&self, flag: &str, identifier: &str) -> Option<String> {
128        let val = self.value(flag)?;
129        self.evaluate(&val, flag, Some(identifier))
130    }
131}