1use 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
19pub const DEFAULT_RUNS: u32 = 20;
22pub const DEFAULT_WARMUP: u32 = 3;
23
24#[derive(Debug, Deserialize)]
25pub struct Config {
26 #[serde(default)]
29 pub bench: BTreeMap<String, Bench>,
30 #[serde(default)]
33 pub env: Option<EnvSection>,
34 #[serde(default)]
36 pub gate: Option<GateSection>,
37 #[serde(default)]
39 pub report: Option<ReportSection>,
40 #[serde(default)]
41 pub runner: Option<RunnerSection>,
42}
43
44#[derive(Debug, Deserialize, Default, Clone, PartialEq)]
46pub struct GateSection {
47 pub pct: Option<f64>,
48}
49
50#[derive(Debug, Deserialize, Default, Clone, PartialEq)]
52pub struct ReportSection {
53 pub credit: Option<bool>,
54}
55
56#[derive(Debug, Deserialize, Default, Clone, PartialEq)]
58pub struct RunnerSection {
59 pub class: Option<String>,
60}
61
62#[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 #[serde(default)]
73 pub runner: Option<RunnerSection>,
74}
75
76#[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#[derive(Debug, Deserialize)]
97#[serde(untagged)]
98enum Cmd {
99 Argv(Vec<String>),
100 Line(String),
101}
102
103impl Bench {
104 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 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 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 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 #[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 #[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}