Skip to main content

tak_cli/
settings.rs

1//! Settings, and where their values come from.
2//!
3//! `settings.toml` in the repository root is the source of truth. `build.rs`
4//! turns it into the [`Settings`] struct, its [`Default`], and the [`SETTINGS`]
5//! metadata slice included below. Nothing here restates what a setting *is* —
6//! only how a value is chosen for it.
7//!
8//! Precedence, highest first: CLI flag, environment variable, `tak.toml`,
9//! declared default. A source that is absent is skipped rather than treated as
10//! empty, so setting a value in `tak.toml` is not undone by the flag being
11//! unused.
12
13use crate::config::SettingsSections;
14
15include!(concat!(env!("OUT_DIR"), "/settings_generated.rs"));
16
17/// Values supplied on the command line.
18///
19/// `None` means the flag was not given, which is not the same as being given an
20/// empty list — the first defers to lower-precedence sources, the second would
21/// override them. Repeated flags accumulate, and clap yields an empty vector
22/// when a flag is absent, so [`from_cli`] does that conversion in one place.
23#[derive(Debug, Clone, Default, PartialEq)]
24pub struct Overrides {
25    pub env_deny: Option<Vec<String>>,
26    pub env_allow: Option<Vec<String>>,
27    pub gate_pct: Option<f64>,
28    pub credit: Option<bool>,
29}
30
31/// Treat an empty vector from clap as "flag not given".
32///
33/// A consequence worth knowing: there is no way to clear a list from the
34/// command line. `tak.toml` can hold `deny = []`, and an environment variable
35/// can be set to the empty string, because for those two the presence of the
36/// key is itself the signal.
37pub fn from_cli(v: Vec<String>) -> Option<Vec<String>> {
38    (!v.is_empty()).then_some(v)
39}
40
41/// How the process environment is read.
42///
43/// Injected rather than called directly so tests can exercise the declared
44/// variables without mutating the environment of the whole test binary, which
45/// races every other test in it.
46pub type EnvLookup<'a> = &'a dyn Fn(&str) -> Option<String>;
47
48/// Read a boolean setting from the environment.
49///
50/// The usual spellings, because someone writing `TAK_CREDIT=0` in a workflow
51/// should not have to discover that only `false` counts. Anything unrecognised
52/// warns rather than silently meaning one of them.
53fn bool_from_env(env: EnvLookup, key: &str) -> Option<bool> {
54    let raw = env(key)?;
55    match raw.trim().to_ascii_lowercase().as_str() {
56        "1" | "true" | "yes" | "on" => Some(true),
57        "0" | "false" | "no" | "off" => Some(false),
58        _ => {
59            eprintln!("warning: {key} is not a boolean: {raw:?}");
60            None
61        }
62    }
63}
64
65/// Read a list-valued setting from the environment.
66///
67/// Presence is the signal: `TAK_ENV_DENY=` yields an empty list rather than
68/// falling through to `tak.toml`, because someone who exported the variable
69/// meant to say something.
70fn list_from_env(env: EnvLookup, key: &str) -> Option<Vec<String>> {
71    env(key).map(|raw| {
72        raw.split(',')
73            .map(str::trim)
74            .filter(|s| !s.is_empty())
75            .map(str::to_string)
76            .collect()
77    })
78}
79
80impl Settings {
81    /// Resolve every setting from the sources declared in `settings.toml`.
82    pub fn resolve(cli: &Overrides, config: &SettingsSections, env: EnvLookup) -> Self {
83        let defaults = Self::default();
84        let envs = config.env.as_ref();
85        Self {
86            credit: cli
87                .credit
88                .or_else(|| bool_from_env(env, "TAK_CREDIT"))
89                .or_else(|| config.report.as_ref().and_then(|r| r.credit))
90                .unwrap_or(defaults.credit),
91            gate_pct: cli
92                .gate_pct
93                .or_else(|| {
94                    env("TAK_GATE_PCT").and_then(|raw| match raw.trim().parse::<f64>() {
95                        Ok(v) => Some(v),
96                        // A typo must not silently become the default and let a
97                        // regression through a gate the user thought they set.
98                        Err(_) => {
99                            eprintln!("warning: TAK_GATE_PCT is not a number: {raw:?}");
100                            None
101                        }
102                    })
103                })
104                .or_else(|| config.gate.as_ref().and_then(|g| g.pct))
105                .unwrap_or(defaults.gate_pct),
106            env_allow: cli
107                .env_allow
108                .clone()
109                .or_else(|| list_from_env(env, "TAK_ENV_ALLOW"))
110                .or_else(|| envs.and_then(|c| c.allow.clone()))
111                .unwrap_or(defaults.env_allow),
112            env_deny: cli
113                .env_deny
114                .clone()
115                .or_else(|| list_from_env(env, "TAK_ENV_DENY"))
116                .or_else(|| envs.and_then(|c| c.deny.clone()))
117                .unwrap_or(defaults.env_deny),
118        }
119    }
120
121    /// Resolve against the real process environment.
122    pub fn from_process(cli: &Overrides, config: &SettingsSections) -> Self {
123        Self::resolve(cli, config, &|key| std::env::var(key).ok())
124    }
125
126    /// The value of a setting, by its registry name.
127    ///
128    /// Exists so display code cannot silently omit a setting: `SETTINGS` is
129    /// generated, so a new entry appears in `tak settings` whether or not
130    /// anything can produce its value. A test asserts this returns `Some` for
131    /// every registry entry, which turns "added a setting, forgot the
132    /// accessor" into a build failure instead of a blank row.
133    pub fn display_value(&self, name: &str) -> Option<String> {
134        match name {
135            "env_allow" => Some(format!("{:?}", self.env_allow)),
136            "env_deny" => Some(format!("{:?}", self.env_deny)),
137            "credit" => Some(format!("{}", self.credit)),
138            "gate_pct" => Some(format!("{}", self.gate_pct)),
139            _ => None,
140        }
141    }
142
143    /// Variables to remove from a benchmark subject: denied, less allowed.
144    ///
145    /// Allow subtracts from deny rather than sitting beside it, so opting one
146    /// variable back in does not mean restating the whole default list.
147    pub fn scrubbed_env(&self) -> impl Iterator<Item = &str> {
148        self.env_deny
149            .iter()
150            .filter(|name| !self.env_allow.contains(name))
151            .map(String::as_str)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn no_env(_: &str) -> Option<String> {
160        None
161    }
162
163    /// A `tak.toml` with just an `[env]` table.
164    fn env_config(deny: Option<&[&str]>, allow: Option<&[&str]>) -> SettingsSections {
165        SettingsSections {
166            env: Some(crate::config::EnvSection {
167                deny: deny.map(|v| v.iter().map(|s| s.to_string()).collect()),
168                allow: allow.map(|v| v.iter().map(|s| s.to_string()).collect()),
169            }),
170            gate: None,
171            report: None,
172        }
173    }
174
175    #[test]
176    fn the_default_protects_forge_tokens() {
177        let s = Settings::default();
178        let scrubbed: Vec<_> = s.scrubbed_env().collect();
179        assert!(scrubbed.contains(&"GITHUB_TOKEN"));
180        assert!(scrubbed.contains(&"GH_TOKEN"));
181    }
182
183    #[test]
184    fn allow_subtracts_from_deny() {
185        let s = Settings {
186            env_deny: vec!["A".into(), "B".into()],
187            env_allow: vec!["B".into()],
188            gate_pct: Settings::default().gate_pct,
189            credit: Settings::default().credit,
190        };
191        assert_eq!(s.scrubbed_env().collect::<Vec<_>>(), ["A"]);
192    }
193
194    /// Allowing something that is not denied is a no-op, not an error and not
195    /// an addition — this setting only ever removes entries from the deny list.
196    #[test]
197    fn allowing_an_undenied_variable_does_nothing() {
198        let s = Settings {
199            env_deny: vec!["A".into()],
200            env_allow: vec!["ZZZ".into()],
201            gate_pct: Settings::default().gate_pct,
202            credit: Settings::default().credit,
203        };
204        assert_eq!(s.scrubbed_env().collect::<Vec<_>>(), ["A"]);
205    }
206
207    #[test]
208    fn cli_beats_env_beats_config() {
209        let cfg = env_config(Some(&["FROM_CONFIG"]), None);
210        let env = |k: &str| (k == "TAK_ENV_DENY").then(|| "FROM_ENV".to_string());
211
212        let from_config = Settings::resolve(&Overrides::default(), &cfg, &no_env);
213        assert_eq!(from_config.env_deny, ["FROM_CONFIG"]);
214
215        let from_env = Settings::resolve(&Overrides::default(), &cfg, &env);
216        assert_eq!(from_env.env_deny, ["FROM_ENV"]);
217
218        let cli = Overrides {
219            env_deny: Some(vec!["FROM_CLI".into()]),
220            ..Default::default()
221        };
222        let from_cli = Settings::resolve(&cli, &cfg, &env);
223        assert_eq!(from_cli.env_deny, ["FROM_CLI"]);
224    }
225
226    #[test]
227    fn an_absent_source_defers_rather_than_clearing() {
228        let cfg = env_config(Some(&["FROM_CONFIG"]), None);
229        // No CLI flag and no variable: the config value survives.
230        let s = Settings::resolve(&Overrides::default(), &cfg, &no_env);
231        assert_eq!(s.env_deny, ["FROM_CONFIG"]);
232    }
233
234    /// An exported-but-empty variable is a deliberate empty list. Falling
235    /// through to `tak.toml` here would make `TAK_ENV_DENY=` silently do the
236    /// opposite of what it looks like.
237    #[test]
238    fn an_empty_variable_means_an_empty_list() {
239        let cfg = env_config(Some(&["FROM_CONFIG"]), None);
240        let env = |k: &str| (k == "TAK_ENV_DENY").then(String::new);
241        let s = Settings::resolve(&Overrides::default(), &cfg, &env);
242        assert!(s.env_deny.is_empty());
243    }
244
245    #[test]
246    fn a_variable_is_split_on_commas_and_trimmed() {
247        let env = |k: &str| (k == "TAK_ENV_DENY").then(|| " A , B ,, C ".to_string());
248        let s = Settings::resolve(&Overrides::default(), &SettingsSections::default(), &env);
249        assert_eq!(s.env_deny, ["A", "B", "C"]);
250    }
251
252    #[test]
253    fn an_unused_cli_flag_is_not_an_empty_list() {
254        assert_eq!(from_cli(vec![]), None);
255        assert_eq!(from_cli(vec!["A".into()]), Some(vec!["A".to_string()]));
256    }
257
258    /// The drift guard. A setting added to `settings.toml` gets a field and a
259    /// row in `tak settings` for free, but nothing forces it to be *wired* into
260    /// `resolve`. This asserts every declared environment variable actually
261    /// changes the resolved settings, so adding one and forgetting the wiring
262    /// fails here rather than shipping a setting that reads as supported.
263    /// A value that differs from every default and parses as every supported
264    /// type: a list sees `["12345"]`, a float sees `12345`. Using a word here
265    /// would make the float settings silently fall through to their default and
266    /// the drift check would pass while proving nothing.
267    const ENV_SENTINEL: &str = "12345";
268
269    /// Booleans need their own: `12345` is not one, and the drift check would
270    /// pass while proving the setting was never wired.
271    const BOOL_ENV_SENTINEL: &str = "false";
272
273    /// The TOML literal for a sentinel of this registry type.
274    fn config_sentinel(type_: &str) -> String {
275        match type_ {
276            "list<string>" => "[\"SENTINEL\"]".to_string(),
277            "float" => "12345.0".to_string(),
278            // The opposite of every bool default, so flipping it always shows.
279            "bool" => "false".to_string(),
280            other => panic!("the drift check has no sentinel for type `{other}`"),
281        }
282    }
283
284    #[test]
285    fn every_declared_env_var_is_honoured() {
286        for setting in SETTINGS {
287            for var in setting.env_vars {
288                let sentinel = if setting.type_ == "bool" {
289                    BOOL_ENV_SENTINEL
290                } else {
291                    ENV_SENTINEL
292                };
293                let env = |k: &str| (k == *var).then(|| sentinel.to_string());
294                let got =
295                    Settings::resolve(&Overrides::default(), &SettingsSections::default(), &env);
296                assert_ne!(
297                    got,
298                    Settings::default(),
299                    "`{}` declares {var} but setting it changes nothing — \
300                     is it wired into Settings::resolve?",
301                    setting.name
302                );
303            }
304        }
305    }
306
307    /// The same guard for `tak.toml`. A dotted registry key is valid TOML on
308    /// its own, so this builds the smallest config that sets exactly that key
309    /// and checks it lands — which also proves the key spelled in the registry
310    /// is the one `Config` actually deserializes.
311    #[test]
312    fn every_declared_config_key_is_honoured() {
313        for setting in SETTINGS {
314            for key in setting.config_keys {
315                let text = format!("{key} = {}\n", config_sentinel(setting.type_));
316                let cfg: SettingsSections = toml::from_str(&text).unwrap_or_else(|e| {
317                    panic!(
318                        "`{}` declares config key `{key}`, which does not parse: {e}",
319                        setting.name
320                    )
321                });
322                let got = Settings::resolve(&Overrides::default(), &cfg, &no_env);
323                assert_ne!(
324                    got,
325                    Settings::default(),
326                    "`{}` declares config key `{key}` but setting it changes nothing",
327                    setting.name
328                );
329            }
330        }
331    }
332
333    /// Display code reads values by registry name, and `SETTINGS` is generated,
334    /// so a new setting shows up in `tak settings` whether or not its value can
335    /// be produced. This is what stops that being a blank row.
336    #[test]
337    fn every_setting_has_an_accessor() {
338        let s = Settings::default();
339        for setting in SETTINGS {
340            assert!(
341                s.display_value(setting.name).is_some(),
342                "`{}` has no accessor in Settings::get",
343                setting.name
344            );
345        }
346    }
347
348    /// Every setting must be reachable somehow, or it is documentation for a
349    /// feature that does not exist.
350    #[test]
351    fn every_setting_declares_a_source() {
352        for s in SETTINGS {
353            assert!(
354                !s.cli_flags.is_empty() || !s.env_vars.is_empty() || !s.config_keys.is_empty(),
355                "`{}` has no sources",
356                s.name
357            );
358        }
359    }
360}