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