Skip to main content

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}