Skip to main content

what_core/parser/
mod.rs

1//! HTML parser for custom tags
2//!
3//! Parses HTML documents and resolves custom tags like <jumbo>, <loop>, etc.
4//! Also handles `<what>` page directives for auth, routing, etc.
5//! Also handles `.what` config files for directory-level configuration.
6
7use regex::Regex;
8use serde_json::{Value, json};
9use std::collections::HashMap;
10use std::sync::LazyLock;
11
12/// Regex to match #variable# syntax, including arithmetic expressions like #var + 1#
13static VAR_REGEX: LazyLock<Regex> =
14    LazyLock::new(|| Regex::new(r"#([a-zA-Z_][a-zA-Z0-9_. +\-*/]*(?:\|[^#]*)?)#").unwrap());
15
16/// Regex to match tag attributes
17static ATTR_REGEX: LazyLock<Regex> =
18    LazyLock::new(|| Regex::new(r#"([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*"([^"]*)""#).unwrap());
19
20/// Regex to match boolean attributes (standalone words after key="value" pairs are removed)
21static BOOL_ATTR_REGEX: LazyLock<Regex> =
22    LazyLock::new(|| Regex::new(r#"([a-zA-Z_][a-zA-Z0-9_-]*)"#).unwrap());
23
24/// Regex to match <what> directive tags (self-closing or with content)
25/// Does NOT match <what-*> component tags (which have hyphen immediately after "what")
26static WHAT_DIRECTIVE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
27    // Match <what with optional whitespace+attrs, then /> or >...</what>
28    // The pattern requires either:
29    //   - <what/> (immediate self-close)
30    //   - <what> (immediate close, may have content)
31    //   - <what attrs...> (space before attrs)
32    // This naturally excludes <what-nav> because hyphen is not whitespace, /, or >
33    Regex::new(r"(?s)<what((?:\s[^>]*)?)(?:/>|>(.*?)</what>)").unwrap()
34});
35
36// ============================================================================
37// Wired Variable Scoping
38// ============================================================================
39
40/// Scope for a wired variable — determines which WebSocket clients receive updates
41#[derive(Clone, Debug, Default)]
42pub enum WiredScope {
43    /// All clients receive (backwards compatible default)
44    #[default]
45    Public,
46    /// Only clients with a matching JWT role
47    Roles(Vec<String>),
48    /// Only the specific user who triggered the mutation (user_id filled at mutation time)
49    User(String),
50}
51
52impl std::fmt::Display for WiredScope {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            WiredScope::Public => write!(f, "public"),
56            WiredScope::Roles(r) => write!(f, "roles: {}", r.join(", ")),
57            WiredScope::User(_) => write!(f, "per-user"),
58        }
59    }
60}
61
62impl WiredScope {
63    /// Check if a client with the given roles/user_id is allowed to receive this update
64    pub fn allows(&self, client_roles: &[String], client_user_id: Option<&str>) -> bool {
65        match self {
66            WiredScope::Public => true,
67            WiredScope::Roles(required) => client_roles.iter().any(|r| required.contains(r)),
68            WiredScope::User(uid) => client_user_id == Some(uid.as_str()),
69        }
70    }
71}
72
73/// A parsed variable declaration with its scope. Used for both `data.wired`
74/// (real-time push, scope filters delivery) and `data.application` (shared
75/// state, scope gates who may write via `w-set`).
76#[derive(Clone, Debug)]
77pub struct WiredVarDecl {
78    pub name: String,
79    pub scope: WiredScope,
80}
81
82/// Alias clarifying intent when a scoped decl governs write access rather than
83/// WebSocket delivery (e.g. `data.application = ["revenue [admin]"]`).
84pub type ScopedVarDecl = WiredVarDecl;
85
86/// Parse a wired variable value that may contain bracket scope syntax.
87/// Examples:
88///   "counter"           → WiredVarDecl { name: "counter", scope: Public }
89///   "revenue [admin]"   → WiredVarDecl { name: "revenue", scope: Roles(["admin"]) }
90///   "x [admin, editor]" → WiredVarDecl { name: "x", scope: Roles(["admin", "editor"]) }
91///   "notifs [user]"     → WiredVarDecl { name: "notifs", scope: User("") }
92fn parse_wired_decl(s: &str) -> WiredVarDecl {
93    let s = s.trim();
94    if let Some(bracket_start) = s.find('[') {
95        if let Some(bracket_end) = s.find(']') {
96            let name = s[..bracket_start].trim().to_string();
97            let roles_str = &s[bracket_start + 1..bracket_end];
98            let roles: Vec<String> = roles_str
99                .split(',')
100                .map(|r| r.trim().to_string())
101                .filter(|r| !r.is_empty())
102                .collect();
103            // Special case: [user] means per-user scoping
104            if roles.len() == 1 && roles[0] == "user" {
105                return WiredVarDecl {
106                    name,
107                    scope: WiredScope::User(String::new()),
108                };
109            }
110            return WiredVarDecl {
111                name,
112                scope: WiredScope::Roles(roles),
113            };
114        }
115    }
116    WiredVarDecl {
117        name: s.to_string(),
118        scope: WiredScope::Public,
119    }
120}
121
122// ============================================================================
123// .what File Parser
124// ============================================================================
125
126/// Configuration from a .what file
127///
128/// .what files use a simple key-value format:
129/// ```text
130/// // Comments start with // or #
131/// title = "My Application"
132/// port = 8080
133/// debug = true
134/// nav_items = ["Home", "About", "Contact"]
135/// auth = "admin"
136/// layout = "sections/main.html"
137/// ```
138#[derive(Debug, Clone, Default)]
139pub(crate) struct WhatConfig {
140    /// Parsed configuration values
141    pub values: HashMap<String, Value>,
142    /// Page directives extracted from the config (auth, protected, etc.)
143    pub directives: PageDirectives,
144    /// Layout template path (stored separately for easy access)
145    pub layout: Option<String>,
146    /// Application-level data keys to expose (shared across all sessions).
147    /// Bracket scope syntax (`"revenue [admin]"`) gates `w-set` writes.
148    pub data_application: Vec<ScopedVarDecl>,
149    /// Session-level data keys to expose (per-user)
150    pub data_session: Vec<String>,
151    /// Wired data keys to expose (shared + real-time push via WebSocket, with optional scope)
152    pub data_wired: Vec<WiredVarDecl>,
153}
154
155#[allow(dead_code)]
156impl WhatConfig {
157    /// Get a string value
158    pub fn get_string(&self, key: &str) -> Option<&str> {
159        self.values.get(key).and_then(|v| v.as_str())
160    }
161
162    /// Get a number value
163    pub fn get_number(&self, key: &str) -> Option<f64> {
164        self.values.get(key).and_then(|v| v.as_f64())
165    }
166
167    /// Get a boolean value
168    pub fn get_bool(&self, key: &str) -> Option<bool> {
169        self.values.get(key).and_then(|v| v.as_bool())
170    }
171
172    /// Get an array value
173    pub fn get_array(&self, key: &str) -> Option<&Vec<Value>> {
174        self.values.get(key).and_then(|v| v.as_array())
175    }
176
177    /// Merge another config into this one (other takes precedence)
178    pub fn merge(&mut self, other: &WhatConfig) {
179        for (key, value) in &other.values {
180            self.values.insert(key.clone(), value.clone());
181        }
182        // For directives, other takes precedence if it sets auth
183        if other.directives.requires_auth() {
184            self.directives.auth = other.directives.auth.clone();
185        }
186        if other.directives.protected {
187            self.directives.protected = true;
188        }
189        if !other.directives.roles.is_empty() {
190            self.directives.roles = other.directives.roles.clone();
191        }
192        if other.directives.exclude {
193            self.directives.exclude = true;
194        }
195        if other.directives.title.is_some() {
196            self.directives.title = other.directives.title.clone();
197        }
198        if other.directives.redirect.is_some() {
199            self.directives.redirect = other.directives.redirect.clone();
200        }
201        if other.directives.cache_ttl.is_some() {
202            self.directives.cache_ttl = other.directives.cache_ttl;
203        }
204        // Headers: merge (child overrides parent for same header name)
205        for (k, v) in &other.directives.headers {
206            self.directives.headers.insert(k.clone(), v.clone());
207        }
208        // Layout: child overrides parent (including "none" to disable)
209        if other.layout.is_some() {
210            self.layout = other.layout.clone();
211        }
212        if other.directives.layout.is_some() {
213            self.directives.layout = other.directives.layout.clone();
214        }
215        // Data: child overrides parent (or could extend - using override for now)
216        if !other.data_application.is_empty() {
217            self.data_application = other.data_application.clone();
218        }
219        if !other.data_session.is_empty() {
220            self.data_session = other.data_session.clone();
221        }
222        if !other.data_wired.is_empty() {
223            self.data_wired = other.data_wired.clone();
224        }
225    }
226
227    /// Convert to context for template rendering
228    pub fn to_context(&self) -> HashMap<String, Value> {
229        self.values.clone()
230    }
231}
232
233/// Parse a .what file content
234///
235/// Supports:
236/// - Strings: `key = "value"` or `key = 'value'`
237/// - Numbers: `key = 123` or `key = 45.67`
238/// - Booleans: `key = true` or `key = false`
239/// - Arrays: `key = ["a", "b", "c"]` or `key = [1, 2, 3]`
240/// - Comments: `// comment` or `# comment`
241///
242/// Special keys are converted to directives:
243/// - `auth` → AuthLevel
244/// - `protected` → protected directive
245/// - `roles` → roles directive
246/// - `exclude` → exclude directive
247/// - `title` → title directive
248/// - `redirect` → redirect directive
249/// - `cache` / `cache_ttl` → cache TTL directive
250/// - `layout` → layout template path
251pub(crate) fn parse_what_file(content: &str) -> WhatConfig {
252    let mut config = WhatConfig::default();
253
254    for line in content.lines() {
255        let line = line.trim();
256
257        // Skip empty lines and comments
258        if line.is_empty() || line.starts_with("//") || line.starts_with('#') {
259            continue;
260        }
261
262        // Parse key = value
263        if let Some(idx) = line.find('=') {
264            let key = line[..idx].trim().to_lowercase();
265            let value_str = line[idx + 1..].trim();
266
267            // Parse the value
268            let value = parse_what_value(value_str);
269
270            // Check if this is a directive key that shouldn't be exposed as a template variable
271            let is_security_directive = matches!(
272                key.as_str(),
273                "auth"
274                    | "protected"
275                    | "roles"
276                    | "exclude"
277                    | "redirect"
278                    | "cache"
279                    | "cache_ttl"
280                    | "layout"
281                    | "data.application"
282                    | "data.session"
283            );
284
285            // Handle special directive keys
286            match key.as_str() {
287                "auth" => {
288                    if let Some(s) = value.as_str() {
289                        config.directives.auth = parse_auth_level(s);
290                    }
291                }
292                "protected" => {
293                    if let Some(b) = value.as_bool() {
294                        config.directives.protected = b;
295                    } else if let Some(s) = value.as_str() {
296                        config.directives.protected = s != "false";
297                    }
298                }
299                "roles" => {
300                    if let Some(arr) = value.as_array() {
301                        config.directives.roles = arr
302                            .iter()
303                            .filter_map(|v| v.as_str().map(String::from))
304                            .collect();
305                        if !config.directives.roles.is_empty() {
306                            config.directives.protected = true;
307                        }
308                    } else if let Some(s) = value.as_str() {
309                        config.directives.roles = s
310                            .split(',')
311                            .map(|s| s.trim().to_string())
312                            .filter(|s| !s.is_empty())
313                            .collect();
314                        if !config.directives.roles.is_empty() {
315                            config.directives.protected = true;
316                        }
317                    }
318                }
319                "exclude" => {
320                    if let Some(b) = value.as_bool() {
321                        config.directives.exclude = b;
322                    }
323                }
324                "title" => {
325                    if let Some(s) = value.as_str() {
326                        config.directives.title = Some(s.to_string());
327                    }
328                }
329                "redirect" => {
330                    if let Some(s) = value.as_str() {
331                        config.directives.redirect = Some(s.to_string());
332                    }
333                }
334                "layout" => {
335                    if let Some(s) = value.as_str() {
336                        config.layout = Some(s.to_string());
337                        config.directives.layout = Some(s.to_string());
338                    }
339                }
340                "cache" | "cache_ttl" => {
341                    if let Some(n) = value.as_u64() {
342                        config.directives.cache_ttl = Some(n);
343                    }
344                }
345                "data.application" => {
346                    config.data_application = parse_wired_array(&value);
347                }
348                "data.session" => {
349                    config.data_session = parse_string_array(&value);
350                }
351                "data.wired" => {
352                    config.data_wired = parse_wired_array(&value);
353                }
354                _ => {
355                    // Handle header.* keys: header.X-Custom = "value"
356                    if let Some(header_name) = key.strip_prefix("header.") {
357                        if let Some(s) = value.as_str() {
358                            config
359                                .directives
360                                .headers
361                                .insert(header_name.to_string(), s.to_string());
362                        }
363                    }
364                }
365            }
366
367            // Store non-security values for template access
368            let is_header = key.starts_with("header.");
369            if !is_security_directive && !is_header {
370                // Parity with <what> blocks: recommend quoting string values
371                // (data.* keys carry config syntax, not template strings)
372                if !key.starts_with("data.")
373                    && value.is_string()
374                    && !value_str.starts_with('"')
375                    && !value_str.starts_with('\'')
376                    && is_unquoted_string(value_str)
377                {
378                    tracing::warn!(
379                        "Unquoted string in .what file: {} should be quoted, e.g. {} = \"{}\"",
380                        key,
381                        key,
382                        value_str
383                    );
384                }
385                config.values.insert(key, value);
386            }
387        }
388    }
389
390    config
391}
392
393/// Parse a Value into a Vec<String>
394fn parse_string_array(value: &Value) -> Vec<String> {
395    if let Some(arr) = value.as_array() {
396        arr.iter()
397            .filter_map(|v| v.as_str().map(String::from))
398            .collect()
399    } else if let Some(s) = value.as_str() {
400        // Single string becomes a one-element array
401        vec![s.to_string()]
402    } else {
403        Vec::new()
404    }
405}
406
407/// Parse a Value into a Vec<WiredVarDecl> with optional bracket scope syntax
408fn parse_wired_array(value: &Value) -> Vec<WiredVarDecl> {
409    if let Some(arr) = value.as_array() {
410        arr.iter()
411            .filter_map(|v| v.as_str().map(parse_wired_decl))
412            .collect()
413    } else if let Some(s) = value.as_str() {
414        vec![parse_wired_decl(s)]
415    } else {
416        Vec::new()
417    }
418}
419
420/// Split a string on commas that are not inside quotes or nested brackets.
421/// Used for `.what` array literals so a scoped element like
422/// `"revenue [admin, editor]"` is not split at the inner comma.
423fn split_top_level_commas(s: &str) -> Vec<String> {
424    let mut parts = Vec::new();
425    let mut current = String::new();
426    let mut depth = 0i32;
427    let mut quote: Option<char> = None;
428    for c in s.chars() {
429        match quote {
430            Some(q) => {
431                if c == q {
432                    quote = None;
433                }
434                current.push(c);
435            }
436            None => match c {
437                '"' | '\'' => {
438                    quote = Some(c);
439                    current.push(c);
440                }
441                '[' | '{' => {
442                    depth += 1;
443                    current.push(c);
444                }
445                ']' | '}' => {
446                    depth -= 1;
447                    current.push(c);
448                }
449                ',' if depth == 0 => {
450                    parts.push(current.trim().to_string());
451                    current.clear();
452                }
453                _ => current.push(c),
454            },
455        }
456    }
457    if !current.trim().is_empty() {
458        parts.push(current.trim().to_string());
459    }
460    parts
461}
462
463/// Parse a value from .what file format
464fn parse_what_value(s: &str) -> Value {
465    let s = s.trim();
466
467    // Boolean
468    if s == "true" {
469        return json!(true);
470    }
471    if s == "false" {
472        return json!(false);
473    }
474
475    // String (double or single quotes)
476    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
477        return json!(s[1..s.len() - 1].to_string());
478    }
479
480    // Array
481    if s.starts_with('[') && s.ends_with(']') {
482        let inner = s[1..s.len() - 1].trim();
483        if inner.is_empty() {
484            return json!([]);
485        }
486
487        // Split on top-level commas only — commas inside quotes or nested
488        // brackets (e.g. a scope like "revenue [admin, editor]") stay together.
489        let items: Vec<Value> = split_top_level_commas(inner)
490            .into_iter()
491            .map(|item| parse_what_value(item.trim()))
492            .collect();
493        return json!(items);
494    }
495
496    // Number (integer or float)
497    if let Ok(n) = s.parse::<i64>() {
498        return json!(n);
499    }
500    if let Ok(n) = s.parse::<f64>() {
501        return json!(n);
502    }
503
504    // Default to string without quotes
505    json!(s.to_string())
506}
507
508/// Parse attributes from a tag string
509pub(crate) fn parse_attributes(attr_str: &str) -> HashMap<String, String> {
510    let mut attrs = HashMap::new();
511    for cap in ATTR_REGEX.captures_iter(attr_str) {
512        let key = cap[1].to_string();
513        let value = cap[2].to_string();
514        attrs.insert(key, value);
515    }
516    attrs
517}
518
519// ============================================================================
520// Filter System
521// ============================================================================
522
523/// A parsed filter with name and arguments
524#[derive(Debug, Clone, PartialEq)]
525struct Filter {
526    name: String,
527    args: Vec<String>,
528}
529
530/// Result of applying filters — tracks whether output is html_safe
531struct FilterResult {
532    value: String,
533    html_safe: bool,
534}
535
536/// Parse the filter chain from a variable expression.
537/// Input: "var.path|filter1:arg|filter2:arg1,arg2"
538/// Returns: (var_path, Vec<Filter>)
539fn parse_filter_chain(expr: &str) -> (&str, Vec<Filter>) {
540    let Some(first_pipe) = expr.find('|') else {
541        return (expr, Vec::new());
542    };
543
544    let var_path = &expr[..first_pipe];
545    let filter_str = &expr[first_pipe + 1..];
546    let mut filters = Vec::new();
547
548    // Split by | to get individual filters, but respect quoted strings
549    for segment in split_filters(filter_str) {
550        let segment = segment.trim();
551        if segment.is_empty() {
552            continue;
553        }
554
555        if let Some(colon_pos) = segment.find(':') {
556            let name = segment[..colon_pos].trim().to_string();
557            let args_str = &segment[colon_pos + 1..];
558            let args = parse_filter_args(args_str);
559            filters.push(Filter { name, args });
560        } else {
561            filters.push(Filter {
562                name: segment.to_string(),
563                args: Vec::new(),
564            });
565        }
566    }
567
568    (var_path, filters)
569}
570
571/// Split filter chain by `|`, respecting quoted strings
572fn split_filters(s: &str) -> Vec<&str> {
573    let mut parts = Vec::new();
574    let mut start = 0;
575    let mut in_quote = false;
576    let mut quote_char = '"';
577
578    for (i, c) in s.char_indices() {
579        match c {
580            '"' | '\'' if !in_quote => {
581                in_quote = true;
582                quote_char = c;
583            }
584            c if c == quote_char && in_quote => {
585                in_quote = false;
586            }
587            '|' if !in_quote => {
588                parts.push(&s[start..i]);
589                start = i + 1;
590            }
591            _ => {}
592        }
593    }
594    parts.push(&s[start..]);
595    parts
596}
597
598/// Parse filter arguments from a string like `"value"` or `50` or `"old","new"`
599fn parse_filter_args(s: &str) -> Vec<String> {
600    let mut args = Vec::new();
601    let mut current = String::new();
602    let mut in_quote = false;
603    let mut quote_char = '"';
604
605    for c in s.chars() {
606        match c {
607            '"' | '\'' if !in_quote => {
608                in_quote = true;
609                quote_char = c;
610                // Don't include the quote in the arg value
611            }
612            c if c == quote_char && in_quote => {
613                in_quote = false;
614                // Don't include the closing quote
615            }
616            ',' if !in_quote => {
617                args.push(current.trim().to_string());
618                current = String::new();
619            }
620            _ => {
621                current.push(c);
622            }
623        }
624    }
625    let trimmed = current.trim().to_string();
626    if !trimmed.is_empty() {
627        args.push(trimmed);
628    }
629    args
630}
631
632/// Apply a single filter to a value. Returns the filtered value and whether it's html_safe.
633fn apply_filter(value: &str, filter: &Filter) -> FilterResult {
634    match filter.name.as_str() {
635        "raw" => FilterResult {
636            value: value.to_string(),
637            html_safe: true,
638        },
639        "uppercase" => FilterResult {
640            value: value.to_uppercase(),
641            html_safe: false,
642        },
643        "lowercase" => FilterResult {
644            value: value.to_lowercase(),
645            html_safe: false,
646        },
647        "capitalize" => FilterResult {
648            value: capitalize_first(value),
649            html_safe: false,
650        },
651        "title" => FilterResult {
652            value: title_case(value),
653            html_safe: false,
654        },
655        "truncate" => {
656            let max_len: usize = filter
657                .args
658                .first()
659                .and_then(|a| a.parse().ok())
660                .unwrap_or(50);
661            let suffix = filter.args.get(1).map(|s| s.as_str()).unwrap_or("...");
662            FilterResult {
663                value: truncate_str(value, max_len, suffix),
664                html_safe: false,
665            }
666        }
667        "count" => {
668            // Arrays/objects reach filters as their serialized JSON — count
669            // items, not bytes of the serialization. Plain strings count
670            // characters (not bytes), so "José" is 4, not 5.
671            let n = match serde_json::from_str::<Value>(value) {
672                Ok(Value::Array(items)) => items.len(),
673                Ok(Value::Object(map)) => map.len(),
674                Ok(Value::String(s)) => s.chars().count(),
675                _ => value.chars().count(),
676            };
677            FilterResult {
678                value: n.to_string(),
679                html_safe: false,
680            }
681        }
682        "number" => {
683            // Format number with thousands separator
684            FilterResult {
685                value: format_number(value),
686                html_safe: false,
687            }
688        }
689        "currency" => {
690            let code = filter.args.first().map(|s| s.as_str()).unwrap_or("USD");
691            FilterResult {
692                value: format_currency(value, code),
693                html_safe: false,
694            }
695        }
696        "date" => {
697            let fmt = filter.args.first().map(|s| s.as_str()).unwrap_or("medium");
698            FilterResult {
699                value: format_date(value, fmt),
700                html_safe: false,
701            }
702        }
703        "json" => FilterResult {
704            value: serde_json::to_string(&serde_json::Value::String(value.to_string()))
705                .unwrap_or_else(|_| format!("\"{}\"", value)),
706            html_safe: false,
707        },
708        "markdown" => FilterResult {
709            value: simple_markdown(value),
710            html_safe: true,
711        },
712        "pluralize" => {
713            let singular = filter.args.first().map(|s| s.as_str()).unwrap_or("s");
714            let plural = filter.args.get(1).map(|s| s.as_str()).unwrap_or(singular);
715            // If value is a number, use it to determine singular/plural
716            let n: f64 = value.parse().unwrap_or(0.0);
717            FilterResult {
718                value: if n == 1.0 {
719                    // With 2 args: first is singular suffix, second is plural suffix
720                    // With 1 arg: it's the plural suffix, singular is empty
721                    if filter.args.len() >= 2 {
722                        singular.to_string()
723                    } else {
724                        String::new()
725                    }
726                } else {
727                    plural.to_string()
728                },
729                html_safe: false,
730            }
731        }
732        "default" => {
733            let default_val = filter.args.first().map(|s| s.as_str()).unwrap_or("");
734            FilterResult {
735                value: if value.is_empty() {
736                    default_val.to_string()
737                } else {
738                    value.to_string()
739                },
740                html_safe: false,
741            }
742        }
743        "replace" => {
744            let old = filter.args.first().map(|s| s.as_str()).unwrap_or("");
745            let new = filter.args.get(1).map(|s| s.as_str()).unwrap_or("");
746            FilterResult {
747                value: value.replace(old, new),
748                html_safe: false,
749            }
750        }
751        "slice" => {
752            let start: usize = filter
753                .args
754                .first()
755                .and_then(|a| a.parse().ok())
756                .unwrap_or(0);
757            let end: usize = filter
758                .args
759                .get(1)
760                .and_then(|a| a.parse().ok())
761                .unwrap_or(value.len());
762            let chars: Vec<char> = value.chars().collect();
763            let start = start.min(chars.len());
764            let end = end.min(chars.len());
765            FilterResult {
766                value: chars[start..end].iter().collect(),
767                html_safe: false,
768            }
769        }
770        "round" => {
771            let decimals: u32 = filter
772                .args
773                .first()
774                .and_then(|a| a.parse().ok())
775                .unwrap_or(0);
776            let n: f64 = value.parse().unwrap_or(0.0);
777            let factor = 10f64.powi(decimals as i32);
778            let rounded = (n * factor).round() / factor;
779            FilterResult {
780                value: if decimals == 0 {
781                    format!("{}", rounded as i64)
782                } else {
783                    format!("{:.prec$}", rounded, prec = decimals as usize)
784                },
785                html_safe: false,
786            }
787        }
788        "ceil" => {
789            let n: f64 = value.parse().unwrap_or(0.0);
790            let ceiled = n.ceil();
791            FilterResult {
792                value: if ceiled.abs() < i64::MAX as f64 {
793                    format!("{}", ceiled as i64)
794                } else {
795                    format!("{}", ceiled)
796                },
797                html_safe: false,
798            }
799        }
800        "floor" => {
801            let n: f64 = value.parse().unwrap_or(0.0);
802            let floored = n.floor();
803            FilterResult {
804                value: if floored.abs() < i64::MAX as f64 {
805                    format!("{}", floored as i64)
806                } else {
807                    format!("{}", floored)
808                },
809                html_safe: false,
810            }
811        }
812        // Unknown filter — pass through unchanged, but say so: a typo'd
813        // filter (#price|currancy#) otherwise fails with no symptom at all
814        unknown => {
815            warn_unknown_filter_once(unknown);
816            FilterResult {
817                value: value.to_string(),
818                html_safe: false,
819            }
820        }
821    }
822}
823
824/// Filter names already reported as unknown (warn once per name per process).
825static WARNED_UNKNOWN_FILTERS: LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
826    LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
827
828fn warn_unknown_filter_once(name: &str) {
829    let mut warned = WARNED_UNKNOWN_FILTERS
830        .lock()
831        .unwrap_or_else(|e| e.into_inner());
832    if warned.insert(name.to_string()) {
833        tracing::warn!(
834            "Unknown filter '|{}' — the value passes through unchanged. Check the spelling against the filter reference.",
835            name
836        );
837    }
838}
839
840/// Apply a chain of filters, returning final value and html_safe flag
841fn apply_filters(value: &str, filters: &[Filter]) -> FilterResult {
842    let mut current = FilterResult {
843        value: value.to_string(),
844        html_safe: false,
845    };
846    for filter in filters {
847        current = apply_filter(&current.value, filter);
848    }
849    current
850}
851
852// -- Arithmetic evaluation --
853
854/// Check if a string contains arithmetic operators (space-separated)
855fn contains_arithmetic(s: &str) -> bool {
856    s.contains(" + ") || s.contains(" - ") || s.contains(" * ") || s.contains(" / ")
857}
858
859/// Resolve variable-like tokens in an arithmetic expression, then evaluate.
860/// E.g. "session.age + 1" with context where session.age=25 → "25 + 1" → Some(26.0)
861fn resolve_and_evaluate_arithmetic(expr: &str, context: &HashMap<String, Value>) -> Option<String> {
862    static INLINE_VAR: LazyLock<Regex> =
863        LazyLock::new(|| Regex::new(r"[a-zA-Z_][a-zA-Z0-9_.]*").unwrap());
864    // Resolve all variable-like tokens
865    let resolved = INLINE_VAR
866        .replace_all(expr, |caps: &regex::Captures| {
867            let token = &caps[0];
868            let val = resolve_variable(token, context);
869            // If unresolved (still #token#), return the token as-is (will fail arithmetic)
870            if val.starts_with('#') && val.ends_with('#') {
871                token.to_string()
872            } else {
873                val
874            }
875        })
876        .to_string();
877    evaluate_arithmetic(&resolved).map(format_f64_clean)
878}
879
880/// Evaluate a simple arithmetic expression: "10 + 1", "25.5 * 0.21", etc.
881/// Supports +, -, *, / with standard precedence (* / before + -).
882/// Returns None if the expression is not valid arithmetic or contains division by zero.
883pub(crate) fn evaluate_arithmetic(expr: &str) -> Option<f64> {
884    let expr = expr.trim();
885    if expr.is_empty() {
886        return None;
887    }
888
889    let tokens = tokenize_arithmetic(expr)?;
890    if tokens.len() < 3 {
891        return None; // Need at least: number op number
892    }
893
894    evaluate_with_precedence(&tokens)
895}
896
897/// Format f64 cleanly — no trailing .0 for whole numbers
898pub(crate) fn format_f64_clean(n: f64) -> String {
899    if n == n.trunc() && n.abs() < i64::MAX as f64 {
900        format!("{}", n as i64)
901    } else {
902        format!("{}", n)
903    }
904}
905
906#[derive(Debug, Clone)]
907enum ArithToken {
908    Num(f64),
909    Op(char), // +, -, *, /
910}
911
912/// Tokenize an arithmetic expression into numbers and operators.
913/// Handles negative numbers (leading - or - after operator).
914fn tokenize_arithmetic(expr: &str) -> Option<Vec<ArithToken>> {
915    let mut tokens = Vec::new();
916    let mut chars = expr.chars().peekable();
917
918    while let Some(&c) = chars.peek() {
919        if c.is_whitespace() {
920            chars.next();
921            continue;
922        }
923
924        // Number (possibly negative at start or after operator)
925        if c.is_ascii_digit()
926            || c == '.'
927            || (c == '-' && (tokens.is_empty() || matches!(tokens.last(), Some(ArithToken::Op(_)))))
928        {
929            let mut num_str = String::new();
930            if c == '-' {
931                num_str.push('-');
932                chars.next();
933            }
934            while let Some(&nc) = chars.peek() {
935                if nc.is_ascii_digit() || nc == '.' {
936                    num_str.push(nc);
937                    chars.next();
938                } else {
939                    break;
940                }
941            }
942            let n: f64 = num_str.parse().ok()?;
943            tokens.push(ArithToken::Num(n));
944        } else if "+-*/".contains(c) {
945            tokens.push(ArithToken::Op(c));
946            chars.next();
947        } else {
948            // Non-arithmetic character — not a valid expression
949            return None;
950        }
951    }
952
953    // Validate: must alternate Num Op Num Op Num ...
954    for (i, token) in tokens.iter().enumerate() {
955        match (i % 2, token) {
956            (0, ArithToken::Num(_)) => {}
957            (1, ArithToken::Op(_)) => {}
958            _ => return None,
959        }
960    }
961    // Must end with a number
962    if tokens.len() % 2 == 0 {
963        return None;
964    }
965
966    Some(tokens)
967}
968
969/// Evaluate tokens with standard precedence: * / first, then + -
970fn evaluate_with_precedence(tokens: &[ArithToken]) -> Option<f64> {
971    // Extract numbers and operators into separate vecs
972    let mut nums: Vec<f64> = Vec::new();
973    let mut ops: Vec<char> = Vec::new();
974    for token in tokens {
975        match token {
976            ArithToken::Num(n) => nums.push(*n),
977            ArithToken::Op(op) => ops.push(*op),
978        }
979    }
980
981    // First pass: evaluate * and /
982    let mut i = 0;
983    while i < ops.len() {
984        if ops[i] == '*' || ops[i] == '/' {
985            let result = if ops[i] == '*' {
986                nums[i] * nums[i + 1]
987            } else {
988                if nums[i + 1] == 0.0 {
989                    return None; // Division by zero
990                }
991                nums[i] / nums[i + 1]
992            };
993            nums[i] = result;
994            nums.remove(i + 1);
995            ops.remove(i);
996        } else {
997            i += 1;
998        }
999    }
1000
1001    // Second pass: evaluate + and -
1002    let mut result = nums[0];
1003    for (i, op) in ops.iter().enumerate() {
1004        match op {
1005            '+' => result += nums[i + 1],
1006            '-' => result -= nums[i + 1],
1007            _ => return None,
1008        }
1009    }
1010
1011    Some(result)
1012}
1013
1014// -- Filter helper functions --
1015
1016fn capitalize_first(s: &str) -> String {
1017    let mut chars = s.chars();
1018    match chars.next() {
1019        None => String::new(),
1020        Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
1021    }
1022}
1023
1024fn title_case(s: &str) -> String {
1025    s.split_whitespace()
1026        .map(|word| capitalize_first(word))
1027        .collect::<Vec<_>>()
1028        .join(" ")
1029}
1030
1031fn truncate_str(s: &str, max_len: usize, suffix: &str) -> String {
1032    let chars: Vec<char> = s.chars().collect();
1033    if chars.len() <= max_len {
1034        return s.to_string();
1035    }
1036    let truncated: String = chars[..max_len].iter().collect();
1037    format!("{}{}", truncated, suffix)
1038}
1039
1040fn format_number(s: &str) -> String {
1041    // Parse as f64, format with thousands separator
1042    if let Ok(n) = s.parse::<f64>() {
1043        if n == n.floor() && n.abs() < i64::MAX as f64 {
1044            // Integer formatting with commas
1045            let n = n as i64;
1046            let is_negative = n < 0;
1047            let s = n.unsigned_abs().to_string();
1048            let chars: Vec<char> = s.chars().collect();
1049            let mut result = String::new();
1050            for (i, c) in chars.iter().enumerate() {
1051                if i > 0 && (chars.len() - i) % 3 == 0 {
1052                    result.push(',');
1053                }
1054                result.push(*c);
1055            }
1056            if is_negative {
1057                format!("-{}", result)
1058            } else {
1059                result
1060            }
1061        } else {
1062            // Float — keep as-is but with commas in integer part
1063            format!("{}", n)
1064        }
1065    } else {
1066        s.to_string()
1067    }
1068}
1069
1070fn format_currency(s: &str, code: &str) -> String {
1071    let n: f64 = s.parse().unwrap_or(0.0);
1072    let symbol = match code.to_uppercase().as_str() {
1073        "USD" => "$",
1074        "EUR" => "\u{20ac}",
1075        "GBP" => "\u{00a3}",
1076        "JPY" => "\u{00a5}",
1077        "CAD" => "CA$",
1078        "AUD" => "A$",
1079        _ => "$",
1080    };
1081    // Format with 2 decimal places and thousands separator
1082    let abs_n = n.abs();
1083    let integer_part = abs_n.floor() as i64;
1084    let decimal_part = ((abs_n - abs_n.floor()) * 100.0).round() as i64;
1085
1086    let int_str = format_number(&integer_part.to_string());
1087    let sign = if n < 0.0 { "-" } else { "" };
1088    format!("{}{}{}.{:02}", sign, symbol, int_str, decimal_part)
1089}
1090
1091fn format_date(s: &str, mask: &str) -> String {
1092    use chrono::{NaiveDate, NaiveDateTime};
1093
1094    // Parse input into NaiveDateTime
1095    let dt = if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
1096        date.and_hms_opt(0, 0, 0).unwrap()
1097    } else if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
1098        dt
1099    } else if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
1100        dt
1101    } else if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
1102        dt.naive_local()
1103    } else {
1104        return s.to_string();
1105    };
1106
1107    apply_date_mask(&dt, mask)
1108}
1109
1110fn apply_date_mask(dt: &chrono::NaiveDateTime, mask: &str) -> String {
1111    use chrono::{Datelike, Timelike};
1112
1113    // Resolve presets
1114    let mask = match mask {
1115        "short" => "m/d/yy",
1116        "medium" => "mmm d, yyyy",
1117        "long" => "mmmm d, yyyy",
1118        "full" => "dddd, mmmm d, yyyy",
1119        "time" => "h:nn tt",
1120        "iso" => "yyyy-mm-dd",
1121        other => other,
1122    };
1123
1124    static MONTHS: &[&str] = &[
1125        "",
1126        "January",
1127        "February",
1128        "March",
1129        "April",
1130        "May",
1131        "June",
1132        "July",
1133        "August",
1134        "September",
1135        "October",
1136        "November",
1137        "December",
1138    ];
1139    static MONTHS_SHORT: &[&str] = &[
1140        "", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
1141    ];
1142    static DAYS: &[&str] = &[
1143        "Monday",
1144        "Tuesday",
1145        "Wednesday",
1146        "Thursday",
1147        "Friday",
1148        "Saturday",
1149        "Sunday",
1150    ];
1151    static DAYS_SHORT: &[&str] = &["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
1152
1153    let day = dt.day();
1154    let month = dt.month() as usize;
1155    let year = dt.year();
1156    let hour24 = dt.hour();
1157    let hour12 = if hour24 == 0 {
1158        12
1159    } else if hour24 > 12 {
1160        hour24 - 12
1161    } else {
1162        hour24
1163    };
1164    let minute = dt.minute();
1165    let second = dt.second();
1166    let weekday_idx = dt.weekday().num_days_from_monday() as usize;
1167    let ampm = if hour24 < 12 { "AM" } else { "PM" };
1168
1169    let mut result = String::new();
1170    let chars: Vec<char> = mask.chars().collect();
1171    let mut i = 0;
1172
1173    while i < chars.len() {
1174        // Try longest tokens first
1175        let remaining = &mask[i..];
1176
1177        if remaining.starts_with("dddd") {
1178            result.push_str(DAYS[weekday_idx]);
1179            i += 4;
1180        } else if remaining.starts_with("ddd") {
1181            result.push_str(DAYS_SHORT[weekday_idx]);
1182            i += 3;
1183        } else if remaining.starts_with("dd") {
1184            result.push_str(&format!("{:02}", day));
1185            i += 2;
1186        } else if remaining.starts_with('d') && !remaining.starts_with("dd") {
1187            result.push_str(&day.to_string());
1188            i += 1;
1189        } else if remaining.starts_with("mmmm") {
1190            result.push_str(MONTHS[month]);
1191            i += 4;
1192        } else if remaining.starts_with("mmm") {
1193            result.push_str(MONTHS_SHORT[month]);
1194            i += 3;
1195        } else if remaining.starts_with("mm") {
1196            result.push_str(&format!("{:02}", month));
1197            i += 2;
1198        } else if remaining.starts_with('m') && !remaining.starts_with("mm") {
1199            result.push_str(&month.to_string());
1200            i += 1;
1201        } else if remaining.starts_with("yyyy") {
1202            result.push_str(&format!("{:04}", year));
1203            i += 4;
1204        } else if remaining.starts_with("yy") {
1205            result.push_str(&format!("{:02}", year % 100));
1206            i += 2;
1207        } else if remaining.starts_with("HH") {
1208            result.push_str(&format!("{:02}", hour24));
1209            i += 2;
1210        } else if remaining.starts_with('H') && !remaining.starts_with("HH") {
1211            result.push_str(&hour24.to_string());
1212            i += 1;
1213        } else if remaining.starts_with("hh") {
1214            result.push_str(&format!("{:02}", hour12));
1215            i += 2;
1216        } else if remaining.starts_with('h') && !remaining.starts_with("hh") {
1217            result.push_str(&hour12.to_string());
1218            i += 1;
1219        } else if remaining.starts_with("nn") {
1220            result.push_str(&format!("{:02}", minute));
1221            i += 2;
1222        } else if remaining.starts_with('n') && !remaining.starts_with("nn") {
1223            result.push_str(&minute.to_string());
1224            i += 1;
1225        } else if remaining.starts_with("ss") {
1226            result.push_str(&format!("{:02}", second));
1227            i += 2;
1228        } else if remaining.starts_with('s') && !remaining.starts_with("ss") {
1229            result.push_str(&second.to_string());
1230            i += 1;
1231        } else if remaining.starts_with("tt") {
1232            result.push_str(ampm);
1233            i += 2;
1234        } else if remaining.starts_with('t') && !remaining.starts_with("tt") {
1235            result.push(ampm.chars().next().unwrap());
1236            i += 1;
1237        } else {
1238            // Literal character
1239            result.push(chars[i]);
1240            i += 1;
1241        }
1242    }
1243
1244    result
1245}
1246
1247fn simple_markdown(s: &str) -> String {
1248    // Escape HTML entities BEFORE markdown processing to prevent XSS.
1249    // Markdown output (tags like <strong>, <em>, <a>) is added after escaping.
1250    let escaped = html_escape(s);
1251
1252    let mut result = String::new();
1253    let mut in_paragraph = false;
1254
1255    for line in escaped.lines() {
1256        let trimmed = line.trim();
1257        if trimmed.is_empty() {
1258            if in_paragraph {
1259                result.push_str("</p>");
1260                in_paragraph = false;
1261            }
1262            continue;
1263        }
1264
1265        // Process inline formatting
1266        let processed = process_markdown_inline(trimmed);
1267
1268        if !in_paragraph {
1269            result.push_str("<p>");
1270            in_paragraph = true;
1271        } else {
1272            result.push(' ');
1273        }
1274        result.push_str(&processed);
1275    }
1276
1277    if in_paragraph {
1278        result.push_str("</p>");
1279    }
1280
1281    result
1282}
1283
1284// Hoisted: process_markdown_inline runs once per non-blank line of a
1285// |markdown block — compiling these per call multiplied per render.
1286static MD_BOLD_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
1287static MD_ITALIC_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*(.+?)\*").unwrap());
1288static MD_LINK_RE: LazyLock<Regex> =
1289    LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
1290
1291fn process_markdown_inline(s: &str) -> String {
1292    let mut result = s.to_string();
1293
1294    // Bold: **text** → <strong>text</strong>
1295    result = MD_BOLD_RE
1296        .replace_all(&result, "<strong>$1</strong>")
1297        .to_string();
1298
1299    // Italic: *text* → <em>text</em>
1300    result = MD_ITALIC_RE.replace_all(&result, "<em>$1</em>").to_string();
1301
1302    // Links: [text](url) → <a href="url">text</a>
1303    // Only emit an href for a safe URL scheme; otherwise render the text alone.
1304    result = MD_LINK_RE
1305        .replace_all(&result, |caps: &regex::Captures| {
1306            let text = &caps[1];
1307            let url = caps[2].trim();
1308            if markdown_url_is_safe(url) {
1309                format!(r#"<a href="{}">{}</a>"#, url, text)
1310            } else {
1311                text.to_string()
1312            }
1313        })
1314        .to_string();
1315
1316    result
1317}
1318
1319/// Whether a markdown link URL is safe to emit as an `href`. Uses a scheme
1320/// allowlist (http/https/mailto/tel + relative), and first strips characters
1321/// browsers ignore when evaluating a scheme — so `java\tscript:` / `java\nscript:`
1322/// cannot smuggle a `javascript:` URL past a naive `starts_with` check.
1323fn markdown_url_is_safe(url: &str) -> bool {
1324    let stripped: String = url
1325        .chars()
1326        .filter(|c| !c.is_control() && !c.is_whitespace())
1327        .collect();
1328    let lower = stripped.to_lowercase();
1329    match lower.find(':') {
1330        None => true, // no scheme — relative URL / fragment / query
1331        Some(colon) => {
1332            let before = &lower[..colon];
1333            // A '/', '#' or '?' before the colon means it's a path, not a scheme
1334            // (e.g. "/a:b" or "foo?x=1:2").
1335            if before.contains('/') || before.contains('#') || before.contains('?') {
1336                return true;
1337            }
1338            let is_scheme = !before.is_empty()
1339                && before.chars().next().unwrap().is_ascii_alphabetic()
1340                && before
1341                    .chars()
1342                    .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.');
1343            if is_scheme {
1344                matches!(before, "http" | "https" | "mailto" | "tel")
1345            } else {
1346                true
1347            }
1348        }
1349    }
1350}
1351
1352/// Replace #variable# syntax with values from context.
1353/// All output is HTML-escaped by default. Use `|raw` filter to bypass escaping.
1354/// Supports filter chaining: `#var|filter1:arg|filter2#`
1355pub(crate) fn replace_variables(
1356    template: &str,
1357    context: &HashMap<String, serde_json::Value>,
1358) -> String {
1359    VAR_REGEX
1360        .replace_all(template, |caps: &regex::Captures| {
1361            let expr = &caps[1];
1362            let (var_path, filters) = parse_filter_chain(expr);
1363
1364            // Check if var_path contains arithmetic operators
1365            let raw_value = if contains_arithmetic(var_path) {
1366                resolve_and_evaluate_arithmetic(var_path, context)
1367                    .unwrap_or_else(|| resolve_variable(var_path, context))
1368            } else {
1369                resolve_variable(var_path, context)
1370            };
1371
1372            // If variable was unresolved and there's no default filter, keep as #var#
1373            let is_unresolved = raw_value.starts_with('#') && raw_value.ends_with('#');
1374
1375            // A strict not-found passthrough returns exactly `#<var_path>#` — the
1376            // author's own token, which must be preserved verbatim. A resolved
1377            // value that merely *looks* like a token (e.g. a DB field equal to
1378            // "#env.SECRET#") does NOT match this, so it is treated as data and
1379            // gets its `#` neutralized below.
1380            let is_preserved_token = raw_value == format!("#{var_path}#");
1381
1382            // Apply filters
1383            let filtered = if filters.is_empty() {
1384                FilterResult {
1385                    value: raw_value,
1386                    html_safe: false,
1387                }
1388            } else {
1389                // If unresolved and first filter is "default", use empty string to trigger default
1390                let input = if is_unresolved && filters.iter().any(|f| f.name == "default") {
1391                    String::new()
1392                } else {
1393                    raw_value
1394                };
1395                apply_filters(&input, &filters)
1396            };
1397
1398            // Auto-escape unless html_safe. Resolved (data) values additionally
1399            // have their `#` neutralized so they cannot be re-resolved on a later
1400            // render pass; the preserved `#unknown#` token is emitted as-is.
1401            if filtered.html_safe {
1402                filtered.value
1403            } else if is_preserved_token {
1404                html_escape(&filtered.value)
1405            } else {
1406                escape_and_neutralize_hashes(&filtered.value)
1407            }
1408        })
1409        .to_string()
1410}
1411
1412/// Result of reactive variable replacement
1413#[derive(Debug, Clone, Default)]
1414pub struct ReactiveReplaceResult {
1415    /// The rendered HTML content
1416    pub html: String,
1417    /// Session keys that were used (for OOB updates)
1418    pub session_keys: std::collections::HashSet<String>,
1419}
1420
1421/// Replace #variable# syntax with values from context, wrapping session variables
1422/// with reactive `<span w-bind>` elements when they appear in text content.
1423/// Variables inside attributes are replaced without wrapping.
1424/// All output is HTML-escaped by default. Supports filter chaining: `#var|filter1:arg|filter2#`
1425pub(crate) fn replace_variables_reactive(
1426    template: &str,
1427    context: &HashMap<String, serde_json::Value>,
1428) -> ReactiveReplaceResult {
1429    let mut session_keys = std::collections::HashSet::new();
1430
1431    // We need to track position to determine if we're in an attribute context
1432    let mut result = String::with_capacity(template.len());
1433    let mut last_end = 0;
1434
1435    for caps in VAR_REGEX.captures_iter(template) {
1436        let m = caps.get(0).unwrap();
1437        let expr = &caps[1];
1438        let start = m.start();
1439
1440        // Add the text before this match
1441        result.push_str(&template[last_end..start]);
1442
1443        // Parse filter chain
1444        let (var_path, filters) = parse_filter_chain(expr);
1445
1446        // Check if this is a session or wired variable
1447        let is_session_var = var_path.starts_with("session.");
1448        let is_wired_var = var_path.starts_with("wired.");
1449
1450        // Resolve the variable value (with arithmetic support)
1451        let raw_value = if contains_arithmetic(var_path) {
1452            resolve_and_evaluate_arithmetic(var_path, context)
1453                .unwrap_or_else(|| resolve_variable(var_path, context))
1454        } else {
1455            resolve_variable(var_path, context)
1456        };
1457        let is_unresolved = raw_value.starts_with('#') && raw_value.ends_with('#');
1458        // Exact strict not-found passthrough (`#<var_path>#`) — preserve verbatim;
1459        // a resolved value that merely looks like a token is data and gets its
1460        // `#` neutralized on the escaped emit paths below (second-order injection).
1461        let is_preserved_token = raw_value == format!("#{var_path}#");
1462
1463        // Apply filters
1464        let filtered = if filters.is_empty() {
1465            FilterResult {
1466                value: raw_value,
1467                html_safe: false,
1468            }
1469        } else {
1470            let input = if is_unresolved && filters.iter().any(|f| f.name == "default") {
1471                String::new()
1472            } else {
1473                raw_value
1474            };
1475            apply_filters(&input, &filters)
1476        };
1477
1478        if is_session_var || is_wired_var {
1479            let (bind_prefix, bind_key) = if is_session_var {
1480                let key = &var_path[8..]; // Skip "session."
1481                session_keys.insert(key.to_string());
1482                ("session", key)
1483            } else {
1484                let key = &var_path[6..]; // Skip "wired."
1485                ("wired", key)
1486            };
1487
1488            // If the value is unresolved (still looks like #var#), use empty string
1489            // to prevent double-wrapping when layouts re-process the output
1490            let display_value = if filtered.value.starts_with('#') && filtered.value.ends_with('#')
1491            {
1492                String::new()
1493            } else {
1494                filtered.value
1495            };
1496
1497            // Check if we're inside an attribute by looking at the text before this match
1498            let text_before = &template[..start];
1499            let in_attribute = is_in_attribute_context(text_before);
1500
1501            if in_attribute {
1502                // Inside attribute - replace with escaped value (no wrapping)
1503                if filtered.html_safe {
1504                    result.push_str(&display_value);
1505                } else {
1506                    result.push_str(&escape_and_neutralize_hashes(&display_value));
1507                }
1508            } else {
1509                // In text content - wrap with reactive span
1510                // The span content is always escaped (XSS protection in reactive updates)
1511                result.push_str(&format!(
1512                    r#"<span w-bind="{}.{}">{}</span>"#,
1513                    bind_prefix,
1514                    bind_key,
1515                    escape_and_neutralize_hashes(&display_value)
1516                ));
1517            }
1518        } else {
1519            // Non-reactive variable
1520            if filtered.html_safe {
1521                result.push_str(&filtered.value);
1522            } else if is_preserved_token {
1523                result.push_str(&html_escape(&filtered.value));
1524            } else {
1525                result.push_str(&escape_and_neutralize_hashes(&filtered.value));
1526            }
1527        }
1528
1529        last_end = m.end();
1530    }
1531
1532    // Add any remaining text after the last match
1533    result.push_str(&template[last_end..]);
1534
1535    ReactiveReplaceResult {
1536        html: result,
1537        session_keys,
1538    }
1539}
1540
1541/// Check if a position in the template is inside an HTML attribute
1542/// by analyzing the text before that position
1543fn is_in_attribute_context(text_before: &str) -> bool {
1544    // Look for the last opening tag or attribute quote
1545    // We're in an attribute if we find an unclosed attribute pattern
1546    let mut in_attr_value = false;
1547    let mut quote_char: Option<char> = None;
1548
1549    for c in text_before.chars().rev() {
1550        match c {
1551            '"' | '\'' if quote_char == Some(c) => {
1552                // End of attribute value (going backwards, this is actually the start)
1553                quote_char = None;
1554                in_attr_value = false;
1555            }
1556            '"' | '\'' if quote_char.is_none() => {
1557                // Start of attribute value (going backwards, this is actually the end)
1558                quote_char = Some(c);
1559                in_attr_value = true;
1560            }
1561            '>' if quote_char.is_none() => {
1562                // We hit a '>' before finding an unclosed attribute, so we're in text content
1563                return false;
1564            }
1565            '<' if quote_char.is_none() => {
1566                // We hit a '<' - we were inside a tag
1567                // If we have an unclosed quote, we're in an attribute
1568                return in_attr_value;
1569            }
1570            _ => {}
1571        }
1572    }
1573
1574    // If we reach here with an open quote, we're in an attribute
1575    in_attr_value
1576}
1577
1578/// Escape HTML special characters
1579fn html_escape(s: &str) -> String {
1580    s.replace('&', "&amp;")
1581        .replace('<', "&lt;")
1582        .replace('>', "&gt;")
1583        .replace('"', "&quot;")
1584        .replace('\'', "&#39;")
1585}
1586
1587/// HTML-escape a resolved variable value AND neutralize any `#` it contains so a
1588/// data-provided value shaped like a template token (e.g. `#env.SECRET#`) cannot
1589/// be re-interpreted on a later render pass. The engine renders in multiple
1590/// passes (loops/components/includes resolve their bodies, then a final pass
1591/// re-scans the whole page); without this, an attacker-controlled field rendered
1592/// inside a loop could smuggle a live `#env.*#` / `#x|raw#` token into the final
1593/// pass — leaking secrets or bypassing escaping (second-order template injection).
1594///
1595/// `#` is neutralized to the `&num;` HTML entity (renders identically as `#`).
1596/// A private-use sentinel protects data `#` from html_escape's own `&#39;` output,
1597/// which itself contains a `#` we must not touch.
1598fn escape_and_neutralize_hashes(s: &str) -> String {
1599    const SENTINEL: &str = "\u{E000}"; // Unicode private use — never appears in real content
1600    let protected = s.replace('#', SENTINEL);
1601    html_escape(&protected).replace(SENTINEL, "&num;")
1602}
1603
1604/// Reverse of html_escape, for comparison operands: values resolve through
1605/// replace_variables (which escapes for output), so comparing them against
1606/// an author-written literal like `"Ben & Jerry"` must undo the escaping.
1607/// `&amp;` is unescaped LAST — the mirror of html_escape escaping `&` first —
1608/// so `&amp;lt;` round-trips to `&lt;` and never collapses to `<`.
1609pub(crate) fn html_unescape(s: &str) -> String {
1610    if !s.contains('&') {
1611        return s.to_string();
1612    }
1613    s.replace("&lt;", "<")
1614        .replace("&gt;", ">")
1615        .replace("&quot;", "\"")
1616        .replace("&#39;", "'")
1617        .replace("&amp;", "&")
1618}
1619
1620/// Resolve computed variables from page directives and inject them into the context.
1621/// Computed variables use string interpolation: `compute.name = "Hello #user.name#!"`
1622/// They become available as `#name#` (without the `compute.` prefix) in templates.
1623/// Resolved in order, so later computed vars can reference earlier ones.
1624pub(crate) fn resolve_computed_variables(
1625    computed: &[(String, String)],
1626    context: &mut HashMap<String, serde_json::Value>,
1627) {
1628    for (name, template) in computed {
1629        // Interpolate #var# references in the template using current context
1630        let resolved = VAR_REGEX
1631            .replace_all(template, |caps: &regex::Captures| {
1632                let var_path = &caps[1];
1633                resolve_variable(var_path, context)
1634            })
1635            .to_string();
1636
1637        // Insert as a string value (without the compute. prefix)
1638        context.insert(name.clone(), serde_json::Value::String(resolved));
1639    }
1640}
1641
1642/// Resolve a variable path like "user.email" from context.
1643/// Also supports "env.VAR_NAME" to read environment variables.
1644/// Returns the resolved string value, or "#var_path#" if not found.
1645fn resolve_variable(path: &str, context: &HashMap<String, serde_json::Value>) -> String {
1646    let parts: Vec<&str> = path.split('.').collect();
1647
1648    if parts.is_empty() {
1649        return String::new();
1650    }
1651
1652    // Check for environment variable: #env.VAR_NAME#
1653    if parts[0] == "env" && parts.len() >= 2 {
1654        let env_var_name = parts[1..].join("_"); // env.DATABASE_URL -> DATABASE_URL
1655        return std::env::var(&env_var_name).unwrap_or_default();
1656    }
1657
1658    let root = context.get(parts[0]);
1659    let mut current: Option<&serde_json::Value> = root;
1660
1661    for part in parts.iter().skip(1) {
1662        current = current.and_then(|v| {
1663            if let serde_json::Value::Object(obj) = v {
1664                obj.get(*part)
1665            } else {
1666                None
1667            }
1668        });
1669    }
1670
1671    match current {
1672        Some(serde_json::Value::String(s)) => s.clone(),
1673        Some(serde_json::Value::Number(n)) => n.to_string(),
1674        Some(serde_json::Value::Bool(b)) => b.to_string(),
1675        Some(serde_json::Value::Null) => String::new(),
1676        Some(v) => v.to_string(),
1677        None if root.is_some() && parts.len() > 1 => String::new(), // Parent exists, child missing → empty
1678        None => format!("#{}#", path), // Root not in context → keep literal for strict mode
1679    }
1680}
1681
1682/// Check if a tag name is a standard HTML tag
1683#[allow(dead_code)]
1684pub(crate) fn is_standard_html_tag(name: &str) -> bool {
1685    matches!(
1686        name,
1687        "html"
1688            | "head"
1689            | "body"
1690            | "title"
1691            | "meta"
1692            | "link"
1693            | "script"
1694            | "style"
1695            | "div"
1696            | "span"
1697            | "p"
1698            | "a"
1699            | "img"
1700            | "br"
1701            | "hr"
1702            | "h1"
1703            | "h2"
1704            | "h3"
1705            | "h4"
1706            | "h5"
1707            | "h6"
1708            | "ul"
1709            | "ol"
1710            | "li"
1711            | "dl"
1712            | "dt"
1713            | "dd"
1714            | "table"
1715            | "thead"
1716            | "tbody"
1717            | "tfoot"
1718            | "tr"
1719            | "th"
1720            | "td"
1721            | "form"
1722            | "input"
1723            | "textarea"
1724            | "select"
1725            | "option"
1726            | "button"
1727            | "label"
1728            | "header"
1729            | "footer"
1730            | "main"
1731            | "nav"
1732            | "section"
1733            | "article"
1734            | "aside"
1735            | "figure"
1736            | "figcaption"
1737            | "video"
1738            | "audio"
1739            | "source"
1740            | "canvas"
1741            | "iframe"
1742            | "embed"
1743            | "object"
1744            | "param"
1745            | "strong"
1746            | "em"
1747            | "b"
1748            | "i"
1749            | "u"
1750            | "s"
1751            | "mark"
1752            | "small"
1753            | "sub"
1754            | "sup"
1755            | "blockquote"
1756            | "pre"
1757            | "code"
1758            | "kbd"
1759            | "samp"
1760            | "var"
1761            | "time"
1762            | "address"
1763            | "abbr"
1764            | "cite"
1765            | "q"
1766            | "ins"
1767            | "del"
1768            | "dfn"
1769            | "ruby"
1770            | "rt"
1771            | "rp"
1772            | "bdi"
1773            | "bdo"
1774            | "wbr"
1775            | "details"
1776            | "summary"
1777            | "dialog"
1778            | "slot"
1779            | "template"
1780            | "noscript"
1781    )
1782}
1783
1784/// Authentication level for a page
1785#[derive(Debug, Clone, PartialEq)]
1786pub(crate) enum AuthLevel {
1787    /// Public page - no authentication required (auth: all)
1788    All,
1789    /// Any authenticated user (auth: user)
1790    User,
1791    /// Specific roles required (auth: admin, editor)
1792    Roles(Vec<String>),
1793}
1794
1795impl std::fmt::Display for AuthLevel {
1796    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1797        match self {
1798            AuthLevel::All => write!(f, "all"),
1799            AuthLevel::User => write!(f, "user"),
1800            AuthLevel::Roles(v) => write!(f, "roles: {}", v.join(", ")),
1801        }
1802    }
1803}
1804
1805impl Default for AuthLevel {
1806    fn default() -> Self {
1807        AuthLevel::All
1808    }
1809}
1810
1811/// Page directives extracted from <what> tags
1812///
1813/// Example usage in HTML:
1814/// ```html
1815/// <what auth="all" />              <!-- Public page -->
1816/// <what auth="user" />             <!-- Any authenticated user -->
1817/// <what auth="admin, editor" />    <!-- Specific roles -->
1818/// <what layout="sections/main.html" /> <!-- Use a layout -->
1819/// ```
1820///
1821/// Or with content:
1822/// ```html
1823/// <what>
1824///   auth: all           # Public page (no auth)
1825///   auth: user          # Any authenticated user
1826///   auth: admin, editor # Specific roles
1827///   layout: sections/main.html
1828///   title: Dashboard
1829/// </what>
1830/// ```
1831///
1832/// Legacy syntax still supported:
1833/// ```html
1834/// <what protected roles="admin,editor" />
1835/// ```
1836/// Session mutation operation
1837#[derive(Debug, Clone)]
1838pub(crate) enum SessionMutation {
1839    /// Increment a session variable by a value: session.counter += 1
1840    Increment { key: String, value: i64 },
1841    /// Set a session variable: session.name = "value"
1842    Set { key: String, value: Value },
1843    /// Push a value to the end of an array: session.list.push(value)
1844    Push { key: String, value: Value },
1845    /// Push a value to the end of an array with a max size, dropping oldest: session.list.pushmax(10, value)
1846    PushMax {
1847        key: String,
1848        max: usize,
1849        value: Value,
1850    },
1851    /// Push a value to the beginning of an array: session.list.unshift(value)
1852    Unshift { key: String, value: Value },
1853    /// Clear an array: session.list.clear()
1854    Clear { key: String },
1855}
1856
1857#[derive(Debug, Clone, Default)]
1858pub(crate) struct PageDirectives {
1859    /// Authentication level for this page
1860    pub auth: AuthLevel,
1861    /// Page is protected (requires authentication) - LEGACY, use `auth` instead
1862    pub protected: bool,
1863    /// Required roles (any of these roles grants access) - LEGACY, use `auth` instead
1864    pub roles: Vec<String>,
1865    /// Page should be excluded from routing
1866    pub exclude: bool,
1867    /// Custom page title
1868    pub title: Option<String>,
1869    /// Redirect to another page
1870    pub redirect: Option<String>,
1871    /// Cache TTL in seconds (overrides global)
1872    pub cache_ttl: Option<u64>,
1873    /// Layout template path (e.g., "sections/layout.html")
1874    /// Use "none" to explicitly disable layout inheritance
1875    pub layout: Option<String>,
1876    /// Session mutations to apply before rendering
1877    pub session_mutations: Vec<SessionMutation>,
1878    /// Computed variables: compute.name = "interpolated #var# string"
1879    pub computed: Vec<(String, String)>,
1880    /// Custom response headers (from `header.Name = "value"` in application.what)
1881    pub headers: HashMap<String, String>,
1882    /// Any additional custom directives
1883    pub custom: HashMap<String, String>,
1884    /// Inline variables from `<what>` blocks (typed: strings, numbers, arrays, objects)
1885    pub vars: HashMap<String, Value>,
1886}
1887
1888impl PageDirectives {
1889    /// Check if page requires authentication
1890    /// Returns true if auth level is User or Roles, or if legacy `protected` is set
1891    pub fn requires_auth(&self) -> bool {
1892        match &self.auth {
1893            AuthLevel::All => self.protected, // Fall back to legacy
1894            AuthLevel::User => true,
1895            AuthLevel::Roles(_) => true,
1896        }
1897    }
1898
1899    /// Check if user has access based on auth level
1900    /// - All: always returns true
1901    /// - User: returns true if authenticated
1902    /// - Roles: returns true if user has any required role
1903    pub fn check_access(&self, authenticated: bool, user_roles: &[String]) -> bool {
1904        match &self.auth {
1905            AuthLevel::All => {
1906                // Legacy fallback
1907                if self.protected {
1908                    if !authenticated {
1909                        return false;
1910                    }
1911                    self.has_role(user_roles)
1912                } else {
1913                    true
1914                }
1915            }
1916            AuthLevel::User => authenticated,
1917            AuthLevel::Roles(required) => {
1918                authenticated && required.iter().any(|r| user_roles.contains(r))
1919            }
1920        }
1921    }
1922
1923    /// Check if user has any of the required roles (legacy method)
1924    pub fn has_role(&self, user_roles: &[String]) -> bool {
1925        if self.roles.is_empty() {
1926            return true; // No role requirement
1927        }
1928        self.roles.iter().any(|r| user_roles.contains(r))
1929    }
1930}
1931
1932/// Parse <what> directives from page content
1933/// Returns the directives and the content with <what> tags removed
1934pub(crate) fn parse_page_directives(content: &str) -> (PageDirectives, String) {
1935    let mut directives = PageDirectives::default();
1936
1937    let cleaned = WHAT_DIRECTIVE_REGEX
1938        .replace_all(content, |caps: &regex::Captures| {
1939            let attrs_str = caps.get(1).map(|m| m.as_str()).unwrap_or("");
1940            let inner_content = caps.get(2).map(|m| m.as_str());
1941
1942            // Parse attributes from the tag
1943            parse_directive_attributes(attrs_str, &mut directives);
1944
1945            // Parse inner content if present (YAML-like syntax)
1946            if let Some(inner) = inner_content {
1947                parse_directive_content(inner, &mut directives);
1948            }
1949
1950            "" // Remove the <what> tag from output
1951        })
1952        .to_string();
1953
1954    (directives, cleaned)
1955}
1956
1957/// Parse auth value into AuthLevel
1958pub(crate) fn parse_auth_level(value: &str) -> AuthLevel {
1959    let value = value.trim().to_lowercase();
1960    match value.as_str() {
1961        "all" | "public" | "none" => AuthLevel::All,
1962        "user" | "authenticated" => AuthLevel::User,
1963        _ => {
1964            // Parse as comma-separated roles
1965            let roles: Vec<String> = value
1966                .split(',')
1967                .map(|s| s.trim().to_string())
1968                .filter(|s| !s.is_empty())
1969                .collect();
1970            if roles.is_empty() {
1971                AuthLevel::All
1972            } else {
1973                AuthLevel::Roles(roles)
1974            }
1975        }
1976    }
1977}
1978
1979/// Parse attributes from <what> tag
1980fn parse_directive_attributes(attrs_str: &str, directives: &mut PageDirectives) {
1981    // Parse key="value" attributes
1982    let attrs = parse_attributes(attrs_str);
1983
1984    for (key, value) in &attrs {
1985        match key.as_str() {
1986            "auth" => {
1987                directives.auth = parse_auth_level(value);
1988            }
1989            // Legacy support
1990            "protected" => directives.protected = value != "false",
1991            "roles" => {
1992                directives.roles = value
1993                    .split(',')
1994                    .map(|s| s.trim().to_string())
1995                    .filter(|s| !s.is_empty())
1996                    .collect();
1997                // If roles are specified, page is implicitly protected
1998                if !directives.roles.is_empty() {
1999                    directives.protected = true;
2000                }
2001            }
2002            "exclude" => directives.exclude = value != "false",
2003            "title" => directives.title = Some(value.clone()),
2004            "redirect" => directives.redirect = Some(value.clone()),
2005            "layout" => directives.layout = Some(value.clone()),
2006            "cache" | "cache-ttl" => {
2007                directives.cache_ttl = value.parse().ok();
2008            }
2009            _ => {
2010                // Handle header.* keys
2011                if let Some(header_name) = key.strip_prefix("header.") {
2012                    directives
2013                        .headers
2014                        .insert(header_name.to_string(), value.clone());
2015                } else {
2016                    warn_access_directive_near_miss(key);
2017                    directives.custom.insert(key.clone(), value.clone());
2018                }
2019            }
2020        }
2021    }
2022
2023    // Parse boolean attributes (no value)
2024    // Find words that aren't part of key="value" pairs
2025    let without_attrs = ATTR_REGEX.replace_all(attrs_str, "");
2026    for cap in BOOL_ATTR_REGEX.captures_iter(&without_attrs) {
2027        let key = &cap[1];
2028        match key {
2029            "protected" => directives.protected = true,
2030            "exclude" => directives.exclude = true,
2031            _ => {}
2032        }
2033    }
2034}
2035
2036/// Reserved directive keys — these are NOT inline variables
2037fn is_reserved_directive(key: &str) -> bool {
2038    matches!(
2039        key,
2040        "auth"
2041            | "protected"
2042            | "roles"
2043            | "exclude"
2044            | "title"
2045            | "redirect"
2046            | "layout"
2047            | "cache"
2048            | "cache-ttl"
2049            | "method"
2050            | "paginate"
2051    ) || key.starts_with("fetch.")
2052        || key.starts_with("session.")
2053        || key.starts_with("compute.")
2054        || key.starts_with("set.")
2055        || key.starts_with("data.")
2056        || key.starts_with("header.")
2057        || key.starts_with("mutation.")
2058}
2059
2060/// True when `a` and `b` are within one edit of each other: a single
2061/// substitution, insertion, deletion, or adjacent transposition.
2062fn within_one_edit(a: &str, b: &str) -> bool {
2063    if a == b {
2064        return true;
2065    }
2066    let a: Vec<char> = a.chars().collect();
2067    let b: Vec<char> = b.chars().collect();
2068    if a.len().abs_diff(b.len()) > 1 {
2069        return false;
2070    }
2071    if a.len() == b.len() {
2072        let diffs: Vec<usize> = (0..a.len()).filter(|&i| a[i] != b[i]).collect();
2073        match diffs.len() {
2074            1 => true,
2075            2 => {
2076                diffs[1] == diffs[0] + 1
2077                    && a[diffs[0]] == b[diffs[1]]
2078                    && a[diffs[1]] == b[diffs[0]]
2079            }
2080            _ => false,
2081        }
2082    } else {
2083        let (short, long) = if a.len() < b.len() { (&a, &b) } else { (&b, &a) };
2084        let mut i = 0;
2085        let mut j = 0;
2086        let mut skipped = false;
2087        while i < short.len() && j < long.len() {
2088            if short[i] == long[j] {
2089                i += 1;
2090                j += 1;
2091            } else if skipped {
2092                return false;
2093            } else {
2094                skipped = true;
2095                j += 1;
2096            }
2097        }
2098        true
2099    }
2100}
2101
2102/// Detect a likely misspelling of an access-control directive key.
2103///
2104/// An unknown `<what>` key falls through to "inline variable", and
2105/// `AuthLevel` defaults to `All` — so `auht: user` would silently leave the
2106/// page PUBLIC. Only the access-control keys get fuzzy matching: a typo'd
2107/// `title` or `layout` breaks visibly, but a typo'd `auth` fails open with
2108/// no symptom. Requires a matching first letter so common real variable
2109/// names near these words (e.g. `oauth`) don't trip it.
2110fn access_directive_near_miss(key: &str) -> Option<&'static str> {
2111    const ACCESS_KEYS: [&str; 3] = ["auth", "protected", "roles"];
2112    let lower = key.to_lowercase();
2113    // Compare the ORIGINAL key for the exact-match exclusion: `Auth` is a
2114    // case typo worth flagging, only a byte-exact `auth` parses as the
2115    // real directive.
2116    ACCESS_KEYS.into_iter().find(|reserved| {
2117        key != *reserved
2118            && lower.chars().next() == reserved.chars().next()
2119            && within_one_edit(&lower, reserved)
2120    })
2121}
2122
2123/// Keys already reported as access-directive near-misses (warn once per key
2124/// per process — directives re-parse on every request).
2125static WARNED_NEAR_MISSES: LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
2126    LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
2127
2128/// Warn loudly (all modes, not just dev) when a `<what>` key looks like a
2129/// misspelled access-control directive. The page renders unchanged — this
2130/// must never fail closed on a false positive — but silence here means a
2131/// world-readable page the author believes is protected.
2132fn warn_access_directive_near_miss(key: &str) {
2133    if let Some(reserved) = access_directive_near_miss(key) {
2134        let mut warned = WARNED_NEAR_MISSES.lock().unwrap();
2135        if warned.insert(key.to_string()) {
2136            tracing::error!(
2137                "<what> key '{}' looks like a misspelling of the '{}' access-control directive. \
2138                 It was treated as an inline variable, so NO access restriction was applied to this page. \
2139                 If you meant '{}', fix the spelling; if it is a real variable, rename it.",
2140                key,
2141                reserved,
2142                reserved
2143            );
2144        }
2145    }
2146}
2147
2148/// Parse inner content of <what> tag (YAML-like syntax)
2149fn parse_directive_content(content: &str, directives: &mut PageDirectives) {
2150    let mut current_section: Option<String> = None;
2151
2152    // Multi-line JSON accumulation state
2153    let mut json_key: Option<String> = None;
2154    let mut json_buf = String::new();
2155    let mut json_depth: usize = 0;
2156    let mut json_bracket: char = ' '; // '[' or '{'
2157
2158    let lines: Vec<&str> = content.lines().collect();
2159    let mut i = 0;
2160
2161    while i < lines.len() {
2162        let raw_line = lines[i];
2163        let trimmed = raw_line.trim();
2164        i += 1;
2165
2166        // If we're accumulating a multi-line JSON value, keep appending
2167        if json_key.is_some() {
2168            json_buf.push('\n');
2169            json_buf.push_str(trimmed);
2170            let close_char = if json_bracket == '[' { ']' } else { '}' };
2171            json_depth += trimmed.matches(json_bracket).count();
2172            json_depth -= trimmed.matches(close_char).count();
2173            if json_depth == 0 {
2174                // Done accumulating — parse the JSON
2175                let key = json_key.take().unwrap();
2176                let relaxed = relax_json(&json_buf);
2177                match serde_json::from_str::<Value>(&relaxed) {
2178                    Ok(val) => {
2179                        directives.vars.insert(key, val);
2180                    }
2181                    Err(e) => {
2182                        tracing::warn!("Invalid JSON for inline var: {}", e);
2183                    }
2184                }
2185                json_buf.clear();
2186            }
2187            continue;
2188        }
2189
2190        if trimmed.is_empty() {
2191            continue;
2192        }
2193
2194        // Section header: [name] sets prefix, [] resets to root
2195        if trimmed.starts_with('[') && trimmed.ends_with(']') {
2196            let inner = trimmed[1..trimmed.len() - 1].trim();
2197            if inner.is_empty() {
2198                current_section = None;
2199            } else {
2200                current_section = Some(inner.to_lowercase().to_string());
2201            }
2202            continue;
2203        }
2204
2205        // Apply section prefix to raw line (syntactic sugar for flat dotted keys)
2206        let line_owned;
2207        let line: &str = if let Some(ref section) = current_section {
2208            line_owned = format!("{}.{}", section, trimmed);
2209            line_owned.trim()
2210        } else {
2211            trimmed
2212        };
2213
2214        // Check for session mutation: session.key += value or session.key = value
2215        if line.starts_with("session.") {
2216            if let Some(mutation) = parse_session_mutation(line) {
2217                directives.session_mutations.push(mutation);
2218            }
2219            continue;
2220        }
2221
2222        // Check for computed variable: compute.name = "interpolated #var# string"
2223        if line.starts_with("compute.") {
2224            if let Some(eq_pos) = line.find('=') {
2225                let name = line[8..eq_pos].trim().to_string(); // Skip "compute."
2226                let value = line[eq_pos + 1..].trim();
2227                // Remove surrounding quotes if present (one symmetric pair only)
2228                let value = strip_symmetric_quotes(value).0.to_string();
2229                directives.computed.push((name, value));
2230            }
2231            continue;
2232        }
2233
2234        // Check for boolean directive (just a word)
2235        if !line.contains(':') && !line.contains('=') {
2236            match line.to_lowercase().as_str() {
2237                "protected" => directives.protected = true,
2238                "exclude" => directives.exclude = true,
2239                _ => {}
2240            }
2241            continue;
2242        }
2243
2244        // Parse key: value or key = value
2245        // Prefer '=' when it appears before ':' (handles URLs like https://...)
2246        let colon_idx = line.find(':');
2247        let equals_idx = line.find('=');
2248        let (key, value) = match (colon_idx, equals_idx) {
2249            (Some(c), Some(e)) => {
2250                if e < c {
2251                    (&line[..e], line[e + 1..].trim())
2252                } else {
2253                    (&line[..c], line[c + 1..].trim())
2254                }
2255            }
2256            (Some(c), None) => (&line[..c], line[c + 1..].trim()),
2257            (None, Some(e)) => (&line[..e], line[e + 1..].trim()),
2258            _ => continue,
2259        };
2260
2261        let key = key.trim().to_lowercase();
2262        let (value, value_was_quoted) = strip_symmetric_quotes(value);
2263        if !value_was_quoted
2264            && value.len() >= 2
2265            && (value.starts_with('"')
2266                || value.starts_with('\'')
2267                || value.ends_with('"')
2268                || value.ends_with('\''))
2269        {
2270            tracing::warn!(
2271                "Mismatched quotes in <what> block value for '{}': {} — use one matching pair, e.g. \"value\"",
2272                key,
2273                value
2274            );
2275        }
2276
2277        match key.as_str() {
2278            "auth" => {
2279                directives.auth = parse_auth_level(value);
2280            }
2281            // Legacy support
2282            "protected" => directives.protected = value != "false",
2283            "roles" => {
2284                directives.roles = value
2285                    .split(',')
2286                    .map(|s| s.trim().to_string())
2287                    .filter(|s| !s.is_empty())
2288                    .collect();
2289                if !directives.roles.is_empty() {
2290                    directives.protected = true;
2291                }
2292            }
2293            "exclude" => directives.exclude = value != "false",
2294            "title" => {
2295                if !value_was_quoted && is_unquoted_string(value) {
2296                    tracing::warn!(
2297                        "Unquoted string in <what> block: title should be quoted, e.g. title: \"{}\"",
2298                        value
2299                    );
2300                }
2301                directives.title = Some(value.to_string());
2302            }
2303            "redirect" => {
2304                if !value_was_quoted && is_unquoted_string(value) {
2305                    tracing::warn!(
2306                        "Unquoted string in <what> block: redirect should be quoted, e.g. redirect: \"{}\"",
2307                        value
2308                    );
2309                }
2310                directives.redirect = Some(value.to_string());
2311            }
2312            "layout" => {
2313                if !value_was_quoted && is_unquoted_string(value) {
2314                    tracing::warn!(
2315                        "Unquoted string in <what> block: layout should be quoted, e.g. layout: \"{}\"",
2316                        value
2317                    );
2318                }
2319                directives.layout = Some(value.to_string());
2320            }
2321            "cache" | "cache-ttl" => {
2322                directives.cache_ttl = value.parse().ok();
2323            }
2324            _ => {
2325                // Handle header.* keys
2326                if let Some(header_name) = key.strip_prefix("header.") {
2327                    directives
2328                        .headers
2329                        .insert(header_name.to_string(), value.to_string());
2330                } else if is_reserved_directive(&key) {
2331                    // Reserved prefixed directives (fetch.*, set.*, data.*, etc.)
2332                    if !value_was_quoted && !value.is_empty() && is_unquoted_string(value) {
2333                        tracing::warn!(
2334                            "Unquoted string in <what> block: {} should be quoted, e.g. {} = \"{}\"",
2335                            key,
2336                            key,
2337                            value
2338                        );
2339                    }
2340                    directives.custom.insert(key, value.to_string());
2341                } else {
2342                    // Non-reserved key — treat as inline variable
2343                    warn_access_directive_near_miss(&key);
2344                    // Check if value starts a multi-line JSON block
2345                    let value_untrimmed = {
2346                        let eq_pos = line.find('=').or_else(|| line.find(':')).unwrap();
2347                        line[eq_pos + 1..].trim()
2348                    };
2349                    if (value_untrimmed.starts_with('[') || value_untrimmed.starts_with('{'))
2350                        && !value_untrimmed.ends_with(']')
2351                        && !value_untrimmed.ends_with('}')
2352                    {
2353                        // Start multi-line JSON accumulation
2354                        json_bracket = value_untrimmed.chars().next().unwrap();
2355                        json_buf = value_untrimmed.to_string();
2356                        json_depth = value_untrimmed.matches(json_bracket).count();
2357                        let close_char = if json_bracket == '[' { ']' } else { '}' };
2358                        json_depth -= value_untrimmed.matches(close_char).count();
2359                        if json_depth == 0 {
2360                            // Single-line JSON that's complete
2361                            let relaxed = relax_json(value_untrimmed);
2362                            match serde_json::from_str::<Value>(&relaxed) {
2363                                Ok(val) => {
2364                                    directives.vars.insert(key, val);
2365                                }
2366                                Err(e) => {
2367                                    tracing::warn!("Invalid JSON for inline var '{}': {}", key, e);
2368                                }
2369                            }
2370                            json_buf.clear();
2371                        } else {
2372                            json_key = Some(key);
2373                        }
2374                    } else if value_untrimmed.starts_with('[') || value_untrimmed.starts_with('{') {
2375                        // Single-line JSON (complete on one line)
2376                        let relaxed = relax_json(value_untrimmed);
2377                        match serde_json::from_str::<Value>(&relaxed) {
2378                            Ok(val) => {
2379                                directives.vars.insert(key, val);
2380                            }
2381                            Err(e) => {
2382                                tracing::warn!("Invalid JSON for inline var '{}': {}", key, e);
2383                            }
2384                        }
2385                    } else {
2386                        // Scalar inline variable — quoting forces string type
2387                        // (`zip = "01234"` stays "01234"; unquoted values are
2388                        // type-inferred as before).
2389                        let parsed = if value_was_quoted {
2390                            Value::String(value.to_string())
2391                        } else {
2392                            parse_inline_value(value)
2393                        };
2394                        // Warn if string value is unquoted (numbers/bools are fine)
2395                        if !value_was_quoted && is_unquoted_string(value) {
2396                            tracing::warn!(
2397                                "Unquoted string in <what> block: {} should be quoted, e.g. {} = \"{}\"",
2398                                key,
2399                                key,
2400                                value
2401                            );
2402                        }
2403                        directives.vars.insert(key, parsed);
2404                    }
2405                }
2406            }
2407        }
2408    }
2409}
2410
2411/// Strip exactly one symmetric pair of matching quotes.
2412/// Returns (stripped_value, was_quoted). Mismatched, unterminated, or nested
2413/// quotes are left intact so author mistakes stay visible instead of being
2414/// silently swallowed (the old `trim_matches` stripped repeated AND mismatched
2415/// quote characters).
2416pub(crate) fn strip_symmetric_quotes(s: &str) -> (&str, bool) {
2417    let bytes = s.as_bytes();
2418    if bytes.len() >= 2 {
2419        let first = bytes[0];
2420        if (first == b'"' || first == b'\'') && bytes[bytes.len() - 1] == first {
2421            return (&s[1..s.len() - 1], true);
2422        }
2423    }
2424    (s, false)
2425}
2426
2427/// Check if a value is a string that should have been quoted.
2428/// Returns false for numbers, booleans, and known keywords.
2429fn is_unquoted_string(value: &str) -> bool {
2430    if value.is_empty() {
2431        return false;
2432    }
2433    if value.parse::<f64>().is_ok() {
2434        return false;
2435    }
2436    match value.to_lowercase().as_str() {
2437        "true" | "false" | "none" | "all" | "user" => false,
2438        _ => true,
2439    }
2440}
2441
2442/// Relax JSON: quote unquoted object keys so serde_json can parse it.
2443/// Turns `{ name: "Widget" }` into `{ "name": "Widget" }`.
2444fn relax_json(s: &str) -> String {
2445    static UNQUOTED_KEY: LazyLock<Regex> =
2446        LazyLock::new(|| Regex::new(r#"(?m)([{\[,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:"#).unwrap());
2447    UNQUOTED_KEY.replace_all(s, r#"$1 "$2":"#).into_owned()
2448}
2449
2450/// Parse a scalar inline value to a typed JSON Value
2451fn parse_inline_value(s: &str) -> Value {
2452    // Try integer
2453    if let Ok(n) = s.parse::<i64>() {
2454        return json!(n);
2455    }
2456    // Try float
2457    if let Ok(n) = s.parse::<f64>() {
2458        return json!(n);
2459    }
2460    // Booleans
2461    if s == "true" {
2462        return json!(true);
2463    }
2464    if s == "false" {
2465        return json!(false);
2466    }
2467    // String (already had quotes stripped)
2468    json!(s)
2469}
2470
2471/// Parse session mutation from line like "session.counter += 1" or "session.name = value"
2472fn parse_session_mutation(line: &str) -> Option<SessionMutation> {
2473    // Remove "session." prefix
2474    let rest = line.strip_prefix("session.")?;
2475
2476    // Check for array operations: key.pushmax(N, value), key.push(value), key.unshift(value), key.clear()
2477    // pushmax must be checked before push since ".push(" is a prefix of ".pushmax("
2478    if let Some(idx) = rest.find(".pushmax(") {
2479        let key = rest[..idx].trim().to_string();
2480        let value_start = idx + 9; // len of ".pushmax("
2481        let value_end = rest.rfind(')')?;
2482        let inner = rest[value_start..value_end].trim();
2483        // Parse "N, value" — first arg is max size, second is the value
2484        if let Some(comma) = inner.find(',') {
2485            let max_str = inner[..comma].trim();
2486            let (value_str, was_quoted) = strip_symmetric_quotes(inner[comma + 1..].trim());
2487            if let Ok(max) = max_str.parse::<usize>() {
2488                let value = parse_mutation_value(value_str, was_quoted);
2489                return Some(SessionMutation::PushMax { key, max, value });
2490            }
2491        }
2492    }
2493
2494    if let Some(idx) = rest.find(".push(") {
2495        let key = rest[..idx].trim().to_string();
2496        let value_start = idx + 6; // len of ".push("
2497        let value_end = rest.rfind(')')?;
2498        let (value_str, was_quoted) = strip_symmetric_quotes(rest[value_start..value_end].trim());
2499        let value = parse_mutation_value(value_str, was_quoted);
2500        return Some(SessionMutation::Push { key, value });
2501    }
2502
2503    if let Some(idx) = rest.find(".unshift(") {
2504        let key = rest[..idx].trim().to_string();
2505        let value_start = idx + 9; // len of ".unshift("
2506        let value_end = rest.rfind(')')?;
2507        let (value_str, was_quoted) = strip_symmetric_quotes(rest[value_start..value_end].trim());
2508        let value = parse_mutation_value(value_str, was_quoted);
2509        return Some(SessionMutation::Unshift { key, value });
2510    }
2511
2512    if let Some(idx) = rest.find(".clear()") {
2513        let key = rest[..idx].trim().to_string();
2514        return Some(SessionMutation::Clear { key });
2515    }
2516
2517    // Check for increment: key += value
2518    if let Some(idx) = rest.find("+=") {
2519        let key = rest[..idx].trim().to_string();
2520        let value_str = rest[idx + 2..].trim();
2521        let value: i64 = value_str.parse().ok()?;
2522        return Some(SessionMutation::Increment { key, value });
2523    }
2524
2525    // Check for decrement: key -= value (convert to negative increment)
2526    if let Some(idx) = rest.find("-=") {
2527        let key = rest[..idx].trim().to_string();
2528        let value_str = rest[idx + 2..].trim();
2529        let value: i64 = value_str.parse().ok()?;
2530        return Some(SessionMutation::Increment { key, value: -value });
2531    }
2532
2533    // Check for assignment: key = value
2534    if let Some(idx) = rest.find('=') {
2535        let key = rest[..idx].trim().to_string();
2536        let (value_str, was_quoted) = strip_symmetric_quotes(rest[idx + 1..].trim());
2537        let value = parse_mutation_value(value_str, was_quoted);
2538        return Some(SessionMutation::Set { key, value });
2539    }
2540
2541    None
2542}
2543
2544/// Parse a mutation value — quoting forces string type, unquoted values are
2545/// type-inferred (`session.zip = "01234"` stays "01234"; `session.count = 42`
2546/// is a number).
2547fn parse_mutation_value(value_str: &str, was_quoted: bool) -> Value {
2548    if was_quoted {
2549        json!(value_str)
2550    } else {
2551        parse_value_str(value_str)
2552    }
2553}
2554
2555/// Parse a value string into a JSON Value
2556fn parse_value_str(value_str: &str) -> Value {
2557    // Check for empty array
2558    if value_str == "[]" {
2559        return json!([]);
2560    }
2561    // Try to parse as number first, then boolean, then string
2562    if let Ok(n) = value_str.parse::<i64>() {
2563        json!(n)
2564    } else if let Ok(f) = value_str.parse::<f64>() {
2565        json!(f)
2566    } else if value_str == "true" {
2567        json!(true)
2568    } else if value_str == "false" {
2569        json!(false)
2570    } else {
2571        json!(value_str)
2572    }
2573}
2574
2575#[cfg(test)]
2576mod tests {
2577    use super::*;
2578
2579    #[test]
2580    fn test_parse_attributes() {
2581        let attrs = parse_attributes(r#"title="Hello" size="large""#);
2582        assert_eq!(attrs.get("title"), Some(&"Hello".to_string()));
2583        assert_eq!(attrs.get("size"), Some(&"large".to_string()));
2584    }
2585
2586    #[test]
2587    fn test_replace_variables() {
2588        let mut context = HashMap::new();
2589        context.insert("name".to_string(), serde_json::json!("World"));
2590
2591        let result = replace_variables("Hello #name#!", &context);
2592        assert_eq!(result, "Hello World!");
2593    }
2594
2595    #[test]
2596    fn test_nested_variables() {
2597        let mut context = HashMap::new();
2598        context.insert(
2599            "user".to_string(),
2600            serde_json::json!({
2601                "name": "Alice",
2602                "email": "alice@example.com"
2603            }),
2604        );
2605
2606        let result = replace_variables("#user.name# (#user.email#)", &context);
2607        assert_eq!(result, "Alice (alice@example.com)");
2608    }
2609
2610    #[test]
2611    fn test_resolved_value_hashes_are_neutralized() {
2612        // A data value that looks like a template token must NOT survive as a
2613        // live token — otherwise a later render pass re-resolves it (second-order
2614        // template injection). See escape_and_neutralize_hashes.
2615        let mut context = HashMap::new();
2616        context.insert(
2617            "bio".to_string(),
2618            serde_json::json!("#env.SECRET# and #other|raw#"),
2619        );
2620        let result = replace_variables("<p>#bio#</p>", &context);
2621        // The '#' characters are emitted as &num; entities (render as '#' in the
2622        // browser) so VAR_REGEX cannot match them on a subsequent pass.
2623        assert_eq!(result, "<p>&num;env.SECRET&num; and &num;other|raw&num;</p>");
2624        assert!(!result.contains("#env.SECRET#"));
2625    }
2626
2627    #[test]
2628    fn test_unresolved_token_preserved_verbatim() {
2629        // Strict not-found passthrough keeps the author's literal #unknown# token.
2630        let context = HashMap::new();
2631        let result = replace_variables("<p>#unknown_var#</p>", &context);
2632        assert_eq!(result, "<p>#unknown_var#</p>");
2633    }
2634
2635    #[test]
2636    fn test_reactive_resolved_value_hashes_neutralized() {
2637        // The reactive render path (used for live pages) must also neutralize '#'
2638        // in resolved data so a session/data value like "#env.SECRET#" cannot be
2639        // re-resolved on a later pass. Covers the non-reactive var branch...
2640        let mut context = HashMap::new();
2641        context.insert("bio".to_string(), serde_json::json!("#env.SECRET#"));
2642        let out = replace_variables_reactive("<p>#bio#</p>", &context).html;
2643        assert!(!out.contains("#env.SECRET#"), "reactive path leaked a live token: {out}");
2644        assert!(out.contains("&num;env.SECRET&num;"));
2645
2646        // ...and the reactive <span w-bind> text branch for a session value.
2647        let mut ctx2 = HashMap::new();
2648        ctx2.insert(
2649            "session".to_string(),
2650            serde_json::json!({ "note": "x #env.SECRET# y" }),
2651        );
2652        let out2 = replace_variables_reactive("<p>#session.note#</p>", &ctx2).html;
2653        assert!(!out2.contains("#env.SECRET#"), "reactive span leaked a live token: {out2}");
2654
2655        // Strict not-found token still preserved verbatim on the reactive path.
2656        let empty = HashMap::new();
2657        let out3 = replace_variables_reactive("<p>#nope#</p>", &empty).html;
2658        assert_eq!(out3, "<p>#nope#</p>");
2659    }
2660
2661    #[test]
2662    fn test_env_variables() {
2663        // Set a test environment variable
2664        unsafe {
2665            std::env::set_var("WHAT_TEST_VAR", "test_value");
2666        }
2667
2668        let context = HashMap::new();
2669        let result = replace_variables("Value: #env.WHAT_TEST_VAR#", &context);
2670        assert_eq!(result, "Value: test_value");
2671
2672        // Clean up
2673        unsafe {
2674            std::env::remove_var("WHAT_TEST_VAR");
2675        }
2676    }
2677
2678    #[test]
2679    fn test_env_variable_not_found() {
2680        let context = HashMap::new();
2681        let result = replace_variables("#env.NONEXISTENT_VAR_12345#", &context);
2682        assert_eq!(result, ""); // Returns empty string for missing env vars
2683    }
2684
2685    #[test]
2686    fn test_page_directives_self_closing() {
2687        let content = r#"<what protected roles="admin,editor" />
2688<!DOCTYPE html>
2689<html>
2690<body>Hello</body>
2691</html>"#;
2692
2693        let (directives, cleaned) = parse_page_directives(content);
2694
2695        assert!(directives.protected);
2696        assert_eq!(directives.roles, vec!["admin", "editor"]);
2697        assert!(cleaned.contains("<!DOCTYPE html>"));
2698        assert!(!cleaned.contains("<what"));
2699    }
2700
2701    #[test]
2702    fn test_page_directives_boolean() {
2703        let content = r#"<what protected exclude />
2704<html></html>"#;
2705
2706        let (directives, cleaned) = parse_page_directives(content);
2707
2708        assert!(directives.protected);
2709        assert!(directives.exclude);
2710        assert!(!cleaned.contains("<what"));
2711    }
2712
2713    #[test]
2714    fn test_page_directives_content_syntax() {
2715        let content = r#"<what>
2716protected
2717roles: admin, manager
2718title: Admin Dashboard
2719</what>
2720<!DOCTYPE html>
2721<html></html>"#;
2722
2723        let (directives, cleaned) = parse_page_directives(content);
2724
2725        assert!(directives.protected);
2726        assert_eq!(directives.roles, vec!["admin", "manager"]);
2727        assert_eq!(directives.title, Some("Admin Dashboard".to_string()));
2728        assert!(cleaned.contains("<!DOCTYPE html>"));
2729    }
2730
2731    #[test]
2732    fn test_page_directives_roles_imply_protected() {
2733        let content = r#"<what roles="admin" />
2734<html></html>"#;
2735
2736        let (directives, _) = parse_page_directives(content);
2737
2738        assert!(directives.protected); // Implied by roles
2739        assert_eq!(directives.roles, vec!["admin"]);
2740    }
2741
2742    #[test]
2743    fn test_no_directives() {
2744        let content = r#"<!DOCTYPE html>
2745<html><body>Hello</body></html>"#;
2746
2747        let (directives, cleaned) = parse_page_directives(content);
2748
2749        assert!(!directives.protected);
2750        assert!(directives.roles.is_empty());
2751        assert_eq!(content, cleaned);
2752    }
2753
2754    #[test]
2755    fn test_page_tag_preserved() {
2756        // Ensure parse_page_directives doesn't touch <page> tags
2757        let content = r#"<page title="Test">
2758  <what-nav active="home"/>
2759  <main>Content</main>
2760</page>"#;
2761
2762        let (_, cleaned) = parse_page_directives(content);
2763        println!("Cleaned content: '{}'", cleaned);
2764
2765        assert!(cleaned.contains("<page"), "Should preserve <page> tag");
2766        assert!(cleaned.contains("</page>"), "Should preserve </page> tag");
2767        assert!(
2768            cleaned.contains("<what-nav"),
2769            "Should preserve <what-nav> tag"
2770        );
2771        assert_eq!(content, cleaned, "Content should be unchanged");
2772    }
2773
2774    #[test]
2775    fn test_auth_all() {
2776        let content = r#"<what auth="all" />
2777<html></html>"#;
2778
2779        let (directives, _) = parse_page_directives(content);
2780
2781        assert_eq!(directives.auth, AuthLevel::All);
2782        assert!(!directives.requires_auth());
2783    }
2784
2785    #[test]
2786    fn test_auth_user() {
2787        let content = r#"<what auth="user" />
2788<html></html>"#;
2789
2790        let (directives, _) = parse_page_directives(content);
2791
2792        assert_eq!(directives.auth, AuthLevel::User);
2793        assert!(directives.requires_auth());
2794        assert!(directives.check_access(true, &[]));
2795        assert!(!directives.check_access(false, &[]));
2796    }
2797
2798    #[test]
2799    fn test_auth_roles() {
2800        let content = r#"<what auth="admin, editor" />
2801<html></html>"#;
2802
2803        let (directives, _) = parse_page_directives(content);
2804
2805        assert_eq!(
2806            directives.auth,
2807            AuthLevel::Roles(vec!["admin".to_string(), "editor".to_string()])
2808        );
2809        assert!(directives.requires_auth());
2810
2811        // Has admin role
2812        assert!(directives.check_access(true, &["admin".to_string()]));
2813        // Has editor role
2814        assert!(directives.check_access(true, &["editor".to_string()]));
2815        // Has neither role
2816        assert!(!directives.check_access(true, &["viewer".to_string()]));
2817        // Not authenticated
2818        assert!(!directives.check_access(false, &["admin".to_string()]));
2819    }
2820
2821    #[test]
2822    fn test_auth_content_syntax() {
2823        let content = r#"<what>
2824auth: admin, manager
2825title: Dashboard
2826</what>
2827<html></html>"#;
2828
2829        let (directives, _) = parse_page_directives(content);
2830
2831        assert_eq!(
2832            directives.auth,
2833            AuthLevel::Roles(vec!["admin".to_string(), "manager".to_string()])
2834        );
2835        assert_eq!(directives.title, Some("Dashboard".to_string()));
2836    }
2837
2838    #[test]
2839    fn test_auth_public_aliases() {
2840        // Test "public" alias
2841        let (d1, _) = parse_page_directives(r#"<what auth="public" /><html></html>"#);
2842        assert_eq!(d1.auth, AuthLevel::All);
2843
2844        // Test "none" alias
2845        let (d2, _) = parse_page_directives(r#"<what auth="none" /><html></html>"#);
2846        assert_eq!(d2.auth, AuthLevel::All);
2847
2848        // Test "authenticated" alias
2849        let (d3, _) = parse_page_directives(r#"<what auth="authenticated" /><html></html>"#);
2850        assert_eq!(d3.auth, AuthLevel::User);
2851    }
2852
2853    // =========================================================================
2854    // Fetch Directive Parsing Tests
2855    // =========================================================================
2856
2857    #[test]
2858    fn test_fetch_directive_url_with_equals() {
2859        let content = r#"<what>
2860title: Remote Data
2861fetch.dog_facts = "https://dogapi.dog/api/v2/facts?limit=3"
2862</what>
2863<html></html>"#;
2864
2865        let (directives, _) = parse_page_directives(content);
2866
2867        assert_eq!(directives.title, Some("Remote Data".to_string()));
2868        assert_eq!(
2869            directives.custom.get("fetch.dog_facts"),
2870            Some(&"https://dogapi.dog/api/v2/facts?limit=3".to_string()),
2871            "fetch URL should be parsed correctly with = delimiter"
2872        );
2873    }
2874
2875    #[test]
2876    fn test_fetch_directive_url_with_multiple_equals() {
2877        // URLs with query params containing = signs
2878        let content = r#"<what>
2879fetch.dog_breeds = "https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6"
2880</what>
2881<html></html>"#;
2882
2883        let (directives, _) = parse_page_directives(content);
2884
2885        assert_eq!(
2886            directives.custom.get("fetch.dog_breeds"),
2887            Some(&"https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6".to_string()),
2888            "URL with multiple = in query params should be preserved"
2889        );
2890    }
2891
2892    #[test]
2893    fn test_fetch_directive_full_remote_data_page() {
2894        // Exact <what> block from remote-data.html
2895        let content = r#"<what>
2896title: Remote Data
2897page: remote-data
2898fetch.dog_facts = "https://dogapi.dog/api/v2/facts?limit=3"
2899fetch.dog_breeds = "https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6"
2900fetch.dog_images = "https://dog.ceo/api/breeds/image/random/4"
2901</what>
2902<html></html>"#;
2903
2904        let (directives, cleaned) = parse_page_directives(content);
2905
2906        assert_eq!(directives.title, Some("Remote Data".to_string()));
2907        assert_eq!(directives.vars.get("page"), Some(&json!("remote-data")));
2908        assert_eq!(
2909            directives.custom.get("fetch.dog_facts"),
2910            Some(&"https://dogapi.dog/api/v2/facts?limit=3".to_string())
2911        );
2912        assert_eq!(
2913            directives.custom.get("fetch.dog_breeds"),
2914            Some(&"https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6".to_string())
2915        );
2916        assert_eq!(
2917            directives.custom.get("fetch.dog_images"),
2918            Some(&"https://dog.ceo/api/breeds/image/random/4".to_string())
2919        );
2920        assert!(!cleaned.contains("<what>"));
2921    }
2922
2923    // =========================================================================
2924    // Section Header Tests
2925    // =========================================================================
2926
2927    #[test]
2928    fn test_section_header_fetch() {
2929        let content = r##"<what>
2930title: Dashboard
2931[fetch.weather]
2932url = "https://api.weather.com/current"
2933method = "GET"
2934headers = "Authorization: Bearer abc123"
2935path = "data.current"
2936limit = 5
2937</what>
2938<html></html>"##;
2939
2940        let (directives, _) = parse_page_directives(content);
2941        assert_eq!(directives.title, Some("Dashboard".to_string()));
2942        assert_eq!(
2943            directives.custom.get("fetch.weather.url"),
2944            Some(&"https://api.weather.com/current".to_string())
2945        );
2946        assert_eq!(
2947            directives.custom.get("fetch.weather.method"),
2948            Some(&"GET".to_string())
2949        );
2950        assert_eq!(
2951            directives.custom.get("fetch.weather.headers"),
2952            Some(&"Authorization: Bearer abc123".to_string())
2953        );
2954        assert_eq!(
2955            directives.custom.get("fetch.weather.path"),
2956            Some(&"data.current".to_string())
2957        );
2958        assert_eq!(
2959            directives.custom.get("fetch.weather.limit"),
2960            Some(&"5".to_string())
2961        );
2962    }
2963
2964    #[test]
2965    fn test_section_header_og() {
2966        let content = r##"<what>
2967[og]
2968title: My Dashboard
2969description: Real-time weather data
2970image: /images/dashboard-og.png
2971</what>
2972<html></html>"##;
2973
2974        let (directives, _) = parse_page_directives(content);
2975        assert_eq!(
2976            directives.vars.get("og.title"),
2977            Some(&json!("My Dashboard"))
2978        );
2979        assert_eq!(
2980            directives.vars.get("og.description"),
2981            Some(&json!("Real-time weather data"))
2982        );
2983        assert_eq!(
2984            directives.vars.get("og.image"),
2985            Some(&json!("/images/dashboard-og.png"))
2986        );
2987    }
2988
2989    #[test]
2990    fn test_section_header_session() {
2991        let content = r##"<what>
2992[session]
2993visit_count += 1
2994theme = "dark"
2995</what>
2996<html></html>"##;
2997
2998        let (directives, _) = parse_page_directives(content);
2999        assert_eq!(directives.session_mutations.len(), 2);
3000    }
3001
3002    #[test]
3003    fn test_section_header_compute() {
3004        let content = r##"<what>
3005[compute]
3006greeting = "Hello, #user.full_name#!"
3007</what>
3008<html></html>"##;
3009
3010        let (directives, _) = parse_page_directives(content);
3011        assert_eq!(directives.computed.len(), 1);
3012        assert_eq!(directives.computed[0].0, "greeting");
3013        assert_eq!(directives.computed[0].1, "Hello, #user.full_name#!");
3014    }
3015
3016    #[test]
3017    fn test_section_header_reset() {
3018        let content = r##"<what>
3019[fetch.weather]
3020url = "https://api.weather.com/current"
3021
3022[]
3023session.visit_count += 1
3024compute.greeting = "Hello!"
3025</what>
3026<html></html>"##;
3027
3028        let (directives, _) = parse_page_directives(content);
3029        assert_eq!(
3030            directives.custom.get("fetch.weather.url"),
3031            Some(&"https://api.weather.com/current".to_string())
3032        );
3033        assert_eq!(directives.session_mutations.len(), 1);
3034        assert_eq!(directives.computed.len(), 1);
3035    }
3036
3037    #[test]
3038    fn test_section_header_mixed() {
3039        // Flat syntax and section syntax in the same block
3040        let content = r##"<what>
3041title: Dashboard
3042auth: user
3043fetch.legacy = "https://old-api.com/data"
3044
3045[fetch.weather]
3046url = "https://api.weather.com/current"
3047method = "POST"
3048
3049[]
3050session.count += 1
3051</what>
3052<html></html>"##;
3053
3054        let (directives, _) = parse_page_directives(content);
3055        assert_eq!(directives.title, Some("Dashboard".to_string()));
3056        assert_eq!(
3057            directives.custom.get("fetch.legacy"),
3058            Some(&"https://old-api.com/data".to_string())
3059        );
3060        assert_eq!(
3061            directives.custom.get("fetch.weather.url"),
3062            Some(&"https://api.weather.com/current".to_string())
3063        );
3064        assert_eq!(
3065            directives.custom.get("fetch.weather.method"),
3066            Some(&"POST".to_string())
3067        );
3068        assert_eq!(directives.session_mutations.len(), 1);
3069    }
3070
3071    #[test]
3072    fn test_section_header_multiple_fetch() {
3073        let content = r##"<what>
3074[fetch.weather]
3075url = "https://api.weather.com/current"
3076path = "data.current"
3077
3078[fetch.news]
3079url = "https://api.news.com/latest"
3080path = "articles"
3081limit = 10
3082</what>
3083<html></html>"##;
3084
3085        let (directives, _) = parse_page_directives(content);
3086        assert_eq!(
3087            directives.custom.get("fetch.weather.url"),
3088            Some(&"https://api.weather.com/current".to_string())
3089        );
3090        assert_eq!(
3091            directives.custom.get("fetch.weather.path"),
3092            Some(&"data.current".to_string())
3093        );
3094        assert_eq!(
3095            directives.custom.get("fetch.news.url"),
3096            Some(&"https://api.news.com/latest".to_string())
3097        );
3098        assert_eq!(
3099            directives.custom.get("fetch.news.path"),
3100            Some(&"articles".to_string())
3101        );
3102        assert_eq!(
3103            directives.custom.get("fetch.news.limit"),
3104            Some(&"10".to_string())
3105        );
3106    }
3107
3108    #[test]
3109    fn test_section_header_backward_compat() {
3110        // Old flat syntax must still work identically
3111        let content = r##"<what>
3112title: Remote Data
3113fetch.dogs = "https://dogapi.dog/api/v2/facts?limit=3"
3114fetch.dogs.path = "data"
3115session.count += 1
3116compute.greeting = "Hello!"
3117og.title: My Page
3118</what>
3119<html></html>"##;
3120
3121        let (directives, _) = parse_page_directives(content);
3122        assert_eq!(directives.title, Some("Remote Data".to_string()));
3123        assert_eq!(
3124            directives.custom.get("fetch.dogs"),
3125            Some(&"https://dogapi.dog/api/v2/facts?limit=3".to_string())
3126        );
3127        assert_eq!(
3128            directives.custom.get("fetch.dogs.path"),
3129            Some(&"data".to_string())
3130        );
3131        assert_eq!(directives.session_mutations.len(), 1);
3132        assert_eq!(directives.computed.len(), 1);
3133        assert_eq!(directives.vars.get("og.title"), Some(&json!("My Page")));
3134    }
3135
3136    // =========================================================================
3137    // Inline Variable Tests
3138    // =========================================================================
3139
3140    #[test]
3141    fn inline_var_string() {
3142        let content = r#"<what>
3143title = "My Page"
3144subtitle = "Welcome"
3145</what>
3146<html></html>"#;
3147        let (directives, _) = parse_page_directives(content);
3148        // title is a reserved directive
3149        assert_eq!(directives.title, Some("My Page".to_string()));
3150        // subtitle is an inline variable
3151        assert_eq!(directives.vars.get("subtitle"), Some(&json!("Welcome")));
3152    }
3153
3154    #[test]
3155    fn inline_var_number() {
3156        let content = r#"<what>
3157count = 42
3158price = 9.99
3159</what>
3160<html></html>"#;
3161        let (directives, _) = parse_page_directives(content);
3162        assert_eq!(directives.vars.get("count"), Some(&json!(42)));
3163        assert_eq!(directives.vars.get("price"), Some(&json!(9.99)));
3164    }
3165
3166    #[test]
3167    fn inline_var_boolean() {
3168        let content = r#"<what>
3169show_banner = true
3170debug = false
3171</what>
3172<html></html>"#;
3173        let (directives, _) = parse_page_directives(content);
3174        assert_eq!(directives.vars.get("show_banner"), Some(&json!(true)));
3175        assert_eq!(directives.vars.get("debug"), Some(&json!(false)));
3176    }
3177
3178    #[test]
3179    fn inline_var_single_line_array() {
3180        let content = r#"<what>
3181colors = ["red", "green", "blue"]
3182</what>
3183<html></html>"#;
3184        let (directives, _) = parse_page_directives(content);
3185        assert_eq!(
3186            directives.vars.get("colors"),
3187            Some(&json!(["red", "green", "blue"]))
3188        );
3189    }
3190
3191    #[test]
3192    fn inline_var_multi_line_array() {
3193        let content = r##"<what>
3194products = [
3195  { "name": "Widget", "price": 9.99 },
3196  { "name": "Gadget", "price": 24.99 }
3197]
3198</what>
3199<html></html>"##;
3200        let (directives, _) = parse_page_directives(content);
3201        let products = directives.vars.get("products").unwrap();
3202        assert!(products.is_array());
3203        assert_eq!(products.as_array().unwrap().len(), 2);
3204        assert_eq!(products[0]["name"], json!("Widget"));
3205        assert_eq!(products[1]["price"], json!(24.99));
3206    }
3207
3208    #[test]
3209    fn inline_var_multi_line_object() {
3210        let content = r##"<what>
3211config = {
3212  "theme": "dark",
3213  "sidebar": true
3214}
3215</what>
3216<html></html>"##;
3217        let (directives, _) = parse_page_directives(content);
3218        let config = directives.vars.get("config").unwrap();
3219        assert!(config.is_object());
3220        assert_eq!(config["theme"], json!("dark"));
3221        assert_eq!(config["sidebar"], json!(true));
3222    }
3223
3224    #[test]
3225    fn inline_var_relaxed_json_unquoted_keys() {
3226        let content = r##"<what>
3227products = [
3228  { name: "Widget", price: 9.99 },
3229  { name: "Gadget", price: 24.99 }
3230]
3231</what>
3232<html></html>"##;
3233        let (directives, _) = parse_page_directives(content);
3234        let products = directives.vars.get("products").unwrap();
3235        assert!(products.is_array());
3236        assert_eq!(products[0]["name"], json!("Widget"));
3237        assert_eq!(products[1]["price"], json!(24.99));
3238    }
3239
3240    #[test]
3241    fn inline_var_relaxed_json_single_line() {
3242        let content = r##"<what>
3243item = { name: "Widget", price: 9.99 }
3244</what>
3245<html></html>"##;
3246        let (directives, _) = parse_page_directives(content);
3247        let item = directives.vars.get("item").unwrap();
3248        assert_eq!(item["name"], json!("Widget"));
3249        assert_eq!(item["price"], json!(9.99));
3250    }
3251
3252    #[test]
3253    fn inline_var_does_not_affect_reserved() {
3254        let content = r#"<what>
3255auth = user
3256layout = main.html
3257fetch.api = "https://example.com"
3258my_var = "hello"
3259</what>
3260<html></html>"#;
3261        let (directives, _) = parse_page_directives(content);
3262        // Reserved directives work normally
3263        assert!(directives.requires_auth());
3264        assert_eq!(directives.layout, Some("main.html".to_string()));
3265        assert_eq!(
3266            directives.custom.get("fetch.api"),
3267            Some(&"https://example.com".to_string())
3268        );
3269        // Non-reserved goes to vars
3270        assert_eq!(directives.vars.get("my_var"), Some(&json!("hello")));
3271        // Reserved keys NOT in vars
3272        assert!(directives.vars.get("auth").is_none());
3273        assert!(directives.vars.get("layout").is_none());
3274    }
3275
3276    #[test]
3277    fn inline_var_mixed_with_directives() {
3278        let content = r##"<what>
3279title = "Dashboard"
3280items_per_page = 25
3281nav_items = ["Home", "About", "Contact"]
3282fetch.data = "https://api.example.com/data"
3283compute.greeting = "Hello #user.name#!"
3284</what>
3285<html></html>"##;
3286        let (directives, _) = parse_page_directives(content);
3287        assert_eq!(directives.title, Some("Dashboard".to_string()));
3288        assert_eq!(directives.vars.get("items_per_page"), Some(&json!(25)));
3289        assert_eq!(
3290            directives.vars.get("nav_items"),
3291            Some(&json!(["Home", "About", "Contact"]))
3292        );
3293        assert_eq!(
3294            directives.custom.get("fetch.data"),
3295            Some(&"https://api.example.com/data".to_string())
3296        );
3297        assert_eq!(directives.computed.len(), 1);
3298    }
3299
3300    // =========================================================================
3301    // Named Mutation Block Tests
3302    // =========================================================================
3303
3304    #[test]
3305    fn mutation_stored_as_custom_string() {
3306        let content = r#"<what>
3307mutation.reset = "session.score = 0; session.lives = 3"
3308</what>
3309<html></html>"#;
3310        let (directives, _) = parse_page_directives(content);
3311        assert_eq!(
3312            directives.custom.get("mutation.reset"),
3313            Some(&"session.score = 0; session.lives = 3".to_string())
3314        );
3315        // Should NOT be in vars (not JSON-parsed)
3316        assert!(directives.vars.get("mutation.reset").is_none());
3317    }
3318
3319    #[test]
3320    fn mutation_not_parsed_as_inline_var() {
3321        let content = r#"<what>
3322mutation.toggle = "session.dark_mode = 1"
3323my_var = 42
3324</what>
3325<html></html>"#;
3326        let (directives, _) = parse_page_directives(content);
3327        // mutation goes to custom, my_var goes to vars
3328        assert!(directives.custom.contains_key("mutation.toggle"));
3329        assert_eq!(directives.vars.get("my_var"), Some(&json!(42)));
3330    }
3331
3332    // =========================================================================
3333    // .what File Parser Tests
3334    // =========================================================================
3335
3336    #[test]
3337    fn test_what_file_strings() {
3338        let content = r#"
3339title = "My Application"
3340description = 'Single quotes work too'
3341bare_string = unquoted
3342"#;
3343        let config = parse_what_file(content);
3344
3345        assert_eq!(config.get_string("title"), Some("My Application"));
3346        assert_eq!(
3347            config.get_string("description"),
3348            Some("Single quotes work too")
3349        );
3350        assert_eq!(config.get_string("bare_string"), Some("unquoted"));
3351    }
3352
3353    #[test]
3354    fn test_what_file_numbers() {
3355        let content = r#"
3356port = 8080
3357version = 1.5
3358negative = -42
3359"#;
3360        let config = parse_what_file(content);
3361
3362        assert_eq!(config.get_number("port"), Some(8080.0));
3363        assert_eq!(config.get_number("version"), Some(1.5));
3364        assert_eq!(config.get_number("negative"), Some(-42.0));
3365    }
3366
3367    #[test]
3368    fn test_what_file_booleans() {
3369        let content = r#"
3370debug = true
3371production = false
3372"#;
3373        let config = parse_what_file(content);
3374
3375        assert_eq!(config.get_bool("debug"), Some(true));
3376        assert_eq!(config.get_bool("production"), Some(false));
3377    }
3378
3379    #[test]
3380    fn test_what_file_arrays() {
3381        let content = r#"
3382nav_items = ["Home", "About", "Contact"]
3383numbers = [1, 2, 3]
3384mixed = ["a", 1, true]
3385empty = []
3386"#;
3387        let config = parse_what_file(content);
3388
3389        let nav = config.get_array("nav_items").unwrap();
3390        assert_eq!(nav.len(), 3);
3391        assert_eq!(nav[0].as_str(), Some("Home"));
3392
3393        let nums = config.get_array("numbers").unwrap();
3394        assert_eq!(nums.len(), 3);
3395        assert_eq!(nums[0].as_i64(), Some(1));
3396
3397        let empty = config.get_array("empty").unwrap();
3398        assert!(empty.is_empty());
3399    }
3400
3401    #[test]
3402    fn test_what_file_comments() {
3403        let content = r#"
3404// This is a comment
3405title = "Hello"
3406# This is also a comment
3407name = "World"
3408"#;
3409        let config = parse_what_file(content);
3410
3411        assert_eq!(config.get_string("title"), Some("Hello"));
3412        assert_eq!(config.get_string("name"), Some("World"));
3413        // Comments should not appear as values
3414        assert!(config.values.len() == 2);
3415    }
3416
3417    #[test]
3418    fn test_what_file_auth_directive() {
3419        let content = r#"
3420auth = "admin"
3421title = "Dashboard"
3422"#;
3423        let config = parse_what_file(content);
3424
3425        assert_eq!(
3426            config.directives.auth,
3427            AuthLevel::Roles(vec!["admin".to_string()])
3428        );
3429        assert_eq!(config.directives.title, Some("Dashboard".to_string()));
3430    }
3431
3432    #[test]
3433    fn test_what_file_auth_all() {
3434        let content = r#"
3435auth = "all"
3436"#;
3437        let config = parse_what_file(content);
3438
3439        assert_eq!(config.directives.auth, AuthLevel::All);
3440        assert!(!config.directives.requires_auth());
3441    }
3442
3443    #[test]
3444    fn test_what_file_roles_array() {
3445        let content = r#"
3446roles = ["admin", "editor"]
3447"#;
3448        let config = parse_what_file(content);
3449
3450        assert_eq!(config.directives.roles, vec!["admin", "editor"]);
3451        assert!(config.directives.protected);
3452    }
3453
3454    #[test]
3455    fn test_what_config_merge() {
3456        let content1 = r#"
3457title = "Parent"
3458theme = "light"
3459auth = "all"
3460"#;
3461        let content2 = r#"
3462title = "Child"
3463nav = ["Home"]
3464auth = "admin"
3465"#;
3466        let mut config1 = parse_what_file(content1);
3467        let config2 = parse_what_file(content2);
3468
3469        config1.merge(&config2);
3470
3471        // Child overrides parent
3472        assert_eq!(config1.get_string("title"), Some("Child"));
3473        // Parent value preserved
3474        assert_eq!(config1.get_string("theme"), Some("light"));
3475        // Child adds new value
3476        assert!(config1.get_array("nav").is_some());
3477        // Auth from child takes precedence
3478        assert_eq!(
3479            config1.directives.auth,
3480            AuthLevel::Roles(vec!["admin".to_string()])
3481        );
3482    }
3483
3484    // =========================================================================
3485    // Edge Case Tests
3486    // =========================================================================
3487
3488    #[test]
3489    fn test_auth_level_edge_cases() {
3490        // Empty string should be All
3491        assert_eq!(parse_auth_level(""), AuthLevel::All);
3492
3493        // Whitespace-only should be All
3494        assert_eq!(parse_auth_level("   "), AuthLevel::All);
3495
3496        // Case insensitivity
3497        assert_eq!(parse_auth_level("ALL"), AuthLevel::All);
3498        assert_eq!(parse_auth_level("User"), AuthLevel::User);
3499        assert_eq!(
3500            parse_auth_level("ADMIN"),
3501            AuthLevel::Roles(vec!["admin".to_string()])
3502        );
3503
3504        // Whitespace in roles
3505        assert_eq!(
3506            parse_auth_level("  admin  ,  editor  "),
3507            AuthLevel::Roles(vec!["admin".to_string(), "editor".to_string()])
3508        );
3509
3510        // Single role
3511        assert_eq!(
3512            parse_auth_level("superuser"),
3513            AuthLevel::Roles(vec!["superuser".to_string()])
3514        );
3515    }
3516
3517    #[test]
3518    fn test_page_directives_cache_ttl() {
3519        // Self-closing with cache attribute
3520        let content = r#"<what cache="3600" />
3521<html></html>"#;
3522        let (directives, _) = parse_page_directives(content);
3523        assert_eq!(directives.cache_ttl, Some(3600));
3524
3525        // Content syntax with cache-ttl
3526        let content = r#"<what>
3527cache-ttl: 1800
3528</what>
3529<html></html>"#;
3530        let (directives, _) = parse_page_directives(content);
3531        assert_eq!(directives.cache_ttl, Some(1800));
3532    }
3533
3534    #[test]
3535    fn test_page_directives_custom() {
3536        let content = r#"<what custom_field="my_value" another="test" />
3537<html></html>"#;
3538        let (directives, _) = parse_page_directives(content);
3539
3540        assert_eq!(
3541            directives.custom.get("custom_field"),
3542            Some(&"my_value".to_string())
3543        );
3544        assert_eq!(directives.custom.get("another"), Some(&"test".to_string()));
3545    }
3546
3547    #[test]
3548    fn test_page_directives_redirect() {
3549        let content = r#"<what redirect="/new-page" />
3550<html></html>"#;
3551        let (directives, _) = parse_page_directives(content);
3552        assert_eq!(directives.redirect, Some("/new-page".to_string()));
3553    }
3554
3555    #[test]
3556    fn test_what_file_empty() {
3557        let content = "";
3558        let config = parse_what_file(content);
3559        assert!(config.values.is_empty());
3560        assert_eq!(config.directives.auth, AuthLevel::All);
3561    }
3562
3563    #[test]
3564    fn test_what_file_only_comments() {
3565        let content = r#"
3566// This is a comment
3567# Another comment
3568// More comments
3569"#;
3570        let config = parse_what_file(content);
3571        assert!(config.values.is_empty());
3572    }
3573
3574    #[test]
3575    fn test_what_file_data_application() {
3576        let content = r#"
3577data.application = ["posts", "products"]
3578"#;
3579        let config = parse_what_file(content);
3580        let names: Vec<&str> = config.data_application.iter().map(|d| d.name.as_str()).collect();
3581        assert_eq!(names, vec!["posts", "products"]);
3582        // Should not be exposed as template variable
3583        assert!(!config.values.contains_key("data.application"));
3584    }
3585
3586    #[test]
3587    fn test_what_file_data_application_scoped() {
3588        let content = r#"
3589data.application = ["visits", "revenue [admin, editor]"]
3590"#;
3591        let config = parse_what_file(content);
3592        assert_eq!(config.data_application[0].name, "visits");
3593        assert!(matches!(config.data_application[0].scope, WiredScope::Public));
3594        assert_eq!(config.data_application[1].name, "revenue");
3595        match &config.data_application[1].scope {
3596            WiredScope::Roles(r) => assert_eq!(r, &vec!["admin".to_string(), "editor".to_string()]),
3597            other => panic!("expected Roles, got {other:?}"),
3598        }
3599    }
3600
3601    #[test]
3602    fn test_what_file_data_session() {
3603        let content = r#"
3604data.session = ["cart", "wishlist"]
3605"#;
3606        let config = parse_what_file(content);
3607        assert_eq!(config.data_session, vec!["cart", "wishlist"]);
3608        // Should not be exposed as template variable
3609        assert!(!config.values.contains_key("data.session"));
3610    }
3611
3612    #[test]
3613    fn test_what_file_data_single_value() {
3614        // Single string instead of array should still work
3615        let content = r#"
3616data.application = "posts"
3617data.session = "cart"
3618"#;
3619        let config = parse_what_file(content);
3620        assert_eq!(config.data_application.len(), 1);
3621        assert_eq!(config.data_application[0].name, "posts");
3622        assert_eq!(config.data_session, vec!["cart"]);
3623    }
3624
3625    #[test]
3626    fn test_what_value_edge_cases() {
3627        // Empty array
3628        assert_eq!(parse_what_value("[]"), serde_json::json!([]));
3629
3630        // Negative float
3631        assert_eq!(parse_what_value("-3.14"), serde_json::json!(-3.14));
3632
3633        // Zero
3634        assert_eq!(parse_what_value("0"), serde_json::json!(0));
3635
3636        // Very large number
3637        assert_eq!(
3638            parse_what_value("9999999999"),
3639            serde_json::json!(9999999999_i64)
3640        );
3641    }
3642
3643    #[test]
3644    fn test_page_directives_mixed_syntax() {
3645        // Mix of boolean and key-value
3646        let content = r#"<what protected title="Dashboard" exclude />
3647<html></html>"#;
3648        let (directives, _) = parse_page_directives(content);
3649
3650        assert!(directives.protected);
3651        assert!(directives.exclude);
3652        assert_eq!(directives.title, Some("Dashboard".to_string()));
3653    }
3654
3655    #[test]
3656    fn test_is_standard_html_tag() {
3657        // Standard tags
3658        assert!(is_standard_html_tag("div"));
3659        assert!(is_standard_html_tag("span"));
3660        assert!(is_standard_html_tag("html"));
3661        assert!(is_standard_html_tag("body"));
3662        assert!(is_standard_html_tag("form"));
3663        assert!(is_standard_html_tag("input"));
3664        assert!(is_standard_html_tag("template"));
3665        assert!(is_standard_html_tag("slot"));
3666
3667        // Custom tags
3668        assert!(!is_standard_html_tag("jumbo"));
3669        assert!(!is_standard_html_tag("card"));
3670        assert!(!is_standard_html_tag("my-component"));
3671        assert!(!is_standard_html_tag("loop"));
3672    }
3673
3674    #[test]
3675    fn test_page_directives_requires_auth() {
3676        // auth: all should not require auth
3677        let mut d = PageDirectives::default();
3678        d.auth = AuthLevel::All;
3679        assert!(!d.requires_auth());
3680
3681        // auth: user should require auth
3682        d.auth = AuthLevel::User;
3683        assert!(d.requires_auth());
3684
3685        // auth: roles should require auth
3686        d.auth = AuthLevel::Roles(vec!["admin".to_string()]);
3687        assert!(d.requires_auth());
3688
3689        // Legacy: protected = true should require auth even with auth: all
3690        d.auth = AuthLevel::All;
3691        d.protected = true;
3692        assert!(d.requires_auth());
3693    }
3694
3695    #[test]
3696    fn test_page_directives_check_access_legacy() {
3697        // Test legacy protected + roles behavior
3698        let mut d = PageDirectives::default();
3699        d.protected = true;
3700        d.roles = vec!["admin".to_string(), "editor".to_string()];
3701
3702        // Not authenticated - denied
3703        assert!(!d.check_access(false, &[]));
3704
3705        // Authenticated without roles - denied
3706        assert!(!d.check_access(true, &[]));
3707
3708        // Authenticated with wrong role - denied
3709        assert!(!d.check_access(true, &["viewer".to_string()]));
3710
3711        // Authenticated with correct role - allowed
3712        assert!(d.check_access(true, &["admin".to_string()]));
3713        assert!(d.check_access(true, &["editor".to_string()]));
3714    }
3715
3716    // =========================================================================
3717    // Layout System Tests
3718    // =========================================================================
3719
3720    #[test]
3721    fn test_layout_in_what_file() {
3722        let content = r#"
3723layout = "sections/main.html"
3724title = "Test Page"
3725"#;
3726        let config = parse_what_file(content);
3727
3728        assert_eq!(config.layout, Some("sections/main.html".to_string()));
3729        assert_eq!(
3730            config.directives.layout,
3731            Some("sections/main.html".to_string())
3732        );
3733        // Layout should not be exposed as a template variable
3734        assert!(config.get_string("layout").is_none());
3735    }
3736
3737    #[test]
3738    fn test_layout_in_page_directive_attribute() {
3739        let content = r#"<what layout="sections/page.html" />
3740<h1>Hello</h1>"#;
3741        let (directives, cleaned) = parse_page_directives(content);
3742
3743        assert_eq!(directives.layout, Some("sections/page.html".to_string()));
3744        assert!(cleaned.contains("<h1>Hello</h1>"));
3745        assert!(!cleaned.contains("<what"));
3746    }
3747
3748    #[test]
3749    fn test_layout_in_page_directive_content() {
3750        let content = r#"<what>
3751layout: sections/admin.html
3752title: Dashboard
3753</what>
3754<h1>Admin Dashboard</h1>"#;
3755        let (directives, cleaned) = parse_page_directives(content);
3756
3757        assert_eq!(directives.layout, Some("sections/admin.html".to_string()));
3758        assert_eq!(directives.title, Some("Dashboard".to_string()));
3759        assert!(cleaned.contains("<h1>Admin Dashboard</h1>"));
3760    }
3761
3762    #[test]
3763    fn test_layout_none_disables() {
3764        // Page can use layout: none to disable inherited layout
3765        let content = r#"<what layout="none" />
3766<h1>No Layout</h1>"#;
3767        let (directives, _) = parse_page_directives(content);
3768
3769        assert_eq!(directives.layout, Some("none".to_string()));
3770    }
3771
3772    #[test]
3773    fn test_what_config_layout_merge() {
3774        let parent_content = r#"
3775layout = "sections/base.html"
3776title = "Parent"
3777"#;
3778        let child_content = r#"
3779layout = "sections/admin.html"
3780"#;
3781        let mut parent = parse_what_file(parent_content);
3782        let child = parse_what_file(child_content);
3783
3784        parent.merge(&child);
3785
3786        // Child layout overrides parent
3787        assert_eq!(parent.layout, Some("sections/admin.html".to_string()));
3788    }
3789
3790    #[test]
3791    fn test_what_config_layout_inherit() {
3792        let parent_content = r#"
3793layout = "sections/base.html"
3794title = "Parent"
3795"#;
3796        let child_content = r#"
3797title = "Child"
3798"#;
3799        let mut parent = parse_what_file(parent_content);
3800        let child = parse_what_file(child_content);
3801
3802        parent.merge(&child);
3803
3804        // Parent layout is preserved when child doesn't set one
3805        assert_eq!(parent.layout, Some("sections/base.html".to_string()));
3806        assert_eq!(parent.get_string("title"), Some("Child"));
3807    }
3808
3809    #[test]
3810    fn test_what_config_layout_none_override() {
3811        let parent_content = r#"
3812layout = "sections/base.html"
3813"#;
3814        let child_content = r#"
3815layout = "none"
3816"#;
3817        let mut parent = parse_what_file(parent_content);
3818        let child = parse_what_file(child_content);
3819
3820        parent.merge(&child);
3821
3822        // Child's "none" overrides parent layout
3823        assert_eq!(parent.layout, Some("none".to_string()));
3824    }
3825
3826    // =========================================================================
3827    // Reactive Variable Replacement Tests
3828    // =========================================================================
3829
3830    #[test]
3831    fn test_reactive_session_var_in_text() {
3832        let mut context = HashMap::new();
3833        context.insert(
3834            "session".to_string(),
3835            serde_json::json!({
3836                "count": 8
3837            }),
3838        );
3839
3840        let template = "<p>Counter: #session.count#</p>";
3841        let result = replace_variables_reactive(template, &context);
3842
3843        assert!(
3844            result
3845                .html
3846                .contains(r#"<span w-bind="session.count">8</span>"#)
3847        );
3848        assert!(result.session_keys.contains("count"));
3849    }
3850
3851    #[test]
3852    fn test_reactive_session_var_in_attribute() {
3853        let mut context = HashMap::new();
3854        context.insert(
3855            "session".to_string(),
3856            serde_json::json!({
3857                "count": 8
3858            }),
3859        );
3860
3861        // Session variable in attribute should NOT be wrapped
3862        let template = r##"<div title="#session.count#">Content</div>"##;
3863        let result = replace_variables_reactive(template, &context);
3864
3865        // Should replace value but not wrap
3866        assert!(result.html.contains(r##"title="8""##));
3867        // Should NOT contain span with w-bind
3868        assert!(!result.html.contains("w-bind"));
3869        // But should still track the key
3870        assert!(result.session_keys.contains("count"));
3871    }
3872
3873    #[test]
3874    fn test_reactive_non_session_var() {
3875        let mut context = HashMap::new();
3876        context.insert("name".to_string(), serde_json::json!("Alice"));
3877
3878        let template = "<p>Hello #name#!</p>";
3879        let result = replace_variables_reactive(template, &context);
3880
3881        // Non-session variables should be replaced without wrapping
3882        assert!(result.html.contains("Hello Alice!"));
3883        assert!(!result.html.contains("w-bind"));
3884        assert!(result.session_keys.is_empty());
3885    }
3886
3887    #[test]
3888    fn test_reactive_multiple_session_vars() {
3889        let mut context = HashMap::new();
3890        context.insert(
3891            "session".to_string(),
3892            serde_json::json!({
3893                "count": 5,
3894                "name": "Test"
3895            }),
3896        );
3897
3898        let template = "<p>Count: #session.count#, Name: #session.name#</p>";
3899        let result = replace_variables_reactive(template, &context);
3900
3901        assert!(
3902            result
3903                .html
3904                .contains(r#"<span w-bind="session.count">5</span>"#)
3905        );
3906        assert!(
3907            result
3908                .html
3909                .contains(r#"<span w-bind="session.name">Test</span>"#)
3910        );
3911        assert!(result.session_keys.contains("count"));
3912        assert!(result.session_keys.contains("name"));
3913    }
3914
3915    #[test]
3916    fn test_is_in_attribute_context() {
3917        // Text content cases (should return false)
3918        assert!(!is_in_attribute_context("<p>"));
3919        assert!(!is_in_attribute_context("<p>Hello "));
3920        assert!(!is_in_attribute_context("<div><span>"));
3921
3922        // Attribute cases (should return true)
3923        assert!(is_in_attribute_context(r#"<div title=""#));
3924        assert!(is_in_attribute_context(r#"<div class="foo "#));
3925        assert!(is_in_attribute_context(r#"<input value=""#));
3926
3927        // After closing attribute (should return false)
3928        assert!(!is_in_attribute_context(r#"<div title="test">"#));
3929        assert!(!is_in_attribute_context(r#"<div class="foo">Hello"#));
3930    }
3931
3932    #[test]
3933    fn test_html_escape() {
3934        assert_eq!(html_escape("<"), "&lt;");
3935        assert_eq!(html_escape(">"), "&gt;");
3936        assert_eq!(html_escape("&"), "&amp;");
3937        assert_eq!(html_escape("\""), "&quot;");
3938        assert_eq!(html_escape("'"), "&#39;");
3939        assert_eq!(
3940            html_escape("<script>alert('xss')</script>"),
3941            "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
3942        );
3943    }
3944
3945    #[test]
3946    fn test_parse_session_mutation_push() {
3947        let m = parse_session_mutation("session.items.push(\"hello\")").unwrap();
3948        match m {
3949            SessionMutation::Push { key, value } => {
3950                assert_eq!(key, "items");
3951                assert_eq!(value, serde_json::json!("hello"));
3952            }
3953            _ => panic!("Expected Push"),
3954        }
3955    }
3956
3957    #[test]
3958    fn test_parse_session_mutation_pushmax() {
3959        let m = parse_session_mutation("session.history.pushmax(5, \"page1\")").unwrap();
3960        match m {
3961            SessionMutation::PushMax { key, max, value } => {
3962                assert_eq!(key, "history");
3963                assert_eq!(max, 5);
3964                assert_eq!(value, serde_json::json!("page1"));
3965            }
3966            _ => panic!("Expected PushMax"),
3967        }
3968    }
3969
3970    #[test]
3971    fn test_parse_session_mutation_pushmax_numeric() {
3972        let m = parse_session_mutation("session.ids.pushmax(10, 42)").unwrap();
3973        match m {
3974            SessionMutation::PushMax { key, max, value } => {
3975                assert_eq!(key, "ids");
3976                assert_eq!(max, 10);
3977                assert_eq!(value, serde_json::json!(42));
3978            }
3979            _ => panic!("Expected PushMax"),
3980        }
3981    }
3982
3983    // =========================================================================
3984    // Auto-Escaping Tests
3985    // =========================================================================
3986
3987    #[test]
3988    fn test_auto_escape_html_in_variables() {
3989        let mut context = HashMap::new();
3990        context.insert(
3991            "name".to_string(),
3992            serde_json::json!("<script>alert('xss')</script>"),
3993        );
3994
3995        let result = replace_variables("Hello #name#!", &context);
3996        assert_eq!(
3997            result,
3998            "Hello &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;!"
3999        );
4000        assert!(!result.contains("<script>"));
4001    }
4002
4003    #[test]
4004    fn test_auto_escape_ampersand() {
4005        let mut context = HashMap::new();
4006        context.insert("text".to_string(), serde_json::json!("Tom & Jerry"));
4007
4008        let result = replace_variables("#text#", &context);
4009        assert_eq!(result, "Tom &amp; Jerry");
4010    }
4011
4012    #[test]
4013    fn test_auto_escape_quotes() {
4014        let mut context = HashMap::new();
4015        context.insert("text".to_string(), serde_json::json!(r#"He said "hello""#));
4016
4017        let result = replace_variables("#text#", &context);
4018        assert_eq!(result, "He said &quot;hello&quot;");
4019    }
4020
4021    #[test]
4022    fn test_raw_filter_bypasses_escaping() {
4023        let mut context = HashMap::new();
4024        context.insert("html".to_string(), serde_json::json!("<b>bold</b>"));
4025
4026        let result = replace_variables("#html|raw#", &context);
4027        assert_eq!(result, "<b>bold</b>");
4028    }
4029
4030    #[test]
4031    fn test_raw_filter_with_default_value() {
4032        let mut context = HashMap::new();
4033        context.insert("html".to_string(), serde_json::json!("<em>yes</em>"));
4034
4035        // |raw at end of filter chain
4036        let result = replace_variables("#html|raw#", &context);
4037        assert_eq!(result, "<em>yes</em>");
4038    }
4039
4040    #[test]
4041    fn test_auto_escape_preserves_safe_text() {
4042        let mut context = HashMap::new();
4043        context.insert("name".to_string(), serde_json::json!("Alice"));
4044
4045        let result = replace_variables("Hello #name#!", &context);
4046        assert_eq!(result, "Hello Alice!");
4047    }
4048
4049    #[test]
4050    fn test_auto_escape_nested_variables() {
4051        let mut context = HashMap::new();
4052        context.insert(
4053            "user".to_string(),
4054            serde_json::json!({
4055                "name": "<b>Admin</b>",
4056                "bio": "Loves coding & testing"
4057            }),
4058        );
4059
4060        let result = replace_variables("#user.name# - #user.bio#", &context);
4061        assert_eq!(
4062            result,
4063            "&lt;b&gt;Admin&lt;/b&gt; - Loves coding &amp; testing"
4064        );
4065    }
4066
4067    #[test]
4068    fn test_auto_escape_with_default_filter() {
4069        let context = HashMap::new();
4070
4071        // New syntax: |default:"value"
4072        let result = replace_variables(r##"#missing|default:"<fallback>"#"##, &context);
4073        assert_eq!(result, "&lt;fallback&gt;");
4074    }
4075
4076    #[test]
4077    fn test_reactive_auto_escape_non_session_var() {
4078        let mut context = HashMap::new();
4079        context.insert(
4080            "name".to_string(),
4081            serde_json::json!("<script>xss</script>"),
4082        );
4083
4084        let result = replace_variables_reactive("<p>#name#</p>", &context);
4085        assert!(result.html.contains("&lt;script&gt;xss&lt;/script&gt;"));
4086        assert!(!result.html.contains("<script>xss</script>"));
4087    }
4088
4089    #[test]
4090    fn test_reactive_auto_escape_session_var_in_attribute() {
4091        let mut context = HashMap::new();
4092        context.insert(
4093            "session".to_string(),
4094            serde_json::json!({
4095                "name": "Tom & Jerry"
4096            }),
4097        );
4098
4099        let template = r##"<div title="#session.name#">Content</div>"##;
4100        let result = replace_variables_reactive(template, &context);
4101
4102        // Attribute should be escaped
4103        assert!(result.html.contains("Tom &amp; Jerry"));
4104        assert!(!result.html.contains("w-bind"));
4105    }
4106
4107    #[test]
4108    fn test_reactive_raw_filter_non_session_var() {
4109        let mut context = HashMap::new();
4110        context.insert("html".to_string(), serde_json::json!("<b>bold</b>"));
4111
4112        let result = replace_variables_reactive("<p>#html|raw#</p>", &context);
4113        assert!(result.html.contains("<b>bold</b>"));
4114    }
4115
4116    #[test]
4117    fn test_reactive_session_var_always_escaped_in_span() {
4118        let mut context = HashMap::new();
4119        context.insert(
4120            "session".to_string(),
4121            serde_json::json!({
4122                "name": "<script>xss</script>"
4123            }),
4124        );
4125
4126        let result = replace_variables_reactive("<p>#session.name#</p>", &context);
4127        // Session vars in text are always escaped inside the reactive span
4128        assert!(result.html.contains("&lt;script&gt;xss&lt;/script&gt;"));
4129        assert!(result.html.contains("w-bind"));
4130    }
4131
4132    #[test]
4133    fn test_reactive_session_raw_in_attribute() {
4134        let mut context = HashMap::new();
4135        context.insert(
4136            "session".to_string(),
4137            serde_json::json!({
4138                "url": "/path?a=1&b=2"
4139            }),
4140        );
4141
4142        // |raw in attribute context should skip escaping
4143        let template = r##"<a href="#session.url|raw#">Link</a>"##;
4144        let result = replace_variables_reactive(template, &context);
4145        assert!(result.html.contains(r#"href="/path?a=1&b=2""#));
4146    }
4147
4148    #[test]
4149    fn test_auto_escape_number_values() {
4150        let mut context = HashMap::new();
4151        context.insert("count".to_string(), serde_json::json!(42));
4152
4153        let result = replace_variables("Count: #count#", &context);
4154        assert_eq!(result, "Count: 42");
4155    }
4156
4157    #[test]
4158    fn test_auto_escape_boolean_values() {
4159        let mut context = HashMap::new();
4160        context.insert("flag".to_string(), serde_json::json!(true));
4161
4162        let result = replace_variables("Flag: #flag#", &context);
4163        assert_eq!(result, "Flag: true");
4164    }
4165
4166    #[test]
4167    fn test_auto_escape_unresolved_variable() {
4168        let context = HashMap::new();
4169        // Unresolved variables stay as #var# — this should NOT be escaped
4170        // because the hash signs are part of the template syntax, not user data
4171        let result = replace_variables("#unknown#", &context);
4172        // The unresolved var format "#unknown#" gets escaped as-is
4173        assert_eq!(result, "#unknown#");
4174    }
4175
4176    #[test]
4177    fn test_nested_var_on_empty_parent_resolves_empty() {
4178        let mut context = HashMap::new();
4179        // Parent object exists but child key is missing → empty, not literal
4180        context.insert("old".into(), serde_json::json!({}));
4181        let result = replace_variables("#old.title#", &context);
4182        assert_eq!(
4183            result, "",
4184            "Missing child on existing parent should be empty"
4185        );
4186    }
4187
4188    #[test]
4189    fn test_nested_var_on_missing_root_stays_literal() {
4190        let context = HashMap::new();
4191        // Root key not in context at all → preserve literal for strict mode
4192        let result = replace_variables("#old.title#", &context);
4193        assert_eq!(result, "#old.title#", "Missing root should keep literal");
4194    }
4195
4196    #[test]
4197    fn test_nested_var_on_populated_parent_resolves() {
4198        let mut context = HashMap::new();
4199        context.insert("user".into(), serde_json::json!({"name": "Alice"}));
4200        // Existing child → resolves normally
4201        assert_eq!(replace_variables("#user.name#", &context), "Alice");
4202        // Missing child on populated parent → empty
4203        assert_eq!(replace_variables("#user.role#", &context), "");
4204    }
4205
4206    // =========================================================================
4207    // Filter System Tests
4208    // =========================================================================
4209
4210    #[test]
4211    fn test_filter_parse_chain() {
4212        let (path, filters) = parse_filter_chain("name|uppercase");
4213        assert_eq!(path, "name");
4214        assert_eq!(filters.len(), 1);
4215        assert_eq!(filters[0].name, "uppercase");
4216        assert!(filters[0].args.is_empty());
4217    }
4218
4219    #[test]
4220    fn test_filter_parse_with_arg() {
4221        let (path, filters) = parse_filter_chain("title|truncate:50");
4222        assert_eq!(path, "title");
4223        assert_eq!(filters.len(), 1);
4224        assert_eq!(filters[0].name, "truncate");
4225        assert_eq!(filters[0].args, vec!["50"]);
4226    }
4227
4228    #[test]
4229    fn test_filter_parse_chained() {
4230        let (path, filters) = parse_filter_chain("title|truncate:50|uppercase");
4231        assert_eq!(path, "title");
4232        assert_eq!(filters.len(), 2);
4233        assert_eq!(filters[0].name, "truncate");
4234        assert_eq!(filters[1].name, "uppercase");
4235    }
4236
4237    #[test]
4238    fn test_filter_parse_quoted_args() {
4239        let (path, filters) = parse_filter_chain(r#"name|default:"Anonymous""#);
4240        assert_eq!(path, "name");
4241        assert_eq!(filters.len(), 1);
4242        assert_eq!(filters[0].name, "default");
4243        assert_eq!(filters[0].args, vec!["Anonymous"]);
4244    }
4245
4246    #[test]
4247    fn test_filter_parse_multiple_args() {
4248        let (path, filters) = parse_filter_chain(r#"text|replace:"old","new""#);
4249        assert_eq!(path, "text");
4250        assert_eq!(filters.len(), 1);
4251        assert_eq!(filters[0].name, "replace");
4252        assert_eq!(filters[0].args, vec!["old", "new"]);
4253    }
4254
4255    #[test]
4256    fn test_filter_uppercase() {
4257        let mut ctx = HashMap::new();
4258        ctx.insert("name".to_string(), serde_json::json!("hello"));
4259        let result = replace_variables("#name|uppercase#", &ctx);
4260        assert_eq!(result, "HELLO");
4261    }
4262
4263    #[test]
4264    fn test_filter_lowercase() {
4265        let mut ctx = HashMap::new();
4266        ctx.insert("name".to_string(), serde_json::json!("HELLO"));
4267        let result = replace_variables("#name|lowercase#", &ctx);
4268        assert_eq!(result, "hello");
4269    }
4270
4271    #[test]
4272    fn test_filter_capitalize() {
4273        let mut ctx = HashMap::new();
4274        ctx.insert("name".to_string(), serde_json::json!("hello world"));
4275        let result = replace_variables("#name|capitalize#", &ctx);
4276        assert_eq!(result, "Hello world");
4277    }
4278
4279    #[test]
4280    fn test_filter_title() {
4281        let mut ctx = HashMap::new();
4282        ctx.insert("name".to_string(), serde_json::json!("hello world today"));
4283        let result = replace_variables("#name|title#", &ctx);
4284        assert_eq!(result, "Hello World Today");
4285    }
4286
4287    #[test]
4288    fn test_filter_truncate() {
4289        let mut ctx = HashMap::new();
4290        ctx.insert(
4291            "text".to_string(),
4292            serde_json::json!("This is a long text that should be truncated"),
4293        );
4294        let result = replace_variables("#text|truncate:10#", &ctx);
4295        assert_eq!(result, "This is a ...");
4296    }
4297
4298    #[test]
4299    fn test_filter_truncate_short_text() {
4300        let mut ctx = HashMap::new();
4301        ctx.insert("text".to_string(), serde_json::json!("Short"));
4302        let result = replace_variables("#text|truncate:10#", &ctx);
4303        assert_eq!(result, "Short");
4304    }
4305
4306    #[test]
4307    fn test_filter_count() {
4308        let mut ctx = HashMap::new();
4309        ctx.insert("text".to_string(), serde_json::json!("hello"));
4310        let result = replace_variables("#text|count#", &ctx);
4311        assert_eq!(result, "5");
4312    }
4313
4314    #[test]
4315    fn test_filter_number() {
4316        let mut ctx = HashMap::new();
4317        ctx.insert("n".to_string(), serde_json::json!(1234567));
4318        let result = replace_variables("#n|number#", &ctx);
4319        assert_eq!(result, "1,234,567");
4320    }
4321
4322    #[test]
4323    fn test_filter_currency_usd() {
4324        let mut ctx = HashMap::new();
4325        ctx.insert("price".to_string(), serde_json::json!(1299.99));
4326        let result = replace_variables(r##"#price|currency:"USD"#"##, &ctx);
4327        assert_eq!(result, "$1,299.99");
4328    }
4329
4330    #[test]
4331    fn test_filter_currency_eur() {
4332        let mut ctx = HashMap::new();
4333        ctx.insert("price".to_string(), serde_json::json!(49.5));
4334        let result = replace_variables(r##"#price|currency:"EUR"#"##, &ctx);
4335        assert_eq!(result, "\u{20ac}49.50");
4336    }
4337
4338    #[test]
4339    fn test_filter_date() {
4340        let mut ctx = HashMap::new();
4341        ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4342        let result = replace_variables("#d|date#", &ctx);
4343        assert_eq!(result, "Mar 15, 2025"); // default = "medium" preset
4344    }
4345
4346    #[test]
4347    fn test_filter_date_custom_format() {
4348        let mut ctx = HashMap::new();
4349        ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4350        let result = replace_variables(r##"#d|date:"dd/mm/yyyy"#"##, &ctx);
4351        assert_eq!(result, "15/03/2025");
4352    }
4353
4354    #[test]
4355    fn test_date_mask_short() {
4356        let mut ctx = HashMap::new();
4357        ctx.insert("d".to_string(), serde_json::json!("2025-03-05"));
4358        let result = replace_variables(r##"#d|date:"short"#"##, &ctx);
4359        assert_eq!(result, "3/5/25");
4360    }
4361
4362    #[test]
4363    fn test_date_mask_full() {
4364        let mut ctx = HashMap::new();
4365        ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4366        let result = replace_variables(r##"#d|date:"full"#"##, &ctx);
4367        assert_eq!(result, "Saturday, March 15, 2025");
4368    }
4369
4370    #[test]
4371    fn test_date_mask_long() {
4372        let mut ctx = HashMap::new();
4373        ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4374        let result = replace_variables(r##"#d|date:"long"#"##, &ctx);
4375        assert_eq!(result, "March 15, 2025");
4376    }
4377
4378    #[test]
4379    fn test_date_mask_iso() {
4380        let mut ctx = HashMap::new();
4381        ctx.insert("d".to_string(), serde_json::json!("2025-03-05"));
4382        let result = replace_variables(r##"#d|date:"iso"#"##, &ctx);
4383        assert_eq!(result, "2025-03-05");
4384    }
4385
4386    #[test]
4387    fn test_date_mask_time() {
4388        let mut ctx = HashMap::new();
4389        ctx.insert("d".to_string(), serde_json::json!("2025-03-15T14:05:09"));
4390        let result = replace_variables(r##"#d|date:"time"#"##, &ctx);
4391        assert_eq!(result, "2:05 PM");
4392    }
4393
4394    #[test]
4395    fn test_date_mask_combined() {
4396        let mut ctx = HashMap::new();
4397        ctx.insert("d".to_string(), serde_json::json!("2025-03-15T14:30:00"));
4398        let result = replace_variables(r##"#d|date:"mmm d, yyyy h:nn tt"#"##, &ctx);
4399        assert_eq!(result, "Mar 15, 2025 2:30 PM");
4400    }
4401
4402    #[test]
4403    fn test_date_mask_24hour() {
4404        let mut ctx = HashMap::new();
4405        ctx.insert("d".to_string(), serde_json::json!("2025-03-15T09:05:00"));
4406        let result = replace_variables(r##"#d|date:"HH:nn"#"##, &ctx);
4407        assert_eq!(result, "09:05");
4408    }
4409
4410    #[test]
4411    fn test_date_mask_weekday() {
4412        let mut ctx = HashMap::new();
4413        ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4414        let result = replace_variables(r##"#d|date:"ddd"#"##, &ctx);
4415        assert_eq!(result, "Sat");
4416    }
4417
4418    #[test]
4419    fn test_date_mask_literals() {
4420        let mut ctx = HashMap::new();
4421        ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4422        let result = replace_variables(r##"#d|date:"yyyy-mm-dd"#"##, &ctx);
4423        assert_eq!(result, "2025-03-15");
4424    }
4425
4426    #[test]
4427    fn test_date_mask_midnight_12hr() {
4428        let mut ctx = HashMap::new();
4429        ctx.insert("d".to_string(), serde_json::json!("2025-03-15T00:00:00"));
4430        let result = replace_variables(r##"#d|date:"h:nn tt"#"##, &ctx);
4431        assert_eq!(result, "12:00 AM");
4432    }
4433
4434    #[test]
4435    fn test_date_rfc3339_input() {
4436        let mut ctx = HashMap::new();
4437        ctx.insert("d".to_string(), serde_json::json!("2025-03-15T14:30:00Z"));
4438        let result = replace_variables(r##"#d|date:"mmm d"#"##, &ctx);
4439        assert_eq!(result, "Mar 15");
4440    }
4441
4442    #[test]
4443    fn test_filter_json() {
4444        let mut ctx = HashMap::new();
4445        ctx.insert("name".to_string(), serde_json::json!("hello"));
4446        let result = replace_variables("#name|json#", &ctx);
4447        assert_eq!(result, "&quot;hello&quot;"); // Escaped because auto-escape
4448    }
4449
4450    #[test]
4451    fn test_filter_json_raw() {
4452        let mut ctx = HashMap::new();
4453        ctx.insert("name".to_string(), serde_json::json!("hello"));
4454        let result = replace_variables("#name|json|raw#", &ctx);
4455        assert_eq!(result, "\"hello\""); // Not escaped because |raw
4456    }
4457
4458    #[test]
4459    fn test_filter_markdown() {
4460        let mut ctx = HashMap::new();
4461        ctx.insert(
4462            "text".to_string(),
4463            serde_json::json!("This is **bold** and *italic*"),
4464        );
4465        let result = replace_variables("#text|markdown#", &ctx);
4466        assert!(result.contains("<strong>bold</strong>"));
4467        assert!(result.contains("<em>italic</em>"));
4468        // markdown is html_safe, so not escaped
4469        assert!(result.contains("<p>"));
4470    }
4471
4472    #[test]
4473    fn test_markdown_url_scheme_allowlist() {
4474        // Safe schemes and relative URLs get an href
4475        assert!(markdown_url_is_safe("https://example.com"));
4476        assert!(markdown_url_is_safe("http://example.com"));
4477        assert!(markdown_url_is_safe("mailto:a@b.com"));
4478        assert!(markdown_url_is_safe("/relative/path"));
4479        assert!(markdown_url_is_safe("#anchor"));
4480        assert!(markdown_url_is_safe("page?x=1:2"));
4481        // Dangerous schemes are blocked — including control-char smuggling that
4482        // browsers would strip before evaluating the scheme.
4483        assert!(!markdown_url_is_safe("javascript:alert(1)"));
4484        assert!(!markdown_url_is_safe("JavaScript:alert(1)"));
4485        assert!(!markdown_url_is_safe("java\tscript:alert(1)"));
4486        assert!(!markdown_url_is_safe("java\nscript:alert(1)"));
4487        assert!(!markdown_url_is_safe("data:text/html,<script>"));
4488        assert!(!markdown_url_is_safe("vbscript:msgbox(1)"));
4489
4490        // End-to-end through the filter: no href, no javascript
4491        let mut ctx = HashMap::new();
4492        ctx.insert(
4493            "t".to_string(),
4494            serde_json::json!("[x](java\tscript:alert(1))"),
4495        );
4496        let out = replace_variables("#t|markdown#", &ctx);
4497        assert!(!out.contains("href"), "dangerous URL emitted an href: {out}");
4498    }
4499
4500    #[test]
4501    fn test_filter_pluralize() {
4502        let mut ctx = HashMap::new();
4503        ctx.insert("count".to_string(), serde_json::json!(1));
4504        let result = replace_variables(r##"#count# item#count|pluralize:"","s"#"##, &ctx);
4505        assert_eq!(result, "1 item");
4506
4507        ctx.insert("count".to_string(), serde_json::json!(5));
4508        let result = replace_variables(r##"#count# item#count|pluralize:"","s"#"##, &ctx);
4509        assert_eq!(result, "5 items");
4510    }
4511
4512    #[test]
4513    fn test_filter_default() {
4514        let ctx = HashMap::new();
4515        let result = replace_variables(r##"#missing|default:"N/A"#"##, &ctx);
4516        assert_eq!(result, "N/A");
4517    }
4518
4519    #[test]
4520    fn test_filter_default_not_needed() {
4521        let mut ctx = HashMap::new();
4522        ctx.insert("name".to_string(), serde_json::json!("Alice"));
4523        let result = replace_variables(r##"#name|default:"N/A"#"##, &ctx);
4524        assert_eq!(result, "Alice");
4525    }
4526
4527    #[test]
4528    fn test_filter_replace() {
4529        let mut ctx = HashMap::new();
4530        ctx.insert("text".to_string(), serde_json::json!("Hello World"));
4531        let result = replace_variables(r##"#text|replace:"World","Rust"#"##, &ctx);
4532        assert_eq!(result, "Hello Rust");
4533    }
4534
4535    #[test]
4536    fn test_filter_slice() {
4537        let mut ctx = HashMap::new();
4538        ctx.insert("text".to_string(), serde_json::json!("Hello World"));
4539        let result = replace_variables("#text|slice:0,5#", &ctx);
4540        assert_eq!(result, "Hello");
4541    }
4542
4543    #[test]
4544    fn test_filter_chaining() {
4545        let mut ctx = HashMap::new();
4546        ctx.insert("name".to_string(), serde_json::json!("hello world"));
4547        let result = replace_variables("#name|uppercase|truncate:5#", &ctx);
4548        assert_eq!(result, "HELLO...");
4549    }
4550
4551    #[test]
4552    fn test_filter_chaining_with_escaping() {
4553        let mut ctx = HashMap::new();
4554        ctx.insert("text".to_string(), serde_json::json!("<b>hello</b>"));
4555        // Without |raw, output is escaped after filters
4556        let result = replace_variables("#text|uppercase#", &ctx);
4557        assert_eq!(result, "&lt;B&gt;HELLO&lt;/B&gt;");
4558    }
4559
4560    #[test]
4561    fn test_filter_raw_bypasses_escaping() {
4562        let mut ctx = HashMap::new();
4563        ctx.insert("html".to_string(), serde_json::json!("<b>bold</b>"));
4564        let result = replace_variables("#html|raw#", &ctx);
4565        assert_eq!(result, "<b>bold</b>");
4566    }
4567
4568    #[test]
4569    fn test_filter_in_reactive_mode() {
4570        let mut ctx = HashMap::new();
4571        ctx.insert("name".to_string(), serde_json::json!("hello"));
4572        let result = replace_variables_reactive("<p>#name|uppercase#</p>", &ctx);
4573        assert!(result.html.contains("HELLO"));
4574    }
4575
4576    #[test]
4577    fn test_filter_default_with_session_var() {
4578        let ctx = HashMap::new();
4579        let result = replace_variables_reactive(r##"<p>#session.count|default:"0"#</p>"##, &ctx);
4580        // Session var not found, default filter triggers, wrapped in span
4581        assert!(result.html.contains("w-bind"));
4582        assert!(result.html.contains(">0<"));
4583    }
4584
4585    #[test]
4586    fn test_filter_unknown_passes_through() {
4587        let mut ctx = HashMap::new();
4588        ctx.insert("name".to_string(), serde_json::json!("hello"));
4589        // Unknown filter should pass value through unchanged
4590        let result = replace_variables("#name|bogusfilter#", &ctx);
4591        assert_eq!(result, "hello");
4592    }
4593
4594    #[test]
4595    fn test_filter_round() {
4596        let mut ctx = HashMap::new();
4597        ctx.insert("price".to_string(), json!("3.14159"));
4598        assert_eq!(replace_variables("#price|round:2#", &ctx), "3.14");
4599    }
4600
4601    #[test]
4602    fn test_filter_round_no_args() {
4603        let mut ctx = HashMap::new();
4604        ctx.insert("val".to_string(), json!("3.7"));
4605        assert_eq!(replace_variables("#val|round#", &ctx), "4");
4606    }
4607
4608    #[test]
4609    fn test_filter_ceil() {
4610        let mut ctx = HashMap::new();
4611        ctx.insert("val".to_string(), json!("3.2"));
4612        assert_eq!(replace_variables("#val|ceil#", &ctx), "4");
4613    }
4614
4615    #[test]
4616    fn test_filter_floor() {
4617        let mut ctx = HashMap::new();
4618        ctx.insert("val".to_string(), json!("3.9"));
4619        assert_eq!(replace_variables("#val|floor#", &ctx), "3");
4620    }
4621
4622    #[test]
4623    fn test_filter_ceil_negative() {
4624        let mut ctx = HashMap::new();
4625        ctx.insert("val".to_string(), json!("-2.3"));
4626        assert_eq!(replace_variables("#val|ceil#", &ctx), "-2");
4627    }
4628
4629    #[test]
4630    fn test_filter_floor_negative() {
4631        let mut ctx = HashMap::new();
4632        ctx.insert("val".to_string(), json!("-2.3"));
4633        assert_eq!(replace_variables("#val|floor#", &ctx), "-3");
4634    }
4635
4636    // =========================================================================
4637    // Arithmetic Tests
4638    // =========================================================================
4639
4640    #[test]
4641    fn test_arithmetic_basic_addition() {
4642        assert_eq!(evaluate_arithmetic("10 + 1"), Some(11.0));
4643    }
4644
4645    #[test]
4646    fn test_arithmetic_subtraction() {
4647        assert_eq!(evaluate_arithmetic("10 - 3"), Some(7.0));
4648    }
4649
4650    #[test]
4651    fn test_arithmetic_multiply() {
4652        assert_eq!(evaluate_arithmetic("5 * 3"), Some(15.0));
4653    }
4654
4655    #[test]
4656    fn test_arithmetic_divide() {
4657        assert_eq!(evaluate_arithmetic("10 / 4"), Some(2.5));
4658    }
4659
4660    #[test]
4661    fn test_arithmetic_precedence() {
4662        // 2 + 3 * 4 = 14 (not 20)
4663        assert_eq!(evaluate_arithmetic("2 + 3 * 4"), Some(14.0));
4664    }
4665
4666    #[test]
4667    fn test_arithmetic_division_by_zero() {
4668        assert_eq!(evaluate_arithmetic("10 / 0"), None);
4669    }
4670
4671    #[test]
4672    fn test_arithmetic_negative_result() {
4673        assert_eq!(evaluate_arithmetic("3 - 10"), Some(-7.0));
4674    }
4675
4676    #[test]
4677    fn test_arithmetic_not_arithmetic() {
4678        assert_eq!(evaluate_arithmetic("hello"), None);
4679        assert_eq!(evaluate_arithmetic("42"), None);
4680    }
4681
4682    #[test]
4683    fn test_arithmetic_in_template() {
4684        let mut ctx = HashMap::new();
4685        ctx.insert("session".to_string(), json!({"age": 25}));
4686        let result = replace_variables("#session.age + 1#", &ctx);
4687        assert_eq!(result, "26");
4688    }
4689
4690    #[test]
4691    fn test_arithmetic_multiply_in_template() {
4692        let mut ctx = HashMap::new();
4693        ctx.insert("price".to_string(), json!(100));
4694        let result = replace_variables("#price * 0.21#", &ctx);
4695        assert_eq!(result, "21");
4696    }
4697
4698    #[test]
4699    fn test_arithmetic_with_filter() {
4700        let mut ctx = HashMap::new();
4701        ctx.insert("price".to_string(), json!(99.99));
4702        let result = replace_variables("#price * 0.21|round:2#", &ctx);
4703        assert_eq!(result, "21.00");
4704    }
4705
4706    #[test]
4707    fn test_no_filters_still_escapes() {
4708        let mut ctx = HashMap::new();
4709        ctx.insert(
4710            "xss".to_string(),
4711            serde_json::json!("<script>alert(1)</script>"),
4712        );
4713        let result = replace_variables("#xss#", &ctx);
4714        assert!(!result.contains("<script>"));
4715        assert!(result.contains("&lt;script&gt;"));
4716    }
4717
4718    // =========================================================================
4719    // Computed Variables Tests
4720    // =========================================================================
4721
4722    #[test]
4723    fn test_computed_variable_parsing() {
4724        let content = r##"<what>
4725title: My Page
4726compute.greeting = "Hello #user.name#!"
4727compute.full_url = "/posts/#post.id#"
4728</what>
4729<html></html>"##;
4730
4731        let (directives, _) = parse_page_directives(content);
4732        assert_eq!(directives.computed.len(), 2);
4733        assert_eq!(directives.computed[0].0, "greeting");
4734        assert_eq!(directives.computed[0].1, "Hello #user.name#!");
4735        assert_eq!(directives.computed[1].0, "full_url");
4736        assert_eq!(directives.computed[1].1, "/posts/#post.id#");
4737    }
4738
4739    #[test]
4740    fn test_computed_variable_resolution() {
4741        let mut context = HashMap::new();
4742        context.insert(
4743            "user".to_string(),
4744            serde_json::json!({
4745                "name": "Alice"
4746            }),
4747        );
4748
4749        let computed = vec![("greeting".to_string(), "Hello #user.name#!".to_string())];
4750
4751        resolve_computed_variables(&computed, &mut context);
4752
4753        assert_eq!(
4754            context.get("greeting"),
4755            Some(&serde_json::json!("Hello Alice!"))
4756        );
4757    }
4758
4759    #[test]
4760    fn test_computed_variable_chained() {
4761        let mut context = HashMap::new();
4762        context.insert("first".to_string(), serde_json::json!("John"));
4763        context.insert("last".to_string(), serde_json::json!("Doe"));
4764
4765        let computed = vec![
4766            ("full_name".to_string(), "#first# #last#".to_string()),
4767            ("greeting".to_string(), "Hello #full_name#!".to_string()),
4768        ];
4769
4770        resolve_computed_variables(&computed, &mut context);
4771
4772        assert_eq!(
4773            context.get("full_name"),
4774            Some(&serde_json::json!("John Doe"))
4775        );
4776        assert_eq!(
4777            context.get("greeting"),
4778            Some(&serde_json::json!("Hello John Doe!"))
4779        );
4780    }
4781
4782    #[test]
4783    fn test_computed_variable_with_nested_path() {
4784        let mut context = HashMap::new();
4785        context.insert(
4786            "post".to_string(),
4787            serde_json::json!({
4788                "id": 42,
4789                "title": "My Post"
4790            }),
4791        );
4792
4793        let computed = vec![(
4794            "edit_url".to_string(),
4795            "/admin/posts/#post.id#/edit".to_string(),
4796        )];
4797
4798        resolve_computed_variables(&computed, &mut context);
4799
4800        assert_eq!(
4801            context.get("edit_url"),
4802            Some(&serde_json::json!("/admin/posts/42/edit"))
4803        );
4804    }
4805
4806    #[test]
4807    fn test_computed_variable_unresolved_reference() {
4808        let mut context = HashMap::new();
4809
4810        let computed = vec![("url".to_string(), "/page/#missing_var#".to_string())];
4811
4812        resolve_computed_variables(&computed, &mut context);
4813
4814        // Unresolved vars stay as-is
4815        assert_eq!(
4816            context.get("url"),
4817            Some(&serde_json::json!("/page/#missing_var#"))
4818        );
4819    }
4820
4821    #[test]
4822    fn test_computed_variable_no_prefix_in_template() {
4823        // Computed vars are available as #name# not #compute.name#
4824        let mut context = HashMap::new();
4825        context.insert("x".to_string(), serde_json::json!("world"));
4826
4827        let computed = vec![("greeting".to_string(), "hello #x#".to_string())];
4828
4829        resolve_computed_variables(&computed, &mut context);
4830
4831        // Use in template
4832        let result = replace_variables("Say: #greeting#", &context);
4833        assert_eq!(result, "Say: hello world");
4834    }
4835
4836    #[test]
4837    fn test_computed_variable_empty() {
4838        let mut context = HashMap::new();
4839        let computed: Vec<(String, String)> = Vec::new();
4840
4841        resolve_computed_variables(&computed, &mut context);
4842        // No crash, context unchanged
4843        assert!(context.is_empty());
4844    }
4845
4846    // ---- Wired Scope Parsing Tests ----
4847
4848    #[test]
4849    fn parse_wired_no_brackets() {
4850        let decl = parse_wired_decl("counter");
4851        assert_eq!(decl.name, "counter");
4852        assert!(matches!(decl.scope, WiredScope::Public));
4853    }
4854
4855    #[test]
4856    fn parse_wired_single_role() {
4857        let decl = parse_wired_decl("revenue [admin]");
4858        assert_eq!(decl.name, "revenue");
4859        match decl.scope {
4860            WiredScope::Roles(roles) => assert_eq!(roles, vec!["admin"]),
4861            _ => panic!("Expected Roles scope"),
4862        }
4863    }
4864
4865    #[test]
4866    fn parse_wired_multi_role() {
4867        let decl = parse_wired_decl("x [admin, editor]");
4868        assert_eq!(decl.name, "x");
4869        match decl.scope {
4870            WiredScope::Roles(roles) => assert_eq!(roles, vec!["admin", "editor"]),
4871            _ => panic!("Expected Roles scope"),
4872        }
4873    }
4874
4875    #[test]
4876    fn parse_wired_user_scope() {
4877        let decl = parse_wired_decl("notifs [user]");
4878        assert_eq!(decl.name, "notifs");
4879        assert!(matches!(decl.scope, WiredScope::User(_)));
4880    }
4881
4882    #[test]
4883    fn wired_backwards_compat() {
4884        // data.wired = ["counter", "visitors"] without brackets → all Public
4885        let content = r#"data.wired = ["counter", "visitors"]"#;
4886        let config = parse_what_file(content);
4887        assert_eq!(config.data_wired.len(), 2);
4888        assert_eq!(config.data_wired[0].name, "counter");
4889        assert!(matches!(config.data_wired[0].scope, WiredScope::Public));
4890        assert_eq!(config.data_wired[1].name, "visitors");
4891        assert!(matches!(config.data_wired[1].scope, WiredScope::Public));
4892    }
4893
4894    #[test]
4895    fn wired_scope_allows_public() {
4896        let scope = WiredScope::Public;
4897        assert!(scope.allows(&[], None));
4898        assert!(scope.allows(&["admin".into()], Some("user1")));
4899    }
4900
4901    #[test]
4902    fn wired_scope_allows_role_match() {
4903        let scope = WiredScope::Roles(vec!["admin".into(), "editor".into()]);
4904        assert!(scope.allows(&["admin".into()], None));
4905        assert!(scope.allows(&["editor".into()], None));
4906        assert!(!scope.allows(&["viewer".into()], None));
4907        assert!(!scope.allows(&[], None));
4908    }
4909
4910    #[test]
4911    fn wired_scope_allows_user_match() {
4912        let scope = WiredScope::User("user42".into());
4913        assert!(scope.allows(&[], Some("user42")));
4914        assert!(!scope.allows(&[], Some("user99")));
4915        assert!(!scope.allows(&[], None));
4916    }
4917
4918    #[test]
4919    fn test_is_unquoted_string() {
4920        // Numbers are not unquoted strings
4921        assert!(!is_unquoted_string("42"));
4922        assert!(!is_unquoted_string("3.14"));
4923        assert!(!is_unquoted_string("-1"));
4924        // Booleans and keywords are not unquoted strings
4925        assert!(!is_unquoted_string("true"));
4926        assert!(!is_unquoted_string("false"));
4927        assert!(!is_unquoted_string("none"));
4928        assert!(!is_unquoted_string("all"));
4929        assert!(!is_unquoted_string("user"));
4930        assert!(!is_unquoted_string("None"));
4931        assert!(!is_unquoted_string(""));
4932        // Actual strings should be flagged
4933        assert!(is_unquoted_string("Hello World"));
4934        assert!(is_unquoted_string("local:items"));
4935        assert!(is_unquoted_string("main"));
4936        assert!(is_unquoted_string("/login"));
4937    }
4938
4939    #[test]
4940    fn test_quoted_strings_no_warning() {
4941        // Quoted values should parse without warnings
4942        let content = r#"title: "My Page"
4943layout: "main"
4944fetch.items = "local:items"
4945greeting = "Hello World""#;
4946        let mut directives = PageDirectives::default();
4947        parse_directive_content(content, &mut directives);
4948        assert_eq!(directives.title.as_deref(), Some("My Page"));
4949        assert_eq!(directives.layout.as_deref(), Some("main"));
4950        assert_eq!(
4951            directives.custom.get("fetch.items").map(|s| s.as_str()),
4952            Some("local:items")
4953        );
4954        assert_eq!(
4955            directives.vars.get("greeting"),
4956            Some(&serde_json::json!("Hello World"))
4957        );
4958    }
4959
4960    #[test]
4961    fn test_unquoted_numbers_and_bools_ok() {
4962        // Numbers and booleans should not trigger warnings
4963        let content = "count = 42\nprice = 9.99\nactive = true";
4964        let mut directives = PageDirectives::default();
4965        parse_directive_content(content, &mut directives);
4966        assert_eq!(directives.vars.get("count"), Some(&serde_json::json!(42)));
4967        assert_eq!(directives.vars.get("price"), Some(&serde_json::json!(9.99)));
4968        assert_eq!(
4969            directives.vars.get("active"),
4970            Some(&serde_json::json!(true))
4971        );
4972    }
4973
4974    #[test]
4975    fn test_html_unescape_round_trip() {
4976        assert_eq!(html_unescape(&html_escape("Ben & Jerry")), "Ben & Jerry");
4977        assert_eq!(html_unescape(&html_escape("O'Brien")), "O'Brien");
4978        assert_eq!(html_unescape(&html_escape("a < b > c")), "a < b > c");
4979        // Author-written entity text survives the round trip un-collapsed
4980        assert_eq!(html_unescape(&html_escape("&lt;")), "&lt;");
4981        assert_eq!(html_unescape("plain"), "plain");
4982    }
4983
4984    #[test]
4985    fn test_count_filter_counts_items_not_bytes() {
4986        let mut ctx = HashMap::new();
4987        ctx.insert(
4988            "items".to_string(),
4989            serde_json::json!([{"name": "a"}, {"name": "b"}, {"name": "c"}]),
4990        );
4991        ctx.insert("name".to_string(), serde_json::json!("José"));
4992        assert_eq!(replace_variables("#items|count#", &ctx), "3");
4993        assert_eq!(replace_variables("#name|count#", &ctx), "4");
4994    }
4995
4996    #[test]
4997    fn test_within_one_edit() {
4998        assert!(within_one_edit("auth", "auth"));
4999        assert!(within_one_edit("auht", "auth")); // adjacent transposition
5000        assert!(within_one_edit("atuh", "auth")); // adjacent transposition
5001        assert!(within_one_edit("aut", "auth")); // deletion
5002        assert!(within_one_edit("auths", "auth")); // insertion
5003        assert!(within_one_edit("autj", "auth")); // substitution
5004        assert!(within_one_edit("oauth", "auth")); // deletion (filtered by first-letter guard)
5005        assert!(!within_one_edit("author", "auth"));
5006        assert!(!within_one_edit("au", "auth"));
5007        assert!(!within_one_edit("layout", "auth"));
5008    }
5009
5010    #[test]
5011    fn test_access_directive_near_miss() {
5012        assert_eq!(access_directive_near_miss("auht"), Some("auth"));
5013        assert_eq!(access_directive_near_miss("atuh"), Some("auth"));
5014        assert_eq!(access_directive_near_miss("aut"), Some("auth"));
5015        assert_eq!(access_directive_near_miss("Auth"), Some("auth")); // case typo
5016        assert_eq!(access_directive_near_miss("role"), Some("roles"));
5017        assert_eq!(access_directive_near_miss("protectd"), Some("protected"));
5018        // Exact key is not a near-miss (it parses as the real directive)
5019        assert_eq!(access_directive_near_miss("auth"), None);
5020        // Different first letter: legitimate variable names near these words
5021        assert_eq!(access_directive_near_miss("oauth"), None);
5022        // Unrelated keys
5023        assert_eq!(access_directive_near_miss("title"), None);
5024        assert_eq!(access_directive_near_miss("items"), None);
5025    }
5026
5027    #[test]
5028    fn test_auth_typo_key_stays_inline_var_and_page_stays_public() {
5029        // Documents the fail-open the near-miss warning exists for: `auht:`
5030        // is NOT `auth`, so the page keeps AuthLevel::All (public) and the
5031        // key becomes an inline variable.
5032        let content = r#"auht: "user""#;
5033        let mut directives = PageDirectives::default();
5034        parse_directive_content(content, &mut directives);
5035        assert!(matches!(directives.auth, AuthLevel::All));
5036        assert_eq!(directives.vars.get("auht"), Some(&serde_json::json!("user")));
5037    }
5038
5039    #[test]
5040    fn test_strip_symmetric_quotes() {
5041        assert_eq!(strip_symmetric_quotes(r#""hello""#), ("hello", true));
5042        assert_eq!(strip_symmetric_quotes("'hello'"), ("hello", true));
5043        assert_eq!(strip_symmetric_quotes("hello"), ("hello", false));
5044        // Mismatched quotes are left intact
5045        assert_eq!(strip_symmetric_quotes(r#""hello'"#), (r#""hello'"#, false));
5046        // Only ONE pair is stripped (the old trim_matches stripped repeats)
5047        assert_eq!(strip_symmetric_quotes("''x''"), ("'x'", true));
5048        // Empty quoted string
5049        assert_eq!(strip_symmetric_quotes(r#""""#), ("", true));
5050        // Bare quote char is not a pair
5051        assert_eq!(strip_symmetric_quotes(r#"""#), (r#"""#, false));
5052    }
5053
5054    #[test]
5055    fn test_quoting_forces_string_type_inline_vars() {
5056        // v1.0 rule: quoted values are strings, unquoted values are type-inferred.
5057        let content = "zip = \"01234\"\nversion = \"1.0\"\nflag = \"true\"\ncount = 42";
5058        let mut directives = PageDirectives::default();
5059        parse_directive_content(content, &mut directives);
5060        assert_eq!(
5061            directives.vars.get("zip"),
5062            Some(&serde_json::json!("01234")),
5063            "quoted leading-zero value must stay a string"
5064        );
5065        assert_eq!(
5066            directives.vars.get("version"),
5067            Some(&serde_json::json!("1.0")),
5068            "quoted numeric-looking value must stay a string"
5069        );
5070        assert_eq!(
5071            directives.vars.get("flag"),
5072            Some(&serde_json::json!("true")),
5073            "quoted boolean-looking value must stay a string"
5074        );
5075        assert_eq!(directives.vars.get("count"), Some(&serde_json::json!(42)));
5076    }
5077
5078    #[test]
5079    fn test_quoting_forces_string_type_session_mutations() {
5080        let set = parse_session_mutation(r#"session.zip = "01234""#).unwrap();
5081        match set {
5082            SessionMutation::Set { key, value } => {
5083                assert_eq!(key, "zip");
5084                assert_eq!(value, serde_json::json!("01234"));
5085            }
5086            other => panic!("expected Set, got {:?}", other),
5087        }
5088        let set = parse_session_mutation("session.count = 42").unwrap();
5089        match set {
5090            SessionMutation::Set { value, .. } => {
5091                assert_eq!(value, serde_json::json!(42));
5092            }
5093            other => panic!("expected Set, got {:?}", other),
5094        }
5095        let push = parse_session_mutation(r#"session.items.push("42")"#).unwrap();
5096        match push {
5097            SessionMutation::Push { value, .. } => {
5098                assert_eq!(value, serde_json::json!("42"));
5099            }
5100            other => panic!("expected Push, got {:?}", other),
5101        }
5102    }
5103
5104    #[test]
5105    fn test_mismatched_quotes_left_intact() {
5106        // A mismatched pair must not be silently swallowed.
5107        let content = "label = \"oops'";
5108        let mut directives = PageDirectives::default();
5109        parse_directive_content(content, &mut directives);
5110        assert_eq!(
5111            directives.vars.get("label"),
5112            Some(&serde_json::json!("\"oops'"))
5113        );
5114    }
5115
5116    #[test]
5117    fn test_what_file_quoted_number_stays_string() {
5118        // application.what files already kept quoted numbers as strings — lock it.
5119        let config = parse_what_file("zip = \"01234\"\ncount = 7");
5120        assert_eq!(config.values.get("zip"), Some(&serde_json::json!("01234")));
5121        assert_eq!(config.values.get("count"), Some(&serde_json::json!(7)));
5122    }
5123}