diff --git a/Cargo.toml b/Cargo.toml
index b3d9a4d..cde76db 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -62,6 +62,8 @@ tinytemplate = { version = "*", optional = true, path = "tmpls/tinytemplate", pa
 
 ahash = { version = "0.8.11", features = ["no-rng"] }
 criterion = { version = "0.5.1", features = ["html_reports"] }
+pretty-error-debug = "0.3.0"
+thiserror = "1.0.63"
 
 [build-dependencies]
 pretty-error-debug = "0.3.0"
@@ -71,3 +73,6 @@ thiserror = "1.0.63"
 [[bench]]
 name = "template-benchmark"
 harness = false
+
+[lints.clippy]
+type_complexity = "allow"
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..0409d93
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,129 @@
+use std::borrow::Cow;
+use std::env::args;
+use std::fmt;
+use std::str::{from_utf8, Utf8Error};
+
+use tmpls::{Benchmark, BigTable, Output, Teams};
+
+fn main() -> Result<(), Error> {
+    let mut args = args().fuse();
+    let exe = args.next().unwrap_or_default();
+    let case = args.next().unwrap_or_default();
+    let tmpl = args.next().unwrap_or_default();
+    let end = args.next();
+
+    if case.is_empty() || tmpl.is_empty() || end.is_some() {
+        return Err(Error::Usage(match exe.is_empty() {
+            true => Cow::Borrowed("template-benchmark"),
+            false => Cow::Owned(exe),
+        }));
+    }
+
+    let tmpl = TMPLS
+        .iter()
+        .find_map(|&(name, func)| (name == tmpl).then_some(func))
+        .ok_or(Error::Tmpl(tmpl.into()))?;
+    tmpl(Case::try_from(case)?)
+}
+
+#[derive(thiserror::Error, pretty_error_debug::Debug)]
+enum Error {
+    #[error("
+Usage: {} <case> <tmpl>
+Where
+    <case>    is a test case: <big-table | teams>
+    <tmpl>    is a templating library: <{}>", .0, Tmpls(3))]
+    Usage(Cow<'static, str>),
+
+    #[error("
+Unknown test case: <{case}>
+Expected: <big-table | teams>", case = .0)]
+    Case(Cow<'static, str>),
+
+    #[error("
+Unknown template engine: <{}>
+Expected one of: <{}>", .0, Tmpls(usize::MAX))]
+    Tmpl(Cow<'static, str>),
+
+    #[error("template rendering failed")]
+    Execution(#[source] Box<dyn std::error::Error + Send + 'static>),
+
+    #[error("template rendering generator non-UTF-8 data")]
+    Utf8(#[source] Utf8Error),
+}
+
+enum Case {
+    BigTable(BigTable),
+    Teams(Teams),
+}
+
+impl TryFrom<String> for Case {
+    type Error = Error;
+
+    fn try_from(case: String) -> Result<Self, Self::Error> {
+        match case.as_str() {
+            "big-table" => Ok(Case::BigTable(BigTable::default())),
+            "teams" => Ok(Case::Teams(Teams::default())),
+            _ => Err(Error::Case(case.into())),
+        }
+    }
+}
+
+const TMPLS: &[(&str, fn(Case) -> Result<(), Error>)] = &[
+    #[cfg(feature = "askama")]
+    ("askama", tmpl::<askama::Benchmark>),
+    #[cfg(feature = "handlebars")]
+    ("handlebars", tmpl::<handlebars::Benchmark>),
+    #[cfg(feature = "horrorshow")]
+    ("horrorshow", tmpl::<horrorshow::Benchmark>),
+    #[cfg(feature = "markup")]
+    ("markup", tmpl::<markup::Benchmark>),
+    #[cfg(feature = "maud")]
+    ("maud", tmpl::<maud::Benchmark>),
+    #[cfg(feature = "minijinja")]
+    ("minijinja", tmpl::<minijinja::Benchmark>),
+    #[cfg(feature = "rinja")]
+    ("rinja", tmpl::<rinja::Benchmark>),
+    #[cfg(feature = "rinja_git")]
+    ("rinja_git", tmpl::<rinja_git::Benchmark>),
+    #[cfg(feature = "ructe")]
+    ("ructe", tmpl::<ructe::Benchmark>),
+    #[cfg(feature = "sailfish")]
+    ("sailfish", tmpl::<sailfish::Benchmark>),
+    #[cfg(feature = "tera")]
+    ("tera", tmpl::<tera::Benchmark>),
+    #[cfg(feature = "tinytemplate")]
+    ("tinytemplate", tmpl::<tinytemplate::Benchmark>),
+];
+
+struct Tmpls(usize);
+
+impl fmt::Display for Tmpls {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        for (idx, &(name, _)) in TMPLS.iter().enumerate() {
+            if idx >= self.0 {
+                return write!(f, "...");
+            }
+            match idx {
+                0 => write!(f, "{name}"),
+                _ => write!(f, " | {name}"),
+            }?;
+        }
+        Ok(())
+    }
+}
+
+fn tmpl<B: Benchmark>(case: Case) -> Result<(), Error> {
+    let mut tmpl = B::default();
+    let mut output = B::Output::default();
+    let result = match case {
+        Case::Teams(input) => tmpl.teams(&mut output, &input),
+        Case::BigTable(input) => tmpl.big_table(&mut output, &input),
+    };
+    result.map_err(|err| Error::Execution(Box::new(err)))?;
+
+    let bytes = from_utf8(output.as_bytes()).map_err(Error::Utf8)?;
+    println!("{}", bytes);
+
+    Ok(())
+}
diff --git a/tmpls/src/lib.rs b/tmpls/src/lib.rs
index ad823cb..2393fa7 100644
--- a/tmpls/src/lib.rs
+++ b/tmpls/src/lib.rs
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
 
 pub trait Benchmark: Default {
     type Output: Output;
-    type Error: std::error::Error;
+    type Error: std::error::Error + Send + 'static;
 
     fn big_table(&mut self, output: &mut Self::Output, input: &BigTable)
     -> Result<(), Self::Error>;