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