Validate configs in Rust
Assisted-by: GLM-5.3 via pi
change uozkvklzkkwqsqxonkvrtwzlswuwulmr
commit 742f3cd79ad2e84c2968511ee08dd5745e1628ba
author Alpha Chen <alpha@kejadlen.dev>
date
parent xlryppqq
diff --git a/src/config.rs b/src/config.rs
index 916f3f4..12cef26 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,14 +1,15 @@
 use std::path::Path;
 use std::path::PathBuf;
 
-use crate::Error;
 use mlua::Function;
 use mlua::Lua;
 use mlua::Table;
 use mlua::Value;
 
+use crate::Error;
+use crate::validate;
+
 const FENNEL_LUA: &str = include_str!("../vendor/fennel.lua");
-const PARALLAX_FNL: &str = include_str!("parallax.fnl");
 
 /// 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.
@@ -21,18 +22,6 @@ pub struct Config {
 }
 
 impl Config {
-    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);
-        }
-        Ok(names)
-    }
-
     pub fn load(path: &Path) -> Result<Config, Error> {
         let source = fs_err::read_to_string(path)?;
         // The Fennel compiler requires the debug library, which mlua's
@@ -45,27 +34,19 @@ impl Config {
             lua,
             path: path.to_path_buf(),
         };
-        config.fill_default_name()?;
         Ok(config)
     }
 
-    fn fill_default_name(&self) -> Result<(), Error> {
-        if self
-            .table
-            .get::<Value>("name")
-            .map_err(self.fail())?
-            .is_nil()
-        {
-            let name = self
-                .path
-                .parent()
-                .and_then(Path::file_name)
-                .and_then(std::ffi::OsStr::to_str)
-                .unwrap_or("parallax")
-                .to_owned();
-            self.table.set("name", name).map_err(self.fail())?;
+    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);
         }
-        Ok(())
+        Ok(names)
     }
 
     fn fail(&self) -> impl Fn(mlua::Error) -> Error + '_ {
@@ -88,13 +69,7 @@ fn load_table(lua: &Lua, path: &Path, source: &str) -> Result<Table, Error> {
         .eval()
         .map_err(fail)?;
 
-    let eval: Function = fennel.get("eval").map_err(fail)?;
-    let parallax: Table =
-        match eval_fennel(lua, &eval, PARALLAX_FNL, "parallax.fnl").map_err(fail)? {
-            Value::Table(table) => table,
-            _ => return Err(Error::module_not_a_table(path)),
-        };
-    install_preload(lua, &parallax).map_err(fail)?;
+    install_module(lua, path).map_err(fail)?;
 
     let filename = path.to_string_lossy().into_owned();
     let value = run_config(lua, &fennel, source, &filename).map_err(fail)?;
@@ -120,15 +95,63 @@ fn load_table(lua: &Lua, path: &Path, source: &str) -> Result<Table, Error> {
     Ok(table)
 }
 
-fn eval_fennel(
-    lua: &Lua,
-    eval: &Function,
-    source: &str,
-    filename: &str,
-) -> Result<Value, mlua::Error> {
-    let options = lua.create_table()?;
-    options.set("filename", filename)?;
-    eval.call((source, options))
+/// 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> {
+    let module = lua.create_table()?;
+    let metatable = lua.create_table()?;
+    metatable.set("__call", parallax_call(lua, path)?)?;
+    module.set_metatable(Some(metatable))?;
+
+    let package: Table = lua.globals().get("package")?;
+    let preload: Table = package.get("preload")?;
+    let lookup = lua.create_function(move |_, _name: String| Ok(module.clone()))?;
+    preload.set("parallax", lookup)?;
+    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();
+    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) {
+            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))?;
+        Ok(table)
+    })
+}
+
+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}"))
+}
+
+/// 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.
+fn caller_position(lua: &Lua) -> Option<String> {
+    let debug: Table = lua.globals().get("debug").ok()?;
+    let getinfo: Function = debug.get("getinfo").ok()?;
+    let mut level: i64 = 2;
+    loop {
+        let info: Table = getinfo.call((level, "Sl")).ok()?;
+        if info.get::<String>("what").ok()? == "main" {
+            let source = info.get::<String>("short_src").ok()?;
+            let line = info.get::<i64>("currentline").unwrap_or(0);
+            return Some(format!("{source}:{line}"));
+        }
+        level = level.saturating_add(1);
+    }
 }
 
 fn run_config(
@@ -180,12 +203,3 @@ fn protect_tail_call(compiled: &str) -> String {
         }
     }
 }
-
-fn install_preload(lua: &Lua, module: &Table) -> Result<(), mlua::Error> {
-    let package: Table = lua.globals().get("package")?;
-    let preload: Table = package.get("preload")?;
-    let module = module.clone();
-    let lookup = lua.create_function(move |_, _name: String| Ok(module.clone()))?;
-    preload.set("parallax", lookup)?;
-    Ok(())
-}
diff --git a/src/error.rs b/src/error.rs
index c1b45dd..cc5c6fa 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,4 +1,3 @@
-use std::path::Path;
 use std::path::PathBuf;
 
 use miette::Diagnostic;
@@ -34,15 +33,3 @@ pub enum Error {
     )]
     NotImplemented,
 }
-
-impl Error {
-    /// The embedded parallax module compiled to something other than a
-    /// table — a parallax bug, not a config problem, but it surfaces
-    /// through the same path as config errors.
-    pub(crate) fn module_not_a_table(path: &Path) -> Self {
-        Error::Config {
-            path: path.to_path_buf(),
-            error: mlua::Error::runtime("the embedded parallax module must be a table"),
-        }
-    }
-}
diff --git a/src/lib.rs b/src/lib.rs
index 3b3e0e2..ddcda0c 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,6 +1,7 @@
 mod config;
 mod error;
 mod run;
+mod validate;
 
 pub use config::Config;
 pub use error::Error;
diff --git a/src/parallax.fnl b/src/parallax.fnl
deleted file mode 100644
index 94c5703..0000000
--- a/src/parallax.fnl
+++ /dev/null
@@ -1,112 +0,0 @@
-;; The parallax module, embedded in the binary. A config requires it
-;; and returns the result of calling it with a spec table; the call
-;; validates the table, fills in defaults, and marks the result so the
-;; loader can tell a real config from any other table. See
-;; docs/plans/2026-09-25-parallax-design.md, Config.
-;;
-;; Validation errors are raised with error level 2 from inside __call,
-;; so Lua blames the (parallax {...}) call site in the config file.
-
-;; Validation errors carry the position of the (parallax {...}) call
-;; site, found by walking up to the config's main chunk. error's own
-;; level argument can't cross the __call C boundary reliably.
-(fn caller-position []
-  (var position "?")
-  (var level 2)
-  (var searching true)
-  (while searching
-    (let [info (debug.getinfo level "Sl")]
-      (if (= nil info)
-          (set searching false)
-          (when (= (. info :what) :main)
-            (set position (.. (. info :short_src) ":" (or (. info :currentline) 0)))
-            (set searching false))))
-    (set level (+ level 1)))
-  position)
-
-(fn raise [msg]
-  (error (.. (caller-position) ": " msg) 0))
-
-(fn first-error [items validate]
-  (var err nil)
-  (each [i item (ipairs items) &until err]
-    (set err (validate i item)))
-  err)
-
-(fn nonempty-string? [v]
-  (and (= (type v) :string) (not= v "")))
-
-(fn positive-integer? [v]
-  (and (= (type v) :number) (> v 0) (= v (math.floor v))))
-
-(fn valid-flow? [v]
-  (and (= (type v) :table)
-       (nonempty-string? (. v 1))
-       (= (type (. v 2)) :function)))
-
-(fn validate-viewports [viewports]
-  (or (when (or (not= (type viewports) :table) (< (# viewports) 1))
-        ":viewports must be a non-empty sequence of [name width height] triples")
-      (first-error viewports
-        (fn [i v]
-          (when (not (and (= (type v) :table)
-                          (nonempty-string? (. v 1))
-                          (positive-integer? (. v 2))
-                          (positive-integer? (. v 3))))
-            (.. ":viewports[" i "] must be a [name width height] triple with a string name and positive integer dimensions"))))))
-
-(fn validate-server [server]
-  (or (when (not= (type server) :table)
-        ":server must be a table with :cmd and optionally :timeout")
-      (let [cmd (. server :cmd)]
-        (or (when (or (not= (type cmd) :table) (< (# cmd) 1))
-              ":server.cmd must be a non-empty sequence of strings")
-            (first-error cmd
-              (fn [i arg]
-                (when (not= (type arg) :string)
-                  (.. ":server.cmd[" i "] must be a string"))))))
-      (let [timeout (. server :timeout)]
-        (when (and (not= nil timeout) (not= (type timeout) :number))
-          ":server.timeout must be a number"))))
-
-(fn validate-flows [flows]
-  (if (not= (type flows) :table)
-      ":flows must be a sequence of [name function] pairs"
-      (first-error flows
-        (fn [i v]
-          (when (not (valid-flow? v))
-            (.. ":flows[" i "] must be a [name function] pair"))))))
-
-(fn validate [cfg]
-  (or (when (not= (type cfg) :table)
-        "parallax must be called with a table")
-      (when (= nil (. cfg :viewports))
-        "missing :viewports")
-      (validate-viewports (. cfg :viewports))
-      (when (= nil (. cfg :server))
-        "missing :server")
-      (validate-server (. cfg :server))
-      (let [baseline (. cfg :baseline)]
-        (when (and (not= nil baseline) (not= (type baseline) :function))
-          ":baseline must be a function"))
-      (when (= nil (. cfg :flows))
-        "missing :flows")
-      (validate-flows (. cfg :flows))
-      (let [name (. cfg :name)]
-        (when (and (not= nil name) (not= (type name) :string))
-          ":name must be a string"))))
-
-(local module {})
-
-(setmetatable module
-  {:__call
-   (fn [_ cfg]
-     (let [err (validate cfg)]
-       (when err
-         (raise err)))
-     (setmetatable cfg {:__parallax_config true})
-     (when (= nil (. cfg :server :timeout))
-       (set cfg.server.timeout 30))
-     cfg)})
-
-module
diff --git a/src/validate.rs b/src/validate.rs
new file mode 100644
index 0000000..0cf90e0
--- /dev/null
+++ b/src/validate.rs
@@ -0,0 +1,298 @@
+#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
+
+use std::path::Path;
+
+use mlua::Table;
+use mlua::Value;
+
+/// 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 {
+        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 {
+            return Err(format!(
+                ":viewports[{index}] must be a [name width height] triple with a string name and positive integer dimensions"
+            ));
+        }
+    }
+
+    let server = required_table(
+        cfg,
+        "server",
+        ":server must be a table with :cmd and optionally :timeout",
+    )?;
+    validate_server(&server)?;
+
+    match field(cfg, "baseline")? {
+        Value::Nil | Value::Function(_) => {}
+        _ => return Err(":baseline must be a function".into()),
+    }
+
+    let flows = required_table(
+        cfg,
+        "flows",
+        ":flows must be a sequence of [name function] pairs",
+    )?;
+    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(_))
+            }
+            _ => false,
+        };
+        if !valid {
+            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::*;
+    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_config<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_invalid(body: &str, fragment: &str) {
+        with_config(body, |table| {
+            let error = validate(table).expect_err("expected an invalid config");
+            assert!(
+                error.contains(fragment),
+                "expected {error:?} to contain {fragment:?}"
+            );
+        });
+    }
+
+    #[test]
+    fn accepts_a_valid_config() {
+        with_config(&format!("{VIEWPORTS}, {SERVER}, {FLOWS}"), |table| {
+            assert!(validate(table).is_ok())
+        });
+    }
+
+    #[test]
+    fn accepts_an_empty_flow_list() {
+        with_config(&format!("{VIEWPORTS}, {SERVER}, flows = {{}}"), |table| {
+            assert!(validate(table).is_ok())
+        });
+    }
+
+    #[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");
+    }
+
+    #[test]
+    fn rejects_empty_viewports() {
+        assert_invalid(
+            &format!("viewports = {{}}, {SERVER}, {FLOWS}"),
+            ":viewports must be a non-empty sequence",
+        );
+    }
+
+    #[test]
+    fn rejects_malformed_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""#,
+        ];
+        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}"
+            );
+        }
+    }
+
+    #[test]
+    fn rejects_malformed_servers() {
+        assert_invalid(
+            &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()),
+        );
+    }
+
+    #[test]
+    fn rejects_malformed_optional_fields() {
+        assert_invalid(
+            &format!("{VIEWPORTS}, {SERVER}, {FLOWS}, baseline = \"nope\""),
+            ":baseline must be a function",
+        );
+        assert_invalid(
+            &format!("{VIEWPORTS}, {SERVER}, {FLOWS}, name = 42"),
+            ":name must be a string",
+        );
+        with_config(
+            &format!("{VIEWPORTS}, {SERVER}, {FLOWS}, name = \"ketchup\""),
+            |table| assert!(validate(table).is_ok()),
+        );
+    }
+
+    #[test]
+    fn rejects_malformed_flows() {
+        let cases = [
+            r#"flows = {{"flow", "not-a-function"}}"#,
+            r#"flows = {{42, function() end}}"#,
+            r#"flows = {{"", function() end}}"#,
+        ];
+        for case in cases {
+            assert_invalid(
+                &format!("{VIEWPORTS}, {SERVER}, {case}"),
+                ":flows[1] must be a [name function] pair",
+            );
+        }
+    }
+
+    #[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(
+            &format!(
+                "{VIEWPORTS}, server = {{cmd = {{\"true\"}}, timeout = 5}}, flows = {{}}, name = \"custom\""
+            ),
+            |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");
+            },
+        );
+    }
+}