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