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}
41
42#[derive(Debug, Deserialize, Default, Clone, PartialEq)]
44pub struct GateSection {
45 pub pct: Option<f64>,
46}
47
48#[derive(Debug, Deserialize, Default, Clone, PartialEq)]
50pub struct ReportSection {
51 pub credit: Option<bool>,
52}
53
54#[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#[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#[derive(Debug, Deserialize)]
86#[serde(untagged)]
87enum Cmd {
88 Argv(Vec<String>),
89 Line(String),
90}
91
92impl Bench {
93 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 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 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 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 #[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 #[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}