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}
31
32#[derive(Debug, Deserialize)]
33pub struct Bench {
34 cmd: Cmd,
35 pub runs: Option<u32>,
36 pub warmup: Option<u32>,
37}
38
39#[derive(Debug, Deserialize)]
41#[serde(untagged)]
42enum Cmd {
43 Argv(Vec<String>),
44 Line(String),
45}
46
47impl Bench {
48 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 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 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 #[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 #[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}