tak_cli/settings.rs
1//! Settings, and where their values come from.
2//!
3//! The [`Settings`] struct below is the registry: `#[derive(usage_rs::Config)]`
4//! generates the metadata slice (`SETTINGS_PROPS`), the resolver registry
5//! (`SETTINGS_REGISTRY`), the reader that fills the struct from a resolution,
6//! and the spec `config` block that documents it. There is no `settings.toml`
7//! and no build-script generator left to keep in step with this file.
8//!
9//! Precedence, highest first: CLI flag, environment variable, `tak.toml`,
10//! declared default. A source that is absent is skipped rather than treated as
11//! empty, so setting a value in `tak.toml` is not undone by the flag being
12//! unused.
13
14use anyhow::{Context, Result, anyhow};
15use std::path::Path;
16use usage_rs::config::{
17 Layer, LayerCtx, LayerError, LayerOutput, Layers, Origin, SourceKind, Ty, Value,
18};
19
20pub use usage_rs::config::{CliLayer, EnvLayer};
21
22/// Every setting tak supports, resolved.
23///
24/// `PartialEq` but not `Eq`: a float setting has no total equality.
25#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
26pub struct Settings {
27 /// Environment variables removed from every command tak measures.
28 ///
29 /// Two reasons this defaults to a non-empty list rather than to nothing.
30 ///
31 /// **Determinism.** A CLI that finds a forge token in its environment often does
32 /// more with it than without — authenticating, fetching, checking rate limits. A
33 /// measurement that moves depending on whether CI happened to export a token is
34 /// not a measurement of the code under test. It lands in the series as an
35 /// unexplained step change on the day someone edits an unrelated workflow.
36 ///
37 /// **This is not a credential sandbox.** The listed variables are absent from the
38 /// child's direct environment, but a hostile binary can still inspect accessible
39 /// same-user processes and files. In particular, `tak backfill` downloads and
40 /// executes release binaries; run it on an isolated credential-free machine when
41 /// those assets are not fully trusted.
42 ///
43 /// Setting this replaces the default list rather than adding to it. To keep the
44 /// defaults and remove more, list them alongside. To keep the defaults and remove
45 /// fewer, use `env_allow` — it is subtracted from this list, so the two compose
46 /// without either having to restate the other.
47 ///
48 /// Names are matched exactly. There is no globbing: a benchmark whose behaviour
49 /// depends on which variables happen to match a pattern is the problem this
50 /// setting exists to avoid.
51 ///
52 /// tak's own network calls are unaffected. `backfill` authenticates with `curl`
53 /// directly rather than through the measurement path.
54 #[usage(
55 default("GITHUB_TOKEN", "GH_TOKEN"),
56 cli("--env-deny"),
57 env = "TAK_ENV_DENY",
58 parse = "list_by_comma",
59 source("config", "env.deny"),
60 example("tak run --env-deny AWS_PROFILE --env-deny AWS_REGION"),
61 example("TAK_ENV_DENY=GITHUB_TOKEN,GH_TOKEN,NPM_TOKEN tak run"),
62 since = "0.0.3"
63 )]
64 pub env_deny: Vec<String>,
65
66 /// Environment variables kept even though `env_deny` lists them.
67 ///
68 /// Subtracted from `env_deny`, so a project can opt one variable back in without
69 /// restating the whole default list. A CLI whose measured path genuinely requires
70 /// a token — a client that cannot start unauthenticated, say — needs this.
71 ///
72 /// Doing so makes the measurement depend on something outside the repository.
73 /// That is a real cost, not a formality: the numbers become conditional on the
74 /// environment the run happened to have, and a token expiring will read as a
75 /// performance change.
76 ///
77 /// Listing a variable here that `env_deny` does not mention has no effect. This
78 /// setting removes entries from the deny list; it does not add anything to the
79 /// environment.
80 #[usage(
81 default(),
82 cli("--env-allow"),
83 env = "TAK_ENV_ALLOW",
84 parse = "list_by_comma",
85 source("config", "env.allow"),
86 example("tak run --env-deny GITHUB_TOKEN --env-allow GITHUB_TOKEN"),
87 since = "0.0.3"
88 )]
89 pub env_allow: Vec<String>,
90
91 /// How much an instruction count may rise before `tak compare` fails.
92 ///
93 /// A percentage of the base measurement. Only instruction counts are gated. Wall
94 /// clock is reported and never gated: on the same hardware it moves 4-20% run to
95 /// run, so a threshold tight enough to catch a real regression would fire
96 /// constantly, and one loose enough to stay quiet would catch nothing.
97 ///
98 /// The default of 1% is about fifty times the ~0.02% instruction counting
99 /// reproduces to, leaving room for the small differences a compiler or dependency
100 /// bump can produce without turning the gate into noise.
101 ///
102 /// Raise it to report without effectively failing. Setting it to zero fails on any
103 /// increase at all, which sounds appealing and is not: one extra instruction on a
104 /// startup path is not worth blocking a pull request over.
105 #[usage(
106 default = 1.0,
107 cli("--gate-pct"),
108 env = "TAK_GATE_PCT",
109 source("config", "gate.pct"),
110 example("tak compare origin/main --gate-pct 0.5"),
111 example("TAK_GATE_PCT=5 tak compare origin/main"),
112 since = "0.0.4"
113 )]
114 pub gate_pct: f64,
115
116 /// Whether generated reports end with a line naming tak.
117 ///
118 /// On by default. A report that appears in someone's pull request should say what
119 /// put it there — a reader who has never heard of tak needs a way to find out, and
120 /// a maintainer evaluating the comment needs to know what to turn off.
121 ///
122 /// Turn it off with `--no-credit`, `TAK_CREDIT=0`, or `credit = false` under
123 /// `[report]`. Nothing else about the report changes.
124 #[usage(
125 default = true,
126 cli("--no-credit"),
127 env = "TAK_CREDIT",
128 source("config", "report.credit"),
129 example("tak compare origin/main --no-credit"),
130 example("TAK_CREDIT=0 tak compare origin/main"),
131 since = "0.0.4"
132 )]
133 pub credit: bool,
134
135 /// The machine class a measurement is recorded under, and compared within.
136 ///
137 /// Empty means derive it: `gha-<os>-<arch>` under GitHub Actions, `local-<os>-<arch>`
138 /// otherwise. That is right until something about the machine changes without the
139 /// name changing.
140 ///
141 /// Series are partitioned on this, and must be. Absolute instruction counts shift
142 /// between machine types by more than a real regression does, so tak will not
143 /// compare across classes — it reports the old series as removed and the new one as
144 /// added rather than inventing a step change.
145 ///
146 /// Set it when the *environment* changes in a way the derived name cannot see. The
147 /// common case is a toolchain bump: a hosted runner image or a compiler upgrade
148 /// between the base measurement and this one is attributed to the code otherwise,
149 /// and on a one-percent gate that is a false failure. Encoding the compiler version
150 /// into the class starts a fresh series at the bump, which is honest — the numbers
151 /// either side genuinely are not comparable.
152 ///
153 /// tak cannot detect this for you. It measures programs, not build systems, and has
154 /// no way to know what produced the binary it is timing.
155 #[usage(
156 default = "",
157 default_note = "derived from the machine",
158 cli("--runner"),
159 env = "TAK_RUNNER",
160 source("config", "runner.class"),
161 example("TAK_RUNNER=gha-linux-x64-rust1.85 tak run --record"),
162 example("tak run --runner gha-linux-x64-glibc2.39"),
163 since = "0.0.6"
164 )]
165 pub runner_class: String,
166}
167
168/// The source kind `tak.toml` contributes under, for `source(...)` bindings
169/// and for [`TakConfigLayer`].
170pub fn config_source() -> SourceKind {
171 SourceKind::new("config")
172}
173
174/// `tak.toml` as a settings layer.
175///
176/// Not usage's `FileLayer`: `tak.toml` is tak's general config file, so most of
177/// what it holds — `[bench]` above all — is not a setting, and scanning the file
178/// would warn about every one of those keys. This reads the other way around:
179/// it iterates the registry's `source("config", ...)` bindings and looks each
180/// dotted key up in the parsed TOML, so a key nothing declares is simply not
181/// looked at.
182///
183/// A *missing* `tak.toml` is fine. A *syntax-broken* one — or a declared key
184/// holding the wrong type — is an error rather than a warning: the file may
185/// carry `[env]` settings that change what gets scrubbed from a subject's
186/// environment, and quietly applying a weaker filter than the project asked
187/// for is not a good failure.
188pub struct TakConfigLayer {
189 /// The file that was found, if any, and its parsed contents.
190 found: Option<(std::path::PathBuf, toml::Table)>,
191}
192
193impl TakConfigLayer {
194 /// Find and parse `tak.toml`, searching upward from `start`.
195 ///
196 /// Walking up means settings resolve the same from a subdirectory as from
197 /// the repository root, exactly like `Config::find`.
198 pub fn find(start: &Path) -> Result<Self> {
199 for dir in start.ancestors() {
200 let path = dir.join(crate::config::FILE_NAME);
201 if path.is_file() {
202 let text = std::fs::read_to_string(&path)
203 .with_context(|| format!("could not read {}", path.display()))?;
204 let table: toml::Table = text
205 .parse()
206 .with_context(|| format!("could not parse {}", path.display()))?;
207 return Ok(Self {
208 found: Some((path, table)),
209 });
210 }
211 }
212 Ok(Self { found: None })
213 }
214
215 /// No file at all — what a missing `tak.toml` resolves with, and what
216 /// `doctor` falls back to when the file cannot be read.
217 pub fn empty() -> Self {
218 Self { found: None }
219 }
220
221 /// A layer over literal TOML text, for tests.
222 #[cfg(test)]
223 fn from_text(text: &str) -> Self {
224 Self {
225 found: Some((
226 std::path::PathBuf::from("tak.toml"),
227 text.parse().expect("test TOML parses"),
228 )),
229 }
230 }
231}
232
233/// A `toml::Value` as the resolver's own value type.
234fn value_of(v: &toml::Value) -> Value {
235 match v {
236 toml::Value::String(s) => Value::String(s.clone()),
237 toml::Value::Integer(i) => Value::Int(*i),
238 toml::Value::Float(f) => Value::Float(*f),
239 toml::Value::Boolean(b) => Value::Bool(*b),
240 toml::Value::Datetime(d) => Value::String(d.to_string()),
241 toml::Value::Array(items) => Value::List(items.iter().map(value_of).collect()),
242 toml::Value::Table(entries) => Value::Map(
243 entries
244 .iter()
245 .map(|(k, v)| (k.clone(), value_of(v)))
246 .collect(),
247 ),
248 }
249}
250
251/// Whether a TOML value is written as the declared type, before any coercion.
252///
253/// The resolver's coercion is deliberately forgiving — `deny = "X"` would become
254/// a one-item list, `credit = "yes"` would become `true`. tak's config file has
255/// always been stricter than that: a value of the wrong TOML type is an error,
256/// not a guess, because guessing here changes what gets scrubbed from a
257/// subject's environment without saying so.
258fn written_as(ty: &Ty, v: &toml::Value) -> bool {
259 match ty {
260 Ty::Bool => v.is_bool(),
261 Ty::Int | Ty::Uint => v.is_integer(),
262 Ty::Float => v.is_float() || v.is_integer(),
263 Ty::String | Ty::Path | Ty::Url | Ty::Duration => v.is_str(),
264 Ty::List(item) | Ty::Set(item) => v
265 .as_array()
266 .is_some_and(|items| items.iter().all(|item_value| written_as(item, item_value))),
267 Ty::Map(value) => v
268 .as_table()
269 .is_some_and(|entries| entries.values().all(|entry| written_as(value, entry))),
270 Ty::Option(inner) => written_as(inner, v),
271 _ => true,
272 }
273}
274
275impl Layer for TakConfigLayer {
276 fn source(&self) -> SourceKind {
277 config_source()
278 }
279
280 fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
281 let mut out = LayerOutput::new();
282 let Some((path, table)) = &self.found else {
283 return Ok(out);
284 };
285 let registry = ctx.registry();
286 for (id, config_key) in registry.bindings(self.source()) {
287 // Walk the dotted key. An absent table defers like an absent key;
288 // a *present* name that is not a table means the file says
289 // something this cannot read, which is an error like any other
290 // wrong type here.
291 let mut parts = config_key.split('.');
292 let mut current = table.get(parts.next().unwrap_or_default());
293 for part in parts {
294 current = match current {
295 None => break,
296 Some(toml::Value::Table(t)) => t.get(part),
297 Some(_) => {
298 return Err(LayerError::Unreadable {
299 source: path.display().to_string(),
300 why: format!("`{config_key}` is not a table of settings"),
301 });
302 }
303 };
304 }
305 let Some(raw) = current else {
306 continue;
307 };
308 let meta = registry.get(id);
309 if !written_as(&meta.ty, raw) {
310 return Err(LayerError::Unreadable {
311 source: path.display().to_string(),
312 why: format!("`{config_key}` expected {}", meta.ty.describe()),
313 });
314 }
315 let origin = Origin::new(self.source(), path.display().to_string());
316 match ctx.entry_from_value(meta.key, value_of(raw), origin) {
317 Ok(entry) => out.push(entry),
318 // The shape check above should have refused everything the
319 // coercion would; anything left is still the file being wrong.
320 Err(warning) => {
321 return Err(LayerError::Unreadable {
322 source: path.display().to_string(),
323 why: warning.message,
324 });
325 }
326 }
327 }
328 Ok(out)
329 }
330}
331
332/// A layer with a blank `runner_class` treated as not given at all.
333///
334/// Blank means the same thing everywhere: derive the class. Treating a blank
335/// `--runner ""` or an exported-but-empty `TAK_RUNNER=` as a set value would
336/// block every lower-precedence source and record every machine under one
337/// empty class — so a blank entry falls through to the next layer, exactly as
338/// the hand-written resolver always had it.
339struct SkipBlankRunner<'a>(&'a dyn Layer);
340
341impl Layer for SkipBlankRunner<'_> {
342 fn source(&self) -> SourceKind {
343 self.0.source()
344 }
345
346 fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
347 let mut out = self.0.load(ctx)?;
348 let runner = ctx.prop("runner_class").map(|found| found.id);
349 out.entries.retain(|entry| {
350 Some(entry.prop) != runner
351 || !matches!(&entry.value, Value::String(s) if s.trim().is_empty())
352 });
353 Ok(out)
354 }
355}
356
357impl Default for Settings {
358 /// The declared defaults, read the same way any other resolution is.
359 fn default() -> Self {
360 let resolved = usage_rs::config::resolve(Self::SETTINGS_REGISTRY, Layers::new())
361 .expect("no layers were given, so there is nothing to fail");
362 Self::read(&resolved).expect("every setting declares a default")
363 }
364}
365
366impl Settings {
367 /// Resolve every setting from the given layers, highest precedence first.
368 pub fn resolve(cli: &CliLayer, env: &EnvLayer, config: &TakConfigLayer) -> Result<Self> {
369 let cli = SkipBlankRunner(cli);
370 let env = SkipBlankRunner(env);
371 let config = SkipBlankRunner(config);
372 let resolved = usage_rs::config::resolve(
373 Self::SETTINGS_REGISTRY,
374 Layers::new().then(&cli).then(&env).then(&config),
375 )
376 .map_err(|e| anyhow!("{e}"))?;
377 // A typo must not silently become the default and let a regression
378 // through a gate the user thought they set — say so, then proceed with
379 // the value the remaining sources produce.
380 for warning in usage_rs::config::explain::warnings(&resolved) {
381 eprintln!("warning: {warning}");
382 }
383 let mut settings = Self::read(&resolved).map_err(|e| anyhow!("{e}"))?;
384 // `TAK_ENV_DENY=A,,B` never named an empty variable; the hand-written
385 // reader dropped blanks and call sites still rely on that.
386 settings.env_deny.retain(|name| !name.is_empty());
387 settings.env_allow.retain(|name| !name.is_empty());
388 Ok(settings)
389 }
390
391 /// Resolve against the real process environment and the `tak.toml` found
392 /// upward from the current directory.
393 pub fn from_process(cli: &CliLayer) -> Result<Self> {
394 let config =
395 TakConfigLayer::find(&std::env::current_dir()?).context("could not read settings")?;
396 Self::resolve(cli, &EnvLayer::from_process(), &config)
397 }
398
399 /// The value of a setting, by its registry key.
400 ///
401 /// Exists so display code cannot silently omit a setting: `SETTINGS_PROPS`
402 /// is generated, so a new entry appears in `tak settings` whether or not
403 /// anything can produce its value. A test asserts this returns `Some` for
404 /// every registry entry, which turns "added a setting, forgot the
405 /// accessor" into a build failure instead of a blank row.
406 pub fn display_value(&self, name: &str) -> Option<String> {
407 match name {
408 "env_allow" => Some(format!("{:?}", self.env_allow)),
409 "env_deny" => Some(format!("{:?}", self.env_deny)),
410 "credit" => Some(format!("{}", self.credit)),
411 "runner_class" => Some(if self.runner_class.is_empty() {
412 "(derived)".to_string()
413 } else {
414 self.runner_class.clone()
415 }),
416 "gate_pct" => Some(format!("{}", self.gate_pct)),
417 _ => None,
418 }
419 }
420
421 /// Variables to remove from a benchmark subject: denied, less allowed.
422 ///
423 /// Allow subtracts from deny rather than sitting beside it, so opting one
424 /// variable back in does not mean restating the whole default list.
425 pub fn scrubbed_env(&self) -> impl Iterator<Item = &str> {
426 self.env_deny
427 .iter()
428 .filter(|name| !self.env_allow.contains(name))
429 .map(String::as_str)
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 fn no_cli() -> CliLayer {
438 CliLayer::new(std::iter::empty::<(String, String)>())
439 }
440
441 fn no_env() -> EnvLayer {
442 EnvLayer::new(std::iter::empty::<(String, String)>())
443 }
444
445 fn env(vars: &[(&str, &str)]) -> EnvLayer {
446 EnvLayer::new(
447 vars.iter()
448 .map(|(k, v)| (k.to_string(), v.to_string()))
449 .collect::<Vec<_>>(),
450 )
451 }
452
453 #[test]
454 fn the_default_scrubs_forge_tokens() {
455 let s = Settings::default();
456 let scrubbed: Vec<_> = s.scrubbed_env().collect();
457 assert!(scrubbed.contains(&"GITHUB_TOKEN"));
458 assert!(scrubbed.contains(&"GH_TOKEN"));
459 }
460
461 #[test]
462 fn allow_subtracts_from_deny() {
463 let s = Settings {
464 env_deny: vec!["A".into(), "B".into()],
465 env_allow: vec!["B".into()],
466 ..Settings::default()
467 };
468 assert_eq!(s.scrubbed_env().collect::<Vec<_>>(), ["A"]);
469 }
470
471 /// Allowing something that is not denied is a no-op, not an error and not
472 /// an addition — this setting only ever removes entries from the deny list.
473 #[test]
474 fn allowing_an_undenied_variable_does_nothing() {
475 let s = Settings {
476 env_deny: vec!["A".into()],
477 env_allow: vec!["ZZZ".into()],
478 ..Settings::default()
479 };
480 assert_eq!(s.scrubbed_env().collect::<Vec<_>>(), ["A"]);
481 }
482
483 #[test]
484 fn cli_beats_env_beats_config() {
485 let cfg = TakConfigLayer::from_text("[env]\ndeny = [\"FROM_CONFIG\"]\n");
486 let with_env = env(&[("TAK_ENV_DENY", "FROM_ENV")]);
487
488 let from_config = Settings::resolve(&no_cli(), &no_env(), &cfg).unwrap();
489 assert_eq!(from_config.env_deny, ["FROM_CONFIG"]);
490
491 let from_env = Settings::resolve(&no_cli(), &with_env, &cfg).unwrap();
492 assert_eq!(from_env.env_deny, ["FROM_ENV"]);
493
494 let cli = no_cli().with_value("env_deny", Value::List(vec![Value::from("FROM_CLI")]));
495 let from_cli = Settings::resolve(&cli, &with_env, &cfg).unwrap();
496 assert_eq!(from_cli.env_deny, ["FROM_CLI"]);
497 }
498
499 #[test]
500 fn an_absent_source_defers_rather_than_clearing() {
501 let cfg = TakConfigLayer::from_text("[env]\ndeny = [\"FROM_CONFIG\"]\n");
502 // No CLI flag and no variable: the config value survives.
503 let s = Settings::resolve(&no_cli(), &no_env(), &cfg).unwrap();
504 assert_eq!(s.env_deny, ["FROM_CONFIG"]);
505 }
506
507 /// An exported-but-empty variable is a deliberate empty list. Falling
508 /// through to `tak.toml` here would make `TAK_ENV_DENY=` silently do the
509 /// opposite of what it looks like.
510 #[test]
511 fn an_empty_variable_means_an_empty_list() {
512 let cfg = TakConfigLayer::from_text("[env]\ndeny = [\"FROM_CONFIG\"]\n");
513 let s = Settings::resolve(&no_cli(), &env(&[("TAK_ENV_DENY", "")]), &cfg).unwrap();
514 assert!(s.env_deny.is_empty());
515 }
516
517 #[test]
518 fn a_variable_is_split_on_commas_and_trimmed() {
519 let with_env = env(&[("TAK_ENV_DENY", " A , B ,, C ")]);
520 let s = Settings::resolve(&no_cli(), &with_env, &TakConfigLayer::empty()).unwrap();
521 assert_eq!(s.env_deny, ["A", "B", "C"]);
522 }
523
524 /// A blank flag must defer, like a blank variable and a blank config key.
525 /// Otherwise `--runner ""` blocks every lower-precedence source and records
526 /// under an empty class, merging every machine into one series.
527 #[test]
528 fn a_blank_cli_runner_falls_through() {
529 let cfg = TakConfigLayer::from_text("[runner]\nclass = \"from-config\"\n");
530 let cli = no_cli().with("runner_class", " ");
531 let s = Settings::resolve(&cli, &no_env(), &cfg).unwrap();
532 assert_eq!(s.runner_class, "from-config");
533 }
534
535 /// The same for the environment: exported-but-empty means "derive it".
536 #[test]
537 fn a_blank_runner_variable_falls_through() {
538 let cfg = TakConfigLayer::from_text("[runner]\nclass = \"from-config\"\n");
539 let s = Settings::resolve(&no_cli(), &env(&[("TAK_RUNNER", "")]), &cfg).unwrap();
540 assert_eq!(s.runner_class, "from-config");
541 }
542
543 /// Keys in `tak.toml` that are not settings — `[bench]` above all — are
544 /// none of the resolver's business and must not produce warnings or
545 /// errors. The layer reads the registry's bindings, not the file's keys.
546 #[test]
547 fn non_setting_keys_are_not_looked_at() {
548 let cfg = TakConfigLayer::from_text(
549 "[bench.startup]\ncmd = \"./x --version\"\n[gate]\npct = 0.5\n",
550 );
551 let s = Settings::resolve(&no_cli(), &no_env(), &cfg).unwrap();
552 assert_eq!(s.gate_pct, 0.5);
553 }
554
555 /// A declared key holding the wrong TOML type is an error, not a guess.
556 /// The resolver's coercion would read `deny = "X"` as a one-item list;
557 /// tak's config file has always been stricter, because guessing here
558 /// changes what gets scrubbed from a subject's environment.
559 #[test]
560 fn a_wrongly_typed_config_key_is_an_error() {
561 let cfg = TakConfigLayer::from_text("[env]\ndeny = \"not a list\"\n");
562 let err = Settings::resolve(&no_cli(), &no_env(), &cfg).unwrap_err();
563 assert!(format!("{err:#}").contains("env.deny"), "{err:#}");
564 }
565
566 /// The drift guard, half one: every declared environment variable actually
567 /// changes the resolved settings. A sentinel that differs from every
568 /// default and parses as every declared type — a list sees `["12345"]`, a
569 /// float sees `12345`; booleans get the opposite of their default.
570 #[test]
571 fn every_declared_env_var_is_honoured() {
572 for meta in Settings::SETTINGS_PROPS {
573 for var in meta.envs {
574 let sentinel = if meta.ty == Ty::Bool {
575 "false"
576 } else {
577 "12345"
578 };
579 let with_env = env(&[(var, sentinel)]);
580 let got =
581 Settings::resolve(&no_cli(), &with_env, &TakConfigLayer::empty()).unwrap();
582 assert_ne!(
583 got,
584 Settings::default(),
585 "`{}` declares {var} but setting it changes nothing",
586 meta.key
587 );
588 }
589 }
590 }
591
592 /// The TOML literal for a sentinel of this registry type.
593 fn config_sentinel(ty: &Ty) -> String {
594 match ty {
595 Ty::List(_) => "[\"SENTINEL\"]".to_string(),
596 Ty::Float => "12345.0".to_string(),
597 // The opposite of every bool default, so flipping it always shows.
598 Ty::Bool => "false".to_string(),
599 Ty::String => "\"SENTINEL\"".to_string(),
600 other => panic!(
601 "the drift check has no sentinel for type `{}`",
602 other.name()
603 ),
604 }
605 }
606
607 /// The drift guard, half two: every declared `tak.toml` key reaches its
608 /// field. A dotted registry key is valid TOML on its own, so this builds
609 /// the smallest config that sets exactly that key and checks it lands.
610 #[test]
611 fn every_declared_config_key_is_honoured() {
612 let kind = config_source();
613 for meta in Settings::SETTINGS_PROPS {
614 for (source, key) in meta.bindings {
615 if *source != kind.name() {
616 continue;
617 }
618 let text = format!("{key} = {}\n", config_sentinel(&meta.ty));
619 let cfg = TakConfigLayer::from_text(&text);
620 let got = Settings::resolve(&no_cli(), &no_env(), &cfg).unwrap();
621 assert_ne!(
622 got,
623 Settings::default(),
624 "`{}` declares config key `{key}` but setting it changes nothing",
625 meta.key
626 );
627 }
628 }
629 }
630
631 /// Display code reads values by registry key, and `SETTINGS_PROPS` is
632 /// generated, so a new setting shows up in `tak settings` whether or not
633 /// its value can be produced. This is what stops that being a blank row.
634 #[test]
635 fn every_setting_has_an_accessor() {
636 let s = Settings::default();
637 for meta in Settings::SETTINGS_PROPS {
638 assert!(
639 s.display_value(meta.key).is_some(),
640 "`{}` has no accessor in Settings::display_value",
641 meta.key
642 );
643 }
644 }
645
646 /// Every setting must be reachable somehow, or it is documentation for a
647 /// feature that does not exist.
648 #[test]
649 fn every_setting_declares_a_source() {
650 for meta in Settings::SETTINGS_PROPS {
651 assert!(
652 !meta.cli.is_empty() || !meta.envs.is_empty() || !meta.bindings.is_empty(),
653 "`{}` has no sources",
654 meta.key
655 );
656 }
657 }
658}