Validate configs in Rust
Assisted-by: GLM-5.3 via pi
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, ¶llax).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");
+ },
+ );
+ }
+}