use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const FILE_NAME: &str = "tak.toml";
pub const DEFAULT_RUNS: u32 = 20;
pub const DEFAULT_WARMUP: u32 = 3;
#[derive(Debug, Deserialize)]
pub struct Config {
#[serde(default)]
pub bench: BTreeMap<String, Bench>,
#[serde(default)]
pub env: Option<EnvSection>,
}
#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)]
pub struct EnvSection {
pub allow: Option<Vec<String>>,
pub deny: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub struct Bench {
cmd: Cmd,
pub runs: Option<u32>,
pub warmup: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Cmd {
Argv(Vec<String>),
Line(String),
}
impl Bench {
pub fn argv(&self) -> Result<Vec<String>> {
let v = match &self.cmd {
Cmd::Argv(v) => v.clone(),
Cmd::Line(s) => s.split_whitespace().map(str::to_string).collect(),
};
if v.is_empty() {
bail!("empty command");
}
Ok(v)
}
pub fn runs(&self) -> u32 {
self.runs.unwrap_or(DEFAULT_RUNS)
}
pub fn warmup(&self) -> u32 {
self.warmup.unwrap_or(DEFAULT_WARMUP)
}
}
impl Config {
pub fn parse(text: &str) -> Result<Self> {
let cfg: Config = toml::from_str(text).context("could not parse tak.toml")?;
for (name, b) in &cfg.bench {
b.argv()
.with_context(|| format!("benchmark `{name}` has no command"))?;
}
Ok(cfg)
}
pub fn find(start: &Path) -> Result<Option<(PathBuf, Self)>> {
for dir in start.ancestors() {
let path = dir.join(FILE_NAME);
if path.is_file() {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("could not read {}", path.display()))?;
let cfg = Self::parse(&text).with_context(|| format!("in {}", path.display()))?;
return Ok(Some((path, cfg)));
}
}
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_command_may_be_a_list_or_a_string() {
let c = Config::parse(
r#"
[bench.a]
cmd = ["mycli", "--version"]
[bench.b]
cmd = "mycli --help"
"#,
)
.unwrap();
assert_eq!(c.bench["a"].argv().unwrap(), ["mycli", "--version"]);
assert_eq!(c.bench["b"].argv().unwrap(), ["mycli", "--help"]);
}
#[test]
fn a_string_command_gets_no_shell_semantics() {
let c = Config::parse(
r#"[bench.a]
cmd = "mycli 'two words'""#,
)
.unwrap();
assert_eq!(c.bench["a"].argv().unwrap(), ["mycli", "'two", "words'"]);
}
#[test]
fn defaults_match_the_cli() {
let c = Config::parse("[bench.a]\ncmd = \"x\"").unwrap();
assert_eq!(c.bench["a"].runs(), DEFAULT_RUNS);
assert_eq!(c.bench["a"].warmup(), DEFAULT_WARMUP);
}
#[test]
fn per_benchmark_overrides_win() {
let c = Config::parse("[bench.a]\ncmd = \"x\"\nruns = 5\nwarmup = 1").unwrap();
assert_eq!(c.bench["a"].runs(), 5);
assert_eq!(c.bench["a"].warmup(), 1);
}
#[test]
fn an_empty_command_is_rejected_at_parse_time() {
let err = Config::parse("[bench.a]\ncmd = []").unwrap_err();
assert!(format!("{err:#}").contains('a'), "{err:#}");
}
#[test]
fn benchmarks_run_in_a_stable_order() {
let c = Config::parse("[bench.zebra]\ncmd = \"z\"\n[bench.alpha]\ncmd = \"a\"").unwrap();
assert_eq!(c.bench.keys().collect::<Vec<_>>(), ["alpha", "zebra"]);
}
#[test]
fn an_empty_file_declares_nothing() {
assert!(Config::parse("").unwrap().bench.is_empty());
}
#[test]
fn find_walks_up_from_a_subdirectory() {
let root = std::env::temp_dir().join(format!("tak-cfg-{}", std::process::id()));
let nested = root.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(root.join(FILE_NAME), "[bench.x]\ncmd = \"true\"").unwrap();
let (path, cfg) = Config::find(&nested).unwrap().expect("should find it");
assert_eq!(path, root.join(FILE_NAME));
assert!(cfg.bench.contains_key("x"));
std::fs::remove_dir_all(&root).ok();
}
}