Deserialize configs with serde
mlua skips function values while iterating sequences, so flows is
declared IgnoredAny and checked by hand; baseline passes through its
map slot as a unit value. The typed Config owns the extracted Function
handles and replaces the raw table.

Assisted-by: GLM-5.3 via pi
change xuvrurnrnqkkzumzzkztuqsqvkvntlnp
commit 9341fea337725caa2c18ba9fcde0801228e9097a
author Alpha Chen <alpha@kejadlen.dev>
date
parent uozkvklz
diff --git a/Cargo.lock b/Cargo.lock
index 217a307..c9e3a12 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -238,6 +238,17 @@ version = "1.18.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
 
+[[package]]
+name = "erased-serde"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
+dependencies = [
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
 [[package]]
 name = "errno"
 version = "0.3.14"
@@ -400,6 +411,12 @@ version = "1.70.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
 
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
 [[package]]
 name = "lazy_static"
 version = "1.5.0"
@@ -531,11 +548,14 @@ checksum = "d96e5d00f19d8c46c71ceaced99593b90c31c57aa1fe2cb3e93a8b1698eedba9"
 dependencies = [
  "bstr",
  "either",
+ "erased-serde",
  "libc",
  "mlua-sys",
  "num-traits",
  "parking_lot",
  "rustc-hash",
+ "serde",
+ "serde-value",
 ]
 
 [[package]]
@@ -597,6 +617,15 @@ version = "1.70.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
 
+[[package]]
+name = "ordered-float"
+version = "2.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "owo-colors"
 version = "4.4.0"
@@ -614,6 +643,8 @@ dependencies = [
  "miette",
  "mlua",
  "predicates",
+ "serde",
+ "serde_path_to_error",
  "tempfile",
  "thiserror",
  "tokio",
@@ -800,6 +831,16 @@ dependencies = [
  "serde_derive",
 ]
 
+[[package]]
+name = "serde-value"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
+dependencies = [
+ "ordered-float",
+ "serde",
+]
+
 [[package]]
 name = "serde_core"
 version = "1.0.229"
@@ -820,6 +861,17 @@ dependencies = [
  "syn 3.0.6",
 ]
 
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
 [[package]]
 name = "sharded-slab"
 version = "0.1.7"
@@ -1095,6 +1147,12 @@ dependencies = [
  "tracing-log",
 ]
 
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.26"
diff --git a/Cargo.toml b/Cargo.toml
index 26b79c2..0ccf915 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,7 +12,9 @@ clap = { version = "*", features = ["derive", "env"] }
 clap_complete = "*"
 fs-err = "*"
 miette = { version = "*", features = ["fancy"] }
-mlua = { version = "*", features = ["lua54", "vendored", "send"] }
+mlua = { version = "*", features = ["lua54", "vendored", "send", "serde"] }
+serde = { version = "*", features = ["derive"] }
+serde_path_to_error = "*"
 thiserror = "*"
 tokio = { version = "*", features = ["full"] }
 tracing = "*"
diff --git a/src/config.rs b/src/config.rs
index 12cef26..41ceefc 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -11,14 +11,33 @@ use crate::validate;
 
 const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
 
-/// A loaded config: the validated spec table and the Lua state it lives in.
-/// Fields are ordered so the table drops while its VM is still alive.
+/// A flow: its name from the config and the Lua function to call when
+/// the config runs.
+struct Flow {
+    name: String,
+    // Called once running configs lands.
+    #[allow(dead_code)]
+    call: Function,
+}
+
+/// A loaded config, parsed into typed fields. The functions 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 Config {
-    table: Table,
-    // Holds the VM the table belongs to; read once flows execute in it.
+    flows: Vec<Flow>,
+    // The fields below are consumed once running configs lands: the
+    // baseline function gets called with the cache directory, the
+    // schema drives viewport loops and the server supervisor, and the
+    // VM keeps the function handles alive.
+    #[allow(dead_code)]
+    baseline: Option<Function>,
+    #[allow(dead_code)]
+    schema: validate::Schema,
+    name: String,
     #[allow(dead_code)]
-    lua: Lua,
     path: PathBuf,
+    #[allow(dead_code)]
+    lua: Lua,
 }
 
 impl Config {
@@ -29,32 +48,65 @@ impl Config {
         // so the full stdlib is fine here.
         let lua = unsafe { Lua::unsafe_new() };
         let table = load_table(&lua, path, &source)?;
-        let config = Config {
-            table,
-            lua,
-            path: path.to_path_buf(),
-        };
-        Ok(config)
+        build_config(lua, &table, path)
     }
 
-    pub fn flow_names(&self) -> Result<Vec<String>, Error> {
-        let flows: Table = self.table.get("flows").map_err(self.fail())?;
-        let length = flows.raw_len();
-        let mut names = Vec::with_capacity(length);
-        for index in 1..=length {
-            let pair: Table = flows.get(index).map_err(self.fail())?;
-            let name: String = pair.get(1).map_err(self.fail())?;
-            names.push(name);
+    pub fn flow_names(&self) -> Vec<String> {
+        let mut names = Vec::with_capacity(self.flows.len());
+        for flow in &self.flows {
+            names.push(flow.name.clone());
         }
-        Ok(names)
+        names
     }
 
-    fn fail(&self) -> impl Fn(mlua::Error) -> Error + '_ {
-        move |error| Error::Config {
-            path: self.path.clone(),
-            error,
-        }
+    pub fn name(&self) -> &str {
+        &self.name
+    }
+}
+
+/// Re-parses the validated table into the typed fields and extracts the
+/// function handles serde can't carry. Validation already ran inside
+/// the parallax call, so failures here are internal errors.
+fn build_config(lua: Lua, table: &Table, path: &Path) -> Result<Config, Error> {
+    let fail = |error: mlua::Error| Error::Config {
+        path: path.to_path_buf(),
+        error,
+    };
+
+    let schema = validate::parse(table).map_err(|message| fail(mlua::Error::runtime(message)))?;
+
+    let flows_table: Table = table.get("flows").map_err(fail)?;
+    let mut flows = Vec::with_capacity(flows_table.raw_len());
+    for index in 1..=flows_table.raw_len() {
+        let pair: Table = flows_table.get(index).map_err(fail)?;
+        let name: String = pair.get(1).map_err(fail)?;
+        let call: Function = pair.get(2).map_err(fail)?;
+        flows.push(Flow { name, call });
     }
+
+    let baseline = match table.get::<Value>("baseline").map_err(fail)? {
+        Value::Function(call) => Some(call),
+        _ => None,
+    };
+
+    let name = schema.name.clone().unwrap_or_else(|| default_name(path));
+
+    Ok(Config {
+        flows,
+        baseline,
+        schema,
+        name,
+        path: path.to_path_buf(),
+        lua,
+    })
+}
+
+fn default_name(path: &Path) -> String {
+    path.parent()
+        .and_then(Path::file_name)
+        .and_then(std::ffi::OsStr::to_str)
+        .unwrap_or("parallax")
+        .to_owned()
 }
 
 fn load_table(lua: &Lua, path: &Path, source: &str) -> Result<Table, Error> {
@@ -69,7 +121,7 @@ fn load_table(lua: &Lua, path: &Path, source: &str) -> Result<Table, Error> {
         .eval()
         .map_err(fail)?;
 
-    install_module(lua, path).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)?;
@@ -97,10 +149,10 @@ fn load_table(lua: &Lua, path: &Path, source: &str) -> Result<Table, Error> {
 
 /// 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, path: &Path) -> Result<(), mlua::Error> {
+fn install_module(lua: &Lua) -> Result<(), mlua::Error> {
     let module = lua.create_table()?;
     let metatable = lua.create_table()?;
-    metatable.set("__call", parallax_call(lua, path)?)?;
+    metatable.set("__call", parallax_call(lua)?)?;
     module.set_metatable(Some(metatable))?;
 
     let package: Table = lua.globals().get("package")?;
@@ -110,20 +162,19 @@ fn install_module(lua: &Lua, path: &Path) -> Result<(), mlua::Error> {
     Ok(())
 }
 
-/// The __call metamethod: validates the spec table, fills in defaults,
-/// and marks the result so the loader can tell a real config from any
-/// other table.
-fn parallax_call(lua: &Lua, path: &Path) -> Result<Function, mlua::Error> {
-    let call_path = path.to_path_buf();
+/// The __call metamethod: parses and validates the spec table and marks
+/// the result so the loader can tell a real config from any other
+/// table. Errors are raised while the config's stack frame is alive,
+/// so they 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)| {
         let table = match cfg {
             Value::Table(table) => table,
             _ => return Err(error_at(lua, "parallax must be called with a table")),
         };
-        if let Err(message) = validate::validate(&table) {
+        if let Err(message) = validate::parse(&table) {
             return Err(error_at(lua, &message));
         }
-        validate::fill_defaults(&table, &call_path)?;
         let marker = lua.create_table()?;
         marker.set("__parallax_config", true)?;
         table.set_metatable(Some(marker))?;
@@ -133,7 +184,7 @@ fn parallax_call(lua: &Lua, path: &Path) -> Result<Function, mlua::Error> {
 
 fn error_at(lua: &Lua, message: &str) -> mlua::Error {
     let position = caller_position(lua).unwrap_or_else(|| "?".to_string());
-    mlua::Error::RuntimeError(format!("{position}: {message}"))
+    mlua::Error::runtime(format!("{position}: {message}"))
 }
 
 /// Walks the Lua stack for the config's main chunk so errors point at
diff --git a/src/run.rs b/src/run.rs
index b3c0473..b6437ab 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -5,7 +5,7 @@ use crate::Error;
 
 pub fn list(path: &Path) -> Result<(), Error> {
     let config = Config::load(path)?;
-    for name in config.flow_names()? {
+    for name in config.flow_names() {
         println!("{name}");
     }
     Ok(())
diff --git a/src/validate.rs b/src/validate.rs
index 0cf90e0..7d37973 100644
--- a/src/validate.rs
+++ b/src/validate.rs
@@ -1,57 +1,102 @@
 #![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
 
-use std::path::Path;
-
 use mlua::Table;
 use mlua::Value;
+use serde::Deserialize;
+use serde::de::IgnoredAny;
+
+/// The scalar skeleton of a config, deserialized from the validated
+/// table. mlua's deserializer skips function values inside sequences,
+/// so `flows` — whose entries hold Lua functions — is declared as
+/// ignored and validated by hand from the table instead. `baseline`
+/// sits at a map position, where functions pass through as unit values,
+/// so it only records presence; its type is also checked by hand.
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Schema {
+    pub(crate) viewports: Vec<(String, u32, u32)>,
+    pub(crate) server: Server,
+
+    #[serde(default)]
+    #[allow(dead_code)]
+    pub(crate) baseline: Option<IgnoredAny>,
+
+    #[allow(dead_code)]
+    pub(crate) flows: IgnoredAny,
+    #[serde(default)]
+    pub(crate) name: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Server {
+    pub(crate) cmd: Vec<String>,
+
+    #[serde(default = "default_timeout")]
+    #[allow(dead_code)]
+    pub(crate) timeout: f64,
+}
+
+fn default_timeout() -> f64 {
+    30.0
+}
 
-/// Validates a config table, returning a bare message on failure; the
-/// caller prefixes the call-site position. Mirrors the Config section of
-/// docs/plans/2026-09-25-parallax-design.md.
-pub fn validate(cfg: &Table) -> Result<(), String> {
-    let viewports = required_table(
-        cfg,
-        "viewports",
-        ":viewports must be a non-empty sequence of [name width height] triples",
-    )?;
-    if viewports.raw_len() == 0 {
+/// Parses and validates a config table, returning a bare message on
+/// failure; the caller prefixes the call-site position.
+pub fn parse(table: &Table) -> Result<Schema, String> {
+    let options = mlua::serde::DeserializeOptions::default().deny_unsupported_types(false);
+    let deserializer =
+        mlua::serde::Deserializer::new_with_options(Value::Table(table.clone()), options);
+    let schema: Schema =
+        serde_path_to_error::deserialize(deserializer).map_err(|error| error.to_string())?;
+
+    check_viewports(&schema)?;
+    check_server(&schema)?;
+    check_baseline(table)?;
+    check_flows(table)?;
+    Ok(schema)
+}
+
+fn check_viewports(schema: &Schema) -> Result<(), String> {
+    if schema.viewports.is_empty() {
         return Err(
             ":viewports must be a non-empty sequence of [name width height] triples".into(),
         );
     }
-    for index in 1..=viewports.raw_len() {
-        let valid = match viewports.get::<Value>(index) {
-            Ok(Value::Table(entry)) => valid_viewport(&entry),
-            _ => false,
-        };
-        if !valid {
+    for ((name, width, height), position) in schema.viewports.iter().zip(1usize..) {
+        if name.is_empty() || *width == 0 || *height == 0 {
             return Err(format!(
-                ":viewports[{index}] must be a [name width height] triple with a string name and positive integer dimensions"
+                ":viewports[{position}] must be a [name width height] triple with a string name and positive integer dimensions"
             ));
         }
     }
+    Ok(())
+}
 
-    let server = required_table(
-        cfg,
-        "server",
-        ":server must be a table with :cmd and optionally :timeout",
-    )?;
-    validate_server(&server)?;
+fn check_server(schema: &Schema) -> Result<(), String> {
+    if schema.server.cmd.is_empty() {
+        return Err(":server.cmd must be a non-empty sequence of strings".into());
+    }
+    Ok(())
+}
 
-    match field(cfg, "baseline")? {
-        Value::Nil | Value::Function(_) => {}
-        _ => return Err(":baseline must be a function".into()),
+fn check_baseline(table: &Table) -> Result<(), String> {
+    match table.get::<Value>("baseline") {
+        Ok(Value::Nil) | Ok(Value::Function(_)) => Ok(()),
+        _ => Err(":baseline must be a function".into()),
     }
+}
 
-    let flows = required_table(
-        cfg,
-        "flows",
-        ":flows must be a sequence of [name function] pairs",
-    )?;
+fn check_flows(table: &Table) -> Result<(), String> {
+    let flows = match table.get::<Value>("flows") {
+        Ok(Value::Table(flows)) => flows,
+        _ => return Err(":flows must be a sequence of [name function] pairs".into()),
+    };
     for index in 1..=flows.raw_len() {
         let valid = match flows.get::<Value>(index) {
-            Ok(Value::Table(entry)) => {
-                nonempty_string(&at(&entry, 1)) && matches!(at(&entry, 2), Value::Function(_))
+            Ok(Value::Table(pair)) => {
+                nonempty_string(&pair.get::<Value>(1).unwrap_or(Value::Nil))
+                    && matches!(pair.get::<Value>(2), Ok(Value::Function(_)))
             }
             _ => false,
         };
@@ -59,84 +104,13 @@ pub fn validate(cfg: &Table) -> Result<(), String> {
             return Err(format!(":flows[{index}] must be a [name function] pair"));
         }
     }
-
-    match field(cfg, "name")? {
-        Value::Nil | Value::String(_) => {}
-        _ => return Err(":name must be a string".into()),
-    }
-
-    Ok(())
-}
-
-/// Fills in the defaults the design assigns to the parallax call:
-/// `:server.timeout` and `:name`.
-pub fn fill_defaults(cfg: &Table, path: &Path) -> Result<(), mlua::Error> {
-    let server: Table = cfg.get("server")?;
-    if server.get::<Value>("timeout")?.is_nil() {
-        server.set("timeout", 30)?;
-    }
-    if cfg.get::<Value>("name")?.is_nil() {
-        let name = path
-            .parent()
-            .and_then(Path::file_name)
-            .and_then(std::ffi::OsStr::to_str)
-            .unwrap_or("parallax")
-            .to_owned();
-        cfg.set("name", name)?;
-    }
     Ok(())
 }
 
-fn validate_server(server: &Table) -> Result<(), String> {
-    let cmd = match server.get::<Value>("cmd") {
-        Ok(Value::Table(cmd)) if cmd.raw_len() > 0 => cmd,
-        _ => return Err(":server.cmd must be a non-empty sequence of strings".into()),
-    };
-    for index in 1..=cmd.raw_len() {
-        if !matches!(cmd.get::<Value>(index), Ok(Value::String(_))) {
-            return Err(format!(":server.cmd[{index}] must be a string"));
-        }
-    }
-    match field(server, "timeout")? {
-        Value::Nil | Value::Integer(_) | Value::Number(_) => Ok(()),
-        _ => Err(":server.timeout must be a number".into()),
-    }
-}
-
-fn valid_viewport(entry: &Table) -> bool {
-    nonempty_string(&at(entry, 1))
-        && positive_integer(&at(entry, 2))
-        && positive_integer(&at(entry, 3))
-}
-
-fn required_table(cfg: &Table, key: &str, message: &str) -> Result<Table, String> {
-    match field(cfg, key)? {
-        Value::Nil => Err(format!("missing :{key}")),
-        Value::Table(table) => Ok(table),
-        _ => Err(message.to_owned()),
-    }
-}
-
-fn field(parent: &Table, key: &str) -> Result<Value, String> {
-    parent.get::<Value>(key).map_err(|error| error.to_string())
-}
-
-fn at(parent: &Table, index: i64) -> Value {
-    parent.get::<Value>(index).unwrap_or(Value::Nil)
-}
-
 fn nonempty_string(value: &Value) -> bool {
     matches!(value, Value::String(string) if !string.as_bytes().is_empty())
 }
 
-fn positive_integer(value: &Value) -> bool {
-    match value {
-        Value::Integer(n) => *n > 0,
-        Value::Number(n) => *n > 0.0 && n.fract() == 0.0,
-        _ => false,
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -146,7 +120,7 @@ mod tests {
     const SERVER: &str = r#"server = {cmd = {"true"}}"#;
     const FLOWS: &str = r#"flows = {{"flow", function() end}}"#;
 
-    fn with_config<T>(body: &str, check: impl FnOnce(&Table) -> T) -> T {
+    fn with_table<T>(body: &str, check: impl FnOnce(&Table) -> T) -> T {
         let lua = unsafe { Lua::unsafe_new() };
         let table: Table = lua
             .load(format!("return {{{body}}}"))
@@ -155,9 +129,9 @@ mod tests {
         check(&table)
     }
 
-    fn assert_invalid(body: &str, fragment: &str) {
-        with_config(body, |table| {
-            let error = validate(table).expect_err("expected an invalid config");
+    fn assert_message(body: &str, fragment: &str) {
+        with_table(body, |table| {
+            let error = parse(table).expect_err("expected an invalid config");
             assert!(
                 error.contains(fragment),
                 "expected {error:?} to contain {fragment:?}"
@@ -167,103 +141,122 @@ mod tests {
 
     #[test]
     fn accepts_a_valid_config() {
-        with_config(&format!("{VIEWPORTS}, {SERVER}, {FLOWS}"), |table| {
-            assert!(validate(table).is_ok())
+        with_table(&format!("{VIEWPORTS}, {SERVER}, {FLOWS}"), |table| {
+            let schema = parse(table).expect("valid config");
+            assert_eq!(schema.viewports.len(), 1);
+            assert_eq!(schema.server.timeout, 30.0);
+            assert_eq!(schema.name, None);
         });
     }
 
     #[test]
     fn accepts_an_empty_flow_list() {
-        with_config(&format!("{VIEWPORTS}, {SERVER}, flows = {{}}"), |table| {
-            assert!(validate(table).is_ok())
+        with_table(&format!("{VIEWPORTS}, {SERVER}, flows = {{}}"), |table| {
+            parse(table).expect("valid config");
         });
     }
 
     #[test]
-    fn reports_missing_keys() {
-        assert_invalid(&format!("{SERVER}, {FLOWS}"), "missing :viewports");
-        assert_invalid(&format!("{VIEWPORTS}, {FLOWS}"), "missing :server");
-        assert_invalid(&format!("{VIEWPORTS}, {SERVER}"), "missing :flows");
+    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_with_paths() {
+        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_viewports() {
-        assert_invalid(
+        assert_message(
             &format!("viewports = {{}}, {SERVER}, {FLOWS}"),
             ":viewports must be a non-empty sequence",
         );
     }
 
     #[test]
-    fn rejects_malformed_viewports() {
+    fn rejects_nonpositive_or_unnamed_viewports() {
         let cases = [
-            r#"viewports = {{"desktop"}}"#,
-            r#"viewports = {{"desktop", 1280}}"#,
-            r#"viewports = {{"", 1280, 900}}"#,
             r#"viewports = {{"desktop", 0, 900}}"#,
-            r#"viewports = {{"desktop", -1280, 900}}"#,
-            r#"viewports = {{"desktop", 1280.5, 900}}"#,
-            r#"viewports = {{1280, 900}}"#,
-            r#"viewports = "desktop""#,
+            r#"viewports = {{"desktop", 1280, 0}}"#,
+            r#"viewports = {{"", 1280, 900}}"#,
         ];
         for case in cases {
-            let error = with_config(&format!("{case}, {SERVER}, {FLOWS}"), |table| {
-                validate(table)
-            })
-            .expect_err("expected an invalid viewport");
-            assert!(
-                error.contains("must be a [name width height] triple")
-                    || error.contains(":viewports must be a non-empty sequence"),
-                "unexpected error for {case}: {error}"
+            assert_message(
+                &format!("{case}, {SERVER}, {FLOWS}"),
+                ":viewports[1] must be a [name width height] triple",
             );
         }
     }
 
     #[test]
-    fn rejects_malformed_servers() {
-        assert_invalid(
+    fn rejects_an_empty_server_cmd() {
+        assert_message(
             &format!("{VIEWPORTS}, server = {{cmd = {{}}}}, {FLOWS}"),
-            ":server.cmd must be a non-empty sequence",
-        );
-        assert_invalid(
-            &format!(r#"{VIEWPORTS}, server = {{cmd = {{"true", 2}}}}, {FLOWS}"#),
-            ":server.cmd[2] must be a string",
-        );
-        assert_invalid(
-            &format!(r#"{VIEWPORTS}, server = {{cmd = {{"true"}}, timeout = "30"}}, {FLOWS}"#),
-            ":server.timeout must be a number",
-        );
-        with_config(
-            &format!(r#"{VIEWPORTS}, server = {{cmd = {{"true"}}, timeout = 5}}, {FLOWS}"#),
-            |table| assert!(validate(table).is_ok()),
+            ":server.cmd must be a non-empty sequence of strings",
         );
     }
 
     #[test]
-    fn rejects_malformed_optional_fields() {
-        assert_invalid(
-            &format!("{VIEWPORTS}, {SERVER}, {FLOWS}, baseline = \"nope\""),
+    fn rejects_a_non_function_baseline() {
+        assert_message(
+            &format!(r#"{VIEWPORTS}, {SERVER}, {FLOWS}, baseline = "nope""#),
             ":baseline must be a function",
         );
-        assert_invalid(
-            &format!("{VIEWPORTS}, {SERVER}, {FLOWS}, name = 42"),
-            ":name must be a string",
+    }
+
+    #[test]
+    fn accepts_a_function_baseline() {
+        with_table(
+            &format!("{VIEWPORTS}, {SERVER}, {FLOWS}, baseline = function() end"),
+            |table| {
+                parse(table).expect("valid config");
+            },
         );
-        with_config(
-            &format!("{VIEWPORTS}, {SERVER}, {FLOWS}, name = \"ketchup\""),
-            |table| assert!(validate(table).is_ok()),
+    }
+
+    #[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_flows() {
+    fn rejects_malformed_flow_pairs() {
         let cases = [
             r#"flows = {{"flow", "not-a-function"}}"#,
-            r#"flows = {{42, function() end}}"#,
             r#"flows = {{"", function() end}}"#,
+            r#"flows = {"flow"}"#,
         ];
         for case in cases {
-            assert_invalid(
+            assert_message(
                 &format!("{VIEWPORTS}, {SERVER}, {case}"),
                 ":flows[1] must be a [name function] pair",
             );
@@ -271,27 +264,15 @@ mod tests {
     }
 
     #[test]
-    fn fill_defaults_sets_timeout_and_name() {
-        with_config(&format!("{VIEWPORTS}, {SERVER}, {FLOWS}"), |table| {
-            fill_defaults(table, Path::new("/cache/ketchup/parallax.fnl")).expect("fill defaults");
-            let server: Table = table.get("server").expect("server");
-            assert_eq!(server.get::<i64>("timeout").expect("timeout"), 30);
-            assert_eq!(table.get::<String>("name").expect("name"), "ketchup");
-        });
-    }
-
-    #[test]
-    fn fill_defaults_preserves_existing_values() {
-        with_config(
+    fn keeps_explicit_timeout_and_name() {
+        with_table(
             &format!(
-                "{VIEWPORTS}, server = {{cmd = {{\"true\"}}, timeout = 5}}, flows = {{}}, name = \"custom\""
+                r#"{VIEWPORTS}, server = {{cmd = {{"true"}}, timeout = 5}}, {FLOWS}, name = "ketchup""#
             ),
             |table| {
-                fill_defaults(table, Path::new("/cache/ketchup/parallax.fnl"))
-                    .expect("fill defaults");
-                let server: Table = table.get("server").expect("server");
-                assert_eq!(server.get::<i64>("timeout").expect("timeout"), 5);
-                assert_eq!(table.get::<String>("name").expect("name"), "custom");
+                let schema = parse(table).expect("valid config");
+                assert_eq!(schema.server.timeout, 5.0);
+                assert_eq!(schema.name.as_deref(), Some("ketchup"));
             },
         );
     }