Skip to main content

a2ui_base/catalog/
basic_functions.rs

1//! Basic catalog function implementations for A2UI.
2//!
3//! Provides validation, logical, formatting, localization, and side-effect
4//! functions that implement the `FunctionImplementation` trait.
5
6use std::collections::HashMap;
7
8use chrono::{NaiveDateTime, Timelike, Datelike};
9use regex::Regex;
10use serde_json::Value;
11
12use crate::catalog::function_api::{FunctionImplementation, ReturnType};
13use crate::error::A2uiError;
14use crate::model::data_context::DataContext;
15
16// ---------------------------------------------------------------------------
17// Helper
18// ---------------------------------------------------------------------------
19
20/// Extract a required string argument, returning a descriptive error if missing.
21fn require_str<'a>(
22    args: &'a HashMap<String, Value>,
23    key: &str,
24    func_name: &str,
25) -> std::result::Result<&'a str, A2uiError> {
26    args.get(key)
27        .and_then(|v| v.as_str())
28        .ok_or_else(|| {
29            A2uiError::InvalidFunctionCall(format!(
30                "{func_name}: missing or non-string argument '{key}'"
31            ))
32        })
33}
34
35/// Extract an optional f64 argument.
36fn opt_f64(args: &HashMap<String, Value>, key: &str) -> Option<f64> {
37    args.get(key).and_then(|v| v.as_f64())
38}
39
40/// Extract an optional bool argument.
41fn opt_bool(args: &HashMap<String, Value>, key: &str) -> Option<bool> {
42    args.get(key).and_then(|v| v.as_bool())
43}
44
45// ===========================================================================
46// 1. Required
47// ===========================================================================
48
49/// Validation function that returns `true` when the value is non-null,
50/// non-empty-string, and non-empty-array.
51pub struct RequiredFunction;
52
53impl FunctionImplementation for RequiredFunction {
54    fn name(&self) -> &'static str {
55        "required"
56    }
57
58    fn return_type(&self) -> ReturnType {
59        ReturnType::Boolean
60    }
61
62    fn execute(
63        &self,
64        args: &HashMap<String, Value>,
65        _context: &DataContext,
66    ) -> Result<Value, A2uiError> {
67        let val = args.get("value").cloned().unwrap_or(Value::Null);
68        let present = match &val {
69            Value::Null => false,
70            Value::String(s) => !s.is_empty(),
71            Value::Array(arr) => !arr.is_empty(),
72            _ => true,
73        };
74        Ok(Value::Bool(present))
75    }
76}
77
78// ===========================================================================
79// 2. Regex
80// ===========================================================================
81
82/// Validation function that tests a string against a regex pattern.
83pub struct RegexFunction;
84
85impl FunctionImplementation for RegexFunction {
86    fn name(&self) -> &'static str {
87        "regex"
88    }
89
90    fn return_type(&self) -> ReturnType {
91        ReturnType::Boolean
92    }
93
94    fn execute(
95        &self,
96        args: &HashMap<String, Value>,
97        _context: &DataContext,
98    ) -> Result<Value, A2uiError> {
99        let value = require_str(args, "value", "regex")?;
100        let pattern = require_str(args, "pattern", "regex")?;
101
102        let re = Regex::new(pattern).map_err(|e| {
103            A2uiError::InvalidFunctionCall(format!("regex: invalid pattern '{pattern}': {e}"))
104        })?;
105
106        Ok(Value::Bool(re.is_match(value)))
107    }
108}
109
110// ===========================================================================
111// 3. Length
112// ===========================================================================
113
114/// Validation function that checks string length bounds.
115pub struct LengthFunction;
116
117impl FunctionImplementation for LengthFunction {
118    fn name(&self) -> &'static str {
119        "length"
120    }
121
122    fn return_type(&self) -> ReturnType {
123        ReturnType::Boolean
124    }
125
126    fn execute(
127        &self,
128        args: &HashMap<String, Value>,
129        _context: &DataContext,
130    ) -> Result<Value, A2uiError> {
131        let value = require_str(args, "value", "length")?;
132        let len = value.chars().count() as f64;
133
134        if let Some(min) = opt_f64(args, "min") {
135            if len < min {
136                return Ok(Value::Bool(false));
137            }
138        }
139        if let Some(max) = opt_f64(args, "max") {
140            if len > max {
141                return Ok(Value::Bool(false));
142            }
143        }
144        Ok(Value::Bool(true))
145    }
146}
147
148// ===========================================================================
149// 4. Numeric
150// ===========================================================================
151
152/// Validation function that checks whether a value is a valid number within
153/// optional bounds.
154pub struct NumericFunction;
155
156impl FunctionImplementation for NumericFunction {
157    fn name(&self) -> &'static str {
158        "numeric"
159    }
160
161    fn return_type(&self) -> ReturnType {
162        ReturnType::Boolean
163    }
164
165    fn execute(
166        &self,
167        args: &HashMap<String, Value>,
168        _context: &DataContext,
169    ) -> Result<Value, A2uiError> {
170        let val = args.get("value").cloned().unwrap_or(Value::Null);
171
172        // Accept either a JSON number or a numeric string.
173        let num = match &val {
174            Value::Number(n) => n.as_f64(),
175            Value::String(s) => s.parse::<f64>().ok(),
176            _ => None,
177        };
178
179        let Some(n) = num else {
180            return Ok(Value::Bool(false));
181        };
182
183        if let Some(min) = opt_f64(args, "min") {
184            if n < min {
185                return Ok(Value::Bool(false));
186            }
187        }
188        if let Some(max) = opt_f64(args, "max") {
189            if n > max {
190                return Ok(Value::Bool(false));
191            }
192        }
193        Ok(Value::Bool(true))
194    }
195}
196
197// ===========================================================================
198// 5. Email
199// ===========================================================================
200
201/// Validation function that tests a value against a simple email pattern.
202pub struct EmailFunction;
203
204impl FunctionImplementation for EmailFunction {
205    fn name(&self) -> &'static str {
206        "email"
207    }
208
209    fn return_type(&self) -> ReturnType {
210        ReturnType::Boolean
211    }
212
213    fn execute(
214        &self,
215        args: &HashMap<String, Value>,
216        _context: &DataContext,
217    ) -> Result<Value, A2uiError> {
218        let value = require_str(args, "value", "email")?;
219
220        // /^[^\s@]+@[^\s@]+\.[^\s@]+$/
221        let re = Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap();
222        Ok(Value::Bool(re.is_match(value)))
223    }
224}
225
226// ===========================================================================
227// 6. And
228// ===========================================================================
229
230/// Logical AND over an array of booleans.
231pub struct AndFunction;
232
233impl FunctionImplementation for AndFunction {
234    fn name(&self) -> &'static str {
235        "and"
236    }
237
238    fn return_type(&self) -> ReturnType {
239        ReturnType::Boolean
240    }
241
242    fn execute(
243        &self,
244        args: &HashMap<String, Value>,
245        _context: &DataContext,
246    ) -> Result<Value, A2uiError> {
247        let arr = args
248            .get("values")
249            .and_then(|v| v.as_array())
250            .ok_or_else(|| {
251                A2uiError::InvalidFunctionCall("and: missing or non-array argument 'values'".into())
252            })?;
253
254        let all_true = arr.iter().all(|v| v.as_bool().unwrap_or(false));
255        Ok(Value::Bool(all_true))
256    }
257}
258
259// ===========================================================================
260// 7. Or
261// ===========================================================================
262
263/// Logical OR over an array of booleans.
264pub struct OrFunction;
265
266impl FunctionImplementation for OrFunction {
267    fn name(&self) -> &'static str {
268        "or"
269    }
270
271    fn return_type(&self) -> ReturnType {
272        ReturnType::Boolean
273    }
274
275    fn execute(
276        &self,
277        args: &HashMap<String, Value>,
278        _context: &DataContext,
279    ) -> Result<Value, A2uiError> {
280        let arr = args
281            .get("values")
282            .and_then(|v| v.as_array())
283            .ok_or_else(|| {
284                A2uiError::InvalidFunctionCall("or: missing or non-array argument 'values'".into())
285            })?;
286
287        let any_true = arr.iter().any(|v| v.as_bool().unwrap_or(false));
288        Ok(Value::Bool(any_true))
289    }
290}
291
292// ===========================================================================
293// 8. Not
294// ===========================================================================
295
296/// Logical NOT of a single boolean value.
297pub struct NotFunction;
298
299impl FunctionImplementation for NotFunction {
300    fn name(&self) -> &'static str {
301        "not"
302    }
303
304    fn return_type(&self) -> ReturnType {
305        ReturnType::Boolean
306    }
307
308    fn execute(
309        &self,
310        args: &HashMap<String, Value>,
311        _context: &DataContext,
312    ) -> Result<Value, A2uiError> {
313        let val = args
314            .get("value")
315            .and_then(|v| v.as_bool())
316            .ok_or_else(|| {
317                A2uiError::InvalidFunctionCall(
318                    "not: missing or non-boolean argument 'value'".into(),
319                )
320            })?;
321
322        Ok(Value::Bool(!val))
323    }
324}
325
326// ===========================================================================
327// 9. FormatNumber
328// ===========================================================================
329
330/// Format a number with optional grouping and decimal precision.
331pub struct FormatNumberFunction;
332
333impl FunctionImplementation for FormatNumberFunction {
334    fn name(&self) -> &'static str {
335        "formatNumber"
336    }
337
338    fn return_type(&self) -> ReturnType {
339        ReturnType::String
340    }
341
342    fn execute(
343        &self,
344        args: &HashMap<String, Value>,
345        _context: &DataContext,
346    ) -> Result<Value, A2uiError> {
347        let val = args
348            .get("value")
349            .and_then(|v| v.as_f64())
350            .ok_or_else(|| {
351                A2uiError::InvalidFunctionCall(
352                    "formatNumber: missing or non-numeric argument 'value'".into(),
353                )
354            })?;
355
356        let grouping = opt_bool(args, "grouping").unwrap_or(true);
357        let decimals = opt_f64(args, "decimals").map(|d| d as usize);
358
359        let formatted = format_number_impl(val, grouping, decimals);
360        Ok(Value::String(formatted))
361    }
362}
363
364/// Core number formatting logic.
365fn format_number_impl(val: f64, grouping: bool, decimals: Option<usize>) -> String {
366    let abs = val.abs();
367    let sign = if val < 0.0 { "-" } else { "" };
368
369    // Integer and fractional parts.
370    let int_part = abs.trunc() as u64;
371
372    let int_str = if grouping {
373        format_with_grouping(int_part)
374    } else {
375        int_part.to_string()
376    };
377
378    let frac_str = match decimals {
379        Some(d) => {
380            // Round to exactly `d` decimal places.
381            let rounded = format!("{abs:.d$}");
382            // rounded is "NNN.FFF"; take everything after the dot.
383            if d == 0 {
384                String::new()
385            } else {
386                rounded
387                    .find('.')
388                    .map(|pos| rounded[pos + 1..].to_string())
389                    .unwrap_or_default()
390            }
391        }
392        None => {
393            // Use the original decimal representation to avoid float noise.
394            // `format!("{}", f64)` gives a reasonably short representation.
395            let s = format!("{abs}");
396            if let Some(dot) = s.find('.') {
397                let frac = &s[dot + 1..];
398                // "0" means no meaningful fractional part.
399                if frac == "0" {
400                    String::new()
401                } else {
402                    frac.to_string()
403                }
404            } else {
405                String::new()
406            }
407        }
408    };
409
410    if frac_str.is_empty() {
411        format!("{sign}{int_str}")
412    } else {
413        format!("{sign}{int_str}.{frac_str}")
414    }
415}
416
417/// Insert comma thousands separators into an integer string.
418fn format_with_grouping(n: u64) -> String {
419    let s = n.to_string();
420    let mut result = String::with_capacity(s.len() + s.len() / 3);
421    let mut count = 0;
422    for ch in s.chars().rev() {
423        if count == 3 {
424            result.push(',');
425            count = 0;
426        }
427        result.push(ch);
428        count += 1;
429    }
430    result.chars().rev().collect()
431}
432
433// ===========================================================================
434// 10. FormatCurrency
435// ===========================================================================
436
437/// Format a number as a currency string.
438pub struct FormatCurrencyFunction;
439
440impl FunctionImplementation for FormatCurrencyFunction {
441    fn name(&self) -> &'static str {
442        "formatCurrency"
443    }
444
445    fn return_type(&self) -> ReturnType {
446        ReturnType::String
447    }
448
449    fn execute(
450        &self,
451        args: &HashMap<String, Value>,
452        _context: &DataContext,
453    ) -> Result<Value, A2uiError> {
454        let val = args
455            .get("value")
456            .and_then(|v| v.as_f64())
457            .ok_or_else(|| {
458                A2uiError::InvalidFunctionCall(
459                    "formatCurrency: missing or non-numeric argument 'value'".into(),
460                )
461            })?;
462
463        let currency = require_str(args, "currency", "formatCurrency")?;
464
465        let grouping = opt_bool(args, "grouping").unwrap_or(true);
466        let decimals = opt_f64(args, "decimals").map(|d| d as usize);
467
468        let formatted = format_number_impl(val, grouping, decimals);
469        Ok(Value::String(format!("{currency} {formatted}")))
470    }
471}
472
473// ===========================================================================
474// 11. FormatDate
475// ===========================================================================
476
477/// Format an ISO-8601 datetime string using a TR35-style pattern.
478pub struct FormatDateFunction;
479
480impl FunctionImplementation for FormatDateFunction {
481    fn name(&self) -> &'static str {
482        "formatDate"
483    }
484
485    fn return_type(&self) -> ReturnType {
486        ReturnType::String
487    }
488
489    fn execute(
490        &self,
491        args: &HashMap<String, Value>,
492        _context: &DataContext,
493    ) -> Result<Value, A2uiError> {
494        let value = require_str(args, "value", "formatDate")?;
495        let fmt = require_str(args, "format", "formatDate")?;
496
497        // Parse as ISO 8601. Try with timezone first, then without.
498        let dt = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S")
499            .or_else(|_| NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M:%S%.f"))
500            .or_else(|_| NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S"))
501            .or_else(|_| {
502                // Try date-only
503                chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d")
504                    .map(|d| d.and_hms_opt(0, 0, 0).unwrap())
505            })
506            .map_err(|_| {
507                A2uiError::InvalidFunctionCall(format!(
508                    "formatDate: could not parse datetime '{value}'"
509                ))
510            })?;
511
512        let formatted = apply_date_format(&dt, fmt);
513        Ok(Value::String(formatted))
514    }
515}
516
517/// Apply a simple TR35-style date format pattern.
518fn apply_date_format(dt: &NaiveDateTime, fmt: &str) -> String {
519    let mut result = String::with_capacity(fmt.len() * 2);
520    let chars: Vec<char> = fmt.chars().collect();
521    let mut i = 0;
522
523    let weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
524    let months = [
525        "Jan",
526        "Feb",
527        "Mar",
528        "Apr",
529        "May",
530        "Jun",
531        "Jul",
532        "Aug",
533        "Sep",
534        "Oct",
535        "Nov",
536        "Dec",
537    ];
538
539    while i < chars.len() {
540        let c = chars[i];
541
542        // Count consecutive identical letters.
543        let start = i;
544        while i < chars.len() && chars[i] == c {
545            i += 1;
546        }
547        let count = i - start;
548
549        match c {
550            'y' => match count {
551                4 => result.push_str(&format!("{:04}", dt.year())),
552                2 => result.push_str(&format!("{:02}", dt.year() % 100)),
553                _ => result.push_str(&dt.year().to_string()),
554            },
555            'M' => match count {
556                3 => result.push_str(months[(dt.month() - 1) as usize]),
557                2 => result.push_str(&format!("{:02}", dt.month())),
558                1 => result.push_str(&dt.month().to_string()),
559                _ => result.push_str(&format!("{:02}", dt.month())),
560            },
561            'd' => match count {
562                2 => result.push_str(&format!("{:02}", dt.day())),
563                1 => result.push_str(&dt.day().to_string()),
564                _ => result.push_str(&format!("{:02}", dt.day())),
565            },
566            'H' => match count {
567                2 => result.push_str(&format!("{:02}", dt.hour())),
568                1 => result.push_str(&dt.hour().to_string()),
569                _ => result.push_str(&format!("{:02}", dt.hour())),
570            },
571            'm' => match count {
572                2 => result.push_str(&format!("{:02}", dt.minute())),
573                1 => result.push_str(&dt.minute().to_string()),
574                _ => result.push_str(&format!("{:02}", dt.minute())),
575            },
576            's' => match count {
577                2 => result.push_str(&format!("{:02}", dt.second())),
578                1 => result.push_str(&dt.second().to_string()),
579                _ => result.push_str(&format!("{:02}", dt.second())),
580            },
581            'E' => {
582                // chrono::Weekday: Mon=0 .. Sun=6
583                result.push_str(weekdays[dt.weekday().num_days_from_monday() as usize]);
584            }
585            'h' => {
586                // 12-hour clock: 1-12 (no leading zero for single 'h', leading zero for 'hh')
587                let hour_12 = dt.hour() % 12;
588                let hour_12 = if hour_12 == 0 { 12 } else { hour_12 };
589                match count {
590                    2 => result.push_str(&format!("{hour_12:02}")),
591                    _ => result.push_str(&hour_12.to_string()),
592                }
593            }
594            'a' => {
595                // AM/PM marker
596                let ampm = if dt.hour() < 12 { "AM" } else { "PM" };
597                result.push_str(ampm);
598            }
599            '\'' => {
600                // Escaped literal between single quotes.
601                // Find the closing quote.
602                let mut j = start + 1;
603                while j < chars.len() && chars[j] != '\'' {
604                    result.push(chars[j]);
605                    j += 1;
606                }
607                i = j + 1;
608            }
609            _ => {
610                // Literal character — push all repetitions.
611                for _ in 0..count {
612                    result.push(c);
613                }
614            }
615        }
616    }
617
618    result
619}
620
621// ===========================================================================
622// 12. FormatString
623// ===========================================================================
624
625/// String interpolation with `${expression}` blocks and `\${` escaping.
626pub struct FormatStringFunction;
627
628impl FunctionImplementation for FormatStringFunction {
629    fn name(&self) -> &'static str {
630        "formatString"
631    }
632
633    fn return_type(&self) -> ReturnType {
634        ReturnType::String
635    }
636
637    fn execute(
638        &self,
639        args: &HashMap<String, Value>,
640        context: &DataContext,
641    ) -> Result<Value, A2uiError> {
642        let value = require_str(args, "value", "formatString")?;
643        let result = interpolate_string(value, context);
644        Ok(Value::String(result))
645    }
646}
647
648/// Perform basic `${...}` interpolation on a template string.
649///
650/// Supported expressions:
651/// - `${/absolute/path}` — resolve absolute data path
652/// - `${relative/path}` — resolve relative data path (via context)
653/// - `${functionName(key:value,...)}` — call a registered function
654/// - `\${` — escaped literal `${`
655fn interpolate_string(template: &str, context: &DataContext) -> String {
656    let mut result = String::with_capacity(template.len());
657    let bytes = template.as_bytes();
658    let mut i = 0;
659
660    while i < bytes.len() {
661        if bytes[i] == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'$' {
662            // Check for escaped `\${`
663            if i + 2 < bytes.len() && bytes[i + 2] == b'{' {
664                result.push_str("${");
665                i += 3;
666                continue;
667            }
668            // Just a backslash before $ without { — keep as-is.
669            result.push('\\');
670            i += 1;
671            continue;
672        }
673
674        if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
675            // Find the closing `}`.
676            let start = i + 2;
677            let mut depth = 1u32;
678            let mut end = start;
679            while end < bytes.len() && depth > 0 {
680                if bytes[end] == b'{' {
681                    depth += 1;
682                } else if bytes[end] == b'}' {
683                    depth -= 1;
684                }
685                if depth > 0 {
686                    end += 1;
687                }
688            }
689
690            if depth == 0 {
691                let expr = &template[start..end];
692                let resolved = resolve_expression(expr, context);
693                result.push_str(&resolved);
694                i = end + 1; // skip past '}'
695            } else {
696                // Unmatched `${`, keep as literal.
697                result.push_str("${");
698                i += 2;
699            }
700        } else {
701            result.push(bytes[i] as char);
702            i += 1;
703        }
704    }
705
706    result
707}
708
709/// Resolve a single expression inside `${...}`.
710///
711/// Supported forms:
712/// - `/absolute/path` or `relative/path` — data interpolation
713/// - `functionName(key1:value1,key2:value2)` — function call with arguments
714///
715/// Function call argument values can be:
716/// - A data path: `/path/to/data` or `relative/path`
717/// - A quoted string: `'text'`
718/// - A number: `42` or `3.14`
719fn resolve_expression(expr: &str, context: &DataContext) -> String {
720    let trimmed = expr.trim();
721
722    // Check for function call syntax: identifier followed by '('
723    if let Some(paren_pos) = trimmed.find('(') {
724        let func_name = &trimmed[..paren_pos];
725        // Validate that func_name looks like an identifier (letters, digits, underscore).
726        if is_identifier(func_name)
727            && trimmed.ends_with(')')
728            && func_name.chars().next().map_or(false, |c| c.is_alphabetic() || c == '_')
729        {
730            let args_str = &trimmed[paren_pos + 1..trimmed.len() - 1];
731            let args = match parse_function_args(args_str, context) {
732                Ok(a) => a,
733                Err(_) => return String::new(),
734            };
735            return context
736                .call_function_by_name(func_name, &args)
737                .map(|v| crate::model::data_context::value_to_string(&v))
738                .unwrap_or_default();
739        }
740    }
741
742    // Absolute data path.
743    if trimmed.starts_with('/') {
744        return context
745            .get(trimmed)
746            .map(|v| crate::model::data_context::value_to_string(&v))
747            .unwrap_or_default();
748    }
749
750    // Relative data path.
751    context
752        .get(trimmed)
753        .map(|v| crate::model::data_context::value_to_string(&v))
754        .unwrap_or_default()
755}
756
757/// Check if a string is a valid identifier (alphanumeric + underscore).
758fn is_identifier(s: &str) -> bool {
759    !s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_')
760}
761
762/// Parse function arguments from a comma-separated `key:value` string.
763///
764/// Values can be:
765/// - Single-quoted strings: `'hello'`
766/// - Data paths: `/path` or `relative`
767/// - Numbers: `42`, `3.14`
768/// - Booleans: `true`, `false`
769fn parse_function_args(
770    args_str: &str,
771    context: &DataContext,
772) -> Result<HashMap<String, Value>, A2uiError> {
773    let mut args = HashMap::new();
774    if args_str.trim().is_empty() {
775        return Ok(args);
776    }
777
778    let mut i = 0;
779    let chars: Vec<char> = args_str.chars().collect();
780
781    while i < chars.len() {
782        // Skip whitespace and commas between args.
783        while i < chars.len() && (chars[i].is_whitespace() || chars[i] == ',') {
784            i += 1;
785        }
786        if i >= chars.len() {
787            break;
788        }
789
790        // Parse key (identifier).
791        let key_start = i;
792        while i < chars.len() && chars[i] != ':' && chars[i] != '=' {
793            i += 1;
794        }
795        if i >= chars.len() || (chars[i] != ':' && chars[i] != '=') {
796            return Err(A2uiError::InvalidFunctionCall(format!(
797                "formatString: expected ':' or '=' in function args at position {i}"
798            )));
799        }
800        let key: String = chars[key_start..i].iter().collect();
801        let key = key.trim().to_string();
802        i += 1; // skip ':' or '='
803
804        // Skip whitespace after separator.
805        while i < chars.len() && chars[i].is_whitespace() {
806            i += 1;
807        }
808
809        // Parse value.
810        let val;
811        if i + 1 < chars.len() && chars[i] == '$' && chars[i + 1] == '{' {
812            // Nested ${...} expression — resolve recursively.
813            i += 2; // skip '${'
814            let mut depth = 1u32;
815            let inner_start = i;
816            while i < chars.len() && depth > 0 {
817                if chars[i] == '{' {
818                    depth += 1;
819                } else if chars[i] == '}' {
820                    depth -= 1;
821                }
822                if depth > 0 {
823                    i += 1;
824                }
825            }
826            let inner: String = chars[inner_start..i].iter().collect();
827            if i < chars.len() {
828                i += 1; // skip closing '}'
829            }
830            // Resolve the inner expression as a data path.
831            val = context
832                .get(inner.trim())
833                .unwrap_or(Value::String(String::new()));
834        } else if i < chars.len() && chars[i] == '\'' {
835            // Single-quoted string.
836            i += 1; // skip opening quote
837            let val_start = i;
838            while i < chars.len() && chars[i] != '\'' {
839                i += 1;
840            }
841            let s: String = chars[val_start..i].iter().collect();
842            if i < chars.len() {
843                i += 1; // skip closing quote
844            }
845            val = Value::String(s);
846        } else if i < chars.len() && (chars[i] == '-' || chars[i].is_ascii_digit()) {
847            // Number (possibly negative).
848            let val_start = i;
849            if chars[i] == '-' {
850                i += 1;
851            }
852            while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
853                i += 1;
854            }
855            let num_str: String = chars[val_start..i].iter().collect();
856            val = num_str
857                .parse::<f64>()
858                .map(|n| serde_json::json!(n))
859                .unwrap_or(Value::String(num_str));
860        } else {
861            // Otherwise treat as a data path or boolean literal.
862            let val_start = i;
863            while i < chars.len() && chars[i] != ',' && chars[i] != ')' && !chars[i].is_whitespace()
864            {
865                i += 1;
866            }
867            let token: String = chars[val_start..i].iter().collect();
868            let token = token.trim();
869
870            // Check for boolean literals.
871            if token == "true" {
872                val = Value::Bool(true);
873            } else if token == "false" {
874                val = Value::Bool(false);
875            } else {
876                // Resolve as data path. If not found, use the raw token as a string.
877                val = context
878                    .get(token)
879                    .unwrap_or_else(|| Value::String(token.to_string()));
880            }
881        };
882
883        args.insert(key, val);
884    }
885
886    Ok(args)
887}
888
889// ===========================================================================
890// 13. Pluralize
891// ===========================================================================
892
893/// Resolve the correct plural form for a numeric value.
894pub struct PluralizeFunction;
895
896impl FunctionImplementation for PluralizeFunction {
897    fn name(&self) -> &'static str {
898        "pluralize"
899    }
900
901    fn return_type(&self) -> ReturnType {
902        ReturnType::String
903    }
904
905    fn execute(
906        &self,
907        args: &HashMap<String, Value>,
908        _context: &DataContext,
909    ) -> Result<Value, A2uiError> {
910        let val = args
911            .get("value")
912            .and_then(|v| v.as_f64())
913            .ok_or_else(|| {
914                A2uiError::InvalidFunctionCall(
915                    "pluralize: missing or non-numeric argument 'value'".into(),
916                )
917            })?;
918
919        // Determine the plural category using simple English rules.
920        let category = if val == 0.0 {
921            "zero"
922        } else if val == 1.0 {
923            "one"
924        } else {
925            "other"
926        };
927
928        // Try the specific category, then fall back to "other".
929        let result = args
930            .get(category)
931            .and_then(|v| v.as_str())
932            .or_else(|| args.get("other").and_then(|v| v.as_str()))
933            .unwrap_or("")
934            .to_string();
935
936        Ok(Value::String(result))
937    }
938}
939
940// ===========================================================================
941// 14. OpenUrl
942// ===========================================================================
943
944/// No-op side-effect function (cannot open URLs in a TUI environment).
945pub struct OpenUrlFunction;
946
947impl FunctionImplementation for OpenUrlFunction {
948    fn name(&self) -> &'static str {
949        "openUrl"
950    }
951
952    fn return_type(&self) -> ReturnType {
953        ReturnType::Void
954    }
955
956    fn execute(
957        &self,
958        _args: &HashMap<String, Value>,
959        _context: &DataContext,
960    ) -> Result<Value, A2uiError> {
961        // No-op in TUI environment.
962        Ok(Value::Null)
963    }
964}
965
966// ===========================================================================
967// Builder
968// ===========================================================================
969
970/// Construct a vector containing all built-in basic function implementations.
971pub fn build_basic_functions() -> Vec<Box<dyn FunctionImplementation>> {
972    vec![
973        Box::new(RequiredFunction),
974        Box::new(RegexFunction),
975        Box::new(LengthFunction),
976        Box::new(NumericFunction),
977        Box::new(EmailFunction),
978        Box::new(AndFunction),
979        Box::new(OrFunction),
980        Box::new(NotFunction),
981        Box::new(FormatNumberFunction),
982        Box::new(FormatCurrencyFunction),
983        Box::new(FormatDateFunction),
984        Box::new(FormatStringFunction),
985        Box::new(PluralizeFunction),
986        Box::new(OpenUrlFunction),
987    ]
988}
989
990// ===========================================================================
991// Tests
992// ===========================================================================
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997    use crate::model::data_model::DataModel;
998    use serde_json::json;
999
1000    /// Build a minimal DataContext backed by an empty DataModel and no functions.
1001    fn empty_context() -> DataContext<'static> {
1002        // Safety: we leak the DataModel and HashMap to obtain 'static references.
1003        // This is acceptable only within tests.
1004        let dm = Box::leak(Box::new(DataModel::new()));
1005        let fns = Box::leak(Box::new(HashMap::new()));
1006        DataContext::new(dm, fns)
1007    }
1008
1009    /// Build a DataContext backed by a DataModel containing the given JSON value.
1010    fn context_with_data(data: Value) -> DataContext<'static> {
1011        let dm = Box::leak(Box::new(DataModel::from_value(data)));
1012        let fns = Box::leak(Box::new(HashMap::new()));
1013        DataContext::new(dm, fns)
1014    }
1015
1016    /// Build a DataContext with basic functions registered (for formatString function call tests).
1017    fn context_with_functions(data: Value) -> DataContext<'static> {
1018        use crate::catalog::function_api::FunctionImplementation;
1019        let dm = Box::leak(Box::new(DataModel::from_value(data)));
1020        let fns_map: HashMap<String, Box<dyn FunctionImplementation>> = build_basic_functions()
1021            .into_iter()
1022            .map(|f| (f.name().to_string(), f))
1023            .collect();
1024        let fns = Box::leak(Box::new(fns_map));
1025        DataContext::new(dm, fns)
1026    }
1027
1028    // ---- required ----
1029
1030    #[test]
1031    fn test_required_string() {
1032        let ctx = empty_context();
1033        let f = RequiredFunction;
1034
1035        let mut args = HashMap::new();
1036        args.insert("value".into(), json!("hello"));
1037        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(true));
1038
1039        args.insert("value".into(), json!(""));
1040        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(false));
1041
1042        args.insert("value".into(), Value::Null);
1043        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(false));
1044    }
1045
1046    #[test]
1047    fn test_required_array() {
1048        let ctx = empty_context();
1049        let f = RequiredFunction;
1050
1051        let mut args = HashMap::new();
1052        args.insert("value".into(), json!([1, 2, 3]));
1053        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(true));
1054
1055        args.insert("value".into(), json!([]));
1056        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(false));
1057    }
1058
1059    // ---- regex ----
1060
1061    #[test]
1062    fn test_regex_match() {
1063        let ctx = empty_context();
1064        let f = RegexFunction;
1065
1066        let mut args = HashMap::new();
1067        args.insert("value".into(), json!("hello123"));
1068        args.insert("pattern".into(), json!("^[a-z]+[0-9]+$"));
1069        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(true));
1070
1071        args.insert("value".into(), json!("HELLO"));
1072        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(false));
1073    }
1074
1075    // ---- length ----
1076
1077    #[test]
1078    fn test_length_bounds() {
1079        let ctx = empty_context();
1080        let f = LengthFunction;
1081
1082        let mut args = HashMap::new();
1083        args.insert("value".into(), json!("abc"));
1084        args.insert("min".into(), json!(2));
1085        args.insert("max".into(), json!(5));
1086        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(true));
1087
1088        args.insert("value".into(), json!("a"));
1089        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(false));
1090
1091        args.insert("value".into(), json!("abcdef"));
1092        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(false));
1093    }
1094
1095    #[test]
1096    fn test_length_no_bounds() {
1097        let ctx = empty_context();
1098        let f = LengthFunction;
1099
1100        let mut args = HashMap::new();
1101        args.insert("value".into(), json!("anything"));
1102        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(true));
1103    }
1104
1105    // ---- numeric ----
1106
1107    #[test]
1108    fn test_numeric_valid() {
1109        let ctx = empty_context();
1110        let f = NumericFunction;
1111
1112        let mut args = HashMap::new();
1113        args.insert("value".into(), json!(42));
1114        args.insert("min".into(), json!(0));
1115        args.insert("max".into(), json!(100));
1116        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(true));
1117    }
1118
1119    #[test]
1120    fn test_numeric_string_value() {
1121        let ctx = empty_context();
1122        let f = NumericFunction;
1123
1124        let mut args = HashMap::new();
1125        args.insert("value".into(), json!("3.14"));
1126        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(true));
1127    }
1128
1129    #[test]
1130    fn test_numeric_invalid_string() {
1131        let ctx = empty_context();
1132        let f = NumericFunction;
1133
1134        let mut args = HashMap::new();
1135        args.insert("value".into(), json!("not a number"));
1136        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(false));
1137    }
1138
1139    #[test]
1140    fn test_numeric_out_of_range() {
1141        let ctx = empty_context();
1142        let f = NumericFunction;
1143
1144        let mut args = HashMap::new();
1145        args.insert("value".into(), json!(200));
1146        args.insert("max".into(), json!(100));
1147        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(false));
1148    }
1149
1150    // ---- email ----
1151
1152    #[test]
1153    fn test_email_valid() {
1154        let ctx = empty_context();
1155        let f = EmailFunction;
1156
1157        let mut args = HashMap::new();
1158        args.insert("value".into(), json!("user@example.com"));
1159        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(true));
1160    }
1161
1162    #[test]
1163    fn test_email_invalid() {
1164        let ctx = empty_context();
1165        let f = EmailFunction;
1166
1167        let mut args = HashMap::new();
1168        args.insert("value".into(), json!("not-an-email"));
1169        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(false));
1170
1171        args.insert("value".into(), json!("@missing-local.com"));
1172        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(false));
1173    }
1174
1175    // ---- and ----
1176
1177    #[test]
1178    fn test_and_all_true() {
1179        let ctx = empty_context();
1180        let f = AndFunction;
1181
1182        let mut args = HashMap::new();
1183        args.insert("values".into(), json!([true, true, true]));
1184        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(true));
1185    }
1186
1187    #[test]
1188    fn test_and_with_false() {
1189        let ctx = empty_context();
1190        let f = AndFunction;
1191
1192        let mut args = HashMap::new();
1193        args.insert("values".into(), json!([true, false, true]));
1194        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(false));
1195    }
1196
1197    // ---- or ----
1198
1199    #[test]
1200    fn test_or_any_true() {
1201        let ctx = empty_context();
1202        let f = OrFunction;
1203
1204        let mut args = HashMap::new();
1205        args.insert("values".into(), json!([false, true, false]));
1206        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(true));
1207    }
1208
1209    #[test]
1210    fn test_or_all_false() {
1211        let ctx = empty_context();
1212        let f = OrFunction;
1213
1214        let mut args = HashMap::new();
1215        args.insert("values".into(), json!([false, false, false]));
1216        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(false));
1217    }
1218
1219    // ---- not ----
1220
1221    #[test]
1222    fn test_not() {
1223        let ctx = empty_context();
1224        let f = NotFunction;
1225
1226        let mut args = HashMap::new();
1227        args.insert("value".into(), json!(true));
1228        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(false));
1229
1230        args.insert("value".into(), json!(false));
1231        assert_eq!(f.execute(&args, &ctx).unwrap(), json!(true));
1232    }
1233
1234    // ---- formatNumber ----
1235
1236    #[test]
1237    fn test_format_number_basic() {
1238        let ctx = empty_context();
1239        let f = FormatNumberFunction;
1240
1241        let mut args = HashMap::new();
1242        args.insert("value".into(), json!(1234567.89));
1243        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("1,234,567.89"));
1244    }
1245
1246    #[test]
1247    fn test_format_number_no_grouping() {
1248        let ctx = empty_context();
1249        let f = FormatNumberFunction;
1250
1251        let mut args = HashMap::new();
1252        args.insert("value".into(), json!(1234567));
1253        args.insert("grouping".into(), json!(false));
1254        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("1234567"));
1255    }
1256
1257    #[test]
1258    fn test_format_number_decimals() {
1259        let ctx = empty_context();
1260        let f = FormatNumberFunction;
1261
1262        let mut args = HashMap::new();
1263        args.insert("value".into(), json!(std::f64::consts::PI));
1264        args.insert("decimals".into(), json!(2));
1265        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("3.14"));
1266    }
1267
1268    #[test]
1269    fn test_format_number_negative() {
1270        let ctx = empty_context();
1271        let f = FormatNumberFunction;
1272
1273        let mut args = HashMap::new();
1274        args.insert("value".into(), json!(-1234.5));
1275        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("-1,234.5"));
1276    }
1277
1278    // ---- formatCurrency ----
1279
1280    #[test]
1281    fn test_format_currency() {
1282        let ctx = empty_context();
1283        let f = FormatCurrencyFunction;
1284
1285        let mut args = HashMap::new();
1286        args.insert("value".into(), json!(1234.56));
1287        args.insert("currency".into(), json!("USD"));
1288        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("USD 1,234.56"));
1289    }
1290
1291    // ---- formatDate ----
1292
1293    #[test]
1294    fn test_format_date_full() {
1295        let ctx = empty_context();
1296        let f = FormatDateFunction;
1297
1298        let mut args = HashMap::new();
1299        args.insert("value".into(), json!("2024-03-15T14:30:00"));
1300        args.insert("format".into(), json!("yyyy-MM-dd HH:mm:ss"));
1301        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("2024-03-15 14:30:00"));
1302    }
1303
1304    #[test]
1305    fn test_format_date_weekday() {
1306        let ctx = empty_context();
1307        let f = FormatDateFunction;
1308
1309        // 2024-03-15 is a Friday
1310        let mut args = HashMap::new();
1311        args.insert("value".into(), json!("2024-03-15T00:00:00"));
1312        args.insert("format".into(), json!("E yyyy-MM-dd"));
1313        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("Fri 2024-03-15"));
1314    }
1315
1316    #[test]
1317    fn test_format_date_month_name() {
1318        let ctx = empty_context();
1319        let f = FormatDateFunction;
1320
1321        let mut args = HashMap::new();
1322        args.insert("value".into(), json!("2024-12-25T10:00:00"));
1323        args.insert("format".into(), json!("MMM dd, yyyy"));
1324        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("Dec 25, 2024"));
1325    }
1326
1327    #[test]
1328    fn test_format_date_time_only() {
1329        let ctx = empty_context();
1330        let f = FormatDateFunction;
1331
1332        let mut args = HashMap::new();
1333        args.insert("value".into(), json!("2024-01-01T09:05:03"));
1334        args.insert("format".into(), json!("HH:mm:ss"));
1335        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("09:05:03"));
1336    }
1337
1338    // ---- formatDate 12-hour and AM/PM ----
1339
1340    #[test]
1341    fn test_format_date_12h_midnight() {
1342        // Midnight (00:00) should show 12 AM
1343        let ctx = empty_context();
1344        let f = FormatDateFunction;
1345
1346        let mut args = HashMap::new();
1347        args.insert("value".into(), json!("2024-01-01T00:00:00"));
1348        args.insert("format".into(), json!("h:mm a"));
1349        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("12:00 AM"));
1350    }
1351
1352    #[test]
1353    fn test_format_date_12h_noon() {
1354        // Noon (12:00) should show 12 PM
1355        let ctx = empty_context();
1356        let f = FormatDateFunction;
1357
1358        let mut args = HashMap::new();
1359        args.insert("value".into(), json!("2024-06-15T12:00:00"));
1360        args.insert("format".into(), json!("h:mm a"));
1361        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("12:00 PM"));
1362    }
1363
1364    #[test]
1365    fn test_format_date_12h_afternoon() {
1366        // 15:30 (3:30 PM)
1367        let ctx = empty_context();
1368        let f = FormatDateFunction;
1369
1370        let mut args = HashMap::new();
1371        args.insert("value".into(), json!("2024-03-15T15:30:00"));
1372        args.insert("format".into(), json!("h:mm a"));
1373        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("3:30 PM"));
1374    }
1375
1376    #[test]
1377    fn test_format_date_12h_morning() {
1378        // 09:05 (9:05 AM)
1379        let ctx = empty_context();
1380        let f = FormatDateFunction;
1381
1382        let mut args = HashMap::new();
1383        args.insert("value".into(), json!("2024-03-15T09:05:00"));
1384        args.insert("format".into(), json!("h:mm a"));
1385        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("9:05 AM"));
1386    }
1387
1388    #[test]
1389    fn test_format_date_hh_leading_zero() {
1390        // 09:00 should show 09 with 'hh'
1391        let ctx = empty_context();
1392        let f = FormatDateFunction;
1393
1394        let mut args = HashMap::new();
1395        args.insert("value".into(), json!("2024-03-15T09:00:00"));
1396        args.insert("format".into(), json!("hh:mm a"));
1397        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("09:00 AM"));
1398    }
1399
1400    #[test]
1401    fn test_format_date_hh_midnight_leading_zero() {
1402        // Midnight should show 12 with 'hh'
1403        let ctx = empty_context();
1404        let f = FormatDateFunction;
1405
1406        let mut args = HashMap::new();
1407        args.insert("value".into(), json!("2024-01-01T00:30:00"));
1408        args.insert("format".into(), json!("hh:mm a"));
1409        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("12:30 AM"));
1410    }
1411
1412    #[test]
1413    fn test_format_date_12h_full_format() {
1414        // Full format combining 12-hour with date
1415        let ctx = empty_context();
1416        let f = FormatDateFunction;
1417
1418        let mut args = HashMap::new();
1419        args.insert("value".into(), json!("2024-12-25T14:30:00"));
1420        args.insert("format".into(), json!("MMM dd, yyyy hh:mm a"));
1421        assert_eq!(
1422            f.execute(&args, &ctx).unwrap(),
1423            json!("Dec 25, 2024 02:30 PM")
1424        );
1425    }
1426
1427    // ---- formatString ----
1428
1429    #[test]
1430    fn test_format_string_data_path() {
1431        let ctx = context_with_data(json!({"user": {"name": "Alice"}}));
1432        let f = FormatStringFunction;
1433
1434        let mut args = HashMap::new();
1435        args.insert("value".into(), json!("Hello, ${/user/name}!"));
1436        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("Hello, Alice!"));
1437    }
1438
1439    #[test]
1440    fn test_format_string_escape() {
1441        let ctx = empty_context();
1442        let f = FormatStringFunction;
1443
1444        let mut args = HashMap::new();
1445        args.insert("value".into(), json!("escaped: \\${literal}"));
1446        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("escaped: ${literal}"));
1447    }
1448
1449    #[test]
1450    fn test_format_string_mixed() {
1451        let ctx = context_with_data(json!({"greeting": "Hello", "target": "World"}));
1452        let f = FormatStringFunction;
1453
1454        let mut args = HashMap::new();
1455        args.insert("value".into(), json!("${/greeting}, ${/target}!"));
1456        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("Hello, World!"));
1457    }
1458
1459    // ---- formatString with nested function calls ----
1460
1461    #[test]
1462    fn test_format_string_function_call_format_date() {
1463        // Call formatDate inside formatString with string literal args
1464        let ctx = context_with_functions(json!({"event": {"date": "2024-03-15T15:30:00"}}));
1465        let f = FormatStringFunction;
1466
1467        let mut args = HashMap::new();
1468        args.insert(
1469            "value".into(),
1470            json!("The event is at ${formatDate(value:${/event/date}, format:'h:mm a')}"),
1471        );
1472        assert_eq!(
1473            f.execute(&args, &ctx).unwrap(),
1474            json!("The event is at 3:30 PM")
1475        );
1476    }
1477
1478    #[test]
1479    fn test_format_string_function_call_format_number() {
1480        // Call formatNumber inside formatString
1481        let ctx = context_with_functions(json!({"price": 1234.5}));
1482        let f = FormatStringFunction;
1483
1484        let mut args = HashMap::new();
1485        args.insert(
1486            "value".into(),
1487            json!("Price: ${formatNumber(value:${/price}, grouping:false)}"),
1488        );
1489        assert_eq!(
1490            f.execute(&args, &ctx).unwrap(),
1491            json!("Price: 1234.5")
1492        );
1493    }
1494
1495    #[test]
1496    fn test_format_string_function_call_pluralize() {
1497        // Call pluralize inside formatString
1498        let ctx = context_with_functions(json!({"count": 5}));
1499        let f = FormatStringFunction;
1500
1501        let mut args = HashMap::new();
1502        args.insert(
1503            "value".into(),
1504            json!("${pluralize(value:${/count}, one:'item', other:'items')}"),
1505        );
1506        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("items"));
1507    }
1508
1509    #[test]
1510    fn test_format_string_unknown_function() {
1511        // Unknown function should resolve to empty string
1512        let ctx = context_with_functions(json!({}));
1513        let f = FormatStringFunction;
1514
1515        let mut args = HashMap::new();
1516        args.insert("value".into(), json!("result: ${unknownFunc()}"));
1517        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("result: "));
1518    }
1519
1520    // ---- pluralize ----
1521
1522    #[test]
1523    fn test_pluralize_one() {
1524        let ctx = empty_context();
1525        let f = PluralizeFunction;
1526
1527        let mut args = HashMap::new();
1528        args.insert("value".into(), json!(1));
1529        args.insert("one".into(), json!("item"));
1530        args.insert("other".into(), json!("items"));
1531        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("item"));
1532    }
1533
1534    #[test]
1535    fn test_pluralize_other() {
1536        let ctx = empty_context();
1537        let f = PluralizeFunction;
1538
1539        let mut args = HashMap::new();
1540        args.insert("value".into(), json!(5));
1541        args.insert("one".into(), json!("item"));
1542        args.insert("other".into(), json!("items"));
1543        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("items"));
1544    }
1545
1546    #[test]
1547    fn test_pluralize_zero() {
1548        let ctx = empty_context();
1549        let f = PluralizeFunction;
1550
1551        let mut args = HashMap::new();
1552        args.insert("value".into(), json!(0));
1553        args.insert("zero".into(), json!("no items"));
1554        args.insert("one".into(), json!("item"));
1555        args.insert("other".into(), json!("items"));
1556        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("no items"));
1557    }
1558
1559    #[test]
1560    fn test_pluralize_zero_fallback() {
1561        let ctx = empty_context();
1562        let f = PluralizeFunction;
1563
1564        let mut args = HashMap::new();
1565        args.insert("value".into(), json!(0));
1566        args.insert("one".into(), json!("item"));
1567        args.insert("other".into(), json!("items"));
1568        assert_eq!(f.execute(&args, &ctx).unwrap(), json!("items"));
1569    }
1570
1571    // ---- openUrl ----
1572
1573    #[test]
1574    fn test_open_url_noop() {
1575        let ctx = empty_context();
1576        let f = OpenUrlFunction;
1577
1578        let args = HashMap::new();
1579        assert_eq!(f.execute(&args, &ctx).unwrap(), Value::Null);
1580    }
1581
1582    // ---- builder ----
1583
1584    #[test]
1585    fn test_build_basic_functions_count() {
1586        let fns = build_basic_functions();
1587        assert_eq!(fns.len(), 14);
1588
1589        let names: Vec<&str> = fns.iter().map(|f| f.name()).collect();
1590        assert!(names.contains(&"required"));
1591        assert!(names.contains(&"regex"));
1592        assert!(names.contains(&"length"));
1593        assert!(names.contains(&"numeric"));
1594        assert!(names.contains(&"email"));
1595        assert!(names.contains(&"and"));
1596        assert!(names.contains(&"or"));
1597        assert!(names.contains(&"not"));
1598        assert!(names.contains(&"formatNumber"));
1599        assert!(names.contains(&"formatCurrency"));
1600        assert!(names.contains(&"formatDate"));
1601        assert!(names.contains(&"formatString"));
1602        assert!(names.contains(&"pluralize"));
1603        assert!(names.contains(&"openUrl"));
1604    }
1605}