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)]
34 pub bench: BTreeMap<String, Bench>,
35}
36
37#[derive(Debug, Deserialize)]
38pub struct Bench {
39 cmd: Cmd,
40 pub runs: Option<u32>,
41 pub warmup: Option<u32>,
42}
43
44#[derive(Debug, Deserialize)]
46#[serde(untagged)]
47enum Cmd {
48 Argv(Vec<String>),
49 Line(String),
50}
51
52impl Bench {
53 pub fn argv(&self) -> Result<Vec<String>> {
61 let v = match &self.cmd {
62 Cmd::Argv(v) => v.clone(),
63 Cmd::Line(s) => s.split_whitespace().map(str::to_string).collect(),
64 };
65 if v.is_empty() {
66 bail!("empty command");
67 }
68 Ok(v)
69 }
70
71 pub fn runs(&self) -> u32 {
72 self.runs.unwrap_or(DEFAULT_RUNS)
73 }
74
75 pub fn warmup(&self) -> u32 {
76 self.warmup.unwrap_or(DEFAULT_WARMUP)
77 }
78}
79
80impl Config {
81 pub fn parse(text: &str) -> Result<Self> {
82 let cfg: Config = toml::from_str(text).context("could not parse tak.toml")?;
83 for (name, b) in &cfg.bench {
86 b.argv()
87 .with_context(|| format!("benchmark `{name}` has no command"))?;
88 }
89 Ok(cfg)
90 }
91
92 pub fn find(start: &Path) -> Result<Option<(PathBuf, Self)>> {
97 for dir in start.ancestors() {
98 let path = dir.join(FILE_NAME);
99 if path.is_file() {
100 let text = std::fs::read_to_string(&path)
101 .with_context(|| format!("could not read {}", path.display()))?;
102 let cfg = Self::parse(&text).with_context(|| format!("in {}", path.display()))?;
103 return Ok(Some((path, cfg)));
104 }
105 }
106 Ok(None)
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn a_command_may_be_a_list_or_a_string() {
116 let c = Config::parse(
117 r#"
118 [bench.a]
119 cmd = ["mycli", "--version"]
120 [bench.b]
121 cmd = "mycli --help"
122 "#,
123 )
124 .unwrap();
125 assert_eq!(c.bench["a"].argv().unwrap(), ["mycli", "--version"]);
126 assert_eq!(c.bench["b"].argv().unwrap(), ["mycli", "--help"]);
127 }
128
129 #[test]
132 fn a_string_command_gets_no_shell_semantics() {
133 let c = Config::parse(
134 r#"[bench.a]
135cmd = "mycli 'two words'""#,
136 )
137 .unwrap();
138 assert_eq!(c.bench["a"].argv().unwrap(), ["mycli", "'two", "words'"]);
139 }
140
141 #[test]
142 fn defaults_match_the_cli() {
143 let c = Config::parse("[bench.a]\ncmd = \"x\"").unwrap();
144 assert_eq!(c.bench["a"].runs(), DEFAULT_RUNS);
145 assert_eq!(c.bench["a"].warmup(), DEFAULT_WARMUP);
146 }
147
148 #[test]
149 fn per_benchmark_overrides_win() {
150 let c = Config::parse("[bench.a]\ncmd = \"x\"\nruns = 5\nwarmup = 1").unwrap();
151 assert_eq!(c.bench["a"].runs(), 5);
152 assert_eq!(c.bench["a"].warmup(), 1);
153 }
154
155 #[test]
158 fn an_empty_command_is_rejected_at_parse_time() {
159 let err = Config::parse("[bench.a]\ncmd = []").unwrap_err();
160 assert!(format!("{err:#}").contains('a'), "{err:#}");
161 }
162
163 #[test]
164 fn benchmarks_run_in_a_stable_order() {
165 let c = Config::parse("[bench.zebra]\ncmd = \"z\"\n[bench.alpha]\ncmd = \"a\"").unwrap();
166 assert_eq!(c.bench.keys().collect::<Vec<_>>(), ["alpha", "zebra"]);
167 }
168
169 #[test]
170 fn an_empty_file_declares_nothing() {
171 assert!(Config::parse("").unwrap().bench.is_empty());
172 }
173
174 #[test]
175 fn find_walks_up_from_a_subdirectory() {
176 let root = std::env::temp_dir().join(format!("tak-cfg-{}", std::process::id()));
177 let nested = root.join("a").join("b");
178 std::fs::create_dir_all(&nested).unwrap();
179 std::fs::write(root.join(FILE_NAME), "[bench.x]\ncmd = \"true\"").unwrap();
180
181 let (path, cfg) = Config::find(&nested).unwrap().expect("should find it");
182 assert_eq!(path, root.join(FILE_NAME));
183 assert!(cfg.bench.contains_key("x"));
184
185 std::fs::remove_dir_all(&root).ok();
186 }
187}