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::EnvSection;
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, Eq)]
24pub struct Overrides {
25    pub env_deny: Option<Vec<String>>,
26    pub env_allow: Option<Vec<String>>,
27}
28
29/// Treat an empty vector from clap as "flag not given".
30///
31/// A consequence worth knowing: there is no way to clear a list from the
32/// command line. `tak.toml` can hold `deny = []`, and an environment variable
33/// can be set to the empty string, because for those two the presence of the
34/// key is itself the signal.
35pub fn from_cli(v: Vec<String>) -> Option<Vec<String>> {
36    (!v.is_empty()).then_some(v)
37}
38
39/// How the process environment is read.
40///
41/// Injected rather than called directly so tests can exercise the declared
42/// variables without mutating the environment of the whole test binary, which
43/// races every other test in it.
44pub type EnvLookup<'a> = &'a dyn Fn(&str) -> Option<String>;
45
46/// Read a list-valued setting from the environment.
47///
48/// Presence is the signal: `TAK_ENV_DENY=` yields an empty list rather than
49/// falling through to `tak.toml`, because someone who exported the variable
50/// meant to say something.
51fn list_from_env(env: EnvLookup, key: &str) -> Option<Vec<String>> {
52    env(key).map(|raw| {
53        raw.split(',')
54            .map(str::trim)
55            .filter(|s| !s.is_empty())
56            .map(str::to_string)
57            .collect()
58    })
59}
60
61impl Settings {
62    /// Resolve every setting from the sources declared in `settings.toml`.
63    pub fn resolve(cli: &Overrides, config: Option<&EnvSection>, env: EnvLookup) -> Self {
64        let defaults = Self::default();
65        Self {
66            env_allow: cli
67                .env_allow
68                .clone()
69                .or_else(|| list_from_env(env, "TAK_ENV_ALLOW"))
70                .or_else(|| config.and_then(|c| c.allow.clone()))
71                .unwrap_or(defaults.env_allow),
72            env_deny: cli
73                .env_deny
74                .clone()
75                .or_else(|| list_from_env(env, "TAK_ENV_DENY"))
76                .or_else(|| config.and_then(|c| c.deny.clone()))
77                .unwrap_or(defaults.env_deny),
78        }
79    }
80
81    /// Resolve against the real process environment.
82    pub fn from_process(cli: &Overrides, config: Option<&EnvSection>) -> Self {
83        Self::resolve(cli, config, &|key| std::env::var(key).ok())
84    }
85
86    /// The value of a setting, by its registry name.
87    ///
88    /// Exists so display code cannot silently omit a setting: `SETTINGS` is
89    /// generated, so a new entry appears in `tak settings` whether or not
90    /// anything can produce its value. A test asserts this returns `Some` for
91    /// every registry entry, which turns "added a setting, forgot the
92    /// accessor" into a build failure instead of a blank row.
93    pub fn get(&self, name: &str) -> Option<&Vec<String>> {
94        match name {
95            "env_allow" => Some(&self.env_allow),
96            "env_deny" => Some(&self.env_deny),
97            _ => None,
98        }
99    }
100
101    /// Variables to remove from a benchmark subject: denied, less allowed.
102    ///
103    /// Allow subtracts from deny rather than sitting beside it, so opting one
104    /// variable back in does not mean restating the whole default list.
105    pub fn scrubbed_env(&self) -> impl Iterator<Item = &str> {
106        self.env_deny
107            .iter()
108            .filter(|name| !self.env_allow.contains(name))
109            .map(String::as_str)
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn no_env(_: &str) -> Option<String> {
118        None
119    }
120
121    #[test]
122    fn the_default_protects_forge_tokens() {
123        let s = Settings::default();
124        let scrubbed: Vec<_> = s.scrubbed_env().collect();
125        assert!(scrubbed.contains(&"GITHUB_TOKEN"));
126        assert!(scrubbed.contains(&"GH_TOKEN"));
127    }
128
129    #[test]
130    fn allow_subtracts_from_deny() {
131        let s = Settings {
132            env_deny: vec!["A".into(), "B".into()],
133            env_allow: vec!["B".into()],
134        };
135        assert_eq!(s.scrubbed_env().collect::<Vec<_>>(), ["A"]);
136    }
137
138    /// Allowing something that is not denied is a no-op, not an error and not
139    /// an addition — this setting only ever removes entries from the deny list.
140    #[test]
141    fn allowing_an_undenied_variable_does_nothing() {
142        let s = Settings {
143            env_deny: vec!["A".into()],
144            env_allow: vec!["ZZZ".into()],
145        };
146        assert_eq!(s.scrubbed_env().collect::<Vec<_>>(), ["A"]);
147    }
148
149    #[test]
150    fn cli_beats_env_beats_config() {
151        let cfg = EnvSection {
152            deny: Some(vec!["FROM_CONFIG".into()]),
153            allow: None,
154        };
155        let env = |k: &str| (k == "TAK_ENV_DENY").then(|| "FROM_ENV".to_string());
156
157        let from_config = Settings::resolve(&Overrides::default(), Some(&cfg), &no_env);
158        assert_eq!(from_config.env_deny, ["FROM_CONFIG"]);
159
160        let from_env = Settings::resolve(&Overrides::default(), Some(&cfg), &env);
161        assert_eq!(from_env.env_deny, ["FROM_ENV"]);
162
163        let cli = Overrides {
164            env_deny: Some(vec!["FROM_CLI".into()]),
165            ..Default::default()
166        };
167        let from_cli = Settings::resolve(&cli, Some(&cfg), &env);
168        assert_eq!(from_cli.env_deny, ["FROM_CLI"]);
169    }
170
171    #[test]
172    fn an_absent_source_defers_rather_than_clearing() {
173        let cfg = EnvSection {
174            deny: Some(vec!["FROM_CONFIG".into()]),
175            allow: None,
176        };
177        // No CLI flag and no variable: the config value survives.
178        let s = Settings::resolve(&Overrides::default(), Some(&cfg), &no_env);
179        assert_eq!(s.env_deny, ["FROM_CONFIG"]);
180    }
181
182    /// An exported-but-empty variable is a deliberate empty list. Falling
183    /// through to `tak.toml` here would make `TAK_ENV_DENY=` silently do the
184    /// opposite of what it looks like.
185    #[test]
186    fn an_empty_variable_means_an_empty_list() {
187        let cfg = EnvSection {
188            deny: Some(vec!["FROM_CONFIG".into()]),
189            allow: None,
190        };
191        let env = |k: &str| (k == "TAK_ENV_DENY").then(String::new);
192        let s = Settings::resolve(&Overrides::default(), Some(&cfg), &env);
193        assert!(s.env_deny.is_empty());
194    }
195
196    #[test]
197    fn a_variable_is_split_on_commas_and_trimmed() {
198        let env = |k: &str| (k == "TAK_ENV_DENY").then(|| " A , B ,, C ".to_string());
199        let s = Settings::resolve(&Overrides::default(), None, &env);
200        assert_eq!(s.env_deny, ["A", "B", "C"]);
201    }
202
203    #[test]
204    fn an_unused_cli_flag_is_not_an_empty_list() {
205        assert_eq!(from_cli(vec![]), None);
206        assert_eq!(from_cli(vec!["A".into()]), Some(vec!["A".to_string()]));
207    }
208
209    /// The drift guard. A setting added to `settings.toml` gets a field and a
210    /// row in `tak settings` for free, but nothing forces it to be *wired* into
211    /// `resolve`. This asserts every declared environment variable actually
212    /// changes the resolved settings, so adding one and forgetting the wiring
213    /// fails here rather than shipping a setting that reads as supported.
214    #[test]
215    fn every_declared_env_var_is_honoured() {
216        for setting in SETTINGS {
217            for var in setting.env_vars {
218                let env = |k: &str| (k == *var).then(|| "SENTINEL".to_string());
219                let got = Settings::resolve(&Overrides::default(), None, &env);
220                assert_ne!(
221                    got,
222                    Settings::default(),
223                    "`{}` declares {var} but setting it changes nothing — \
224                     is it wired into Settings::resolve?",
225                    setting.name
226                );
227            }
228        }
229    }
230
231    /// The same guard for `tak.toml`. A dotted registry key is valid TOML on
232    /// its own, so this builds the smallest config that sets exactly that key
233    /// and checks it lands — which also proves the key spelled in the registry
234    /// is the one `Config` actually deserializes.
235    #[test]
236    fn every_declared_config_key_is_honoured() {
237        for setting in SETTINGS {
238            for key in setting.config_keys {
239                let text = format!("{key} = [\"SENTINEL\"]\n");
240                let cfg: crate::config::Config = toml::from_str(&text).unwrap_or_else(|e| {
241                    panic!(
242                        "`{}` declares config key `{key}`, which does not parse: {e}",
243                        setting.name
244                    )
245                });
246                let got = Settings::resolve(&Overrides::default(), cfg.env.as_ref(), &no_env);
247                assert_ne!(
248                    got,
249                    Settings::default(),
250                    "`{}` declares config key `{key}` but setting it changes nothing",
251                    setting.name
252                );
253            }
254        }
255    }
256
257    /// Display code reads values by registry name, and `SETTINGS` is generated,
258    /// so a new setting shows up in `tak settings` whether or not its value can
259    /// be produced. This is what stops that being a blank row.
260    #[test]
261    fn every_setting_has_an_accessor() {
262        let s = Settings::default();
263        for setting in SETTINGS {
264            assert!(
265                s.get(setting.name).is_some(),
266                "`{}` has no accessor in Settings::get",
267                setting.name
268            );
269        }
270    }
271
272    /// Every setting must be reachable somehow, or it is documentation for a
273    /// feature that does not exist.
274    #[test]
275    fn every_setting_declares_a_source() {
276        for s in SETTINGS {
277            assert!(
278                !s.cli_flags.is_empty() || !s.env_vars.is_empty() || !s.config_keys.is_empty(),
279                "`{}` has no sources",
280                s.name
281            );
282        }
283    }
284}