config: return the config table instead of registering it
Validating after the chunk returns trades line labels for key paths, and drops the registration module along with its duplicate-call, late-call, and missing-call cases.

Assisted-by: Claude Opus 5.5 via Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
change zzmymswxyqkotyxvoyrmkvzsxynytpux
commit 4a820e29447d35d0407689558f2206bb9fe6b26a
author Alpha Chen <alpha@kejadlen.dev>
date
parent ztyppkvp
diff --git a/docs/plans/2026-09-25-parallax-design.md b/docs/plans/2026-09-25-parallax-design.md
index 9da077e..e2e22d2 100644
--- a/docs/plans/2026-09-25-parallax-design.md
+++ b/docs/plans/2026-09-25-parallax-design.md
@@ -73,7 +73,7 @@ Parallax has the following parts:
 | CLI | Loads the config, runs variants, writes output, sets the exit code |
 | Server supervisor | Spawns the app, waits for the handshake, tears down |
 | Browser driver | Talks to Chrome over the DevTools Protocol |
-| Fennel runtime | Embeds Lua and the Fennel compiler, exposes the `parallax` module |
+| Fennel runtime | Embeds Lua and the Fennel compiler, evaluates the config |
 | Comparator | Matches snapshots against the baseline and computes stats |
 | GitHub client | Downloads snapshot archives from release assets |
 | Site generator | Writes the static site and `report.json` |
@@ -92,7 +92,7 @@ For each variant, in sorted name order, parallax does the following:
 
 1. Create a temporary directory and set `PARALLAX_HANDSHAKE` to a file
    path inside it in the child's environment.
-2. Call the registered server function and spawn the returned command
+2. Call the config's server function and spawn the returned command
    in its own process group, so that wrappers like `bundle exec` don't
    leave orphaned children.
 3. Poll for the handshake file until it exists and parses. If the child
@@ -138,46 +138,39 @@ avoids scanning output for a marker line.
 
 ## Config
 
-A config is a Fennel file that registers its pieces with the parallax
-module: `(setup {...})` with the static spec, `(server fn)` for the
-app under test, and `(flow fn)` for the single flow. Registration
-records without running anything; the CLI owns execution, so it can
-inspect a config without starting servers, for example to list
-variants.
+A config is a Fennel file that returns one table: the static spec,
+the app under test's start command, and the flow. Loading a config
+only evaluates it; the CLI owns execution, so it can inspect a config
+without starting servers, for example to list variants.
 
 ```fennel
-(local {: setup : flow : server} (require :parallax))
-
-(setup
-  {:variants {:desktop-light {:viewport [1280 900] :scheme :light}
-              :desktop-dark {:viewport [1280 900] :scheme :dark}
-              :mobile-light {:viewport [375 812] :scheme :light}
-              :mobile-dark {:viewport [375 812] :scheme :dark}}
-   :baseline {:repo "kejadlen/ketchup"}})
-
-(server
-  (fn []
-    {:cmd ["bundle" "exec" "rake" "snapshots:serve"]}))
-
-(flow
-  (fn [{: goto : wait-for : click : snap : app : variant}]
-    (goto "/")
-    (wait-for ".dashboard")
-    (snap :dashboard)
-    (click ".complete-btn")
-    (wait-for ".flash-bar")
-    (snap :dashboard-after-complete)
-    (goto (.. "/series/" (. (app) :series :mixed_history)))
-    (wait-for ".task-history")
-    (snap :series-history {:selector ".main-column"})
-    (when (= (. variant :scheme) :dark)
-      (click ".theme-toggle")
-      (snap :series-history-dark {:selector ".main-column"}))))
+{:variants {:desktop-light {:viewport [1280 900] :scheme :light}
+            :desktop-dark {:viewport [1280 900] :scheme :dark}
+            :mobile-light {:viewport [375 812] :scheme :light}
+            :mobile-dark {:viewport [375 812] :scheme :dark}}
+ :baseline {:repo "kejadlen/ketchup"}
+ :server (fn []
+   {:cmd ["bundle" "exec" "rake" "snapshots:serve"]})
+ :flow (fn [{: goto : wait-for : click : snap : app : variant}]
+   (goto "/")
+   (wait-for ".dashboard")
+   (snap :dashboard)
+   (click ".complete-btn")
+   (wait-for ".flash-bar")
+   (snap :dashboard-after-complete)
+   (goto (.. "/series/" (. (app) :series :mixed_history)))
+   (wait-for ".task-history")
+   (snap :series-history {:selector ".main-column"})
+   (when (= (. variant :scheme) :dark)
+     (click ".theme-toggle")
+     (snap :series-history-dark {:selector ".main-column"})))}
 ```
 
-Registration calls can appear in any order. `setup`, `server`, and
-`flow` must each be called exactly once — a config that skips one
-gets an error saying so.
+Parallax validates the returned table after the config finishes
+running, so validation errors — an unknown field, a non-function
+`:server`, no variants — name the offending key path, such as
+`variants.desktop.shceme`, rather than a line. Syntax and runtime
+errors inside the config still report its line numbers.
 
 A variant names a browser emulation: a viewport size paired with a
 color scheme. The variant's name is its key in the `:variants` table,
@@ -186,9 +179,9 @@ tables don't preserve key order, and no variant depends on another's
 state — each gets a fresh server — so variants run in sorted name
 order, and the report and site inherit that grouping.
 
-The `setup` table and the table the `server` function returns are pure
-data, so both are validated where they're built and a bad table's
-error points into the config. Parallax calls the `server` function
+The `:server` and `:flow` functions are taken out of the table
+before the rest deserializes, so everything else is pure data,
+validated where it's built. Parallax calls the `:server` function
 once per variant, before starting that variant's server, and spawns
 the returned `:cmd` in its own process group. `:timeout` bounds the
 handshake wait and defaults to 30 seconds.
@@ -205,10 +198,7 @@ baseline. Only GitHub releases are supported for now (see
 A baseline directory is the output directory of an earlier run, so
 archiving one run's output produces the next run's baseline.
 
-## Module API
-
-The module exposes three registration functions: `setup`, `server`,
-and `flow`.
+## Flow API
 
 The flow takes one argument: the flow context, a table of command
 functions Rust builds for the current variant, carrying its page, base
@@ -357,7 +347,7 @@ subject to GitHub's lower anonymous rate limit.
 
 ### Reading
 
-The `:baseline` field of `setup` names the source:
+The `:baseline` field of the config table names the source:
 
 | Field | Meaning |
 |---|---|
diff --git a/src/error.rs b/src/error.rs
index 3330ab9..da4c52a 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,8 +1,6 @@
 use camino::Utf8PathBuf;
 
 use miette::Diagnostic;
-use miette::NamedSource;
-use miette::SourceSpan;
 
 #[derive(Debug, thiserror::Error, Diagnostic)]
 pub enum Error {
@@ -13,22 +11,12 @@ pub enum Error {
     )]
     Config(#[from] mlua::Error),
 
-    #[error("{message}")]
-    #[diagnostic(code(parallax::config_invalid))]
-    ConfigInvalid {
-        message: String,
-        #[source_code]
-        source_code: NamedSource<String>,
-        #[label("here")]
-        span: SourceSpan,
-    },
-
-    #[error("{path} must call (parallax ...)")]
+    #[error("{path}: {message}")]
     #[diagnostic(
-        code(parallax::config_registration),
-        help("a config registers with (parallax {{...}})")
+        code(parallax::config_invalid),
+        help("a config returns {{:variants {{...}} :server (fn ...) :flow (fn ...)}}")
     )]
-    ConfigRegistration { path: Utf8PathBuf },
+    ConfigInvalid { path: Utf8PathBuf, message: String },
 
     #[error("io error: {0}")]
     #[diagnostic(code(parallax::io))]
diff --git a/src/parallax.rs b/src/parallax.rs
index b70a77e..8c14e1f 100644
--- a/src/parallax.rs
+++ b/src/parallax.rs
@@ -1,16 +1,7 @@
 use camino::Utf8Path;
 use camino::Utf8PathBuf;
-use std::fmt;
-use std::mem;
-use std::sync::Arc;
-use std::sync::Mutex;
-use std::sync::MutexGuard;
-use std::sync::PoisonError;
 
-use miette::NamedSource;
-use miette::SourceSpan;
 use mlua::ErrorContext as _;
-use mlua::FromLua;
 use mlua::Function;
 use mlua::Lua;
 use mlua::Table;
@@ -23,37 +14,41 @@ use crate::spec::Variant;
 
 const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
 
-/// The table passed to `(parallax {...})`: the setup spec plus the
-/// registered server and flow functions. Converted inside the
-/// registration callback, so conversion errors carry the call site.
+/// The table a config returns: the setup spec plus the server and flow
+/// functions.
 struct Config {
     server: Function,
     flow: Function,
     setup: Setup,
 }
 
-impl FromLua for Config {
-    fn from_lua(value: Value, lua: &Lua) -> Result<Config, mlua::Error> {
+impl Config {
+    /// Converts after the config's chunk has returned, so there's no Lua
+    /// position to report; the key path locates mistakes instead.
+    fn from_value(path: &Utf8Path, value: Value) -> Result<Config, Error> {
+        let invalid = |message: String| Error::ConfigInvalid {
+            path: path.to_owned(),
+            message,
+        };
         let Value::Table(table) = value else {
-            return Err(config_error(lua, "parallax takes a table"));
+            return Err(invalid(format!(
+                "the config must return a table, not {}",
+                value.type_name()
+            )));
         };
-        // Popping from a copy leaves the config's own table intact.
-        let spec = shallow_copy(lua, &table)?;
-        let server = pop_function(&spec, "server", lua)?;
-        let flow = pop_function(&spec, "flow", lua)?;
-        // The whole call usually sits on one line, so the key path is
-        // what locates a mistake inside the table.
-        let setup: Setup = serde_path_to_error::deserialize(Deserializer::new(Value::Table(spec)))
+        let server = pop_function(&table, "server")?
+            .ok_or_else(|| invalid("server must be a function".to_owned()))?;
+        let flow = pop_function(&table, "flow")?
+            .ok_or_else(|| invalid("flow must be a function".to_owned()))?;
+        let setup: Setup = serde_path_to_error::deserialize(Deserializer::new(Value::Table(table)))
             .map_err(|error| {
-                let path = error.path().to_string();
+                let key_path = error.path().to_string();
                 match error.into_inner() {
-                    mlua::Error::DeserializeError(message) if path == "." => {
-                        config_error(lua, message)
-                    }
+                    mlua::Error::DeserializeError(message) if key_path == "." => invalid(message),
                     mlua::Error::DeserializeError(message) => {
-                        config_error(lua, format!("{path}: {message}"))
+                        invalid(format!("{key_path}: {message}"))
                     }
-                    other => other,
+                    other => Error::Config(other),
                 }
             })?;
         Ok(Config {
@@ -64,23 +59,15 @@ impl FromLua for Config {
     }
 }
 
-/// Takes `key` out of the table as a function, so the serde pass
-/// never sees it and unknown-field validation still catches typos.
-fn pop_function(table: &Table, key: &str, lua: &Lua) -> Result<Function, mlua::Error> {
-    let Value::Function(call) = table.raw_get::<Value>(key)? else {
-        return Err(config_error(lua, format!("{key} must be a function")));
-    };
+/// Takes `key` out of the table, so the serde pass never sees it and
+/// unknown-field validation still catches typos.
+fn pop_function(table: &Table, key: &str) -> Result<Option<Function>, mlua::Error> {
+    let value = table.raw_get::<Value>(key)?;
     table.raw_set(key, Value::Nil)?;
-    Ok(call)
-}
-
-fn shallow_copy(lua: &Lua, table: &Table) -> Result<Table, mlua::Error> {
-    let copy = lua.create_table()?;
-    for pair in table.pairs::<Value, Value>() {
-        let (key, value) = pair?;
-        copy.raw_set(key, value)?;
-    }
-    Ok(copy)
+    Ok(match value {
+        Value::Function(call) => Some(call),
+        _ => None,
+    })
 }
 
 /// A loaded parallax: the executable form of a config. The server
@@ -109,13 +96,10 @@ impl Parallax {
         // safe default omits. Configs are the user's own trusted files,
         // so the full stdlib is fine here.
         let lua = unsafe { Lua::unsafe_new() };
-        let filename = path.as_str().to_owned();
 
-        let config = run_config(&lua, &source, &filename)
-            .map_err(|error| load_error(path, &source, error))?
-            .ok_or_else(|| Error::ConfigRegistration {
-                path: path.to_owned(),
-            })?;
+        let value =
+            run_config(&lua, &source, path.as_str()).with_context(|_| format!("loading {path}"))?;
+        let config = Config::from_value(path, value)?;
         let name = config
             .setup
             .name
@@ -154,200 +138,19 @@ fn default_name(path: &Utf8Path) -> String {
         .to_owned()
 }
 
-/// Where `(parallax ...)` leaves its config. Closed once loading ends,
-/// so a call from inside a flow fails instead of registering into a
-/// slot nobody reads.
-enum Slot {
-    Empty,
-    Filled(Config),
-    Closed,
-}
-
-// Poisoning only follows a panic mid-store; the slot is still
-// coherent, so take it rather than fail.
-fn lock(slot: &Mutex<Slot>) -> MutexGuard<'_, Slot> {
-    slot.lock().unwrap_or_else(PoisonError::into_inner)
-}
-
-/// Builds the `parallax` module in package.preload — a single function
-/// that converts its argument to a Config and stores it in the slot
-/// the caller keeps — and returns that slot. The function raises
-/// errors while the config's stack frame is alive, so failures point
-/// at the call site.
-fn install_module(lua: &Lua) -> Result<Arc<Mutex<Slot>>, mlua::Error> {
-    let slot = Arc::new(Mutex::new(Slot::Empty));
-    let parallax = {
-        let slot = slot.clone();
-        // Takes a Value and converts it after checking the slot, so a
-        // late call reports that rather than a problem with its table.
-        lua.create_function(move |lua, value: Value| {
-            match *lock(&slot) {
-                Slot::Empty => {}
-                Slot::Filled(_) => {
-                    return Err(config_error(lua, "parallax must be called exactly once"));
-                }
-                Slot::Closed => {
-                    return Err(config_error(
-                        lua,
-                        "parallax can only be called while the config loads",
-                    ));
-                }
-            }
-            let config = Config::from_lua(value, lua)?;
-            *lock(&slot) = Slot::Filled(config);
-            Ok(())
-        })?
-    };
-
-    let lookup = lua.create_function(move |_, _name: String| Ok(parallax.clone()))?;
-    let package: Table = lua.globals().get("package")?;
-    let preload: Table = package.get("preload")?;
-    preload.set("parallax", lookup)?;
-    Ok(slot)
-}
-
-/// A config mistake caught by a Rust callback. It travels through Lua
-/// as an external error, and `load_error` downcasts it to label the
-/// config's source.
-#[derive(Debug)]
-struct ConfigError {
-    caller: Option<Caller>,
-    message: String,
-}
-
-/// The Lua position that called into Rust.
-#[derive(Debug)]
-struct Caller {
-    file: String,
-    line: usize,
-}
-
-impl fmt::Display for ConfigError {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        if let Some(Caller { file, line }) = &self.caller {
-            write!(f, "{file}:{line}: ")?;
-        }
-        f.write_str(&self.message)
-    }
-}
-
-impl std::error::Error for ConfigError {}
-
-fn config_error(lua: &Lua, message: impl Into<String>) -> mlua::Error {
-    mlua::Error::external(ConfigError {
-        caller: caller(lua),
-        message: message.into(),
-    })
-}
-
-/// Level 0 is the running Rust callback, so level 1 is its caller —
-/// what `error(msg, 2)` reports in plain Lua. Calling a C function
-/// from tail position keeps the caller's frame, so this holds even for
-/// a config's final form.
-fn caller(lua: &Lua) -> Option<Caller> {
-    lua.inspect_stack(1, |debug| {
-        let file = debug.source().source?.strip_prefix('@')?.to_owned();
-        let line = debug.current_line()?;
-        Some(Caller { file, line })
-    })?
-}
-
-/// Labels the config's source when the error came from a call in the
-/// config file itself; anything else stays an mlua error.
-fn load_error(path: &Utf8Path, source: &str, error: mlua::Error) -> Error {
-    if let Some(ConfigError {
-        caller: Some(caller),
-        message,
-    }) = error.downcast_ref::<ConfigError>()
-        && caller.file == path.as_str()
-        && let Some(span) = line_span(source, caller.line)
-    {
-        return Error::ConfigInvalid {
-            message: message.clone(),
-            source_code: NamedSource::new(path.as_str(), source.to_owned()),
-            span,
-        };
-    }
-    Error::Config(error.context(format!("loading {path}")))
-}
-
-/// The span of a 1-based line, without its indentation or line ending.
-fn line_span(source: &str, line: usize) -> Option<SourceSpan> {
-    let mut offset: usize = 0;
-    for (number, text) in (1..).zip(source.split_inclusive('\n')) {
-        if number == line {
-            let trimmed = text.trim();
-            let indent = text.len().checked_sub(text.trim_start().len())?;
-            return Some((offset.checked_add(indent)?, trimmed.len()).into());
-        }
-        offset = offset.checked_add(text.len())?;
-    }
-    None
-}
-
-/// Loads the Fennel compiler, runs the config, and returns the config
-/// it registered — None when the config never called `(parallax ...)`.
-/// Every failure surfaces as an mlua error; the caller annotates it
-/// with the config's path.
-fn run_config(lua: &Lua, source: &str, filename: &str) -> Result<Option<Config>, mlua::Error> {
+/// Loads the Fennel compiler, runs the config, and returns whatever
+/// its chunk returns.
+fn run_config(lua: &Lua, source: &str, filename: &str) -> Result<Value, mlua::Error> {
     let fennel: Table = lua.load(FENNEL_LUA).set_name("fennel.lua").eval()?;
-    let slot = install_module(lua)?;
 
     // `correlate` makes Fennel emit each statement on its source line,
-    // so debug line numbers match the config; a form like the
-    // destructured require emits several statements and would otherwise
-    // drift every following line.
+    // so syntax and runtime errors report the config's line numbers; a
+    // form that emits several statements would otherwise drift every
+    // following line.
     let compile: Function = fennel.get("compile-string")?;
     let options = lua.create_table()?;
     options.set("filename", filename)?;
     options.set("correlate", true)?;
     let compiled: String = compile.call((source, options))?;
-    lua.load(compiled)
-        .set_name(format!("@{filename}"))
-        .eval::<()>()?;
-
-    match mem::replace(&mut *lock(&slot), Slot::Closed) {
-        Slot::Filled(config) => Ok(Some(config)),
-        Slot::Empty | Slot::Closed => Ok(None),
-    }
-}
-
-#[cfg(test)]
-#[allow(clippy::expect_used)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn spans_a_line_without_indentation_or_newline() {
-        let source = "(first)\n  (second)\r\n(third)";
-        assert_eq!(line_span(source, 1), Some((0, 7).into()));
-        assert_eq!(line_span(source, 2), Some((10, 8).into()));
-        assert_eq!(line_span(source, 3), Some((20, 7).into()));
-        assert_eq!(line_span(source, 4), None);
-        assert_eq!(line_span(source, 0), None);
-    }
-
-    #[test]
-    fn rejects_registration_after_loading() {
-        let lua = unsafe { Lua::unsafe_new() };
-        let source = concat!(
-            "(local parallax (require :parallax))\n",
-            "(fn late [] (parallax {:variants {:x {:viewport [1 1]}}",
-            " :server (fn [] nil) :flow (fn [] nil)}))\n",
-            "(parallax {:variants {:d {:viewport [1 1]}} :server (fn [] nil) :flow late})\n",
-        );
-        let config = run_config(&lua, source, "config.fnl")
-            .expect("config loads")
-            .expect("config registers");
-        let error = config
-            .flow
-            .call::<()>(())
-            .expect_err("registering after loading must fail");
-        assert!(
-            error
-                .to_string()
-                .contains("only be called while the config loads"),
-            "unexpected error: {error}"
-        );
-    }
+    lua.load(compiled).set_name(format!("@{filename}")).eval()
 }
diff --git a/src/spec.rs b/src/spec.rs
index 955dea4..d881555 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -56,7 +56,7 @@ pub struct Variant {
     pub scheme: Scheme,
 }
 
-/// The data half of the table passed to `(parallax {...})`, with
+/// The data half of the table a config returns, with
 /// variants flattened into run order. The caller pulls `server` and
 /// `flow` out first; a function anywhere else is an error, which is why
 /// it deserializes with mlua's default strict options.
@@ -70,7 +70,7 @@ pub(crate) struct Setup {
     pub(crate) name: Option<String>,
 }
 
-/// What `(parallax {...})` deserializes from: variants keyed by name,
+/// What a config's table deserializes from: variants keyed by name,
 /// so the names are data rather than struct fields.
 #[derive(Deserialize)]
 #[serde(deny_unknown_fields)]
diff --git a/tests/cli.rs b/tests/cli.rs
index 8131d68..387d93c 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -6,15 +6,12 @@ use assert_cmd::cargo::cargo_bin_cmd;
 use predicates::prelude::*;
 use tempfile::TempDir;
 
-const VALID: &str = r#"(local parallax (require :parallax))
-
-(parallax
-  {:variants {:mobile-dark {:viewport [375 812] :scheme :dark}
-              :desktop-light {:viewport [1280 900]}
-              :desktop-dark {:viewport [1280 900] :scheme :dark}}
-   :baseline {:repo "kejadlen/ketchup"}
-   :server (fn [] {:cmd ["bundle" "exec" "rake" "snapshots:serve"]})
-   :flow (fn [{: goto : wait-for : click : snap : variant}] nil)})"#;
+const VALID: &str = r#"{:variants {:mobile-dark {:viewport [375 812] :scheme :dark}
+            :desktop-light {:viewport [1280 900]}
+            :desktop-dark {:viewport [1280 900] :scheme :dark}}
+ :baseline {:repo "kejadlen/ketchup"}
+ :server (fn [] {:cmd ["bundle" "exec" "rake" "snapshots:serve"]})
+ :flow (fn [{: goto : wait-for : click : snap : variant}] nil)}"#;
 
 fn config_file(source: &str) -> (TempDir, PathBuf) {
     let dir = tempfile::tempdir().expect("tempdir");
@@ -23,6 +20,21 @@ fn config_file(source: &str) -> (TempDir, PathBuf) {
     (dir, path)
 }
 
+fn list_fails_with(source: &str, fragments: &[&str]) {
+    let (_dir, path) = config_file(source);
+    let mut stderr = predicate::str::contains("config.fnl").boxed();
+    for fragment in fragments {
+        stderr = stderr.and(predicate::str::contains(*fragment)).boxed();
+    }
+    cargo_bin_cmd!("parallax")
+        .arg("list")
+        .arg("--config")
+        .arg(&path)
+        .assert()
+        .failure()
+        .stderr(stderr);
+}
+
 #[test]
 fn lists_variant_names_in_run_order() {
     let (_dir, path) = config_file(VALID);
@@ -60,157 +72,63 @@ fn missing_config_file_fails() {
 
 #[test]
 fn syntax_error_points_at_the_config_line() {
-    let (_dir, path) = config_file("(local ok 1)\n)\n");
-    cargo_bin_cmd!("parallax")
-        .arg("list")
-        .arg("--config")
-        .arg(&path)
-        .assert()
-        .failure()
-        .stderr(
-            predicate::str::contains("config.fnl:2:").and(predicate::str::contains("Parse error")),
-        );
+    list_fails_with("(local ok 1)\n)\n", &["config.fnl:2:", "Parse error"]);
 }
 
 #[test]
-fn empty_variants_error_points_at_the_parallax_call() {
-    let (_dir, path) = config_file(concat!(
-        "(local parallax (require :parallax))\n",
-        "(parallax {:variants {} :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)})\n",
-    ));
-    cargo_bin_cmd!("parallax")
-        .arg("list")
-        .arg("--config")
-        .arg(&path)
-        .assert()
-        .failure()
-        .stderr(predicate::str::contains("config.fnl:2:").and(predicate::str::contains("empty")));
+fn empty_variants_fail() {
+    list_fails_with(
+        "{:variants {} :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)}\n",
+        &["variants must not be empty"],
+    );
 }
 
 #[test]
-fn unknown_field_errors_point_at_the_parallax_call() {
-    let (_dir, path) = config_file(concat!(
-        "(local parallax (require :parallax))\n",
-        "(parallax {:variants {:desktop {:viewport [1280 900]}} :extra 1\n",
-        "           :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)})\n",
-    ));
-    cargo_bin_cmd!("parallax")
-        .arg("list")
-        .arg("--config")
-        .arg(&path)
-        .assert()
-        .failure()
-        .stderr(
-            predicate::str::contains("config.fnl:3:")
-                .and(predicate::str::contains("unknown"))
-                .and(predicate::str::contains("`server`, `flow`"))
-                .and(predicate::str::contains("bad argument").not()),
-        );
+fn unknown_fields_fail() {
+    list_fails_with(
+        concat!(
+            "{:variants {:desktop {:viewport [1280 900]}} :extra 1\n",
+            " :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)}\n",
+        ),
+        &["unknown field `extra`", "`server`, `flow`"],
+    );
 }
 
 #[test]
 fn nested_errors_name_the_key_path() {
-    let (_dir, path) = config_file(concat!(
-        "(local parallax (require :parallax))\n",
-        "(parallax {:variants {:desktop {:viewport [1280 900] :shceme :dark}}\n",
-        "           :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)})\n",
-    ));
-    cargo_bin_cmd!("parallax")
-        .arg("list")
-        .arg("--config")
-        .arg(&path)
-        .assert()
-        .failure()
-        .stderr(predicate::str::contains("variants.desktop.shceme:"));
+    list_fails_with(
+        concat!(
+            "{:variants {:desktop {:viewport [1280 900] :shceme :dark}}\n",
+            " :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)}\n",
+        ),
+        &["variants.desktop.shceme:"],
+    );
 }
 
 #[test]
-fn leaves_the_config_table_intact() {
-    let (_dir, path) = config_file(concat!(
-        "(local parallax (require :parallax))\n",
-        "(local config {:variants {:desktop {:viewport [1280 900]}}\n",
-        "               :server (fn [] {:cmd [\"true\"]}) :flow (fn [] nil)})\n",
-        "(parallax config)\n",
-        "(assert config.server \"server was removed from the config table\")\n",
-    ));
-    cargo_bin_cmd!("parallax")
-        .arg("list")
-        .arg("--config")
-        .arg(&path)
-        .assert()
-        .success();
+fn non_table_config_fails() {
+    list_fails_with("\"nope\"\n", &["must return a table, not string"]);
 }
 
 #[test]
-fn non_table_argument_fails() {
-    let (_dir, path) = config_file("(local parallax (require :parallax))\n(parallax \"nope\")\n");
-    cargo_bin_cmd!("parallax")
-        .arg("list")
-        .arg("--config")
-        .arg(&path)
-        .assert()
-        .failure()
-        .stderr(predicate::str::contains("config.fnl:2:").and(predicate::str::contains("takes")));
+fn config_that_returns_nothing_fails() {
+    list_fails_with("(local ok 1)\n", &["must return a table, not nil"]);
 }
 
 #[test]
 fn server_must_be_a_function() {
-    let (_dir, path) = config_file(concat!(
-        "(local parallax (require :parallax))\n",
-        "(parallax {:variants {:desktop {:viewport [1280 900]}} :server \"not-a-function\"\n",
-        "           :flow (fn [] nil)})\n",
-    ));
-    cargo_bin_cmd!("parallax")
-        .arg("list")
-        .arg("--config")
-        .arg(&path)
-        .assert()
-        .failure()
-        .stderr(predicate::str::contains("config.fnl:3:").and(predicate::str::contains("server")));
+    list_fails_with(
+        "{:variants {:desktop {:viewport [1280 900]}} :server \"nope\" :flow (fn [] nil)}\n",
+        &["server must be a function"],
+    );
 }
 
 #[test]
 fn flow_must_be_a_function() {
-    let (_dir, path) = config_file(concat!(
-        "(local parallax (require :parallax))\n",
-        "(parallax {:variants {:desktop {:viewport [1280 900]}}\n",
-        "           :server (fn [] {:cmd [\"true\"]}) :flow \"not-a-function\"})\n",
-    ));
-    cargo_bin_cmd!("parallax")
-        .arg("list")
-        .arg("--config")
-        .arg(&path)
-        .assert()
-        .failure()
-        .stderr(predicate::str::contains("config.fnl:3:").and(predicate::str::contains("flow")));
-}
-
-#[test]
-fn missing_parallax_call_fails() {
-    let (_dir, path) = config_file("(local parallax (require :parallax))\n");
-    cargo_bin_cmd!("parallax")
-        .arg("list")
-        .arg("--config")
-        .arg(&path)
-        .assert()
-        .failure()
-        .stderr(predicate::str::contains("must call (parallax ...)"));
-}
-
-#[test]
-fn duplicate_parallax_call_fails() {
-    let (_dir, path) = config_file(concat!(
-        "(local parallax (require :parallax))\n",
-        "(parallax {:variants {:desktop {:viewport [1280 900]}} :server (fn [] nil) :flow (fn [] nil)})\n",
-        "(parallax {:variants {:desktop {:viewport [1280 900]}} :server (fn [] nil) :flow (fn [] nil)})\n",
-    ));
-    cargo_bin_cmd!("parallax")
-        .arg("list")
-        .arg("--config")
-        .arg(&path)
-        .assert()
-        .failure()
-        .stderr(predicate::str::contains("config.fnl:3:").and(predicate::str::contains("exactly")));
+    list_fails_with(
+        "{:variants {:desktop {:viewport [1280 900]}} :server (fn [] nil) :flow \"nope\"}\n",
+        &["flow must be a function"],
+    );
 }
 
 #[test]