1use crate::config::EnvSection;
14
15include!(concat!(env!("OUT_DIR"), "/settings_generated.rs"));
16
17#[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
29pub fn from_cli(v: Vec<String>) -> Option<Vec<String>> {
36 (!v.is_empty()).then_some(v)
37}
38
39pub type EnvLookup<'a> = &'a dyn Fn(&str) -> Option<String>;
45
46fn 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 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 pub fn from_process(cli: &Overrides, config: Option<&EnvSection>) -> Self {
83 Self::resolve(cli, config, &|key| std::env::var(key).ok())
84 }
85
86 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 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 #[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 let s = Settings::resolve(&Overrides::default(), Some(&cfg), &no_env);
179 assert_eq!(s.env_deny, ["FROM_CONFIG"]);
180 }
181
182 #[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 #[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 #[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 #[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 #[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}