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    /// **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}