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