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    // Block javascript: and data: URIs to prevent XSS
1304    result = MD_LINK_RE
1305        .replace_all(&result, |caps: &regex::Captures| {
1306            let text = &caps[1];
1307            let url = caps[2].trim();
1308            let url_lower = url.to_lowercase();
1309            if url_lower.starts_with("javascript:") || url_lower.starts_with("data:") {
1310                text.to_string()
1311            } else {
1312                format!(r#"<a href="{}">{}</a>"#, url, text)
1313            }
1314        })
1315        .to_string();
1316
1317    result
1318}
1319
1320/// Replace #variable# syntax with values from context.
1321/// All output is HTML-escaped by default. Use `|raw` filter to bypass escaping.
1322/// Supports filter chaining: `#var|filter1:arg|filter2#`
1323pub(crate) fn replace_variables(
1324    template: &str,
1325    context: &HashMap<String, serde_json::Value>,
1326) -> String {
1327    VAR_REGEX
1328        .replace_all(template, |caps: &regex::Captures| {
1329            let expr = &caps[1];
1330            let (var_path, filters) = parse_filter_chain(expr);
1331
1332            // Check if var_path contains arithmetic operators
1333            let raw_value = if contains_arithmetic(var_path) {
1334                resolve_and_evaluate_arithmetic(var_path, context)
1335                    .unwrap_or_else(|| resolve_variable(var_path, context))
1336            } else {
1337                resolve_variable(var_path, context)
1338            };
1339
1340            // If variable was unresolved and there's no default filter, keep as #var#
1341            let is_unresolved = raw_value.starts_with('#') && raw_value.ends_with('#');
1342
1343            // Apply filters
1344            let filtered = if filters.is_empty() {
1345                FilterResult {
1346                    value: raw_value,
1347                    html_safe: false,
1348                }
1349            } else {
1350                // If unresolved and first filter is "default", use empty string to trigger default
1351                let input = if is_unresolved && filters.iter().any(|f| f.name == "default") {
1352                    String::new()
1353                } else {
1354                    raw_value
1355                };
1356                apply_filters(&input, &filters)
1357            };
1358
1359            // Auto-escape unless html_safe
1360            if filtered.html_safe {
1361                filtered.value
1362            } else {
1363                html_escape(&filtered.value)
1364            }
1365        })
1366        .to_string()
1367}
1368
1369/// Result of reactive variable replacement
1370#[derive(Debug, Clone, Default)]
1371pub struct ReactiveReplaceResult {
1372    /// The rendered HTML content
1373    pub html: String,
1374    /// Session keys that were used (for OOB updates)
1375    pub session_keys: std::collections::HashSet<String>,
1376}
1377
1378/// Replace #variable# syntax with values from context, wrapping session variables
1379/// with reactive `<span w-bind>` elements when they appear in text content.
1380/// Variables inside attributes are replaced without wrapping.
1381/// All output is HTML-escaped by default. Supports filter chaining: `#var|filter1:arg|filter2#`
1382pub(crate) fn replace_variables_reactive(
1383    template: &str,
1384    context: &HashMap<String, serde_json::Value>,
1385) -> ReactiveReplaceResult {
1386    let mut session_keys = std::collections::HashSet::new();
1387
1388    // We need to track position to determine if we're in an attribute context
1389    let mut result = String::with_capacity(template.len());
1390    let mut last_end = 0;
1391
1392    for caps in VAR_REGEX.captures_iter(template) {
1393        let m = caps.get(0).unwrap();
1394        let expr = &caps[1];
1395        let start = m.start();
1396
1397        // Add the text before this match
1398        result.push_str(&template[last_end..start]);
1399
1400        // Parse filter chain
1401        let (var_path, filters) = parse_filter_chain(expr);
1402
1403        // Check if this is a session or wired variable
1404        let is_session_var = var_path.starts_with("session.");
1405        let is_wired_var = var_path.starts_with("wired.");
1406
1407        // Resolve the variable value (with arithmetic support)
1408        let raw_value = if contains_arithmetic(var_path) {
1409            resolve_and_evaluate_arithmetic(var_path, context)
1410                .unwrap_or_else(|| resolve_variable(var_path, context))
1411        } else {
1412            resolve_variable(var_path, context)
1413        };
1414        let is_unresolved = raw_value.starts_with('#') && raw_value.ends_with('#');
1415
1416        // Apply filters
1417        let filtered = if filters.is_empty() {
1418            FilterResult {
1419                value: raw_value,
1420                html_safe: false,
1421            }
1422        } else {
1423            let input = if is_unresolved && filters.iter().any(|f| f.name == "default") {
1424                String::new()
1425            } else {
1426                raw_value
1427            };
1428            apply_filters(&input, &filters)
1429        };
1430
1431        if is_session_var || is_wired_var {
1432            let (bind_prefix, bind_key) = if is_session_var {
1433                let key = &var_path[8..]; // Skip "session."
1434                session_keys.insert(key.to_string());
1435                ("session", key)
1436            } else {
1437                let key = &var_path[6..]; // Skip "wired."
1438                ("wired", key)
1439            };
1440
1441            // If the value is unresolved (still looks like #var#), use empty string
1442            // to prevent double-wrapping when layouts re-process the output
1443            let display_value = if filtered.value.starts_with('#') && filtered.value.ends_with('#')
1444            {
1445                String::new()
1446            } else {
1447                filtered.value
1448            };
1449
1450            // Check if we're inside an attribute by looking at the text before this match
1451            let text_before = &template[..start];
1452            let in_attribute = is_in_attribute_context(text_before);
1453
1454            if in_attribute {
1455                // Inside attribute - replace with escaped value (no wrapping)
1456                if filtered.html_safe {
1457                    result.push_str(&display_value);
1458                } else {
1459                    result.push_str(&html_escape(&display_value));
1460                }
1461            } else {
1462                // In text content - wrap with reactive span
1463                // The span content is always escaped (XSS protection in reactive updates)
1464                result.push_str(&format!(
1465                    r#"<span w-bind="{}.{}">{}</span>"#,
1466                    bind_prefix,
1467                    bind_key,
1468                    html_escape(&display_value)
1469                ));
1470            }
1471        } else {
1472            // Non-reactive variable
1473            if filtered.html_safe {
1474                result.push_str(&filtered.value);
1475            } else {
1476                result.push_str(&html_escape(&filtered.value));
1477            }
1478        }
1479
1480        last_end = m.end();
1481    }
1482
1483    // Add any remaining text after the last match
1484    result.push_str(&template[last_end..]);
1485
1486    ReactiveReplaceResult {
1487        html: result,
1488        session_keys,
1489    }
1490}
1491
1492/// Check if a position in the template is inside an HTML attribute
1493/// by analyzing the text before that position
1494fn is_in_attribute_context(text_before: &str) -> bool {
1495    // Look for the last opening tag or attribute quote
1496    // We're in an attribute if we find an unclosed attribute pattern
1497    let mut in_attr_value = false;
1498    let mut quote_char: Option<char> = None;
1499
1500    for c in text_before.chars().rev() {
1501        match c {
1502            '"' | '\'' if quote_char == Some(c) => {
1503                // End of attribute value (going backwards, this is actually the start)
1504                quote_char = None;
1505                in_attr_value = false;
1506            }
1507            '"' | '\'' if quote_char.is_none() => {
1508                // Start of attribute value (going backwards, this is actually the end)
1509                quote_char = Some(c);
1510                in_attr_value = true;
1511            }
1512            '>' if quote_char.is_none() => {
1513                // We hit a '>' before finding an unclosed attribute, so we're in text content
1514                return false;
1515            }
1516            '<' if quote_char.is_none() => {
1517                // We hit a '<' - we were inside a tag
1518                // If we have an unclosed quote, we're in an attribute
1519                return in_attr_value;
1520            }
1521            _ => {}
1522        }
1523    }
1524
1525    // If we reach here with an open quote, we're in an attribute
1526    in_attr_value
1527}
1528
1529/// Escape HTML special characters
1530fn html_escape(s: &str) -> String {
1531    s.replace('&', "&amp;")
1532        .replace('<', "&lt;")
1533        .replace('>', "&gt;")
1534        .replace('"', "&quot;")
1535        .replace('\'', "&#39;")
1536}
1537
1538/// Reverse of html_escape, for comparison operands: values resolve through
1539/// replace_variables (which escapes for output), so comparing them against
1540/// an author-written literal like `"Ben & Jerry"` must undo the escaping.
1541/// `&amp;` is unescaped LAST — the mirror of html_escape escaping `&` first —
1542/// so `&amp;lt;` round-trips to `&lt;` and never collapses to `<`.
1543pub(crate) fn html_unescape(s: &str) -> String {
1544    if !s.contains('&') {
1545        return s.to_string();
1546    }
1547    s.replace("&lt;", "<")
1548        .replace("&gt;", ">")
1549        .replace("&quot;", "\"")
1550        .replace("&#39;", "'")
1551        .replace("&amp;", "&")
1552}
1553
1554/// Resolve computed variables from page directives and inject them into the context.
1555/// Computed variables use string interpolation: `compute.name = "Hello #user.name#!"`
1556/// They become available as `#name#` (without the `compute.` prefix) in templates.
1557/// Resolved in order, so later computed vars can reference earlier ones.
1558pub(crate) fn resolve_computed_variables(
1559    computed: &[(String, String)],
1560    context: &mut HashMap<String, serde_json::Value>,
1561) {
1562    for (name, template) in computed {
1563        // Interpolate #var# references in the template using current context
1564        let resolved = VAR_REGEX
1565            .replace_all(template, |caps: &regex::Captures| {
1566                let var_path = &caps[1];
1567                resolve_variable(var_path, context)
1568            })
1569            .to_string();
1570
1571        // Insert as a string value (without the compute. prefix)
1572        context.insert(name.clone(), serde_json::Value::String(resolved));
1573    }
1574}
1575
1576/// Resolve a variable path like "user.email" from context.
1577/// Also supports "env.VAR_NAME" to read environment variables.
1578/// Returns the resolved string value, or "#var_path#" if not found.
1579fn resolve_variable(path: &str, context: &HashMap<String, serde_json::Value>) -> String {
1580    let parts: Vec<&str> = path.split('.').collect();
1581
1582    if parts.is_empty() {
1583        return String::new();
1584    }
1585
1586    // Check for environment variable: #env.VAR_NAME#
1587    if parts[0] == "env" && parts.len() >= 2 {
1588        let env_var_name = parts[1..].join("_"); // env.DATABASE_URL -> DATABASE_URL
1589        return std::env::var(&env_var_name).unwrap_or_default();
1590    }
1591
1592    let root = context.get(parts[0]);
1593    let mut current: Option<&serde_json::Value> = root;
1594
1595    for part in parts.iter().skip(1) {
1596        current = current.and_then(|v| {
1597            if let serde_json::Value::Object(obj) = v {
1598                obj.get(*part)
1599            } else {
1600                None
1601            }
1602        });
1603    }
1604
1605    match current {
1606        Some(serde_json::Value::String(s)) => s.clone(),
1607        Some(serde_json::Value::Number(n)) => n.to_string(),
1608        Some(serde_json::Value::Bool(b)) => b.to_string(),
1609        Some(serde_json::Value::Null) => String::new(),
1610        Some(v) => v.to_string(),
1611        None if root.is_some() && parts.len() > 1 => String::new(), // Parent exists, child missing → empty
1612        None => format!("#{}#", path), // Root not in context → keep literal for strict mode
1613    }
1614}
1615
1616/// Check if a tag name is a standard HTML tag
1617#[allow(dead_code)]
1618pub(crate) fn is_standard_html_tag(name: &str) -> bool {
1619    matches!(
1620        name,
1621        "html"
1622            | "head"
1623            | "body"
1624            | "title"
1625            | "meta"
1626            | "link"
1627            | "script"
1628            | "style"
1629            | "div"
1630            | "span"
1631            | "p"
1632            | "a"
1633            | "img"
1634            | "br"
1635            | "hr"
1636            | "h1"
1637            | "h2"
1638            | "h3"
1639            | "h4"
1640            | "h5"
1641            | "h6"
1642            | "ul"
1643            | "ol"
1644            | "li"
1645            | "dl"
1646            | "dt"
1647            | "dd"
1648            | "table"
1649            | "thead"
1650            | "tbody"
1651            | "tfoot"
1652            | "tr"
1653            | "th"
1654            | "td"
1655            | "form"
1656            | "input"
1657            | "textarea"
1658            | "select"
1659            | "option"
1660            | "button"
1661            | "label"
1662            | "header"
1663            | "footer"
1664            | "main"
1665            | "nav"
1666            | "section"
1667            | "article"
1668            | "aside"
1669            | "figure"
1670            | "figcaption"
1671            | "video"
1672            | "audio"
1673            | "source"
1674            | "canvas"
1675            | "iframe"
1676            | "embed"
1677            | "object"
1678            | "param"
1679            | "strong"
1680            | "em"
1681            | "b"
1682            | "i"
1683            | "u"
1684            | "s"
1685            | "mark"
1686            | "small"
1687            | "sub"
1688            | "sup"
1689            | "blockquote"
1690            | "pre"
1691            | "code"
1692            | "kbd"
1693            | "samp"
1694            | "var"
1695            | "time"
1696            | "address"
1697            | "abbr"
1698            | "cite"
1699            | "q"
1700            | "ins"
1701            | "del"
1702            | "dfn"
1703            | "ruby"
1704            | "rt"
1705            | "rp"
1706            | "bdi"
1707            | "bdo"
1708            | "wbr"
1709            | "details"
1710            | "summary"
1711            | "dialog"
1712            | "slot"
1713            | "template"
1714            | "noscript"
1715    )
1716}
1717
1718/// Authentication level for a page
1719#[derive(Debug, Clone, PartialEq)]
1720pub(crate) enum AuthLevel {
1721    /// Public page - no authentication required (auth: all)
1722    All,
1723    /// Any authenticated user (auth: user)
1724    User,
1725    /// Specific roles required (auth: admin, editor)
1726    Roles(Vec<String>),
1727}
1728
1729impl std::fmt::Display for AuthLevel {
1730    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1731        match self {
1732            AuthLevel::All => write!(f, "all"),
1733            AuthLevel::User => write!(f, "user"),
1734            AuthLevel::Roles(v) => write!(f, "roles: {}", v.join(", ")),
1735        }
1736    }
1737}
1738
1739impl Default for AuthLevel {
1740    fn default() -> Self {
1741        AuthLevel::All
1742    }
1743}
1744
1745/// Page directives extracted from <what> tags
1746///
1747/// Example usage in HTML:
1748/// ```html
1749/// <what auth="all" />              <!-- Public page -->
1750/// <what auth="user" />             <!-- Any authenticated user -->
1751/// <what auth="admin, editor" />    <!-- Specific roles -->
1752/// <what layout="sections/main.html" /> <!-- Use a layout -->
1753/// ```
1754///
1755/// Or with content:
1756/// ```html
1757/// <what>
1758///   auth: all           # Public page (no auth)
1759///   auth: user          # Any authenticated user
1760///   auth: admin, editor # Specific roles
1761///   layout: sections/main.html
1762///   title: Dashboard
1763/// </what>
1764/// ```
1765///
1766/// Legacy syntax still supported:
1767/// ```html
1768/// <what protected roles="admin,editor" />
1769/// ```
1770/// Session mutation operation
1771#[derive(Debug, Clone)]
1772pub(crate) enum SessionMutation {
1773    /// Increment a session variable by a value: session.counter += 1
1774    Increment { key: String, value: i64 },
1775    /// Set a session variable: session.name = "value"
1776    Set { key: String, value: Value },
1777    /// Push a value to the end of an array: session.list.push(value)
1778    Push { key: String, value: Value },
1779    /// Push a value to the end of an array with a max size, dropping oldest: session.list.pushmax(10, value)
1780    PushMax {
1781        key: String,
1782        max: usize,
1783        value: Value,
1784    },
1785    /// Push a value to the beginning of an array: session.list.unshift(value)
1786    Unshift { key: String, value: Value },
1787    /// Clear an array: session.list.clear()
1788    Clear { key: String },
1789}
1790
1791#[derive(Debug, Clone, Default)]
1792pub(crate) struct PageDirectives {
1793    /// Authentication level for this page
1794    pub auth: AuthLevel,
1795    /// Page is protected (requires authentication) - LEGACY, use `auth` instead
1796    pub protected: bool,
1797    /// Required roles (any of these roles grants access) - LEGACY, use `auth` instead
1798    pub roles: Vec<String>,
1799    /// Page should be excluded from routing
1800    pub exclude: bool,
1801    /// Custom page title
1802    pub title: Option<String>,
1803    /// Redirect to another page
1804    pub redirect: Option<String>,
1805    /// Cache TTL in seconds (overrides global)
1806    pub cache_ttl: Option<u64>,
1807    /// Layout template path (e.g., "sections/layout.html")
1808    /// Use "none" to explicitly disable layout inheritance
1809    pub layout: Option<String>,
1810    /// Session mutations to apply before rendering
1811    pub session_mutations: Vec<SessionMutation>,
1812    /// Computed variables: compute.name = "interpolated #var# string"
1813    pub computed: Vec<(String, String)>,
1814    /// Custom response headers (from `header.Name = "value"` in application.what)
1815    pub headers: HashMap<String, String>,
1816    /// Any additional custom directives
1817    pub custom: HashMap<String, String>,
1818    /// Inline variables from `<what>` blocks (typed: strings, numbers, arrays, objects)
1819    pub vars: HashMap<String, Value>,
1820}
1821
1822impl PageDirectives {
1823    /// Check if page requires authentication
1824    /// Returns true if auth level is User or Roles, or if legacy `protected` is set
1825    pub fn requires_auth(&self) -> bool {
1826        match &self.auth {
1827            AuthLevel::All => self.protected, // Fall back to legacy
1828            AuthLevel::User => true,
1829            AuthLevel::Roles(_) => true,
1830        }
1831    }
1832
1833    /// Check if user has access based on auth level
1834    /// - All: always returns true
1835    /// - User: returns true if authenticated
1836    /// - Roles: returns true if user has any required role
1837    pub fn check_access(&self, authenticated: bool, user_roles: &[String]) -> bool {
1838        match &self.auth {
1839            AuthLevel::All => {
1840                // Legacy fallback
1841                if self.protected {
1842                    if !authenticated {
1843                        return false;
1844                    }
1845                    self.has_role(user_roles)
1846                } else {
1847                    true
1848                }
1849            }
1850            AuthLevel::User => authenticated,
1851            AuthLevel::Roles(required) => {
1852                authenticated && required.iter().any(|r| user_roles.contains(r))
1853            }
1854        }
1855    }
1856
1857    /// Check if user has any of the required roles (legacy method)
1858    pub fn has_role(&self, user_roles: &[String]) -> bool {
1859        if self.roles.is_empty() {
1860            return true; // No role requirement
1861        }
1862        self.roles.iter().any(|r| user_roles.contains(r))
1863    }
1864}
1865
1866/// Parse <what> directives from page content
1867/// Returns the directives and the content with <what> tags removed
1868pub(crate) fn parse_page_directives(content: &str) -> (PageDirectives, String) {
1869    let mut directives = PageDirectives::default();
1870
1871    let cleaned = WHAT_DIRECTIVE_REGEX
1872        .replace_all(content, |caps: &regex::Captures| {
1873            let attrs_str = caps.get(1).map(|m| m.as_str()).unwrap_or("");
1874            let inner_content = caps.get(2).map(|m| m.as_str());
1875
1876            // Parse attributes from the tag
1877            parse_directive_attributes(attrs_str, &mut directives);
1878
1879            // Parse inner content if present (YAML-like syntax)
1880            if let Some(inner) = inner_content {
1881                parse_directive_content(inner, &mut directives);
1882            }
1883
1884            "" // Remove the <what> tag from output
1885        })
1886        .to_string();
1887
1888    (directives, cleaned)
1889}
1890
1891/// Parse auth value into AuthLevel
1892pub(crate) fn parse_auth_level(value: &str) -> AuthLevel {
1893    let value = value.trim().to_lowercase();
1894    match value.as_str() {
1895        "all" | "public" | "none" => AuthLevel::All,
1896        "user" | "authenticated" => AuthLevel::User,
1897        _ => {
1898            // Parse as comma-separated roles
1899            let roles: Vec<String> = value
1900                .split(',')
1901                .map(|s| s.trim().to_string())
1902                .filter(|s| !s.is_empty())
1903                .collect();
1904            if roles.is_empty() {
1905                AuthLevel::All
1906            } else {
1907                AuthLevel::Roles(roles)
1908            }
1909        }
1910    }
1911}
1912
1913/// Parse attributes from <what> tag
1914fn parse_directive_attributes(attrs_str: &str, directives: &mut PageDirectives) {
1915    // Parse key="value" attributes
1916    let attrs = parse_attributes(attrs_str);
1917
1918    for (key, value) in &attrs {
1919        match key.as_str() {
1920            "auth" => {
1921                directives.auth = parse_auth_level(value);
1922            }
1923            // Legacy support
1924            "protected" => directives.protected = value != "false",
1925            "roles" => {
1926                directives.roles = value
1927                    .split(',')
1928                    .map(|s| s.trim().to_string())
1929                    .filter(|s| !s.is_empty())
1930                    .collect();
1931                // If roles are specified, page is implicitly protected
1932                if !directives.roles.is_empty() {
1933                    directives.protected = true;
1934                }
1935            }
1936            "exclude" => directives.exclude = value != "false",
1937            "title" => directives.title = Some(value.clone()),
1938            "redirect" => directives.redirect = Some(value.clone()),
1939            "layout" => directives.layout = Some(value.clone()),
1940            "cache" | "cache-ttl" => {
1941                directives.cache_ttl = value.parse().ok();
1942            }
1943            _ => {
1944                // Handle header.* keys
1945                if let Some(header_name) = key.strip_prefix("header.") {
1946                    directives
1947                        .headers
1948                        .insert(header_name.to_string(), value.clone());
1949                } else {
1950                    warn_access_directive_near_miss(key);
1951                    directives.custom.insert(key.clone(), value.clone());
1952                }
1953            }
1954        }
1955    }
1956
1957    // Parse boolean attributes (no value)
1958    // Find words that aren't part of key="value" pairs
1959    let without_attrs = ATTR_REGEX.replace_all(attrs_str, "");
1960    for cap in BOOL_ATTR_REGEX.captures_iter(&without_attrs) {
1961        let key = &cap[1];
1962        match key {
1963            "protected" => directives.protected = true,
1964            "exclude" => directives.exclude = true,
1965            _ => {}
1966        }
1967    }
1968}
1969
1970/// Reserved directive keys — these are NOT inline variables
1971fn is_reserved_directive(key: &str) -> bool {
1972    matches!(
1973        key,
1974        "auth"
1975            | "protected"
1976            | "roles"
1977            | "exclude"
1978            | "title"
1979            | "redirect"
1980            | "layout"
1981            | "cache"
1982            | "cache-ttl"
1983            | "method"
1984            | "paginate"
1985    ) || key.starts_with("fetch.")
1986        || key.starts_with("session.")
1987        || key.starts_with("compute.")
1988        || key.starts_with("set.")
1989        || key.starts_with("data.")
1990        || key.starts_with("header.")
1991        || key.starts_with("mutation.")
1992}
1993
1994/// True when `a` and `b` are within one edit of each other: a single
1995/// substitution, insertion, deletion, or adjacent transposition.
1996fn within_one_edit(a: &str, b: &str) -> bool {
1997    if a == b {
1998        return true;
1999    }
2000    let a: Vec<char> = a.chars().collect();
2001    let b: Vec<char> = b.chars().collect();
2002    if a.len().abs_diff(b.len()) > 1 {
2003        return false;
2004    }
2005    if a.len() == b.len() {
2006        let diffs: Vec<usize> = (0..a.len()).filter(|&i| a[i] != b[i]).collect();
2007        match diffs.len() {
2008            1 => true,
2009            2 => {
2010                diffs[1] == diffs[0] + 1
2011                    && a[diffs[0]] == b[diffs[1]]
2012                    && a[diffs[1]] == b[diffs[0]]
2013            }
2014            _ => false,
2015        }
2016    } else {
2017        let (short, long) = if a.len() < b.len() { (&a, &b) } else { (&b, &a) };
2018        let mut i = 0;
2019        let mut j = 0;
2020        let mut skipped = false;
2021        while i < short.len() && j < long.len() {
2022            if short[i] == long[j] {
2023                i += 1;
2024                j += 1;
2025            } else if skipped {
2026                return false;
2027            } else {
2028                skipped = true;
2029                j += 1;
2030            }
2031        }
2032        true
2033    }
2034}
2035
2036/// Detect a likely misspelling of an access-control directive key.
2037///
2038/// An unknown `<what>` key falls through to "inline variable", and
2039/// `AuthLevel` defaults to `All` — so `auht: user` would silently leave the
2040/// page PUBLIC. Only the access-control keys get fuzzy matching: a typo'd
2041/// `title` or `layout` breaks visibly, but a typo'd `auth` fails open with
2042/// no symptom. Requires a matching first letter so common real variable
2043/// names near these words (e.g. `oauth`) don't trip it.
2044fn access_directive_near_miss(key: &str) -> Option<&'static str> {
2045    const ACCESS_KEYS: [&str; 3] = ["auth", "protected", "roles"];
2046    let lower = key.to_lowercase();
2047    // Compare the ORIGINAL key for the exact-match exclusion: `Auth` is a
2048    // case typo worth flagging, only a byte-exact `auth` parses as the
2049    // real directive.
2050    ACCESS_KEYS.into_iter().find(|reserved| {
2051        key != *reserved
2052            && lower.chars().next() == reserved.chars().next()
2053            && within_one_edit(&lower, reserved)
2054    })
2055}
2056
2057/// Keys already reported as access-directive near-misses (warn once per key
2058/// per process — directives re-parse on every request).
2059static WARNED_NEAR_MISSES: LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
2060    LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
2061
2062/// Warn loudly (all modes, not just dev) when a `<what>` key looks like a
2063/// misspelled access-control directive. The page renders unchanged — this
2064/// must never fail closed on a false positive — but silence here means a
2065/// world-readable page the author believes is protected.
2066fn warn_access_directive_near_miss(key: &str) {
2067    if let Some(reserved) = access_directive_near_miss(key) {
2068        let mut warned = WARNED_NEAR_MISSES.lock().unwrap();
2069        if warned.insert(key.to_string()) {
2070            tracing::error!(
2071                "<what> key '{}' looks like a misspelling of the '{}' access-control directive. \
2072                 It was treated as an inline variable, so NO access restriction was applied to this page. \
2073                 If you meant '{}', fix the spelling; if it is a real variable, rename it.",
2074                key,
2075                reserved,
2076                reserved
2077            );
2078        }
2079    }
2080}
2081
2082/// Parse inner content of <what> tag (YAML-like syntax)
2083fn parse_directive_content(content: &str, directives: &mut PageDirectives) {
2084    let mut current_section: Option<String> = None;
2085
2086    // Multi-line JSON accumulation state
2087    let mut json_key: Option<String> = None;
2088    let mut json_buf = String::new();
2089    let mut json_depth: usize = 0;
2090    let mut json_bracket: char = ' '; // '[' or '{'
2091
2092    let lines: Vec<&str> = content.lines().collect();
2093    let mut i = 0;
2094
2095    while i < lines.len() {
2096        let raw_line = lines[i];
2097        let trimmed = raw_line.trim();
2098        i += 1;
2099
2100        // If we're accumulating a multi-line JSON value, keep appending
2101        if json_key.is_some() {
2102            json_buf.push('\n');
2103            json_buf.push_str(trimmed);
2104            let close_char = if json_bracket == '[' { ']' } else { '}' };
2105            json_depth += trimmed.matches(json_bracket).count();
2106            json_depth -= trimmed.matches(close_char).count();
2107            if json_depth == 0 {
2108                // Done accumulating — parse the JSON
2109                let key = json_key.take().unwrap();
2110                let relaxed = relax_json(&json_buf);
2111                match serde_json::from_str::<Value>(&relaxed) {
2112                    Ok(val) => {
2113                        directives.vars.insert(key, val);
2114                    }
2115                    Err(e) => {
2116                        tracing::warn!("Invalid JSON for inline var: {}", e);
2117                    }
2118                }
2119                json_buf.clear();
2120            }
2121            continue;
2122        }
2123
2124        if trimmed.is_empty() {
2125            continue;
2126        }
2127
2128        // Section header: [name] sets prefix, [] resets to root
2129        if trimmed.starts_with('[') && trimmed.ends_with(']') {
2130            let inner = trimmed[1..trimmed.len() - 1].trim();
2131            if inner.is_empty() {
2132                current_section = None;
2133            } else {
2134                current_section = Some(inner.to_lowercase().to_string());
2135            }
2136            continue;
2137        }
2138
2139        // Apply section prefix to raw line (syntactic sugar for flat dotted keys)
2140        let line_owned;
2141        let line: &str = if let Some(ref section) = current_section {
2142            line_owned = format!("{}.{}", section, trimmed);
2143            line_owned.trim()
2144        } else {
2145            trimmed
2146        };
2147
2148        // Check for session mutation: session.key += value or session.key = value
2149        if line.starts_with("session.") {
2150            if let Some(mutation) = parse_session_mutation(line) {
2151                directives.session_mutations.push(mutation);
2152            }
2153            continue;
2154        }
2155
2156        // Check for computed variable: compute.name = "interpolated #var# string"
2157        if line.starts_with("compute.") {
2158            if let Some(eq_pos) = line.find('=') {
2159                let name = line[8..eq_pos].trim().to_string(); // Skip "compute."
2160                let value = line[eq_pos + 1..].trim();
2161                // Remove surrounding quotes if present (one symmetric pair only)
2162                let value = strip_symmetric_quotes(value).0.to_string();
2163                directives.computed.push((name, value));
2164            }
2165            continue;
2166        }
2167
2168        // Check for boolean directive (just a word)
2169        if !line.contains(':') && !line.contains('=') {
2170            match line.to_lowercase().as_str() {
2171                "protected" => directives.protected = true,
2172                "exclude" => directives.exclude = true,
2173                _ => {}
2174            }
2175            continue;
2176        }
2177
2178        // Parse key: value or key = value
2179        // Prefer '=' when it appears before ':' (handles URLs like https://...)
2180        let colon_idx = line.find(':');
2181        let equals_idx = line.find('=');
2182        let (key, value) = match (colon_idx, equals_idx) {
2183            (Some(c), Some(e)) => {
2184                if e < c {
2185                    (&line[..e], line[e + 1..].trim())
2186                } else {
2187                    (&line[..c], line[c + 1..].trim())
2188                }
2189            }
2190            (Some(c), None) => (&line[..c], line[c + 1..].trim()),
2191            (None, Some(e)) => (&line[..e], line[e + 1..].trim()),
2192            _ => continue,
2193        };
2194
2195        let key = key.trim().to_lowercase();
2196        let (value, value_was_quoted) = strip_symmetric_quotes(value);
2197        if !value_was_quoted
2198            && value.len() >= 2
2199            && (value.starts_with('"')
2200                || value.starts_with('\'')
2201                || value.ends_with('"')
2202                || value.ends_with('\''))
2203        {
2204            tracing::warn!(
2205                "Mismatched quotes in <what> block value for '{}': {} — use one matching pair, e.g. \"value\"",
2206                key,
2207                value
2208            );
2209        }
2210
2211        match key.as_str() {
2212            "auth" => {
2213                directives.auth = parse_auth_level(value);
2214            }
2215            // Legacy support
2216            "protected" => directives.protected = value != "false",
2217            "roles" => {
2218                directives.roles = value
2219                    .split(',')
2220                    .map(|s| s.trim().to_string())
2221                    .filter(|s| !s.is_empty())
2222                    .collect();
2223                if !directives.roles.is_empty() {
2224                    directives.protected = true;
2225                }
2226            }
2227            "exclude" => directives.exclude = value != "false",
2228            "title" => {
2229                if !value_was_quoted && is_unquoted_string(value) {
2230                    tracing::warn!(
2231                        "Unquoted string in <what> block: title should be quoted, e.g. title: \"{}\"",
2232                        value
2233                    );
2234                }
2235                directives.title = Some(value.to_string());
2236            }
2237            "redirect" => {
2238                if !value_was_quoted && is_unquoted_string(value) {
2239                    tracing::warn!(
2240                        "Unquoted string in <what> block: redirect should be quoted, e.g. redirect: \"{}\"",
2241                        value
2242                    );
2243                }
2244                directives.redirect = Some(value.to_string());
2245            }
2246            "layout" => {
2247                if !value_was_quoted && is_unquoted_string(value) {
2248                    tracing::warn!(
2249                        "Unquoted string in <what> block: layout should be quoted, e.g. layout: \"{}\"",
2250                        value
2251                    );
2252                }
2253                directives.layout = Some(value.to_string());
2254            }
2255            "cache" | "cache-ttl" => {
2256                directives.cache_ttl = value.parse().ok();
2257            }
2258            _ => {
2259                // Handle header.* keys
2260                if let Some(header_name) = key.strip_prefix("header.") {
2261                    directives
2262                        .headers
2263                        .insert(header_name.to_string(), value.to_string());
2264                } else if is_reserved_directive(&key) {
2265                    // Reserved prefixed directives (fetch.*, set.*, data.*, etc.)
2266                    if !value_was_quoted && !value.is_empty() && is_unquoted_string(value) {
2267                        tracing::warn!(
2268                            "Unquoted string in <what> block: {} should be quoted, e.g. {} = \"{}\"",
2269                            key,
2270                            key,
2271                            value
2272                        );
2273                    }
2274                    directives.custom.insert(key, value.to_string());
2275                } else {
2276                    // Non-reserved key — treat as inline variable
2277                    warn_access_directive_near_miss(&key);
2278                    // Check if value starts a multi-line JSON block
2279                    let value_untrimmed = {
2280                        let eq_pos = line.find('=').or_else(|| line.find(':')).unwrap();
2281                        line[eq_pos + 1..].trim()
2282                    };
2283                    if (value_untrimmed.starts_with('[') || value_untrimmed.starts_with('{'))
2284                        && !value_untrimmed.ends_with(']')
2285                        && !value_untrimmed.ends_with('}')
2286                    {
2287                        // Start multi-line JSON accumulation
2288                        json_bracket = value_untrimmed.chars().next().unwrap();
2289                        json_buf = value_untrimmed.to_string();
2290                        json_depth = value_untrimmed.matches(json_bracket).count();
2291                        let close_char = if json_bracket == '[' { ']' } else { '}' };
2292                        json_depth -= value_untrimmed.matches(close_char).count();
2293                        if json_depth == 0 {
2294                            // Single-line JSON that's complete
2295                            let relaxed = relax_json(value_untrimmed);
2296                            match serde_json::from_str::<Value>(&relaxed) {
2297                                Ok(val) => {
2298                                    directives.vars.insert(key, val);
2299                                }
2300                                Err(e) => {
2301                                    tracing::warn!("Invalid JSON for inline var '{}': {}", key, e);
2302                                }
2303                            }
2304                            json_buf.clear();
2305                        } else {
2306                            json_key = Some(key);
2307                        }
2308                    } else if value_untrimmed.starts_with('[') || value_untrimmed.starts_with('{') {
2309                        // Single-line JSON (complete on one line)
2310                        let relaxed = relax_json(value_untrimmed);
2311                        match serde_json::from_str::<Value>(&relaxed) {
2312                            Ok(val) => {
2313                                directives.vars.insert(key, val);
2314                            }
2315                            Err(e) => {
2316                                tracing::warn!("Invalid JSON for inline var '{}': {}", key, e);
2317                            }
2318                        }
2319                    } else {
2320                        // Scalar inline variable — quoting forces string type
2321                        // (`zip = "01234"` stays "01234"; unquoted values are
2322                        // type-inferred as before).
2323                        let parsed = if value_was_quoted {
2324                            Value::String(value.to_string())
2325                        } else {
2326                            parse_inline_value(value)
2327                        };
2328                        // Warn if string value is unquoted (numbers/bools are fine)
2329                        if !value_was_quoted && is_unquoted_string(value) {
2330                            tracing::warn!(
2331                                "Unquoted string in <what> block: {} should be quoted, e.g. {} = \"{}\"",
2332                                key,
2333                                key,
2334                                value
2335                            );
2336                        }
2337                        directives.vars.insert(key, parsed);
2338                    }
2339                }
2340            }
2341        }
2342    }
2343}
2344
2345/// Strip exactly one symmetric pair of matching quotes.
2346/// Returns (stripped_value, was_quoted). Mismatched, unterminated, or nested
2347/// quotes are left intact so author mistakes stay visible instead of being
2348/// silently swallowed (the old `trim_matches` stripped repeated AND mismatched
2349/// quote characters).
2350pub(crate) fn strip_symmetric_quotes(s: &str) -> (&str, bool) {
2351    let bytes = s.as_bytes();
2352    if bytes.len() >= 2 {
2353        let first = bytes[0];
2354        if (first == b'"' || first == b'\'') && bytes[bytes.len() - 1] == first {
2355            return (&s[1..s.len() - 1], true);
2356        }
2357    }
2358    (s, false)
2359}
2360
2361/// Check if a value is a string that should have been quoted.
2362/// Returns false for numbers, booleans, and known keywords.
2363fn is_unquoted_string(value: &str) -> bool {
2364    if value.is_empty() {
2365        return false;
2366    }
2367    if value.parse::<f64>().is_ok() {
2368        return false;
2369    }
2370    match value.to_lowercase().as_str() {
2371        "true" | "false" | "none" | "all" | "user" => false,
2372        _ => true,
2373    }
2374}
2375
2376/// Relax JSON: quote unquoted object keys so serde_json can parse it.
2377/// Turns `{ name: "Widget" }` into `{ "name": "Widget" }`.
2378fn relax_json(s: &str) -> String {
2379    static UNQUOTED_KEY: LazyLock<Regex> =
2380        LazyLock::new(|| Regex::new(r#"(?m)([{\[,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:"#).unwrap());
2381    UNQUOTED_KEY.replace_all(s, r#"$1 "$2":"#).into_owned()
2382}
2383
2384/// Parse a scalar inline value to a typed JSON Value
2385fn parse_inline_value(s: &str) -> Value {
2386    // Try integer
2387    if let Ok(n) = s.parse::<i64>() {
2388        return json!(n);
2389    }
2390    // Try float
2391    if let Ok(n) = s.parse::<f64>() {
2392        return json!(n);
2393    }
2394    // Booleans
2395    if s == "true" {
2396        return json!(true);
2397    }
2398    if s == "false" {
2399        return json!(false);
2400    }
2401    // String (already had quotes stripped)
2402    json!(s)
2403}
2404
2405/// Parse session mutation from line like "session.counter += 1" or "session.name = value"
2406fn parse_session_mutation(line: &str) -> Option<SessionMutation> {
2407    // Remove "session." prefix
2408    let rest = line.strip_prefix("session.")?;
2409
2410    // Check for array operations: key.pushmax(N, value), key.push(value), key.unshift(value), key.clear()
2411    // pushmax must be checked before push since ".push(" is a prefix of ".pushmax("
2412    if let Some(idx) = rest.find(".pushmax(") {
2413        let key = rest[..idx].trim().to_string();
2414        let value_start = idx + 9; // len of ".pushmax("
2415        let value_end = rest.rfind(')')?;
2416        let inner = rest[value_start..value_end].trim();
2417        // Parse "N, value" — first arg is max size, second is the value
2418        if let Some(comma) = inner.find(',') {
2419            let max_str = inner[..comma].trim();
2420            let (value_str, was_quoted) = strip_symmetric_quotes(inner[comma + 1..].trim());
2421            if let Ok(max) = max_str.parse::<usize>() {
2422                let value = parse_mutation_value(value_str, was_quoted);
2423                return Some(SessionMutation::PushMax { key, max, value });
2424            }
2425        }
2426    }
2427
2428    if let Some(idx) = rest.find(".push(") {
2429        let key = rest[..idx].trim().to_string();
2430        let value_start = idx + 6; // len of ".push("
2431        let value_end = rest.rfind(')')?;
2432        let (value_str, was_quoted) = strip_symmetric_quotes(rest[value_start..value_end].trim());
2433        let value = parse_mutation_value(value_str, was_quoted);
2434        return Some(SessionMutation::Push { key, value });
2435    }
2436
2437    if let Some(idx) = rest.find(".unshift(") {
2438        let key = rest[..idx].trim().to_string();
2439        let value_start = idx + 9; // len of ".unshift("
2440        let value_end = rest.rfind(')')?;
2441        let (value_str, was_quoted) = strip_symmetric_quotes(rest[value_start..value_end].trim());
2442        let value = parse_mutation_value(value_str, was_quoted);
2443        return Some(SessionMutation::Unshift { key, value });
2444    }
2445
2446    if let Some(idx) = rest.find(".clear()") {
2447        let key = rest[..idx].trim().to_string();
2448        return Some(SessionMutation::Clear { key });
2449    }
2450
2451    // Check for increment: key += value
2452    if let Some(idx) = rest.find("+=") {
2453        let key = rest[..idx].trim().to_string();
2454        let value_str = rest[idx + 2..].trim();
2455        let value: i64 = value_str.parse().ok()?;
2456        return Some(SessionMutation::Increment { key, value });
2457    }
2458
2459    // Check for decrement: key -= value (convert to negative increment)
2460    if let Some(idx) = rest.find("-=") {
2461        let key = rest[..idx].trim().to_string();
2462        let value_str = rest[idx + 2..].trim();
2463        let value: i64 = value_str.parse().ok()?;
2464        return Some(SessionMutation::Increment { key, value: -value });
2465    }
2466
2467    // Check for assignment: key = value
2468    if let Some(idx) = rest.find('=') {
2469        let key = rest[..idx].trim().to_string();
2470        let (value_str, was_quoted) = strip_symmetric_quotes(rest[idx + 1..].trim());
2471        let value = parse_mutation_value(value_str, was_quoted);
2472        return Some(SessionMutation::Set { key, value });
2473    }
2474
2475    None
2476}
2477
2478/// Parse a mutation value — quoting forces string type, unquoted values are
2479/// type-inferred (`session.zip = "01234"` stays "01234"; `session.count = 42`
2480/// is a number).
2481fn parse_mutation_value(value_str: &str, was_quoted: bool) -> Value {
2482    if was_quoted {
2483        json!(value_str)
2484    } else {
2485        parse_value_str(value_str)
2486    }
2487}
2488
2489/// Parse a value string into a JSON Value
2490fn parse_value_str(value_str: &str) -> Value {
2491    // Check for empty array
2492    if value_str == "[]" {
2493        return json!([]);
2494    }
2495    // Try to parse as number first, then boolean, then string
2496    if let Ok(n) = value_str.parse::<i64>() {
2497        json!(n)
2498    } else if let Ok(f) = value_str.parse::<f64>() {
2499        json!(f)
2500    } else if value_str == "true" {
2501        json!(true)
2502    } else if value_str == "false" {
2503        json!(false)
2504    } else {
2505        json!(value_str)
2506    }
2507}
2508
2509#[cfg(test)]
2510mod tests {
2511    use super::*;
2512
2513    #[test]
2514    fn test_parse_attributes() {
2515        let attrs = parse_attributes(r#"title="Hello" size="large""#);
2516        assert_eq!(attrs.get("title"), Some(&"Hello".to_string()));
2517        assert_eq!(attrs.get("size"), Some(&"large".to_string()));
2518    }
2519
2520    #[test]
2521    fn test_replace_variables() {
2522        let mut context = HashMap::new();
2523        context.insert("name".to_string(), serde_json::json!("World"));
2524
2525        let result = replace_variables("Hello #name#!", &context);
2526        assert_eq!(result, "Hello World!");
2527    }
2528
2529    #[test]
2530    fn test_nested_variables() {
2531        let mut context = HashMap::new();
2532        context.insert(
2533            "user".to_string(),
2534            serde_json::json!({
2535                "name": "Alice",
2536                "email": "alice@example.com"
2537            }),
2538        );
2539
2540        let result = replace_variables("#user.name# (#user.email#)", &context);
2541        assert_eq!(result, "Alice (alice@example.com)");
2542    }
2543
2544    #[test]
2545    fn test_env_variables() {
2546        // Set a test environment variable
2547        unsafe {
2548            std::env::set_var("WHAT_TEST_VAR", "test_value");
2549        }
2550
2551        let context = HashMap::new();
2552        let result = replace_variables("Value: #env.WHAT_TEST_VAR#", &context);
2553        assert_eq!(result, "Value: test_value");
2554
2555        // Clean up
2556        unsafe {
2557            std::env::remove_var("WHAT_TEST_VAR");
2558        }
2559    }
2560
2561    #[test]
2562    fn test_env_variable_not_found() {
2563        let context = HashMap::new();
2564        let result = replace_variables("#env.NONEXISTENT_VAR_12345#", &context);
2565        assert_eq!(result, ""); // Returns empty string for missing env vars
2566    }
2567
2568    #[test]
2569    fn test_page_directives_self_closing() {
2570        let content = r#"<what protected roles="admin,editor" />
2571<!DOCTYPE html>
2572<html>
2573<body>Hello</body>
2574</html>"#;
2575
2576        let (directives, cleaned) = parse_page_directives(content);
2577
2578        assert!(directives.protected);
2579        assert_eq!(directives.roles, vec!["admin", "editor"]);
2580        assert!(cleaned.contains("<!DOCTYPE html>"));
2581        assert!(!cleaned.contains("<what"));
2582    }
2583
2584    #[test]
2585    fn test_page_directives_boolean() {
2586        let content = r#"<what protected exclude />
2587<html></html>"#;
2588
2589        let (directives, cleaned) = parse_page_directives(content);
2590
2591        assert!(directives.protected);
2592        assert!(directives.exclude);
2593        assert!(!cleaned.contains("<what"));
2594    }
2595
2596    #[test]
2597    fn test_page_directives_content_syntax() {
2598        let content = r#"<what>
2599protected
2600roles: admin, manager
2601title: Admin Dashboard
2602</what>
2603<!DOCTYPE html>
2604<html></html>"#;
2605
2606        let (directives, cleaned) = parse_page_directives(content);
2607
2608        assert!(directives.protected);
2609        assert_eq!(directives.roles, vec!["admin", "manager"]);
2610        assert_eq!(directives.title, Some("Admin Dashboard".to_string()));
2611        assert!(cleaned.contains("<!DOCTYPE html>"));
2612    }
2613
2614    #[test]
2615    fn test_page_directives_roles_imply_protected() {
2616        let content = r#"<what roles="admin" />
2617<html></html>"#;
2618
2619        let (directives, _) = parse_page_directives(content);
2620
2621        assert!(directives.protected); // Implied by roles
2622        assert_eq!(directives.roles, vec!["admin"]);
2623    }
2624
2625    #[test]
2626    fn test_no_directives() {
2627        let content = r#"<!DOCTYPE html>
2628<html><body>Hello</body></html>"#;
2629
2630        let (directives, cleaned) = parse_page_directives(content);
2631
2632        assert!(!directives.protected);
2633        assert!(directives.roles.is_empty());
2634        assert_eq!(content, cleaned);
2635    }
2636
2637    #[test]
2638    fn test_page_tag_preserved() {
2639        // Ensure parse_page_directives doesn't touch <page> tags
2640        let content = r#"<page title="Test">
2641  <what-nav active="home"/>
2642  <main>Content</main>
2643</page>"#;
2644
2645        let (_, cleaned) = parse_page_directives(content);
2646        println!("Cleaned content: '{}'", cleaned);
2647
2648        assert!(cleaned.contains("<page"), "Should preserve <page> tag");
2649        assert!(cleaned.contains("</page>"), "Should preserve </page> tag");
2650        assert!(
2651            cleaned.contains("<what-nav"),
2652            "Should preserve <what-nav> tag"
2653        );
2654        assert_eq!(content, cleaned, "Content should be unchanged");
2655    }
2656
2657    #[test]
2658    fn test_auth_all() {
2659        let content = r#"<what auth="all" />
2660<html></html>"#;
2661
2662        let (directives, _) = parse_page_directives(content);
2663
2664        assert_eq!(directives.auth, AuthLevel::All);
2665        assert!(!directives.requires_auth());
2666    }
2667
2668    #[test]
2669    fn test_auth_user() {
2670        let content = r#"<what auth="user" />
2671<html></html>"#;
2672
2673        let (directives, _) = parse_page_directives(content);
2674
2675        assert_eq!(directives.auth, AuthLevel::User);
2676        assert!(directives.requires_auth());
2677        assert!(directives.check_access(true, &[]));
2678        assert!(!directives.check_access(false, &[]));
2679    }
2680
2681    #[test]
2682    fn test_auth_roles() {
2683        let content = r#"<what auth="admin, editor" />
2684<html></html>"#;
2685
2686        let (directives, _) = parse_page_directives(content);
2687
2688        assert_eq!(
2689            directives.auth,
2690            AuthLevel::Roles(vec!["admin".to_string(), "editor".to_string()])
2691        );
2692        assert!(directives.requires_auth());
2693
2694        // Has admin role
2695        assert!(directives.check_access(true, &["admin".to_string()]));
2696        // Has editor role
2697        assert!(directives.check_access(true, &["editor".to_string()]));
2698        // Has neither role
2699        assert!(!directives.check_access(true, &["viewer".to_string()]));
2700        // Not authenticated
2701        assert!(!directives.check_access(false, &["admin".to_string()]));
2702    }
2703
2704    #[test]
2705    fn test_auth_content_syntax() {
2706        let content = r#"<what>
2707auth: admin, manager
2708title: Dashboard
2709</what>
2710<html></html>"#;
2711
2712        let (directives, _) = parse_page_directives(content);
2713
2714        assert_eq!(
2715            directives.auth,
2716            AuthLevel::Roles(vec!["admin".to_string(), "manager".to_string()])
2717        );
2718        assert_eq!(directives.title, Some("Dashboard".to_string()));
2719    }
2720
2721    #[test]
2722    fn test_auth_public_aliases() {
2723        // Test "public" alias
2724        let (d1, _) = parse_page_directives(r#"<what auth="public" /><html></html>"#);
2725        assert_eq!(d1.auth, AuthLevel::All);
2726
2727        // Test "none" alias
2728        let (d2, _) = parse_page_directives(r#"<what auth="none" /><html></html>"#);
2729        assert_eq!(d2.auth, AuthLevel::All);
2730
2731        // Test "authenticated" alias
2732        let (d3, _) = parse_page_directives(r#"<what auth="authenticated" /><html></html>"#);
2733        assert_eq!(d3.auth, AuthLevel::User);
2734    }
2735
2736    // =========================================================================
2737    // Fetch Directive Parsing Tests
2738    // =========================================================================
2739
2740    #[test]
2741    fn test_fetch_directive_url_with_equals() {
2742        let content = r#"<what>
2743title: Remote Data
2744fetch.dog_facts = "https://dogapi.dog/api/v2/facts?limit=3"
2745</what>
2746<html></html>"#;
2747
2748        let (directives, _) = parse_page_directives(content);
2749
2750        assert_eq!(directives.title, Some("Remote Data".to_string()));
2751        assert_eq!(
2752            directives.custom.get("fetch.dog_facts"),
2753            Some(&"https://dogapi.dog/api/v2/facts?limit=3".to_string()),
2754            "fetch URL should be parsed correctly with = delimiter"
2755        );
2756    }
2757
2758    #[test]
2759    fn test_fetch_directive_url_with_multiple_equals() {
2760        // URLs with query params containing = signs
2761        let content = r#"<what>
2762fetch.dog_breeds = "https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6"
2763</what>
2764<html></html>"#;
2765
2766        let (directives, _) = parse_page_directives(content);
2767
2768        assert_eq!(
2769            directives.custom.get("fetch.dog_breeds"),
2770            Some(&"https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6".to_string()),
2771            "URL with multiple = in query params should be preserved"
2772        );
2773    }
2774
2775    #[test]
2776    fn test_fetch_directive_full_remote_data_page() {
2777        // Exact <what> block from remote-data.html
2778        let content = r#"<what>
2779title: Remote Data
2780page: remote-data
2781fetch.dog_facts = "https://dogapi.dog/api/v2/facts?limit=3"
2782fetch.dog_breeds = "https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6"
2783fetch.dog_images = "https://dog.ceo/api/breeds/image/random/4"
2784</what>
2785<html></html>"#;
2786
2787        let (directives, cleaned) = parse_page_directives(content);
2788
2789        assert_eq!(directives.title, Some("Remote Data".to_string()));
2790        assert_eq!(directives.vars.get("page"), Some(&json!("remote-data")));
2791        assert_eq!(
2792            directives.custom.get("fetch.dog_facts"),
2793            Some(&"https://dogapi.dog/api/v2/facts?limit=3".to_string())
2794        );
2795        assert_eq!(
2796            directives.custom.get("fetch.dog_breeds"),
2797            Some(&"https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6".to_string())
2798        );
2799        assert_eq!(
2800            directives.custom.get("fetch.dog_images"),
2801            Some(&"https://dog.ceo/api/breeds/image/random/4".to_string())
2802        );
2803        assert!(!cleaned.contains("<what>"));
2804    }
2805
2806    // =========================================================================
2807    // Section Header Tests
2808    // =========================================================================
2809
2810    #[test]
2811    fn test_section_header_fetch() {
2812        let content = r##"<what>
2813title: Dashboard
2814[fetch.weather]
2815url = "https://api.weather.com/current"
2816method = "GET"
2817headers = "Authorization: Bearer abc123"
2818path = "data.current"
2819limit = 5
2820</what>
2821<html></html>"##;
2822
2823        let (directives, _) = parse_page_directives(content);
2824        assert_eq!(directives.title, Some("Dashboard".to_string()));
2825        assert_eq!(
2826            directives.custom.get("fetch.weather.url"),
2827            Some(&"https://api.weather.com/current".to_string())
2828        );
2829        assert_eq!(
2830            directives.custom.get("fetch.weather.method"),
2831            Some(&"GET".to_string())
2832        );
2833        assert_eq!(
2834            directives.custom.get("fetch.weather.headers"),
2835            Some(&"Authorization: Bearer abc123".to_string())
2836        );
2837        assert_eq!(
2838            directives.custom.get("fetch.weather.path"),
2839            Some(&"data.current".to_string())
2840        );
2841        assert_eq!(
2842            directives.custom.get("fetch.weather.limit"),
2843            Some(&"5".to_string())
2844        );
2845    }
2846
2847    #[test]
2848    fn test_section_header_og() {
2849        let content = r##"<what>
2850[og]
2851title: My Dashboard
2852description: Real-time weather data
2853image: /images/dashboard-og.png
2854</what>
2855<html></html>"##;
2856
2857        let (directives, _) = parse_page_directives(content);
2858        assert_eq!(
2859            directives.vars.get("og.title"),
2860            Some(&json!("My Dashboard"))
2861        );
2862        assert_eq!(
2863            directives.vars.get("og.description"),
2864            Some(&json!("Real-time weather data"))
2865        );
2866        assert_eq!(
2867            directives.vars.get("og.image"),
2868            Some(&json!("/images/dashboard-og.png"))
2869        );
2870    }
2871
2872    #[test]
2873    fn test_section_header_session() {
2874        let content = r##"<what>
2875[session]
2876visit_count += 1
2877theme = "dark"
2878</what>
2879<html></html>"##;
2880
2881        let (directives, _) = parse_page_directives(content);
2882        assert_eq!(directives.session_mutations.len(), 2);
2883    }
2884
2885    #[test]
2886    fn test_section_header_compute() {
2887        let content = r##"<what>
2888[compute]
2889greeting = "Hello, #user.full_name#!"
2890</what>
2891<html></html>"##;
2892
2893        let (directives, _) = parse_page_directives(content);
2894        assert_eq!(directives.computed.len(), 1);
2895        assert_eq!(directives.computed[0].0, "greeting");
2896        assert_eq!(directives.computed[0].1, "Hello, #user.full_name#!");
2897    }
2898
2899    #[test]
2900    fn test_section_header_reset() {
2901        let content = r##"<what>
2902[fetch.weather]
2903url = "https://api.weather.com/current"
2904
2905[]
2906session.visit_count += 1
2907compute.greeting = "Hello!"
2908</what>
2909<html></html>"##;
2910
2911        let (directives, _) = parse_page_directives(content);
2912        assert_eq!(
2913            directives.custom.get("fetch.weather.url"),
2914            Some(&"https://api.weather.com/current".to_string())
2915        );
2916        assert_eq!(directives.session_mutations.len(), 1);
2917        assert_eq!(directives.computed.len(), 1);
2918    }
2919
2920    #[test]
2921    fn test_section_header_mixed() {
2922        // Flat syntax and section syntax in the same block
2923        let content = r##"<what>
2924title: Dashboard
2925auth: user
2926fetch.legacy = "https://old-api.com/data"
2927
2928[fetch.weather]
2929url = "https://api.weather.com/current"
2930method = "POST"
2931
2932[]
2933session.count += 1
2934</what>
2935<html></html>"##;
2936
2937        let (directives, _) = parse_page_directives(content);
2938        assert_eq!(directives.title, Some("Dashboard".to_string()));
2939        assert_eq!(
2940            directives.custom.get("fetch.legacy"),
2941            Some(&"https://old-api.com/data".to_string())
2942        );
2943        assert_eq!(
2944            directives.custom.get("fetch.weather.url"),
2945            Some(&"https://api.weather.com/current".to_string())
2946        );
2947        assert_eq!(
2948            directives.custom.get("fetch.weather.method"),
2949            Some(&"POST".to_string())
2950        );
2951        assert_eq!(directives.session_mutations.len(), 1);
2952    }
2953
2954    #[test]
2955    fn test_section_header_multiple_fetch() {
2956        let content = r##"<what>
2957[fetch.weather]
2958url = "https://api.weather.com/current"
2959path = "data.current"
2960
2961[fetch.news]
2962url = "https://api.news.com/latest"
2963path = "articles"
2964limit = 10
2965</what>
2966<html></html>"##;
2967
2968        let (directives, _) = parse_page_directives(content);
2969        assert_eq!(
2970            directives.custom.get("fetch.weather.url"),
2971            Some(&"https://api.weather.com/current".to_string())
2972        );
2973        assert_eq!(
2974            directives.custom.get("fetch.weather.path"),
2975            Some(&"data.current".to_string())
2976        );
2977        assert_eq!(
2978            directives.custom.get("fetch.news.url"),
2979            Some(&"https://api.news.com/latest".to_string())
2980        );
2981        assert_eq!(
2982            directives.custom.get("fetch.news.path"),
2983            Some(&"articles".to_string())
2984        );
2985        assert_eq!(
2986            directives.custom.get("fetch.news.limit"),
2987            Some(&"10".to_string())
2988        );
2989    }
2990
2991    #[test]
2992    fn test_section_header_backward_compat() {
2993        // Old flat syntax must still work identically
2994        let content = r##"<what>
2995title: Remote Data
2996fetch.dogs = "https://dogapi.dog/api/v2/facts?limit=3"
2997fetch.dogs.path = "data"
2998session.count += 1
2999compute.greeting = "Hello!"
3000og.title: My Page
3001</what>
3002<html></html>"##;
3003
3004        let (directives, _) = parse_page_directives(content);
3005        assert_eq!(directives.title, Some("Remote Data".to_string()));
3006        assert_eq!(
3007            directives.custom.get("fetch.dogs"),
3008            Some(&"https://dogapi.dog/api/v2/facts?limit=3".to_string())
3009        );
3010        assert_eq!(
3011            directives.custom.get("fetch.dogs.path"),
3012            Some(&"data".to_string())
3013        );
3014        assert_eq!(directives.session_mutations.len(), 1);
3015        assert_eq!(directives.computed.len(), 1);
3016        assert_eq!(directives.vars.get("og.title"), Some(&json!("My Page")));
3017    }
3018
3019    // =========================================================================
3020    // Inline Variable Tests
3021    // =========================================================================
3022
3023    #[test]
3024    fn inline_var_string() {
3025        let content = r#"<what>
3026title = "My Page"
3027subtitle = "Welcome"
3028</what>
3029<html></html>"#;
3030        let (directives, _) = parse_page_directives(content);
3031        // title is a reserved directive
3032        assert_eq!(directives.title, Some("My Page".to_string()));
3033        // subtitle is an inline variable
3034        assert_eq!(directives.vars.get("subtitle"), Some(&json!("Welcome")));
3035    }
3036
3037    #[test]
3038    fn inline_var_number() {
3039        let content = r#"<what>
3040count = 42
3041price = 9.99
3042</what>
3043<html></html>"#;
3044        let (directives, _) = parse_page_directives(content);
3045        assert_eq!(directives.vars.get("count"), Some(&json!(42)));
3046        assert_eq!(directives.vars.get("price"), Some(&json!(9.99)));
3047    }
3048
3049    #[test]
3050    fn inline_var_boolean() {
3051        let content = r#"<what>
3052show_banner = true
3053debug = false
3054</what>
3055<html></html>"#;
3056        let (directives, _) = parse_page_directives(content);
3057        assert_eq!(directives.vars.get("show_banner"), Some(&json!(true)));
3058        assert_eq!(directives.vars.get("debug"), Some(&json!(false)));
3059    }
3060
3061    #[test]
3062    fn inline_var_single_line_array() {
3063        let content = r#"<what>
3064colors = ["red", "green", "blue"]
3065</what>
3066<html></html>"#;
3067        let (directives, _) = parse_page_directives(content);
3068        assert_eq!(
3069            directives.vars.get("colors"),
3070            Some(&json!(["red", "green", "blue"]))
3071        );
3072    }
3073
3074    #[test]
3075    fn inline_var_multi_line_array() {
3076        let content = r##"<what>
3077products = [
3078  { "name": "Widget", "price": 9.99 },
3079  { "name": "Gadget", "price": 24.99 }
3080]
3081</what>
3082<html></html>"##;
3083        let (directives, _) = parse_page_directives(content);
3084        let products = directives.vars.get("products").unwrap();
3085        assert!(products.is_array());
3086        assert_eq!(products.as_array().unwrap().len(), 2);
3087        assert_eq!(products[0]["name"], json!("Widget"));
3088        assert_eq!(products[1]["price"], json!(24.99));
3089    }
3090
3091    #[test]
3092    fn inline_var_multi_line_object() {
3093        let content = r##"<what>
3094config = {
3095  "theme": "dark",
3096  "sidebar": true
3097}
3098</what>
3099<html></html>"##;
3100        let (directives, _) = parse_page_directives(content);
3101        let config = directives.vars.get("config").unwrap();
3102        assert!(config.is_object());
3103        assert_eq!(config["theme"], json!("dark"));
3104        assert_eq!(config["sidebar"], json!(true));
3105    }
3106
3107    #[test]
3108    fn inline_var_relaxed_json_unquoted_keys() {
3109        let content = r##"<what>
3110products = [
3111  { name: "Widget", price: 9.99 },
3112  { name: "Gadget", price: 24.99 }
3113]
3114</what>
3115<html></html>"##;
3116        let (directives, _) = parse_page_directives(content);
3117        let products = directives.vars.get("products").unwrap();
3118        assert!(products.is_array());
3119        assert_eq!(products[0]["name"], json!("Widget"));
3120        assert_eq!(products[1]["price"], json!(24.99));
3121    }
3122
3123    #[test]
3124    fn inline_var_relaxed_json_single_line() {
3125        let content = r##"<what>
3126item = { name: "Widget", price: 9.99 }
3127</what>
3128<html></html>"##;
3129        let (directives, _) = parse_page_directives(content);
3130        let item = directives.vars.get("item").unwrap();
3131        assert_eq!(item["name"], json!("Widget"));
3132        assert_eq!(item["price"], json!(9.99));
3133    }
3134
3135    #[test]
3136    fn inline_var_does_not_affect_reserved() {
3137        let content = r#"<what>
3138auth = user
3139layout = main.html
3140fetch.api = "https://example.com"
3141my_var = "hello"
3142</what>
3143<html></html>"#;
3144        let (directives, _) = parse_page_directives(content);
3145        // Reserved directives work normally
3146        assert!(directives.requires_auth());
3147        assert_eq!(directives.layout, Some("main.html".to_string()));
3148        assert_eq!(
3149            directives.custom.get("fetch.api"),
3150            Some(&"https://example.com".to_string())
3151        );
3152        // Non-reserved goes to vars
3153        assert_eq!(directives.vars.get("my_var"), Some(&json!("hello")));
3154        // Reserved keys NOT in vars
3155        assert!(directives.vars.get("auth").is_none());
3156        assert!(directives.vars.get("layout").is_none());
3157    }
3158
3159    #[test]
3160    fn inline_var_mixed_with_directives() {
3161        let content = r##"<what>
3162title = "Dashboard"
3163items_per_page = 25
3164nav_items = ["Home", "About", "Contact"]
3165fetch.data = "https://api.example.com/data"
3166compute.greeting = "Hello #user.name#!"
3167</what>
3168<html></html>"##;
3169        let (directives, _) = parse_page_directives(content);
3170        assert_eq!(directives.title, Some("Dashboard".to_string()));
3171        assert_eq!(directives.vars.get("items_per_page"), Some(&json!(25)));
3172        assert_eq!(
3173            directives.vars.get("nav_items"),
3174            Some(&json!(["Home", "About", "Contact"]))
3175        );
3176        assert_eq!(
3177            directives.custom.get("fetch.data"),
3178            Some(&"https://api.example.com/data".to_string())
3179        );
3180        assert_eq!(directives.computed.len(), 1);
3181    }
3182
3183    // =========================================================================
3184    // Named Mutation Block Tests
3185    // =========================================================================
3186
3187    #[test]
3188    fn mutation_stored_as_custom_string() {
3189        let content = r#"<what>
3190mutation.reset = "session.score = 0; session.lives = 3"
3191</what>
3192<html></html>"#;
3193        let (directives, _) = parse_page_directives(content);
3194        assert_eq!(
3195            directives.custom.get("mutation.reset"),
3196            Some(&"session.score = 0; session.lives = 3".to_string())
3197        );
3198        // Should NOT be in vars (not JSON-parsed)
3199        assert!(directives.vars.get("mutation.reset").is_none());
3200    }
3201
3202    #[test]
3203    fn mutation_not_parsed_as_inline_var() {
3204        let content = r#"<what>
3205mutation.toggle = "session.dark_mode = 1"
3206my_var = 42
3207</what>
3208<html></html>"#;
3209        let (directives, _) = parse_page_directives(content);
3210        // mutation goes to custom, my_var goes to vars
3211        assert!(directives.custom.contains_key("mutation.toggle"));
3212        assert_eq!(directives.vars.get("my_var"), Some(&json!(42)));
3213    }
3214
3215    // =========================================================================
3216    // .what File Parser Tests
3217    // =========================================================================
3218
3219    #[test]
3220    fn test_what_file_strings() {
3221        let content = r#"
3222title = "My Application"
3223description = 'Single quotes work too'
3224bare_string = unquoted
3225"#;
3226        let config = parse_what_file(content);
3227
3228        assert_eq!(config.get_string("title"), Some("My Application"));
3229        assert_eq!(
3230            config.get_string("description"),
3231            Some("Single quotes work too")
3232        );
3233        assert_eq!(config.get_string("bare_string"), Some("unquoted"));
3234    }
3235
3236    #[test]
3237    fn test_what_file_numbers() {
3238        let content = r#"
3239port = 8080
3240version = 1.5
3241negative = -42
3242"#;
3243        let config = parse_what_file(content);
3244
3245        assert_eq!(config.get_number("port"), Some(8080.0));
3246        assert_eq!(config.get_number("version"), Some(1.5));
3247        assert_eq!(config.get_number("negative"), Some(-42.0));
3248    }
3249
3250    #[test]
3251    fn test_what_file_booleans() {
3252        let content = r#"
3253debug = true
3254production = false
3255"#;
3256        let config = parse_what_file(content);
3257
3258        assert_eq!(config.get_bool("debug"), Some(true));
3259        assert_eq!(config.get_bool("production"), Some(false));
3260    }
3261
3262    #[test]
3263    fn test_what_file_arrays() {
3264        let content = r#"
3265nav_items = ["Home", "About", "Contact"]
3266numbers = [1, 2, 3]
3267mixed = ["a", 1, true]
3268empty = []
3269"#;
3270        let config = parse_what_file(content);
3271
3272        let nav = config.get_array("nav_items").unwrap();
3273        assert_eq!(nav.len(), 3);
3274        assert_eq!(nav[0].as_str(), Some("Home"));
3275
3276        let nums = config.get_array("numbers").unwrap();
3277        assert_eq!(nums.len(), 3);
3278        assert_eq!(nums[0].as_i64(), Some(1));
3279
3280        let empty = config.get_array("empty").unwrap();
3281        assert!(empty.is_empty());
3282    }
3283
3284    #[test]
3285    fn test_what_file_comments() {
3286        let content = r#"
3287// This is a comment
3288title = "Hello"
3289# This is also a comment
3290name = "World"
3291"#;
3292        let config = parse_what_file(content);
3293
3294        assert_eq!(config.get_string("title"), Some("Hello"));
3295        assert_eq!(config.get_string("name"), Some("World"));
3296        // Comments should not appear as values
3297        assert!(config.values.len() == 2);
3298    }
3299
3300    #[test]
3301    fn test_what_file_auth_directive() {
3302        let content = r#"
3303auth = "admin"
3304title = "Dashboard"
3305"#;
3306        let config = parse_what_file(content);
3307
3308        assert_eq!(
3309            config.directives.auth,
3310            AuthLevel::Roles(vec!["admin".to_string()])
3311        );
3312        assert_eq!(config.directives.title, Some("Dashboard".to_string()));
3313    }
3314
3315    #[test]
3316    fn test_what_file_auth_all() {
3317        let content = r#"
3318auth = "all"
3319"#;
3320        let config = parse_what_file(content);
3321
3322        assert_eq!(config.directives.auth, AuthLevel::All);
3323        assert!(!config.directives.requires_auth());
3324    }
3325
3326    #[test]
3327    fn test_what_file_roles_array() {
3328        let content = r#"
3329roles = ["admin", "editor"]
3330"#;
3331        let config = parse_what_file(content);
3332
3333        assert_eq!(config.directives.roles, vec!["admin", "editor"]);
3334        assert!(config.directives.protected);
3335    }
3336
3337    #[test]
3338    fn test_what_config_merge() {
3339        let content1 = r#"
3340title = "Parent"
3341theme = "light"
3342auth = "all"
3343"#;
3344        let content2 = r#"
3345title = "Child"
3346nav = ["Home"]
3347auth = "admin"
3348"#;
3349        let mut config1 = parse_what_file(content1);
3350        let config2 = parse_what_file(content2);
3351
3352        config1.merge(&config2);
3353
3354        // Child overrides parent
3355        assert_eq!(config1.get_string("title"), Some("Child"));
3356        // Parent value preserved
3357        assert_eq!(config1.get_string("theme"), Some("light"));
3358        // Child adds new value
3359        assert!(config1.get_array("nav").is_some());
3360        // Auth from child takes precedence
3361        assert_eq!(
3362            config1.directives.auth,
3363            AuthLevel::Roles(vec!["admin".to_string()])
3364        );
3365    }
3366
3367    // =========================================================================
3368    // Edge Case Tests
3369    // =========================================================================
3370
3371    #[test]
3372    fn test_auth_level_edge_cases() {
3373        // Empty string should be All
3374        assert_eq!(parse_auth_level(""), AuthLevel::All);
3375
3376        // Whitespace-only should be All
3377        assert_eq!(parse_auth_level("   "), AuthLevel::All);
3378
3379        // Case insensitivity
3380        assert_eq!(parse_auth_level("ALL"), AuthLevel::All);
3381        assert_eq!(parse_auth_level("User"), AuthLevel::User);
3382        assert_eq!(
3383            parse_auth_level("ADMIN"),
3384            AuthLevel::Roles(vec!["admin".to_string()])
3385        );
3386
3387        // Whitespace in roles
3388        assert_eq!(
3389            parse_auth_level("  admin  ,  editor  "),
3390            AuthLevel::Roles(vec!["admin".to_string(), "editor".to_string()])
3391        );
3392
3393        // Single role
3394        assert_eq!(
3395            parse_auth_level("superuser"),
3396            AuthLevel::Roles(vec!["superuser".to_string()])
3397        );
3398    }
3399
3400    #[test]
3401    fn test_page_directives_cache_ttl() {
3402        // Self-closing with cache attribute
3403        let content = r#"<what cache="3600" />
3404<html></html>"#;
3405        let (directives, _) = parse_page_directives(content);
3406        assert_eq!(directives.cache_ttl, Some(3600));
3407
3408        // Content syntax with cache-ttl
3409        let content = r#"<what>
3410cache-ttl: 1800
3411</what>
3412<html></html>"#;
3413        let (directives, _) = parse_page_directives(content);
3414        assert_eq!(directives.cache_ttl, Some(1800));
3415    }
3416
3417    #[test]
3418    fn test_page_directives_custom() {
3419        let content = r#"<what custom_field="my_value" another="test" />
3420<html></html>"#;
3421        let (directives, _) = parse_page_directives(content);
3422
3423        assert_eq!(
3424            directives.custom.get("custom_field"),
3425            Some(&"my_value".to_string())
3426        );
3427        assert_eq!(directives.custom.get("another"), Some(&"test".to_string()));
3428    }
3429
3430    #[test]
3431    fn test_page_directives_redirect() {
3432        let content = r#"<what redirect="/new-page" />
3433<html></html>"#;
3434        let (directives, _) = parse_page_directives(content);
3435        assert_eq!(directives.redirect, Some("/new-page".to_string()));
3436    }
3437
3438    #[test]
3439    fn test_what_file_empty() {
3440        let content = "";
3441        let config = parse_what_file(content);
3442        assert!(config.values.is_empty());
3443        assert_eq!(config.directives.auth, AuthLevel::All);
3444    }
3445
3446    #[test]
3447    fn test_what_file_only_comments() {
3448        let content = r#"
3449// This is a comment
3450# Another comment
3451// More comments
3452"#;
3453        let config = parse_what_file(content);
3454        assert!(config.values.is_empty());
3455    }
3456
3457    #[test]
3458    fn test_what_file_data_application() {
3459        let content = r#"
3460data.application = ["posts", "products"]
3461"#;
3462        let config = parse_what_file(content);
3463        let names: Vec<&str> = config.data_application.iter().map(|d| d.name.as_str()).collect();
3464        assert_eq!(names, vec!["posts", "products"]);
3465        // Should not be exposed as template variable
3466        assert!(!config.values.contains_key("data.application"));
3467    }
3468
3469    #[test]
3470    fn test_what_file_data_application_scoped() {
3471        let content = r#"
3472data.application = ["visits", "revenue [admin, editor]"]
3473"#;
3474        let config = parse_what_file(content);
3475        assert_eq!(config.data_application[0].name, "visits");
3476        assert!(matches!(config.data_application[0].scope, WiredScope::Public));
3477        assert_eq!(config.data_application[1].name, "revenue");
3478        match &config.data_application[1].scope {
3479            WiredScope::Roles(r) => assert_eq!(r, &vec!["admin".to_string(), "editor".to_string()]),
3480            other => panic!("expected Roles, got {other:?}"),
3481        }
3482    }
3483
3484    #[test]
3485    fn test_what_file_data_session() {
3486        let content = r#"
3487data.session = ["cart", "wishlist"]
3488"#;
3489        let config = parse_what_file(content);
3490        assert_eq!(config.data_session, vec!["cart", "wishlist"]);
3491        // Should not be exposed as template variable
3492        assert!(!config.values.contains_key("data.session"));
3493    }
3494
3495    #[test]
3496    fn test_what_file_data_single_value() {
3497        // Single string instead of array should still work
3498        let content = r#"
3499data.application = "posts"
3500data.session = "cart"
3501"#;
3502        let config = parse_what_file(content);
3503        assert_eq!(config.data_application.len(), 1);
3504        assert_eq!(config.data_application[0].name, "posts");
3505        assert_eq!(config.data_session, vec!["cart"]);
3506    }
3507
3508    #[test]
3509    fn test_what_value_edge_cases() {
3510        // Empty array
3511        assert_eq!(parse_what_value("[]"), serde_json::json!([]));
3512
3513        // Negative float
3514        assert_eq!(parse_what_value("-3.14"), serde_json::json!(-3.14));
3515
3516        // Zero
3517        assert_eq!(parse_what_value("0"), serde_json::json!(0));
3518
3519        // Very large number
3520        assert_eq!(
3521            parse_what_value("9999999999"),
3522            serde_json::json!(9999999999_i64)
3523        );
3524    }
3525
3526    #[test]
3527    fn test_page_directives_mixed_syntax() {
3528        // Mix of boolean and key-value
3529        let content = r#"<what protected title="Dashboard" exclude />
3530<html></html>"#;
3531        let (directives, _) = parse_page_directives(content);
3532
3533        assert!(directives.protected);
3534        assert!(directives.exclude);
3535        assert_eq!(directives.title, Some("Dashboard".to_string()));
3536    }
3537
3538    #[test]
3539    fn test_is_standard_html_tag() {
3540        // Standard tags
3541        assert!(is_standard_html_tag("div"));
3542        assert!(is_standard_html_tag("span"));
3543        assert!(is_standard_html_tag("html"));
3544        assert!(is_standard_html_tag("body"));
3545        assert!(is_standard_html_tag("form"));
3546        assert!(is_standard_html_tag("input"));
3547        assert!(is_standard_html_tag("template"));
3548        assert!(is_standard_html_tag("slot"));
3549
3550        // Custom tags
3551        assert!(!is_standard_html_tag("jumbo"));
3552        assert!(!is_standard_html_tag("card"));
3553        assert!(!is_standard_html_tag("my-component"));
3554        assert!(!is_standard_html_tag("loop"));
3555    }
3556
3557    #[test]
3558    fn test_page_directives_requires_auth() {
3559        // auth: all should not require auth
3560        let mut d = PageDirectives::default();
3561        d.auth = AuthLevel::All;
3562        assert!(!d.requires_auth());
3563
3564        // auth: user should require auth
3565        d.auth = AuthLevel::User;
3566        assert!(d.requires_auth());
3567
3568        // auth: roles should require auth
3569        d.auth = AuthLevel::Roles(vec!["admin".to_string()]);
3570        assert!(d.requires_auth());
3571
3572        // Legacy: protected = true should require auth even with auth: all
3573        d.auth = AuthLevel::All;
3574        d.protected = true;
3575        assert!(d.requires_auth());
3576    }
3577
3578    #[test]
3579    fn test_page_directives_check_access_legacy() {
3580        // Test legacy protected + roles behavior
3581        let mut d = PageDirectives::default();
3582        d.protected = true;
3583        d.roles = vec!["admin".to_string(), "editor".to_string()];
3584
3585        // Not authenticated - denied
3586        assert!(!d.check_access(false, &[]));
3587
3588        // Authenticated without roles - denied
3589        assert!(!d.check_access(true, &[]));
3590
3591        // Authenticated with wrong role - denied
3592        assert!(!d.check_access(true, &["viewer".to_string()]));
3593
3594        // Authenticated with correct role - allowed
3595        assert!(d.check_access(true, &["admin".to_string()]));
3596        assert!(d.check_access(true, &["editor".to_string()]));
3597    }
3598
3599    // =========================================================================
3600    // Layout System Tests
3601    // =========================================================================
3602
3603    #[test]
3604    fn test_layout_in_what_file() {
3605        let content = r#"
3606layout = "sections/main.html"
3607title = "Test Page"
3608"#;
3609        let config = parse_what_file(content);
3610
3611        assert_eq!(config.layout, Some("sections/main.html".to_string()));
3612        assert_eq!(
3613            config.directives.layout,
3614            Some("sections/main.html".to_string())
3615        );
3616        // Layout should not be exposed as a template variable
3617        assert!(config.get_string("layout").is_none());
3618    }
3619
3620    #[test]
3621    fn test_layout_in_page_directive_attribute() {
3622        let content = r#"<what layout="sections/page.html" />
3623<h1>Hello</h1>"#;
3624        let (directives, cleaned) = parse_page_directives(content);
3625
3626        assert_eq!(directives.layout, Some("sections/page.html".to_string()));
3627        assert!(cleaned.contains("<h1>Hello</h1>"));
3628        assert!(!cleaned.contains("<what"));
3629    }
3630
3631    #[test]
3632    fn test_layout_in_page_directive_content() {
3633        let content = r#"<what>
3634layout: sections/admin.html
3635title: Dashboard
3636</what>
3637<h1>Admin Dashboard</h1>"#;
3638        let (directives, cleaned) = parse_page_directives(content);
3639
3640        assert_eq!(directives.layout, Some("sections/admin.html".to_string()));
3641        assert_eq!(directives.title, Some("Dashboard".to_string()));
3642        assert!(cleaned.contains("<h1>Admin Dashboard</h1>"));
3643    }
3644
3645    #[test]
3646    fn test_layout_none_disables() {
3647        // Page can use layout: none to disable inherited layout
3648        let content = r#"<what layout="none" />
3649<h1>No Layout</h1>"#;
3650        let (directives, _) = parse_page_directives(content);
3651
3652        assert_eq!(directives.layout, Some("none".to_string()));
3653    }
3654
3655    #[test]
3656    fn test_what_config_layout_merge() {
3657        let parent_content = r#"
3658layout = "sections/base.html"
3659title = "Parent"
3660"#;
3661        let child_content = r#"
3662layout = "sections/admin.html"
3663"#;
3664        let mut parent = parse_what_file(parent_content);
3665        let child = parse_what_file(child_content);
3666
3667        parent.merge(&child);
3668
3669        // Child layout overrides parent
3670        assert_eq!(parent.layout, Some("sections/admin.html".to_string()));
3671    }
3672
3673    #[test]
3674    fn test_what_config_layout_inherit() {
3675        let parent_content = r#"
3676layout = "sections/base.html"
3677title = "Parent"
3678"#;
3679        let child_content = r#"
3680title = "Child"
3681"#;
3682        let mut parent = parse_what_file(parent_content);
3683        let child = parse_what_file(child_content);
3684
3685        parent.merge(&child);
3686
3687        // Parent layout is preserved when child doesn't set one
3688        assert_eq!(parent.layout, Some("sections/base.html".to_string()));
3689        assert_eq!(parent.get_string("title"), Some("Child"));
3690    }
3691
3692    #[test]
3693    fn test_what_config_layout_none_override() {
3694        let parent_content = r#"
3695layout = "sections/base.html"
3696"#;
3697        let child_content = r#"
3698layout = "none"
3699"#;
3700        let mut parent = parse_what_file(parent_content);
3701        let child = parse_what_file(child_content);
3702
3703        parent.merge(&child);
3704
3705        // Child's "none" overrides parent layout
3706        assert_eq!(parent.layout, Some("none".to_string()));
3707    }
3708
3709    // =========================================================================
3710    // Reactive Variable Replacement Tests
3711    // =========================================================================
3712
3713    #[test]
3714    fn test_reactive_session_var_in_text() {
3715        let mut context = HashMap::new();
3716        context.insert(
3717            "session".to_string(),
3718            serde_json::json!({
3719                "count": 8
3720            }),
3721        );
3722
3723        let template = "<p>Counter: #session.count#</p>";
3724        let result = replace_variables_reactive(template, &context);
3725
3726        assert!(
3727            result
3728                .html
3729                .contains(r#"<span w-bind="session.count">8</span>"#)
3730        );
3731        assert!(result.session_keys.contains("count"));
3732    }
3733
3734    #[test]
3735    fn test_reactive_session_var_in_attribute() {
3736        let mut context = HashMap::new();
3737        context.insert(
3738            "session".to_string(),
3739            serde_json::json!({
3740                "count": 8
3741            }),
3742        );
3743
3744        // Session variable in attribute should NOT be wrapped
3745        let template = r##"<div title="#session.count#">Content</div>"##;
3746        let result = replace_variables_reactive(template, &context);
3747
3748        // Should replace value but not wrap
3749        assert!(result.html.contains(r##"title="8""##));
3750        // Should NOT contain span with w-bind
3751        assert!(!result.html.contains("w-bind"));
3752        // But should still track the key
3753        assert!(result.session_keys.contains("count"));
3754    }
3755
3756    #[test]
3757    fn test_reactive_non_session_var() {
3758        let mut context = HashMap::new();
3759        context.insert("name".to_string(), serde_json::json!("Alice"));
3760
3761        let template = "<p>Hello #name#!</p>";
3762        let result = replace_variables_reactive(template, &context);
3763
3764        // Non-session variables should be replaced without wrapping
3765        assert!(result.html.contains("Hello Alice!"));
3766        assert!(!result.html.contains("w-bind"));
3767        assert!(result.session_keys.is_empty());
3768    }
3769
3770    #[test]
3771    fn test_reactive_multiple_session_vars() {
3772        let mut context = HashMap::new();
3773        context.insert(
3774            "session".to_string(),
3775            serde_json::json!({
3776                "count": 5,
3777                "name": "Test"
3778            }),
3779        );
3780
3781        let template = "<p>Count: #session.count#, Name: #session.name#</p>";
3782        let result = replace_variables_reactive(template, &context);
3783
3784        assert!(
3785            result
3786                .html
3787                .contains(r#"<span w-bind="session.count">5</span>"#)
3788        );
3789        assert!(
3790            result
3791                .html
3792                .contains(r#"<span w-bind="session.name">Test</span>"#)
3793        );
3794        assert!(result.session_keys.contains("count"));
3795        assert!(result.session_keys.contains("name"));
3796    }
3797
3798    #[test]
3799    fn test_is_in_attribute_context() {
3800        // Text content cases (should return false)
3801        assert!(!is_in_attribute_context("<p>"));
3802        assert!(!is_in_attribute_context("<p>Hello "));
3803        assert!(!is_in_attribute_context("<div><span>"));
3804
3805        // Attribute cases (should return true)
3806        assert!(is_in_attribute_context(r#"<div title=""#));
3807        assert!(is_in_attribute_context(r#"<div class="foo "#));
3808        assert!(is_in_attribute_context(r#"<input value=""#));
3809
3810        // After closing attribute (should return false)
3811        assert!(!is_in_attribute_context(r#"<div title="test">"#));
3812        assert!(!is_in_attribute_context(r#"<div class="foo">Hello"#));
3813    }
3814
3815    #[test]
3816    fn test_html_escape() {
3817        assert_eq!(html_escape("<"), "&lt;");
3818        assert_eq!(html_escape(">"), "&gt;");
3819        assert_eq!(html_escape("&"), "&amp;");
3820        assert_eq!(html_escape("\""), "&quot;");
3821        assert_eq!(html_escape("'"), "&#39;");
3822        assert_eq!(
3823            html_escape("<script>alert('xss')</script>"),
3824            "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"
3825        );
3826    }
3827
3828    #[test]
3829    fn test_parse_session_mutation_push() {
3830        let m = parse_session_mutation("session.items.push(\"hello\")").unwrap();
3831        match m {
3832            SessionMutation::Push { key, value } => {
3833                assert_eq!(key, "items");
3834                assert_eq!(value, serde_json::json!("hello"));
3835            }
3836            _ => panic!("Expected Push"),
3837        }
3838    }
3839
3840    #[test]
3841    fn test_parse_session_mutation_pushmax() {
3842        let m = parse_session_mutation("session.history.pushmax(5, \"page1\")").unwrap();
3843        match m {
3844            SessionMutation::PushMax { key, max, value } => {
3845                assert_eq!(key, "history");
3846                assert_eq!(max, 5);
3847                assert_eq!(value, serde_json::json!("page1"));
3848            }
3849            _ => panic!("Expected PushMax"),
3850        }
3851    }
3852
3853    #[test]
3854    fn test_parse_session_mutation_pushmax_numeric() {
3855        let m = parse_session_mutation("session.ids.pushmax(10, 42)").unwrap();
3856        match m {
3857            SessionMutation::PushMax { key, max, value } => {
3858                assert_eq!(key, "ids");
3859                assert_eq!(max, 10);
3860                assert_eq!(value, serde_json::json!(42));
3861            }
3862            _ => panic!("Expected PushMax"),
3863        }
3864    }
3865
3866    // =========================================================================
3867    // Auto-Escaping Tests
3868    // =========================================================================
3869
3870    #[test]
3871    fn test_auto_escape_html_in_variables() {
3872        let mut context = HashMap::new();
3873        context.insert(
3874            "name".to_string(),
3875            serde_json::json!("<script>alert('xss')</script>"),
3876        );
3877
3878        let result = replace_variables("Hello #name#!", &context);
3879        assert_eq!(
3880            result,
3881            "Hello &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;!"
3882        );
3883        assert!(!result.contains("<script>"));
3884    }
3885
3886    #[test]
3887    fn test_auto_escape_ampersand() {
3888        let mut context = HashMap::new();
3889        context.insert("text".to_string(), serde_json::json!("Tom & Jerry"));
3890
3891        let result = replace_variables("#text#", &context);
3892        assert_eq!(result, "Tom &amp; Jerry");
3893    }
3894
3895    #[test]
3896    fn test_auto_escape_quotes() {
3897        let mut context = HashMap::new();
3898        context.insert("text".to_string(), serde_json::json!(r#"He said "hello""#));
3899
3900        let result = replace_variables("#text#", &context);
3901        assert_eq!(result, "He said &quot;hello&quot;");
3902    }
3903
3904    #[test]
3905    fn test_raw_filter_bypasses_escaping() {
3906        let mut context = HashMap::new();
3907        context.insert("html".to_string(), serde_json::json!("<b>bold</b>"));
3908
3909        let result = replace_variables("#html|raw#", &context);
3910        assert_eq!(result, "<b>bold</b>");
3911    }
3912
3913    #[test]
3914    fn test_raw_filter_with_default_value() {
3915        let mut context = HashMap::new();
3916        context.insert("html".to_string(), serde_json::json!("<em>yes</em>"));
3917
3918        // |raw at end of filter chain
3919        let result = replace_variables("#html|raw#", &context);
3920        assert_eq!(result, "<em>yes</em>");
3921    }
3922
3923    #[test]
3924    fn test_auto_escape_preserves_safe_text() {
3925        let mut context = HashMap::new();
3926        context.insert("name".to_string(), serde_json::json!("Alice"));
3927
3928        let result = replace_variables("Hello #name#!", &context);
3929        assert_eq!(result, "Hello Alice!");
3930    }
3931
3932    #[test]
3933    fn test_auto_escape_nested_variables() {
3934        let mut context = HashMap::new();
3935        context.insert(
3936            "user".to_string(),
3937            serde_json::json!({
3938                "name": "<b>Admin</b>",
3939                "bio": "Loves coding & testing"
3940            }),
3941        );
3942
3943        let result = replace_variables("#user.name# - #user.bio#", &context);
3944        assert_eq!(
3945            result,
3946            "&lt;b&gt;Admin&lt;/b&gt; - Loves coding &amp; testing"
3947        );
3948    }
3949
3950    #[test]
3951    fn test_auto_escape_with_default_filter() {
3952        let context = HashMap::new();
3953
3954        // New syntax: |default:"value"
3955        let result = replace_variables(r##"#missing|default:"<fallback>"#"##, &context);
3956        assert_eq!(result, "&lt;fallback&gt;");
3957    }
3958
3959    #[test]
3960    fn test_reactive_auto_escape_non_session_var() {
3961        let mut context = HashMap::new();
3962        context.insert(
3963            "name".to_string(),
3964            serde_json::json!("<script>xss</script>"),
3965        );
3966
3967        let result = replace_variables_reactive("<p>#name#</p>", &context);
3968        assert!(result.html.contains("&lt;script&gt;xss&lt;/script&gt;"));
3969        assert!(!result.html.contains("<script>xss</script>"));
3970    }
3971
3972    #[test]
3973    fn test_reactive_auto_escape_session_var_in_attribute() {
3974        let mut context = HashMap::new();
3975        context.insert(
3976            "session".to_string(),
3977            serde_json::json!({
3978                "name": "Tom & Jerry"
3979            }),
3980        );
3981
3982        let template = r##"<div title="#session.name#">Content</div>"##;
3983        let result = replace_variables_reactive(template, &context);
3984
3985        // Attribute should be escaped
3986        assert!(result.html.contains("Tom &amp; Jerry"));
3987        assert!(!result.html.contains("w-bind"));
3988    }
3989
3990    #[test]
3991    fn test_reactive_raw_filter_non_session_var() {
3992        let mut context = HashMap::new();
3993        context.insert("html".to_string(), serde_json::json!("<b>bold</b>"));
3994
3995        let result = replace_variables_reactive("<p>#html|raw#</p>", &context);
3996        assert!(result.html.contains("<b>bold</b>"));
3997    }
3998
3999    #[test]
4000    fn test_reactive_session_var_always_escaped_in_span() {
4001        let mut context = HashMap::new();
4002        context.insert(
4003            "session".to_string(),
4004            serde_json::json!({
4005                "name": "<script>xss</script>"
4006            }),
4007        );
4008
4009        let result = replace_variables_reactive("<p>#session.name#</p>", &context);
4010        // Session vars in text are always escaped inside the reactive span
4011        assert!(result.html.contains("&lt;script&gt;xss&lt;/script&gt;"));
4012        assert!(result.html.contains("w-bind"));
4013    }
4014
4015    #[test]
4016    fn test_reactive_session_raw_in_attribute() {
4017        let mut context = HashMap::new();
4018        context.insert(
4019            "session".to_string(),
4020            serde_json::json!({
4021                "url": "/path?a=1&b=2"
4022            }),
4023        );
4024
4025        // |raw in attribute context should skip escaping
4026        let template = r##"<a href="#session.url|raw#">Link</a>"##;
4027        let result = replace_variables_reactive(template, &context);
4028        assert!(result.html.contains(r#"href="/path?a=1&b=2""#));
4029    }
4030
4031    #[test]
4032    fn test_auto_escape_number_values() {
4033        let mut context = HashMap::new();
4034        context.insert("count".to_string(), serde_json::json!(42));
4035
4036        let result = replace_variables("Count: #count#", &context);
4037        assert_eq!(result, "Count: 42");
4038    }
4039
4040    #[test]
4041    fn test_auto_escape_boolean_values() {
4042        let mut context = HashMap::new();
4043        context.insert("flag".to_string(), serde_json::json!(true));
4044
4045        let result = replace_variables("Flag: #flag#", &context);
4046        assert_eq!(result, "Flag: true");
4047    }
4048
4049    #[test]
4050    fn test_auto_escape_unresolved_variable() {
4051        let context = HashMap::new();
4052        // Unresolved variables stay as #var# — this should NOT be escaped
4053        // because the hash signs are part of the template syntax, not user data
4054        let result = replace_variables("#unknown#", &context);
4055        // The unresolved var format "#unknown#" gets escaped as-is
4056        assert_eq!(result, "#unknown#");
4057    }
4058
4059    #[test]
4060    fn test_nested_var_on_empty_parent_resolves_empty() {
4061        let mut context = HashMap::new();
4062        // Parent object exists but child key is missing → empty, not literal
4063        context.insert("old".into(), serde_json::json!({}));
4064        let result = replace_variables("#old.title#", &context);
4065        assert_eq!(
4066            result, "",
4067            "Missing child on existing parent should be empty"
4068        );
4069    }
4070
4071    #[test]
4072    fn test_nested_var_on_missing_root_stays_literal() {
4073        let context = HashMap::new();
4074        // Root key not in context at all → preserve literal for strict mode
4075        let result = replace_variables("#old.title#", &context);
4076        assert_eq!(result, "#old.title#", "Missing root should keep literal");
4077    }
4078
4079    #[test]
4080    fn test_nested_var_on_populated_parent_resolves() {
4081        let mut context = HashMap::new();
4082        context.insert("user".into(), serde_json::json!({"name": "Alice"}));
4083        // Existing child → resolves normally
4084        assert_eq!(replace_variables("#user.name#", &context), "Alice");
4085        // Missing child on populated parent → empty
4086        assert_eq!(replace_variables("#user.role#", &context), "");
4087    }
4088
4089    // =========================================================================
4090    // Filter System Tests
4091    // =========================================================================
4092
4093    #[test]
4094    fn test_filter_parse_chain() {
4095        let (path, filters) = parse_filter_chain("name|uppercase");
4096        assert_eq!(path, "name");
4097        assert_eq!(filters.len(), 1);
4098        assert_eq!(filters[0].name, "uppercase");
4099        assert!(filters[0].args.is_empty());
4100    }
4101
4102    #[test]
4103    fn test_filter_parse_with_arg() {
4104        let (path, filters) = parse_filter_chain("title|truncate:50");
4105        assert_eq!(path, "title");
4106        assert_eq!(filters.len(), 1);
4107        assert_eq!(filters[0].name, "truncate");
4108        assert_eq!(filters[0].args, vec!["50"]);
4109    }
4110
4111    #[test]
4112    fn test_filter_parse_chained() {
4113        let (path, filters) = parse_filter_chain("title|truncate:50|uppercase");
4114        assert_eq!(path, "title");
4115        assert_eq!(filters.len(), 2);
4116        assert_eq!(filters[0].name, "truncate");
4117        assert_eq!(filters[1].name, "uppercase");
4118    }
4119
4120    #[test]
4121    fn test_filter_parse_quoted_args() {
4122        let (path, filters) = parse_filter_chain(r#"name|default:"Anonymous""#);
4123        assert_eq!(path, "name");
4124        assert_eq!(filters.len(), 1);
4125        assert_eq!(filters[0].name, "default");
4126        assert_eq!(filters[0].args, vec!["Anonymous"]);
4127    }
4128
4129    #[test]
4130    fn test_filter_parse_multiple_args() {
4131        let (path, filters) = parse_filter_chain(r#"text|replace:"old","new""#);
4132        assert_eq!(path, "text");
4133        assert_eq!(filters.len(), 1);
4134        assert_eq!(filters[0].name, "replace");
4135        assert_eq!(filters[0].args, vec!["old", "new"]);
4136    }
4137
4138    #[test]
4139    fn test_filter_uppercase() {
4140        let mut ctx = HashMap::new();
4141        ctx.insert("name".to_string(), serde_json::json!("hello"));
4142        let result = replace_variables("#name|uppercase#", &ctx);
4143        assert_eq!(result, "HELLO");
4144    }
4145
4146    #[test]
4147    fn test_filter_lowercase() {
4148        let mut ctx = HashMap::new();
4149        ctx.insert("name".to_string(), serde_json::json!("HELLO"));
4150        let result = replace_variables("#name|lowercase#", &ctx);
4151        assert_eq!(result, "hello");
4152    }
4153
4154    #[test]
4155    fn test_filter_capitalize() {
4156        let mut ctx = HashMap::new();
4157        ctx.insert("name".to_string(), serde_json::json!("hello world"));
4158        let result = replace_variables("#name|capitalize#", &ctx);
4159        assert_eq!(result, "Hello world");
4160    }
4161
4162    #[test]
4163    fn test_filter_title() {
4164        let mut ctx = HashMap::new();
4165        ctx.insert("name".to_string(), serde_json::json!("hello world today"));
4166        let result = replace_variables("#name|title#", &ctx);
4167        assert_eq!(result, "Hello World Today");
4168    }
4169
4170    #[test]
4171    fn test_filter_truncate() {
4172        let mut ctx = HashMap::new();
4173        ctx.insert(
4174            "text".to_string(),
4175            serde_json::json!("This is a long text that should be truncated"),
4176        );
4177        let result = replace_variables("#text|truncate:10#", &ctx);
4178        assert_eq!(result, "This is a ...");
4179    }
4180
4181    #[test]
4182    fn test_filter_truncate_short_text() {
4183        let mut ctx = HashMap::new();
4184        ctx.insert("text".to_string(), serde_json::json!("Short"));
4185        let result = replace_variables("#text|truncate:10#", &ctx);
4186        assert_eq!(result, "Short");
4187    }
4188
4189    #[test]
4190    fn test_filter_count() {
4191        let mut ctx = HashMap::new();
4192        ctx.insert("text".to_string(), serde_json::json!("hello"));
4193        let result = replace_variables("#text|count#", &ctx);
4194        assert_eq!(result, "5");
4195    }
4196
4197    #[test]
4198    fn test_filter_number() {
4199        let mut ctx = HashMap::new();
4200        ctx.insert("n".to_string(), serde_json::json!(1234567));
4201        let result = replace_variables("#n|number#", &ctx);
4202        assert_eq!(result, "1,234,567");
4203    }
4204
4205    #[test]
4206    fn test_filter_currency_usd() {
4207        let mut ctx = HashMap::new();
4208        ctx.insert("price".to_string(), serde_json::json!(1299.99));
4209        let result = replace_variables(r##"#price|currency:"USD"#"##, &ctx);
4210        assert_eq!(result, "$1,299.99");
4211    }
4212
4213    #[test]
4214    fn test_filter_currency_eur() {
4215        let mut ctx = HashMap::new();
4216        ctx.insert("price".to_string(), serde_json::json!(49.5));
4217        let result = replace_variables(r##"#price|currency:"EUR"#"##, &ctx);
4218        assert_eq!(result, "\u{20ac}49.50");
4219    }
4220
4221    #[test]
4222    fn test_filter_date() {
4223        let mut ctx = HashMap::new();
4224        ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4225        let result = replace_variables("#d|date#", &ctx);
4226        assert_eq!(result, "Mar 15, 2025"); // default = "medium" preset
4227    }
4228
4229    #[test]
4230    fn test_filter_date_custom_format() {
4231        let mut ctx = HashMap::new();
4232        ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4233        let result = replace_variables(r##"#d|date:"dd/mm/yyyy"#"##, &ctx);
4234        assert_eq!(result, "15/03/2025");
4235    }
4236
4237    #[test]
4238    fn test_date_mask_short() {
4239        let mut ctx = HashMap::new();
4240        ctx.insert("d".to_string(), serde_json::json!("2025-03-05"));
4241        let result = replace_variables(r##"#d|date:"short"#"##, &ctx);
4242        assert_eq!(result, "3/5/25");
4243    }
4244
4245    #[test]
4246    fn test_date_mask_full() {
4247        let mut ctx = HashMap::new();
4248        ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4249        let result = replace_variables(r##"#d|date:"full"#"##, &ctx);
4250        assert_eq!(result, "Saturday, March 15, 2025");
4251    }
4252
4253    #[test]
4254    fn test_date_mask_long() {
4255        let mut ctx = HashMap::new();
4256        ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4257        let result = replace_variables(r##"#d|date:"long"#"##, &ctx);
4258        assert_eq!(result, "March 15, 2025");
4259    }
4260
4261    #[test]
4262    fn test_date_mask_iso() {
4263        let mut ctx = HashMap::new();
4264        ctx.insert("d".to_string(), serde_json::json!("2025-03-05"));
4265        let result = replace_variables(r##"#d|date:"iso"#"##, &ctx);
4266        assert_eq!(result, "2025-03-05");
4267    }
4268
4269    #[test]
4270    fn test_date_mask_time() {
4271        let mut ctx = HashMap::new();
4272        ctx.insert("d".to_string(), serde_json::json!("2025-03-15T14:05:09"));
4273        let result = replace_variables(r##"#d|date:"time"#"##, &ctx);
4274        assert_eq!(result, "2:05 PM");
4275    }
4276
4277    #[test]
4278    fn test_date_mask_combined() {
4279        let mut ctx = HashMap::new();
4280        ctx.insert("d".to_string(), serde_json::json!("2025-03-15T14:30:00"));
4281        let result = replace_variables(r##"#d|date:"mmm d, yyyy h:nn tt"#"##, &ctx);
4282        assert_eq!(result, "Mar 15, 2025 2:30 PM");
4283    }
4284
4285    #[test]
4286    fn test_date_mask_24hour() {
4287        let mut ctx = HashMap::new();
4288        ctx.insert("d".to_string(), serde_json::json!("2025-03-15T09:05:00"));
4289        let result = replace_variables(r##"#d|date:"HH:nn"#"##, &ctx);
4290        assert_eq!(result, "09:05");
4291    }
4292
4293    #[test]
4294    fn test_date_mask_weekday() {
4295        let mut ctx = HashMap::new();
4296        ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4297        let result = replace_variables(r##"#d|date:"ddd"#"##, &ctx);
4298        assert_eq!(result, "Sat");
4299    }
4300
4301    #[test]
4302    fn test_date_mask_literals() {
4303        let mut ctx = HashMap::new();
4304        ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4305        let result = replace_variables(r##"#d|date:"yyyy-mm-dd"#"##, &ctx);
4306        assert_eq!(result, "2025-03-15");
4307    }
4308
4309    #[test]
4310    fn test_date_mask_midnight_12hr() {
4311        let mut ctx = HashMap::new();
4312        ctx.insert("d".to_string(), serde_json::json!("2025-03-15T00:00:00"));
4313        let result = replace_variables(r##"#d|date:"h:nn tt"#"##, &ctx);
4314        assert_eq!(result, "12:00 AM");
4315    }
4316
4317    #[test]
4318    fn test_date_rfc3339_input() {
4319        let mut ctx = HashMap::new();
4320        ctx.insert("d".to_string(), serde_json::json!("2025-03-15T14:30:00Z"));
4321        let result = replace_variables(r##"#d|date:"mmm d"#"##, &ctx);
4322        assert_eq!(result, "Mar 15");
4323    }
4324
4325    #[test]
4326    fn test_filter_json() {
4327        let mut ctx = HashMap::new();
4328        ctx.insert("name".to_string(), serde_json::json!("hello"));
4329        let result = replace_variables("#name|json#", &ctx);
4330        assert_eq!(result, "&quot;hello&quot;"); // Escaped because auto-escape
4331    }
4332
4333    #[test]
4334    fn test_filter_json_raw() {
4335        let mut ctx = HashMap::new();
4336        ctx.insert("name".to_string(), serde_json::json!("hello"));
4337        let result = replace_variables("#name|json|raw#", &ctx);
4338        assert_eq!(result, "\"hello\""); // Not escaped because |raw
4339    }
4340
4341    #[test]
4342    fn test_filter_markdown() {
4343        let mut ctx = HashMap::new();
4344        ctx.insert(
4345            "text".to_string(),
4346            serde_json::json!("This is **bold** and *italic*"),
4347        );
4348        let result = replace_variables("#text|markdown#", &ctx);
4349        assert!(result.contains("<strong>bold</strong>"));
4350        assert!(result.contains("<em>italic</em>"));
4351        // markdown is html_safe, so not escaped
4352        assert!(result.contains("<p>"));
4353    }
4354
4355    #[test]
4356    fn test_filter_pluralize() {
4357        let mut ctx = HashMap::new();
4358        ctx.insert("count".to_string(), serde_json::json!(1));
4359        let result = replace_variables(r##"#count# item#count|pluralize:"","s"#"##, &ctx);
4360        assert_eq!(result, "1 item");
4361
4362        ctx.insert("count".to_string(), serde_json::json!(5));
4363        let result = replace_variables(r##"#count# item#count|pluralize:"","s"#"##, &ctx);
4364        assert_eq!(result, "5 items");
4365    }
4366
4367    #[test]
4368    fn test_filter_default() {
4369        let ctx = HashMap::new();
4370        let result = replace_variables(r##"#missing|default:"N/A"#"##, &ctx);
4371        assert_eq!(result, "N/A");
4372    }
4373
4374    #[test]
4375    fn test_filter_default_not_needed() {
4376        let mut ctx = HashMap::new();
4377        ctx.insert("name".to_string(), serde_json::json!("Alice"));
4378        let result = replace_variables(r##"#name|default:"N/A"#"##, &ctx);
4379        assert_eq!(result, "Alice");
4380    }
4381
4382    #[test]
4383    fn test_filter_replace() {
4384        let mut ctx = HashMap::new();
4385        ctx.insert("text".to_string(), serde_json::json!("Hello World"));
4386        let result = replace_variables(r##"#text|replace:"World","Rust"#"##, &ctx);
4387        assert_eq!(result, "Hello Rust");
4388    }
4389
4390    #[test]
4391    fn test_filter_slice() {
4392        let mut ctx = HashMap::new();
4393        ctx.insert("text".to_string(), serde_json::json!("Hello World"));
4394        let result = replace_variables("#text|slice:0,5#", &ctx);
4395        assert_eq!(result, "Hello");
4396    }
4397
4398    #[test]
4399    fn test_filter_chaining() {
4400        let mut ctx = HashMap::new();
4401        ctx.insert("name".to_string(), serde_json::json!("hello world"));
4402        let result = replace_variables("#name|uppercase|truncate:5#", &ctx);
4403        assert_eq!(result, "HELLO...");
4404    }
4405
4406    #[test]
4407    fn test_filter_chaining_with_escaping() {
4408        let mut ctx = HashMap::new();
4409        ctx.insert("text".to_string(), serde_json::json!("<b>hello</b>"));
4410        // Without |raw, output is escaped after filters
4411        let result = replace_variables("#text|uppercase#", &ctx);
4412        assert_eq!(result, "&lt;B&gt;HELLO&lt;/B&gt;");
4413    }
4414
4415    #[test]
4416    fn test_filter_raw_bypasses_escaping() {
4417        let mut ctx = HashMap::new();
4418        ctx.insert("html".to_string(), serde_json::json!("<b>bold</b>"));
4419        let result = replace_variables("#html|raw#", &ctx);
4420        assert_eq!(result, "<b>bold</b>");
4421    }
4422
4423    #[test]
4424    fn test_filter_in_reactive_mode() {
4425        let mut ctx = HashMap::new();
4426        ctx.insert("name".to_string(), serde_json::json!("hello"));
4427        let result = replace_variables_reactive("<p>#name|uppercase#</p>", &ctx);
4428        assert!(result.html.contains("HELLO"));
4429    }
4430
4431    #[test]
4432    fn test_filter_default_with_session_var() {
4433        let ctx = HashMap::new();
4434        let result = replace_variables_reactive(r##"<p>#session.count|default:"0"#</p>"##, &ctx);
4435        // Session var not found, default filter triggers, wrapped in span
4436        assert!(result.html.contains("w-bind"));
4437        assert!(result.html.contains(">0<"));
4438    }
4439
4440    #[test]
4441    fn test_filter_unknown_passes_through() {
4442        let mut ctx = HashMap::new();
4443        ctx.insert("name".to_string(), serde_json::json!("hello"));
4444        // Unknown filter should pass value through unchanged
4445        let result = replace_variables("#name|bogusfilter#", &ctx);
4446        assert_eq!(result, "hello");
4447    }
4448
4449    #[test]
4450    fn test_filter_round() {
4451        let mut ctx = HashMap::new();
4452        ctx.insert("price".to_string(), json!("3.14159"));
4453        assert_eq!(replace_variables("#price|round:2#", &ctx), "3.14");
4454    }
4455
4456    #[test]
4457    fn test_filter_round_no_args() {
4458        let mut ctx = HashMap::new();
4459        ctx.insert("val".to_string(), json!("3.7"));
4460        assert_eq!(replace_variables("#val|round#", &ctx), "4");
4461    }
4462
4463    #[test]
4464    fn test_filter_ceil() {
4465        let mut ctx = HashMap::new();
4466        ctx.insert("val".to_string(), json!("3.2"));
4467        assert_eq!(replace_variables("#val|ceil#", &ctx), "4");
4468    }
4469
4470    #[test]
4471    fn test_filter_floor() {
4472        let mut ctx = HashMap::new();
4473        ctx.insert("val".to_string(), json!("3.9"));
4474        assert_eq!(replace_variables("#val|floor#", &ctx), "3");
4475    }
4476
4477    #[test]
4478    fn test_filter_ceil_negative() {
4479        let mut ctx = HashMap::new();
4480        ctx.insert("val".to_string(), json!("-2.3"));
4481        assert_eq!(replace_variables("#val|ceil#", &ctx), "-2");
4482    }
4483
4484    #[test]
4485    fn test_filter_floor_negative() {
4486        let mut ctx = HashMap::new();
4487        ctx.insert("val".to_string(), json!("-2.3"));
4488        assert_eq!(replace_variables("#val|floor#", &ctx), "-3");
4489    }
4490
4491    // =========================================================================
4492    // Arithmetic Tests
4493    // =========================================================================
4494
4495    #[test]
4496    fn test_arithmetic_basic_addition() {
4497        assert_eq!(evaluate_arithmetic("10 + 1"), Some(11.0));
4498    }
4499
4500    #[test]
4501    fn test_arithmetic_subtraction() {
4502        assert_eq!(evaluate_arithmetic("10 - 3"), Some(7.0));
4503    }
4504
4505    #[test]
4506    fn test_arithmetic_multiply() {
4507        assert_eq!(evaluate_arithmetic("5 * 3"), Some(15.0));
4508    }
4509
4510    #[test]
4511    fn test_arithmetic_divide() {
4512        assert_eq!(evaluate_arithmetic("10 / 4"), Some(2.5));
4513    }
4514
4515    #[test]
4516    fn test_arithmetic_precedence() {
4517        // 2 + 3 * 4 = 14 (not 20)
4518        assert_eq!(evaluate_arithmetic("2 + 3 * 4"), Some(14.0));
4519    }
4520
4521    #[test]
4522    fn test_arithmetic_division_by_zero() {
4523        assert_eq!(evaluate_arithmetic("10 / 0"), None);
4524    }
4525
4526    #[test]
4527    fn test_arithmetic_negative_result() {
4528        assert_eq!(evaluate_arithmetic("3 - 10"), Some(-7.0));
4529    }
4530
4531    #[test]
4532    fn test_arithmetic_not_arithmetic() {
4533        assert_eq!(evaluate_arithmetic("hello"), None);
4534        assert_eq!(evaluate_arithmetic("42"), None);
4535    }
4536
4537    #[test]
4538    fn test_arithmetic_in_template() {
4539        let mut ctx = HashMap::new();
4540        ctx.insert("session".to_string(), json!({"age": 25}));
4541        let result = replace_variables("#session.age + 1#", &ctx);
4542        assert_eq!(result, "26");
4543    }
4544
4545    #[test]
4546    fn test_arithmetic_multiply_in_template() {
4547        let mut ctx = HashMap::new();
4548        ctx.insert("price".to_string(), json!(100));
4549        let result = replace_variables("#price * 0.21#", &ctx);
4550        assert_eq!(result, "21");
4551    }
4552
4553    #[test]
4554    fn test_arithmetic_with_filter() {
4555        let mut ctx = HashMap::new();
4556        ctx.insert("price".to_string(), json!(99.99));
4557        let result = replace_variables("#price * 0.21|round:2#", &ctx);
4558        assert_eq!(result, "21.00");
4559    }
4560
4561    #[test]
4562    fn test_no_filters_still_escapes() {
4563        let mut ctx = HashMap::new();
4564        ctx.insert(
4565            "xss".to_string(),
4566            serde_json::json!("<script>alert(1)</script>"),
4567        );
4568        let result = replace_variables("#xss#", &ctx);
4569        assert!(!result.contains("<script>"));
4570        assert!(result.contains("&lt;script&gt;"));
4571    }
4572
4573    // =========================================================================
4574    // Computed Variables Tests
4575    // =========================================================================
4576
4577    #[test]
4578    fn test_computed_variable_parsing() {
4579        let content = r##"<what>
4580title: My Page
4581compute.greeting = "Hello #user.name#!"
4582compute.full_url = "/posts/#post.id#"
4583</what>
4584<html></html>"##;
4585
4586        let (directives, _) = parse_page_directives(content);
4587        assert_eq!(directives.computed.len(), 2);
4588        assert_eq!(directives.computed[0].0, "greeting");
4589        assert_eq!(directives.computed[0].1, "Hello #user.name#!");
4590        assert_eq!(directives.computed[1].0, "full_url");
4591        assert_eq!(directives.computed[1].1, "/posts/#post.id#");
4592    }
4593
4594    #[test]
4595    fn test_computed_variable_resolution() {
4596        let mut context = HashMap::new();
4597        context.insert(
4598            "user".to_string(),
4599            serde_json::json!({
4600                "name": "Alice"
4601            }),
4602        );
4603
4604        let computed = vec![("greeting".to_string(), "Hello #user.name#!".to_string())];
4605
4606        resolve_computed_variables(&computed, &mut context);
4607
4608        assert_eq!(
4609            context.get("greeting"),
4610            Some(&serde_json::json!("Hello Alice!"))
4611        );
4612    }
4613
4614    #[test]
4615    fn test_computed_variable_chained() {
4616        let mut context = HashMap::new();
4617        context.insert("first".to_string(), serde_json::json!("John"));
4618        context.insert("last".to_string(), serde_json::json!("Doe"));
4619
4620        let computed = vec![
4621            ("full_name".to_string(), "#first# #last#".to_string()),
4622            ("greeting".to_string(), "Hello #full_name#!".to_string()),
4623        ];
4624
4625        resolve_computed_variables(&computed, &mut context);
4626
4627        assert_eq!(
4628            context.get("full_name"),
4629            Some(&serde_json::json!("John Doe"))
4630        );
4631        assert_eq!(
4632            context.get("greeting"),
4633            Some(&serde_json::json!("Hello John Doe!"))
4634        );
4635    }
4636
4637    #[test]
4638    fn test_computed_variable_with_nested_path() {
4639        let mut context = HashMap::new();
4640        context.insert(
4641            "post".to_string(),
4642            serde_json::json!({
4643                "id": 42,
4644                "title": "My Post"
4645            }),
4646        );
4647
4648        let computed = vec![(
4649            "edit_url".to_string(),
4650            "/admin/posts/#post.id#/edit".to_string(),
4651        )];
4652
4653        resolve_computed_variables(&computed, &mut context);
4654
4655        assert_eq!(
4656            context.get("edit_url"),
4657            Some(&serde_json::json!("/admin/posts/42/edit"))
4658        );
4659    }
4660
4661    #[test]
4662    fn test_computed_variable_unresolved_reference() {
4663        let mut context = HashMap::new();
4664
4665        let computed = vec![("url".to_string(), "/page/#missing_var#".to_string())];
4666
4667        resolve_computed_variables(&computed, &mut context);
4668
4669        // Unresolved vars stay as-is
4670        assert_eq!(
4671            context.get("url"),
4672            Some(&serde_json::json!("/page/#missing_var#"))
4673        );
4674    }
4675
4676    #[test]
4677    fn test_computed_variable_no_prefix_in_template() {
4678        // Computed vars are available as #name# not #compute.name#
4679        let mut context = HashMap::new();
4680        context.insert("x".to_string(), serde_json::json!("world"));
4681
4682        let computed = vec![("greeting".to_string(), "hello #x#".to_string())];
4683
4684        resolve_computed_variables(&computed, &mut context);
4685
4686        // Use in template
4687        let result = replace_variables("Say: #greeting#", &context);
4688        assert_eq!(result, "Say: hello world");
4689    }
4690
4691    #[test]
4692    fn test_computed_variable_empty() {
4693        let mut context = HashMap::new();
4694        let computed: Vec<(String, String)> = Vec::new();
4695
4696        resolve_computed_variables(&computed, &mut context);
4697        // No crash, context unchanged
4698        assert!(context.is_empty());
4699    }
4700
4701    // ---- Wired Scope Parsing Tests ----
4702
4703    #[test]
4704    fn parse_wired_no_brackets() {
4705        let decl = parse_wired_decl("counter");
4706        assert_eq!(decl.name, "counter");
4707        assert!(matches!(decl.scope, WiredScope::Public));
4708    }
4709
4710    #[test]
4711    fn parse_wired_single_role() {
4712        let decl = parse_wired_decl("revenue [admin]");
4713        assert_eq!(decl.name, "revenue");
4714        match decl.scope {
4715            WiredScope::Roles(roles) => assert_eq!(roles, vec!["admin"]),
4716            _ => panic!("Expected Roles scope"),
4717        }
4718    }
4719
4720    #[test]
4721    fn parse_wired_multi_role() {
4722        let decl = parse_wired_decl("x [admin, editor]");
4723        assert_eq!(decl.name, "x");
4724        match decl.scope {
4725            WiredScope::Roles(roles) => assert_eq!(roles, vec!["admin", "editor"]),
4726            _ => panic!("Expected Roles scope"),
4727        }
4728    }
4729
4730    #[test]
4731    fn parse_wired_user_scope() {
4732        let decl = parse_wired_decl("notifs [user]");
4733        assert_eq!(decl.name, "notifs");
4734        assert!(matches!(decl.scope, WiredScope::User(_)));
4735    }
4736
4737    #[test]
4738    fn wired_backwards_compat() {
4739        // data.wired = ["counter", "visitors"] without brackets → all Public
4740        let content = r#"data.wired = ["counter", "visitors"]"#;
4741        let config = parse_what_file(content);
4742        assert_eq!(config.data_wired.len(), 2);
4743        assert_eq!(config.data_wired[0].name, "counter");
4744        assert!(matches!(config.data_wired[0].scope, WiredScope::Public));
4745        assert_eq!(config.data_wired[1].name, "visitors");
4746        assert!(matches!(config.data_wired[1].scope, WiredScope::Public));
4747    }
4748
4749    #[test]
4750    fn wired_scope_allows_public() {
4751        let scope = WiredScope::Public;
4752        assert!(scope.allows(&[], None));
4753        assert!(scope.allows(&["admin".into()], Some("user1")));
4754    }
4755
4756    #[test]
4757    fn wired_scope_allows_role_match() {
4758        let scope = WiredScope::Roles(vec!["admin".into(), "editor".into()]);
4759        assert!(scope.allows(&["admin".into()], None));
4760        assert!(scope.allows(&["editor".into()], None));
4761        assert!(!scope.allows(&["viewer".into()], None));
4762        assert!(!scope.allows(&[], None));
4763    }
4764
4765    #[test]
4766    fn wired_scope_allows_user_match() {
4767        let scope = WiredScope::User("user42".into());
4768        assert!(scope.allows(&[], Some("user42")));
4769        assert!(!scope.allows(&[], Some("user99")));
4770        assert!(!scope.allows(&[], None));
4771    }
4772
4773    #[test]
4774    fn test_is_unquoted_string() {
4775        // Numbers are not unquoted strings
4776        assert!(!is_unquoted_string("42"));
4777        assert!(!is_unquoted_string("3.14"));
4778        assert!(!is_unquoted_string("-1"));
4779        // Booleans and keywords are not unquoted strings
4780        assert!(!is_unquoted_string("true"));
4781        assert!(!is_unquoted_string("false"));
4782        assert!(!is_unquoted_string("none"));
4783        assert!(!is_unquoted_string("all"));
4784        assert!(!is_unquoted_string("user"));
4785        assert!(!is_unquoted_string("None"));
4786        assert!(!is_unquoted_string(""));
4787        // Actual strings should be flagged
4788        assert!(is_unquoted_string("Hello World"));
4789        assert!(is_unquoted_string("local:items"));
4790        assert!(is_unquoted_string("main"));
4791        assert!(is_unquoted_string("/login"));
4792    }
4793
4794    #[test]
4795    fn test_quoted_strings_no_warning() {
4796        // Quoted values should parse without warnings
4797        let content = r#"title: "My Page"
4798layout: "main"
4799fetch.items = "local:items"
4800greeting = "Hello World""#;
4801        let mut directives = PageDirectives::default();
4802        parse_directive_content(content, &mut directives);
4803        assert_eq!(directives.title.as_deref(), Some("My Page"));
4804        assert_eq!(directives.layout.as_deref(), Some("main"));
4805        assert_eq!(
4806            directives.custom.get("fetch.items").map(|s| s.as_str()),
4807            Some("local:items")
4808        );
4809        assert_eq!(
4810            directives.vars.get("greeting"),
4811            Some(&serde_json::json!("Hello World"))
4812        );
4813    }
4814
4815    #[test]
4816    fn test_unquoted_numbers_and_bools_ok() {
4817        // Numbers and booleans should not trigger warnings
4818        let content = "count = 42\nprice = 9.99\nactive = true";
4819        let mut directives = PageDirectives::default();
4820        parse_directive_content(content, &mut directives);
4821        assert_eq!(directives.vars.get("count"), Some(&serde_json::json!(42)));
4822        assert_eq!(directives.vars.get("price"), Some(&serde_json::json!(9.99)));
4823        assert_eq!(
4824            directives.vars.get("active"),
4825            Some(&serde_json::json!(true))
4826        );
4827    }
4828
4829    #[test]
4830    fn test_html_unescape_round_trip() {
4831        assert_eq!(html_unescape(&html_escape("Ben & Jerry")), "Ben & Jerry");
4832        assert_eq!(html_unescape(&html_escape("O'Brien")), "O'Brien");
4833        assert_eq!(html_unescape(&html_escape("a < b > c")), "a < b > c");
4834        // Author-written entity text survives the round trip un-collapsed
4835        assert_eq!(html_unescape(&html_escape("&lt;")), "&lt;");
4836        assert_eq!(html_unescape("plain"), "plain");
4837    }
4838
4839    #[test]
4840    fn test_count_filter_counts_items_not_bytes() {
4841        let mut ctx = HashMap::new();
4842        ctx.insert(
4843            "items".to_string(),
4844            serde_json::json!([{"name": "a"}, {"name": "b"}, {"name": "c"}]),
4845        );
4846        ctx.insert("name".to_string(), serde_json::json!("José"));
4847        assert_eq!(replace_variables("#items|count#", &ctx), "3");
4848        assert_eq!(replace_variables("#name|count#", &ctx), "4");
4849    }
4850
4851    #[test]
4852    fn test_within_one_edit() {
4853        assert!(within_one_edit("auth", "auth"));
4854        assert!(within_one_edit("auht", "auth")); // adjacent transposition
4855        assert!(within_one_edit("atuh", "auth")); // adjacent transposition
4856        assert!(within_one_edit("aut", "auth")); // deletion
4857        assert!(within_one_edit("auths", "auth")); // insertion
4858        assert!(within_one_edit("autj", "auth")); // substitution
4859        assert!(within_one_edit("oauth", "auth")); // deletion (filtered by first-letter guard)
4860        assert!(!within_one_edit("author", "auth"));
4861        assert!(!within_one_edit("au", "auth"));
4862        assert!(!within_one_edit("layout", "auth"));
4863    }
4864
4865    #[test]
4866    fn test_access_directive_near_miss() {
4867        assert_eq!(access_directive_near_miss("auht"), Some("auth"));
4868        assert_eq!(access_directive_near_miss("atuh"), Some("auth"));
4869        assert_eq!(access_directive_near_miss("aut"), Some("auth"));
4870        assert_eq!(access_directive_near_miss("Auth"), Some("auth")); // case typo
4871        assert_eq!(access_directive_near_miss("role"), Some("roles"));
4872        assert_eq!(access_directive_near_miss("protectd"), Some("protected"));
4873        // Exact key is not a near-miss (it parses as the real directive)
4874        assert_eq!(access_directive_near_miss("auth"), None);
4875        // Different first letter: legitimate variable names near these words
4876        assert_eq!(access_directive_near_miss("oauth"), None);
4877        // Unrelated keys
4878        assert_eq!(access_directive_near_miss("title"), None);
4879        assert_eq!(access_directive_near_miss("items"), None);
4880    }
4881
4882    #[test]
4883    fn test_auth_typo_key_stays_inline_var_and_page_stays_public() {
4884        // Documents the fail-open the near-miss warning exists for: `auht:`
4885        // is NOT `auth`, so the page keeps AuthLevel::All (public) and the
4886        // key becomes an inline variable.
4887        let content = r#"auht: "user""#;
4888        let mut directives = PageDirectives::default();
4889        parse_directive_content(content, &mut directives);
4890        assert!(matches!(directives.auth, AuthLevel::All));
4891        assert_eq!(directives.vars.get("auht"), Some(&serde_json::json!("user")));
4892    }
4893
4894    #[test]
4895    fn test_strip_symmetric_quotes() {
4896        assert_eq!(strip_symmetric_quotes(r#""hello""#), ("hello", true));
4897        assert_eq!(strip_symmetric_quotes("'hello'"), ("hello", true));
4898        assert_eq!(strip_symmetric_quotes("hello"), ("hello", false));
4899        // Mismatched quotes are left intact
4900        assert_eq!(strip_symmetric_quotes(r#""hello'"#), (r#""hello'"#, false));
4901        // Only ONE pair is stripped (the old trim_matches stripped repeats)
4902        assert_eq!(strip_symmetric_quotes("''x''"), ("'x'", true));
4903        // Empty quoted string
4904        assert_eq!(strip_symmetric_quotes(r#""""#), ("", true));
4905        // Bare quote char is not a pair
4906        assert_eq!(strip_symmetric_quotes(r#"""#), (r#"""#, false));
4907    }
4908
4909    #[test]
4910    fn test_quoting_forces_string_type_inline_vars() {
4911        // v1.0 rule: quoted values are strings, unquoted values are type-inferred.
4912        let content = "zip = \"01234\"\nversion = \"1.0\"\nflag = \"true\"\ncount = 42";
4913        let mut directives = PageDirectives::default();
4914        parse_directive_content(content, &mut directives);
4915        assert_eq!(
4916            directives.vars.get("zip"),
4917            Some(&serde_json::json!("01234")),
4918            "quoted leading-zero value must stay a string"
4919        );
4920        assert_eq!(
4921            directives.vars.get("version"),
4922            Some(&serde_json::json!("1.0")),
4923            "quoted numeric-looking value must stay a string"
4924        );
4925        assert_eq!(
4926            directives.vars.get("flag"),
4927            Some(&serde_json::json!("true")),
4928            "quoted boolean-looking value must stay a string"
4929        );
4930        assert_eq!(directives.vars.get("count"), Some(&serde_json::json!(42)));
4931    }
4932
4933    #[test]
4934    fn test_quoting_forces_string_type_session_mutations() {
4935        let set = parse_session_mutation(r#"session.zip = "01234""#).unwrap();
4936        match set {
4937            SessionMutation::Set { key, value } => {
4938                assert_eq!(key, "zip");
4939                assert_eq!(value, serde_json::json!("01234"));
4940            }
4941            other => panic!("expected Set, got {:?}", other),
4942        }
4943        let set = parse_session_mutation("session.count = 42").unwrap();
4944        match set {
4945            SessionMutation::Set { value, .. } => {
4946                assert_eq!(value, serde_json::json!(42));
4947            }
4948            other => panic!("expected Set, got {:?}", other),
4949        }
4950        let push = parse_session_mutation(r#"session.items.push("42")"#).unwrap();
4951        match push {
4952            SessionMutation::Push { value, .. } => {
4953                assert_eq!(value, serde_json::json!("42"));
4954            }
4955            other => panic!("expected Push, got {:?}", other),
4956        }
4957    }
4958
4959    #[test]
4960    fn test_mismatched_quotes_left_intact() {
4961        // A mismatched pair must not be silently swallowed.
4962        let content = "label = \"oops'";
4963        let mut directives = PageDirectives::default();
4964        parse_directive_content(content, &mut directives);
4965        assert_eq!(
4966            directives.vars.get("label"),
4967            Some(&serde_json::json!("\"oops'"))
4968        );
4969    }
4970
4971    #[test]
4972    fn test_what_file_quoted_number_stays_string() {
4973        // application.what files already kept quoted numbers as strings — lock it.
4974        let config = parse_what_file("zip = \"01234\"\ncount = 7");
4975        assert_eq!(config.values.get("zip"), Some(&serde_json::json!("01234")));
4976        assert_eq!(config.values.get("count"), Some(&serde_json::json!(7)));
4977    }
4978}