1use crate::config::SettingsSections;
14
15include!(concat!(env!("OUT_DIR"), "/settings_generated.rs"));
16
17#[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
31pub fn from_cli(v: Vec<String>) -> Option<Vec<String>> {
38 (!v.is_empty()).then_some(v)
39}
40
41pub type EnvLookup<'a> = &'a dyn Fn(&str) -> Option<String>;
47
48fn 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
65fn 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 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 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 pub fn from_process(cli: &Overrides, config: &SettingsSections) -> Self {
123 Self::resolve(cli, config, &|key| std::env::var(key).ok())
124 }
125
126 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 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 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 #[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 let s = Settings::resolve(&Overrides::default(), &cfg, &no_env);
231 assert_eq!(s.env_deny, ["FROM_CONFIG"]);
232 }
233
234 #[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 const ENV_SENTINEL: &str = "12345";
268
269 const BOOL_ENV_SENTINEL: &str = "false";
272
273 fn config_sentinel(type_: &str) -> String {
275 match type_ {
276 "list<string>" => "[\"SENTINEL\"]".to_string(),
277 "float" => "12345.0".to_string(),
278 "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 #[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 #[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 #[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}