Convert configs through FromLua impls
Every conversion is a trait impl on the type it produces: Spec wraps
serde for the scalar skeleton (mlua has no FromLua bridge for serde
types, and coherence forbids one) and converts the function-bearing
fields through mlua directly, since serde has no slot for functions.
validate.rs becomes spec.rs and the parse_ functions are gone.

Assisted-by: GLM-5.3 via pi
change wkopkvmpnoxmkpxxsqyvpoqskqzsnzzr
commit dd67f0b85bc5022cb85823922c8e891ea6977dfc
author Alpha Chen <alpha@kejadlen.dev>
date
parent qkozszov
diff --git a/Cargo.lock b/Cargo.lock
index c9e3a12..f349bd8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -411,12 +411,6 @@ 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"
@@ -644,7 +638,6 @@ dependencies = [
  "mlua",
  "predicates",
  "serde",
- "serde_path_to_error",
  "tempfile",
  "thiserror",
  "tokio",
@@ -861,17 +854,6 @@ 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"
diff --git a/Cargo.toml b/Cargo.toml
index 0ccf915..e05c244 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,7 +14,6 @@ fs-err = "*"
 miette = { version = "*", features = ["fancy"] }
 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/lib.rs b/src/lib.rs
index 9311129..e4234c5 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,6 +1,6 @@
 mod error;
 mod parallax;
-mod validate;
+mod spec;
 
 pub use error::Error;
 pub use parallax::Parallax;
diff --git a/src/parallax.rs b/src/parallax.rs
index 6210dd1..42c5434 100644
--- a/src/parallax.rs
+++ b/src/parallax.rs
@@ -1,37 +1,28 @@
 use std::path::Path;
 use std::path::PathBuf;
 
+use mlua::FromLua;
 use mlua::Function;
 use mlua::Lua;
 use mlua::Table;
 use mlua::Value;
 
 use crate::Error;
-use crate::validate;
+use crate::spec::Flow;
+use crate::spec::Spec;
 
 const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
 
-/// A flow: its name from the config and the Lua function to call when
-/// the parallax runs.
-pub struct Flow {
-    pub name: String,
-    pub call: Function,
-}
-
-/// A loaded parallax: the executable form of a config. The flows and
-/// baseline 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.
+/// 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.
 pub struct Parallax {
-    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.
+    // Consumed once running configs lands: the flows and baseline get
+    // called, and the schema drives viewport loops and the server
+    // supervisor.
     #[allow(dead_code)]
-    baseline: Option<Function>,
-    #[allow(dead_code)]
-    schema: validate::Schema,
+    spec: Spec,
     name: String,
     #[allow(dead_code)]
     path: PathBuf,
@@ -51,7 +42,7 @@ impl Parallax {
     }
 
     pub fn flows(&self) -> &[Flow] {
-        &self.flows
+        self.spec.flows.as_slice()
     }
 
     pub fn name(&self) -> &str {
@@ -64,37 +55,23 @@ impl Parallax {
     }
 }
 
-/// 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.
+/// 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 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));
+    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 {
-        flows,
-        baseline,
-        schema,
+        spec,
         name,
         path: path.to_path_buf(),
         lua,
@@ -162,19 +139,18 @@ fn install_module(lua: &Lua) -> Result<(), mlua::Error> {
     Ok(())
 }
 
-/// 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.
+/// 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)| {
-        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::parse(&table) {
-            return Err(error_at(lua, &message));
+        if let Err(error) = Spec::from_lua(cfg.clone(), lua) {
+            return Err(error_at(lua, &error_message(&error)));
         }
+        let Value::Table(table) = cfg else {
+            return Err(error_at(lua, "parallax must be called with a table"));
+        };
         let marker = lua.create_table()?;
         marker.set("__parallax_config", true)?;
         table.set_metatable(Some(marker))?;
@@ -187,6 +163,15 @@ fn error_at(lua: &Lua, message: &str) -> mlua::Error {
     mlua::Error::runtime(format!("{position}: {message}"))
 }
 
+/// Spec 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(),
+        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,
 /// where that frame is still alive.
@@ -213,7 +198,7 @@ fn run_config(
 ) -> 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 validation errors. Turning the final return into
+    // 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.
     let compile: Function = fennel.get("compile-string")?;
diff --git a/src/spec.rs b/src/spec.rs
new file mode 100644
index 0000000..01a8569
--- /dev/null
+++ b/src/spec.rs
@@ -0,0 +1,371 @@
+#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
+
+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)]
+pub struct NonEmptyString(String);
+
+impl NonEmptyString {
+    pub fn new(value: String) -> Option<Self> {
+        (!value.is_empty()).then_some(NonEmptyString(value))
+    }
+
+    pub fn as_str(&self) -> &str {
+        &self.0
+    }
+}
+
+impl fmt::Display for NonEmptyString {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(&self.0)
+    }
+}
+
+impl<'de> Deserialize<'de> for NonEmptyString {
+    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+        let value = String::deserialize(deserializer)?;
+        NonEmptyString::new(value).ok_or_else(|| serde::de::Error::custom("empty string"))
+    }
+}
+
+/// A non-empty sequence of values, rejected at deserialization time.
+#[derive(Debug)]
+pub(crate) struct NonEmpty<T>(Vec<T>);
+
+impl<T> NonEmpty<T> {
+    // Consumed once running configs lands.
+    #[allow(dead_code)]
+    pub(crate) fn as_slice(&self) -> &[T] {
+        &self.0
+    }
+}
+
+impl<'de, T: Deserialize<'de>> Deserialize<'de> for NonEmpty<T> {
+    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+        let items = Vec::<T>::deserialize(deserializer)?;
+        if items.is_empty() {
+            return Err(serde::de::Error::custom("must not be empty"));
+        }
+        Ok(NonEmpty(items))
+    }
+}
+
+/// A flow: its name from the config and the Lua function to call when
+/// the parallax runs.
+#[derive(Debug)]
+pub struct Flow {
+    pub name: NonEmptyString,
+    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>);
+
+impl Baseline {
+    // Called once running configs lands.
+    #[allow(dead_code)]
+    pub(crate) fn as_function(&self) -> Option<&Function> {
+        self.0.as_ref()
+    }
+}
+
+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")),
+        }
+    }
+}
+
+/// The scalar skeleton of a config, deserialized from the validated
+/// table.
+#[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: NonEmpty<(NonEmptyString, NonZeroU32, NonZeroU32)>,
+    pub(crate) server: Server,
+
+    pub(crate) baseline: Option<IgnoredAny>,
+
+    pub(crate) flows: IgnoredAny,
+
+    pub(crate) name: Option<String>,
+}
+
+#[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: NonEmpty<String>,
+
+    #[serde(default = "default_timeout")]
+    pub(crate) timeout: f64,
+}
+
+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,
+                }
+            })?;
+        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::*;
+
+    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 {
+        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:?}"
+            );
+        });
+    }
+
+    #[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.as_slice().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");
+            },
+        );
+    }
+
+    #[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}"),
+            "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}"),
+            "must not be empty",
+        );
+    }
+
+    #[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());
+            },
+        );
+        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",
+            );
+        }
+    }
+
+    #[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"));
+            },
+        );
+    }
+}
diff --git a/src/validate.rs b/src/validate.rs
deleted file mode 100644
index 7d37973..0000000
--- a/src/validate.rs
+++ /dev/null
@@ -1,279 +0,0 @@
-#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
-
-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
-}
-
-/// 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 ((name, width, height), position) in schema.viewports.iter().zip(1usize..) {
-        if name.is_empty() || *width == 0 || *height == 0 {
-            return Err(format!(
-                ":viewports[{position}] must be a [name width height] triple with a string name and positive integer dimensions"
-            ));
-        }
-    }
-    Ok(())
-}
-
-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(())
-}
-
-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()),
-    }
-}
-
-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(pair)) => {
-                nonempty_string(&pair.get::<Value>(1).unwrap_or(Value::Nil))
-                    && matches!(pair.get::<Value>(2), Ok(Value::Function(_)))
-            }
-            _ => false,
-        };
-        if !valid {
-            return Err(format!(":flows[{index}] must be a [name function] pair"));
-        }
-    }
-    Ok(())
-}
-
-fn nonempty_string(value: &Value) -> bool {
-    matches!(value, Value::String(string) if !string.as_bytes().is_empty())
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use mlua::Lua;
-
-    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_table<T>(body: &str, check: impl FnOnce(&Table) -> T) -> T {
-        let lua = unsafe { Lua::unsafe_new() };
-        let table: Table = lua
-            .load(format!("return {{{body}}}"))
-            .eval()
-            .expect("lua table source");
-        check(&table)
-    }
-
-    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:?}"
-            );
-        });
-    }
-
-    #[test]
-    fn accepts_a_valid_config() {
-        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_table(&format!("{VIEWPORTS}, {SERVER}, flows = {{}}"), |table| {
-            parse(table).expect("valid config");
-        });
-    }
-
-    #[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_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_message(
-            &format!("viewports = {{}}, {SERVER}, {FLOWS}"),
-            ":viewports must be a non-empty sequence",
-        );
-    }
-
-    #[test]
-    fn rejects_nonpositive_or_unnamed_viewports() {
-        let cases = [
-            r#"viewports = {{"desktop", 0, 900}}"#,
-            r#"viewports = {{"desktop", 1280, 0}}"#,
-            r#"viewports = {{"", 1280, 900}}"#,
-        ];
-        for case in cases {
-            assert_message(
-                &format!("{case}, {SERVER}, {FLOWS}"),
-                ":viewports[1] must be a [name width height] triple",
-            );
-        }
-    }
-
-    #[test]
-    fn rejects_an_empty_server_cmd() {
-        assert_message(
-            &format!("{VIEWPORTS}, server = {{cmd = {{}}}}, {FLOWS}"),
-            ":server.cmd must be a non-empty sequence of strings",
-        );
-    }
-
-    #[test]
-    fn rejects_a_non_function_baseline() {
-        assert_message(
-            &format!(r#"{VIEWPORTS}, {SERVER}, {FLOWS}, baseline = "nope""#),
-            ":baseline must be a function",
-        );
-    }
-
-    #[test]
-    fn accepts_a_function_baseline() {
-        with_table(
-            &format!("{VIEWPORTS}, {SERVER}, {FLOWS}, baseline = function() end"),
-            |table| {
-                parse(table).expect("valid config");
-            },
-        );
-    }
-
-    #[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",
-            );
-        }
-    }
-
-    #[test]
-    fn keeps_explicit_timeout_and_name() {
-        with_table(
-            &format!(
-                r#"{VIEWPORTS}, server = {{cmd = {{"true"}}, timeout = 5}}, {FLOWS}, name = "ketchup""#
-            ),
-            |table| {
-                let schema = parse(table).expect("valid config");
-                assert_eq!(schema.server.timeout, 5.0);
-                assert_eq!(schema.name.as_deref(), Some("ketchup"));
-            },
-        );
-    }
-}
diff --git a/tests/cli.rs b/tests/cli.rs
index b56c706..06daeff 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -94,7 +94,8 @@ fn validation_error_points_at_the_parallax_call() {
         .assert()
         .failure()
         .stderr(
-            predicate::str::contains("config.fnl:5:").and(predicate::str::contains(":viewports")),
+            predicate::str::contains("config.fnl:5:")
+                .and(predicate::str::contains("must not be empty")),
         );
 }