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