use regex::Regex;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::LazyLock;
static VAR_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"#([a-zA-Z_][a-zA-Z0-9_. +\-*/]*(?:\|[^#]*)?)#").unwrap());
static ATTR_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*"([^"]*)""#).unwrap());
static BOOL_ATTR_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"([a-zA-Z_][a-zA-Z0-9_-]*)"#).unwrap());
static WHAT_DIRECTIVE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?s)<what((?:\s[^>]*)?)(?:/>|>(.*?)</what>)").unwrap()
});
#[derive(Clone, Debug, Default)]
pub enum WiredScope {
#[default]
Public,
Roles(Vec<String>),
User(String),
}
impl std::fmt::Display for WiredScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WiredScope::Public => write!(f, "public"),
WiredScope::Roles(r) => write!(f, "roles: {}", r.join(", ")),
WiredScope::User(_) => write!(f, "per-user"),
}
}
}
impl WiredScope {
pub fn allows(&self, client_roles: &[String], client_user_id: Option<&str>) -> bool {
match self {
WiredScope::Public => true,
WiredScope::Roles(required) => client_roles.iter().any(|r| required.contains(r)),
WiredScope::User(uid) => client_user_id == Some(uid.as_str()),
}
}
}
#[derive(Clone, Debug)]
pub struct WiredVarDecl {
pub name: String,
pub scope: WiredScope,
}
pub type ScopedVarDecl = WiredVarDecl;
fn parse_wired_decl(s: &str) -> WiredVarDecl {
let s = s.trim();
if let Some(bracket_start) = s.find('[') {
if let Some(bracket_end) = s.find(']') {
let name = s[..bracket_start].trim().to_string();
let roles_str = &s[bracket_start + 1..bracket_end];
let roles: Vec<String> = roles_str
.split(',')
.map(|r| r.trim().to_string())
.filter(|r| !r.is_empty())
.collect();
if roles.len() == 1 && roles[0] == "user" {
return WiredVarDecl {
name,
scope: WiredScope::User(String::new()),
};
}
return WiredVarDecl {
name,
scope: WiredScope::Roles(roles),
};
}
}
WiredVarDecl {
name: s.to_string(),
scope: WiredScope::Public,
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct WhatConfig {
pub values: HashMap<String, Value>,
pub directives: PageDirectives,
pub layout: Option<String>,
pub data_application: Vec<ScopedVarDecl>,
pub data_session: Vec<String>,
pub data_wired: Vec<WiredVarDecl>,
}
#[allow(dead_code)]
impl WhatConfig {
pub fn get_string(&self, key: &str) -> Option<&str> {
self.values.get(key).and_then(|v| v.as_str())
}
pub fn get_number(&self, key: &str) -> Option<f64> {
self.values.get(key).and_then(|v| v.as_f64())
}
pub fn get_bool(&self, key: &str) -> Option<bool> {
self.values.get(key).and_then(|v| v.as_bool())
}
pub fn get_array(&self, key: &str) -> Option<&Vec<Value>> {
self.values.get(key).and_then(|v| v.as_array())
}
pub fn merge(&mut self, other: &WhatConfig) {
for (key, value) in &other.values {
self.values.insert(key.clone(), value.clone());
}
if other.directives.requires_auth() {
self.directives.auth = other.directives.auth.clone();
}
if other.directives.protected {
self.directives.protected = true;
}
if !other.directives.roles.is_empty() {
self.directives.roles = other.directives.roles.clone();
}
if other.directives.exclude {
self.directives.exclude = true;
}
if other.directives.title.is_some() {
self.directives.title = other.directives.title.clone();
}
if other.directives.redirect.is_some() {
self.directives.redirect = other.directives.redirect.clone();
}
if other.directives.cache_ttl.is_some() {
self.directives.cache_ttl = other.directives.cache_ttl;
}
for (k, v) in &other.directives.headers {
self.directives.headers.insert(k.clone(), v.clone());
}
if other.layout.is_some() {
self.layout = other.layout.clone();
}
if other.directives.layout.is_some() {
self.directives.layout = other.directives.layout.clone();
}
if !other.data_application.is_empty() {
self.data_application = other.data_application.clone();
}
if !other.data_session.is_empty() {
self.data_session = other.data_session.clone();
}
if !other.data_wired.is_empty() {
self.data_wired = other.data_wired.clone();
}
}
pub fn to_context(&self) -> HashMap<String, Value> {
self.values.clone()
}
}
pub(crate) fn parse_what_file(content: &str) -> WhatConfig {
let mut config = WhatConfig::default();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with("//") || line.starts_with('#') {
continue;
}
if let Some(idx) = line.find('=') {
let key = line[..idx].trim().to_lowercase();
let value_str = line[idx + 1..].trim();
let value = parse_what_value(value_str);
let is_security_directive = matches!(
key.as_str(),
"auth"
| "protected"
| "roles"
| "exclude"
| "redirect"
| "cache"
| "cache_ttl"
| "layout"
| "data.application"
| "data.session"
);
match key.as_str() {
"auth" => {
if let Some(s) = value.as_str() {
config.directives.auth = parse_auth_level(s);
}
}
"protected" => {
if let Some(b) = value.as_bool() {
config.directives.protected = b;
} else if let Some(s) = value.as_str() {
config.directives.protected = s != "false";
}
}
"roles" => {
if let Some(arr) = value.as_array() {
config.directives.roles = arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect();
if !config.directives.roles.is_empty() {
config.directives.protected = true;
}
} else if let Some(s) = value.as_str() {
config.directives.roles = s
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if !config.directives.roles.is_empty() {
config.directives.protected = true;
}
}
}
"exclude" => {
if let Some(b) = value.as_bool() {
config.directives.exclude = b;
}
}
"title" => {
if let Some(s) = value.as_str() {
config.directives.title = Some(s.to_string());
}
}
"redirect" => {
if let Some(s) = value.as_str() {
config.directives.redirect = Some(s.to_string());
}
}
"layout" => {
if let Some(s) = value.as_str() {
config.layout = Some(s.to_string());
config.directives.layout = Some(s.to_string());
}
}
"cache" | "cache_ttl" => {
if let Some(n) = value.as_u64() {
config.directives.cache_ttl = Some(n);
}
}
"data.application" => {
config.data_application = parse_wired_array(&value);
}
"data.session" => {
config.data_session = parse_string_array(&value);
}
"data.wired" => {
config.data_wired = parse_wired_array(&value);
}
_ => {
if let Some(header_name) = key.strip_prefix("header.") {
if let Some(s) = value.as_str() {
config
.directives
.headers
.insert(header_name.to_string(), s.to_string());
}
}
}
}
let is_header = key.starts_with("header.");
if !is_security_directive && !is_header {
if !key.starts_with("data.")
&& value.is_string()
&& !value_str.starts_with('"')
&& !value_str.starts_with('\'')
&& is_unquoted_string(value_str)
{
tracing::warn!(
"Unquoted string in .what file: {} should be quoted, e.g. {} = \"{}\"",
key,
key,
value_str
);
}
config.values.insert(key, value);
}
}
}
config
}
fn parse_string_array(value: &Value) -> Vec<String> {
if let Some(arr) = value.as_array() {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
} else if let Some(s) = value.as_str() {
vec![s.to_string()]
} else {
Vec::new()
}
}
fn parse_wired_array(value: &Value) -> Vec<WiredVarDecl> {
if let Some(arr) = value.as_array() {
arr.iter()
.filter_map(|v| v.as_str().map(parse_wired_decl))
.collect()
} else if let Some(s) = value.as_str() {
vec![parse_wired_decl(s)]
} else {
Vec::new()
}
}
fn split_top_level_commas(s: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut depth = 0i32;
let mut quote: Option<char> = None;
for c in s.chars() {
match quote {
Some(q) => {
if c == q {
quote = None;
}
current.push(c);
}
None => match c {
'"' | '\'' => {
quote = Some(c);
current.push(c);
}
'[' | '{' => {
depth += 1;
current.push(c);
}
']' | '}' => {
depth -= 1;
current.push(c);
}
',' if depth == 0 => {
parts.push(current.trim().to_string());
current.clear();
}
_ => current.push(c),
},
}
}
if !current.trim().is_empty() {
parts.push(current.trim().to_string());
}
parts
}
fn parse_what_value(s: &str) -> Value {
let s = s.trim();
if s == "true" {
return json!(true);
}
if s == "false" {
return json!(false);
}
if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
return json!(s[1..s.len() - 1].to_string());
}
if s.starts_with('[') && s.ends_with(']') {
let inner = s[1..s.len() - 1].trim();
if inner.is_empty() {
return json!([]);
}
let items: Vec<Value> = split_top_level_commas(inner)
.into_iter()
.map(|item| parse_what_value(item.trim()))
.collect();
return json!(items);
}
if let Ok(n) = s.parse::<i64>() {
return json!(n);
}
if let Ok(n) = s.parse::<f64>() {
return json!(n);
}
json!(s.to_string())
}
pub(crate) fn parse_attributes(attr_str: &str) -> HashMap<String, String> {
let mut attrs = HashMap::new();
for cap in ATTR_REGEX.captures_iter(attr_str) {
let key = cap[1].to_string();
let value = cap[2].to_string();
attrs.insert(key, value);
}
attrs
}
#[derive(Debug, Clone, PartialEq)]
struct Filter {
name: String,
args: Vec<String>,
}
struct FilterResult {
value: String,
html_safe: bool,
}
fn parse_filter_chain(expr: &str) -> (&str, Vec<Filter>) {
let Some(first_pipe) = expr.find('|') else {
return (expr, Vec::new());
};
let var_path = &expr[..first_pipe];
let filter_str = &expr[first_pipe + 1..];
let mut filters = Vec::new();
for segment in split_filters(filter_str) {
let segment = segment.trim();
if segment.is_empty() {
continue;
}
if let Some(colon_pos) = segment.find(':') {
let name = segment[..colon_pos].trim().to_string();
let args_str = &segment[colon_pos + 1..];
let args = parse_filter_args(args_str);
filters.push(Filter { name, args });
} else {
filters.push(Filter {
name: segment.to_string(),
args: Vec::new(),
});
}
}
(var_path, filters)
}
fn split_filters(s: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0;
let mut in_quote = false;
let mut quote_char = '"';
for (i, c) in s.char_indices() {
match c {
'"' | '\'' if !in_quote => {
in_quote = true;
quote_char = c;
}
c if c == quote_char && in_quote => {
in_quote = false;
}
'|' if !in_quote => {
parts.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&s[start..]);
parts
}
fn parse_filter_args(s: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_quote = false;
let mut quote_char = '"';
for c in s.chars() {
match c {
'"' | '\'' if !in_quote => {
in_quote = true;
quote_char = c;
}
c if c == quote_char && in_quote => {
in_quote = false;
}
',' if !in_quote => {
args.push(current.trim().to_string());
current = String::new();
}
_ => {
current.push(c);
}
}
}
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
args.push(trimmed);
}
args
}
fn apply_filter(value: &str, filter: &Filter) -> FilterResult {
match filter.name.as_str() {
"raw" => FilterResult {
value: value.to_string(),
html_safe: true,
},
"uppercase" => FilterResult {
value: value.to_uppercase(),
html_safe: false,
},
"lowercase" => FilterResult {
value: value.to_lowercase(),
html_safe: false,
},
"capitalize" => FilterResult {
value: capitalize_first(value),
html_safe: false,
},
"title" => FilterResult {
value: title_case(value),
html_safe: false,
},
"truncate" => {
let max_len: usize = filter
.args
.first()
.and_then(|a| a.parse().ok())
.unwrap_or(50);
let suffix = filter.args.get(1).map(|s| s.as_str()).unwrap_or("...");
FilterResult {
value: truncate_str(value, max_len, suffix),
html_safe: false,
}
}
"count" => {
let n = match serde_json::from_str::<Value>(value) {
Ok(Value::Array(items)) => items.len(),
Ok(Value::Object(map)) => map.len(),
Ok(Value::String(s)) => s.chars().count(),
_ => value.chars().count(),
};
FilterResult {
value: n.to_string(),
html_safe: false,
}
}
"number" => {
FilterResult {
value: format_number(value),
html_safe: false,
}
}
"currency" => {
let code = filter.args.first().map(|s| s.as_str()).unwrap_or("USD");
FilterResult {
value: format_currency(value, code),
html_safe: false,
}
}
"date" => {
let fmt = filter.args.first().map(|s| s.as_str()).unwrap_or("medium");
FilterResult {
value: format_date(value, fmt),
html_safe: false,
}
}
"json" => FilterResult {
value: serde_json::to_string(&serde_json::Value::String(value.to_string()))
.unwrap_or_else(|_| format!("\"{}\"", value)),
html_safe: false,
},
"markdown" => FilterResult {
value: simple_markdown(value),
html_safe: true,
},
"pluralize" => {
let singular = filter.args.first().map(|s| s.as_str()).unwrap_or("s");
let plural = filter.args.get(1).map(|s| s.as_str()).unwrap_or(singular);
let n: f64 = value.parse().unwrap_or(0.0);
FilterResult {
value: if n == 1.0 {
if filter.args.len() >= 2 {
singular.to_string()
} else {
String::new()
}
} else {
plural.to_string()
},
html_safe: false,
}
}
"default" => {
let default_val = filter.args.first().map(|s| s.as_str()).unwrap_or("");
FilterResult {
value: if value.is_empty() {
default_val.to_string()
} else {
value.to_string()
},
html_safe: false,
}
}
"replace" => {
let old = filter.args.first().map(|s| s.as_str()).unwrap_or("");
let new = filter.args.get(1).map(|s| s.as_str()).unwrap_or("");
FilterResult {
value: value.replace(old, new),
html_safe: false,
}
}
"slice" => {
let start: usize = filter
.args
.first()
.and_then(|a| a.parse().ok())
.unwrap_or(0);
let end: usize = filter
.args
.get(1)
.and_then(|a| a.parse().ok())
.unwrap_or(value.len());
let chars: Vec<char> = value.chars().collect();
let start = start.min(chars.len());
let end = end.min(chars.len());
FilterResult {
value: chars[start..end].iter().collect(),
html_safe: false,
}
}
"round" => {
let decimals: u32 = filter
.args
.first()
.and_then(|a| a.parse().ok())
.unwrap_or(0);
let n: f64 = value.parse().unwrap_or(0.0);
let factor = 10f64.powi(decimals as i32);
let rounded = (n * factor).round() / factor;
FilterResult {
value: if decimals == 0 {
format!("{}", rounded as i64)
} else {
format!("{:.prec$}", rounded, prec = decimals as usize)
},
html_safe: false,
}
}
"ceil" => {
let n: f64 = value.parse().unwrap_or(0.0);
let ceiled = n.ceil();
FilterResult {
value: if ceiled.abs() < i64::MAX as f64 {
format!("{}", ceiled as i64)
} else {
format!("{}", ceiled)
},
html_safe: false,
}
}
"floor" => {
let n: f64 = value.parse().unwrap_or(0.0);
let floored = n.floor();
FilterResult {
value: if floored.abs() < i64::MAX as f64 {
format!("{}", floored as i64)
} else {
format!("{}", floored)
},
html_safe: false,
}
}
unknown => {
warn_unknown_filter_once(unknown);
FilterResult {
value: value.to_string(),
html_safe: false,
}
}
}
}
static WARNED_UNKNOWN_FILTERS: LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
fn warn_unknown_filter_once(name: &str) {
let mut warned = WARNED_UNKNOWN_FILTERS
.lock()
.unwrap_or_else(|e| e.into_inner());
if warned.insert(name.to_string()) {
tracing::warn!(
"Unknown filter '|{}' — the value passes through unchanged. Check the spelling against the filter reference.",
name
);
}
}
fn apply_filters(value: &str, filters: &[Filter]) -> FilterResult {
let mut current = FilterResult {
value: value.to_string(),
html_safe: false,
};
for filter in filters {
current = apply_filter(¤t.value, filter);
}
current
}
fn contains_arithmetic(s: &str) -> bool {
s.contains(" + ") || s.contains(" - ") || s.contains(" * ") || s.contains(" / ")
}
fn resolve_and_evaluate_arithmetic(expr: &str, context: &HashMap<String, Value>) -> Option<String> {
static INLINE_VAR: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"[a-zA-Z_][a-zA-Z0-9_.]*").unwrap());
let resolved = INLINE_VAR
.replace_all(expr, |caps: ®ex::Captures| {
let token = &caps[0];
let val = resolve_variable(token, context);
if val.starts_with('#') && val.ends_with('#') {
token.to_string()
} else {
val
}
})
.to_string();
evaluate_arithmetic(&resolved).map(format_f64_clean)
}
pub(crate) fn evaluate_arithmetic(expr: &str) -> Option<f64> {
let expr = expr.trim();
if expr.is_empty() {
return None;
}
let tokens = tokenize_arithmetic(expr)?;
if tokens.len() < 3 {
return None; }
evaluate_with_precedence(&tokens)
}
pub(crate) fn format_f64_clean(n: f64) -> String {
if n == n.trunc() && n.abs() < i64::MAX as f64 {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}
#[derive(Debug, Clone)]
enum ArithToken {
Num(f64),
Op(char), }
fn tokenize_arithmetic(expr: &str) -> Option<Vec<ArithToken>> {
let mut tokens = Vec::new();
let mut chars = expr.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
continue;
}
if c.is_ascii_digit()
|| c == '.'
|| (c == '-' && (tokens.is_empty() || matches!(tokens.last(), Some(ArithToken::Op(_)))))
{
let mut num_str = String::new();
if c == '-' {
num_str.push('-');
chars.next();
}
while let Some(&nc) = chars.peek() {
if nc.is_ascii_digit() || nc == '.' {
num_str.push(nc);
chars.next();
} else {
break;
}
}
let n: f64 = num_str.parse().ok()?;
tokens.push(ArithToken::Num(n));
} else if "+-*/".contains(c) {
tokens.push(ArithToken::Op(c));
chars.next();
} else {
return None;
}
}
for (i, token) in tokens.iter().enumerate() {
match (i % 2, token) {
(0, ArithToken::Num(_)) => {}
(1, ArithToken::Op(_)) => {}
_ => return None,
}
}
if tokens.len() % 2 == 0 {
return None;
}
Some(tokens)
}
fn evaluate_with_precedence(tokens: &[ArithToken]) -> Option<f64> {
let mut nums: Vec<f64> = Vec::new();
let mut ops: Vec<char> = Vec::new();
for token in tokens {
match token {
ArithToken::Num(n) => nums.push(*n),
ArithToken::Op(op) => ops.push(*op),
}
}
let mut i = 0;
while i < ops.len() {
if ops[i] == '*' || ops[i] == '/' {
let result = if ops[i] == '*' {
nums[i] * nums[i + 1]
} else {
if nums[i + 1] == 0.0 {
return None; }
nums[i] / nums[i + 1]
};
nums[i] = result;
nums.remove(i + 1);
ops.remove(i);
} else {
i += 1;
}
}
let mut result = nums[0];
for (i, op) in ops.iter().enumerate() {
match op {
'+' => result += nums[i + 1],
'-' => result -= nums[i + 1],
_ => return None,
}
}
Some(result)
}
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
}
}
fn title_case(s: &str) -> String {
s.split_whitespace()
.map(|word| capitalize_first(word))
.collect::<Vec<_>>()
.join(" ")
}
fn truncate_str(s: &str, max_len: usize, suffix: &str) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max_len {
return s.to_string();
}
let truncated: String = chars[..max_len].iter().collect();
format!("{}{}", truncated, suffix)
}
fn format_number(s: &str) -> String {
if let Ok(n) = s.parse::<f64>() {
if n == n.floor() && n.abs() < i64::MAX as f64 {
let n = n as i64;
let is_negative = n < 0;
let s = n.unsigned_abs().to_string();
let chars: Vec<char> = s.chars().collect();
let mut result = String::new();
for (i, c) in chars.iter().enumerate() {
if i > 0 && (chars.len() - i) % 3 == 0 {
result.push(',');
}
result.push(*c);
}
if is_negative {
format!("-{}", result)
} else {
result
}
} else {
format!("{}", n)
}
} else {
s.to_string()
}
}
fn format_currency(s: &str, code: &str) -> String {
let n: f64 = s.parse().unwrap_or(0.0);
let symbol = match code.to_uppercase().as_str() {
"USD" => "$",
"EUR" => "\u{20ac}",
"GBP" => "\u{00a3}",
"JPY" => "\u{00a5}",
"CAD" => "CA$",
"AUD" => "A$",
_ => "$",
};
let abs_n = n.abs();
let integer_part = abs_n.floor() as i64;
let decimal_part = ((abs_n - abs_n.floor()) * 100.0).round() as i64;
let int_str = format_number(&integer_part.to_string());
let sign = if n < 0.0 { "-" } else { "" };
format!("{}{}{}.{:02}", sign, symbol, int_str, decimal_part)
}
fn format_date(s: &str, mask: &str) -> String {
use chrono::{NaiveDate, NaiveDateTime};
let dt = if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
date.and_hms_opt(0, 0, 0).unwrap()
} else if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
dt
} else if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
dt
} else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
dt.naive_local()
} else {
return s.to_string();
};
apply_date_mask(&dt, mask)
}
fn apply_date_mask(dt: &chrono::NaiveDateTime, mask: &str) -> String {
use chrono::{Datelike, Timelike};
let mask = match mask {
"short" => "m/d/yy",
"medium" => "mmm d, yyyy",
"long" => "mmmm d, yyyy",
"full" => "dddd, mmmm d, yyyy",
"time" => "h:nn tt",
"iso" => "yyyy-mm-dd",
other => other,
};
static MONTHS: &[&str] = &[
"",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
static MONTHS_SHORT: &[&str] = &[
"", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
static DAYS: &[&str] = &[
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
];
static DAYS_SHORT: &[&str] = &["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let day = dt.day();
let month = dt.month() as usize;
let year = dt.year();
let hour24 = dt.hour();
let hour12 = if hour24 == 0 {
12
} else if hour24 > 12 {
hour24 - 12
} else {
hour24
};
let minute = dt.minute();
let second = dt.second();
let weekday_idx = dt.weekday().num_days_from_monday() as usize;
let ampm = if hour24 < 12 { "AM" } else { "PM" };
let mut result = String::new();
let chars: Vec<char> = mask.chars().collect();
let mut i = 0;
while i < chars.len() {
let remaining = &mask[i..];
if remaining.starts_with("dddd") {
result.push_str(DAYS[weekday_idx]);
i += 4;
} else if remaining.starts_with("ddd") {
result.push_str(DAYS_SHORT[weekday_idx]);
i += 3;
} else if remaining.starts_with("dd") {
result.push_str(&format!("{:02}", day));
i += 2;
} else if remaining.starts_with('d') && !remaining.starts_with("dd") {
result.push_str(&day.to_string());
i += 1;
} else if remaining.starts_with("mmmm") {
result.push_str(MONTHS[month]);
i += 4;
} else if remaining.starts_with("mmm") {
result.push_str(MONTHS_SHORT[month]);
i += 3;
} else if remaining.starts_with("mm") {
result.push_str(&format!("{:02}", month));
i += 2;
} else if remaining.starts_with('m') && !remaining.starts_with("mm") {
result.push_str(&month.to_string());
i += 1;
} else if remaining.starts_with("yyyy") {
result.push_str(&format!("{:04}", year));
i += 4;
} else if remaining.starts_with("yy") {
result.push_str(&format!("{:02}", year % 100));
i += 2;
} else if remaining.starts_with("HH") {
result.push_str(&format!("{:02}", hour24));
i += 2;
} else if remaining.starts_with('H') && !remaining.starts_with("HH") {
result.push_str(&hour24.to_string());
i += 1;
} else if remaining.starts_with("hh") {
result.push_str(&format!("{:02}", hour12));
i += 2;
} else if remaining.starts_with('h') && !remaining.starts_with("hh") {
result.push_str(&hour12.to_string());
i += 1;
} else if remaining.starts_with("nn") {
result.push_str(&format!("{:02}", minute));
i += 2;
} else if remaining.starts_with('n') && !remaining.starts_with("nn") {
result.push_str(&minute.to_string());
i += 1;
} else if remaining.starts_with("ss") {
result.push_str(&format!("{:02}", second));
i += 2;
} else if remaining.starts_with('s') && !remaining.starts_with("ss") {
result.push_str(&second.to_string());
i += 1;
} else if remaining.starts_with("tt") {
result.push_str(ampm);
i += 2;
} else if remaining.starts_with('t') && !remaining.starts_with("tt") {
result.push(ampm.chars().next().unwrap());
i += 1;
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
fn simple_markdown(s: &str) -> String {
let escaped = html_escape(s);
let mut result = String::new();
let mut in_paragraph = false;
for line in escaped.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
if in_paragraph {
result.push_str("</p>");
in_paragraph = false;
}
continue;
}
let processed = process_markdown_inline(trimmed);
if !in_paragraph {
result.push_str("<p>");
in_paragraph = true;
} else {
result.push(' ');
}
result.push_str(&processed);
}
if in_paragraph {
result.push_str("</p>");
}
result
}
static MD_BOLD_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
static MD_ITALIC_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*(.+?)\*").unwrap());
static MD_LINK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
fn process_markdown_inline(s: &str) -> String {
let mut result = s.to_string();
result = MD_BOLD_RE
.replace_all(&result, "<strong>$1</strong>")
.to_string();
result = MD_ITALIC_RE.replace_all(&result, "<em>$1</em>").to_string();
result = MD_LINK_RE
.replace_all(&result, |caps: ®ex::Captures| {
let text = &caps[1];
let url = caps[2].trim();
let url_lower = url.to_lowercase();
if url_lower.starts_with("javascript:") || url_lower.starts_with("data:") {
text.to_string()
} else {
format!(r#"<a href="{}">{}</a>"#, url, text)
}
})
.to_string();
result
}
pub(crate) fn replace_variables(
template: &str,
context: &HashMap<String, serde_json::Value>,
) -> String {
VAR_REGEX
.replace_all(template, |caps: ®ex::Captures| {
let expr = &caps[1];
let (var_path, filters) = parse_filter_chain(expr);
let raw_value = if contains_arithmetic(var_path) {
resolve_and_evaluate_arithmetic(var_path, context)
.unwrap_or_else(|| resolve_variable(var_path, context))
} else {
resolve_variable(var_path, context)
};
let is_unresolved = raw_value.starts_with('#') && raw_value.ends_with('#');
let filtered = if filters.is_empty() {
FilterResult {
value: raw_value,
html_safe: false,
}
} else {
let input = if is_unresolved && filters.iter().any(|f| f.name == "default") {
String::new()
} else {
raw_value
};
apply_filters(&input, &filters)
};
if filtered.html_safe {
filtered.value
} else {
html_escape(&filtered.value)
}
})
.to_string()
}
#[derive(Debug, Clone, Default)]
pub struct ReactiveReplaceResult {
pub html: String,
pub session_keys: std::collections::HashSet<String>,
}
pub(crate) fn replace_variables_reactive(
template: &str,
context: &HashMap<String, serde_json::Value>,
) -> ReactiveReplaceResult {
let mut session_keys = std::collections::HashSet::new();
let mut result = String::with_capacity(template.len());
let mut last_end = 0;
for caps in VAR_REGEX.captures_iter(template) {
let m = caps.get(0).unwrap();
let expr = &caps[1];
let start = m.start();
result.push_str(&template[last_end..start]);
let (var_path, filters) = parse_filter_chain(expr);
let is_session_var = var_path.starts_with("session.");
let is_wired_var = var_path.starts_with("wired.");
let raw_value = if contains_arithmetic(var_path) {
resolve_and_evaluate_arithmetic(var_path, context)
.unwrap_or_else(|| resolve_variable(var_path, context))
} else {
resolve_variable(var_path, context)
};
let is_unresolved = raw_value.starts_with('#') && raw_value.ends_with('#');
let filtered = if filters.is_empty() {
FilterResult {
value: raw_value,
html_safe: false,
}
} else {
let input = if is_unresolved && filters.iter().any(|f| f.name == "default") {
String::new()
} else {
raw_value
};
apply_filters(&input, &filters)
};
if is_session_var || is_wired_var {
let (bind_prefix, bind_key) = if is_session_var {
let key = &var_path[8..]; session_keys.insert(key.to_string());
("session", key)
} else {
let key = &var_path[6..]; ("wired", key)
};
let display_value = if filtered.value.starts_with('#') && filtered.value.ends_with('#')
{
String::new()
} else {
filtered.value
};
let text_before = &template[..start];
let in_attribute = is_in_attribute_context(text_before);
if in_attribute {
if filtered.html_safe {
result.push_str(&display_value);
} else {
result.push_str(&html_escape(&display_value));
}
} else {
result.push_str(&format!(
r#"<span w-bind="{}.{}">{}</span>"#,
bind_prefix,
bind_key,
html_escape(&display_value)
));
}
} else {
if filtered.html_safe {
result.push_str(&filtered.value);
} else {
result.push_str(&html_escape(&filtered.value));
}
}
last_end = m.end();
}
result.push_str(&template[last_end..]);
ReactiveReplaceResult {
html: result,
session_keys,
}
}
fn is_in_attribute_context(text_before: &str) -> bool {
let mut in_attr_value = false;
let mut quote_char: Option<char> = None;
for c in text_before.chars().rev() {
match c {
'"' | '\'' if quote_char == Some(c) => {
quote_char = None;
in_attr_value = false;
}
'"' | '\'' if quote_char.is_none() => {
quote_char = Some(c);
in_attr_value = true;
}
'>' if quote_char.is_none() => {
return false;
}
'<' if quote_char.is_none() => {
return in_attr_value;
}
_ => {}
}
}
in_attr_value
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
pub(crate) fn html_unescape(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
s.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
pub(crate) fn resolve_computed_variables(
computed: &[(String, String)],
context: &mut HashMap<String, serde_json::Value>,
) {
for (name, template) in computed {
let resolved = VAR_REGEX
.replace_all(template, |caps: ®ex::Captures| {
let var_path = &caps[1];
resolve_variable(var_path, context)
})
.to_string();
context.insert(name.clone(), serde_json::Value::String(resolved));
}
}
fn resolve_variable(path: &str, context: &HashMap<String, serde_json::Value>) -> String {
let parts: Vec<&str> = path.split('.').collect();
if parts.is_empty() {
return String::new();
}
if parts[0] == "env" && parts.len() >= 2 {
let env_var_name = parts[1..].join("_"); return std::env::var(&env_var_name).unwrap_or_default();
}
let root = context.get(parts[0]);
let mut current: Option<&serde_json::Value> = root;
for part in parts.iter().skip(1) {
current = current.and_then(|v| {
if let serde_json::Value::Object(obj) = v {
obj.get(*part)
} else {
None
}
});
}
match current {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Number(n)) => n.to_string(),
Some(serde_json::Value::Bool(b)) => b.to_string(),
Some(serde_json::Value::Null) => String::new(),
Some(v) => v.to_string(),
None if root.is_some() && parts.len() > 1 => String::new(), None => format!("#{}#", path), }
}
#[allow(dead_code)]
pub(crate) fn is_standard_html_tag(name: &str) -> bool {
matches!(
name,
"html"
| "head"
| "body"
| "title"
| "meta"
| "link"
| "script"
| "style"
| "div"
| "span"
| "p"
| "a"
| "img"
| "br"
| "hr"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "ul"
| "ol"
| "li"
| "dl"
| "dt"
| "dd"
| "table"
| "thead"
| "tbody"
| "tfoot"
| "tr"
| "th"
| "td"
| "form"
| "input"
| "textarea"
| "select"
| "option"
| "button"
| "label"
| "header"
| "footer"
| "main"
| "nav"
| "section"
| "article"
| "aside"
| "figure"
| "figcaption"
| "video"
| "audio"
| "source"
| "canvas"
| "iframe"
| "embed"
| "object"
| "param"
| "strong"
| "em"
| "b"
| "i"
| "u"
| "s"
| "mark"
| "small"
| "sub"
| "sup"
| "blockquote"
| "pre"
| "code"
| "kbd"
| "samp"
| "var"
| "time"
| "address"
| "abbr"
| "cite"
| "q"
| "ins"
| "del"
| "dfn"
| "ruby"
| "rt"
| "rp"
| "bdi"
| "bdo"
| "wbr"
| "details"
| "summary"
| "dialog"
| "slot"
| "template"
| "noscript"
)
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum AuthLevel {
All,
User,
Roles(Vec<String>),
}
impl std::fmt::Display for AuthLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AuthLevel::All => write!(f, "all"),
AuthLevel::User => write!(f, "user"),
AuthLevel::Roles(v) => write!(f, "roles: {}", v.join(", ")),
}
}
}
impl Default for AuthLevel {
fn default() -> Self {
AuthLevel::All
}
}
#[derive(Debug, Clone)]
pub(crate) enum SessionMutation {
Increment { key: String, value: i64 },
Set { key: String, value: Value },
Push { key: String, value: Value },
PushMax {
key: String,
max: usize,
value: Value,
},
Unshift { key: String, value: Value },
Clear { key: String },
}
#[derive(Debug, Clone, Default)]
pub(crate) struct PageDirectives {
pub auth: AuthLevel,
pub protected: bool,
pub roles: Vec<String>,
pub exclude: bool,
pub title: Option<String>,
pub redirect: Option<String>,
pub cache_ttl: Option<u64>,
pub layout: Option<String>,
pub session_mutations: Vec<SessionMutation>,
pub computed: Vec<(String, String)>,
pub headers: HashMap<String, String>,
pub custom: HashMap<String, String>,
pub vars: HashMap<String, Value>,
}
impl PageDirectives {
pub fn requires_auth(&self) -> bool {
match &self.auth {
AuthLevel::All => self.protected, AuthLevel::User => true,
AuthLevel::Roles(_) => true,
}
}
pub fn check_access(&self, authenticated: bool, user_roles: &[String]) -> bool {
match &self.auth {
AuthLevel::All => {
if self.protected {
if !authenticated {
return false;
}
self.has_role(user_roles)
} else {
true
}
}
AuthLevel::User => authenticated,
AuthLevel::Roles(required) => {
authenticated && required.iter().any(|r| user_roles.contains(r))
}
}
}
pub fn has_role(&self, user_roles: &[String]) -> bool {
if self.roles.is_empty() {
return true; }
self.roles.iter().any(|r| user_roles.contains(r))
}
}
pub(crate) fn parse_page_directives(content: &str) -> (PageDirectives, String) {
let mut directives = PageDirectives::default();
let cleaned = WHAT_DIRECTIVE_REGEX
.replace_all(content, |caps: ®ex::Captures| {
let attrs_str = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let inner_content = caps.get(2).map(|m| m.as_str());
parse_directive_attributes(attrs_str, &mut directives);
if let Some(inner) = inner_content {
parse_directive_content(inner, &mut directives);
}
"" })
.to_string();
(directives, cleaned)
}
pub(crate) fn parse_auth_level(value: &str) -> AuthLevel {
let value = value.trim().to_lowercase();
match value.as_str() {
"all" | "public" | "none" => AuthLevel::All,
"user" | "authenticated" => AuthLevel::User,
_ => {
let roles: Vec<String> = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if roles.is_empty() {
AuthLevel::All
} else {
AuthLevel::Roles(roles)
}
}
}
}
fn parse_directive_attributes(attrs_str: &str, directives: &mut PageDirectives) {
let attrs = parse_attributes(attrs_str);
for (key, value) in &attrs {
match key.as_str() {
"auth" => {
directives.auth = parse_auth_level(value);
}
"protected" => directives.protected = value != "false",
"roles" => {
directives.roles = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if !directives.roles.is_empty() {
directives.protected = true;
}
}
"exclude" => directives.exclude = value != "false",
"title" => directives.title = Some(value.clone()),
"redirect" => directives.redirect = Some(value.clone()),
"layout" => directives.layout = Some(value.clone()),
"cache" | "cache-ttl" => {
directives.cache_ttl = value.parse().ok();
}
_ => {
if let Some(header_name) = key.strip_prefix("header.") {
directives
.headers
.insert(header_name.to_string(), value.clone());
} else {
warn_access_directive_near_miss(key);
directives.custom.insert(key.clone(), value.clone());
}
}
}
}
let without_attrs = ATTR_REGEX.replace_all(attrs_str, "");
for cap in BOOL_ATTR_REGEX.captures_iter(&without_attrs) {
let key = &cap[1];
match key {
"protected" => directives.protected = true,
"exclude" => directives.exclude = true,
_ => {}
}
}
}
fn is_reserved_directive(key: &str) -> bool {
matches!(
key,
"auth"
| "protected"
| "roles"
| "exclude"
| "title"
| "redirect"
| "layout"
| "cache"
| "cache-ttl"
| "method"
| "paginate"
) || key.starts_with("fetch.")
|| key.starts_with("session.")
|| key.starts_with("compute.")
|| key.starts_with("set.")
|| key.starts_with("data.")
|| key.starts_with("header.")
|| key.starts_with("mutation.")
}
fn within_one_edit(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.len().abs_diff(b.len()) > 1 {
return false;
}
if a.len() == b.len() {
let diffs: Vec<usize> = (0..a.len()).filter(|&i| a[i] != b[i]).collect();
match diffs.len() {
1 => true,
2 => {
diffs[1] == diffs[0] + 1
&& a[diffs[0]] == b[diffs[1]]
&& a[diffs[1]] == b[diffs[0]]
}
_ => false,
}
} else {
let (short, long) = if a.len() < b.len() { (&a, &b) } else { (&b, &a) };
let mut i = 0;
let mut j = 0;
let mut skipped = false;
while i < short.len() && j < long.len() {
if short[i] == long[j] {
i += 1;
j += 1;
} else if skipped {
return false;
} else {
skipped = true;
j += 1;
}
}
true
}
}
fn access_directive_near_miss(key: &str) -> Option<&'static str> {
const ACCESS_KEYS: [&str; 3] = ["auth", "protected", "roles"];
let lower = key.to_lowercase();
ACCESS_KEYS.into_iter().find(|reserved| {
key != *reserved
&& lower.chars().next() == reserved.chars().next()
&& within_one_edit(&lower, reserved)
})
}
static WARNED_NEAR_MISSES: LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
fn warn_access_directive_near_miss(key: &str) {
if let Some(reserved) = access_directive_near_miss(key) {
let mut warned = WARNED_NEAR_MISSES.lock().unwrap();
if warned.insert(key.to_string()) {
tracing::error!(
"<what> key '{}' looks like a misspelling of the '{}' access-control directive. \
It was treated as an inline variable, so NO access restriction was applied to this page. \
If you meant '{}', fix the spelling; if it is a real variable, rename it.",
key,
reserved,
reserved
);
}
}
}
fn parse_directive_content(content: &str, directives: &mut PageDirectives) {
let mut current_section: Option<String> = None;
let mut json_key: Option<String> = None;
let mut json_buf = String::new();
let mut json_depth: usize = 0;
let mut json_bracket: char = ' ';
let lines: Vec<&str> = content.lines().collect();
let mut i = 0;
while i < lines.len() {
let raw_line = lines[i];
let trimmed = raw_line.trim();
i += 1;
if json_key.is_some() {
json_buf.push('\n');
json_buf.push_str(trimmed);
let close_char = if json_bracket == '[' { ']' } else { '}' };
json_depth += trimmed.matches(json_bracket).count();
json_depth -= trimmed.matches(close_char).count();
if json_depth == 0 {
let key = json_key.take().unwrap();
let relaxed = relax_json(&json_buf);
match serde_json::from_str::<Value>(&relaxed) {
Ok(val) => {
directives.vars.insert(key, val);
}
Err(e) => {
tracing::warn!("Invalid JSON for inline var: {}", e);
}
}
json_buf.clear();
}
continue;
}
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let inner = trimmed[1..trimmed.len() - 1].trim();
if inner.is_empty() {
current_section = None;
} else {
current_section = Some(inner.to_lowercase().to_string());
}
continue;
}
let line_owned;
let line: &str = if let Some(ref section) = current_section {
line_owned = format!("{}.{}", section, trimmed);
line_owned.trim()
} else {
trimmed
};
if line.starts_with("session.") {
if let Some(mutation) = parse_session_mutation(line) {
directives.session_mutations.push(mutation);
}
continue;
}
if line.starts_with("compute.") {
if let Some(eq_pos) = line.find('=') {
let name = line[8..eq_pos].trim().to_string(); let value = line[eq_pos + 1..].trim();
let value = strip_symmetric_quotes(value).0.to_string();
directives.computed.push((name, value));
}
continue;
}
if !line.contains(':') && !line.contains('=') {
match line.to_lowercase().as_str() {
"protected" => directives.protected = true,
"exclude" => directives.exclude = true,
_ => {}
}
continue;
}
let colon_idx = line.find(':');
let equals_idx = line.find('=');
let (key, value) = match (colon_idx, equals_idx) {
(Some(c), Some(e)) => {
if e < c {
(&line[..e], line[e + 1..].trim())
} else {
(&line[..c], line[c + 1..].trim())
}
}
(Some(c), None) => (&line[..c], line[c + 1..].trim()),
(None, Some(e)) => (&line[..e], line[e + 1..].trim()),
_ => continue,
};
let key = key.trim().to_lowercase();
let (value, value_was_quoted) = strip_symmetric_quotes(value);
if !value_was_quoted
&& value.len() >= 2
&& (value.starts_with('"')
|| value.starts_with('\'')
|| value.ends_with('"')
|| value.ends_with('\''))
{
tracing::warn!(
"Mismatched quotes in <what> block value for '{}': {} — use one matching pair, e.g. \"value\"",
key,
value
);
}
match key.as_str() {
"auth" => {
directives.auth = parse_auth_level(value);
}
"protected" => directives.protected = value != "false",
"roles" => {
directives.roles = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if !directives.roles.is_empty() {
directives.protected = true;
}
}
"exclude" => directives.exclude = value != "false",
"title" => {
if !value_was_quoted && is_unquoted_string(value) {
tracing::warn!(
"Unquoted string in <what> block: title should be quoted, e.g. title: \"{}\"",
value
);
}
directives.title = Some(value.to_string());
}
"redirect" => {
if !value_was_quoted && is_unquoted_string(value) {
tracing::warn!(
"Unquoted string in <what> block: redirect should be quoted, e.g. redirect: \"{}\"",
value
);
}
directives.redirect = Some(value.to_string());
}
"layout" => {
if !value_was_quoted && is_unquoted_string(value) {
tracing::warn!(
"Unquoted string in <what> block: layout should be quoted, e.g. layout: \"{}\"",
value
);
}
directives.layout = Some(value.to_string());
}
"cache" | "cache-ttl" => {
directives.cache_ttl = value.parse().ok();
}
_ => {
if let Some(header_name) = key.strip_prefix("header.") {
directives
.headers
.insert(header_name.to_string(), value.to_string());
} else if is_reserved_directive(&key) {
if !value_was_quoted && !value.is_empty() && is_unquoted_string(value) {
tracing::warn!(
"Unquoted string in <what> block: {} should be quoted, e.g. {} = \"{}\"",
key,
key,
value
);
}
directives.custom.insert(key, value.to_string());
} else {
warn_access_directive_near_miss(&key);
let value_untrimmed = {
let eq_pos = line.find('=').or_else(|| line.find(':')).unwrap();
line[eq_pos + 1..].trim()
};
if (value_untrimmed.starts_with('[') || value_untrimmed.starts_with('{'))
&& !value_untrimmed.ends_with(']')
&& !value_untrimmed.ends_with('}')
{
json_bracket = value_untrimmed.chars().next().unwrap();
json_buf = value_untrimmed.to_string();
json_depth = value_untrimmed.matches(json_bracket).count();
let close_char = if json_bracket == '[' { ']' } else { '}' };
json_depth -= value_untrimmed.matches(close_char).count();
if json_depth == 0 {
let relaxed = relax_json(value_untrimmed);
match serde_json::from_str::<Value>(&relaxed) {
Ok(val) => {
directives.vars.insert(key, val);
}
Err(e) => {
tracing::warn!("Invalid JSON for inline var '{}': {}", key, e);
}
}
json_buf.clear();
} else {
json_key = Some(key);
}
} else if value_untrimmed.starts_with('[') || value_untrimmed.starts_with('{') {
let relaxed = relax_json(value_untrimmed);
match serde_json::from_str::<Value>(&relaxed) {
Ok(val) => {
directives.vars.insert(key, val);
}
Err(e) => {
tracing::warn!("Invalid JSON for inline var '{}': {}", key, e);
}
}
} else {
let parsed = if value_was_quoted {
Value::String(value.to_string())
} else {
parse_inline_value(value)
};
if !value_was_quoted && is_unquoted_string(value) {
tracing::warn!(
"Unquoted string in <what> block: {} should be quoted, e.g. {} = \"{}\"",
key,
key,
value
);
}
directives.vars.insert(key, parsed);
}
}
}
}
}
}
pub(crate) fn strip_symmetric_quotes(s: &str) -> (&str, bool) {
let bytes = s.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0];
if (first == b'"' || first == b'\'') && bytes[bytes.len() - 1] == first {
return (&s[1..s.len() - 1], true);
}
}
(s, false)
}
fn is_unquoted_string(value: &str) -> bool {
if value.is_empty() {
return false;
}
if value.parse::<f64>().is_ok() {
return false;
}
match value.to_lowercase().as_str() {
"true" | "false" | "none" | "all" | "user" => false,
_ => true,
}
}
fn relax_json(s: &str) -> String {
static UNQUOTED_KEY: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?m)([{\[,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:"#).unwrap());
UNQUOTED_KEY.replace_all(s, r#"$1 "$2":"#).into_owned()
}
fn parse_inline_value(s: &str) -> Value {
if let Ok(n) = s.parse::<i64>() {
return json!(n);
}
if let Ok(n) = s.parse::<f64>() {
return json!(n);
}
if s == "true" {
return json!(true);
}
if s == "false" {
return json!(false);
}
json!(s)
}
fn parse_session_mutation(line: &str) -> Option<SessionMutation> {
let rest = line.strip_prefix("session.")?;
if let Some(idx) = rest.find(".pushmax(") {
let key = rest[..idx].trim().to_string();
let value_start = idx + 9; let value_end = rest.rfind(')')?;
let inner = rest[value_start..value_end].trim();
if let Some(comma) = inner.find(',') {
let max_str = inner[..comma].trim();
let (value_str, was_quoted) = strip_symmetric_quotes(inner[comma + 1..].trim());
if let Ok(max) = max_str.parse::<usize>() {
let value = parse_mutation_value(value_str, was_quoted);
return Some(SessionMutation::PushMax { key, max, value });
}
}
}
if let Some(idx) = rest.find(".push(") {
let key = rest[..idx].trim().to_string();
let value_start = idx + 6; let value_end = rest.rfind(')')?;
let (value_str, was_quoted) = strip_symmetric_quotes(rest[value_start..value_end].trim());
let value = parse_mutation_value(value_str, was_quoted);
return Some(SessionMutation::Push { key, value });
}
if let Some(idx) = rest.find(".unshift(") {
let key = rest[..idx].trim().to_string();
let value_start = idx + 9; let value_end = rest.rfind(')')?;
let (value_str, was_quoted) = strip_symmetric_quotes(rest[value_start..value_end].trim());
let value = parse_mutation_value(value_str, was_quoted);
return Some(SessionMutation::Unshift { key, value });
}
if let Some(idx) = rest.find(".clear()") {
let key = rest[..idx].trim().to_string();
return Some(SessionMutation::Clear { key });
}
if let Some(idx) = rest.find("+=") {
let key = rest[..idx].trim().to_string();
let value_str = rest[idx + 2..].trim();
let value: i64 = value_str.parse().ok()?;
return Some(SessionMutation::Increment { key, value });
}
if let Some(idx) = rest.find("-=") {
let key = rest[..idx].trim().to_string();
let value_str = rest[idx + 2..].trim();
let value: i64 = value_str.parse().ok()?;
return Some(SessionMutation::Increment { key, value: -value });
}
if let Some(idx) = rest.find('=') {
let key = rest[..idx].trim().to_string();
let (value_str, was_quoted) = strip_symmetric_quotes(rest[idx + 1..].trim());
let value = parse_mutation_value(value_str, was_quoted);
return Some(SessionMutation::Set { key, value });
}
None
}
fn parse_mutation_value(value_str: &str, was_quoted: bool) -> Value {
if was_quoted {
json!(value_str)
} else {
parse_value_str(value_str)
}
}
fn parse_value_str(value_str: &str) -> Value {
if value_str == "[]" {
return json!([]);
}
if let Ok(n) = value_str.parse::<i64>() {
json!(n)
} else if let Ok(f) = value_str.parse::<f64>() {
json!(f)
} else if value_str == "true" {
json!(true)
} else if value_str == "false" {
json!(false)
} else {
json!(value_str)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_attributes() {
let attrs = parse_attributes(r#"title="Hello" size="large""#);
assert_eq!(attrs.get("title"), Some(&"Hello".to_string()));
assert_eq!(attrs.get("size"), Some(&"large".to_string()));
}
#[test]
fn test_replace_variables() {
let mut context = HashMap::new();
context.insert("name".to_string(), serde_json::json!("World"));
let result = replace_variables("Hello #name#!", &context);
assert_eq!(result, "Hello World!");
}
#[test]
fn test_nested_variables() {
let mut context = HashMap::new();
context.insert(
"user".to_string(),
serde_json::json!({
"name": "Alice",
"email": "alice@example.com"
}),
);
let result = replace_variables("#user.name# (#user.email#)", &context);
assert_eq!(result, "Alice (alice@example.com)");
}
#[test]
fn test_env_variables() {
unsafe {
std::env::set_var("WHAT_TEST_VAR", "test_value");
}
let context = HashMap::new();
let result = replace_variables("Value: #env.WHAT_TEST_VAR#", &context);
assert_eq!(result, "Value: test_value");
unsafe {
std::env::remove_var("WHAT_TEST_VAR");
}
}
#[test]
fn test_env_variable_not_found() {
let context = HashMap::new();
let result = replace_variables("#env.NONEXISTENT_VAR_12345#", &context);
assert_eq!(result, ""); }
#[test]
fn test_page_directives_self_closing() {
let content = r#"<what protected roles="admin,editor" />
<!DOCTYPE html>
<html>
<body>Hello</body>
</html>"#;
let (directives, cleaned) = parse_page_directives(content);
assert!(directives.protected);
assert_eq!(directives.roles, vec!["admin", "editor"]);
assert!(cleaned.contains("<!DOCTYPE html>"));
assert!(!cleaned.contains("<what"));
}
#[test]
fn test_page_directives_boolean() {
let content = r#"<what protected exclude />
<html></html>"#;
let (directives, cleaned) = parse_page_directives(content);
assert!(directives.protected);
assert!(directives.exclude);
assert!(!cleaned.contains("<what"));
}
#[test]
fn test_page_directives_content_syntax() {
let content = r#"<what>
protected
roles: admin, manager
title: Admin Dashboard
</what>
<!DOCTYPE html>
<html></html>"#;
let (directives, cleaned) = parse_page_directives(content);
assert!(directives.protected);
assert_eq!(directives.roles, vec!["admin", "manager"]);
assert_eq!(directives.title, Some("Admin Dashboard".to_string()));
assert!(cleaned.contains("<!DOCTYPE html>"));
}
#[test]
fn test_page_directives_roles_imply_protected() {
let content = r#"<what roles="admin" />
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert!(directives.protected); assert_eq!(directives.roles, vec!["admin"]);
}
#[test]
fn test_no_directives() {
let content = r#"<!DOCTYPE html>
<html><body>Hello</body></html>"#;
let (directives, cleaned) = parse_page_directives(content);
assert!(!directives.protected);
assert!(directives.roles.is_empty());
assert_eq!(content, cleaned);
}
#[test]
fn test_page_tag_preserved() {
let content = r#"<page title="Test">
<what-nav active="home"/>
<main>Content</main>
</page>"#;
let (_, cleaned) = parse_page_directives(content);
println!("Cleaned content: '{}'", cleaned);
assert!(cleaned.contains("<page"), "Should preserve <page> tag");
assert!(cleaned.contains("</page>"), "Should preserve </page> tag");
assert!(
cleaned.contains("<what-nav"),
"Should preserve <what-nav> tag"
);
assert_eq!(content, cleaned, "Content should be unchanged");
}
#[test]
fn test_auth_all() {
let content = r#"<what auth="all" />
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.auth, AuthLevel::All);
assert!(!directives.requires_auth());
}
#[test]
fn test_auth_user() {
let content = r#"<what auth="user" />
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.auth, AuthLevel::User);
assert!(directives.requires_auth());
assert!(directives.check_access(true, &[]));
assert!(!directives.check_access(false, &[]));
}
#[test]
fn test_auth_roles() {
let content = r#"<what auth="admin, editor" />
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(
directives.auth,
AuthLevel::Roles(vec!["admin".to_string(), "editor".to_string()])
);
assert!(directives.requires_auth());
assert!(directives.check_access(true, &["admin".to_string()]));
assert!(directives.check_access(true, &["editor".to_string()]));
assert!(!directives.check_access(true, &["viewer".to_string()]));
assert!(!directives.check_access(false, &["admin".to_string()]));
}
#[test]
fn test_auth_content_syntax() {
let content = r#"<what>
auth: admin, manager
title: Dashboard
</what>
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(
directives.auth,
AuthLevel::Roles(vec!["admin".to_string(), "manager".to_string()])
);
assert_eq!(directives.title, Some("Dashboard".to_string()));
}
#[test]
fn test_auth_public_aliases() {
let (d1, _) = parse_page_directives(r#"<what auth="public" /><html></html>"#);
assert_eq!(d1.auth, AuthLevel::All);
let (d2, _) = parse_page_directives(r#"<what auth="none" /><html></html>"#);
assert_eq!(d2.auth, AuthLevel::All);
let (d3, _) = parse_page_directives(r#"<what auth="authenticated" /><html></html>"#);
assert_eq!(d3.auth, AuthLevel::User);
}
#[test]
fn test_fetch_directive_url_with_equals() {
let content = r#"<what>
title: Remote Data
fetch.dog_facts = "https://dogapi.dog/api/v2/facts?limit=3"
</what>
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.title, Some("Remote Data".to_string()));
assert_eq!(
directives.custom.get("fetch.dog_facts"),
Some(&"https://dogapi.dog/api/v2/facts?limit=3".to_string()),
"fetch URL should be parsed correctly with = delimiter"
);
}
#[test]
fn test_fetch_directive_url_with_multiple_equals() {
let content = r#"<what>
fetch.dog_breeds = "https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6"
</what>
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(
directives.custom.get("fetch.dog_breeds"),
Some(&"https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6".to_string()),
"URL with multiple = in query params should be preserved"
);
}
#[test]
fn test_fetch_directive_full_remote_data_page() {
let content = r#"<what>
title: Remote Data
page: remote-data
fetch.dog_facts = "https://dogapi.dog/api/v2/facts?limit=3"
fetch.dog_breeds = "https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6"
fetch.dog_images = "https://dog.ceo/api/breeds/image/random/4"
</what>
<html></html>"#;
let (directives, cleaned) = parse_page_directives(content);
assert_eq!(directives.title, Some("Remote Data".to_string()));
assert_eq!(directives.vars.get("page"), Some(&json!("remote-data")));
assert_eq!(
directives.custom.get("fetch.dog_facts"),
Some(&"https://dogapi.dog/api/v2/facts?limit=3".to_string())
);
assert_eq!(
directives.custom.get("fetch.dog_breeds"),
Some(&"https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6".to_string())
);
assert_eq!(
directives.custom.get("fetch.dog_images"),
Some(&"https://dog.ceo/api/breeds/image/random/4".to_string())
);
assert!(!cleaned.contains("<what>"));
}
#[test]
fn test_section_header_fetch() {
let content = r##"<what>
title: Dashboard
[fetch.weather]
url = "https://api.weather.com/current"
method = "GET"
headers = "Authorization: Bearer abc123"
path = "data.current"
limit = 5
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.title, Some("Dashboard".to_string()));
assert_eq!(
directives.custom.get("fetch.weather.url"),
Some(&"https://api.weather.com/current".to_string())
);
assert_eq!(
directives.custom.get("fetch.weather.method"),
Some(&"GET".to_string())
);
assert_eq!(
directives.custom.get("fetch.weather.headers"),
Some(&"Authorization: Bearer abc123".to_string())
);
assert_eq!(
directives.custom.get("fetch.weather.path"),
Some(&"data.current".to_string())
);
assert_eq!(
directives.custom.get("fetch.weather.limit"),
Some(&"5".to_string())
);
}
#[test]
fn test_section_header_og() {
let content = r##"<what>
[og]
title: My Dashboard
description: Real-time weather data
image: /images/dashboard-og.png
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
assert_eq!(
directives.vars.get("og.title"),
Some(&json!("My Dashboard"))
);
assert_eq!(
directives.vars.get("og.description"),
Some(&json!("Real-time weather data"))
);
assert_eq!(
directives.vars.get("og.image"),
Some(&json!("/images/dashboard-og.png"))
);
}
#[test]
fn test_section_header_session() {
let content = r##"<what>
[session]
visit_count += 1
theme = "dark"
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.session_mutations.len(), 2);
}
#[test]
fn test_section_header_compute() {
let content = r##"<what>
[compute]
greeting = "Hello, #user.full_name#!"
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.computed.len(), 1);
assert_eq!(directives.computed[0].0, "greeting");
assert_eq!(directives.computed[0].1, "Hello, #user.full_name#!");
}
#[test]
fn test_section_header_reset() {
let content = r##"<what>
[fetch.weather]
url = "https://api.weather.com/current"
[]
session.visit_count += 1
compute.greeting = "Hello!"
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
assert_eq!(
directives.custom.get("fetch.weather.url"),
Some(&"https://api.weather.com/current".to_string())
);
assert_eq!(directives.session_mutations.len(), 1);
assert_eq!(directives.computed.len(), 1);
}
#[test]
fn test_section_header_mixed() {
let content = r##"<what>
title: Dashboard
auth: user
fetch.legacy = "https://old-api.com/data"
[fetch.weather]
url = "https://api.weather.com/current"
method = "POST"
[]
session.count += 1
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.title, Some("Dashboard".to_string()));
assert_eq!(
directives.custom.get("fetch.legacy"),
Some(&"https://old-api.com/data".to_string())
);
assert_eq!(
directives.custom.get("fetch.weather.url"),
Some(&"https://api.weather.com/current".to_string())
);
assert_eq!(
directives.custom.get("fetch.weather.method"),
Some(&"POST".to_string())
);
assert_eq!(directives.session_mutations.len(), 1);
}
#[test]
fn test_section_header_multiple_fetch() {
let content = r##"<what>
[fetch.weather]
url = "https://api.weather.com/current"
path = "data.current"
[fetch.news]
url = "https://api.news.com/latest"
path = "articles"
limit = 10
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
assert_eq!(
directives.custom.get("fetch.weather.url"),
Some(&"https://api.weather.com/current".to_string())
);
assert_eq!(
directives.custom.get("fetch.weather.path"),
Some(&"data.current".to_string())
);
assert_eq!(
directives.custom.get("fetch.news.url"),
Some(&"https://api.news.com/latest".to_string())
);
assert_eq!(
directives.custom.get("fetch.news.path"),
Some(&"articles".to_string())
);
assert_eq!(
directives.custom.get("fetch.news.limit"),
Some(&"10".to_string())
);
}
#[test]
fn test_section_header_backward_compat() {
let content = r##"<what>
title: Remote Data
fetch.dogs = "https://dogapi.dog/api/v2/facts?limit=3"
fetch.dogs.path = "data"
session.count += 1
compute.greeting = "Hello!"
og.title: My Page
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.title, Some("Remote Data".to_string()));
assert_eq!(
directives.custom.get("fetch.dogs"),
Some(&"https://dogapi.dog/api/v2/facts?limit=3".to_string())
);
assert_eq!(
directives.custom.get("fetch.dogs.path"),
Some(&"data".to_string())
);
assert_eq!(directives.session_mutations.len(), 1);
assert_eq!(directives.computed.len(), 1);
assert_eq!(directives.vars.get("og.title"), Some(&json!("My Page")));
}
#[test]
fn inline_var_string() {
let content = r#"<what>
title = "My Page"
subtitle = "Welcome"
</what>
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.title, Some("My Page".to_string()));
assert_eq!(directives.vars.get("subtitle"), Some(&json!("Welcome")));
}
#[test]
fn inline_var_number() {
let content = r#"<what>
count = 42
price = 9.99
</what>
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.vars.get("count"), Some(&json!(42)));
assert_eq!(directives.vars.get("price"), Some(&json!(9.99)));
}
#[test]
fn inline_var_boolean() {
let content = r#"<what>
show_banner = true
debug = false
</what>
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.vars.get("show_banner"), Some(&json!(true)));
assert_eq!(directives.vars.get("debug"), Some(&json!(false)));
}
#[test]
fn inline_var_single_line_array() {
let content = r#"<what>
colors = ["red", "green", "blue"]
</what>
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(
directives.vars.get("colors"),
Some(&json!(["red", "green", "blue"]))
);
}
#[test]
fn inline_var_multi_line_array() {
let content = r##"<what>
products = [
{ "name": "Widget", "price": 9.99 },
{ "name": "Gadget", "price": 24.99 }
]
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
let products = directives.vars.get("products").unwrap();
assert!(products.is_array());
assert_eq!(products.as_array().unwrap().len(), 2);
assert_eq!(products[0]["name"], json!("Widget"));
assert_eq!(products[1]["price"], json!(24.99));
}
#[test]
fn inline_var_multi_line_object() {
let content = r##"<what>
config = {
"theme": "dark",
"sidebar": true
}
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
let config = directives.vars.get("config").unwrap();
assert!(config.is_object());
assert_eq!(config["theme"], json!("dark"));
assert_eq!(config["sidebar"], json!(true));
}
#[test]
fn inline_var_relaxed_json_unquoted_keys() {
let content = r##"<what>
products = [
{ name: "Widget", price: 9.99 },
{ name: "Gadget", price: 24.99 }
]
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
let products = directives.vars.get("products").unwrap();
assert!(products.is_array());
assert_eq!(products[0]["name"], json!("Widget"));
assert_eq!(products[1]["price"], json!(24.99));
}
#[test]
fn inline_var_relaxed_json_single_line() {
let content = r##"<what>
item = { name: "Widget", price: 9.99 }
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
let item = directives.vars.get("item").unwrap();
assert_eq!(item["name"], json!("Widget"));
assert_eq!(item["price"], json!(9.99));
}
#[test]
fn inline_var_does_not_affect_reserved() {
let content = r#"<what>
auth = user
layout = main.html
fetch.api = "https://example.com"
my_var = "hello"
</what>
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert!(directives.requires_auth());
assert_eq!(directives.layout, Some("main.html".to_string()));
assert_eq!(
directives.custom.get("fetch.api"),
Some(&"https://example.com".to_string())
);
assert_eq!(directives.vars.get("my_var"), Some(&json!("hello")));
assert!(directives.vars.get("auth").is_none());
assert!(directives.vars.get("layout").is_none());
}
#[test]
fn inline_var_mixed_with_directives() {
let content = r##"<what>
title = "Dashboard"
items_per_page = 25
nav_items = ["Home", "About", "Contact"]
fetch.data = "https://api.example.com/data"
compute.greeting = "Hello #user.name#!"
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.title, Some("Dashboard".to_string()));
assert_eq!(directives.vars.get("items_per_page"), Some(&json!(25)));
assert_eq!(
directives.vars.get("nav_items"),
Some(&json!(["Home", "About", "Contact"]))
);
assert_eq!(
directives.custom.get("fetch.data"),
Some(&"https://api.example.com/data".to_string())
);
assert_eq!(directives.computed.len(), 1);
}
#[test]
fn mutation_stored_as_custom_string() {
let content = r#"<what>
mutation.reset = "session.score = 0; session.lives = 3"
</what>
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(
directives.custom.get("mutation.reset"),
Some(&"session.score = 0; session.lives = 3".to_string())
);
assert!(directives.vars.get("mutation.reset").is_none());
}
#[test]
fn mutation_not_parsed_as_inline_var() {
let content = r#"<what>
mutation.toggle = "session.dark_mode = 1"
my_var = 42
</what>
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert!(directives.custom.contains_key("mutation.toggle"));
assert_eq!(directives.vars.get("my_var"), Some(&json!(42)));
}
#[test]
fn test_what_file_strings() {
let content = r#"
title = "My Application"
description = 'Single quotes work too'
bare_string = unquoted
"#;
let config = parse_what_file(content);
assert_eq!(config.get_string("title"), Some("My Application"));
assert_eq!(
config.get_string("description"),
Some("Single quotes work too")
);
assert_eq!(config.get_string("bare_string"), Some("unquoted"));
}
#[test]
fn test_what_file_numbers() {
let content = r#"
port = 8080
version = 1.5
negative = -42
"#;
let config = parse_what_file(content);
assert_eq!(config.get_number("port"), Some(8080.0));
assert_eq!(config.get_number("version"), Some(1.5));
assert_eq!(config.get_number("negative"), Some(-42.0));
}
#[test]
fn test_what_file_booleans() {
let content = r#"
debug = true
production = false
"#;
let config = parse_what_file(content);
assert_eq!(config.get_bool("debug"), Some(true));
assert_eq!(config.get_bool("production"), Some(false));
}
#[test]
fn test_what_file_arrays() {
let content = r#"
nav_items = ["Home", "About", "Contact"]
numbers = [1, 2, 3]
mixed = ["a", 1, true]
empty = []
"#;
let config = parse_what_file(content);
let nav = config.get_array("nav_items").unwrap();
assert_eq!(nav.len(), 3);
assert_eq!(nav[0].as_str(), Some("Home"));
let nums = config.get_array("numbers").unwrap();
assert_eq!(nums.len(), 3);
assert_eq!(nums[0].as_i64(), Some(1));
let empty = config.get_array("empty").unwrap();
assert!(empty.is_empty());
}
#[test]
fn test_what_file_comments() {
let content = r#"
// This is a comment
title = "Hello"
# This is also a comment
name = "World"
"#;
let config = parse_what_file(content);
assert_eq!(config.get_string("title"), Some("Hello"));
assert_eq!(config.get_string("name"), Some("World"));
assert!(config.values.len() == 2);
}
#[test]
fn test_what_file_auth_directive() {
let content = r#"
auth = "admin"
title = "Dashboard"
"#;
let config = parse_what_file(content);
assert_eq!(
config.directives.auth,
AuthLevel::Roles(vec!["admin".to_string()])
);
assert_eq!(config.directives.title, Some("Dashboard".to_string()));
}
#[test]
fn test_what_file_auth_all() {
let content = r#"
auth = "all"
"#;
let config = parse_what_file(content);
assert_eq!(config.directives.auth, AuthLevel::All);
assert!(!config.directives.requires_auth());
}
#[test]
fn test_what_file_roles_array() {
let content = r#"
roles = ["admin", "editor"]
"#;
let config = parse_what_file(content);
assert_eq!(config.directives.roles, vec!["admin", "editor"]);
assert!(config.directives.protected);
}
#[test]
fn test_what_config_merge() {
let content1 = r#"
title = "Parent"
theme = "light"
auth = "all"
"#;
let content2 = r#"
title = "Child"
nav = ["Home"]
auth = "admin"
"#;
let mut config1 = parse_what_file(content1);
let config2 = parse_what_file(content2);
config1.merge(&config2);
assert_eq!(config1.get_string("title"), Some("Child"));
assert_eq!(config1.get_string("theme"), Some("light"));
assert!(config1.get_array("nav").is_some());
assert_eq!(
config1.directives.auth,
AuthLevel::Roles(vec!["admin".to_string()])
);
}
#[test]
fn test_auth_level_edge_cases() {
assert_eq!(parse_auth_level(""), AuthLevel::All);
assert_eq!(parse_auth_level(" "), AuthLevel::All);
assert_eq!(parse_auth_level("ALL"), AuthLevel::All);
assert_eq!(parse_auth_level("User"), AuthLevel::User);
assert_eq!(
parse_auth_level("ADMIN"),
AuthLevel::Roles(vec!["admin".to_string()])
);
assert_eq!(
parse_auth_level(" admin , editor "),
AuthLevel::Roles(vec!["admin".to_string(), "editor".to_string()])
);
assert_eq!(
parse_auth_level("superuser"),
AuthLevel::Roles(vec!["superuser".to_string()])
);
}
#[test]
fn test_page_directives_cache_ttl() {
let content = r#"<what cache="3600" />
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.cache_ttl, Some(3600));
let content = r#"<what>
cache-ttl: 1800
</what>
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.cache_ttl, Some(1800));
}
#[test]
fn test_page_directives_custom() {
let content = r#"<what custom_field="my_value" another="test" />
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(
directives.custom.get("custom_field"),
Some(&"my_value".to_string())
);
assert_eq!(directives.custom.get("another"), Some(&"test".to_string()));
}
#[test]
fn test_page_directives_redirect() {
let content = r#"<what redirect="/new-page" />
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.redirect, Some("/new-page".to_string()));
}
#[test]
fn test_what_file_empty() {
let content = "";
let config = parse_what_file(content);
assert!(config.values.is_empty());
assert_eq!(config.directives.auth, AuthLevel::All);
}
#[test]
fn test_what_file_only_comments() {
let content = r#"
// This is a comment
# Another comment
// More comments
"#;
let config = parse_what_file(content);
assert!(config.values.is_empty());
}
#[test]
fn test_what_file_data_application() {
let content = r#"
data.application = ["posts", "products"]
"#;
let config = parse_what_file(content);
let names: Vec<&str> = config.data_application.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, vec!["posts", "products"]);
assert!(!config.values.contains_key("data.application"));
}
#[test]
fn test_what_file_data_application_scoped() {
let content = r#"
data.application = ["visits", "revenue [admin, editor]"]
"#;
let config = parse_what_file(content);
assert_eq!(config.data_application[0].name, "visits");
assert!(matches!(config.data_application[0].scope, WiredScope::Public));
assert_eq!(config.data_application[1].name, "revenue");
match &config.data_application[1].scope {
WiredScope::Roles(r) => assert_eq!(r, &vec!["admin".to_string(), "editor".to_string()]),
other => panic!("expected Roles, got {other:?}"),
}
}
#[test]
fn test_what_file_data_session() {
let content = r#"
data.session = ["cart", "wishlist"]
"#;
let config = parse_what_file(content);
assert_eq!(config.data_session, vec!["cart", "wishlist"]);
assert!(!config.values.contains_key("data.session"));
}
#[test]
fn test_what_file_data_single_value() {
let content = r#"
data.application = "posts"
data.session = "cart"
"#;
let config = parse_what_file(content);
assert_eq!(config.data_application.len(), 1);
assert_eq!(config.data_application[0].name, "posts");
assert_eq!(config.data_session, vec!["cart"]);
}
#[test]
fn test_what_value_edge_cases() {
assert_eq!(parse_what_value("[]"), serde_json::json!([]));
assert_eq!(parse_what_value("-3.14"), serde_json::json!(-3.14));
assert_eq!(parse_what_value("0"), serde_json::json!(0));
assert_eq!(
parse_what_value("9999999999"),
serde_json::json!(9999999999_i64)
);
}
#[test]
fn test_page_directives_mixed_syntax() {
let content = r#"<what protected title="Dashboard" exclude />
<html></html>"#;
let (directives, _) = parse_page_directives(content);
assert!(directives.protected);
assert!(directives.exclude);
assert_eq!(directives.title, Some("Dashboard".to_string()));
}
#[test]
fn test_is_standard_html_tag() {
assert!(is_standard_html_tag("div"));
assert!(is_standard_html_tag("span"));
assert!(is_standard_html_tag("html"));
assert!(is_standard_html_tag("body"));
assert!(is_standard_html_tag("form"));
assert!(is_standard_html_tag("input"));
assert!(is_standard_html_tag("template"));
assert!(is_standard_html_tag("slot"));
assert!(!is_standard_html_tag("jumbo"));
assert!(!is_standard_html_tag("card"));
assert!(!is_standard_html_tag("my-component"));
assert!(!is_standard_html_tag("loop"));
}
#[test]
fn test_page_directives_requires_auth() {
let mut d = PageDirectives::default();
d.auth = AuthLevel::All;
assert!(!d.requires_auth());
d.auth = AuthLevel::User;
assert!(d.requires_auth());
d.auth = AuthLevel::Roles(vec!["admin".to_string()]);
assert!(d.requires_auth());
d.auth = AuthLevel::All;
d.protected = true;
assert!(d.requires_auth());
}
#[test]
fn test_page_directives_check_access_legacy() {
let mut d = PageDirectives::default();
d.protected = true;
d.roles = vec!["admin".to_string(), "editor".to_string()];
assert!(!d.check_access(false, &[]));
assert!(!d.check_access(true, &[]));
assert!(!d.check_access(true, &["viewer".to_string()]));
assert!(d.check_access(true, &["admin".to_string()]));
assert!(d.check_access(true, &["editor".to_string()]));
}
#[test]
fn test_layout_in_what_file() {
let content = r#"
layout = "sections/main.html"
title = "Test Page"
"#;
let config = parse_what_file(content);
assert_eq!(config.layout, Some("sections/main.html".to_string()));
assert_eq!(
config.directives.layout,
Some("sections/main.html".to_string())
);
assert!(config.get_string("layout").is_none());
}
#[test]
fn test_layout_in_page_directive_attribute() {
let content = r#"<what layout="sections/page.html" />
<h1>Hello</h1>"#;
let (directives, cleaned) = parse_page_directives(content);
assert_eq!(directives.layout, Some("sections/page.html".to_string()));
assert!(cleaned.contains("<h1>Hello</h1>"));
assert!(!cleaned.contains("<what"));
}
#[test]
fn test_layout_in_page_directive_content() {
let content = r#"<what>
layout: sections/admin.html
title: Dashboard
</what>
<h1>Admin Dashboard</h1>"#;
let (directives, cleaned) = parse_page_directives(content);
assert_eq!(directives.layout, Some("sections/admin.html".to_string()));
assert_eq!(directives.title, Some("Dashboard".to_string()));
assert!(cleaned.contains("<h1>Admin Dashboard</h1>"));
}
#[test]
fn test_layout_none_disables() {
let content = r#"<what layout="none" />
<h1>No Layout</h1>"#;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.layout, Some("none".to_string()));
}
#[test]
fn test_what_config_layout_merge() {
let parent_content = r#"
layout = "sections/base.html"
title = "Parent"
"#;
let child_content = r#"
layout = "sections/admin.html"
"#;
let mut parent = parse_what_file(parent_content);
let child = parse_what_file(child_content);
parent.merge(&child);
assert_eq!(parent.layout, Some("sections/admin.html".to_string()));
}
#[test]
fn test_what_config_layout_inherit() {
let parent_content = r#"
layout = "sections/base.html"
title = "Parent"
"#;
let child_content = r#"
title = "Child"
"#;
let mut parent = parse_what_file(parent_content);
let child = parse_what_file(child_content);
parent.merge(&child);
assert_eq!(parent.layout, Some("sections/base.html".to_string()));
assert_eq!(parent.get_string("title"), Some("Child"));
}
#[test]
fn test_what_config_layout_none_override() {
let parent_content = r#"
layout = "sections/base.html"
"#;
let child_content = r#"
layout = "none"
"#;
let mut parent = parse_what_file(parent_content);
let child = parse_what_file(child_content);
parent.merge(&child);
assert_eq!(parent.layout, Some("none".to_string()));
}
#[test]
fn test_reactive_session_var_in_text() {
let mut context = HashMap::new();
context.insert(
"session".to_string(),
serde_json::json!({
"count": 8
}),
);
let template = "<p>Counter: #session.count#</p>";
let result = replace_variables_reactive(template, &context);
assert!(
result
.html
.contains(r#"<span w-bind="session.count">8</span>"#)
);
assert!(result.session_keys.contains("count"));
}
#[test]
fn test_reactive_session_var_in_attribute() {
let mut context = HashMap::new();
context.insert(
"session".to_string(),
serde_json::json!({
"count": 8
}),
);
let template = r##"<div title="#session.count#">Content</div>"##;
let result = replace_variables_reactive(template, &context);
assert!(result.html.contains(r##"title="8""##));
assert!(!result.html.contains("w-bind"));
assert!(result.session_keys.contains("count"));
}
#[test]
fn test_reactive_non_session_var() {
let mut context = HashMap::new();
context.insert("name".to_string(), serde_json::json!("Alice"));
let template = "<p>Hello #name#!</p>";
let result = replace_variables_reactive(template, &context);
assert!(result.html.contains("Hello Alice!"));
assert!(!result.html.contains("w-bind"));
assert!(result.session_keys.is_empty());
}
#[test]
fn test_reactive_multiple_session_vars() {
let mut context = HashMap::new();
context.insert(
"session".to_string(),
serde_json::json!({
"count": 5,
"name": "Test"
}),
);
let template = "<p>Count: #session.count#, Name: #session.name#</p>";
let result = replace_variables_reactive(template, &context);
assert!(
result
.html
.contains(r#"<span w-bind="session.count">5</span>"#)
);
assert!(
result
.html
.contains(r#"<span w-bind="session.name">Test</span>"#)
);
assert!(result.session_keys.contains("count"));
assert!(result.session_keys.contains("name"));
}
#[test]
fn test_is_in_attribute_context() {
assert!(!is_in_attribute_context("<p>"));
assert!(!is_in_attribute_context("<p>Hello "));
assert!(!is_in_attribute_context("<div><span>"));
assert!(is_in_attribute_context(r#"<div title=""#));
assert!(is_in_attribute_context(r#"<div class="foo "#));
assert!(is_in_attribute_context(r#"<input value=""#));
assert!(!is_in_attribute_context(r#"<div title="test">"#));
assert!(!is_in_attribute_context(r#"<div class="foo">Hello"#));
}
#[test]
fn test_html_escape() {
assert_eq!(html_escape("<"), "<");
assert_eq!(html_escape(">"), ">");
assert_eq!(html_escape("&"), "&");
assert_eq!(html_escape("\""), """);
assert_eq!(html_escape("'"), "'");
assert_eq!(
html_escape("<script>alert('xss')</script>"),
"<script>alert('xss')</script>"
);
}
#[test]
fn test_parse_session_mutation_push() {
let m = parse_session_mutation("session.items.push(\"hello\")").unwrap();
match m {
SessionMutation::Push { key, value } => {
assert_eq!(key, "items");
assert_eq!(value, serde_json::json!("hello"));
}
_ => panic!("Expected Push"),
}
}
#[test]
fn test_parse_session_mutation_pushmax() {
let m = parse_session_mutation("session.history.pushmax(5, \"page1\")").unwrap();
match m {
SessionMutation::PushMax { key, max, value } => {
assert_eq!(key, "history");
assert_eq!(max, 5);
assert_eq!(value, serde_json::json!("page1"));
}
_ => panic!("Expected PushMax"),
}
}
#[test]
fn test_parse_session_mutation_pushmax_numeric() {
let m = parse_session_mutation("session.ids.pushmax(10, 42)").unwrap();
match m {
SessionMutation::PushMax { key, max, value } => {
assert_eq!(key, "ids");
assert_eq!(max, 10);
assert_eq!(value, serde_json::json!(42));
}
_ => panic!("Expected PushMax"),
}
}
#[test]
fn test_auto_escape_html_in_variables() {
let mut context = HashMap::new();
context.insert(
"name".to_string(),
serde_json::json!("<script>alert('xss')</script>"),
);
let result = replace_variables("Hello #name#!", &context);
assert_eq!(
result,
"Hello <script>alert('xss')</script>!"
);
assert!(!result.contains("<script>"));
}
#[test]
fn test_auto_escape_ampersand() {
let mut context = HashMap::new();
context.insert("text".to_string(), serde_json::json!("Tom & Jerry"));
let result = replace_variables("#text#", &context);
assert_eq!(result, "Tom & Jerry");
}
#[test]
fn test_auto_escape_quotes() {
let mut context = HashMap::new();
context.insert("text".to_string(), serde_json::json!(r#"He said "hello""#));
let result = replace_variables("#text#", &context);
assert_eq!(result, "He said "hello"");
}
#[test]
fn test_raw_filter_bypasses_escaping() {
let mut context = HashMap::new();
context.insert("html".to_string(), serde_json::json!("<b>bold</b>"));
let result = replace_variables("#html|raw#", &context);
assert_eq!(result, "<b>bold</b>");
}
#[test]
fn test_raw_filter_with_default_value() {
let mut context = HashMap::new();
context.insert("html".to_string(), serde_json::json!("<em>yes</em>"));
let result = replace_variables("#html|raw#", &context);
assert_eq!(result, "<em>yes</em>");
}
#[test]
fn test_auto_escape_preserves_safe_text() {
let mut context = HashMap::new();
context.insert("name".to_string(), serde_json::json!("Alice"));
let result = replace_variables("Hello #name#!", &context);
assert_eq!(result, "Hello Alice!");
}
#[test]
fn test_auto_escape_nested_variables() {
let mut context = HashMap::new();
context.insert(
"user".to_string(),
serde_json::json!({
"name": "<b>Admin</b>",
"bio": "Loves coding & testing"
}),
);
let result = replace_variables("#user.name# - #user.bio#", &context);
assert_eq!(
result,
"<b>Admin</b> - Loves coding & testing"
);
}
#[test]
fn test_auto_escape_with_default_filter() {
let context = HashMap::new();
let result = replace_variables(r##"#missing|default:"<fallback>"#"##, &context);
assert_eq!(result, "<fallback>");
}
#[test]
fn test_reactive_auto_escape_non_session_var() {
let mut context = HashMap::new();
context.insert(
"name".to_string(),
serde_json::json!("<script>xss</script>"),
);
let result = replace_variables_reactive("<p>#name#</p>", &context);
assert!(result.html.contains("<script>xss</script>"));
assert!(!result.html.contains("<script>xss</script>"));
}
#[test]
fn test_reactive_auto_escape_session_var_in_attribute() {
let mut context = HashMap::new();
context.insert(
"session".to_string(),
serde_json::json!({
"name": "Tom & Jerry"
}),
);
let template = r##"<div title="#session.name#">Content</div>"##;
let result = replace_variables_reactive(template, &context);
assert!(result.html.contains("Tom & Jerry"));
assert!(!result.html.contains("w-bind"));
}
#[test]
fn test_reactive_raw_filter_non_session_var() {
let mut context = HashMap::new();
context.insert("html".to_string(), serde_json::json!("<b>bold</b>"));
let result = replace_variables_reactive("<p>#html|raw#</p>", &context);
assert!(result.html.contains("<b>bold</b>"));
}
#[test]
fn test_reactive_session_var_always_escaped_in_span() {
let mut context = HashMap::new();
context.insert(
"session".to_string(),
serde_json::json!({
"name": "<script>xss</script>"
}),
);
let result = replace_variables_reactive("<p>#session.name#</p>", &context);
assert!(result.html.contains("<script>xss</script>"));
assert!(result.html.contains("w-bind"));
}
#[test]
fn test_reactive_session_raw_in_attribute() {
let mut context = HashMap::new();
context.insert(
"session".to_string(),
serde_json::json!({
"url": "/path?a=1&b=2"
}),
);
let template = r##"<a href="#session.url|raw#">Link</a>"##;
let result = replace_variables_reactive(template, &context);
assert!(result.html.contains(r#"href="/path?a=1&b=2""#));
}
#[test]
fn test_auto_escape_number_values() {
let mut context = HashMap::new();
context.insert("count".to_string(), serde_json::json!(42));
let result = replace_variables("Count: #count#", &context);
assert_eq!(result, "Count: 42");
}
#[test]
fn test_auto_escape_boolean_values() {
let mut context = HashMap::new();
context.insert("flag".to_string(), serde_json::json!(true));
let result = replace_variables("Flag: #flag#", &context);
assert_eq!(result, "Flag: true");
}
#[test]
fn test_auto_escape_unresolved_variable() {
let context = HashMap::new();
let result = replace_variables("#unknown#", &context);
assert_eq!(result, "#unknown#");
}
#[test]
fn test_nested_var_on_empty_parent_resolves_empty() {
let mut context = HashMap::new();
context.insert("old".into(), serde_json::json!({}));
let result = replace_variables("#old.title#", &context);
assert_eq!(
result, "",
"Missing child on existing parent should be empty"
);
}
#[test]
fn test_nested_var_on_missing_root_stays_literal() {
let context = HashMap::new();
let result = replace_variables("#old.title#", &context);
assert_eq!(result, "#old.title#", "Missing root should keep literal");
}
#[test]
fn test_nested_var_on_populated_parent_resolves() {
let mut context = HashMap::new();
context.insert("user".into(), serde_json::json!({"name": "Alice"}));
assert_eq!(replace_variables("#user.name#", &context), "Alice");
assert_eq!(replace_variables("#user.role#", &context), "");
}
#[test]
fn test_filter_parse_chain() {
let (path, filters) = parse_filter_chain("name|uppercase");
assert_eq!(path, "name");
assert_eq!(filters.len(), 1);
assert_eq!(filters[0].name, "uppercase");
assert!(filters[0].args.is_empty());
}
#[test]
fn test_filter_parse_with_arg() {
let (path, filters) = parse_filter_chain("title|truncate:50");
assert_eq!(path, "title");
assert_eq!(filters.len(), 1);
assert_eq!(filters[0].name, "truncate");
assert_eq!(filters[0].args, vec!["50"]);
}
#[test]
fn test_filter_parse_chained() {
let (path, filters) = parse_filter_chain("title|truncate:50|uppercase");
assert_eq!(path, "title");
assert_eq!(filters.len(), 2);
assert_eq!(filters[0].name, "truncate");
assert_eq!(filters[1].name, "uppercase");
}
#[test]
fn test_filter_parse_quoted_args() {
let (path, filters) = parse_filter_chain(r#"name|default:"Anonymous""#);
assert_eq!(path, "name");
assert_eq!(filters.len(), 1);
assert_eq!(filters[0].name, "default");
assert_eq!(filters[0].args, vec!["Anonymous"]);
}
#[test]
fn test_filter_parse_multiple_args() {
let (path, filters) = parse_filter_chain(r#"text|replace:"old","new""#);
assert_eq!(path, "text");
assert_eq!(filters.len(), 1);
assert_eq!(filters[0].name, "replace");
assert_eq!(filters[0].args, vec!["old", "new"]);
}
#[test]
fn test_filter_uppercase() {
let mut ctx = HashMap::new();
ctx.insert("name".to_string(), serde_json::json!("hello"));
let result = replace_variables("#name|uppercase#", &ctx);
assert_eq!(result, "HELLO");
}
#[test]
fn test_filter_lowercase() {
let mut ctx = HashMap::new();
ctx.insert("name".to_string(), serde_json::json!("HELLO"));
let result = replace_variables("#name|lowercase#", &ctx);
assert_eq!(result, "hello");
}
#[test]
fn test_filter_capitalize() {
let mut ctx = HashMap::new();
ctx.insert("name".to_string(), serde_json::json!("hello world"));
let result = replace_variables("#name|capitalize#", &ctx);
assert_eq!(result, "Hello world");
}
#[test]
fn test_filter_title() {
let mut ctx = HashMap::new();
ctx.insert("name".to_string(), serde_json::json!("hello world today"));
let result = replace_variables("#name|title#", &ctx);
assert_eq!(result, "Hello World Today");
}
#[test]
fn test_filter_truncate() {
let mut ctx = HashMap::new();
ctx.insert(
"text".to_string(),
serde_json::json!("This is a long text that should be truncated"),
);
let result = replace_variables("#text|truncate:10#", &ctx);
assert_eq!(result, "This is a ...");
}
#[test]
fn test_filter_truncate_short_text() {
let mut ctx = HashMap::new();
ctx.insert("text".to_string(), serde_json::json!("Short"));
let result = replace_variables("#text|truncate:10#", &ctx);
assert_eq!(result, "Short");
}
#[test]
fn test_filter_count() {
let mut ctx = HashMap::new();
ctx.insert("text".to_string(), serde_json::json!("hello"));
let result = replace_variables("#text|count#", &ctx);
assert_eq!(result, "5");
}
#[test]
fn test_filter_number() {
let mut ctx = HashMap::new();
ctx.insert("n".to_string(), serde_json::json!(1234567));
let result = replace_variables("#n|number#", &ctx);
assert_eq!(result, "1,234,567");
}
#[test]
fn test_filter_currency_usd() {
let mut ctx = HashMap::new();
ctx.insert("price".to_string(), serde_json::json!(1299.99));
let result = replace_variables(r##"#price|currency:"USD"#"##, &ctx);
assert_eq!(result, "$1,299.99");
}
#[test]
fn test_filter_currency_eur() {
let mut ctx = HashMap::new();
ctx.insert("price".to_string(), serde_json::json!(49.5));
let result = replace_variables(r##"#price|currency:"EUR"#"##, &ctx);
assert_eq!(result, "\u{20ac}49.50");
}
#[test]
fn test_filter_date() {
let mut ctx = HashMap::new();
ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
let result = replace_variables("#d|date#", &ctx);
assert_eq!(result, "Mar 15, 2025"); }
#[test]
fn test_filter_date_custom_format() {
let mut ctx = HashMap::new();
ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
let result = replace_variables(r##"#d|date:"dd/mm/yyyy"#"##, &ctx);
assert_eq!(result, "15/03/2025");
}
#[test]
fn test_date_mask_short() {
let mut ctx = HashMap::new();
ctx.insert("d".to_string(), serde_json::json!("2025-03-05"));
let result = replace_variables(r##"#d|date:"short"#"##, &ctx);
assert_eq!(result, "3/5/25");
}
#[test]
fn test_date_mask_full() {
let mut ctx = HashMap::new();
ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
let result = replace_variables(r##"#d|date:"full"#"##, &ctx);
assert_eq!(result, "Saturday, March 15, 2025");
}
#[test]
fn test_date_mask_long() {
let mut ctx = HashMap::new();
ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
let result = replace_variables(r##"#d|date:"long"#"##, &ctx);
assert_eq!(result, "March 15, 2025");
}
#[test]
fn test_date_mask_iso() {
let mut ctx = HashMap::new();
ctx.insert("d".to_string(), serde_json::json!("2025-03-05"));
let result = replace_variables(r##"#d|date:"iso"#"##, &ctx);
assert_eq!(result, "2025-03-05");
}
#[test]
fn test_date_mask_time() {
let mut ctx = HashMap::new();
ctx.insert("d".to_string(), serde_json::json!("2025-03-15T14:05:09"));
let result = replace_variables(r##"#d|date:"time"#"##, &ctx);
assert_eq!(result, "2:05 PM");
}
#[test]
fn test_date_mask_combined() {
let mut ctx = HashMap::new();
ctx.insert("d".to_string(), serde_json::json!("2025-03-15T14:30:00"));
let result = replace_variables(r##"#d|date:"mmm d, yyyy h:nn tt"#"##, &ctx);
assert_eq!(result, "Mar 15, 2025 2:30 PM");
}
#[test]
fn test_date_mask_24hour() {
let mut ctx = HashMap::new();
ctx.insert("d".to_string(), serde_json::json!("2025-03-15T09:05:00"));
let result = replace_variables(r##"#d|date:"HH:nn"#"##, &ctx);
assert_eq!(result, "09:05");
}
#[test]
fn test_date_mask_weekday() {
let mut ctx = HashMap::new();
ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
let result = replace_variables(r##"#d|date:"ddd"#"##, &ctx);
assert_eq!(result, "Sat");
}
#[test]
fn test_date_mask_literals() {
let mut ctx = HashMap::new();
ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
let result = replace_variables(r##"#d|date:"yyyy-mm-dd"#"##, &ctx);
assert_eq!(result, "2025-03-15");
}
#[test]
fn test_date_mask_midnight_12hr() {
let mut ctx = HashMap::new();
ctx.insert("d".to_string(), serde_json::json!("2025-03-15T00:00:00"));
let result = replace_variables(r##"#d|date:"h:nn tt"#"##, &ctx);
assert_eq!(result, "12:00 AM");
}
#[test]
fn test_date_rfc3339_input() {
let mut ctx = HashMap::new();
ctx.insert("d".to_string(), serde_json::json!("2025-03-15T14:30:00Z"));
let result = replace_variables(r##"#d|date:"mmm d"#"##, &ctx);
assert_eq!(result, "Mar 15");
}
#[test]
fn test_filter_json() {
let mut ctx = HashMap::new();
ctx.insert("name".to_string(), serde_json::json!("hello"));
let result = replace_variables("#name|json#", &ctx);
assert_eq!(result, ""hello""); }
#[test]
fn test_filter_json_raw() {
let mut ctx = HashMap::new();
ctx.insert("name".to_string(), serde_json::json!("hello"));
let result = replace_variables("#name|json|raw#", &ctx);
assert_eq!(result, "\"hello\""); }
#[test]
fn test_filter_markdown() {
let mut ctx = HashMap::new();
ctx.insert(
"text".to_string(),
serde_json::json!("This is **bold** and *italic*"),
);
let result = replace_variables("#text|markdown#", &ctx);
assert!(result.contains("<strong>bold</strong>"));
assert!(result.contains("<em>italic</em>"));
assert!(result.contains("<p>"));
}
#[test]
fn test_filter_pluralize() {
let mut ctx = HashMap::new();
ctx.insert("count".to_string(), serde_json::json!(1));
let result = replace_variables(r##"#count# item#count|pluralize:"","s"#"##, &ctx);
assert_eq!(result, "1 item");
ctx.insert("count".to_string(), serde_json::json!(5));
let result = replace_variables(r##"#count# item#count|pluralize:"","s"#"##, &ctx);
assert_eq!(result, "5 items");
}
#[test]
fn test_filter_default() {
let ctx = HashMap::new();
let result = replace_variables(r##"#missing|default:"N/A"#"##, &ctx);
assert_eq!(result, "N/A");
}
#[test]
fn test_filter_default_not_needed() {
let mut ctx = HashMap::new();
ctx.insert("name".to_string(), serde_json::json!("Alice"));
let result = replace_variables(r##"#name|default:"N/A"#"##, &ctx);
assert_eq!(result, "Alice");
}
#[test]
fn test_filter_replace() {
let mut ctx = HashMap::new();
ctx.insert("text".to_string(), serde_json::json!("Hello World"));
let result = replace_variables(r##"#text|replace:"World","Rust"#"##, &ctx);
assert_eq!(result, "Hello Rust");
}
#[test]
fn test_filter_slice() {
let mut ctx = HashMap::new();
ctx.insert("text".to_string(), serde_json::json!("Hello World"));
let result = replace_variables("#text|slice:0,5#", &ctx);
assert_eq!(result, "Hello");
}
#[test]
fn test_filter_chaining() {
let mut ctx = HashMap::new();
ctx.insert("name".to_string(), serde_json::json!("hello world"));
let result = replace_variables("#name|uppercase|truncate:5#", &ctx);
assert_eq!(result, "HELLO...");
}
#[test]
fn test_filter_chaining_with_escaping() {
let mut ctx = HashMap::new();
ctx.insert("text".to_string(), serde_json::json!("<b>hello</b>"));
let result = replace_variables("#text|uppercase#", &ctx);
assert_eq!(result, "<B>HELLO</B>");
}
#[test]
fn test_filter_raw_bypasses_escaping() {
let mut ctx = HashMap::new();
ctx.insert("html".to_string(), serde_json::json!("<b>bold</b>"));
let result = replace_variables("#html|raw#", &ctx);
assert_eq!(result, "<b>bold</b>");
}
#[test]
fn test_filter_in_reactive_mode() {
let mut ctx = HashMap::new();
ctx.insert("name".to_string(), serde_json::json!("hello"));
let result = replace_variables_reactive("<p>#name|uppercase#</p>", &ctx);
assert!(result.html.contains("HELLO"));
}
#[test]
fn test_filter_default_with_session_var() {
let ctx = HashMap::new();
let result = replace_variables_reactive(r##"<p>#session.count|default:"0"#</p>"##, &ctx);
assert!(result.html.contains("w-bind"));
assert!(result.html.contains(">0<"));
}
#[test]
fn test_filter_unknown_passes_through() {
let mut ctx = HashMap::new();
ctx.insert("name".to_string(), serde_json::json!("hello"));
let result = replace_variables("#name|bogusfilter#", &ctx);
assert_eq!(result, "hello");
}
#[test]
fn test_filter_round() {
let mut ctx = HashMap::new();
ctx.insert("price".to_string(), json!("3.14159"));
assert_eq!(replace_variables("#price|round:2#", &ctx), "3.14");
}
#[test]
fn test_filter_round_no_args() {
let mut ctx = HashMap::new();
ctx.insert("val".to_string(), json!("3.7"));
assert_eq!(replace_variables("#val|round#", &ctx), "4");
}
#[test]
fn test_filter_ceil() {
let mut ctx = HashMap::new();
ctx.insert("val".to_string(), json!("3.2"));
assert_eq!(replace_variables("#val|ceil#", &ctx), "4");
}
#[test]
fn test_filter_floor() {
let mut ctx = HashMap::new();
ctx.insert("val".to_string(), json!("3.9"));
assert_eq!(replace_variables("#val|floor#", &ctx), "3");
}
#[test]
fn test_filter_ceil_negative() {
let mut ctx = HashMap::new();
ctx.insert("val".to_string(), json!("-2.3"));
assert_eq!(replace_variables("#val|ceil#", &ctx), "-2");
}
#[test]
fn test_filter_floor_negative() {
let mut ctx = HashMap::new();
ctx.insert("val".to_string(), json!("-2.3"));
assert_eq!(replace_variables("#val|floor#", &ctx), "-3");
}
#[test]
fn test_arithmetic_basic_addition() {
assert_eq!(evaluate_arithmetic("10 + 1"), Some(11.0));
}
#[test]
fn test_arithmetic_subtraction() {
assert_eq!(evaluate_arithmetic("10 - 3"), Some(7.0));
}
#[test]
fn test_arithmetic_multiply() {
assert_eq!(evaluate_arithmetic("5 * 3"), Some(15.0));
}
#[test]
fn test_arithmetic_divide() {
assert_eq!(evaluate_arithmetic("10 / 4"), Some(2.5));
}
#[test]
fn test_arithmetic_precedence() {
assert_eq!(evaluate_arithmetic("2 + 3 * 4"), Some(14.0));
}
#[test]
fn test_arithmetic_division_by_zero() {
assert_eq!(evaluate_arithmetic("10 / 0"), None);
}
#[test]
fn test_arithmetic_negative_result() {
assert_eq!(evaluate_arithmetic("3 - 10"), Some(-7.0));
}
#[test]
fn test_arithmetic_not_arithmetic() {
assert_eq!(evaluate_arithmetic("hello"), None);
assert_eq!(evaluate_arithmetic("42"), None);
}
#[test]
fn test_arithmetic_in_template() {
let mut ctx = HashMap::new();
ctx.insert("session".to_string(), json!({"age": 25}));
let result = replace_variables("#session.age + 1#", &ctx);
assert_eq!(result, "26");
}
#[test]
fn test_arithmetic_multiply_in_template() {
let mut ctx = HashMap::new();
ctx.insert("price".to_string(), json!(100));
let result = replace_variables("#price * 0.21#", &ctx);
assert_eq!(result, "21");
}
#[test]
fn test_arithmetic_with_filter() {
let mut ctx = HashMap::new();
ctx.insert("price".to_string(), json!(99.99));
let result = replace_variables("#price * 0.21|round:2#", &ctx);
assert_eq!(result, "21.00");
}
#[test]
fn test_no_filters_still_escapes() {
let mut ctx = HashMap::new();
ctx.insert(
"xss".to_string(),
serde_json::json!("<script>alert(1)</script>"),
);
let result = replace_variables("#xss#", &ctx);
assert!(!result.contains("<script>"));
assert!(result.contains("<script>"));
}
#[test]
fn test_computed_variable_parsing() {
let content = r##"<what>
title: My Page
compute.greeting = "Hello #user.name#!"
compute.full_url = "/posts/#post.id#"
</what>
<html></html>"##;
let (directives, _) = parse_page_directives(content);
assert_eq!(directives.computed.len(), 2);
assert_eq!(directives.computed[0].0, "greeting");
assert_eq!(directives.computed[0].1, "Hello #user.name#!");
assert_eq!(directives.computed[1].0, "full_url");
assert_eq!(directives.computed[1].1, "/posts/#post.id#");
}
#[test]
fn test_computed_variable_resolution() {
let mut context = HashMap::new();
context.insert(
"user".to_string(),
serde_json::json!({
"name": "Alice"
}),
);
let computed = vec![("greeting".to_string(), "Hello #user.name#!".to_string())];
resolve_computed_variables(&computed, &mut context);
assert_eq!(
context.get("greeting"),
Some(&serde_json::json!("Hello Alice!"))
);
}
#[test]
fn test_computed_variable_chained() {
let mut context = HashMap::new();
context.insert("first".to_string(), serde_json::json!("John"));
context.insert("last".to_string(), serde_json::json!("Doe"));
let computed = vec![
("full_name".to_string(), "#first# #last#".to_string()),
("greeting".to_string(), "Hello #full_name#!".to_string()),
];
resolve_computed_variables(&computed, &mut context);
assert_eq!(
context.get("full_name"),
Some(&serde_json::json!("John Doe"))
);
assert_eq!(
context.get("greeting"),
Some(&serde_json::json!("Hello John Doe!"))
);
}
#[test]
fn test_computed_variable_with_nested_path() {
let mut context = HashMap::new();
context.insert(
"post".to_string(),
serde_json::json!({
"id": 42,
"title": "My Post"
}),
);
let computed = vec![(
"edit_url".to_string(),
"/admin/posts/#post.id#/edit".to_string(),
)];
resolve_computed_variables(&computed, &mut context);
assert_eq!(
context.get("edit_url"),
Some(&serde_json::json!("/admin/posts/42/edit"))
);
}
#[test]
fn test_computed_variable_unresolved_reference() {
let mut context = HashMap::new();
let computed = vec![("url".to_string(), "/page/#missing_var#".to_string())];
resolve_computed_variables(&computed, &mut context);
assert_eq!(
context.get("url"),
Some(&serde_json::json!("/page/#missing_var#"))
);
}
#[test]
fn test_computed_variable_no_prefix_in_template() {
let mut context = HashMap::new();
context.insert("x".to_string(), serde_json::json!("world"));
let computed = vec![("greeting".to_string(), "hello #x#".to_string())];
resolve_computed_variables(&computed, &mut context);
let result = replace_variables("Say: #greeting#", &context);
assert_eq!(result, "Say: hello world");
}
#[test]
fn test_computed_variable_empty() {
let mut context = HashMap::new();
let computed: Vec<(String, String)> = Vec::new();
resolve_computed_variables(&computed, &mut context);
assert!(context.is_empty());
}
#[test]
fn parse_wired_no_brackets() {
let decl = parse_wired_decl("counter");
assert_eq!(decl.name, "counter");
assert!(matches!(decl.scope, WiredScope::Public));
}
#[test]
fn parse_wired_single_role() {
let decl = parse_wired_decl("revenue [admin]");
assert_eq!(decl.name, "revenue");
match decl.scope {
WiredScope::Roles(roles) => assert_eq!(roles, vec!["admin"]),
_ => panic!("Expected Roles scope"),
}
}
#[test]
fn parse_wired_multi_role() {
let decl = parse_wired_decl("x [admin, editor]");
assert_eq!(decl.name, "x");
match decl.scope {
WiredScope::Roles(roles) => assert_eq!(roles, vec!["admin", "editor"]),
_ => panic!("Expected Roles scope"),
}
}
#[test]
fn parse_wired_user_scope() {
let decl = parse_wired_decl("notifs [user]");
assert_eq!(decl.name, "notifs");
assert!(matches!(decl.scope, WiredScope::User(_)));
}
#[test]
fn wired_backwards_compat() {
let content = r#"data.wired = ["counter", "visitors"]"#;
let config = parse_what_file(content);
assert_eq!(config.data_wired.len(), 2);
assert_eq!(config.data_wired[0].name, "counter");
assert!(matches!(config.data_wired[0].scope, WiredScope::Public));
assert_eq!(config.data_wired[1].name, "visitors");
assert!(matches!(config.data_wired[1].scope, WiredScope::Public));
}
#[test]
fn wired_scope_allows_public() {
let scope = WiredScope::Public;
assert!(scope.allows(&[], None));
assert!(scope.allows(&["admin".into()], Some("user1")));
}
#[test]
fn wired_scope_allows_role_match() {
let scope = WiredScope::Roles(vec!["admin".into(), "editor".into()]);
assert!(scope.allows(&["admin".into()], None));
assert!(scope.allows(&["editor".into()], None));
assert!(!scope.allows(&["viewer".into()], None));
assert!(!scope.allows(&[], None));
}
#[test]
fn wired_scope_allows_user_match() {
let scope = WiredScope::User("user42".into());
assert!(scope.allows(&[], Some("user42")));
assert!(!scope.allows(&[], Some("user99")));
assert!(!scope.allows(&[], None));
}
#[test]
fn test_is_unquoted_string() {
assert!(!is_unquoted_string("42"));
assert!(!is_unquoted_string("3.14"));
assert!(!is_unquoted_string("-1"));
assert!(!is_unquoted_string("true"));
assert!(!is_unquoted_string("false"));
assert!(!is_unquoted_string("none"));
assert!(!is_unquoted_string("all"));
assert!(!is_unquoted_string("user"));
assert!(!is_unquoted_string("None"));
assert!(!is_unquoted_string(""));
assert!(is_unquoted_string("Hello World"));
assert!(is_unquoted_string("local:items"));
assert!(is_unquoted_string("main"));
assert!(is_unquoted_string("/login"));
}
#[test]
fn test_quoted_strings_no_warning() {
let content = r#"title: "My Page"
layout: "main"
fetch.items = "local:items"
greeting = "Hello World""#;
let mut directives = PageDirectives::default();
parse_directive_content(content, &mut directives);
assert_eq!(directives.title.as_deref(), Some("My Page"));
assert_eq!(directives.layout.as_deref(), Some("main"));
assert_eq!(
directives.custom.get("fetch.items").map(|s| s.as_str()),
Some("local:items")
);
assert_eq!(
directives.vars.get("greeting"),
Some(&serde_json::json!("Hello World"))
);
}
#[test]
fn test_unquoted_numbers_and_bools_ok() {
let content = "count = 42\nprice = 9.99\nactive = true";
let mut directives = PageDirectives::default();
parse_directive_content(content, &mut directives);
assert_eq!(directives.vars.get("count"), Some(&serde_json::json!(42)));
assert_eq!(directives.vars.get("price"), Some(&serde_json::json!(9.99)));
assert_eq!(
directives.vars.get("active"),
Some(&serde_json::json!(true))
);
}
#[test]
fn test_html_unescape_round_trip() {
assert_eq!(html_unescape(&html_escape("Ben & Jerry")), "Ben & Jerry");
assert_eq!(html_unescape(&html_escape("O'Brien")), "O'Brien");
assert_eq!(html_unescape(&html_escape("a < b > c")), "a < b > c");
assert_eq!(html_unescape(&html_escape("<")), "<");
assert_eq!(html_unescape("plain"), "plain");
}
#[test]
fn test_count_filter_counts_items_not_bytes() {
let mut ctx = HashMap::new();
ctx.insert(
"items".to_string(),
serde_json::json!([{"name": "a"}, {"name": "b"}, {"name": "c"}]),
);
ctx.insert("name".to_string(), serde_json::json!("José"));
assert_eq!(replace_variables("#items|count#", &ctx), "3");
assert_eq!(replace_variables("#name|count#", &ctx), "4");
}
#[test]
fn test_within_one_edit() {
assert!(within_one_edit("auth", "auth"));
assert!(within_one_edit("auht", "auth")); assert!(within_one_edit("atuh", "auth")); assert!(within_one_edit("aut", "auth")); assert!(within_one_edit("auths", "auth")); assert!(within_one_edit("autj", "auth")); assert!(within_one_edit("oauth", "auth")); assert!(!within_one_edit("author", "auth"));
assert!(!within_one_edit("au", "auth"));
assert!(!within_one_edit("layout", "auth"));
}
#[test]
fn test_access_directive_near_miss() {
assert_eq!(access_directive_near_miss("auht"), Some("auth"));
assert_eq!(access_directive_near_miss("atuh"), Some("auth"));
assert_eq!(access_directive_near_miss("aut"), Some("auth"));
assert_eq!(access_directive_near_miss("Auth"), Some("auth")); assert_eq!(access_directive_near_miss("role"), Some("roles"));
assert_eq!(access_directive_near_miss("protectd"), Some("protected"));
assert_eq!(access_directive_near_miss("auth"), None);
assert_eq!(access_directive_near_miss("oauth"), None);
assert_eq!(access_directive_near_miss("title"), None);
assert_eq!(access_directive_near_miss("items"), None);
}
#[test]
fn test_auth_typo_key_stays_inline_var_and_page_stays_public() {
let content = r#"auht: "user""#;
let mut directives = PageDirectives::default();
parse_directive_content(content, &mut directives);
assert!(matches!(directives.auth, AuthLevel::All));
assert_eq!(directives.vars.get("auht"), Some(&serde_json::json!("user")));
}
#[test]
fn test_strip_symmetric_quotes() {
assert_eq!(strip_symmetric_quotes(r#""hello""#), ("hello", true));
assert_eq!(strip_symmetric_quotes("'hello'"), ("hello", true));
assert_eq!(strip_symmetric_quotes("hello"), ("hello", false));
assert_eq!(strip_symmetric_quotes(r#""hello'"#), (r#""hello'"#, false));
assert_eq!(strip_symmetric_quotes("''x''"), ("'x'", true));
assert_eq!(strip_symmetric_quotes(r#""""#), ("", true));
assert_eq!(strip_symmetric_quotes(r#"""#), (r#"""#, false));
}
#[test]
fn test_quoting_forces_string_type_inline_vars() {
let content = "zip = \"01234\"\nversion = \"1.0\"\nflag = \"true\"\ncount = 42";
let mut directives = PageDirectives::default();
parse_directive_content(content, &mut directives);
assert_eq!(
directives.vars.get("zip"),
Some(&serde_json::json!("01234")),
"quoted leading-zero value must stay a string"
);
assert_eq!(
directives.vars.get("version"),
Some(&serde_json::json!("1.0")),
"quoted numeric-looking value must stay a string"
);
assert_eq!(
directives.vars.get("flag"),
Some(&serde_json::json!("true")),
"quoted boolean-looking value must stay a string"
);
assert_eq!(directives.vars.get("count"), Some(&serde_json::json!(42)));
}
#[test]
fn test_quoting_forces_string_type_session_mutations() {
let set = parse_session_mutation(r#"session.zip = "01234""#).unwrap();
match set {
SessionMutation::Set { key, value } => {
assert_eq!(key, "zip");
assert_eq!(value, serde_json::json!("01234"));
}
other => panic!("expected Set, got {:?}", other),
}
let set = parse_session_mutation("session.count = 42").unwrap();
match set {
SessionMutation::Set { value, .. } => {
assert_eq!(value, serde_json::json!(42));
}
other => panic!("expected Set, got {:?}", other),
}
let push = parse_session_mutation(r#"session.items.push("42")"#).unwrap();
match push {
SessionMutation::Push { value, .. } => {
assert_eq!(value, serde_json::json!("42"));
}
other => panic!("expected Push, got {:?}", other),
}
}
#[test]
fn test_mismatched_quotes_left_intact() {
let content = "label = \"oops'";
let mut directives = PageDirectives::default();
parse_directive_content(content, &mut directives);
assert_eq!(
directives.vars.get("label"),
Some(&serde_json::json!("\"oops'"))
);
}
#[test]
fn test_what_file_quoted_number_stays_string() {
let config = parse_what_file("zip = \"01234\"\ncount = 7");
assert_eq!(config.values.get("zip"), Some(&serde_json::json!("01234")));
assert_eq!(config.values.get("count"), Some(&serde_json::json!(7)));
}
}