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 pub runner_class: Option<String>,
30}
31
32pub fn from_cli(v: Vec<String>) -> Option<Vec<String>> {
39 (!v.is_empty()).then_some(v)
40}
41
42pub type EnvLookup<'a> = &'a dyn Fn(&str) -> Option<String>;
48
49fn 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
66fn 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 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 .filter(|v| !v.trim().is_empty())
95 .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 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 pub fn from_process(cli: &Overrides, config: &SettingsSections) -> Self {
144 Self::resolve(cli, config, &|key| std::env::var(key).ok())
145 }
146
147 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 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 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 #[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 let s = Settings::resolve(&Overrides::default(), &cfg, &no_env);
256 assert_eq!(s.env_deny, ["FROM_CONFIG"]);
257 }
258
259 #[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 #[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 const ENV_SENTINEL: &str = "12345";
312
313 const BOOL_ENV_SENTINEL: &str = "false";
316
317 fn config_sentinel(type_: &str) -> String {
319 match type_ {
320 "list<string>" => "[\"SENTINEL\"]".to_string(),
321 "float" => "12345.0".to_string(),
322 "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 #[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 #[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 #[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}