return ucl.to_format(value, indent and 'json' or 'json-compact')
end
+---
+-- Parses a JSON string and returns a Lua table.
+-- @param s The JSON string to parse.
+-- @usage expand('{%- set obj = \'{"a":1}\' | fromjson %}{{ obj.a }}') --> 1
+-- @name filters.fromjson
+function M.filters.fromjson(s)
+ assert(s ~= nil and s ~= '', 'input to filter "fromjson" was nil or empty')
+ local ucl = require('ucl')
+ local parser = ucl.parser()
+ local ok, err = parser:parse_string(s)
+ if not ok then
+ error(string.format('fromjson: failed to parse: %s', err), 0)
+ end
+ return parser:get_object()
+end
+
---
-- Returns a copy of string *s* truncated to *length* number of characters.
-- Truncated strings end with '...' or string *delimiter*. If boolean
return table.concat(attributes, ' ')
end
+-- Lupa validation filters.
+
+---
+-- Returns the value unchanged, but raises an error if the value is nil or empty.
+-- Use to enforce required env vars at config load time.
+-- @param s The value to validate.
+-- @param msg Optional error message (default: "value is required").
+-- @usage expand('{= env.API_KEY | mandatory("API_KEY is required") =}')
+-- @name filters.mandatory
+function M.filters.mandatory(s, msg)
+ if s == nil or s == '' then
+ error(msg or 'mandatory value is missing', 0)
+ end
+ return s
+end
+
+---
+-- Returns the value unchanged, but raises an error if the value is not a valid integer.
+-- @param s The value to validate.
+-- @param msg Optional error message.
+-- @usage expand('{%- set x = env.PORT | require_int("PORT must be an integer") %}')
+-- @name filters.require_int
+function M.filters.require_int(s, msg)
+ if s == nil or s == '' then
+ error(msg or 'require_int: value is missing', 0)
+ end
+ if not tonumber(s) or tonumber(s) ~= math.floor(tonumber(s)) then
+ error(msg or string.format('require_int: "%s" is not a valid integer', tostring(s)), 0)
+ end
+ return s
+end
+
+---
+-- Returns the value unchanged, but raises an error if the value is not a valid number.
+-- @param s The value to validate.
+-- @param msg Optional error message.
+-- @usage expand('{%- set x = env.PROB | require_number("PROB must be a number") %}')
+-- @name filters.require_number
+function M.filters.require_number(s, msg)
+ if s == nil or s == '' then
+ error(msg or 'require_number: value is missing', 0)
+ end
+ if not tonumber(s) then
+ error(msg or string.format('require_number: "%s" is not a valid number', tostring(s)), 0)
+ end
+ return s
+end
+
+---
+-- Returns the value unchanged, but raises an error if the value is not "true" or "false".
+-- @param s The value to validate.
+-- @param msg Optional error message.
+-- @usage expand('{%- set x = env.ENABLED | require_bool("ENABLED must be true or false") %}')
+-- @name filters.require_bool
+function M.filters.require_bool(s, msg)
+ if s == nil or s == '' then
+ error(msg or 'require_bool: value is missing', 0)
+ end
+ local valid = {['true']=1, ['false']=1, ['yes']=1, ['no']=1, ['on']=1, ['off']=1, ['1']=1, ['0']=1}
+ if not valid[tostring(s):lower()] then
+ error(msg or string.format('require_bool: "%s" is not a valid boolean (use true/false, yes/no, on/off, 1/0)', tostring(s)), 0)
+ end
+ return s
+end
+
+---
+-- Parses a duration string and returns seconds as a number.
+-- Validates the input and raises an error if not a valid duration.
+-- Accepted formats: number followed by ms, s, min, m, h, d, w, y (e.g. "500ms", "30s", "5min", "1h", "10d", "1w", "1y").
+-- Plain numbers are also accepted (treated as seconds).
+-- @param s The duration string to parse.
+-- @param msg Optional error message on invalid input.
+-- @usage expand('{%- set timeout = "5min" | require_duration %}') --> 300
+-- @usage expand('{%- set timeout = "2d" | require_duration %}') --> 172800
+-- @name filters.require_duration
+function M.filters.require_duration(s, msg)
+ if s == nil or s == '' then
+ error(msg or 'require_duration: value is missing', 0)
+ end
+ local str = tostring(s)
+ local num, unit = str:match('^(%d+%.?%d*)(.*)')
+ if not num then
+ error(msg or string.format('require_duration: "%s" is not a valid duration (use 30s, 5min, 1h, 10d)', str), 0)
+ end
+ num = tonumber(num)
+ unit = unit == '' and 's' or unit
+ local seconds
+ if unit == 'ms' then seconds = num / 1000
+ elseif unit == 's' then seconds = num
+ elseif unit == 'min' or unit == 'm' then seconds = num * 60
+ elseif unit == 'h' then seconds = num * 3600
+ elseif unit == 'd' then seconds = num * 86400
+ elseif unit == 'w' then seconds = num * 604800
+ elseif unit == 'y' then seconds = num * 31536000
+ else error(msg or string.format('require_duration: unknown unit "%s" in "%s" (use ms, s, min, h, d, w, y)', unit, str), 0)
+ end
+ return seconds
+end
+
+---
+-- Returns the value unchanged, but raises an error if the value is not valid JSON.
+-- @param s The value to validate.
+-- @param msg Optional error message.
+-- @usage expand('{%- set x = env.LIST | require_json("LIST must be valid JSON") %}')
+-- @name filters.require_json
+function M.filters.require_json(s, msg)
+ if s == nil or s == '' then
+ error(msg or 'require_json: value is missing', 0)
+ end
+ local ucl = require('ucl')
+ local parser = ucl.parser()
+ local ok, err = parser:parse_string(s)
+ if not ok then
+ error(msg or string.format('require_json: "%s" is not valid JSON: %s', tostring(s), err), 0)
+ end
+ return s
+end
+
+---
+-- Returns the value unchanged, but raises an error if the value is not a valid size string.
+-- Accepted formats: number followed by optional b, Kb, Mb, Gb suffix (case-insensitive).
+-- Plain numbers are also accepted (treated as bytes).
+-- @param s The value to validate.
+-- @param msg Optional error message.
+-- @usage expand('{%- set x = env.MAX_SIZE | require_size("MAX_SIZE must be a size like 150Mb") %}')
+-- @name filters.require_size
+function M.filters.require_size(s, msg)
+ if s == nil or s == '' then
+ error(msg or 'require_size: value is missing', 0)
+ end
+ local str = tostring(s)
+ local lower = str:lower()
+ local stripped = lower:gsub('gb$', ''):gsub('mb$', ''):gsub('kb$', ''):gsub('b$', '')
+ if tonumber(stripped) == nil or tonumber(stripped) < 0 then
+ error(msg or string.format('require_size: "%s" is not a valid size (use number with optional b, Kb, Mb, Gb suffix)', str), 0)
+ end
+ return s
+end
+
+---
+-- Converts a size string to bytes (number).
+-- Input: number with optional suffix Kb (1024), Mb (1024^2), Gb (1024^3). Case-insensitive.
+-- Plain numbers are treated as bytes.
+-- @param s The size string to convert.
+-- @usage expand('{%- set bytes = "150Mb" | tobytes %}') --> 157286400
+-- @name filters.tobytes
+function M.filters.tobytes(s)
+ if s == nil or s == '' then
+ error('tobytes: value is missing', 0)
+ end
+ local str = tostring(s)
+ local lower = str:lower()
+ local num, suffix = lower:match('^(%d+%.?%d*)(.*)')
+ if not num then
+ error(string.format('tobytes: "%s" is not a valid size', str), 0)
+ end
+ num = tonumber(num)
+ if suffix == '' or suffix == 'b' then return num
+ elseif suffix == 'kb' then return num * 1024
+ elseif suffix == 'mb' then return num * 1024 * 1024
+ elseif suffix == 'gb' then return num * 1024 * 1024 * 1024
+ else error(string.format('tobytes: unknown suffix "%s" in "%s"', suffix, str), 0)
+ end
+end
+
-- Lupa tests.
+---
+-- Returns whether or not value *s* is valid JSON.
+-- @param s The value to test.
+-- @usage expand('{% if is_json(env.X) %}...{% endif %}')
+-- @name tests.is_json
+function M.tests.is_json(s)
+ if s == nil or s == '' then return false end
+ local ucl = require('ucl')
+ local parser = ucl.parser()
+ local ok = parser:parse_string(s)
+ return ok and true or false
+end
+
+---
+-- Returns whether or not value *s* is a valid size string.
+-- Accepts: plain numbers (bytes) or numbers with b, Kb, Mb, Gb suffix (case-insensitive).
+-- @param s The value to test.
+-- @usage expand('{% if is_size(env.MAX_SIZE) %}...{% endif %}')
+-- @name tests.is_size
+function M.tests.is_size(s)
+ if s == nil or s == '' then return false end
+ local lower = tostring(s):lower()
+ local stripped = lower:gsub('gb$', ''):gsub('mb$', ''):gsub('kb$', ''):gsub('b$', '')
+ return tonumber(stripped) ~= nil and tonumber(stripped) >= 0
+end
+
+---
+-- Returns whether or not value *s* is a UCL truthy boolean (true, yes, on, 1).
+-- Case-insensitive, matching UCL parser behavior.
+-- @param s The value to test.
+-- @usage expand('{% if is_true(env.ENABLED) %}...{% endif %}')
+-- @name tests.is_true
+function M.tests.is_true(s)
+ if s == nil then return false end
+ local truthy = {['true']=1, ['yes']=1, ['on']=1, ['1']=1}
+ return truthy[tostring(s):lower()] ~= nil
+end
+
+---
+-- Returns whether or not value *s* is a UCL falsy boolean (false, no, off, 0).
+-- Case-insensitive, matching UCL parser behavior.
+-- @param s The value to test.
+-- @usage expand('{% if is_false(env.ENABLED) %}...{% endif %}')
+-- @name tests.is_false
+function M.tests.is_false(s)
+ if s == nil then return false end
+ local falsy = {['false']=1, ['no']=1, ['off']=1, ['0']=1}
+ return falsy[tostring(s):lower()] ~= nil
+end
+
---
-- Returns whether or not number *n* is odd.
-- @param n The number to test.
-- @param value The value to test.
-- @usage expand('{% if is_number(x) %}...{% endif %}')
-- @name tests.is_number
-function M.tests.is_number(value) return type(value) == 'number' end
+function M.tests.is_number(value)
+ if type(value) == 'number' then return true end
+ if type(value) == 'string' then return tonumber(value) ~= nil end
+ return false
+end
---
-- Returns whether or not value *value* is a sequence, namely a table with
-- @name tests.is_boolean
function M.tests.is_boolean(value) return type(value) == 'boolean' end
----
--- Returns whether or not value *value* is `true`.
--- @param value The value to test.
--- @usage expand('{% if is_true(x) %}...{% endif %}')
--- @name tests.is_true
-function M.tests.is_true(value) return value == true end
-
----
--- Returns whether or not value *value* is `false`.
--- @param value The value to test.
--- @usage expand('{% if is_false(x) %}...{% endif %}')
--- @name tests.is_false
-function M.tests.is_false(value) return value == false end
-
---
-- Returns whether or not value *value* is an integer number.
-- @param value The value to test.
-- @usage expand('{% if is_integer(x) %}...{% endif %}')
-- @name tests.is_integer
function M.tests.is_integer(value)
- if type(value) ~= 'number' then return false end
- return value == math.floor(value)
+ local n = type(value) == 'number' and value or tonumber(tostring(value))
+ if n == nil then return false end
+ return n == math.floor(n)
end
---
-- @usage expand('{% if is_float(x) %}...{% endif %}')
-- @name tests.is_float
function M.tests.is_float(value)
- if type(value) ~= 'number' then return false end
- return value ~= math.floor(value)
+ local n = type(value) == 'number' and value or tonumber(tostring(value))
+ if n == nil then return false end
+ return n ~= math.floor(n)
end
---
--- /dev/null
+--[[
+Copyright (c) 2026, Namecheap Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+]]--
+
+context("Lupa Jinja template engine unit tests", function()
+ local lupa = require("lupa")
+
+ -- Helper: expand template with optional env table (simulates env vars as strings)
+ local function expand(template, env)
+ return lupa.expand(template, env or {})
+ end
+
+ -- Helper: expand with env vars (all values are strings, like real RSPAMD_ env vars)
+ local function expand_env(template, vars)
+ return lupa.expand(template, { env = vars })
+ end
+
+ -- =========================================================================
+ -- VALIDATION FILTERS (with env var string inputs)
+ -- =========================================================================
+
+ -- mandatory
+ test("mandatory: passes non-empty string", function()
+ assert_equal(expand('{{ "test" | mandatory("fail") }}'), 'test')
+ end)
+
+ test("mandatory: works with env var", function()
+ assert_equal(expand_env('{{ env.KEY | mandatory("KEY required") }}', { KEY = "my_key" }), 'my_key')
+ end)
+
+ test("mandatory: crashes on empty string", function()
+ local ok, err = pcall(expand, '{{ "" | mandatory("VALUE_REQUIRED") }}')
+ assert_false(ok)
+ assert_match('VALUE_REQUIRED', err)
+ end)
+
+ test("mandatory: crashes on nil env var", function()
+ local ok, err = pcall(expand_env, '{{ env.MISSING | mandatory("MISSING_VAR") }}', {})
+ assert_false(ok)
+ assert_match('MISSING_VAR', err)
+ end)
+
+ -- require_int
+ test("require_int: valid integer string", function()
+ assert_equal(expand('{{ "42" | require_int }}'), '42')
+ end)
+
+ test("require_int: works with env var", function()
+ assert_equal(expand_env('{{ env.PORT | require_int }}', { PORT = "8080" }), '8080')
+ end)
+
+ test("require_int: zero is valid", function()
+ assert_equal(expand('{{ "0" | require_int }}'), '0')
+ end)
+
+ test("require_int: negative is valid", function()
+ assert_equal(expand('{{ "-1" | require_int }}'), '-1')
+ end)
+
+ test("require_int: crashes on float string", function()
+ local ok, err = pcall(expand, '{{ "3.14" | require_int("NOT_INT") }}')
+ assert_false(ok)
+ assert_match('NOT_INT', err)
+ end)
+
+ test("require_int: crashes on non-numeric string", function()
+ local ok, err = pcall(expand, '{{ "abc" | require_int("NOT_INT") }}')
+ assert_false(ok)
+ assert_match('NOT_INT', err)
+ end)
+
+ -- require_number
+ test("require_number: valid float string", function()
+ assert_equal(expand('{{ "3.14" | require_number }}'), '3.14')
+ end)
+
+ test("require_number: valid integer string", function()
+ assert_equal(expand('{{ "42" | require_number }}'), '42')
+ end)
+
+ test("require_number: works with env var", function()
+ assert_equal(expand_env('{{ env.THRESHOLD | require_number }}', { THRESHOLD = "8.0" }), '8.0')
+ end)
+
+ test("require_number: negative is valid", function()
+ assert_equal(expand('{{ "-4.5" | require_number }}'), '-4.5')
+ end)
+
+ test("require_number: crashes on non-numeric", function()
+ local ok, err = pcall(expand, '{{ "abc" | require_number("NOT_NUM") }}')
+ assert_false(ok)
+ assert_match('NOT_NUM', err)
+ end)
+
+ -- require_bool
+ test("require_bool: accepts all UCL boolean strings", function()
+ for _, v in ipairs({"true", "false", "yes", "no", "on", "off", "1", "0", "TRUE", "False", "YES", "NO", "On", "OFF"}) do
+ local result = expand('{{ "' .. v .. '" | require_bool }}')
+ assert_equal(result, v, 'require_bool should accept "' .. v .. '"')
+ end
+ end)
+
+ test("require_bool: works with env var", function()
+ assert_equal(expand_env('{{ env.ENABLED | require_bool }}', { ENABLED = "true" }), 'true')
+ assert_equal(expand_env('{{ env.ENABLED | require_bool }}', { ENABLED = "YES" }), 'YES')
+ end)
+
+ test("require_bool: crashes on invalid", function()
+ local ok, err = pcall(expand, '{{ "maybe" | require_bool("NOT_BOOL") }}')
+ assert_false(ok)
+ assert_match('NOT_BOOL', err)
+ end)
+
+ -- require_duration
+ test("require_duration: parses all duration formats", function()
+ assert_equal(expand('{{ "30s" | require_duration }}'), '30')
+ assert_equal(expand('{{ "5min" | require_duration }}'), '300')
+ assert_equal(expand('{{ "5m" | require_duration }}'), '300')
+ assert_equal(expand('{{ "1h" | require_duration }}'), '3600')
+ assert_equal(expand('{{ "10d" | require_duration }}'), '864000')
+ assert_equal(expand('{{ "1w" | require_duration }}'), '604800')
+ assert_equal(expand('{{ "1y" | require_duration }}'), '31536000')
+ assert_equal(expand('{{ "500ms" | require_duration }}'), '0.5')
+ assert_equal(expand('{{ "42" | require_duration }}'), '42')
+ end)
+
+ test("require_duration: works with env var", function()
+ assert_equal(expand_env('{{ env.TIMEOUT | require_duration }}', { TIMEOUT = "30s" }), '30')
+ assert_equal(expand_env('{{ env.INTERVAL | require_duration }}', { INTERVAL = "5min" }), '300')
+ end)
+
+ test("require_duration: crashes on invalid", function()
+ local ok, err = pcall(expand, '{{ "abc" | require_duration("BAD_DUR") }}')
+ assert_false(ok)
+ assert_match('BAD_DUR', err)
+ end)
+
+ -- require_json
+ test("require_json: valid JSON passes through", function()
+ assert_equal(expand('{{ \'["a","b"]\' | require_json }}'), '["a","b"]')
+ end)
+
+ test("require_json: works with env var", function()
+ assert_equal(expand_env('{{ env.MODULES | require_json }}', { MODULES = '["mod1","mod2"]' }), '["mod1","mod2"]')
+ end)
+
+ test("require_json: crashes on invalid", function()
+ local ok, err = pcall(expand, '{% set x = "[unclosed" | require_json("BAD_JSON") %}')
+ assert_false(ok)
+ assert_match('BAD_JSON', err)
+ end)
+
+ -- require_size / tobytes
+ test("require_size: accepts valid sizes", function()
+ for _, v in ipairs({"150Mb", "1Gb", "512Kb", "1024", "0", "100b", "0Kb"}) do
+ assert_equal(expand('{{ "' .. v .. '" | require_size }}'), v)
+ end
+ end)
+
+ test("require_size: works with env var", function()
+ assert_equal(expand_env('{{ env.MAX_SIZE | require_size }}', { MAX_SIZE = "150Mb" }), '150Mb')
+ end)
+
+ test("require_size: crashes on invalid", function()
+ local ok, err = pcall(expand, '{{ "abc" | require_size("BAD_SIZE") }}')
+ assert_false(ok)
+ assert_match('BAD_SIZE', err)
+ end)
+
+ test("require_size: crashes on negative", function()
+ local ok, err = pcall(expand, '{{ "-5Mb" | require_size("NEG_SIZE") }}')
+ assert_false(ok)
+ assert_match('NEG_SIZE', err)
+ end)
+
+ test("tobytes: converts correctly", function()
+ assert_equal(expand('{{ "150Mb" | tobytes }}'), '157286400')
+ assert_equal(expand('{{ "1Gb" | tobytes }}'), '1073741824')
+ assert_equal(expand('{{ "512Kb" | tobytes }}'), '524288')
+ assert_equal(expand('{{ "1024" | tobytes }}'), '1024')
+ assert_equal(expand('{{ "100b" | tobytes }}'), '100')
+ assert_equal(expand('{{ "0" | tobytes }}'), '0')
+ end)
+
+ -- =========================================================================
+ -- PARSING FILTERS
+ -- =========================================================================
+
+ -- fromjson
+ test("fromjson: parse JSON object", function()
+ assert_equal(expand('{% set obj = \'{"a":1,"b":"hello"}\' | fromjson %}{{ obj.a }},{{ obj.b }}'), '1,hello')
+ end)
+
+ test("fromjson: parse JSON array", function()
+ assert_equal(expand('{% set arr = \'["x","y","z"]\' | fromjson %}{{ arr[1] }},{{ arr[2] }},{{ arr[3] }}'), 'x,y,z')
+ end)
+
+ test("fromjson: works with env var", function()
+ assert_equal(expand_env('{% set arr = env.LIST | fromjson %}{{ arr[1] }},{{ arr[2] }}', { LIST = '["a","b"]' }), 'a,b')
+ end)
+
+ test("fromjson: nested object access", function()
+ assert_equal(expand('{% set obj = \'{"a":{"b":{"c":"deep"}}}\' | fromjson %}{{ obj.a.b.c }}'), 'deep')
+ end)
+
+ test("fromjson: iterate array with for loop", function()
+ assert_equal(expand('{% set arr = \'["x","y","z"]\' | fromjson %}{% for item in arr %}{{ item }}{% endfor %}'), 'xyz')
+ end)
+
+ -- split
+ test("split: basic CSV", function()
+ assert_equal(expand('{% set arr = "a,b,c" | split(",") %}{{ arr[1] }},{{ arr[2] }},{{ arr[3] }}'), 'a,b,c')
+ end)
+
+ test("split: with max_splits", function()
+ assert_equal(expand('{% set arr = "a,b,c,d" | split(",", 2) %}{{ arr | length }}'), '3')
+ end)
+
+ -- trim
+ test("trim: strips whitespace", function()
+ assert_equal(expand('{{ " hello " | trim }}'), 'hello')
+ end)
+
+ test("trim: tabs and newlines", function()
+ assert_equal(expand('{{ "\t hello \n" | trim }}'), 'hello')
+ end)
+
+ -- =========================================================================
+ -- TYPE TESTS (all must work with string inputs like env vars)
+ -- =========================================================================
+
+ -- is_defined / is_undefined / is_nil / is_none
+ test("is_defined: true for set env var", function()
+ assert_equal(expand_env('{% if is_defined(env.X) %}yes{% else %}no{% endif %}', { X = "val" }), 'yes')
+ end)
+
+ test("is_defined: false for missing env var", function()
+ assert_equal(expand_env('{% if is_defined(env.MISSING) %}yes{% else %}no{% endif %}', {}), 'no')
+ end)
+
+ test("is_nil: true for missing env var", function()
+ assert_equal(expand_env('{% if is_nil(env.MISSING) %}yes{% else %}no{% endif %}', {}), 'yes')
+ end)
+
+ test("is_none: alias for is_nil", function()
+ assert_equal(expand_env('{% if is_none(env.MISSING) %}yes{% else %}no{% endif %}', {}), 'yes')
+ end)
+
+ test("is_undefined: alias for is_nil", function()
+ assert_equal(expand_env('{% if is_undefined(env.MISSING) %}yes{% else %}no{% endif %}', {}), 'yes')
+ end)
+
+ -- is_string
+ test("is_string: true for env var (always string)", function()
+ assert_equal(expand_env('{% if is_string(env.X) %}yes{% else %}no{% endif %}', { X = "hello" }), 'yes')
+ end)
+
+ test("is_string: true for numeric env var (still string)", function()
+ assert_equal(expand_env('{% if is_string(env.X) %}yes{% else %}no{% endif %}', { X = "42" }), 'yes')
+ end)
+
+ -- is_number (string-aware)
+ test("is_number: true for integer string", function()
+ assert_equal(expand('{% if is_number("42") %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ test("is_number: true for float string", function()
+ assert_equal(expand('{% if is_number("3.14") %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ test("is_number: true for negative string", function()
+ assert_equal(expand('{% if is_number("-1") %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ test("is_number: true for zero string", function()
+ assert_equal(expand('{% if is_number("0") %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ test("is_number: false for non-numeric string", function()
+ assert_equal(expand('{% if is_number("abc") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_number: works with env var", function()
+ assert_equal(expand_env('{% if is_number(env.PORT) %}yes{% else %}no{% endif %}', { PORT = "8080" }), 'yes')
+ assert_equal(expand_env('{% if is_number(env.NAME) %}yes{% else %}no{% endif %}', { NAME = "test" }), 'no')
+ end)
+
+ test("is_number: true for actual Lua number", function()
+ assert_equal(expand('{% set x = 42 %}{% if is_number(x) %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ -- is_integer (string-aware)
+ test("is_integer: true for integer string", function()
+ assert_equal(expand('{% if is_integer("42") %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ test("is_integer: false for float string", function()
+ assert_equal(expand('{% if is_integer("3.14") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_integer: true for zero string", function()
+ assert_equal(expand('{% if is_integer("0") %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ test("is_integer: works with env var", function()
+ assert_equal(expand_env('{% if is_integer(env.DB) %}yes{% else %}no{% endif %}', { DB = "15" }), 'yes')
+ assert_equal(expand_env('{% if is_integer(env.PROB) %}yes{% else %}no{% endif %}', { PROB = "0.5" }), 'no')
+ end)
+
+ -- is_float
+ test("is_float: true for float string", function()
+ assert_equal(expand('{% if is_float("3.14") %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ test("is_float: false for integer string", function()
+ assert_equal(expand('{% if is_float("42") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ -- is_boolean
+ test("is_boolean: false for string 'true'", function()
+ assert_equal(expand('{% if is_boolean("true") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_boolean: true for actual boolean", function()
+ assert_equal(expand('{% set x = true %}{% if is_boolean(x) %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ -- is_mapping / is_table
+ test("is_table: true for parsed JSON object", function()
+ assert_equal(expand('{% set obj = \'{"a":1}\' | fromjson %}{% if is_table(obj) %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ test("is_table: false for string", function()
+ assert_equal(expand('{% if is_table("hello") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ -- is_true / is_false (UCL-aware, string inputs)
+ test("is_true: accepts all UCL truthy strings", function()
+ for _, v in ipairs({"true", "TRUE", "True", "yes", "YES", "Yes", "on", "ON", "On", "1"}) do
+ assert_equal(expand('{% if is_true("' .. v .. '") %}yes{% else %}no{% endif %}'), 'yes', 'is_true("' .. v .. '")')
+ end
+ end)
+
+ test("is_true: rejects non-truthy", function()
+ for _, v in ipairs({"false", "no", "off", "0", "maybe", "", "2"}) do
+ assert_equal(expand('{% if is_true("' .. v .. '") %}yes{% else %}no{% endif %}'), 'no', 'is_true("' .. v .. '")')
+ end
+ end)
+
+ test("is_true: works with env var", function()
+ assert_equal(expand_env('{% if is_true(env.ENABLED) %}yes{% else %}no{% endif %}', { ENABLED = "true" }), 'yes')
+ assert_equal(expand_env('{% if is_true(env.ENABLED) %}yes{% else %}no{% endif %}', { ENABLED = "YES" }), 'yes')
+ assert_equal(expand_env('{% if is_true(env.ENABLED) %}yes{% else %}no{% endif %}', { ENABLED = "false" }), 'no')
+ end)
+
+ test("is_false: accepts all UCL falsy strings", function()
+ for _, v in ipairs({"false", "FALSE", "False", "no", "NO", "No", "off", "OFF", "Off", "0"}) do
+ assert_equal(expand('{% if is_false("' .. v .. '") %}yes{% else %}no{% endif %}'), 'yes', 'is_false("' .. v .. '")')
+ end
+ end)
+
+ test("is_false: rejects non-falsy", function()
+ for _, v in ipairs({"true", "yes", "on", "1", "maybe", "", "2"}) do
+ assert_equal(expand('{% if is_false("' .. v .. '") %}yes{% else %}no{% endif %}'), 'no', 'is_false("' .. v .. '")')
+ end
+ end)
+
+ test("is_false: works with env var", function()
+ assert_equal(expand_env('{% if is_false(env.ENABLED) %}yes{% else %}no{% endif %}', { ENABLED = "false" }), 'yes')
+ assert_equal(expand_env('{% if is_false(env.ENABLED) %}yes{% else %}no{% endif %}', { ENABLED = "true" }), 'no')
+ end)
+
+ -- is_json
+ test("is_json: valid JSON", function()
+ assert_equal(expand('{% if is_json(\'["a"]\') %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ test("is_json: valid JSON object", function()
+ assert_equal(expand('{% if is_json(\'{"k":"v"}\') %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ test("is_json: invalid JSON", function()
+ assert_equal(expand('{% if is_json("{broken") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_json: empty string", function()
+ assert_equal(expand('{% if is_json("") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_json: works with env var", function()
+ assert_equal(expand_env('{% if is_json(env.DATA) %}yes{% else %}no{% endif %}', { DATA = '["a"]' }), 'yes')
+ assert_equal(expand_env('{% if is_json(env.DATA) %}yes{% else %}no{% endif %}', { DATA = 'broken' }), 'no')
+ end)
+
+ -- is_size
+ test("is_size: valid sizes", function()
+ for _, v in ipairs({"150Mb", "1Gb", "512Kb", "1024", "0", "100b", "0Kb"}) do
+ assert_equal(expand('{% if is_size("' .. v .. '") %}yes{% else %}no{% endif %}'), 'yes', 'is_size("' .. v .. '")')
+ end
+ end)
+
+ test("is_size: rejects invalid", function()
+ for _, v in ipairs({"abc", "", "-5Mb"}) do
+ assert_equal(expand('{% if is_size("' .. v .. '") %}yes{% else %}no{% endif %}'), 'no', 'is_size("' .. v .. '")')
+ end
+ end)
+
+ test("is_size: works with env var", function()
+ assert_equal(expand_env('{% if is_size(env.MAX) %}yes{% else %}no{% endif %}', { MAX = "150Mb" }), 'yes')
+ assert_equal(expand_env('{% if is_size(env.MAX) %}yes{% else %}no{% endif %}', { MAX = "garbage" }), 'no')
+ end)
+
+ -- =========================================================================
+ -- NUMBER TESTS
+ -- =========================================================================
+
+ test("is_odd: works", function()
+ assert_equal(expand('{% if is_odd(3) %}yes{% else %}no{% endif %}'), 'yes')
+ assert_equal(expand('{% if is_odd(4) %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_even: works", function()
+ assert_equal(expand('{% if is_even(4) %}yes{% else %}no{% endif %}'), 'yes')
+ assert_equal(expand('{% if is_even(3) %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_divisibleby: works", function()
+ assert_equal(expand('{% if is_divisibleby(10, 5) %}yes{% else %}no{% endif %}'), 'yes')
+ assert_equal(expand('{% if is_divisibleby(10, 3) %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ -- =========================================================================
+ -- COMPARISON TESTS
+ -- =========================================================================
+
+ test("is_eq: string comparison", function()
+ assert_equal(expand('{% if is_eq("hello", "hello") %}yes{% else %}no{% endif %}'), 'yes')
+ assert_equal(expand('{% if is_eq("hello", "world") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_ne: not equal", function()
+ assert_equal(expand('{% if is_ne("a", "b") %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ test("is_lt / is_gt: comparison", function()
+ assert_equal(expand('{% if is_lt(1, 2) %}yes{% else %}no{% endif %}'), 'yes')
+ assert_equal(expand('{% if is_gt(2, 1) %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ test("is_le / is_ge: comparison", function()
+ assert_equal(expand('{% if is_le(1, 1) %}yes{% else %}no{% endif %}'), 'yes')
+ assert_equal(expand('{% if is_ge(1, 1) %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ test("is_eq: works with env var", function()
+ assert_equal(expand_env('{% if is_eq(env.MODE, "strict") %}yes{% else %}no{% endif %}', { MODE = "strict" }), 'yes')
+ assert_equal(expand_env('{% if is_eq(env.MODE, "strict") %}yes{% else %}no{% endif %}', { MODE = "lax" }), 'no')
+ end)
+
+ -- =========================================================================
+ -- STRING TESTS
+ -- =========================================================================
+
+ test("is_in: substring check", function()
+ assert_equal(expand('{% if is_in("@", "user@domain.com") %}yes{% else %}no{% endif %}'), 'yes')
+ assert_equal(expand('{% if is_in("@", "nodomain") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_in: table membership", function()
+ assert_equal(expand('{% set t = ["a","b","c"] %}{% if is_in("b", t) %}yes{% else %}no{% endif %}'), 'yes')
+ assert_equal(expand('{% set t = ["a","b","c"] %}{% if is_in("d", t) %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_startswith: works", function()
+ assert_equal(expand('{% if is_startswith("hello world", "hello") %}yes{% else %}no{% endif %}'), 'yes')
+ assert_equal(expand('{% if is_startswith("hello world", "world") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_endswith: works", function()
+ assert_equal(expand('{% if is_endswith("hello.min", "min") %}yes{% else %}no{% endif %}'), 'yes')
+ assert_equal(expand('{% if is_endswith("hello.min", "max") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_match: Lua pattern", function()
+ assert_equal(expand('{% if is_match("hello123", "^%a+%d+$") %}yes{% else %}no{% endif %}'), 'yes')
+ assert_equal(expand('{% if is_match("hello", "^%d+$") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_lower: works", function()
+ assert_equal(expand('{% if is_lower("hello") %}yes{% else %}no{% endif %}'), 'yes')
+ assert_equal(expand('{% if is_lower("Hello") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_upper: works", function()
+ assert_equal(expand('{% if is_upper("HELLO") %}yes{% else %}no{% endif %}'), 'yes')
+ assert_equal(expand('{% if is_upper("Hello") %}yes{% else %}no{% endif %}'), 'no')
+ end)
+
+ test("is_sameas: alias for is_eq", function()
+ assert_equal(expand('{% if is_sameas("a", "a") %}yes{% else %}no{% endif %}'), 'yes')
+ end)
+
+ -- =========================================================================
+ -- CONTROL FLOW AND SCOPING
+ -- =========================================================================
+
+ test("variables set in if blocks persist after endif", function()
+ assert_equal(expand('{% set x = "original" %}{% if 1 == 1 %}{% set x = "redefined" %}{% endif %}{{ x }}'), 'redefined')
+ end)
+
+ test("elseif works", function()
+ assert_equal(expand('{% set x = "b" %}{% if x == "a" %}A{% elseif x == "b" %}B{% else %}C{% endif %}'), 'B')
+ end)
+
+ test("range is 1-based", function()
+ assert_equal(expand('{% for i in range(3) %}{{ i }},{% endfor %}'), '1,2,3,')
+ end)
+
+ test("range with start and stop", function()
+ assert_equal(expand('{% for i in range(2, 5) %}{{ i }},{% endfor %}'), '2,3,4,5,')
+ end)
+
+ test("loop.last works in for", function()
+ assert_equal(expand('{% set arr = ["a","b","c"] %}{% for item in arr %}{{ item }}{% if not loop.last %},{% endif %}{% endfor %}'), 'a,b,c')
+ end)
+
+ test("loop.index works in for", function()
+ assert_equal(expand('{% for i in range(3) %}{{ loop.index }},{% endfor %}'), '1,2,3,')
+ end)
+
+ -- =========================================================================
+ -- REAL-WORLD ENV VAR PATTERNS
+ -- =========================================================================
+
+ test("pattern: boolean env var with is_true", function()
+ assert_equal(
+ expand_env('{% if is_true(env.FEATURE_ENABLED) %}enabled = true;{% else %}enabled = false;{% endif %}', { FEATURE_ENABLED = "yes" }),
+ 'enabled = true;'
+ )
+ end)
+
+ test("pattern: default with require_bool", function()
+ assert_equal(
+ expand_env('{% set enabled = env.FEATURE | default "true" | require_bool %}enabled = {{ enabled }};', {}),
+ 'enabled = true;'
+ )
+ end)
+
+ test("pattern: duration with capping", function()
+ assert_equal(
+ expand_env('{% set timeout = env.TIMEOUT | default "30s" | require_duration %}{% if timeout > 300 %}{% set timeout = "5min" %}{% endif %}timeout = {{ timeout }};', { TIMEOUT = "600s" }),
+ 'timeout = 5min;'
+ )
+ end)
+
+ test("pattern: JSON array iteration", function()
+ assert_equal(
+ expand_env('{% set modules = env.DISABLED | default \'["a"]\' | fromjson %}{% for m in modules %}{{ m }},{% endfor %}', { DISABLED = '["x","y","z"]' }),
+ 'x,y,z,'
+ )
+ end)
+
+ test("pattern: size validation with tobytes", function()
+ assert_equal(
+ expand_env('{% set bytes = env.MAX_SIZE | default "150Mb" | require_size | tobytes %}{{ bytes }}', { MAX_SIZE = "1Gb" }),
+ '1073741824'
+ )
+ end)
+
+ test("pattern: conditional validation with mandatory", function()
+ local ok, err = pcall(expand_env,
+ '{% set pct = env.PERCENT | default "0.5" | require_number %}{% if (pct | float) >= 1 %}{% set _err = "" | mandatory("PERCENT must be < 1") %}{% endif %}',
+ { PERCENT = "1.5" }
+ )
+ assert_false(ok)
+ assert_match('PERCENT must be < 1', err)
+ end)
+end)