1use regex::Regex;
8use serde_json::{Value, json};
9use std::collections::HashMap;
10use std::sync::LazyLock;
11
12static VAR_REGEX: LazyLock<Regex> =
14 LazyLock::new(|| Regex::new(r"#([a-zA-Z_][a-zA-Z0-9_. +\-*/]*(?:\|[^#]*)?)#").unwrap());
15
16static ATTR_REGEX: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r#"([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*"([^"]*)""#).unwrap());
19
20static BOOL_ATTR_REGEX: LazyLock<Regex> =
22 LazyLock::new(|| Regex::new(r#"([a-zA-Z_][a-zA-Z0-9_-]*)"#).unwrap());
23
24static WHAT_DIRECTIVE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
27 Regex::new(r"(?s)<what((?:\s[^>]*)?)(?:/>|>(.*?)</what>)").unwrap()
34});
35
36#[derive(Clone, Debug, Default)]
42pub enum WiredScope {
43 #[default]
45 Public,
46 Roles(Vec<String>),
48 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 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#[derive(Clone, Debug)]
77pub struct WiredVarDecl {
78 pub name: String,
79 pub scope: WiredScope,
80}
81
82pub type ScopedVarDecl = WiredVarDecl;
85
86fn 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 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#[derive(Debug, Clone, Default)]
139pub(crate) struct WhatConfig {
140 pub values: HashMap<String, Value>,
142 pub directives: PageDirectives,
144 pub layout: Option<String>,
146 pub data_application: Vec<ScopedVarDecl>,
149 pub data_session: Vec<String>,
151 pub data_wired: Vec<WiredVarDecl>,
153}
154
155#[allow(dead_code)]
156impl WhatConfig {
157 pub fn get_string(&self, key: &str) -> Option<&str> {
159 self.values.get(key).and_then(|v| v.as_str())
160 }
161
162 pub fn get_number(&self, key: &str) -> Option<f64> {
164 self.values.get(key).and_then(|v| v.as_f64())
165 }
166
167 pub fn get_bool(&self, key: &str) -> Option<bool> {
169 self.values.get(key).and_then(|v| v.as_bool())
170 }
171
172 pub fn get_array(&self, key: &str) -> Option<&Vec<Value>> {
174 self.values.get(key).and_then(|v| v.as_array())
175 }
176
177 pub fn merge(&mut self, other: &WhatConfig) {
179 for (key, value) in &other.values {
180 self.values.insert(key.clone(), value.clone());
181 }
182 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 for (k, v) in &other.directives.headers {
206 self.directives.headers.insert(k.clone(), v.clone());
207 }
208 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 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 pub fn to_context(&self) -> HashMap<String, Value> {
229 self.values.clone()
230 }
231}
232
233pub(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 if line.is_empty() || line.starts_with("//") || line.starts_with('#') {
259 continue;
260 }
261
262 if let Some(idx) = line.find('=') {
264 let key = line[..idx].trim().to_lowercase();
265 let value_str = line[idx + 1..].trim();
266
267 let value = parse_what_value(value_str);
269
270 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 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 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 let is_header = key.starts_with("header.");
369 if !is_security_directive && !is_header {
370 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
393fn 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 vec![s.to_string()]
402 } else {
403 Vec::new()
404 }
405}
406
407fn 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
420fn 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
463fn parse_what_value(s: &str) -> Value {
465 let s = s.trim();
466
467 if s == "true" {
469 return json!(true);
470 }
471 if s == "false" {
472 return json!(false);
473 }
474
475 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 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 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 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 json!(s.to_string())
506}
507
508pub(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#[derive(Debug, Clone, PartialEq)]
525struct Filter {
526 name: String,
527 args: Vec<String>,
528}
529
530struct FilterResult {
532 value: String,
533 html_safe: bool,
534}
535
536fn 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 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
571fn 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
598fn 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 }
612 c if c == quote_char && in_quote => {
613 in_quote = false;
614 }
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
632fn 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 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 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 let n: f64 = value.parse().unwrap_or(0.0);
717 FilterResult {
718 value: if n == 1.0 {
719 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 => {
815 warn_unknown_filter_once(unknown);
816 FilterResult {
817 value: value.to_string(),
818 html_safe: false,
819 }
820 }
821 }
822}
823
824static 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
840fn 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(¤t.value, filter);
848 }
849 current
850}
851
852fn contains_arithmetic(s: &str) -> bool {
856 s.contains(" + ") || s.contains(" - ") || s.contains(" * ") || s.contains(" / ")
857}
858
859fn 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 let resolved = INLINE_VAR
866 .replace_all(expr, |caps: ®ex::Captures| {
867 let token = &caps[0];
868 let val = resolve_variable(token, context);
869 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
880pub(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; }
893
894 evaluate_with_precedence(&tokens)
895}
896
897pub(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), }
911
912fn 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 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 return None;
950 }
951 }
952
953 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 if tokens.len() % 2 == 0 {
963 return None;
964 }
965
966 Some(tokens)
967}
968
969fn evaluate_with_precedence(tokens: &[ArithToken]) -> Option<f64> {
971 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 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; }
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 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
1014fn 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 if let Ok(n) = s.parse::<f64>() {
1043 if n == n.floor() && n.abs() < i64::MAX as f64 {
1044 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 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 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 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 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 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 result.push(chars[i]);
1240 i += 1;
1241 }
1242 }
1243
1244 result
1245}
1246
1247fn simple_markdown(s: &str) -> String {
1248 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 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
1284static 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 result = MD_BOLD_RE
1296 .replace_all(&result, "<strong>$1</strong>")
1297 .to_string();
1298
1299 result = MD_ITALIC_RE.replace_all(&result, "<em>$1</em>").to_string();
1301
1302 result = MD_LINK_RE
1305 .replace_all(&result, |caps: ®ex::Captures| {
1306 let text = &caps[1];
1307 let url = caps[2].trim();
1308 if markdown_url_is_safe(url) {
1309 format!(r#"<a href="{}">{}</a>"#, url, text)
1310 } else {
1311 text.to_string()
1312 }
1313 })
1314 .to_string();
1315
1316 result
1317}
1318
1319fn markdown_url_is_safe(url: &str) -> bool {
1324 let stripped: String = url
1325 .chars()
1326 .filter(|c| !c.is_control() && !c.is_whitespace())
1327 .collect();
1328 let lower = stripped.to_lowercase();
1329 match lower.find(':') {
1330 None => true, Some(colon) => {
1332 let before = &lower[..colon];
1333 if before.contains('/') || before.contains('#') || before.contains('?') {
1336 return true;
1337 }
1338 let is_scheme = !before.is_empty()
1339 && before.chars().next().unwrap().is_ascii_alphabetic()
1340 && before
1341 .chars()
1342 .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.');
1343 if is_scheme {
1344 matches!(before, "http" | "https" | "mailto" | "tel")
1345 } else {
1346 true
1347 }
1348 }
1349 }
1350}
1351
1352pub(crate) fn replace_variables(
1356 template: &str,
1357 context: &HashMap<String, serde_json::Value>,
1358) -> String {
1359 VAR_REGEX
1360 .replace_all(template, |caps: ®ex::Captures| {
1361 let expr = &caps[1];
1362 let (var_path, filters) = parse_filter_chain(expr);
1363
1364 let raw_value = if contains_arithmetic(var_path) {
1366 resolve_and_evaluate_arithmetic(var_path, context)
1367 .unwrap_or_else(|| resolve_variable(var_path, context))
1368 } else {
1369 resolve_variable(var_path, context)
1370 };
1371
1372 let is_unresolved = raw_value.starts_with('#') && raw_value.ends_with('#');
1374
1375 let is_preserved_token = raw_value == format!("#{var_path}#");
1381
1382 let filtered = if filters.is_empty() {
1384 FilterResult {
1385 value: raw_value,
1386 html_safe: false,
1387 }
1388 } else {
1389 let input = if is_unresolved && filters.iter().any(|f| f.name == "default") {
1391 String::new()
1392 } else {
1393 raw_value
1394 };
1395 apply_filters(&input, &filters)
1396 };
1397
1398 if filtered.html_safe {
1402 filtered.value
1403 } else if is_preserved_token {
1404 html_escape(&filtered.value)
1405 } else {
1406 escape_and_neutralize_hashes(&filtered.value)
1407 }
1408 })
1409 .to_string()
1410}
1411
1412#[derive(Debug, Clone, Default)]
1414pub struct ReactiveReplaceResult {
1415 pub html: String,
1417 pub session_keys: std::collections::HashSet<String>,
1419}
1420
1421pub(crate) fn replace_variables_reactive(
1426 template: &str,
1427 context: &HashMap<String, serde_json::Value>,
1428) -> ReactiveReplaceResult {
1429 let mut session_keys = std::collections::HashSet::new();
1430
1431 let mut result = String::with_capacity(template.len());
1433 let mut last_end = 0;
1434
1435 for caps in VAR_REGEX.captures_iter(template) {
1436 let m = caps.get(0).unwrap();
1437 let expr = &caps[1];
1438 let start = m.start();
1439
1440 result.push_str(&template[last_end..start]);
1442
1443 let (var_path, filters) = parse_filter_chain(expr);
1445
1446 let is_session_var = var_path.starts_with("session.");
1448 let is_wired_var = var_path.starts_with("wired.");
1449
1450 let raw_value = if contains_arithmetic(var_path) {
1452 resolve_and_evaluate_arithmetic(var_path, context)
1453 .unwrap_or_else(|| resolve_variable(var_path, context))
1454 } else {
1455 resolve_variable(var_path, context)
1456 };
1457 let is_unresolved = raw_value.starts_with('#') && raw_value.ends_with('#');
1458 let is_preserved_token = raw_value == format!("#{var_path}#");
1462
1463 let filtered = if filters.is_empty() {
1465 FilterResult {
1466 value: raw_value,
1467 html_safe: false,
1468 }
1469 } else {
1470 let input = if is_unresolved && filters.iter().any(|f| f.name == "default") {
1471 String::new()
1472 } else {
1473 raw_value
1474 };
1475 apply_filters(&input, &filters)
1476 };
1477
1478 if is_session_var || is_wired_var {
1479 let (bind_prefix, bind_key) = if is_session_var {
1480 let key = &var_path[8..]; session_keys.insert(key.to_string());
1482 ("session", key)
1483 } else {
1484 let key = &var_path[6..]; ("wired", key)
1486 };
1487
1488 let display_value = if filtered.value.starts_with('#') && filtered.value.ends_with('#')
1491 {
1492 String::new()
1493 } else {
1494 filtered.value
1495 };
1496
1497 let text_before = &template[..start];
1499 let in_attribute = is_in_attribute_context(text_before);
1500
1501 if in_attribute {
1502 if filtered.html_safe {
1504 result.push_str(&display_value);
1505 } else {
1506 result.push_str(&escape_and_neutralize_hashes(&display_value));
1507 }
1508 } else {
1509 result.push_str(&format!(
1512 r#"<span w-bind="{}.{}">{}</span>"#,
1513 bind_prefix,
1514 bind_key,
1515 escape_and_neutralize_hashes(&display_value)
1516 ));
1517 }
1518 } else {
1519 if filtered.html_safe {
1521 result.push_str(&filtered.value);
1522 } else if is_preserved_token {
1523 result.push_str(&html_escape(&filtered.value));
1524 } else {
1525 result.push_str(&escape_and_neutralize_hashes(&filtered.value));
1526 }
1527 }
1528
1529 last_end = m.end();
1530 }
1531
1532 result.push_str(&template[last_end..]);
1534
1535 ReactiveReplaceResult {
1536 html: result,
1537 session_keys,
1538 }
1539}
1540
1541fn is_in_attribute_context(text_before: &str) -> bool {
1544 let mut in_attr_value = false;
1547 let mut quote_char: Option<char> = None;
1548
1549 for c in text_before.chars().rev() {
1550 match c {
1551 '"' | '\'' if quote_char == Some(c) => {
1552 quote_char = None;
1554 in_attr_value = false;
1555 }
1556 '"' | '\'' if quote_char.is_none() => {
1557 quote_char = Some(c);
1559 in_attr_value = true;
1560 }
1561 '>' if quote_char.is_none() => {
1562 return false;
1564 }
1565 '<' if quote_char.is_none() => {
1566 return in_attr_value;
1569 }
1570 _ => {}
1571 }
1572 }
1573
1574 in_attr_value
1576}
1577
1578fn html_escape(s: &str) -> String {
1580 s.replace('&', "&")
1581 .replace('<', "<")
1582 .replace('>', ">")
1583 .replace('"', """)
1584 .replace('\'', "'")
1585}
1586
1587fn escape_and_neutralize_hashes(s: &str) -> String {
1599 const SENTINEL: &str = "\u{E000}"; let protected = s.replace('#', SENTINEL);
1601 html_escape(&protected).replace(SENTINEL, "#")
1602}
1603
1604pub(crate) fn html_unescape(s: &str) -> String {
1610 if !s.contains('&') {
1611 return s.to_string();
1612 }
1613 s.replace("<", "<")
1614 .replace(">", ">")
1615 .replace(""", "\"")
1616 .replace("'", "'")
1617 .replace("&", "&")
1618}
1619
1620pub(crate) fn resolve_computed_variables(
1625 computed: &[(String, String)],
1626 context: &mut HashMap<String, serde_json::Value>,
1627) {
1628 for (name, template) in computed {
1629 let resolved = VAR_REGEX
1631 .replace_all(template, |caps: ®ex::Captures| {
1632 let var_path = &caps[1];
1633 resolve_variable(var_path, context)
1634 })
1635 .to_string();
1636
1637 context.insert(name.clone(), serde_json::Value::String(resolved));
1639 }
1640}
1641
1642fn resolve_variable(path: &str, context: &HashMap<String, serde_json::Value>) -> String {
1646 let parts: Vec<&str> = path.split('.').collect();
1647
1648 if parts.is_empty() {
1649 return String::new();
1650 }
1651
1652 if parts[0] == "env" && parts.len() >= 2 {
1654 let env_var_name = parts[1..].join("_"); return std::env::var(&env_var_name).unwrap_or_default();
1656 }
1657
1658 let root = context.get(parts[0]);
1659 let mut current: Option<&serde_json::Value> = root;
1660
1661 for part in parts.iter().skip(1) {
1662 current = current.and_then(|v| {
1663 if let serde_json::Value::Object(obj) = v {
1664 obj.get(*part)
1665 } else {
1666 None
1667 }
1668 });
1669 }
1670
1671 match current {
1672 Some(serde_json::Value::String(s)) => s.clone(),
1673 Some(serde_json::Value::Number(n)) => n.to_string(),
1674 Some(serde_json::Value::Bool(b)) => b.to_string(),
1675 Some(serde_json::Value::Null) => String::new(),
1676 Some(v) => v.to_string(),
1677 None if root.is_some() && parts.len() > 1 => String::new(), None => format!("#{}#", path), }
1680}
1681
1682#[allow(dead_code)]
1684pub(crate) fn is_standard_html_tag(name: &str) -> bool {
1685 matches!(
1686 name,
1687 "html"
1688 | "head"
1689 | "body"
1690 | "title"
1691 | "meta"
1692 | "link"
1693 | "script"
1694 | "style"
1695 | "div"
1696 | "span"
1697 | "p"
1698 | "a"
1699 | "img"
1700 | "br"
1701 | "hr"
1702 | "h1"
1703 | "h2"
1704 | "h3"
1705 | "h4"
1706 | "h5"
1707 | "h6"
1708 | "ul"
1709 | "ol"
1710 | "li"
1711 | "dl"
1712 | "dt"
1713 | "dd"
1714 | "table"
1715 | "thead"
1716 | "tbody"
1717 | "tfoot"
1718 | "tr"
1719 | "th"
1720 | "td"
1721 | "form"
1722 | "input"
1723 | "textarea"
1724 | "select"
1725 | "option"
1726 | "button"
1727 | "label"
1728 | "header"
1729 | "footer"
1730 | "main"
1731 | "nav"
1732 | "section"
1733 | "article"
1734 | "aside"
1735 | "figure"
1736 | "figcaption"
1737 | "video"
1738 | "audio"
1739 | "source"
1740 | "canvas"
1741 | "iframe"
1742 | "embed"
1743 | "object"
1744 | "param"
1745 | "strong"
1746 | "em"
1747 | "b"
1748 | "i"
1749 | "u"
1750 | "s"
1751 | "mark"
1752 | "small"
1753 | "sub"
1754 | "sup"
1755 | "blockquote"
1756 | "pre"
1757 | "code"
1758 | "kbd"
1759 | "samp"
1760 | "var"
1761 | "time"
1762 | "address"
1763 | "abbr"
1764 | "cite"
1765 | "q"
1766 | "ins"
1767 | "del"
1768 | "dfn"
1769 | "ruby"
1770 | "rt"
1771 | "rp"
1772 | "bdi"
1773 | "bdo"
1774 | "wbr"
1775 | "details"
1776 | "summary"
1777 | "dialog"
1778 | "slot"
1779 | "template"
1780 | "noscript"
1781 )
1782}
1783
1784#[derive(Debug, Clone, PartialEq)]
1786pub(crate) enum AuthLevel {
1787 All,
1789 User,
1791 Roles(Vec<String>),
1793}
1794
1795impl std::fmt::Display for AuthLevel {
1796 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1797 match self {
1798 AuthLevel::All => write!(f, "all"),
1799 AuthLevel::User => write!(f, "user"),
1800 AuthLevel::Roles(v) => write!(f, "roles: {}", v.join(", ")),
1801 }
1802 }
1803}
1804
1805impl Default for AuthLevel {
1806 fn default() -> Self {
1807 AuthLevel::All
1808 }
1809}
1810
1811#[derive(Debug, Clone)]
1838pub(crate) enum SessionMutation {
1839 Increment { key: String, value: i64 },
1841 Set { key: String, value: Value },
1843 Push { key: String, value: Value },
1845 PushMax {
1847 key: String,
1848 max: usize,
1849 value: Value,
1850 },
1851 Unshift { key: String, value: Value },
1853 Clear { key: String },
1855}
1856
1857#[derive(Debug, Clone, Default)]
1858pub(crate) struct PageDirectives {
1859 pub auth: AuthLevel,
1861 pub protected: bool,
1863 pub roles: Vec<String>,
1865 pub exclude: bool,
1867 pub title: Option<String>,
1869 pub redirect: Option<String>,
1871 pub cache_ttl: Option<u64>,
1873 pub layout: Option<String>,
1876 pub session_mutations: Vec<SessionMutation>,
1878 pub computed: Vec<(String, String)>,
1880 pub headers: HashMap<String, String>,
1882 pub custom: HashMap<String, String>,
1884 pub vars: HashMap<String, Value>,
1886}
1887
1888impl PageDirectives {
1889 pub fn requires_auth(&self) -> bool {
1892 match &self.auth {
1893 AuthLevel::All => self.protected, AuthLevel::User => true,
1895 AuthLevel::Roles(_) => true,
1896 }
1897 }
1898
1899 pub fn check_access(&self, authenticated: bool, user_roles: &[String]) -> bool {
1904 match &self.auth {
1905 AuthLevel::All => {
1906 if self.protected {
1908 if !authenticated {
1909 return false;
1910 }
1911 self.has_role(user_roles)
1912 } else {
1913 true
1914 }
1915 }
1916 AuthLevel::User => authenticated,
1917 AuthLevel::Roles(required) => {
1918 authenticated && required.iter().any(|r| user_roles.contains(r))
1919 }
1920 }
1921 }
1922
1923 pub fn has_role(&self, user_roles: &[String]) -> bool {
1925 if self.roles.is_empty() {
1926 return true; }
1928 self.roles.iter().any(|r| user_roles.contains(r))
1929 }
1930}
1931
1932pub(crate) fn parse_page_directives(content: &str) -> (PageDirectives, String) {
1935 let mut directives = PageDirectives::default();
1936
1937 let cleaned = WHAT_DIRECTIVE_REGEX
1938 .replace_all(content, |caps: ®ex::Captures| {
1939 let attrs_str = caps.get(1).map(|m| m.as_str()).unwrap_or("");
1940 let inner_content = caps.get(2).map(|m| m.as_str());
1941
1942 parse_directive_attributes(attrs_str, &mut directives);
1944
1945 if let Some(inner) = inner_content {
1947 parse_directive_content(inner, &mut directives);
1948 }
1949
1950 "" })
1952 .to_string();
1953
1954 (directives, cleaned)
1955}
1956
1957pub(crate) fn parse_auth_level(value: &str) -> AuthLevel {
1959 let value = value.trim().to_lowercase();
1960 match value.as_str() {
1961 "all" | "public" | "none" => AuthLevel::All,
1962 "user" | "authenticated" => AuthLevel::User,
1963 _ => {
1964 let roles: Vec<String> = value
1966 .split(',')
1967 .map(|s| s.trim().to_string())
1968 .filter(|s| !s.is_empty())
1969 .collect();
1970 if roles.is_empty() {
1971 AuthLevel::All
1972 } else {
1973 AuthLevel::Roles(roles)
1974 }
1975 }
1976 }
1977}
1978
1979fn parse_directive_attributes(attrs_str: &str, directives: &mut PageDirectives) {
1981 let attrs = parse_attributes(attrs_str);
1983
1984 for (key, value) in &attrs {
1985 match key.as_str() {
1986 "auth" => {
1987 directives.auth = parse_auth_level(value);
1988 }
1989 "protected" => directives.protected = value != "false",
1991 "roles" => {
1992 directives.roles = value
1993 .split(',')
1994 .map(|s| s.trim().to_string())
1995 .filter(|s| !s.is_empty())
1996 .collect();
1997 if !directives.roles.is_empty() {
1999 directives.protected = true;
2000 }
2001 }
2002 "exclude" => directives.exclude = value != "false",
2003 "title" => directives.title = Some(value.clone()),
2004 "redirect" => directives.redirect = Some(value.clone()),
2005 "layout" => directives.layout = Some(value.clone()),
2006 "cache" | "cache-ttl" => {
2007 directives.cache_ttl = value.parse().ok();
2008 }
2009 _ => {
2010 if let Some(header_name) = key.strip_prefix("header.") {
2012 directives
2013 .headers
2014 .insert(header_name.to_string(), value.clone());
2015 } else {
2016 warn_access_directive_near_miss(key);
2017 directives.custom.insert(key.clone(), value.clone());
2018 }
2019 }
2020 }
2021 }
2022
2023 let without_attrs = ATTR_REGEX.replace_all(attrs_str, "");
2026 for cap in BOOL_ATTR_REGEX.captures_iter(&without_attrs) {
2027 let key = &cap[1];
2028 match key {
2029 "protected" => directives.protected = true,
2030 "exclude" => directives.exclude = true,
2031 _ => {}
2032 }
2033 }
2034}
2035
2036fn is_reserved_directive(key: &str) -> bool {
2038 matches!(
2039 key,
2040 "auth"
2041 | "protected"
2042 | "roles"
2043 | "exclude"
2044 | "title"
2045 | "redirect"
2046 | "layout"
2047 | "cache"
2048 | "cache-ttl"
2049 | "method"
2050 | "paginate"
2051 ) || key.starts_with("fetch.")
2052 || key.starts_with("session.")
2053 || key.starts_with("compute.")
2054 || key.starts_with("set.")
2055 || key.starts_with("data.")
2056 || key.starts_with("header.")
2057 || key.starts_with("mutation.")
2058}
2059
2060fn within_one_edit(a: &str, b: &str) -> bool {
2063 if a == b {
2064 return true;
2065 }
2066 let a: Vec<char> = a.chars().collect();
2067 let b: Vec<char> = b.chars().collect();
2068 if a.len().abs_diff(b.len()) > 1 {
2069 return false;
2070 }
2071 if a.len() == b.len() {
2072 let diffs: Vec<usize> = (0..a.len()).filter(|&i| a[i] != b[i]).collect();
2073 match diffs.len() {
2074 1 => true,
2075 2 => {
2076 diffs[1] == diffs[0] + 1
2077 && a[diffs[0]] == b[diffs[1]]
2078 && a[diffs[1]] == b[diffs[0]]
2079 }
2080 _ => false,
2081 }
2082 } else {
2083 let (short, long) = if a.len() < b.len() { (&a, &b) } else { (&b, &a) };
2084 let mut i = 0;
2085 let mut j = 0;
2086 let mut skipped = false;
2087 while i < short.len() && j < long.len() {
2088 if short[i] == long[j] {
2089 i += 1;
2090 j += 1;
2091 } else if skipped {
2092 return false;
2093 } else {
2094 skipped = true;
2095 j += 1;
2096 }
2097 }
2098 true
2099 }
2100}
2101
2102fn access_directive_near_miss(key: &str) -> Option<&'static str> {
2111 const ACCESS_KEYS: [&str; 3] = ["auth", "protected", "roles"];
2112 let lower = key.to_lowercase();
2113 ACCESS_KEYS.into_iter().find(|reserved| {
2117 key != *reserved
2118 && lower.chars().next() == reserved.chars().next()
2119 && within_one_edit(&lower, reserved)
2120 })
2121}
2122
2123static WARNED_NEAR_MISSES: LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
2126 LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new()));
2127
2128fn warn_access_directive_near_miss(key: &str) {
2133 if let Some(reserved) = access_directive_near_miss(key) {
2134 let mut warned = WARNED_NEAR_MISSES.lock().unwrap();
2135 if warned.insert(key.to_string()) {
2136 tracing::error!(
2137 "<what> key '{}' looks like a misspelling of the '{}' access-control directive. \
2138 It was treated as an inline variable, so NO access restriction was applied to this page. \
2139 If you meant '{}', fix the spelling; if it is a real variable, rename it.",
2140 key,
2141 reserved,
2142 reserved
2143 );
2144 }
2145 }
2146}
2147
2148fn parse_directive_content(content: &str, directives: &mut PageDirectives) {
2150 let mut current_section: Option<String> = None;
2151
2152 let mut json_key: Option<String> = None;
2154 let mut json_buf = String::new();
2155 let mut json_depth: usize = 0;
2156 let mut json_bracket: char = ' '; let lines: Vec<&str> = content.lines().collect();
2159 let mut i = 0;
2160
2161 while i < lines.len() {
2162 let raw_line = lines[i];
2163 let trimmed = raw_line.trim();
2164 i += 1;
2165
2166 if json_key.is_some() {
2168 json_buf.push('\n');
2169 json_buf.push_str(trimmed);
2170 let close_char = if json_bracket == '[' { ']' } else { '}' };
2171 json_depth += trimmed.matches(json_bracket).count();
2172 json_depth -= trimmed.matches(close_char).count();
2173 if json_depth == 0 {
2174 let key = json_key.take().unwrap();
2176 let relaxed = relax_json(&json_buf);
2177 match serde_json::from_str::<Value>(&relaxed) {
2178 Ok(val) => {
2179 directives.vars.insert(key, val);
2180 }
2181 Err(e) => {
2182 tracing::warn!("Invalid JSON for inline var: {}", e);
2183 }
2184 }
2185 json_buf.clear();
2186 }
2187 continue;
2188 }
2189
2190 if trimmed.is_empty() {
2191 continue;
2192 }
2193
2194 if trimmed.starts_with('[') && trimmed.ends_with(']') {
2196 let inner = trimmed[1..trimmed.len() - 1].trim();
2197 if inner.is_empty() {
2198 current_section = None;
2199 } else {
2200 current_section = Some(inner.to_lowercase().to_string());
2201 }
2202 continue;
2203 }
2204
2205 let line_owned;
2207 let line: &str = if let Some(ref section) = current_section {
2208 line_owned = format!("{}.{}", section, trimmed);
2209 line_owned.trim()
2210 } else {
2211 trimmed
2212 };
2213
2214 if line.starts_with("session.") {
2216 if let Some(mutation) = parse_session_mutation(line) {
2217 directives.session_mutations.push(mutation);
2218 }
2219 continue;
2220 }
2221
2222 if line.starts_with("compute.") {
2224 if let Some(eq_pos) = line.find('=') {
2225 let name = line[8..eq_pos].trim().to_string(); let value = line[eq_pos + 1..].trim();
2227 let value = strip_symmetric_quotes(value).0.to_string();
2229 directives.computed.push((name, value));
2230 }
2231 continue;
2232 }
2233
2234 if !line.contains(':') && !line.contains('=') {
2236 match line.to_lowercase().as_str() {
2237 "protected" => directives.protected = true,
2238 "exclude" => directives.exclude = true,
2239 _ => {}
2240 }
2241 continue;
2242 }
2243
2244 let colon_idx = line.find(':');
2247 let equals_idx = line.find('=');
2248 let (key, value) = match (colon_idx, equals_idx) {
2249 (Some(c), Some(e)) => {
2250 if e < c {
2251 (&line[..e], line[e + 1..].trim())
2252 } else {
2253 (&line[..c], line[c + 1..].trim())
2254 }
2255 }
2256 (Some(c), None) => (&line[..c], line[c + 1..].trim()),
2257 (None, Some(e)) => (&line[..e], line[e + 1..].trim()),
2258 _ => continue,
2259 };
2260
2261 let key = key.trim().to_lowercase();
2262 let (value, value_was_quoted) = strip_symmetric_quotes(value);
2263 if !value_was_quoted
2264 && value.len() >= 2
2265 && (value.starts_with('"')
2266 || value.starts_with('\'')
2267 || value.ends_with('"')
2268 || value.ends_with('\''))
2269 {
2270 tracing::warn!(
2271 "Mismatched quotes in <what> block value for '{}': {} — use one matching pair, e.g. \"value\"",
2272 key,
2273 value
2274 );
2275 }
2276
2277 match key.as_str() {
2278 "auth" => {
2279 directives.auth = parse_auth_level(value);
2280 }
2281 "protected" => directives.protected = value != "false",
2283 "roles" => {
2284 directives.roles = value
2285 .split(',')
2286 .map(|s| s.trim().to_string())
2287 .filter(|s| !s.is_empty())
2288 .collect();
2289 if !directives.roles.is_empty() {
2290 directives.protected = true;
2291 }
2292 }
2293 "exclude" => directives.exclude = value != "false",
2294 "title" => {
2295 if !value_was_quoted && is_unquoted_string(value) {
2296 tracing::warn!(
2297 "Unquoted string in <what> block: title should be quoted, e.g. title: \"{}\"",
2298 value
2299 );
2300 }
2301 directives.title = Some(value.to_string());
2302 }
2303 "redirect" => {
2304 if !value_was_quoted && is_unquoted_string(value) {
2305 tracing::warn!(
2306 "Unquoted string in <what> block: redirect should be quoted, e.g. redirect: \"{}\"",
2307 value
2308 );
2309 }
2310 directives.redirect = Some(value.to_string());
2311 }
2312 "layout" => {
2313 if !value_was_quoted && is_unquoted_string(value) {
2314 tracing::warn!(
2315 "Unquoted string in <what> block: layout should be quoted, e.g. layout: \"{}\"",
2316 value
2317 );
2318 }
2319 directives.layout = Some(value.to_string());
2320 }
2321 "cache" | "cache-ttl" => {
2322 directives.cache_ttl = value.parse().ok();
2323 }
2324 _ => {
2325 if let Some(header_name) = key.strip_prefix("header.") {
2327 directives
2328 .headers
2329 .insert(header_name.to_string(), value.to_string());
2330 } else if is_reserved_directive(&key) {
2331 if !value_was_quoted && !value.is_empty() && is_unquoted_string(value) {
2333 tracing::warn!(
2334 "Unquoted string in <what> block: {} should be quoted, e.g. {} = \"{}\"",
2335 key,
2336 key,
2337 value
2338 );
2339 }
2340 directives.custom.insert(key, value.to_string());
2341 } else {
2342 warn_access_directive_near_miss(&key);
2344 let value_untrimmed = {
2346 let eq_pos = line.find('=').or_else(|| line.find(':')).unwrap();
2347 line[eq_pos + 1..].trim()
2348 };
2349 if (value_untrimmed.starts_with('[') || value_untrimmed.starts_with('{'))
2350 && !value_untrimmed.ends_with(']')
2351 && !value_untrimmed.ends_with('}')
2352 {
2353 json_bracket = value_untrimmed.chars().next().unwrap();
2355 json_buf = value_untrimmed.to_string();
2356 json_depth = value_untrimmed.matches(json_bracket).count();
2357 let close_char = if json_bracket == '[' { ']' } else { '}' };
2358 json_depth -= value_untrimmed.matches(close_char).count();
2359 if json_depth == 0 {
2360 let relaxed = relax_json(value_untrimmed);
2362 match serde_json::from_str::<Value>(&relaxed) {
2363 Ok(val) => {
2364 directives.vars.insert(key, val);
2365 }
2366 Err(e) => {
2367 tracing::warn!("Invalid JSON for inline var '{}': {}", key, e);
2368 }
2369 }
2370 json_buf.clear();
2371 } else {
2372 json_key = Some(key);
2373 }
2374 } else if value_untrimmed.starts_with('[') || value_untrimmed.starts_with('{') {
2375 let relaxed = relax_json(value_untrimmed);
2377 match serde_json::from_str::<Value>(&relaxed) {
2378 Ok(val) => {
2379 directives.vars.insert(key, val);
2380 }
2381 Err(e) => {
2382 tracing::warn!("Invalid JSON for inline var '{}': {}", key, e);
2383 }
2384 }
2385 } else {
2386 let parsed = if value_was_quoted {
2390 Value::String(value.to_string())
2391 } else {
2392 parse_inline_value(value)
2393 };
2394 if !value_was_quoted && is_unquoted_string(value) {
2396 tracing::warn!(
2397 "Unquoted string in <what> block: {} should be quoted, e.g. {} = \"{}\"",
2398 key,
2399 key,
2400 value
2401 );
2402 }
2403 directives.vars.insert(key, parsed);
2404 }
2405 }
2406 }
2407 }
2408 }
2409}
2410
2411pub(crate) fn strip_symmetric_quotes(s: &str) -> (&str, bool) {
2417 let bytes = s.as_bytes();
2418 if bytes.len() >= 2 {
2419 let first = bytes[0];
2420 if (first == b'"' || first == b'\'') && bytes[bytes.len() - 1] == first {
2421 return (&s[1..s.len() - 1], true);
2422 }
2423 }
2424 (s, false)
2425}
2426
2427fn is_unquoted_string(value: &str) -> bool {
2430 if value.is_empty() {
2431 return false;
2432 }
2433 if value.parse::<f64>().is_ok() {
2434 return false;
2435 }
2436 match value.to_lowercase().as_str() {
2437 "true" | "false" | "none" | "all" | "user" => false,
2438 _ => true,
2439 }
2440}
2441
2442fn relax_json(s: &str) -> String {
2445 static UNQUOTED_KEY: LazyLock<Regex> =
2446 LazyLock::new(|| Regex::new(r#"(?m)([{\[,])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:"#).unwrap());
2447 UNQUOTED_KEY.replace_all(s, r#"$1 "$2":"#).into_owned()
2448}
2449
2450fn parse_inline_value(s: &str) -> Value {
2452 if let Ok(n) = s.parse::<i64>() {
2454 return json!(n);
2455 }
2456 if let Ok(n) = s.parse::<f64>() {
2458 return json!(n);
2459 }
2460 if s == "true" {
2462 return json!(true);
2463 }
2464 if s == "false" {
2465 return json!(false);
2466 }
2467 json!(s)
2469}
2470
2471fn parse_session_mutation(line: &str) -> Option<SessionMutation> {
2473 let rest = line.strip_prefix("session.")?;
2475
2476 if let Some(idx) = rest.find(".pushmax(") {
2479 let key = rest[..idx].trim().to_string();
2480 let value_start = idx + 9; let value_end = rest.rfind(')')?;
2482 let inner = rest[value_start..value_end].trim();
2483 if let Some(comma) = inner.find(',') {
2485 let max_str = inner[..comma].trim();
2486 let (value_str, was_quoted) = strip_symmetric_quotes(inner[comma + 1..].trim());
2487 if let Ok(max) = max_str.parse::<usize>() {
2488 let value = parse_mutation_value(value_str, was_quoted);
2489 return Some(SessionMutation::PushMax { key, max, value });
2490 }
2491 }
2492 }
2493
2494 if let Some(idx) = rest.find(".push(") {
2495 let key = rest[..idx].trim().to_string();
2496 let value_start = idx + 6; let value_end = rest.rfind(')')?;
2498 let (value_str, was_quoted) = strip_symmetric_quotes(rest[value_start..value_end].trim());
2499 let value = parse_mutation_value(value_str, was_quoted);
2500 return Some(SessionMutation::Push { key, value });
2501 }
2502
2503 if let Some(idx) = rest.find(".unshift(") {
2504 let key = rest[..idx].trim().to_string();
2505 let value_start = idx + 9; let value_end = rest.rfind(')')?;
2507 let (value_str, was_quoted) = strip_symmetric_quotes(rest[value_start..value_end].trim());
2508 let value = parse_mutation_value(value_str, was_quoted);
2509 return Some(SessionMutation::Unshift { key, value });
2510 }
2511
2512 if let Some(idx) = rest.find(".clear()") {
2513 let key = rest[..idx].trim().to_string();
2514 return Some(SessionMutation::Clear { key });
2515 }
2516
2517 if let Some(idx) = rest.find("+=") {
2519 let key = rest[..idx].trim().to_string();
2520 let value_str = rest[idx + 2..].trim();
2521 let value: i64 = value_str.parse().ok()?;
2522 return Some(SessionMutation::Increment { key, value });
2523 }
2524
2525 if let Some(idx) = rest.find("-=") {
2527 let key = rest[..idx].trim().to_string();
2528 let value_str = rest[idx + 2..].trim();
2529 let value: i64 = value_str.parse().ok()?;
2530 return Some(SessionMutation::Increment { key, value: -value });
2531 }
2532
2533 if let Some(idx) = rest.find('=') {
2535 let key = rest[..idx].trim().to_string();
2536 let (value_str, was_quoted) = strip_symmetric_quotes(rest[idx + 1..].trim());
2537 let value = parse_mutation_value(value_str, was_quoted);
2538 return Some(SessionMutation::Set { key, value });
2539 }
2540
2541 None
2542}
2543
2544fn parse_mutation_value(value_str: &str, was_quoted: bool) -> Value {
2548 if was_quoted {
2549 json!(value_str)
2550 } else {
2551 parse_value_str(value_str)
2552 }
2553}
2554
2555fn parse_value_str(value_str: &str) -> Value {
2557 if value_str == "[]" {
2559 return json!([]);
2560 }
2561 if let Ok(n) = value_str.parse::<i64>() {
2563 json!(n)
2564 } else if let Ok(f) = value_str.parse::<f64>() {
2565 json!(f)
2566 } else if value_str == "true" {
2567 json!(true)
2568 } else if value_str == "false" {
2569 json!(false)
2570 } else {
2571 json!(value_str)
2572 }
2573}
2574
2575#[cfg(test)]
2576mod tests {
2577 use super::*;
2578
2579 #[test]
2580 fn test_parse_attributes() {
2581 let attrs = parse_attributes(r#"title="Hello" size="large""#);
2582 assert_eq!(attrs.get("title"), Some(&"Hello".to_string()));
2583 assert_eq!(attrs.get("size"), Some(&"large".to_string()));
2584 }
2585
2586 #[test]
2587 fn test_replace_variables() {
2588 let mut context = HashMap::new();
2589 context.insert("name".to_string(), serde_json::json!("World"));
2590
2591 let result = replace_variables("Hello #name#!", &context);
2592 assert_eq!(result, "Hello World!");
2593 }
2594
2595 #[test]
2596 fn test_nested_variables() {
2597 let mut context = HashMap::new();
2598 context.insert(
2599 "user".to_string(),
2600 serde_json::json!({
2601 "name": "Alice",
2602 "email": "alice@example.com"
2603 }),
2604 );
2605
2606 let result = replace_variables("#user.name# (#user.email#)", &context);
2607 assert_eq!(result, "Alice (alice@example.com)");
2608 }
2609
2610 #[test]
2611 fn test_resolved_value_hashes_are_neutralized() {
2612 let mut context = HashMap::new();
2616 context.insert(
2617 "bio".to_string(),
2618 serde_json::json!("#env.SECRET# and #other|raw#"),
2619 );
2620 let result = replace_variables("<p>#bio#</p>", &context);
2621 assert_eq!(result, "<p>#env.SECRET# and #other|raw#</p>");
2624 assert!(!result.contains("#env.SECRET#"));
2625 }
2626
2627 #[test]
2628 fn test_unresolved_token_preserved_verbatim() {
2629 let context = HashMap::new();
2631 let result = replace_variables("<p>#unknown_var#</p>", &context);
2632 assert_eq!(result, "<p>#unknown_var#</p>");
2633 }
2634
2635 #[test]
2636 fn test_reactive_resolved_value_hashes_neutralized() {
2637 let mut context = HashMap::new();
2641 context.insert("bio".to_string(), serde_json::json!("#env.SECRET#"));
2642 let out = replace_variables_reactive("<p>#bio#</p>", &context).html;
2643 assert!(!out.contains("#env.SECRET#"), "reactive path leaked a live token: {out}");
2644 assert!(out.contains("#env.SECRET#"));
2645
2646 let mut ctx2 = HashMap::new();
2648 ctx2.insert(
2649 "session".to_string(),
2650 serde_json::json!({ "note": "x #env.SECRET# y" }),
2651 );
2652 let out2 = replace_variables_reactive("<p>#session.note#</p>", &ctx2).html;
2653 assert!(!out2.contains("#env.SECRET#"), "reactive span leaked a live token: {out2}");
2654
2655 let empty = HashMap::new();
2657 let out3 = replace_variables_reactive("<p>#nope#</p>", &empty).html;
2658 assert_eq!(out3, "<p>#nope#</p>");
2659 }
2660
2661 #[test]
2662 fn test_env_variables() {
2663 unsafe {
2665 std::env::set_var("WHAT_TEST_VAR", "test_value");
2666 }
2667
2668 let context = HashMap::new();
2669 let result = replace_variables("Value: #env.WHAT_TEST_VAR#", &context);
2670 assert_eq!(result, "Value: test_value");
2671
2672 unsafe {
2674 std::env::remove_var("WHAT_TEST_VAR");
2675 }
2676 }
2677
2678 #[test]
2679 fn test_env_variable_not_found() {
2680 let context = HashMap::new();
2681 let result = replace_variables("#env.NONEXISTENT_VAR_12345#", &context);
2682 assert_eq!(result, ""); }
2684
2685 #[test]
2686 fn test_page_directives_self_closing() {
2687 let content = r#"<what protected roles="admin,editor" />
2688<!DOCTYPE html>
2689<html>
2690<body>Hello</body>
2691</html>"#;
2692
2693 let (directives, cleaned) = parse_page_directives(content);
2694
2695 assert!(directives.protected);
2696 assert_eq!(directives.roles, vec!["admin", "editor"]);
2697 assert!(cleaned.contains("<!DOCTYPE html>"));
2698 assert!(!cleaned.contains("<what"));
2699 }
2700
2701 #[test]
2702 fn test_page_directives_boolean() {
2703 let content = r#"<what protected exclude />
2704<html></html>"#;
2705
2706 let (directives, cleaned) = parse_page_directives(content);
2707
2708 assert!(directives.protected);
2709 assert!(directives.exclude);
2710 assert!(!cleaned.contains("<what"));
2711 }
2712
2713 #[test]
2714 fn test_page_directives_content_syntax() {
2715 let content = r#"<what>
2716protected
2717roles: admin, manager
2718title: Admin Dashboard
2719</what>
2720<!DOCTYPE html>
2721<html></html>"#;
2722
2723 let (directives, cleaned) = parse_page_directives(content);
2724
2725 assert!(directives.protected);
2726 assert_eq!(directives.roles, vec!["admin", "manager"]);
2727 assert_eq!(directives.title, Some("Admin Dashboard".to_string()));
2728 assert!(cleaned.contains("<!DOCTYPE html>"));
2729 }
2730
2731 #[test]
2732 fn test_page_directives_roles_imply_protected() {
2733 let content = r#"<what roles="admin" />
2734<html></html>"#;
2735
2736 let (directives, _) = parse_page_directives(content);
2737
2738 assert!(directives.protected); assert_eq!(directives.roles, vec!["admin"]);
2740 }
2741
2742 #[test]
2743 fn test_no_directives() {
2744 let content = r#"<!DOCTYPE html>
2745<html><body>Hello</body></html>"#;
2746
2747 let (directives, cleaned) = parse_page_directives(content);
2748
2749 assert!(!directives.protected);
2750 assert!(directives.roles.is_empty());
2751 assert_eq!(content, cleaned);
2752 }
2753
2754 #[test]
2755 fn test_page_tag_preserved() {
2756 let content = r#"<page title="Test">
2758 <what-nav active="home"/>
2759 <main>Content</main>
2760</page>"#;
2761
2762 let (_, cleaned) = parse_page_directives(content);
2763 println!("Cleaned content: '{}'", cleaned);
2764
2765 assert!(cleaned.contains("<page"), "Should preserve <page> tag");
2766 assert!(cleaned.contains("</page>"), "Should preserve </page> tag");
2767 assert!(
2768 cleaned.contains("<what-nav"),
2769 "Should preserve <what-nav> tag"
2770 );
2771 assert_eq!(content, cleaned, "Content should be unchanged");
2772 }
2773
2774 #[test]
2775 fn test_auth_all() {
2776 let content = r#"<what auth="all" />
2777<html></html>"#;
2778
2779 let (directives, _) = parse_page_directives(content);
2780
2781 assert_eq!(directives.auth, AuthLevel::All);
2782 assert!(!directives.requires_auth());
2783 }
2784
2785 #[test]
2786 fn test_auth_user() {
2787 let content = r#"<what auth="user" />
2788<html></html>"#;
2789
2790 let (directives, _) = parse_page_directives(content);
2791
2792 assert_eq!(directives.auth, AuthLevel::User);
2793 assert!(directives.requires_auth());
2794 assert!(directives.check_access(true, &[]));
2795 assert!(!directives.check_access(false, &[]));
2796 }
2797
2798 #[test]
2799 fn test_auth_roles() {
2800 let content = r#"<what auth="admin, editor" />
2801<html></html>"#;
2802
2803 let (directives, _) = parse_page_directives(content);
2804
2805 assert_eq!(
2806 directives.auth,
2807 AuthLevel::Roles(vec!["admin".to_string(), "editor".to_string()])
2808 );
2809 assert!(directives.requires_auth());
2810
2811 assert!(directives.check_access(true, &["admin".to_string()]));
2813 assert!(directives.check_access(true, &["editor".to_string()]));
2815 assert!(!directives.check_access(true, &["viewer".to_string()]));
2817 assert!(!directives.check_access(false, &["admin".to_string()]));
2819 }
2820
2821 #[test]
2822 fn test_auth_content_syntax() {
2823 let content = r#"<what>
2824auth: admin, manager
2825title: Dashboard
2826</what>
2827<html></html>"#;
2828
2829 let (directives, _) = parse_page_directives(content);
2830
2831 assert_eq!(
2832 directives.auth,
2833 AuthLevel::Roles(vec!["admin".to_string(), "manager".to_string()])
2834 );
2835 assert_eq!(directives.title, Some("Dashboard".to_string()));
2836 }
2837
2838 #[test]
2839 fn test_auth_public_aliases() {
2840 let (d1, _) = parse_page_directives(r#"<what auth="public" /><html></html>"#);
2842 assert_eq!(d1.auth, AuthLevel::All);
2843
2844 let (d2, _) = parse_page_directives(r#"<what auth="none" /><html></html>"#);
2846 assert_eq!(d2.auth, AuthLevel::All);
2847
2848 let (d3, _) = parse_page_directives(r#"<what auth="authenticated" /><html></html>"#);
2850 assert_eq!(d3.auth, AuthLevel::User);
2851 }
2852
2853 #[test]
2858 fn test_fetch_directive_url_with_equals() {
2859 let content = r#"<what>
2860title: Remote Data
2861fetch.dog_facts = "https://dogapi.dog/api/v2/facts?limit=3"
2862</what>
2863<html></html>"#;
2864
2865 let (directives, _) = parse_page_directives(content);
2866
2867 assert_eq!(directives.title, Some("Remote Data".to_string()));
2868 assert_eq!(
2869 directives.custom.get("fetch.dog_facts"),
2870 Some(&"https://dogapi.dog/api/v2/facts?limit=3".to_string()),
2871 "fetch URL should be parsed correctly with = delimiter"
2872 );
2873 }
2874
2875 #[test]
2876 fn test_fetch_directive_url_with_multiple_equals() {
2877 let content = r#"<what>
2879fetch.dog_breeds = "https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6"
2880</what>
2881<html></html>"#;
2882
2883 let (directives, _) = parse_page_directives(content);
2884
2885 assert_eq!(
2886 directives.custom.get("fetch.dog_breeds"),
2887 Some(&"https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6".to_string()),
2888 "URL with multiple = in query params should be preserved"
2889 );
2890 }
2891
2892 #[test]
2893 fn test_fetch_directive_full_remote_data_page() {
2894 let content = r#"<what>
2896title: Remote Data
2897page: remote-data
2898fetch.dog_facts = "https://dogapi.dog/api/v2/facts?limit=3"
2899fetch.dog_breeds = "https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6"
2900fetch.dog_images = "https://dog.ceo/api/breeds/image/random/4"
2901</what>
2902<html></html>"#;
2903
2904 let (directives, cleaned) = parse_page_directives(content);
2905
2906 assert_eq!(directives.title, Some("Remote Data".to_string()));
2907 assert_eq!(directives.vars.get("page"), Some(&json!("remote-data")));
2908 assert_eq!(
2909 directives.custom.get("fetch.dog_facts"),
2910 Some(&"https://dogapi.dog/api/v2/facts?limit=3".to_string())
2911 );
2912 assert_eq!(
2913 directives.custom.get("fetch.dog_breeds"),
2914 Some(&"https://dogapi.dog/api/v2/breeds?page[number]=1&page[size]=6".to_string())
2915 );
2916 assert_eq!(
2917 directives.custom.get("fetch.dog_images"),
2918 Some(&"https://dog.ceo/api/breeds/image/random/4".to_string())
2919 );
2920 assert!(!cleaned.contains("<what>"));
2921 }
2922
2923 #[test]
2928 fn test_section_header_fetch() {
2929 let content = r##"<what>
2930title: Dashboard
2931[fetch.weather]
2932url = "https://api.weather.com/current"
2933method = "GET"
2934headers = "Authorization: Bearer abc123"
2935path = "data.current"
2936limit = 5
2937</what>
2938<html></html>"##;
2939
2940 let (directives, _) = parse_page_directives(content);
2941 assert_eq!(directives.title, Some("Dashboard".to_string()));
2942 assert_eq!(
2943 directives.custom.get("fetch.weather.url"),
2944 Some(&"https://api.weather.com/current".to_string())
2945 );
2946 assert_eq!(
2947 directives.custom.get("fetch.weather.method"),
2948 Some(&"GET".to_string())
2949 );
2950 assert_eq!(
2951 directives.custom.get("fetch.weather.headers"),
2952 Some(&"Authorization: Bearer abc123".to_string())
2953 );
2954 assert_eq!(
2955 directives.custom.get("fetch.weather.path"),
2956 Some(&"data.current".to_string())
2957 );
2958 assert_eq!(
2959 directives.custom.get("fetch.weather.limit"),
2960 Some(&"5".to_string())
2961 );
2962 }
2963
2964 #[test]
2965 fn test_section_header_og() {
2966 let content = r##"<what>
2967[og]
2968title: My Dashboard
2969description: Real-time weather data
2970image: /images/dashboard-og.png
2971</what>
2972<html></html>"##;
2973
2974 let (directives, _) = parse_page_directives(content);
2975 assert_eq!(
2976 directives.vars.get("og.title"),
2977 Some(&json!("My Dashboard"))
2978 );
2979 assert_eq!(
2980 directives.vars.get("og.description"),
2981 Some(&json!("Real-time weather data"))
2982 );
2983 assert_eq!(
2984 directives.vars.get("og.image"),
2985 Some(&json!("/images/dashboard-og.png"))
2986 );
2987 }
2988
2989 #[test]
2990 fn test_section_header_session() {
2991 let content = r##"<what>
2992[session]
2993visit_count += 1
2994theme = "dark"
2995</what>
2996<html></html>"##;
2997
2998 let (directives, _) = parse_page_directives(content);
2999 assert_eq!(directives.session_mutations.len(), 2);
3000 }
3001
3002 #[test]
3003 fn test_section_header_compute() {
3004 let content = r##"<what>
3005[compute]
3006greeting = "Hello, #user.full_name#!"
3007</what>
3008<html></html>"##;
3009
3010 let (directives, _) = parse_page_directives(content);
3011 assert_eq!(directives.computed.len(), 1);
3012 assert_eq!(directives.computed[0].0, "greeting");
3013 assert_eq!(directives.computed[0].1, "Hello, #user.full_name#!");
3014 }
3015
3016 #[test]
3017 fn test_section_header_reset() {
3018 let content = r##"<what>
3019[fetch.weather]
3020url = "https://api.weather.com/current"
3021
3022[]
3023session.visit_count += 1
3024compute.greeting = "Hello!"
3025</what>
3026<html></html>"##;
3027
3028 let (directives, _) = parse_page_directives(content);
3029 assert_eq!(
3030 directives.custom.get("fetch.weather.url"),
3031 Some(&"https://api.weather.com/current".to_string())
3032 );
3033 assert_eq!(directives.session_mutations.len(), 1);
3034 assert_eq!(directives.computed.len(), 1);
3035 }
3036
3037 #[test]
3038 fn test_section_header_mixed() {
3039 let content = r##"<what>
3041title: Dashboard
3042auth: user
3043fetch.legacy = "https://old-api.com/data"
3044
3045[fetch.weather]
3046url = "https://api.weather.com/current"
3047method = "POST"
3048
3049[]
3050session.count += 1
3051</what>
3052<html></html>"##;
3053
3054 let (directives, _) = parse_page_directives(content);
3055 assert_eq!(directives.title, Some("Dashboard".to_string()));
3056 assert_eq!(
3057 directives.custom.get("fetch.legacy"),
3058 Some(&"https://old-api.com/data".to_string())
3059 );
3060 assert_eq!(
3061 directives.custom.get("fetch.weather.url"),
3062 Some(&"https://api.weather.com/current".to_string())
3063 );
3064 assert_eq!(
3065 directives.custom.get("fetch.weather.method"),
3066 Some(&"POST".to_string())
3067 );
3068 assert_eq!(directives.session_mutations.len(), 1);
3069 }
3070
3071 #[test]
3072 fn test_section_header_multiple_fetch() {
3073 let content = r##"<what>
3074[fetch.weather]
3075url = "https://api.weather.com/current"
3076path = "data.current"
3077
3078[fetch.news]
3079url = "https://api.news.com/latest"
3080path = "articles"
3081limit = 10
3082</what>
3083<html></html>"##;
3084
3085 let (directives, _) = parse_page_directives(content);
3086 assert_eq!(
3087 directives.custom.get("fetch.weather.url"),
3088 Some(&"https://api.weather.com/current".to_string())
3089 );
3090 assert_eq!(
3091 directives.custom.get("fetch.weather.path"),
3092 Some(&"data.current".to_string())
3093 );
3094 assert_eq!(
3095 directives.custom.get("fetch.news.url"),
3096 Some(&"https://api.news.com/latest".to_string())
3097 );
3098 assert_eq!(
3099 directives.custom.get("fetch.news.path"),
3100 Some(&"articles".to_string())
3101 );
3102 assert_eq!(
3103 directives.custom.get("fetch.news.limit"),
3104 Some(&"10".to_string())
3105 );
3106 }
3107
3108 #[test]
3109 fn test_section_header_backward_compat() {
3110 let content = r##"<what>
3112title: Remote Data
3113fetch.dogs = "https://dogapi.dog/api/v2/facts?limit=3"
3114fetch.dogs.path = "data"
3115session.count += 1
3116compute.greeting = "Hello!"
3117og.title: My Page
3118</what>
3119<html></html>"##;
3120
3121 let (directives, _) = parse_page_directives(content);
3122 assert_eq!(directives.title, Some("Remote Data".to_string()));
3123 assert_eq!(
3124 directives.custom.get("fetch.dogs"),
3125 Some(&"https://dogapi.dog/api/v2/facts?limit=3".to_string())
3126 );
3127 assert_eq!(
3128 directives.custom.get("fetch.dogs.path"),
3129 Some(&"data".to_string())
3130 );
3131 assert_eq!(directives.session_mutations.len(), 1);
3132 assert_eq!(directives.computed.len(), 1);
3133 assert_eq!(directives.vars.get("og.title"), Some(&json!("My Page")));
3134 }
3135
3136 #[test]
3141 fn inline_var_string() {
3142 let content = r#"<what>
3143title = "My Page"
3144subtitle = "Welcome"
3145</what>
3146<html></html>"#;
3147 let (directives, _) = parse_page_directives(content);
3148 assert_eq!(directives.title, Some("My Page".to_string()));
3150 assert_eq!(directives.vars.get("subtitle"), Some(&json!("Welcome")));
3152 }
3153
3154 #[test]
3155 fn inline_var_number() {
3156 let content = r#"<what>
3157count = 42
3158price = 9.99
3159</what>
3160<html></html>"#;
3161 let (directives, _) = parse_page_directives(content);
3162 assert_eq!(directives.vars.get("count"), Some(&json!(42)));
3163 assert_eq!(directives.vars.get("price"), Some(&json!(9.99)));
3164 }
3165
3166 #[test]
3167 fn inline_var_boolean() {
3168 let content = r#"<what>
3169show_banner = true
3170debug = false
3171</what>
3172<html></html>"#;
3173 let (directives, _) = parse_page_directives(content);
3174 assert_eq!(directives.vars.get("show_banner"), Some(&json!(true)));
3175 assert_eq!(directives.vars.get("debug"), Some(&json!(false)));
3176 }
3177
3178 #[test]
3179 fn inline_var_single_line_array() {
3180 let content = r#"<what>
3181colors = ["red", "green", "blue"]
3182</what>
3183<html></html>"#;
3184 let (directives, _) = parse_page_directives(content);
3185 assert_eq!(
3186 directives.vars.get("colors"),
3187 Some(&json!(["red", "green", "blue"]))
3188 );
3189 }
3190
3191 #[test]
3192 fn inline_var_multi_line_array() {
3193 let content = r##"<what>
3194products = [
3195 { "name": "Widget", "price": 9.99 },
3196 { "name": "Gadget", "price": 24.99 }
3197]
3198</what>
3199<html></html>"##;
3200 let (directives, _) = parse_page_directives(content);
3201 let products = directives.vars.get("products").unwrap();
3202 assert!(products.is_array());
3203 assert_eq!(products.as_array().unwrap().len(), 2);
3204 assert_eq!(products[0]["name"], json!("Widget"));
3205 assert_eq!(products[1]["price"], json!(24.99));
3206 }
3207
3208 #[test]
3209 fn inline_var_multi_line_object() {
3210 let content = r##"<what>
3211config = {
3212 "theme": "dark",
3213 "sidebar": true
3214}
3215</what>
3216<html></html>"##;
3217 let (directives, _) = parse_page_directives(content);
3218 let config = directives.vars.get("config").unwrap();
3219 assert!(config.is_object());
3220 assert_eq!(config["theme"], json!("dark"));
3221 assert_eq!(config["sidebar"], json!(true));
3222 }
3223
3224 #[test]
3225 fn inline_var_relaxed_json_unquoted_keys() {
3226 let content = r##"<what>
3227products = [
3228 { name: "Widget", price: 9.99 },
3229 { name: "Gadget", price: 24.99 }
3230]
3231</what>
3232<html></html>"##;
3233 let (directives, _) = parse_page_directives(content);
3234 let products = directives.vars.get("products").unwrap();
3235 assert!(products.is_array());
3236 assert_eq!(products[0]["name"], json!("Widget"));
3237 assert_eq!(products[1]["price"], json!(24.99));
3238 }
3239
3240 #[test]
3241 fn inline_var_relaxed_json_single_line() {
3242 let content = r##"<what>
3243item = { name: "Widget", price: 9.99 }
3244</what>
3245<html></html>"##;
3246 let (directives, _) = parse_page_directives(content);
3247 let item = directives.vars.get("item").unwrap();
3248 assert_eq!(item["name"], json!("Widget"));
3249 assert_eq!(item["price"], json!(9.99));
3250 }
3251
3252 #[test]
3253 fn inline_var_does_not_affect_reserved() {
3254 let content = r#"<what>
3255auth = user
3256layout = main.html
3257fetch.api = "https://example.com"
3258my_var = "hello"
3259</what>
3260<html></html>"#;
3261 let (directives, _) = parse_page_directives(content);
3262 assert!(directives.requires_auth());
3264 assert_eq!(directives.layout, Some("main.html".to_string()));
3265 assert_eq!(
3266 directives.custom.get("fetch.api"),
3267 Some(&"https://example.com".to_string())
3268 );
3269 assert_eq!(directives.vars.get("my_var"), Some(&json!("hello")));
3271 assert!(directives.vars.get("auth").is_none());
3273 assert!(directives.vars.get("layout").is_none());
3274 }
3275
3276 #[test]
3277 fn inline_var_mixed_with_directives() {
3278 let content = r##"<what>
3279title = "Dashboard"
3280items_per_page = 25
3281nav_items = ["Home", "About", "Contact"]
3282fetch.data = "https://api.example.com/data"
3283compute.greeting = "Hello #user.name#!"
3284</what>
3285<html></html>"##;
3286 let (directives, _) = parse_page_directives(content);
3287 assert_eq!(directives.title, Some("Dashboard".to_string()));
3288 assert_eq!(directives.vars.get("items_per_page"), Some(&json!(25)));
3289 assert_eq!(
3290 directives.vars.get("nav_items"),
3291 Some(&json!(["Home", "About", "Contact"]))
3292 );
3293 assert_eq!(
3294 directives.custom.get("fetch.data"),
3295 Some(&"https://api.example.com/data".to_string())
3296 );
3297 assert_eq!(directives.computed.len(), 1);
3298 }
3299
3300 #[test]
3305 fn mutation_stored_as_custom_string() {
3306 let content = r#"<what>
3307mutation.reset = "session.score = 0; session.lives = 3"
3308</what>
3309<html></html>"#;
3310 let (directives, _) = parse_page_directives(content);
3311 assert_eq!(
3312 directives.custom.get("mutation.reset"),
3313 Some(&"session.score = 0; session.lives = 3".to_string())
3314 );
3315 assert!(directives.vars.get("mutation.reset").is_none());
3317 }
3318
3319 #[test]
3320 fn mutation_not_parsed_as_inline_var() {
3321 let content = r#"<what>
3322mutation.toggle = "session.dark_mode = 1"
3323my_var = 42
3324</what>
3325<html></html>"#;
3326 let (directives, _) = parse_page_directives(content);
3327 assert!(directives.custom.contains_key("mutation.toggle"));
3329 assert_eq!(directives.vars.get("my_var"), Some(&json!(42)));
3330 }
3331
3332 #[test]
3337 fn test_what_file_strings() {
3338 let content = r#"
3339title = "My Application"
3340description = 'Single quotes work too'
3341bare_string = unquoted
3342"#;
3343 let config = parse_what_file(content);
3344
3345 assert_eq!(config.get_string("title"), Some("My Application"));
3346 assert_eq!(
3347 config.get_string("description"),
3348 Some("Single quotes work too")
3349 );
3350 assert_eq!(config.get_string("bare_string"), Some("unquoted"));
3351 }
3352
3353 #[test]
3354 fn test_what_file_numbers() {
3355 let content = r#"
3356port = 8080
3357version = 1.5
3358negative = -42
3359"#;
3360 let config = parse_what_file(content);
3361
3362 assert_eq!(config.get_number("port"), Some(8080.0));
3363 assert_eq!(config.get_number("version"), Some(1.5));
3364 assert_eq!(config.get_number("negative"), Some(-42.0));
3365 }
3366
3367 #[test]
3368 fn test_what_file_booleans() {
3369 let content = r#"
3370debug = true
3371production = false
3372"#;
3373 let config = parse_what_file(content);
3374
3375 assert_eq!(config.get_bool("debug"), Some(true));
3376 assert_eq!(config.get_bool("production"), Some(false));
3377 }
3378
3379 #[test]
3380 fn test_what_file_arrays() {
3381 let content = r#"
3382nav_items = ["Home", "About", "Contact"]
3383numbers = [1, 2, 3]
3384mixed = ["a", 1, true]
3385empty = []
3386"#;
3387 let config = parse_what_file(content);
3388
3389 let nav = config.get_array("nav_items").unwrap();
3390 assert_eq!(nav.len(), 3);
3391 assert_eq!(nav[0].as_str(), Some("Home"));
3392
3393 let nums = config.get_array("numbers").unwrap();
3394 assert_eq!(nums.len(), 3);
3395 assert_eq!(nums[0].as_i64(), Some(1));
3396
3397 let empty = config.get_array("empty").unwrap();
3398 assert!(empty.is_empty());
3399 }
3400
3401 #[test]
3402 fn test_what_file_comments() {
3403 let content = r#"
3404// This is a comment
3405title = "Hello"
3406# This is also a comment
3407name = "World"
3408"#;
3409 let config = parse_what_file(content);
3410
3411 assert_eq!(config.get_string("title"), Some("Hello"));
3412 assert_eq!(config.get_string("name"), Some("World"));
3413 assert!(config.values.len() == 2);
3415 }
3416
3417 #[test]
3418 fn test_what_file_auth_directive() {
3419 let content = r#"
3420auth = "admin"
3421title = "Dashboard"
3422"#;
3423 let config = parse_what_file(content);
3424
3425 assert_eq!(
3426 config.directives.auth,
3427 AuthLevel::Roles(vec!["admin".to_string()])
3428 );
3429 assert_eq!(config.directives.title, Some("Dashboard".to_string()));
3430 }
3431
3432 #[test]
3433 fn test_what_file_auth_all() {
3434 let content = r#"
3435auth = "all"
3436"#;
3437 let config = parse_what_file(content);
3438
3439 assert_eq!(config.directives.auth, AuthLevel::All);
3440 assert!(!config.directives.requires_auth());
3441 }
3442
3443 #[test]
3444 fn test_what_file_roles_array() {
3445 let content = r#"
3446roles = ["admin", "editor"]
3447"#;
3448 let config = parse_what_file(content);
3449
3450 assert_eq!(config.directives.roles, vec!["admin", "editor"]);
3451 assert!(config.directives.protected);
3452 }
3453
3454 #[test]
3455 fn test_what_config_merge() {
3456 let content1 = r#"
3457title = "Parent"
3458theme = "light"
3459auth = "all"
3460"#;
3461 let content2 = r#"
3462title = "Child"
3463nav = ["Home"]
3464auth = "admin"
3465"#;
3466 let mut config1 = parse_what_file(content1);
3467 let config2 = parse_what_file(content2);
3468
3469 config1.merge(&config2);
3470
3471 assert_eq!(config1.get_string("title"), Some("Child"));
3473 assert_eq!(config1.get_string("theme"), Some("light"));
3475 assert!(config1.get_array("nav").is_some());
3477 assert_eq!(
3479 config1.directives.auth,
3480 AuthLevel::Roles(vec!["admin".to_string()])
3481 );
3482 }
3483
3484 #[test]
3489 fn test_auth_level_edge_cases() {
3490 assert_eq!(parse_auth_level(""), AuthLevel::All);
3492
3493 assert_eq!(parse_auth_level(" "), AuthLevel::All);
3495
3496 assert_eq!(parse_auth_level("ALL"), AuthLevel::All);
3498 assert_eq!(parse_auth_level("User"), AuthLevel::User);
3499 assert_eq!(
3500 parse_auth_level("ADMIN"),
3501 AuthLevel::Roles(vec!["admin".to_string()])
3502 );
3503
3504 assert_eq!(
3506 parse_auth_level(" admin , editor "),
3507 AuthLevel::Roles(vec!["admin".to_string(), "editor".to_string()])
3508 );
3509
3510 assert_eq!(
3512 parse_auth_level("superuser"),
3513 AuthLevel::Roles(vec!["superuser".to_string()])
3514 );
3515 }
3516
3517 #[test]
3518 fn test_page_directives_cache_ttl() {
3519 let content = r#"<what cache="3600" />
3521<html></html>"#;
3522 let (directives, _) = parse_page_directives(content);
3523 assert_eq!(directives.cache_ttl, Some(3600));
3524
3525 let content = r#"<what>
3527cache-ttl: 1800
3528</what>
3529<html></html>"#;
3530 let (directives, _) = parse_page_directives(content);
3531 assert_eq!(directives.cache_ttl, Some(1800));
3532 }
3533
3534 #[test]
3535 fn test_page_directives_custom() {
3536 let content = r#"<what custom_field="my_value" another="test" />
3537<html></html>"#;
3538 let (directives, _) = parse_page_directives(content);
3539
3540 assert_eq!(
3541 directives.custom.get("custom_field"),
3542 Some(&"my_value".to_string())
3543 );
3544 assert_eq!(directives.custom.get("another"), Some(&"test".to_string()));
3545 }
3546
3547 #[test]
3548 fn test_page_directives_redirect() {
3549 let content = r#"<what redirect="/new-page" />
3550<html></html>"#;
3551 let (directives, _) = parse_page_directives(content);
3552 assert_eq!(directives.redirect, Some("/new-page".to_string()));
3553 }
3554
3555 #[test]
3556 fn test_what_file_empty() {
3557 let content = "";
3558 let config = parse_what_file(content);
3559 assert!(config.values.is_empty());
3560 assert_eq!(config.directives.auth, AuthLevel::All);
3561 }
3562
3563 #[test]
3564 fn test_what_file_only_comments() {
3565 let content = r#"
3566// This is a comment
3567# Another comment
3568// More comments
3569"#;
3570 let config = parse_what_file(content);
3571 assert!(config.values.is_empty());
3572 }
3573
3574 #[test]
3575 fn test_what_file_data_application() {
3576 let content = r#"
3577data.application = ["posts", "products"]
3578"#;
3579 let config = parse_what_file(content);
3580 let names: Vec<&str> = config.data_application.iter().map(|d| d.name.as_str()).collect();
3581 assert_eq!(names, vec!["posts", "products"]);
3582 assert!(!config.values.contains_key("data.application"));
3584 }
3585
3586 #[test]
3587 fn test_what_file_data_application_scoped() {
3588 let content = r#"
3589data.application = ["visits", "revenue [admin, editor]"]
3590"#;
3591 let config = parse_what_file(content);
3592 assert_eq!(config.data_application[0].name, "visits");
3593 assert!(matches!(config.data_application[0].scope, WiredScope::Public));
3594 assert_eq!(config.data_application[1].name, "revenue");
3595 match &config.data_application[1].scope {
3596 WiredScope::Roles(r) => assert_eq!(r, &vec!["admin".to_string(), "editor".to_string()]),
3597 other => panic!("expected Roles, got {other:?}"),
3598 }
3599 }
3600
3601 #[test]
3602 fn test_what_file_data_session() {
3603 let content = r#"
3604data.session = ["cart", "wishlist"]
3605"#;
3606 let config = parse_what_file(content);
3607 assert_eq!(config.data_session, vec!["cart", "wishlist"]);
3608 assert!(!config.values.contains_key("data.session"));
3610 }
3611
3612 #[test]
3613 fn test_what_file_data_single_value() {
3614 let content = r#"
3616data.application = "posts"
3617data.session = "cart"
3618"#;
3619 let config = parse_what_file(content);
3620 assert_eq!(config.data_application.len(), 1);
3621 assert_eq!(config.data_application[0].name, "posts");
3622 assert_eq!(config.data_session, vec!["cart"]);
3623 }
3624
3625 #[test]
3626 fn test_what_value_edge_cases() {
3627 assert_eq!(parse_what_value("[]"), serde_json::json!([]));
3629
3630 assert_eq!(parse_what_value("-3.14"), serde_json::json!(-3.14));
3632
3633 assert_eq!(parse_what_value("0"), serde_json::json!(0));
3635
3636 assert_eq!(
3638 parse_what_value("9999999999"),
3639 serde_json::json!(9999999999_i64)
3640 );
3641 }
3642
3643 #[test]
3644 fn test_page_directives_mixed_syntax() {
3645 let content = r#"<what protected title="Dashboard" exclude />
3647<html></html>"#;
3648 let (directives, _) = parse_page_directives(content);
3649
3650 assert!(directives.protected);
3651 assert!(directives.exclude);
3652 assert_eq!(directives.title, Some("Dashboard".to_string()));
3653 }
3654
3655 #[test]
3656 fn test_is_standard_html_tag() {
3657 assert!(is_standard_html_tag("div"));
3659 assert!(is_standard_html_tag("span"));
3660 assert!(is_standard_html_tag("html"));
3661 assert!(is_standard_html_tag("body"));
3662 assert!(is_standard_html_tag("form"));
3663 assert!(is_standard_html_tag("input"));
3664 assert!(is_standard_html_tag("template"));
3665 assert!(is_standard_html_tag("slot"));
3666
3667 assert!(!is_standard_html_tag("jumbo"));
3669 assert!(!is_standard_html_tag("card"));
3670 assert!(!is_standard_html_tag("my-component"));
3671 assert!(!is_standard_html_tag("loop"));
3672 }
3673
3674 #[test]
3675 fn test_page_directives_requires_auth() {
3676 let mut d = PageDirectives::default();
3678 d.auth = AuthLevel::All;
3679 assert!(!d.requires_auth());
3680
3681 d.auth = AuthLevel::User;
3683 assert!(d.requires_auth());
3684
3685 d.auth = AuthLevel::Roles(vec!["admin".to_string()]);
3687 assert!(d.requires_auth());
3688
3689 d.auth = AuthLevel::All;
3691 d.protected = true;
3692 assert!(d.requires_auth());
3693 }
3694
3695 #[test]
3696 fn test_page_directives_check_access_legacy() {
3697 let mut d = PageDirectives::default();
3699 d.protected = true;
3700 d.roles = vec!["admin".to_string(), "editor".to_string()];
3701
3702 assert!(!d.check_access(false, &[]));
3704
3705 assert!(!d.check_access(true, &[]));
3707
3708 assert!(!d.check_access(true, &["viewer".to_string()]));
3710
3711 assert!(d.check_access(true, &["admin".to_string()]));
3713 assert!(d.check_access(true, &["editor".to_string()]));
3714 }
3715
3716 #[test]
3721 fn test_layout_in_what_file() {
3722 let content = r#"
3723layout = "sections/main.html"
3724title = "Test Page"
3725"#;
3726 let config = parse_what_file(content);
3727
3728 assert_eq!(config.layout, Some("sections/main.html".to_string()));
3729 assert_eq!(
3730 config.directives.layout,
3731 Some("sections/main.html".to_string())
3732 );
3733 assert!(config.get_string("layout").is_none());
3735 }
3736
3737 #[test]
3738 fn test_layout_in_page_directive_attribute() {
3739 let content = r#"<what layout="sections/page.html" />
3740<h1>Hello</h1>"#;
3741 let (directives, cleaned) = parse_page_directives(content);
3742
3743 assert_eq!(directives.layout, Some("sections/page.html".to_string()));
3744 assert!(cleaned.contains("<h1>Hello</h1>"));
3745 assert!(!cleaned.contains("<what"));
3746 }
3747
3748 #[test]
3749 fn test_layout_in_page_directive_content() {
3750 let content = r#"<what>
3751layout: sections/admin.html
3752title: Dashboard
3753</what>
3754<h1>Admin Dashboard</h1>"#;
3755 let (directives, cleaned) = parse_page_directives(content);
3756
3757 assert_eq!(directives.layout, Some("sections/admin.html".to_string()));
3758 assert_eq!(directives.title, Some("Dashboard".to_string()));
3759 assert!(cleaned.contains("<h1>Admin Dashboard</h1>"));
3760 }
3761
3762 #[test]
3763 fn test_layout_none_disables() {
3764 let content = r#"<what layout="none" />
3766<h1>No Layout</h1>"#;
3767 let (directives, _) = parse_page_directives(content);
3768
3769 assert_eq!(directives.layout, Some("none".to_string()));
3770 }
3771
3772 #[test]
3773 fn test_what_config_layout_merge() {
3774 let parent_content = r#"
3775layout = "sections/base.html"
3776title = "Parent"
3777"#;
3778 let child_content = r#"
3779layout = "sections/admin.html"
3780"#;
3781 let mut parent = parse_what_file(parent_content);
3782 let child = parse_what_file(child_content);
3783
3784 parent.merge(&child);
3785
3786 assert_eq!(parent.layout, Some("sections/admin.html".to_string()));
3788 }
3789
3790 #[test]
3791 fn test_what_config_layout_inherit() {
3792 let parent_content = r#"
3793layout = "sections/base.html"
3794title = "Parent"
3795"#;
3796 let child_content = r#"
3797title = "Child"
3798"#;
3799 let mut parent = parse_what_file(parent_content);
3800 let child = parse_what_file(child_content);
3801
3802 parent.merge(&child);
3803
3804 assert_eq!(parent.layout, Some("sections/base.html".to_string()));
3806 assert_eq!(parent.get_string("title"), Some("Child"));
3807 }
3808
3809 #[test]
3810 fn test_what_config_layout_none_override() {
3811 let parent_content = r#"
3812layout = "sections/base.html"
3813"#;
3814 let child_content = r#"
3815layout = "none"
3816"#;
3817 let mut parent = parse_what_file(parent_content);
3818 let child = parse_what_file(child_content);
3819
3820 parent.merge(&child);
3821
3822 assert_eq!(parent.layout, Some("none".to_string()));
3824 }
3825
3826 #[test]
3831 fn test_reactive_session_var_in_text() {
3832 let mut context = HashMap::new();
3833 context.insert(
3834 "session".to_string(),
3835 serde_json::json!({
3836 "count": 8
3837 }),
3838 );
3839
3840 let template = "<p>Counter: #session.count#</p>";
3841 let result = replace_variables_reactive(template, &context);
3842
3843 assert!(
3844 result
3845 .html
3846 .contains(r#"<span w-bind="session.count">8</span>"#)
3847 );
3848 assert!(result.session_keys.contains("count"));
3849 }
3850
3851 #[test]
3852 fn test_reactive_session_var_in_attribute() {
3853 let mut context = HashMap::new();
3854 context.insert(
3855 "session".to_string(),
3856 serde_json::json!({
3857 "count": 8
3858 }),
3859 );
3860
3861 let template = r##"<div title="#session.count#">Content</div>"##;
3863 let result = replace_variables_reactive(template, &context);
3864
3865 assert!(result.html.contains(r##"title="8""##));
3867 assert!(!result.html.contains("w-bind"));
3869 assert!(result.session_keys.contains("count"));
3871 }
3872
3873 #[test]
3874 fn test_reactive_non_session_var() {
3875 let mut context = HashMap::new();
3876 context.insert("name".to_string(), serde_json::json!("Alice"));
3877
3878 let template = "<p>Hello #name#!</p>";
3879 let result = replace_variables_reactive(template, &context);
3880
3881 assert!(result.html.contains("Hello Alice!"));
3883 assert!(!result.html.contains("w-bind"));
3884 assert!(result.session_keys.is_empty());
3885 }
3886
3887 #[test]
3888 fn test_reactive_multiple_session_vars() {
3889 let mut context = HashMap::new();
3890 context.insert(
3891 "session".to_string(),
3892 serde_json::json!({
3893 "count": 5,
3894 "name": "Test"
3895 }),
3896 );
3897
3898 let template = "<p>Count: #session.count#, Name: #session.name#</p>";
3899 let result = replace_variables_reactive(template, &context);
3900
3901 assert!(
3902 result
3903 .html
3904 .contains(r#"<span w-bind="session.count">5</span>"#)
3905 );
3906 assert!(
3907 result
3908 .html
3909 .contains(r#"<span w-bind="session.name">Test</span>"#)
3910 );
3911 assert!(result.session_keys.contains("count"));
3912 assert!(result.session_keys.contains("name"));
3913 }
3914
3915 #[test]
3916 fn test_is_in_attribute_context() {
3917 assert!(!is_in_attribute_context("<p>"));
3919 assert!(!is_in_attribute_context("<p>Hello "));
3920 assert!(!is_in_attribute_context("<div><span>"));
3921
3922 assert!(is_in_attribute_context(r#"<div title=""#));
3924 assert!(is_in_attribute_context(r#"<div class="foo "#));
3925 assert!(is_in_attribute_context(r#"<input value=""#));
3926
3927 assert!(!is_in_attribute_context(r#"<div title="test">"#));
3929 assert!(!is_in_attribute_context(r#"<div class="foo">Hello"#));
3930 }
3931
3932 #[test]
3933 fn test_html_escape() {
3934 assert_eq!(html_escape("<"), "<");
3935 assert_eq!(html_escape(">"), ">");
3936 assert_eq!(html_escape("&"), "&");
3937 assert_eq!(html_escape("\""), """);
3938 assert_eq!(html_escape("'"), "'");
3939 assert_eq!(
3940 html_escape("<script>alert('xss')</script>"),
3941 "<script>alert('xss')</script>"
3942 );
3943 }
3944
3945 #[test]
3946 fn test_parse_session_mutation_push() {
3947 let m = parse_session_mutation("session.items.push(\"hello\")").unwrap();
3948 match m {
3949 SessionMutation::Push { key, value } => {
3950 assert_eq!(key, "items");
3951 assert_eq!(value, serde_json::json!("hello"));
3952 }
3953 _ => panic!("Expected Push"),
3954 }
3955 }
3956
3957 #[test]
3958 fn test_parse_session_mutation_pushmax() {
3959 let m = parse_session_mutation("session.history.pushmax(5, \"page1\")").unwrap();
3960 match m {
3961 SessionMutation::PushMax { key, max, value } => {
3962 assert_eq!(key, "history");
3963 assert_eq!(max, 5);
3964 assert_eq!(value, serde_json::json!("page1"));
3965 }
3966 _ => panic!("Expected PushMax"),
3967 }
3968 }
3969
3970 #[test]
3971 fn test_parse_session_mutation_pushmax_numeric() {
3972 let m = parse_session_mutation("session.ids.pushmax(10, 42)").unwrap();
3973 match m {
3974 SessionMutation::PushMax { key, max, value } => {
3975 assert_eq!(key, "ids");
3976 assert_eq!(max, 10);
3977 assert_eq!(value, serde_json::json!(42));
3978 }
3979 _ => panic!("Expected PushMax"),
3980 }
3981 }
3982
3983 #[test]
3988 fn test_auto_escape_html_in_variables() {
3989 let mut context = HashMap::new();
3990 context.insert(
3991 "name".to_string(),
3992 serde_json::json!("<script>alert('xss')</script>"),
3993 );
3994
3995 let result = replace_variables("Hello #name#!", &context);
3996 assert_eq!(
3997 result,
3998 "Hello <script>alert('xss')</script>!"
3999 );
4000 assert!(!result.contains("<script>"));
4001 }
4002
4003 #[test]
4004 fn test_auto_escape_ampersand() {
4005 let mut context = HashMap::new();
4006 context.insert("text".to_string(), serde_json::json!("Tom & Jerry"));
4007
4008 let result = replace_variables("#text#", &context);
4009 assert_eq!(result, "Tom & Jerry");
4010 }
4011
4012 #[test]
4013 fn test_auto_escape_quotes() {
4014 let mut context = HashMap::new();
4015 context.insert("text".to_string(), serde_json::json!(r#"He said "hello""#));
4016
4017 let result = replace_variables("#text#", &context);
4018 assert_eq!(result, "He said "hello"");
4019 }
4020
4021 #[test]
4022 fn test_raw_filter_bypasses_escaping() {
4023 let mut context = HashMap::new();
4024 context.insert("html".to_string(), serde_json::json!("<b>bold</b>"));
4025
4026 let result = replace_variables("#html|raw#", &context);
4027 assert_eq!(result, "<b>bold</b>");
4028 }
4029
4030 #[test]
4031 fn test_raw_filter_with_default_value() {
4032 let mut context = HashMap::new();
4033 context.insert("html".to_string(), serde_json::json!("<em>yes</em>"));
4034
4035 let result = replace_variables("#html|raw#", &context);
4037 assert_eq!(result, "<em>yes</em>");
4038 }
4039
4040 #[test]
4041 fn test_auto_escape_preserves_safe_text() {
4042 let mut context = HashMap::new();
4043 context.insert("name".to_string(), serde_json::json!("Alice"));
4044
4045 let result = replace_variables("Hello #name#!", &context);
4046 assert_eq!(result, "Hello Alice!");
4047 }
4048
4049 #[test]
4050 fn test_auto_escape_nested_variables() {
4051 let mut context = HashMap::new();
4052 context.insert(
4053 "user".to_string(),
4054 serde_json::json!({
4055 "name": "<b>Admin</b>",
4056 "bio": "Loves coding & testing"
4057 }),
4058 );
4059
4060 let result = replace_variables("#user.name# - #user.bio#", &context);
4061 assert_eq!(
4062 result,
4063 "<b>Admin</b> - Loves coding & testing"
4064 );
4065 }
4066
4067 #[test]
4068 fn test_auto_escape_with_default_filter() {
4069 let context = HashMap::new();
4070
4071 let result = replace_variables(r##"#missing|default:"<fallback>"#"##, &context);
4073 assert_eq!(result, "<fallback>");
4074 }
4075
4076 #[test]
4077 fn test_reactive_auto_escape_non_session_var() {
4078 let mut context = HashMap::new();
4079 context.insert(
4080 "name".to_string(),
4081 serde_json::json!("<script>xss</script>"),
4082 );
4083
4084 let result = replace_variables_reactive("<p>#name#</p>", &context);
4085 assert!(result.html.contains("<script>xss</script>"));
4086 assert!(!result.html.contains("<script>xss</script>"));
4087 }
4088
4089 #[test]
4090 fn test_reactive_auto_escape_session_var_in_attribute() {
4091 let mut context = HashMap::new();
4092 context.insert(
4093 "session".to_string(),
4094 serde_json::json!({
4095 "name": "Tom & Jerry"
4096 }),
4097 );
4098
4099 let template = r##"<div title="#session.name#">Content</div>"##;
4100 let result = replace_variables_reactive(template, &context);
4101
4102 assert!(result.html.contains("Tom & Jerry"));
4104 assert!(!result.html.contains("w-bind"));
4105 }
4106
4107 #[test]
4108 fn test_reactive_raw_filter_non_session_var() {
4109 let mut context = HashMap::new();
4110 context.insert("html".to_string(), serde_json::json!("<b>bold</b>"));
4111
4112 let result = replace_variables_reactive("<p>#html|raw#</p>", &context);
4113 assert!(result.html.contains("<b>bold</b>"));
4114 }
4115
4116 #[test]
4117 fn test_reactive_session_var_always_escaped_in_span() {
4118 let mut context = HashMap::new();
4119 context.insert(
4120 "session".to_string(),
4121 serde_json::json!({
4122 "name": "<script>xss</script>"
4123 }),
4124 );
4125
4126 let result = replace_variables_reactive("<p>#session.name#</p>", &context);
4127 assert!(result.html.contains("<script>xss</script>"));
4129 assert!(result.html.contains("w-bind"));
4130 }
4131
4132 #[test]
4133 fn test_reactive_session_raw_in_attribute() {
4134 let mut context = HashMap::new();
4135 context.insert(
4136 "session".to_string(),
4137 serde_json::json!({
4138 "url": "/path?a=1&b=2"
4139 }),
4140 );
4141
4142 let template = r##"<a href="#session.url|raw#">Link</a>"##;
4144 let result = replace_variables_reactive(template, &context);
4145 assert!(result.html.contains(r#"href="/path?a=1&b=2""#));
4146 }
4147
4148 #[test]
4149 fn test_auto_escape_number_values() {
4150 let mut context = HashMap::new();
4151 context.insert("count".to_string(), serde_json::json!(42));
4152
4153 let result = replace_variables("Count: #count#", &context);
4154 assert_eq!(result, "Count: 42");
4155 }
4156
4157 #[test]
4158 fn test_auto_escape_boolean_values() {
4159 let mut context = HashMap::new();
4160 context.insert("flag".to_string(), serde_json::json!(true));
4161
4162 let result = replace_variables("Flag: #flag#", &context);
4163 assert_eq!(result, "Flag: true");
4164 }
4165
4166 #[test]
4167 fn test_auto_escape_unresolved_variable() {
4168 let context = HashMap::new();
4169 let result = replace_variables("#unknown#", &context);
4172 assert_eq!(result, "#unknown#");
4174 }
4175
4176 #[test]
4177 fn test_nested_var_on_empty_parent_resolves_empty() {
4178 let mut context = HashMap::new();
4179 context.insert("old".into(), serde_json::json!({}));
4181 let result = replace_variables("#old.title#", &context);
4182 assert_eq!(
4183 result, "",
4184 "Missing child on existing parent should be empty"
4185 );
4186 }
4187
4188 #[test]
4189 fn test_nested_var_on_missing_root_stays_literal() {
4190 let context = HashMap::new();
4191 let result = replace_variables("#old.title#", &context);
4193 assert_eq!(result, "#old.title#", "Missing root should keep literal");
4194 }
4195
4196 #[test]
4197 fn test_nested_var_on_populated_parent_resolves() {
4198 let mut context = HashMap::new();
4199 context.insert("user".into(), serde_json::json!({"name": "Alice"}));
4200 assert_eq!(replace_variables("#user.name#", &context), "Alice");
4202 assert_eq!(replace_variables("#user.role#", &context), "");
4204 }
4205
4206 #[test]
4211 fn test_filter_parse_chain() {
4212 let (path, filters) = parse_filter_chain("name|uppercase");
4213 assert_eq!(path, "name");
4214 assert_eq!(filters.len(), 1);
4215 assert_eq!(filters[0].name, "uppercase");
4216 assert!(filters[0].args.is_empty());
4217 }
4218
4219 #[test]
4220 fn test_filter_parse_with_arg() {
4221 let (path, filters) = parse_filter_chain("title|truncate:50");
4222 assert_eq!(path, "title");
4223 assert_eq!(filters.len(), 1);
4224 assert_eq!(filters[0].name, "truncate");
4225 assert_eq!(filters[0].args, vec!["50"]);
4226 }
4227
4228 #[test]
4229 fn test_filter_parse_chained() {
4230 let (path, filters) = parse_filter_chain("title|truncate:50|uppercase");
4231 assert_eq!(path, "title");
4232 assert_eq!(filters.len(), 2);
4233 assert_eq!(filters[0].name, "truncate");
4234 assert_eq!(filters[1].name, "uppercase");
4235 }
4236
4237 #[test]
4238 fn test_filter_parse_quoted_args() {
4239 let (path, filters) = parse_filter_chain(r#"name|default:"Anonymous""#);
4240 assert_eq!(path, "name");
4241 assert_eq!(filters.len(), 1);
4242 assert_eq!(filters[0].name, "default");
4243 assert_eq!(filters[0].args, vec!["Anonymous"]);
4244 }
4245
4246 #[test]
4247 fn test_filter_parse_multiple_args() {
4248 let (path, filters) = parse_filter_chain(r#"text|replace:"old","new""#);
4249 assert_eq!(path, "text");
4250 assert_eq!(filters.len(), 1);
4251 assert_eq!(filters[0].name, "replace");
4252 assert_eq!(filters[0].args, vec!["old", "new"]);
4253 }
4254
4255 #[test]
4256 fn test_filter_uppercase() {
4257 let mut ctx = HashMap::new();
4258 ctx.insert("name".to_string(), serde_json::json!("hello"));
4259 let result = replace_variables("#name|uppercase#", &ctx);
4260 assert_eq!(result, "HELLO");
4261 }
4262
4263 #[test]
4264 fn test_filter_lowercase() {
4265 let mut ctx = HashMap::new();
4266 ctx.insert("name".to_string(), serde_json::json!("HELLO"));
4267 let result = replace_variables("#name|lowercase#", &ctx);
4268 assert_eq!(result, "hello");
4269 }
4270
4271 #[test]
4272 fn test_filter_capitalize() {
4273 let mut ctx = HashMap::new();
4274 ctx.insert("name".to_string(), serde_json::json!("hello world"));
4275 let result = replace_variables("#name|capitalize#", &ctx);
4276 assert_eq!(result, "Hello world");
4277 }
4278
4279 #[test]
4280 fn test_filter_title() {
4281 let mut ctx = HashMap::new();
4282 ctx.insert("name".to_string(), serde_json::json!("hello world today"));
4283 let result = replace_variables("#name|title#", &ctx);
4284 assert_eq!(result, "Hello World Today");
4285 }
4286
4287 #[test]
4288 fn test_filter_truncate() {
4289 let mut ctx = HashMap::new();
4290 ctx.insert(
4291 "text".to_string(),
4292 serde_json::json!("This is a long text that should be truncated"),
4293 );
4294 let result = replace_variables("#text|truncate:10#", &ctx);
4295 assert_eq!(result, "This is a ...");
4296 }
4297
4298 #[test]
4299 fn test_filter_truncate_short_text() {
4300 let mut ctx = HashMap::new();
4301 ctx.insert("text".to_string(), serde_json::json!("Short"));
4302 let result = replace_variables("#text|truncate:10#", &ctx);
4303 assert_eq!(result, "Short");
4304 }
4305
4306 #[test]
4307 fn test_filter_count() {
4308 let mut ctx = HashMap::new();
4309 ctx.insert("text".to_string(), serde_json::json!("hello"));
4310 let result = replace_variables("#text|count#", &ctx);
4311 assert_eq!(result, "5");
4312 }
4313
4314 #[test]
4315 fn test_filter_number() {
4316 let mut ctx = HashMap::new();
4317 ctx.insert("n".to_string(), serde_json::json!(1234567));
4318 let result = replace_variables("#n|number#", &ctx);
4319 assert_eq!(result, "1,234,567");
4320 }
4321
4322 #[test]
4323 fn test_filter_currency_usd() {
4324 let mut ctx = HashMap::new();
4325 ctx.insert("price".to_string(), serde_json::json!(1299.99));
4326 let result = replace_variables(r##"#price|currency:"USD"#"##, &ctx);
4327 assert_eq!(result, "$1,299.99");
4328 }
4329
4330 #[test]
4331 fn test_filter_currency_eur() {
4332 let mut ctx = HashMap::new();
4333 ctx.insert("price".to_string(), serde_json::json!(49.5));
4334 let result = replace_variables(r##"#price|currency:"EUR"#"##, &ctx);
4335 assert_eq!(result, "\u{20ac}49.50");
4336 }
4337
4338 #[test]
4339 fn test_filter_date() {
4340 let mut ctx = HashMap::new();
4341 ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4342 let result = replace_variables("#d|date#", &ctx);
4343 assert_eq!(result, "Mar 15, 2025"); }
4345
4346 #[test]
4347 fn test_filter_date_custom_format() {
4348 let mut ctx = HashMap::new();
4349 ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4350 let result = replace_variables(r##"#d|date:"dd/mm/yyyy"#"##, &ctx);
4351 assert_eq!(result, "15/03/2025");
4352 }
4353
4354 #[test]
4355 fn test_date_mask_short() {
4356 let mut ctx = HashMap::new();
4357 ctx.insert("d".to_string(), serde_json::json!("2025-03-05"));
4358 let result = replace_variables(r##"#d|date:"short"#"##, &ctx);
4359 assert_eq!(result, "3/5/25");
4360 }
4361
4362 #[test]
4363 fn test_date_mask_full() {
4364 let mut ctx = HashMap::new();
4365 ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4366 let result = replace_variables(r##"#d|date:"full"#"##, &ctx);
4367 assert_eq!(result, "Saturday, March 15, 2025");
4368 }
4369
4370 #[test]
4371 fn test_date_mask_long() {
4372 let mut ctx = HashMap::new();
4373 ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4374 let result = replace_variables(r##"#d|date:"long"#"##, &ctx);
4375 assert_eq!(result, "March 15, 2025");
4376 }
4377
4378 #[test]
4379 fn test_date_mask_iso() {
4380 let mut ctx = HashMap::new();
4381 ctx.insert("d".to_string(), serde_json::json!("2025-03-05"));
4382 let result = replace_variables(r##"#d|date:"iso"#"##, &ctx);
4383 assert_eq!(result, "2025-03-05");
4384 }
4385
4386 #[test]
4387 fn test_date_mask_time() {
4388 let mut ctx = HashMap::new();
4389 ctx.insert("d".to_string(), serde_json::json!("2025-03-15T14:05:09"));
4390 let result = replace_variables(r##"#d|date:"time"#"##, &ctx);
4391 assert_eq!(result, "2:05 PM");
4392 }
4393
4394 #[test]
4395 fn test_date_mask_combined() {
4396 let mut ctx = HashMap::new();
4397 ctx.insert("d".to_string(), serde_json::json!("2025-03-15T14:30:00"));
4398 let result = replace_variables(r##"#d|date:"mmm d, yyyy h:nn tt"#"##, &ctx);
4399 assert_eq!(result, "Mar 15, 2025 2:30 PM");
4400 }
4401
4402 #[test]
4403 fn test_date_mask_24hour() {
4404 let mut ctx = HashMap::new();
4405 ctx.insert("d".to_string(), serde_json::json!("2025-03-15T09:05:00"));
4406 let result = replace_variables(r##"#d|date:"HH:nn"#"##, &ctx);
4407 assert_eq!(result, "09:05");
4408 }
4409
4410 #[test]
4411 fn test_date_mask_weekday() {
4412 let mut ctx = HashMap::new();
4413 ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4414 let result = replace_variables(r##"#d|date:"ddd"#"##, &ctx);
4415 assert_eq!(result, "Sat");
4416 }
4417
4418 #[test]
4419 fn test_date_mask_literals() {
4420 let mut ctx = HashMap::new();
4421 ctx.insert("d".to_string(), serde_json::json!("2025-03-15"));
4422 let result = replace_variables(r##"#d|date:"yyyy-mm-dd"#"##, &ctx);
4423 assert_eq!(result, "2025-03-15");
4424 }
4425
4426 #[test]
4427 fn test_date_mask_midnight_12hr() {
4428 let mut ctx = HashMap::new();
4429 ctx.insert("d".to_string(), serde_json::json!("2025-03-15T00:00:00"));
4430 let result = replace_variables(r##"#d|date:"h:nn tt"#"##, &ctx);
4431 assert_eq!(result, "12:00 AM");
4432 }
4433
4434 #[test]
4435 fn test_date_rfc3339_input() {
4436 let mut ctx = HashMap::new();
4437 ctx.insert("d".to_string(), serde_json::json!("2025-03-15T14:30:00Z"));
4438 let result = replace_variables(r##"#d|date:"mmm d"#"##, &ctx);
4439 assert_eq!(result, "Mar 15");
4440 }
4441
4442 #[test]
4443 fn test_filter_json() {
4444 let mut ctx = HashMap::new();
4445 ctx.insert("name".to_string(), serde_json::json!("hello"));
4446 let result = replace_variables("#name|json#", &ctx);
4447 assert_eq!(result, ""hello""); }
4449
4450 #[test]
4451 fn test_filter_json_raw() {
4452 let mut ctx = HashMap::new();
4453 ctx.insert("name".to_string(), serde_json::json!("hello"));
4454 let result = replace_variables("#name|json|raw#", &ctx);
4455 assert_eq!(result, "\"hello\""); }
4457
4458 #[test]
4459 fn test_filter_markdown() {
4460 let mut ctx = HashMap::new();
4461 ctx.insert(
4462 "text".to_string(),
4463 serde_json::json!("This is **bold** and *italic*"),
4464 );
4465 let result = replace_variables("#text|markdown#", &ctx);
4466 assert!(result.contains("<strong>bold</strong>"));
4467 assert!(result.contains("<em>italic</em>"));
4468 assert!(result.contains("<p>"));
4470 }
4471
4472 #[test]
4473 fn test_markdown_url_scheme_allowlist() {
4474 assert!(markdown_url_is_safe("https://example.com"));
4476 assert!(markdown_url_is_safe("http://example.com"));
4477 assert!(markdown_url_is_safe("mailto:a@b.com"));
4478 assert!(markdown_url_is_safe("/relative/path"));
4479 assert!(markdown_url_is_safe("#anchor"));
4480 assert!(markdown_url_is_safe("page?x=1:2"));
4481 assert!(!markdown_url_is_safe("javascript:alert(1)"));
4484 assert!(!markdown_url_is_safe("JavaScript:alert(1)"));
4485 assert!(!markdown_url_is_safe("java\tscript:alert(1)"));
4486 assert!(!markdown_url_is_safe("java\nscript:alert(1)"));
4487 assert!(!markdown_url_is_safe("data:text/html,<script>"));
4488 assert!(!markdown_url_is_safe("vbscript:msgbox(1)"));
4489
4490 let mut ctx = HashMap::new();
4492 ctx.insert(
4493 "t".to_string(),
4494 serde_json::json!("[x](java\tscript:alert(1))"),
4495 );
4496 let out = replace_variables("#t|markdown#", &ctx);
4497 assert!(!out.contains("href"), "dangerous URL emitted an href: {out}");
4498 }
4499
4500 #[test]
4501 fn test_filter_pluralize() {
4502 let mut ctx = HashMap::new();
4503 ctx.insert("count".to_string(), serde_json::json!(1));
4504 let result = replace_variables(r##"#count# item#count|pluralize:"","s"#"##, &ctx);
4505 assert_eq!(result, "1 item");
4506
4507 ctx.insert("count".to_string(), serde_json::json!(5));
4508 let result = replace_variables(r##"#count# item#count|pluralize:"","s"#"##, &ctx);
4509 assert_eq!(result, "5 items");
4510 }
4511
4512 #[test]
4513 fn test_filter_default() {
4514 let ctx = HashMap::new();
4515 let result = replace_variables(r##"#missing|default:"N/A"#"##, &ctx);
4516 assert_eq!(result, "N/A");
4517 }
4518
4519 #[test]
4520 fn test_filter_default_not_needed() {
4521 let mut ctx = HashMap::new();
4522 ctx.insert("name".to_string(), serde_json::json!("Alice"));
4523 let result = replace_variables(r##"#name|default:"N/A"#"##, &ctx);
4524 assert_eq!(result, "Alice");
4525 }
4526
4527 #[test]
4528 fn test_filter_replace() {
4529 let mut ctx = HashMap::new();
4530 ctx.insert("text".to_string(), serde_json::json!("Hello World"));
4531 let result = replace_variables(r##"#text|replace:"World","Rust"#"##, &ctx);
4532 assert_eq!(result, "Hello Rust");
4533 }
4534
4535 #[test]
4536 fn test_filter_slice() {
4537 let mut ctx = HashMap::new();
4538 ctx.insert("text".to_string(), serde_json::json!("Hello World"));
4539 let result = replace_variables("#text|slice:0,5#", &ctx);
4540 assert_eq!(result, "Hello");
4541 }
4542
4543 #[test]
4544 fn test_filter_chaining() {
4545 let mut ctx = HashMap::new();
4546 ctx.insert("name".to_string(), serde_json::json!("hello world"));
4547 let result = replace_variables("#name|uppercase|truncate:5#", &ctx);
4548 assert_eq!(result, "HELLO...");
4549 }
4550
4551 #[test]
4552 fn test_filter_chaining_with_escaping() {
4553 let mut ctx = HashMap::new();
4554 ctx.insert("text".to_string(), serde_json::json!("<b>hello</b>"));
4555 let result = replace_variables("#text|uppercase#", &ctx);
4557 assert_eq!(result, "<B>HELLO</B>");
4558 }
4559
4560 #[test]
4561 fn test_filter_raw_bypasses_escaping() {
4562 let mut ctx = HashMap::new();
4563 ctx.insert("html".to_string(), serde_json::json!("<b>bold</b>"));
4564 let result = replace_variables("#html|raw#", &ctx);
4565 assert_eq!(result, "<b>bold</b>");
4566 }
4567
4568 #[test]
4569 fn test_filter_in_reactive_mode() {
4570 let mut ctx = HashMap::new();
4571 ctx.insert("name".to_string(), serde_json::json!("hello"));
4572 let result = replace_variables_reactive("<p>#name|uppercase#</p>", &ctx);
4573 assert!(result.html.contains("HELLO"));
4574 }
4575
4576 #[test]
4577 fn test_filter_default_with_session_var() {
4578 let ctx = HashMap::new();
4579 let result = replace_variables_reactive(r##"<p>#session.count|default:"0"#</p>"##, &ctx);
4580 assert!(result.html.contains("w-bind"));
4582 assert!(result.html.contains(">0<"));
4583 }
4584
4585 #[test]
4586 fn test_filter_unknown_passes_through() {
4587 let mut ctx = HashMap::new();
4588 ctx.insert("name".to_string(), serde_json::json!("hello"));
4589 let result = replace_variables("#name|bogusfilter#", &ctx);
4591 assert_eq!(result, "hello");
4592 }
4593
4594 #[test]
4595 fn test_filter_round() {
4596 let mut ctx = HashMap::new();
4597 ctx.insert("price".to_string(), json!("3.14159"));
4598 assert_eq!(replace_variables("#price|round:2#", &ctx), "3.14");
4599 }
4600
4601 #[test]
4602 fn test_filter_round_no_args() {
4603 let mut ctx = HashMap::new();
4604 ctx.insert("val".to_string(), json!("3.7"));
4605 assert_eq!(replace_variables("#val|round#", &ctx), "4");
4606 }
4607
4608 #[test]
4609 fn test_filter_ceil() {
4610 let mut ctx = HashMap::new();
4611 ctx.insert("val".to_string(), json!("3.2"));
4612 assert_eq!(replace_variables("#val|ceil#", &ctx), "4");
4613 }
4614
4615 #[test]
4616 fn test_filter_floor() {
4617 let mut ctx = HashMap::new();
4618 ctx.insert("val".to_string(), json!("3.9"));
4619 assert_eq!(replace_variables("#val|floor#", &ctx), "3");
4620 }
4621
4622 #[test]
4623 fn test_filter_ceil_negative() {
4624 let mut ctx = HashMap::new();
4625 ctx.insert("val".to_string(), json!("-2.3"));
4626 assert_eq!(replace_variables("#val|ceil#", &ctx), "-2");
4627 }
4628
4629 #[test]
4630 fn test_filter_floor_negative() {
4631 let mut ctx = HashMap::new();
4632 ctx.insert("val".to_string(), json!("-2.3"));
4633 assert_eq!(replace_variables("#val|floor#", &ctx), "-3");
4634 }
4635
4636 #[test]
4641 fn test_arithmetic_basic_addition() {
4642 assert_eq!(evaluate_arithmetic("10 + 1"), Some(11.0));
4643 }
4644
4645 #[test]
4646 fn test_arithmetic_subtraction() {
4647 assert_eq!(evaluate_arithmetic("10 - 3"), Some(7.0));
4648 }
4649
4650 #[test]
4651 fn test_arithmetic_multiply() {
4652 assert_eq!(evaluate_arithmetic("5 * 3"), Some(15.0));
4653 }
4654
4655 #[test]
4656 fn test_arithmetic_divide() {
4657 assert_eq!(evaluate_arithmetic("10 / 4"), Some(2.5));
4658 }
4659
4660 #[test]
4661 fn test_arithmetic_precedence() {
4662 assert_eq!(evaluate_arithmetic("2 + 3 * 4"), Some(14.0));
4664 }
4665
4666 #[test]
4667 fn test_arithmetic_division_by_zero() {
4668 assert_eq!(evaluate_arithmetic("10 / 0"), None);
4669 }
4670
4671 #[test]
4672 fn test_arithmetic_negative_result() {
4673 assert_eq!(evaluate_arithmetic("3 - 10"), Some(-7.0));
4674 }
4675
4676 #[test]
4677 fn test_arithmetic_not_arithmetic() {
4678 assert_eq!(evaluate_arithmetic("hello"), None);
4679 assert_eq!(evaluate_arithmetic("42"), None);
4680 }
4681
4682 #[test]
4683 fn test_arithmetic_in_template() {
4684 let mut ctx = HashMap::new();
4685 ctx.insert("session".to_string(), json!({"age": 25}));
4686 let result = replace_variables("#session.age + 1#", &ctx);
4687 assert_eq!(result, "26");
4688 }
4689
4690 #[test]
4691 fn test_arithmetic_multiply_in_template() {
4692 let mut ctx = HashMap::new();
4693 ctx.insert("price".to_string(), json!(100));
4694 let result = replace_variables("#price * 0.21#", &ctx);
4695 assert_eq!(result, "21");
4696 }
4697
4698 #[test]
4699 fn test_arithmetic_with_filter() {
4700 let mut ctx = HashMap::new();
4701 ctx.insert("price".to_string(), json!(99.99));
4702 let result = replace_variables("#price * 0.21|round:2#", &ctx);
4703 assert_eq!(result, "21.00");
4704 }
4705
4706 #[test]
4707 fn test_no_filters_still_escapes() {
4708 let mut ctx = HashMap::new();
4709 ctx.insert(
4710 "xss".to_string(),
4711 serde_json::json!("<script>alert(1)</script>"),
4712 );
4713 let result = replace_variables("#xss#", &ctx);
4714 assert!(!result.contains("<script>"));
4715 assert!(result.contains("<script>"));
4716 }
4717
4718 #[test]
4723 fn test_computed_variable_parsing() {
4724 let content = r##"<what>
4725title: My Page
4726compute.greeting = "Hello #user.name#!"
4727compute.full_url = "/posts/#post.id#"
4728</what>
4729<html></html>"##;
4730
4731 let (directives, _) = parse_page_directives(content);
4732 assert_eq!(directives.computed.len(), 2);
4733 assert_eq!(directives.computed[0].0, "greeting");
4734 assert_eq!(directives.computed[0].1, "Hello #user.name#!");
4735 assert_eq!(directives.computed[1].0, "full_url");
4736 assert_eq!(directives.computed[1].1, "/posts/#post.id#");
4737 }
4738
4739 #[test]
4740 fn test_computed_variable_resolution() {
4741 let mut context = HashMap::new();
4742 context.insert(
4743 "user".to_string(),
4744 serde_json::json!({
4745 "name": "Alice"
4746 }),
4747 );
4748
4749 let computed = vec![("greeting".to_string(), "Hello #user.name#!".to_string())];
4750
4751 resolve_computed_variables(&computed, &mut context);
4752
4753 assert_eq!(
4754 context.get("greeting"),
4755 Some(&serde_json::json!("Hello Alice!"))
4756 );
4757 }
4758
4759 #[test]
4760 fn test_computed_variable_chained() {
4761 let mut context = HashMap::new();
4762 context.insert("first".to_string(), serde_json::json!("John"));
4763 context.insert("last".to_string(), serde_json::json!("Doe"));
4764
4765 let computed = vec![
4766 ("full_name".to_string(), "#first# #last#".to_string()),
4767 ("greeting".to_string(), "Hello #full_name#!".to_string()),
4768 ];
4769
4770 resolve_computed_variables(&computed, &mut context);
4771
4772 assert_eq!(
4773 context.get("full_name"),
4774 Some(&serde_json::json!("John Doe"))
4775 );
4776 assert_eq!(
4777 context.get("greeting"),
4778 Some(&serde_json::json!("Hello John Doe!"))
4779 );
4780 }
4781
4782 #[test]
4783 fn test_computed_variable_with_nested_path() {
4784 let mut context = HashMap::new();
4785 context.insert(
4786 "post".to_string(),
4787 serde_json::json!({
4788 "id": 42,
4789 "title": "My Post"
4790 }),
4791 );
4792
4793 let computed = vec![(
4794 "edit_url".to_string(),
4795 "/admin/posts/#post.id#/edit".to_string(),
4796 )];
4797
4798 resolve_computed_variables(&computed, &mut context);
4799
4800 assert_eq!(
4801 context.get("edit_url"),
4802 Some(&serde_json::json!("/admin/posts/42/edit"))
4803 );
4804 }
4805
4806 #[test]
4807 fn test_computed_variable_unresolved_reference() {
4808 let mut context = HashMap::new();
4809
4810 let computed = vec![("url".to_string(), "/page/#missing_var#".to_string())];
4811
4812 resolve_computed_variables(&computed, &mut context);
4813
4814 assert_eq!(
4816 context.get("url"),
4817 Some(&serde_json::json!("/page/#missing_var#"))
4818 );
4819 }
4820
4821 #[test]
4822 fn test_computed_variable_no_prefix_in_template() {
4823 let mut context = HashMap::new();
4825 context.insert("x".to_string(), serde_json::json!("world"));
4826
4827 let computed = vec![("greeting".to_string(), "hello #x#".to_string())];
4828
4829 resolve_computed_variables(&computed, &mut context);
4830
4831 let result = replace_variables("Say: #greeting#", &context);
4833 assert_eq!(result, "Say: hello world");
4834 }
4835
4836 #[test]
4837 fn test_computed_variable_empty() {
4838 let mut context = HashMap::new();
4839 let computed: Vec<(String, String)> = Vec::new();
4840
4841 resolve_computed_variables(&computed, &mut context);
4842 assert!(context.is_empty());
4844 }
4845
4846 #[test]
4849 fn parse_wired_no_brackets() {
4850 let decl = parse_wired_decl("counter");
4851 assert_eq!(decl.name, "counter");
4852 assert!(matches!(decl.scope, WiredScope::Public));
4853 }
4854
4855 #[test]
4856 fn parse_wired_single_role() {
4857 let decl = parse_wired_decl("revenue [admin]");
4858 assert_eq!(decl.name, "revenue");
4859 match decl.scope {
4860 WiredScope::Roles(roles) => assert_eq!(roles, vec!["admin"]),
4861 _ => panic!("Expected Roles scope"),
4862 }
4863 }
4864
4865 #[test]
4866 fn parse_wired_multi_role() {
4867 let decl = parse_wired_decl("x [admin, editor]");
4868 assert_eq!(decl.name, "x");
4869 match decl.scope {
4870 WiredScope::Roles(roles) => assert_eq!(roles, vec!["admin", "editor"]),
4871 _ => panic!("Expected Roles scope"),
4872 }
4873 }
4874
4875 #[test]
4876 fn parse_wired_user_scope() {
4877 let decl = parse_wired_decl("notifs [user]");
4878 assert_eq!(decl.name, "notifs");
4879 assert!(matches!(decl.scope, WiredScope::User(_)));
4880 }
4881
4882 #[test]
4883 fn wired_backwards_compat() {
4884 let content = r#"data.wired = ["counter", "visitors"]"#;
4886 let config = parse_what_file(content);
4887 assert_eq!(config.data_wired.len(), 2);
4888 assert_eq!(config.data_wired[0].name, "counter");
4889 assert!(matches!(config.data_wired[0].scope, WiredScope::Public));
4890 assert_eq!(config.data_wired[1].name, "visitors");
4891 assert!(matches!(config.data_wired[1].scope, WiredScope::Public));
4892 }
4893
4894 #[test]
4895 fn wired_scope_allows_public() {
4896 let scope = WiredScope::Public;
4897 assert!(scope.allows(&[], None));
4898 assert!(scope.allows(&["admin".into()], Some("user1")));
4899 }
4900
4901 #[test]
4902 fn wired_scope_allows_role_match() {
4903 let scope = WiredScope::Roles(vec!["admin".into(), "editor".into()]);
4904 assert!(scope.allows(&["admin".into()], None));
4905 assert!(scope.allows(&["editor".into()], None));
4906 assert!(!scope.allows(&["viewer".into()], None));
4907 assert!(!scope.allows(&[], None));
4908 }
4909
4910 #[test]
4911 fn wired_scope_allows_user_match() {
4912 let scope = WiredScope::User("user42".into());
4913 assert!(scope.allows(&[], Some("user42")));
4914 assert!(!scope.allows(&[], Some("user99")));
4915 assert!(!scope.allows(&[], None));
4916 }
4917
4918 #[test]
4919 fn test_is_unquoted_string() {
4920 assert!(!is_unquoted_string("42"));
4922 assert!(!is_unquoted_string("3.14"));
4923 assert!(!is_unquoted_string("-1"));
4924 assert!(!is_unquoted_string("true"));
4926 assert!(!is_unquoted_string("false"));
4927 assert!(!is_unquoted_string("none"));
4928 assert!(!is_unquoted_string("all"));
4929 assert!(!is_unquoted_string("user"));
4930 assert!(!is_unquoted_string("None"));
4931 assert!(!is_unquoted_string(""));
4932 assert!(is_unquoted_string("Hello World"));
4934 assert!(is_unquoted_string("local:items"));
4935 assert!(is_unquoted_string("main"));
4936 assert!(is_unquoted_string("/login"));
4937 }
4938
4939 #[test]
4940 fn test_quoted_strings_no_warning() {
4941 let content = r#"title: "My Page"
4943layout: "main"
4944fetch.items = "local:items"
4945greeting = "Hello World""#;
4946 let mut directives = PageDirectives::default();
4947 parse_directive_content(content, &mut directives);
4948 assert_eq!(directives.title.as_deref(), Some("My Page"));
4949 assert_eq!(directives.layout.as_deref(), Some("main"));
4950 assert_eq!(
4951 directives.custom.get("fetch.items").map(|s| s.as_str()),
4952 Some("local:items")
4953 );
4954 assert_eq!(
4955 directives.vars.get("greeting"),
4956 Some(&serde_json::json!("Hello World"))
4957 );
4958 }
4959
4960 #[test]
4961 fn test_unquoted_numbers_and_bools_ok() {
4962 let content = "count = 42\nprice = 9.99\nactive = true";
4964 let mut directives = PageDirectives::default();
4965 parse_directive_content(content, &mut directives);
4966 assert_eq!(directives.vars.get("count"), Some(&serde_json::json!(42)));
4967 assert_eq!(directives.vars.get("price"), Some(&serde_json::json!(9.99)));
4968 assert_eq!(
4969 directives.vars.get("active"),
4970 Some(&serde_json::json!(true))
4971 );
4972 }
4973
4974 #[test]
4975 fn test_html_unescape_round_trip() {
4976 assert_eq!(html_unescape(&html_escape("Ben & Jerry")), "Ben & Jerry");
4977 assert_eq!(html_unescape(&html_escape("O'Brien")), "O'Brien");
4978 assert_eq!(html_unescape(&html_escape("a < b > c")), "a < b > c");
4979 assert_eq!(html_unescape(&html_escape("<")), "<");
4981 assert_eq!(html_unescape("plain"), "plain");
4982 }
4983
4984 #[test]
4985 fn test_count_filter_counts_items_not_bytes() {
4986 let mut ctx = HashMap::new();
4987 ctx.insert(
4988 "items".to_string(),
4989 serde_json::json!([{"name": "a"}, {"name": "b"}, {"name": "c"}]),
4990 );
4991 ctx.insert("name".to_string(), serde_json::json!("José"));
4992 assert_eq!(replace_variables("#items|count#", &ctx), "3");
4993 assert_eq!(replace_variables("#name|count#", &ctx), "4");
4994 }
4995
4996 #[test]
4997 fn test_within_one_edit() {
4998 assert!(within_one_edit("auth", "auth"));
4999 assert!(within_one_edit("auht", "auth")); assert!(within_one_edit("atuh", "auth")); assert!(within_one_edit("aut", "auth")); assert!(within_one_edit("auths", "auth")); assert!(within_one_edit("autj", "auth")); assert!(within_one_edit("oauth", "auth")); assert!(!within_one_edit("author", "auth"));
5006 assert!(!within_one_edit("au", "auth"));
5007 assert!(!within_one_edit("layout", "auth"));
5008 }
5009
5010 #[test]
5011 fn test_access_directive_near_miss() {
5012 assert_eq!(access_directive_near_miss("auht"), Some("auth"));
5013 assert_eq!(access_directive_near_miss("atuh"), Some("auth"));
5014 assert_eq!(access_directive_near_miss("aut"), Some("auth"));
5015 assert_eq!(access_directive_near_miss("Auth"), Some("auth")); assert_eq!(access_directive_near_miss("role"), Some("roles"));
5017 assert_eq!(access_directive_near_miss("protectd"), Some("protected"));
5018 assert_eq!(access_directive_near_miss("auth"), None);
5020 assert_eq!(access_directive_near_miss("oauth"), None);
5022 assert_eq!(access_directive_near_miss("title"), None);
5024 assert_eq!(access_directive_near_miss("items"), None);
5025 }
5026
5027 #[test]
5028 fn test_auth_typo_key_stays_inline_var_and_page_stays_public() {
5029 let content = r#"auht: "user""#;
5033 let mut directives = PageDirectives::default();
5034 parse_directive_content(content, &mut directives);
5035 assert!(matches!(directives.auth, AuthLevel::All));
5036 assert_eq!(directives.vars.get("auht"), Some(&serde_json::json!("user")));
5037 }
5038
5039 #[test]
5040 fn test_strip_symmetric_quotes() {
5041 assert_eq!(strip_symmetric_quotes(r#""hello""#), ("hello", true));
5042 assert_eq!(strip_symmetric_quotes("'hello'"), ("hello", true));
5043 assert_eq!(strip_symmetric_quotes("hello"), ("hello", false));
5044 assert_eq!(strip_symmetric_quotes(r#""hello'"#), (r#""hello'"#, false));
5046 assert_eq!(strip_symmetric_quotes("''x''"), ("'x'", true));
5048 assert_eq!(strip_symmetric_quotes(r#""""#), ("", true));
5050 assert_eq!(strip_symmetric_quotes(r#"""#), (r#"""#, false));
5052 }
5053
5054 #[test]
5055 fn test_quoting_forces_string_type_inline_vars() {
5056 let content = "zip = \"01234\"\nversion = \"1.0\"\nflag = \"true\"\ncount = 42";
5058 let mut directives = PageDirectives::default();
5059 parse_directive_content(content, &mut directives);
5060 assert_eq!(
5061 directives.vars.get("zip"),
5062 Some(&serde_json::json!("01234")),
5063 "quoted leading-zero value must stay a string"
5064 );
5065 assert_eq!(
5066 directives.vars.get("version"),
5067 Some(&serde_json::json!("1.0")),
5068 "quoted numeric-looking value must stay a string"
5069 );
5070 assert_eq!(
5071 directives.vars.get("flag"),
5072 Some(&serde_json::json!("true")),
5073 "quoted boolean-looking value must stay a string"
5074 );
5075 assert_eq!(directives.vars.get("count"), Some(&serde_json::json!(42)));
5076 }
5077
5078 #[test]
5079 fn test_quoting_forces_string_type_session_mutations() {
5080 let set = parse_session_mutation(r#"session.zip = "01234""#).unwrap();
5081 match set {
5082 SessionMutation::Set { key, value } => {
5083 assert_eq!(key, "zip");
5084 assert_eq!(value, serde_json::json!("01234"));
5085 }
5086 other => panic!("expected Set, got {:?}", other),
5087 }
5088 let set = parse_session_mutation("session.count = 42").unwrap();
5089 match set {
5090 SessionMutation::Set { value, .. } => {
5091 assert_eq!(value, serde_json::json!(42));
5092 }
5093 other => panic!("expected Set, got {:?}", other),
5094 }
5095 let push = parse_session_mutation(r#"session.items.push("42")"#).unwrap();
5096 match push {
5097 SessionMutation::Push { value, .. } => {
5098 assert_eq!(value, serde_json::json!("42"));
5099 }
5100 other => panic!("expected Push, got {:?}", other),
5101 }
5102 }
5103
5104 #[test]
5105 fn test_mismatched_quotes_left_intact() {
5106 let content = "label = \"oops'";
5108 let mut directives = PageDirectives::default();
5109 parse_directive_content(content, &mut directives);
5110 assert_eq!(
5111 directives.vars.get("label"),
5112 Some(&serde_json::json!("\"oops'"))
5113 );
5114 }
5115
5116 #[test]
5117 fn test_what_file_quoted_number_stays_string() {
5118 let config = parse_what_file("zip = \"01234\"\ncount = 7");
5120 assert_eq!(config.values.get("zip"), Some(&serde_json::json!("01234")));
5121 assert_eq!(config.values.get("count"), Some(&serde_json::json!(7)));
5122 }
5123}