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    ///
29    /// Settings tables — `[env]`, `[gate]`, `[report]`, `[runner]` — live in
30    /// the same file but are not deserialized here: the settings registry
31    /// declares their dotted keys, and `settings::TakConfigLayer` reads exactly
32    /// those, so this type never has to be kept in step with it.
33    #[serde(default)]
34    pub bench: BTreeMap<String, Bench>,
35}
36
37#[derive(Debug, Deserialize)]
38pub struct Bench {
39    cmd: Cmd,
40    pub runs: Option<u32>,
41    pub warmup: Option<u32>,
42}
43
44/// A command, written either as a list or as a plain string.
45#[derive(Debug, Deserialize)]
46#[serde(untagged)]
47enum Cmd {
48    Argv(Vec<String>),
49    Line(String),
50}
51
52impl Bench {
53    /// The command as argv.
54    ///
55    /// A string is split on whitespace and nothing else. There is deliberately
56    /// no shell: spawning one adds its own startup cost and variance to every
57    /// sample, which for commands in the 10ms range is a large fraction of the
58    /// measurement. Anything needing a pipe or a glob should be a list whose
59    /// first element is the interpreter.
60    pub fn argv(&self) -> Result<Vec<String>> {
61        let v = match &self.cmd {
62            Cmd::Argv(v) => v.clone(),
63            Cmd::Line(s) => s.split_whitespace().map(str::to_string).collect(),
64        };
65        if v.is_empty() {
66            bail!("empty command");
67        }
68        Ok(v)
69    }
70
71    pub fn runs(&self) -> u32 {
72        self.runs.unwrap_or(DEFAULT_RUNS)
73    }
74
75    pub fn warmup(&self) -> u32 {
76        self.warmup.unwrap_or(DEFAULT_WARMUP)
77    }
78}
79
80impl Config {
81    pub fn parse(text: &str) -> Result<Self> {
82        let cfg: Config = toml::from_str(text).context("could not parse tak.toml")?;
83        // Every declared benchmark is validated up front rather than failing
84        // partway through a run that has already spent minutes measuring.
85        for (name, b) in &cfg.bench {
86            b.argv()
87                .with_context(|| format!("benchmark `{name}` has no command"))?;
88        }
89        Ok(cfg)
90    }
91
92    /// Find and load `tak.toml`, searching upward from `start`.
93    ///
94    /// Walking up means `tak run` behaves the same from a subdirectory as from
95    /// the repository root, which is where people actually are.
96    pub fn find(start: &Path) -> Result<Option<(PathBuf, Self)>> {
97        for dir in start.ancestors() {
98            let path = dir.join(FILE_NAME);
99            if path.is_file() {
100                let text = std::fs::read_to_string(&path)
101                    .with_context(|| format!("could not read {}", path.display()))?;
102                let cfg = Self::parse(&text).with_context(|| format!("in {}", path.display()))?;
103                return Ok(Some((path, cfg)));
104            }
105        }
106        Ok(None)
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn a_command_may_be_a_list_or_a_string() {
116        let c = Config::parse(
117            r#"
118            [bench.a]
119            cmd = ["mycli", "--version"]
120            [bench.b]
121            cmd = "mycli --help"
122            "#,
123        )
124        .unwrap();
125        assert_eq!(c.bench["a"].argv().unwrap(), ["mycli", "--version"]);
126        assert_eq!(c.bench["b"].argv().unwrap(), ["mycli", "--help"]);
127    }
128
129    /// A string is split on whitespace and nothing else — no shell means no
130    /// quoting, and pretending otherwise would measure the wrong thing.
131    #[test]
132    fn a_string_command_gets_no_shell_semantics() {
133        let c = Config::parse(
134            r#"[bench.a]
135cmd = "mycli 'two words'""#,
136        )
137        .unwrap();
138        assert_eq!(c.bench["a"].argv().unwrap(), ["mycli", "'two", "words'"]);
139    }
140
141    #[test]
142    fn defaults_match_the_cli() {
143        let c = Config::parse("[bench.a]\ncmd = \"x\"").unwrap();
144        assert_eq!(c.bench["a"].runs(), DEFAULT_RUNS);
145        assert_eq!(c.bench["a"].warmup(), DEFAULT_WARMUP);
146    }
147
148    #[test]
149    fn per_benchmark_overrides_win() {
150        let c = Config::parse("[bench.a]\ncmd = \"x\"\nruns = 5\nwarmup = 1").unwrap();
151        assert_eq!(c.bench["a"].runs(), 5);
152        assert_eq!(c.bench["a"].warmup(), 1);
153    }
154
155    /// Validation happens at load, not partway through a run that has already
156    /// spent minutes measuring.
157    #[test]
158    fn an_empty_command_is_rejected_at_parse_time() {
159        let err = Config::parse("[bench.a]\ncmd = []").unwrap_err();
160        assert!(format!("{err:#}").contains('a'), "{err:#}");
161    }
162
163    #[test]
164    fn benchmarks_run_in_a_stable_order() {
165        let c = Config::parse("[bench.zebra]\ncmd = \"z\"\n[bench.alpha]\ncmd = \"a\"").unwrap();
166        assert_eq!(c.bench.keys().collect::<Vec<_>>(), ["alpha", "zebra"]);
167    }
168
169    #[test]
170    fn an_empty_file_declares_nothing() {
171        assert!(Config::parse("").unwrap().bench.is_empty());
172    }
173
174    #[test]
175    fn find_walks_up_from_a_subdirectory() {
176        let root = std::env::temp_dir().join(format!("tak-cfg-{}", std::process::id()));
177        let nested = root.join("a").join("b");
178        std::fs::create_dir_all(&nested).unwrap();
179        std::fs::write(root.join(FILE_NAME), "[bench.x]\ncmd = \"true\"").unwrap();
180
181        let (path, cfg) = Config::find(&nested).unwrap().expect("should find it");
182        assert_eq!(path, root.join(FILE_NAME));
183        assert!(cfg.bench.contains_key("x"));
184
185        std::fs::remove_dir_all(&root).ok();
186    }
187}