Load configs through registration calls
The module exposes setup, server, and flow as Rust closures sharing a
registration stash, so function values cross as typed call arguments.
Setup deserializes with strict default options since its table is pure
data; compiling with correlate keeps error line numbers aligned to the
config after multi-statement forms.

Assisted-by: GLM-5.3 via pi
change ypuunnsnrvzmklnytyyttosxulslskyt
commit 99494b6f409579727a8074280b31c62d96d038b5
author Alpha Chen <alpha@kejadlen.dev>
date
parent skvvlkow
diff --git a/src/error.rs b/src/error.rs
index cc5c6fa..fc0f986 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -15,12 +15,12 @@ pub enum Error {
         error: mlua::Error,
     },
 
-    #[error("{} must return (parallax {{...}})", .path.display())]
+    #[error("{} must call ({what} ...)", .path.display())]
     #[diagnostic(
-        code(parallax::config_return),
-        help("make the config's last form a call to the parallax module")
+        code(parallax::config_registration),
+        help("a config registers with (setup {{...}}), (server fn), and (flow :name fn)")
     )]
-    ConfigReturn { path: PathBuf },
+    ConfigRegistration { path: PathBuf, what: &'static str },
 
     #[error("io error: {0}")]
     #[diagnostic(code(parallax::io))]
diff --git a/src/parallax.rs b/src/parallax.rs
index 42c5434..1322b91 100644
--- a/src/parallax.rs
+++ b/src/parallax.rs
@@ -1,28 +1,45 @@
+use std::mem;
 use std::path::Path;
 use std::path::PathBuf;
+use std::sync::Arc;
+use std::sync::Mutex;
+use std::sync::MutexGuard;
 
-use mlua::FromLua;
 use mlua::Function;
 use mlua::Lua;
+use mlua::LuaSerdeExt as _;
 use mlua::Table;
 use mlua::Value;
 
 use crate::Error;
 use crate::spec::Flow;
-use crate::spec::Spec;
+use crate::spec::NonEmptyString;
+use crate::spec::Setup;
 
 const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
 
-/// A loaded parallax: the executable form of a config. The spec holds
-/// the flows and baseline as owned handles into the embedded Lua
-/// state, which stays alive in this struct; fields are ordered so the
-/// handles drop while the VM lives.
+/// What a config has registered so far, filled in by the module's
+/// registration functions as the config runs.
+#[derive(Default)]
+struct Registrations {
+    setup: Option<Setup>,
+    server: Option<Function>,
+    flows: Vec<Flow>,
+}
+
+/// A loaded parallax: the executable form of a config. The server
+/// function and flows are owned handles into the embedded Lua state,
+/// which stays alive in this struct; fields are ordered so the handles
+/// drop while the VM lives.
 pub struct Parallax {
-    // Consumed once running configs lands: the flows and baseline get
-    // called, and the schema drives viewport loops and the server
-    // supervisor.
+    flows: Vec<Flow>,
+    // Consumed once running configs lands: the server function is
+    // called once per viewport, and the setup drives viewport loops
+    // and the baseline download.
+    #[allow(dead_code)]
+    server: Function,
     #[allow(dead_code)]
-    spec: Spec,
+    setup: Setup,
     name: String,
     #[allow(dead_code)]
     path: PathBuf,
@@ -37,12 +54,51 @@ 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 table = load_table(&lua, path, &source)?;
-        build(lua, &table, path)
+        let fail = |error: mlua::Error| Error::Config {
+            path: path.to_path_buf(),
+            error,
+        };
+
+        let fennel: Table = lua
+            .load(FENNEL_LUA)
+            .set_name("fennel.lua")
+            .eval()
+            .map_err(fail)?;
+
+        let stash = Arc::new(Mutex::new(Registrations::default()));
+        install_module(&lua, &stash).map_err(fail)?;
+
+        let filename = path.to_string_lossy().into_owned();
+        run_config(&lua, &fennel, &source, &filename).map_err(fail)?;
+
+        let Registrations {
+            setup,
+            server,
+            flows,
+        } = mem::take(&mut *lock(&stash).map_err(fail)?);
+
+        let setup = setup.ok_or_else(|| Error::ConfigRegistration {
+            path: path.to_path_buf(),
+            what: "setup",
+        })?;
+        let server = server.ok_or_else(|| Error::ConfigRegistration {
+            path: path.to_path_buf(),
+            what: "server",
+        })?;
+        let name = setup.name.clone().unwrap_or_else(|| default_name(path));
+
+        Ok(Parallax {
+            flows,
+            server,
+            setup,
+            name,
+            path: path.to_path_buf(),
+            lua,
+        })
     }
 
     pub fn flows(&self) -> &[Flow] {
-        self.spec.flows.as_slice()
+        &self.flows
     }
 
     pub fn name(&self) -> &str {
@@ -55,29 +111,6 @@ impl Parallax {
     }
 }
 
-/// Converts the validated table into a Spec. Conversion already ran
-/// inside the parallax call, so failures here are internal errors.
-fn build(lua: Lua, table: &Table, path: &Path) -> Result<Parallax, Error> {
-    let fail = |error: mlua::Error| Error::Config {
-        path: path.to_path_buf(),
-        error,
-    };
-
-    let spec = Spec::from_lua(Value::Table(table.clone()), &lua).map_err(fail)?;
-    let name = spec
-        .schema
-        .name
-        .clone()
-        .unwrap_or_else(|| default_name(path));
-
-    Ok(Parallax {
-        spec,
-        name,
-        path: path.to_path_buf(),
-        lua,
-    })
-}
-
 fn default_name(path: &Path) -> String {
     path.parent()
         .and_then(Path::file_name)
@@ -86,51 +119,13 @@ fn default_name(path: &Path) -> String {
         .to_owned()
 }
 
-fn load_table(lua: &Lua, path: &Path, source: &str) -> Result<Table, Error> {
-    let fail = |error: mlua::Error| Error::Config {
-        path: path.to_path_buf(),
-        error,
-    };
-
-    let fennel: Table = lua
-        .load(FENNEL_LUA)
-        .set_name("fennel.lua")
-        .eval()
-        .map_err(fail)?;
-
-    install_module(lua).map_err(fail)?;
-
-    let filename = path.to_string_lossy().into_owned();
-    let value = run_config(lua, &fennel, source, &filename).map_err(fail)?;
-
-    let table = match value {
-        Value::Table(table) => table,
-        _ => {
-            return Err(Error::ConfigReturn {
-                path: path.to_path_buf(),
-            });
-        }
-    };
-
-    let marked = table
-        .metatable()
-        .is_some_and(|metatable| metatable.get::<bool>("__parallax_config").unwrap_or(false));
-    if !marked {
-        return Err(Error::ConfigReturn {
-            path: path.to_path_buf(),
-        });
-    }
-
-    Ok(table)
-}
-
 /// Builds the parallax module in Rust and registers it as the
 /// `parallax` entry in package.preload, so configs can require it.
-fn install_module(lua: &Lua) -> Result<(), mlua::Error> {
+fn install_module(lua: &Lua, stash: &Arc<Mutex<Registrations>>) -> Result<(), mlua::Error> {
     let module = lua.create_table()?;
-    let metatable = lua.create_table()?;
-    metatable.set("__call", parallax_call(lua)?)?;
-    module.set_metatable(Some(metatable))?;
+    module.set("setup", setup_fn(lua, stash.clone())?)?;
+    module.set("server", server_fn(lua, stash.clone())?)?;
+    module.set("flow", flow_fn(lua, stash.clone())?)?;
 
     let package: Table = lua.globals().get("package")?;
     let preload: Table = package.get("preload")?;
@@ -139,41 +134,85 @@ fn install_module(lua: &Lua) -> Result<(), mlua::Error> {
     Ok(())
 }
 
-/// The __call metamethod: converts the spec table into a Spec and
-/// marks the result so the loader can tell a real config from any
-/// other table. Conversion happens while the config's stack frame is
-/// alive, so errors can point at the (parallax {...}) call site.
-fn parallax_call(lua: &Lua) -> Result<Function, mlua::Error> {
-    lua.create_function(move |lua, (_module, cfg): (Table, Value)| {
-        if let Err(error) = Spec::from_lua(cfg.clone(), lua) {
-            return Err(error_at(lua, &error_message(&error)));
+/// The registration functions raise errors while the config's stack
+/// frame is alive, so failures point at the registration call site.
+fn setup_fn(lua: &Lua, stash: Arc<Mutex<Registrations>>) -> Result<Function, mlua::Error> {
+    lua.create_function(move |lua, table: Value| {
+        let Value::Table(table) = table else {
+            return Err(error_at(lua, "setup takes a table"));
+        };
+        if lock(&stash)?.setup.is_some() {
+            return Err(error_at(lua, "setup must be called exactly once"));
+        }
+        let setup: Setup = lua
+            .from_value(Value::Table(table))
+            .map_err(|error| error_at(lua, &error_message(&error)))?;
+        if setup.viewports.is_empty() {
+            return Err(error_at(lua, "viewports must not be empty"));
         }
-        let Value::Table(table) = cfg else {
-            return Err(error_at(lua, "parallax must be called with a table"));
+        lock(&stash)?.setup = Some(setup);
+        Ok(())
+    })
+}
+
+fn server_fn(lua: &Lua, stash: Arc<Mutex<Registrations>>) -> Result<Function, mlua::Error> {
+    lua.create_function(move |lua, call: Value| {
+        let Value::Function(call) = call else {
+            return Err(error_at(lua, "server takes a function"));
         };
-        let marker = lua.create_table()?;
-        marker.set("__parallax_config", true)?;
-        table.set_metatable(Some(marker))?;
-        Ok(table)
+        let mut registrations = lock(&stash)?;
+        if registrations.server.is_some() {
+            return Err(error_at(lua, "server must be called exactly once"));
+        }
+        registrations.server = Some(call);
+        Ok(())
     })
 }
 
+fn flow_fn(lua: &Lua, stash: Arc<Mutex<Registrations>>) -> Result<Function, mlua::Error> {
+    lua.create_function(move |lua, (name, call): (Value, Value)| {
+        let name = match name {
+            Value::String(name) => match name.to_str() {
+                Ok(name) => NonEmptyString::new(name.to_owned()),
+                Err(_) => None,
+            },
+            _ => None,
+        };
+        let call = match call {
+            Value::Function(call) => Some(call),
+            _ => None,
+        };
+        let (Some(name), Some(call)) = (name, call) else {
+            return Err(error_at(lua, "flow takes a non-empty name and a function"));
+        };
+        lock(&stash)?.flows.push(Flow { name, call });
+        Ok(())
+    })
+}
+
+fn lock(stash: &Arc<Mutex<Registrations>>) -> Result<MutexGuard<'_, Registrations>, mlua::Error> {
+    stash
+        .lock()
+        .map_err(|_| mlua::Error::runtime("the registration state was poisoned"))
+}
+
 fn error_at(lua: &Lua, message: &str) -> mlua::Error {
     let position = caller_position(lua).unwrap_or_else(|| "?".to_string());
     mlua::Error::runtime(format!("{position}: {message}"))
 }
 
-/// Spec conversion errors are runtime errors carrying a bare message;
+/// Conversion errors are runtime errors carrying a bare message;
 /// unwrap them so position prefixes don't stack Display prefixes.
 fn error_message(error: &mlua::Error) -> String {
     match error {
         mlua::Error::RuntimeError(message) => message.clone(),
+        mlua::Error::DeserializeError(message) => message.clone(),
         other => other.to_string(),
     }
 }
 
 /// Walks the Lua stack for the config's main chunk so errors point at
-/// the (parallax {...}) call site. Runs inside the __call callback,
+/// the registration call site. Runs inside a registration callback,
 /// where that frame is still alive.
 fn caller_position(lua: &Lua) -> Option<String> {
     let debug: Table = lua.globals().get("debug").ok()?;
@@ -196,14 +235,20 @@ fn run_config(
     source: &str,
     filename: &str,
 ) -> Result<Value, mlua::Error> {
-    // Configs end with (parallax {...}), which compiles to a tail call;
-    // tail elimination destroys the config's stack frame, hiding the
-    // call site from conversion errors. Turning the final return into
-    // a local assignment on the same line keeps the frame alive without
-    // moving any other statement.
+    // Configs end with a registration call, which compiles to a tail
+    // call; tail elimination destroys the config's stack frame, hiding
+    // the call site from registration errors. Turning the final return
+    // into a local assignment on the same line keeps the frame alive
+    // without moving any other statement.
+    //
+    // `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.
     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))?;
     let compiled = protect_tail_call(&compiled);
     lua.load(compiled).set_name(format!("@{filename}")).eval()
diff --git a/src/spec.rs b/src/spec.rs
index 27fbd11..62a0ec4 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -3,23 +3,8 @@
 use std::fmt;
 use std::num::NonZeroU32;
 
-use mlua::FromLua;
 use mlua::Function;
-use mlua::Lua;
-use mlua::LuaSerdeExt as _;
-use mlua::Table;
-use mlua::Value;
 use serde::Deserialize;
-use serde::de::IgnoredAny;
-
-/// Functions can't cross serde's data model — mlua skips them inside
-/// sequences and degrades them to unit values at map slots — so the
-/// scalar skeleton below is deserialized with serde while function-
-/// bearing fields convert through FromLua. This is also why flows and
-/// baseline are declared as ignored on the schema: they have to be
-/// known keys (deny_unknown_fields) without being serde-parsed.
-const OPTIONS: mlua::serde::DeserializeOptions =
-    mlua::serde::DeserializeOptions::new().deny_unsupported_types(false);
 
 /// A string that rejected its own emptiness at deserialization time.
 #[derive(Debug)]
@@ -56,300 +41,140 @@ pub struct Flow {
     pub call: Function,
 }
 
-impl FromLua for Flow {
-    fn from_lua(value: Value, _lua: &Lua) -> Result<Self, mlua::Error> {
-        let Value::Table(pair) = value else {
-            return Err(mlua::Error::runtime("must be a [name function] pair"));
-        };
-        let name = NonEmptyString::new(pair.get::<String>(1)?)
-            .ok_or_else(|| mlua::Error::runtime("name must not be empty"))?;
-        let call = pair.get::<Function>(2)?;
-        Ok(Flow { name, call })
-    }
-}
-
-/// A config's flows, in config order.
-#[derive(Debug)]
-pub struct Flows(Vec<Flow>);
-
-impl Flows {
-    pub fn as_slice(&self) -> &[Flow] {
-        &self.0
-    }
-}
-
-impl FromLua for Flows {
-    fn from_lua(value: Value, lua: &Lua) -> Result<Self, mlua::Error> {
-        let Value::Table(table) = value else {
-            return Err(mlua::Error::runtime(
-                ":flows must be a sequence of [name function] pairs",
-            ));
-        };
-        let mut flows = Vec::with_capacity(table.raw_len());
-        for index in 1..=table.raw_len() {
-            let flow = Flow::from_lua(table.get(index)?, lua).map_err(|_| {
-                mlua::Error::runtime(format!(":flows[{index}] must be a [name function] pair"))
-            })?;
-            flows.push(flow);
-        }
-        Ok(Flows(flows))
-    }
-}
-
-/// The optional baseline function.
-#[derive(Debug)]
-pub struct Baseline(Option<Function>);
+/// The table passed to `(setup {...})`. Pure data — a function
+/// anywhere in it is an error, which is why it deserializes with
+/// mlua's default strict options.
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+#[allow(dead_code)] // baseline is read once the GitHub task lands
+pub(crate) struct Setup {
+    pub(crate) viewports: Vec<(NonEmptyString, NonZeroU32, NonZeroU32)>,
 
-impl Baseline {
-    // Called once running configs lands.
-    #[allow(dead_code)]
-    pub(crate) fn as_function(&self) -> Option<&Function> {
-        self.0.as_ref()
-    }
-}
+    #[serde(default)]
+    pub(crate) baseline: Option<BaselineSource>,
 
-impl FromLua for Baseline {
-    fn from_lua(value: Value, _lua: &Lua) -> Result<Self, mlua::Error> {
-        match value {
-            Value::Nil => Ok(Baseline(None)),
-            Value::Function(call) => Ok(Baseline(Some(call))),
-            _ => Err(mlua::Error::runtime(":baseline must be a function")),
-        }
-    }
+    #[serde(default)]
+    pub(crate) name: Option<String>,
 }
 
-/// The scalar skeleton of a config, deserialized from the validated
-/// table.
+/// Where comparison snapshots come from; see the design doc's GitHub
+/// releases section.
 #[derive(Debug, Deserialize)]
 #[serde(deny_unknown_fields)]
-#[allow(dead_code)] // read by tests now, by the runner once run lands
-pub(crate) struct Schema {
-    pub(crate) viewports: Vec<(NonEmptyString, NonZeroU32, NonZeroU32)>,
-    pub(crate) server: Server,
-
-    pub(crate) baseline: Option<IgnoredAny>,
+#[allow(dead_code)] // read once the GitHub task lands
+pub(crate) struct BaselineSource {
+    pub(crate) repo: NonEmptyString,
 
-    pub(crate) flows: IgnoredAny,
-
-    pub(crate) name: Option<String>,
+    #[serde(default)]
+    pub(crate) asset: Option<NonEmptyString>,
 }
 
+/// The table the registered server function returns, once per
+/// viewport. Validated when the function is called — a future task.
 #[derive(Debug, Deserialize)]
 #[serde(deny_unknown_fields)]
-#[allow(dead_code)] // read by tests now, by the runner once run lands
-pub(crate) struct Server {
-    pub(crate) cmd: Vec<String>,
+#[allow(dead_code)] // constructed when running configs lands
+pub struct ServerSpec {
+    pub cmd: Vec<String>,
 
     #[serde(default = "default_timeout")]
-    pub(crate) timeout: f64,
+    pub timeout: f64,
 }
 
+/// Referenced by ServerSpec's serde default; constructed when running configs lands.
+#[allow(dead_code)]
 fn default_timeout() -> f64 {
     30.0
 }
 
-/// Everything a config's parallax call produces: the deserialized
-/// schema plus the typed values serde can't carry.
-#[derive(Debug)]
-pub struct Spec {
-    pub(crate) schema: Schema,
-    pub(crate) flows: Flows,
-    // Called once running configs lands.
-    #[allow(dead_code)]
-    pub(crate) baseline: Baseline,
-}
-
-impl FromLua for Spec {
-    fn from_lua(value: Value, lua: &Lua) -> Result<Self, mlua::Error> {
-        let schema: Schema = lua
-            .from_value_with(value.clone(), OPTIONS)
-            .map_err(|error| {
-                match error {
-                    // Strip mlua's wrapper so the bare serde message rides
-                    // out; the caller adds the call-site position.
-                    mlua::Error::DeserializeError(message) => mlua::Error::runtime(message),
-                    other => other,
-                }
-            })?;
-        if schema.viewports.is_empty() {
-            return Err(mlua::Error::runtime("viewports must not be empty"));
-        }
-        if schema.server.cmd.is_empty() {
-            return Err(mlua::Error::runtime("server.cmd must not be empty"));
-        }
-        let table = Table::from_lua(value, lua)?;
-        let flows = Flows::from_lua(table.get("flows")?, lua)?;
-        let baseline = Baseline::from_lua(table.get("baseline")?, lua)?;
-        Ok(Spec {
-            schema,
-            flows,
-            baseline,
-        })
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
+    use mlua::Lua;
+    use mlua::LuaSerdeExt as _;
+    use mlua::Value;
 
-    const VIEWPORTS: &str = r#"viewports = {{"desktop", 1280, 900}}"#;
-    const SERVER: &str = r#"server = {cmd = {"true"}}"#;
-    const FLOWS: &str = r#"flows = {{"flow", function() end}}"#;
-
-    fn with_spec<T>(body: &str, check: impl FnOnce(&Lua, &Table) -> T) -> T {
+    fn with_value<T>(body: &str, check: impl FnOnce(&Lua, Value) -> T) -> T {
         let lua = unsafe { Lua::unsafe_new() };
-        let table: Table = lua
-            .load(format!("return {{{body}}}"))
-            .eval()
-            .expect("lua table source");
-        check(&lua, &table)
-    }
-
-    fn spec(lua: &Lua, table: &Table) -> Result<Spec, String> {
-        Spec::from_lua(Value::Table(table.clone()), lua).map_err(|error| error.to_string())
-    }
-
-    fn assert_message(body: &str, fragment: &str) {
-        with_spec(body, |lua, table| {
-            let error = spec(lua, table).expect_err("expected an invalid config");
-            assert!(
-                error.contains(fragment),
-                "expected {error:?} to contain {fragment:?}"
-            );
-        });
+        let value: Value = lua.load(body).eval().expect("lua source");
+        check(&lua, value)
     }
 
     #[test]
-    fn accepts_a_valid_config() {
-        with_spec(&format!("{VIEWPORTS}, {SERVER}, {FLOWS}"), |lua, table| {
-            let spec = spec(lua, table).expect("valid config");
-            assert_eq!(spec.schema.viewports.len(), 1);
-            assert_eq!(spec.flows.as_slice().len(), 1);
-            assert_eq!(spec.flows.as_slice()[0].name.as_str(), "flow");
-            assert!(spec.baseline.as_function().is_none());
-            assert_eq!(spec.schema.server.timeout, 30.0);
-            assert_eq!(spec.schema.name, None);
-        });
-    }
-
-    #[test]
-    fn accepts_an_empty_flow_list() {
-        with_spec(
-            &format!("{VIEWPORTS}, {SERVER}, flows = {{}}"),
-            |lua, table| {
-                spec(lua, table).expect("valid config");
+    fn parses_a_setup_table() {
+        with_value(
+            r#"return {viewports = {{"desktop", 1280, 900}}, baseline = {repo = "kejadlen/ketchup"}}"#,
+            |lua, value| {
+                let setup: Setup = lua.from_value(value).expect("setup table");
+                assert_eq!(setup.viewports.len(), 1);
+                assert_eq!(setup.viewports[0].0.as_str(), "desktop");
+                assert_eq!(
+                    setup.baseline.as_ref().expect("baseline").repo.as_str(),
+                    "kejadlen/ketchup"
+                );
+                assert_eq!(setup.name, None);
             },
         );
     }
 
     #[test]
-    fn reports_missing_fields() {
-        assert_message(&format!("{SERVER}, {FLOWS}"), "missing field `viewports`");
-        assert_message(&format!("{VIEWPORTS}, {FLOWS}"), "missing field `server`");
-        assert_message(&format!("{VIEWPORTS}, {SERVER}"), "missing field `flows`");
-    }
-
-    #[test]
-    fn reports_unknown_fields() {
-        assert_message(
-            &format!(r#"{VIEWPORTS}, {SERVER}, {FLOWS}, extra = 1"#),
-            "unknown field",
-        );
-    }
-
-    #[test]
-    fn reports_type_and_shape_errors() {
-        assert_message(
-            &format!(r#"{VIEWPORTS}, server = {{cmd = {{"true", 2}}}}, {FLOWS}"#),
-            "invalid type",
-        );
-        assert_message(
-            &format!(r#"viewports = {{{{"desktop", 1280}}}}, {SERVER}, {FLOWS}"#),
-            "invalid length",
-        );
-        assert_message(
-            &format!(r#"viewports = {{{{"desktop", "wide", 900}}}}, {SERVER}, {FLOWS}"#),
-            "invalid type",
-        );
-        assert_message(
-            &format!(r#"viewports = {{{{"desktop", -1280, 900}}}}, {SERVER}, {FLOWS}"#),
-            "invalid value",
-        );
-    }
-
-    #[test]
-    fn rejects_empty_or_unnamed_viewports() {
-        assert_message(
-            &format!("viewports = {{}}, {SERVER}, {FLOWS}"),
-            "viewports must not be empty",
-        );
-        assert_message(
-            &format!(r#"viewports = {{{{"desktop", 0, 900}}}}, {SERVER}, {FLOWS}"#),
-            "invalid value",
-        );
-        assert_message(
-            &format!(r#"viewports = {{{{"", 1280, 900}}}}, {SERVER}, {FLOWS}"#),
-            "empty string",
-        );
-    }
-
-    #[test]
-    fn rejects_an_empty_server_cmd() {
-        assert_message(
-            &format!("{VIEWPORTS}, server = {{cmd = {{}}}}, {FLOWS}"),
-            "server.cmd must not be empty",
-        );
+    fn rejects_setup_shapes() {
+        let cases = [
+            (r#"return {baseline = nil}"#, "missing field `viewports`"),
+            (
+                r#"return {viewports = {{"desktop", 1280, 900}}, extra = 1}"#,
+                "unknown field",
+            ),
+            (r#"return {viewports = {{"", 1280, 900}}}"#, "empty string"),
+            (
+                r#"return {viewports = {{"desktop", 0, 900}}}"#,
+                "invalid value",
+            ),
+            (
+                r#"return {viewports = {{"desktop", 1280, 900}}, baseline = {asset = "x.tar.gz"}}"#,
+                "missing field `repo`",
+            ),
+        ];
+        for (body, fragment) in cases {
+            with_value(body, |lua, value| {
+                let error = lua
+                    .from_value::<Setup>(value)
+                    .expect_err("expected an invalid setup table");
+                assert!(
+                    error.to_string().contains(fragment),
+                    "expected {error:?} to contain {fragment:?}"
+                );
+            });
+        }
     }
 
     #[test]
-    fn parses_a_function_baseline() {
-        with_spec(
-            &format!("{VIEWPORTS}, {SERVER}, {FLOWS}, baseline = function() end"),
-            |lua, table| {
-                let spec = spec(lua, table).expect("valid config");
-                assert!(spec.baseline.as_function().is_some());
+    fn rejects_functions_in_setup() {
+        with_value(
+            r#"return {viewports = {{"desktop", 1280, 900}}, name = (function() end)}"#,
+            |lua, value| {
+                let error = lua
+                    .from_value::<Setup>(value)
+                    .expect_err("functions cannot appear in setup");
+                assert!(error.to_string().contains("unsupported"));
             },
         );
-        assert_message(
-            &format!(r#"{VIEWPORTS}, {SERVER}, {FLOWS}, baseline = "nope""#),
-            ":baseline must be a function",
-        );
     }
 
     #[test]
-    fn rejects_non_table_flows() {
-        assert_message(
-            &format!(r#"{VIEWPORTS}, {SERVER}, flows = "none""#),
-            ":flows must be a sequence of [name function] pairs",
-        );
-    }
-
-    #[test]
-    fn rejects_malformed_flow_pairs() {
-        let cases = [
-            r#"flows = {{"flow", "not-a-function"}}"#,
-            r#"flows = {{"", function() end}}"#,
-            r#"flows = {"flow"}"#,
-        ];
-        for case in cases {
-            assert_message(
-                &format!("{VIEWPORTS}, {SERVER}, {case}"),
-                ":flows[1] must be a [name function] pair",
-            );
-        }
+    fn parses_a_server_table_with_defaults() {
+        with_value(r#"return {cmd = {"true"}}"#, |lua, value| {
+            let server: ServerSpec = lua.from_value(value).expect("server table");
+            assert_eq!(server.cmd, vec!["true".to_owned()]);
+            assert_eq!(server.timeout, 30.0);
+        });
     }
 
     #[test]
-    fn keeps_explicit_timeout_and_name() {
-        with_spec(
-            &format!(
-                r#"{VIEWPORTS}, server = {{cmd = {{"true"}}, timeout = 5}}, {FLOWS}, name = "ketchup""#
-            ),
-            |lua, table| {
-                let spec = spec(lua, table).expect("valid config");
-                assert_eq!(spec.schema.server.timeout, 5.0);
-                assert_eq!(spec.schema.name.as_deref(), Some("ketchup"));
-            },
-        );
+    fn parses_a_server_table_with_a_timeout() {
+        with_value(r#"return {cmd = {"true"}, timeout = 5}"#, |lua, value| {
+            let server: ServerSpec = lua.from_value(value).expect("server table");
+            assert_eq!(server.timeout, 5.0);
+        });
     }
 }
diff --git a/tests/cli.rs b/tests/cli.rs
index 06daeff..fb7f3cc 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -6,17 +6,21 @@ use assert_cmd::cargo::cargo_bin_cmd;
 use predicates::prelude::*;
 use tempfile::TempDir;
 
-const VALID: &str = r#"(local parallax (require :parallax))
+const VALID: &str = r#"(local {: setup : flow : server} (require :parallax))
 
-(fn dashboard [] nil)
-(fn series-history [] nil)
-
-(parallax
+(setup
   {:viewports [[:desktop 1280 900] [:mobile 375 812]]
-   :server {:cmd ["bundle" "exec" "rake" "snapshots:serve"]}
-   :flows [[:dashboard dashboard]
-           [:series-history series-history]]})
-"#;
+   :baseline {:repo "kejadlen/ketchup"}})
+
+(server
+  (fn []
+    {:cmd ["bundle" "exec" "rake" "snapshots:serve"]}))
+
+(flow :dashboard
+  (fn [{: goto : wait-for : click : snap}] nil))
+
+(flow :series-history
+  (fn [{: goto : wait-for : app : snap}] nil))"#;
 
 fn config_file(source: &str) -> (TempDir, PathBuf) {
     let dir = tempfile::tempdir().expect("tempdir");
@@ -75,18 +79,15 @@ fn syntax_error_points_at_the_config_line() {
 }
 
 #[test]
-fn validation_error_points_at_the_parallax_call() {
-    let source = concat!(
-        "(local parallax (require :parallax))\n",
+fn setup_errors_point_at_the_setup_call() {
+    let (_dir, path) = config_file(concat!(
+        "(local {: setup : flow : server} (require :parallax))\n",
         "\n",
-        "(fn flow [] nil)\n",
+        "(setup {:viewports []})\n",
         "\n",
-        "(parallax\n",
-        "  {:viewports []\n",
-        "   :server {:cmd [\"true\"]}\n",
-        "   :flows [[:flow flow]]})\n",
-    );
-    let (_dir, path) = config_file(source);
+        "(server (fn [] {:cmd [\"true\"]}))\n",
+        "(flow :flow (fn [] nil))\n",
+    ));
     cargo_bin_cmd!("parallax")
         .arg("list")
         .arg("--config")
@@ -94,21 +95,79 @@ fn validation_error_points_at_the_parallax_call() {
         .assert()
         .failure()
         .stderr(
-            predicate::str::contains("config.fnl:5:")
-                .and(predicate::str::contains("must not be empty")),
+            predicate::str::contains("config.fnl:3:")
+                .and(predicate::str::contains("viewports must not be empty")),
         );
 }
 
 #[test]
-fn config_not_returning_a_parallax_call_fails() {
-    let (_dir, path) = config_file("(local parallax (require :parallax))\n\n{:viewports []}\n");
+fn missing_setup_registration_fails() {
+    let (_dir, path) = config_file(concat!(
+        "(local {: setup : flow : server} (require :parallax))\n",
+        "(server (fn [] {:cmd [\"true\"]}))\n",
+        "(flow :flow (fn [] nil))\n",
+    ));
+    cargo_bin_cmd!("parallax")
+        .arg("list")
+        .arg("--config")
+        .arg(&path)
+        .assert()
+        .failure()
+        .stderr(predicate::str::contains("must call (setup ...)"));
+}
+
+#[test]
+fn missing_server_registration_fails() {
+    let (_dir, path) = config_file(concat!(
+        "(local {: setup : flow : server} (require :parallax))\n",
+        "(setup {:viewports [[:desktop 1280 900]]})\n",
+        "(flow :flow (fn [] nil))\n",
+    ));
     cargo_bin_cmd!("parallax")
         .arg("list")
         .arg("--config")
         .arg(&path)
         .assert()
         .failure()
-        .stderr(predicate::str::contains("must return (parallax {...})"));
+        .stderr(predicate::str::contains("must call (server ...)"));
+}
+
+#[test]
+fn duplicate_setup_registration_fails() {
+    let (_dir, path) = config_file(concat!(
+        "(local {: setup : flow : server} (require :parallax))\n",
+        "(setup {:viewports [[:desktop 1280 900]]})\n",
+        "(setup {:viewports [[:mobile 375 812]]})\n",
+        "(server (fn [] {:cmd [\"true\"]}))\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 once")),
+        );
+}
+
+#[test]
+fn malformed_flow_registration_fails() {
+    let (_dir, path) = config_file(concat!(
+        "(local {: setup : flow : server} (require :parallax))\n",
+        "(setup {:viewports [[:desktop 1280 900]]})\n",
+        "(server (fn [] {:cmd [\"true\"]}))\n",
+        "(flow :flow \"not-a-function\")\n",
+    ));
+    cargo_bin_cmd!("parallax")
+        .arg("list")
+        .arg("--config")
+        .arg(&path)
+        .assert()
+        .failure()
+        .stderr(
+            predicate::str::contains("config.fnl:4:").and(predicate::str::contains("flow takes")),
+        );
 }
 
 #[test]