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