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
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"));
},
);
}