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