Skip to main content

tak_cli/
config.rs

1//! `tak.toml` — declared benchmarks.
2//!
3//! Named after the tool rather than after its current contents. It already
4//! holds more than a command list in spirit, and gates, runner classes and
5//! competitor definitions all belong here too; `bench.toml` would be misnamed
6//! the moment the first of those lands.
7//!
8//! The point of declaring benchmarks is that CI and a laptop run the same
9//! thing. A command line in a workflow file drifts from the one people use
10//! locally, and the numbers stop being comparable without anyone noticing.
11
12use anyhow::{Context, Result, bail};
13use serde::Deserialize;
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16
17pub const FILE_NAME: &str = "tak.toml";
18
19/// Defaults chosen to match `tak run`'s, so moving a command into `tak.toml`
20/// does not silently change what it measures.
21pub const DEFAULT_RUNS: u32 = 20;
22pub const DEFAULT_WARMUP: u32 = 3;
23
24#[derive(Debug, Deserialize)]
25pub struct Config {
26    /// Benchmarks by name. A BTreeMap so runs are ordered and reproducible
27    /// rather than following the file's incidental key order.
28    #[serde(default)]
29    pub bench: BTreeMap<String, Bench>,
30    /// Project-level environment settings. See `settings.toml` for what these
31    /// mean; this type only says where they can be written.
32    #[serde(default)]
33    pub env: Option<EnvSection>,
34    /// Regression-gate settings.
35    #[serde(default)]
36    pub gate: Option<GateSection>,
37    /// Report-rendering settings.
38    #[serde(default)]
39    pub report: Option<ReportSection>,
40    #[serde(default)]
41    pub runner: Option<RunnerSection>,
42}
43
44/// The `[gate]` table in `tak.toml`.
45#[derive(Debug, Deserialize, Default, Clone, PartialEq)]
46pub struct GateSection {
47    pub pct: Option<f64>,
48}
49
50/// The `[report]` table in `tak.toml`.
51#[derive(Debug, Deserialize, Default, Clone, PartialEq)]
52pub struct ReportSection {
53    pub credit: Option<bool>,
54}
55
56/// The `[runner]` table in `tak.toml`.
57#[derive(Debug, Deserialize, Default, Clone, PartialEq)]
58pub struct RunnerSection {
59    pub class: Option<String>,
60}
61
62/// Every `tak.toml` table that feeds a setting.
63#[derive(Debug, Deserialize, Default)]
64pub struct SettingsSections {
65    #[serde(default)]
66    pub env: Option<EnvSection>,
67    #[serde(default)]
68    pub gate: Option<GateSection>,
69    #[serde(default)]
70    pub report: Option<ReportSection>,
71    /// Runner identity.
72    #[serde(default)]
73    pub runner: Option<RunnerSection>,
74}
75
76/// The `[env]` table in `tak.toml`.
77///
78/// Both fields are `Option` so an absent key defers to the environment and the
79/// declared default, while `deny = []` is an explicit empty list. Making them
80/// plain `Vec` would erase that distinction and turn "I did not mention this"
81/// into "I want nothing scrubbed".
82#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)]
83pub struct EnvSection {
84    pub allow: Option<Vec<String>>,
85    pub deny: Option<Vec<String>>,
86}
87
88#[derive(Debug, Deserialize)]
89pub struct Bench {
90    cmd: Cmd,
91    pub runs: Option<u32>,
92    pub warmup: Option<u32>,
93}
94
95/// A command, written either as a list or as a plain string.
96#[derive(Debug, Deserialize)]
97#[serde(untagged)]
98enum Cmd {
99    Argv(Vec<String>),
100    Line(String),
101}
102
103impl Bench {
104    /// The command as argv.
105    ///
106    /// A string is split on whitespace and nothing else. There is deliberately
107    /// no shell: spawning one adds its own startup cost and variance to every
108    /// sample, which for commands in the 10ms range is a large fraction of the
109    /// measurement. Anything needing a pipe or a glob should be a list whose
110    /// first element is the interpreter.
111    pub fn argv(&self) -> Result<Vec<String>> {
112        let v = match &self.cmd {
113            Cmd::Argv(v) => v.clone(),
114            Cmd::Line(s) => s.split_whitespace().map(str::to_string).collect(),
115        };
116        if v.is_empty() {
117            bail!("empty command");
118        }
119        Ok(v)
120    }
121
122    pub fn runs(&self) -> u32 {
123        self.runs.unwrap_or(DEFAULT_RUNS)
124    }
125
126    pub fn warmup(&self) -> u32 {
127        self.warmup.unwrap_or(DEFAULT_WARMUP)
128    }
129}
130
131impl Config {
132    pub fn parse(text: &str) -> Result<Self> {
133        let cfg: Config = toml::from_str(text).context("could not parse tak.toml")?;
134        // Every declared benchmark is validated up front rather than failing
135        // partway through a run that has already spent minutes measuring.
136        for (name, b) in &cfg.bench {
137            b.argv()
138                .with_context(|| format!("benchmark `{name}` has no command"))?;
139        }
140        Ok(cfg)
141    }
142
143    /// Read the setting tables, without validating benchmarks.
144    ///
145    /// Settings and benchmarks live in the same file but are needed at
146    /// different times. Resolving settings through [`Config::parse`] would make
147    /// a broken `[bench.x]` abort `tak run -- somecmd`, which does not read
148    /// benchmarks at all, and `tak settings`, which reads none of them either.
149    ///
150    /// Unknown keys are ignored, so `[bench]` is not even looked at here. A TOML
151    /// *syntax* error still fails: the file may carry `[env]` settings that
152    /// change what gets scrubbed from a subject's environment, and quietly
153    /// falling back to defaults would apply a weaker filter than the project
154    /// asked for without saying so.
155    pub fn find_settings(start: &Path) -> Result<SettingsSections> {
156        for dir in start.ancestors() {
157            let path = dir.join(FILE_NAME);
158            if path.is_file() {
159                let text = std::fs::read_to_string(&path)
160                    .with_context(|| format!("could not read {}", path.display()))?;
161                let parsed: SettingsSections = toml::from_str(&text)
162                    .with_context(|| format!("could not parse {}", path.display()))?;
163                return Ok(parsed);
164            }
165        }
166        Ok(SettingsSections::default())
167    }
168
169    /// Find and load `tak.toml`, searching upward from `start`.
170    ///
171    /// Walking up means `tak run` behaves the same from a subdirectory as from
172    /// the repository root, which is where people actually are.
173    pub fn find(start: &Path) -> Result<Option<(PathBuf, Self)>> {
174        for dir in start.ancestors() {
175            let path = dir.join(FILE_NAME);
176            if path.is_file() {
177                let text = std::fs::read_to_string(&path)
178                    .with_context(|| format!("could not read {}", path.display()))?;
179                let cfg = Self::parse(&text).with_context(|| format!("in {}", path.display()))?;
180                return Ok(Some((path, cfg)));
181            }
182        }
183        Ok(None)
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn a_command_may_be_a_list_or_a_string() {
193        let c = Config::parse(
194            r#"
195            [bench.a]
196            cmd = ["mycli", "--version"]
197            [bench.b]
198            cmd = "mycli --help"
199            "#,
200        )
201        .unwrap();
202        assert_eq!(c.bench["a"].argv().unwrap(), ["mycli", "--version"]);
203        assert_eq!(c.bench["b"].argv().unwrap(), ["mycli", "--help"]);
204    }
205
206    /// A string is split on whitespace and nothing else — no shell means no
207    /// quoting, and pretending otherwise would measure the wrong thing.
208    #[test]
209    fn a_string_command_gets_no_shell_semantics() {
210        let c = Config::parse(
211            r#"[bench.a]
212cmd = "mycli 'two words'""#,
213        )
214        .unwrap();
215        assert_eq!(c.bench["a"].argv().unwrap(), ["mycli", "'two", "words'"]);
216    }
217
218    #[test]
219    fn defaults_match_the_cli() {
220        let c = Config::parse("[bench.a]\ncmd = \"x\"").unwrap();
221        assert_eq!(c.bench["a"].runs(), DEFAULT_RUNS);
222        assert_eq!(c.bench["a"].warmup(), DEFAULT_WARMUP);
223    }
224
225    #[test]
226    fn per_benchmark_overrides_win() {
227        let c = Config::parse("[bench.a]\ncmd = \"x\"\nruns = 5\nwarmup = 1").unwrap();
228        assert_eq!(c.bench["a"].runs(), 5);
229        assert_eq!(c.bench["a"].warmup(), 1);
230    }
231
232    /// Validation happens at load, not partway through a run that has already
233    /// spent minutes measuring.
234    #[test]
235    fn an_empty_command_is_rejected_at_parse_time() {
236        let err = Config::parse("[bench.a]\ncmd = []").unwrap_err();
237        assert!(format!("{err:#}").contains('a'), "{err:#}");
238    }
239
240    #[test]
241    fn benchmarks_run_in_a_stable_order() {
242        let c = Config::parse("[bench.zebra]\ncmd = \"z\"\n[bench.alpha]\ncmd = \"a\"").unwrap();
243        assert_eq!(c.bench.keys().collect::<Vec<_>>(), ["alpha", "zebra"]);
244    }
245
246    #[test]
247    fn an_empty_file_declares_nothing() {
248        assert!(Config::parse("").unwrap().bench.is_empty());
249    }
250
251    #[test]
252    fn find_walks_up_from_a_subdirectory() {
253        let root = std::env::temp_dir().join(format!("tak-cfg-{}", std::process::id()));
254        let nested = root.join("a").join("b");
255        std::fs::create_dir_all(&nested).unwrap();
256        std::fs::write(root.join(FILE_NAME), "[bench.x]\ncmd = \"true\"").unwrap();
257
258        let (path, cfg) = Config::find(&nested).unwrap().expect("should find it");
259        assert_eq!(path, root.join(FILE_NAME));
260        assert!(cfg.bench.contains_key("x"));
261
262        std::fs::remove_dir_all(&root).ok();
263    }
264}