Skip to main content

polyspec_crudui_validator/validate/
rules.rs

1//! CRUDUI validation rule registry (VALIDATION-RULES, JS `rules/*` parity).
2//!
3//! Each rule is a function over a [`RuleContext`] returning the error message
4//! (`Some`) or `None` (pass). The rules mirror the JS reference engine
5//! (`packages/validator/ts/src/rules/*`) function-for-function so the shared
6//! 4-language fixture (`tests/fixtures/validate/cases.json`) holds bit-for-bit:
7//! same skip-on-empty behaviour, same default messages with `{0}`/`{1}`
8//! substitution, same loose comparisons.
9//!
10//! The call site is the CRUDUI validate slot; the rule semantics are the JS rule
11//! registry re-expressed in Rust.
12
13use serde_json::{Map, Value};
14use std::collections::HashSet;
15
16use crate::expr::evaluator::{numbers_equal, relative_base};
17use crate::expr::Expression;
18
19use super::canonical::{canonical_text, number_text};
20use super::length::value_length;
21use super::membership::is_member;
22use super::numeric::{is_multiple, numeric_value};
23use super::parameters::Parameter;
24use super::references::reference_ids;
25use super::whitespace::{is_empty, trim};
26
27// Cache for precomputed duplicate row keys, keyed by (container_path:field_name:filter_param).
28// This enables O(n) precomputation and O(1) lookups for item-level unique checks.
29
30/// The per-rule invocation context handed to a rule function.
31pub struct RuleContext<'a> {
32    /// The field value being validated (scalar, array, or the whole array for an
33    /// array-level rule).
34    pub value: &'a Value,
35    /// The EFFECTIVE rule param (already resolved from any expression/condition
36    /// map). Never an expression for evaluated rules; verbatim for path/pattern/
37    /// literal rules.
38    pub rule_param: &'a Value,
39    /// The checked parameter of a rule whose parameter has a definition.
40    pub(crate) parameter: &'a Parameter,
41    /// The field's `messages` override map, if any.
42    pub messages: Option<&'a Value>,
43    /// The invoked rule name (so `pattern`/`match` aliasing resolves messages by
44    /// the actual key used).
45    pub rule_name: &'a str,
46    /// The absolute path segments of the current field (for relative path refs).
47    pub path_segments: &'a [String],
48    /// The positions of the row keys in `path_segments`.
49    pub row_keys: &'a [usize],
50    /// The full form data tree (for path-reference rules and `unique` siblings).
51    pub form_data: &'a Value,
52    /// The state of the validation the rule runs in.
53    pub(crate) run: &'a super::validator::Run<'a>,
54}
55
56/// Look up a registered rule by name. Returns `None` for any other name, which
57/// fails the load with `UNKNOWN_RULE`.
58pub fn get_rule(name: &str) -> Option<fn(&RuleContext) -> Option<String>> {
59    match name {
60        "required" => Some(rule_required),
61        "email" => Some(rule_email),
62        "minlength" => Some(rule_minlength),
63        "maxlength" => Some(rule_maxlength),
64        "min" => Some(rule_min),
65        "max" => Some(rule_max),
66        "match" => Some(rule_match),
67        // 'pattern' is an alias of 'match' (same implementation).
68        "pattern" => Some(rule_match),
69        "unique" => Some(rule_unique),
70        "in" => Some(rule_in),
71        "range" => Some(rule_range),
72        "rangelength" => Some(rule_rangelength),
73        "number" => Some(rule_number),
74        "digits" => Some(rule_digits),
75        "equalTo" => Some(rule_equal_to),
76        "containedIn" => Some(rule_contained_in),
77        "typeFrom" => Some(rule_type_from),
78        "minFrom" => Some(rule_min_from),
79        "maxFrom" => Some(rule_max_from),
80        "notEqual" => Some(rule_not_equal),
81        "date" => Some(rule_date),
82        "dateISO" => Some(rule_date_iso),
83        "enddate" => Some(rule_enddate),
84        "url" => Some(rule_url),
85        "accept" => Some(rule_accept),
86        "mincount" => Some(rule_mincount),
87        "maxcount" => Some(rule_maxcount),
88        "step" => Some(rule_step),
89        "conditionTree" => Some(rule_condition_tree),
90        "reference" => Some(rule_reference),
91        _ => None,
92    }
93}
94
95// ---------------------------------------------------------------------------
96// Shared helpers (JS rules/required.isEmpty, min.toNumber, etc.).
97// ---------------------------------------------------------------------------
98
99/// Message lookup for a rule: the field `messages[key]` override, else `None`.
100fn message_override<'a>(messages: Option<&'a Value>, key: &str) -> Option<&'a str> {
101    messages
102        .and_then(Value::as_object)
103        .and_then(|m| m.get(key))
104        .and_then(Value::as_str)
105}
106
107/// A message with every `{0}` and `{1}` replaced by the parameters.
108fn fill(message: &str, first: &str, second: Option<&str>) -> String {
109    let message = message.replace("{0}", first);
110    match second {
111        Some(second) => message.replace("{1}", second),
112        None => message,
113    }
114}
115
116/// JS `String(value)` (rule-side stringification for field references).
117fn js_string(value: &Value) -> String {
118    match value {
119        Value::Null => "null".to_string(),
120        Value::String(s) => s.clone(),
121        Value::Bool(b) => b.to_string(),
122        Value::Number(n) => n.as_f64().map(number_text).unwrap_or_default(),
123        // Arrays/objects stringify per JS, but rule call sites never reach here
124        // with one for the relevant rules; keep a stable fallback.
125        Value::Array(_) | Value::Object(_) => value.to_string(),
126    }
127}
128
129// ---------------------------------------------------------------------------
130// required
131// ---------------------------------------------------------------------------
132
133fn rule_required(ctx: &RuleContext) -> Option<String> {
134    // ruleParam must be exactly true (the resolver disables on false/null).
135    if ctx.rule_param != &Value::Bool(true) {
136        return None;
137    }
138    if is_empty(ctx.value) {
139        return Some(
140            message_override(ctx.messages, "required")
141                .unwrap_or("This field is required.")
142                .to_string(),
143        );
144    }
145    None
146}
147
148// ---------------------------------------------------------------------------
149// email
150// ---------------------------------------------------------------------------
151
152fn rule_email(ctx: &RuleContext) -> Option<String> {
153    if ctx.rule_param != &Value::Bool(true) {
154        return None;
155    }
156    if is_empty(ctx.value) {
157        return None;
158    }
159    if !is_valid_email(ctx.value) {
160        return Some(
161            message_override(ctx.messages, "email")
162                .unwrap_or("Please enter a valid email address.")
163                .to_string(),
164        );
165    }
166    None
167}
168
169fn is_valid_email(value: &Value) -> bool {
170    let s = match value {
171        Value::String(s) => s,
172        _ => return false,
173    };
174    let at = match s.find('@') {
175        Some(i) => i,
176        None => return false,
177    };
178    let local = &s[..at];
179    let domain = &s[at + 1..];
180
181    if !is_valid_email_local(local) || !is_valid_email_domain(domain) {
182        return false;
183    }
184    // Local-part dot rules (JS email.ts extra checks).
185    if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
186        return false;
187    }
188    true
189}
190
191/// Local part: `[A-Za-z0-9.!#$%&'*+/=?^_\`{|}~-]+`.
192fn is_valid_email_local(local: &str) -> bool {
193    if local.is_empty() {
194        return false;
195    }
196    local.chars().all(|c| {
197        c.is_ascii_alphanumeric()
198            || matches!(
199                c,
200                '.' | '!'
201                    | '#'
202                    | '$'
203                    | '%'
204                    | '&'
205                    | '\''
206                    | '*'
207                    | '+'
208                    | '/'
209                    | '='
210                    | '?'
211                    | '^'
212                    | '_'
213                    | '`'
214                    | '{'
215                    | '|'
216                    | '}'
217                    | '~'
218                    | '-'
219            )
220    })
221}
222
223/// Domain: one or more dot-separated labels, each
224/// `[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?`.
225fn is_valid_email_domain(domain: &str) -> bool {
226    if domain.is_empty() {
227        return false;
228    }
229    for label in domain.split('.') {
230        if !is_valid_email_label(label) {
231            return false;
232        }
233    }
234    true
235}
236
237fn is_valid_email_label(label: &str) -> bool {
238    let bytes = label.as_bytes();
239    let n = bytes.len();
240    if n == 0 || n > 63 {
241        return false;
242    }
243    let is_alnum = |b: u8| b.is_ascii_alphanumeric();
244    if !is_alnum(bytes[0]) || !is_alnum(bytes[n - 1]) {
245        return false;
246    }
247    bytes
248        .get(1..n.saturating_sub(1))
249        .is_none_or(|middle| middle.iter().all(|&b| is_alnum(b) || b == b'-'))
250}
251
252// ---------------------------------------------------------------------------
253// url
254// ---------------------------------------------------------------------------
255
256fn rule_url(ctx: &RuleContext) -> Option<String> {
257    if ctx.rule_param == &Value::Bool(false) {
258        return None;
259    }
260    if is_empty(ctx.value) {
261        return None;
262    }
263    if !is_valid_url(ctx.value) {
264        return Some(
265            message_override(ctx.messages, "url")
266                .unwrap_or("Please enter a valid URL.")
267                .to_string(),
268        );
269    }
270    None
271}
272
273/// JS uses the `URL` constructor first (accepts http/https/ftp protocols), with a
274/// regex fallback. We emulate the URL-constructor acceptance: a `scheme://rest`
275/// where scheme is http/https/ftp and `rest` is non-empty with no whitespace.
276fn is_valid_url(value: &Value) -> bool {
277    let s = match value {
278        Value::String(s) => trim(s),
279        _ => return false,
280    };
281    if s.is_empty() {
282        return false;
283    }
284    let scheme_end = match s.find("://") {
285        Some(i) => i,
286        None => return false,
287    };
288    let scheme = s[..scheme_end].to_ascii_lowercase();
289    if !matches!(scheme.as_str(), "http" | "https" | "ftp") {
290        return false;
291    }
292    let rest = &s[scheme_end + 3..];
293    if rest.is_empty() {
294        return false;
295    }
296    if rest.chars().any(|c| c.is_whitespace()) {
297        return false;
298    }
299    true
300}
301
302// ---------------------------------------------------------------------------
303// minlength / maxlength / rangelength
304// ---------------------------------------------------------------------------
305
306fn rule_minlength(ctx: &RuleContext) -> Option<String> {
307    let Parameter::Limit(min) = *ctx.parameter else {
308        unreachable!("minlength receives a checked limit")
309    };
310    if is_empty(ctx.value) {
311        return None;
312    }
313    if value_length(ctx.value).is_some_and(|length| length >= min) {
314        return None;
315    }
316    let msg = message_override(ctx.messages, "minlength")
317        .map(str::to_string)
318        .unwrap_or_else(|| format!("Please enter at least {min} characters."));
319    Some(fill(&msg, &min.to_string(), None))
320}
321
322fn rule_maxlength(ctx: &RuleContext) -> Option<String> {
323    let Parameter::Limit(max) = *ctx.parameter else {
324        unreachable!("maxlength receives a checked limit")
325    };
326    if is_empty(ctx.value) {
327        return None;
328    }
329    if value_length(ctx.value).is_some_and(|length| length <= max) {
330        return None;
331    }
332    let msg = message_override(ctx.messages, "maxlength")
333        .map(str::to_string)
334        .unwrap_or_else(|| format!("Please enter no more than {max} characters."));
335    Some(fill(&msg, &max.to_string(), None))
336}
337
338fn rule_rangelength(ctx: &RuleContext) -> Option<String> {
339    let Parameter::Range(min, max) = *ctx.parameter else {
340        unreachable!("rangelength receives checked limits")
341    };
342    if is_empty(ctx.value) {
343        return None;
344    }
345    if value_length(ctx.value).is_some_and(|length| (min..=max).contains(&length)) {
346        return None;
347    }
348    let msg = message_override(ctx.messages, "rangelength")
349        .map(str::to_string)
350        .unwrap_or_else(|| format!("Please enter a value between {min} and {max} characters."));
351    Some(fill(&msg, &min.to_string(), Some(&max.to_string())))
352}
353
354// ---------------------------------------------------------------------------
355// number / digits
356// ---------------------------------------------------------------------------
357
358fn rule_number(ctx: &RuleContext) -> Option<String> {
359    if ctx.rule_param != &Value::Bool(true) || is_empty(ctx.value) {
360        return None;
361    }
362    if numeric_value(ctx.value).is_some() {
363        return None;
364    }
365    Some(
366        message_override(ctx.messages, "number")
367            .unwrap_or("Please enter a valid number.")
368            .to_string(),
369    )
370}
371
372fn rule_digits(ctx: &RuleContext) -> Option<String> {
373    if ctx.rule_param != &Value::Bool(true) || is_empty(ctx.value) {
374        return None;
375    }
376    if is_digits_only(ctx.value) {
377        return None;
378    }
379    Some(
380        message_override(ctx.messages, "digits")
381            .unwrap_or("Please enter only digits.")
382            .to_string(),
383    )
384}
385
386/// `conditionTree`: the value is a condition tree; the parameter `judge` permits
387/// judged text (docs/spec/validation-rules.md, Condition trees). Empty skips.
388fn rule_condition_tree(ctx: &RuleContext) -> Option<String> {
389    if !super::condition_tree::is_parameter(ctx.rule_param) || is_empty(ctx.value) {
390        return None;
391    }
392    let judge = ctx.rule_param.as_str() == Some("judge");
393    if super::condition_tree::is_condition(ctx.value, judge, 1) {
394        return None;
395    }
396    Some(
397        message_override(ctx.messages, "conditionTree")
398            .unwrap_or("Please enter a valid condition.")
399            .to_string(),
400    )
401}
402
403/// A string (trimmed) or a number whose canonical text is ASCII digits only.
404fn is_digits_only(value: &Value) -> bool {
405    let text = match value {
406        Value::String(text) => trim(text).to_string(),
407        Value::Number(_) => match canonical_text(value) {
408            Some(text) => text.into_owned(),
409            None => return false,
410        },
411        _ => return false,
412    };
413    !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit())
414}
415
416// ---------------------------------------------------------------------------
417// min / max / range / step
418// ---------------------------------------------------------------------------
419
420fn rule_min(ctx: &RuleContext) -> Option<String> {
421    let Parameter::Bound(min) = *ctx.parameter else {
422        unreachable!("min receives a checked bound")
423    };
424    if is_empty(ctx.value) || numeric_value(ctx.value).is_some_and(|number| number >= min) {
425        return None;
426    }
427    let msg = message_override(ctx.messages, "min")
428        .unwrap_or("Please enter a value greater than or equal to {0}.");
429    Some(fill(msg, &number_text(min), None))
430}
431
432fn rule_max(ctx: &RuleContext) -> Option<String> {
433    let Parameter::Bound(max) = *ctx.parameter else {
434        unreachable!("max receives a checked bound")
435    };
436    if is_empty(ctx.value) || numeric_value(ctx.value).is_some_and(|number| number <= max) {
437        return None;
438    }
439    let msg = message_override(ctx.messages, "max")
440        .unwrap_or("Please enter a value less than or equal to {0}.");
441    Some(fill(msg, &number_text(max), None))
442}
443
444fn rule_range(ctx: &RuleContext) -> Option<String> {
445    let Parameter::Bounds(min, max) = *ctx.parameter else {
446        unreachable!("range receives checked bounds")
447    };
448    if is_empty(ctx.value)
449        || numeric_value(ctx.value).is_some_and(|number| min <= number && number <= max)
450    {
451        return None;
452    }
453    let msg = message_override(ctx.messages, "range")
454        .unwrap_or("Please enter a value between {0} and {1}.");
455    Some(fill(msg, &number_text(min), Some(&number_text(max))))
456}
457
458fn rule_step(ctx: &RuleContext) -> Option<String> {
459    let Parameter::Step(step) = *ctx.parameter else {
460        unreachable!("step receives a checked step")
461    };
462    if is_empty(ctx.value)
463        || numeric_value(ctx.value).is_some_and(|number| is_multiple(number, step))
464    {
465        return None;
466    }
467    let msg = message_override(ctx.messages, "step")
468        .unwrap_or("Please enter a value that is a multiple of {0}.");
469    Some(fill(msg, &number_text(step), None))
470}
471
472// ---------------------------------------------------------------------------
473// match / pattern
474// ---------------------------------------------------------------------------
475
476fn rule_match(ctx: &RuleContext) -> Option<String> {
477    let Parameter::Pattern(program) = ctx.parameter else {
478        unreachable!("pattern and match receive a compiled pattern")
479    };
480    if is_empty(ctx.value) {
481        return None;
482    }
483    // The whole canonical text must match; an array or object has none.
484    if canonical_text(ctx.value).is_some_and(|text| program.is_match(&text)) {
485        return None;
486    }
487    // The custom message is found under the declared rule name only.
488    let msg =
489        message_override(ctx.messages, ctx.rule_name).unwrap_or("Please enter a valid format.");
490    Some(msg.to_string())
491}
492
493// ---------------------------------------------------------------------------
494// in
495// ---------------------------------------------------------------------------
496
497fn rule_in(ctx: &RuleContext) -> Option<String> {
498    let Parameter::Members(members) = ctx.parameter else {
499        unreachable!("in receives checked members")
500    };
501    if is_empty(ctx.value) || is_member(ctx.value, members) {
502        return None;
503    }
504    Some(
505        message_override(ctx.messages, "in")
506            .unwrap_or("Please select a valid option.")
507            .to_string(),
508    )
509}
510
511// ---------------------------------------------------------------------------
512// reference (validation rules, "References")
513// ---------------------------------------------------------------------------
514
515/// Fail a value that is not a member of the ids of the declaration path its parameter names; the
516/// ids of a path are collected once per validation. Empty values pass.
517fn rule_reference(ctx: &RuleContext) -> Option<String> {
518    let Value::String(path) = ctx.rule_param else {
519        unreachable!("reference receives a checked declaration path")
520    };
521    if is_empty(ctx.value) {
522        return None;
523    }
524    let mut cache = ctx.run.reference_ids.borrow_mut();
525    let ids = cache
526        .entry(path.clone())
527        .or_insert_with(|| reference_ids(ctx.run.properties, ctx.form_data, path));
528    if is_member(ctx.value, ids) {
529        return None;
530    }
531    Some(
532        message_override(ctx.messages, "reference")
533            .unwrap_or("Please select an existing value.")
534            .to_string(),
535    )
536}
537
538// ---------------------------------------------------------------------------
539// equalTo / notEqual
540// ---------------------------------------------------------------------------
541
542/// The value of the field that the reference parameter of a rule names, or `None` when the rule does not apply:
543/// no parameter, or an empty value.
544fn referenced_value<'a>(ctx: &RuleContext<'a>) -> Option<&'a Value> {
545    if ctx.rule_param.is_null() || is_empty(ctx.value) {
546        return None;
547    }
548    let param = match ctx.rule_param {
549        Value::String(s) => s.clone(),
550        other => js_string(other),
551    };
552    Some(resolve_field_reference(&param, ctx.path_segments, ctx.row_keys, ctx.form_data))
553}
554
555fn rule_equal_to(ctx: &RuleContext) -> Option<String> {
556    let target = referenced_value(ctx)?;
557    if !strict_eq(ctx.value, target) {
558        return Some(
559            message_override(ctx.messages, "equalTo")
560                .unwrap_or("Please enter the same value again.")
561                .to_string(),
562        );
563    }
564    None
565}
566
567fn rule_not_equal(ctx: &RuleContext) -> Option<String> {
568    if ctx.rule_param.is_null() {
569        return None;
570    }
571    if is_empty(ctx.value) {
572        return None;
573    }
574    let compare = match ctx.rule_param {
575        Value::String(s) if s.starts_with('.') => {
576            resolve_field_reference(s, ctx.path_segments, ctx.row_keys, ctx.form_data)
577        }
578        other => other,
579    };
580    if strict_eq(ctx.value, compare) {
581        return Some(
582            message_override(ctx.messages, "notEqual")
583                .unwrap_or("Please enter a different value.")
584                .to_string(),
585        );
586    }
587    None
588}
589
590/// containedIn: a string value occurs in the string value of the referenced field; a value or a
591/// referenced value that is not a string fails.
592fn rule_contained_in(ctx: &RuleContext) -> Option<String> {
593    let target = referenced_value(ctx)?;
594    if let (Value::String(text), Value::String(within)) = (ctx.value, target) {
595        if within.contains(text.as_str()) {
596            return None;
597        }
598    }
599    Some(
600        message_override(ctx.messages, "containedIn")
601            .unwrap_or("Please enter a value that the referenced field contains.")
602            .to_string(),
603    )
604}
605
606/// typeFrom: the value has the type that the value of the referenced field names: bool a boolean, int a finite
607/// number without a fractional part, enum a string; a missing or other type fails.
608fn rule_type_from(ctx: &RuleContext) -> Option<String> {
609    let declared = referenced_value(ctx)?;
610    let typed = match (declared, ctx.value) {
611        (Value::String(name), Value::Bool(_)) => name == "bool",
612        (Value::String(name), Value::Number(number)) => {
613            name == "int" && number.as_f64().is_some_and(|n| n.is_finite() && n.fract() == 0.0)
614        }
615        (Value::String(name), Value::String(_)) => name == "enum",
616        _ => false,
617    };
618    if typed {
619        return None;
620    }
621    Some(
622        message_override(ctx.messages, "typeFrom")
623            .unwrap_or("Please enter a value of the declared type.")
624            .to_string(),
625    )
626}
627
628/// minFrom: a finite number at or above the value of the referenced field; a missing or null bound declares none.
629fn rule_min_from(ctx: &RuleContext) -> Option<String> {
630    bound_from(ctx, "minFrom", "Please enter a value at or above the declared minimum.", |value, bound| value >= bound)
631}
632
633/// maxFrom: a finite number at or below the value of the referenced field; a missing or null bound declares none.
634fn rule_max_from(ctx: &RuleContext) -> Option<String> {
635    bound_from(ctx, "maxFrom", "Please enter a value at or below the declared maximum.", |value, bound| value <= bound)
636}
637
638/// The finite number of a JSON value; a boolean is none.
639fn finite_bound(value: &Value) -> Option<f64> {
640    match value {
641        Value::Number(number) => number.as_f64().filter(|n| n.is_finite()),
642        _ => None,
643    }
644}
645
646fn bound_from(ctx: &RuleContext, rule: &str, fallback: &str, within: fn(f64, f64) -> bool) -> Option<String> {
647    let bound = referenced_value(ctx)?;
648    let number = finite_bound(ctx.value);
649    let holds = match (number, bound) {
650        (Some(_), Value::Null) => true,
651        (Some(value), other) => finite_bound(other).is_some_and(|limit| within(value, limit)),
652        (None, _) => false,
653    };
654    if holds {
655        return None;
656    }
657    Some(message_override(ctx.messages, rule).unwrap_or(fallback).to_string())
658}
659
660/// JS `===` for the equalTo/notEqual comparison. Different JSON types never equal;
661/// numbers compare by f64; strings/bools/null by value; arrays/objects structural.
662fn strict_eq(a: &Value, b: &Value) -> bool {
663    match (a, b) {
664        (Value::Number(x), Value::Number(y)) => numbers_equal(x, y),
665        _ => {
666            // Same-type structural equality; cross-type is false (handled by ==
667            // returning false for differing variants).
668            a == b
669        }
670    }
671}
672
673// ---------------------------------------------------------------------------
674// date / dateISO / enddate
675// ---------------------------------------------------------------------------
676
677fn rule_date(ctx: &RuleContext) -> Option<String> {
678    if ctx.rule_param == &Value::Bool(false) {
679        return None;
680    }
681    if is_empty(ctx.value) {
682        return None;
683    }
684    if !is_valid_date(ctx.value) {
685        return Some(
686            message_override(ctx.messages, "date")
687                .unwrap_or("Please enter a valid date.")
688                .to_string(),
689        );
690    }
691    None
692}
693
694/// The date rule of validation-rules.md: a string, trimmed, in one of the explicit layouts of `is_explicit_date`, each with a
695/// calendar date that exists. A number is not a date, and neither is text in no listed layout. Empty already filtered.
696fn is_valid_date(value: &Value) -> bool {
697    match value {
698        Value::String(s) => is_explicit_date(trim(s)),
699        _ => false,
700    }
701}
702
703/// Whether `s` is `YYYY-MM-DD`, `YYYY/MM/DD`, `MM/DD/YYYY` or `DD/MM/YYYY` with a calendar date that exists, or
704/// `YYYY-MM-DDTHH:MM:SS` followed by `Z` or `+HH:MM`/`-HH:MM` with that date.
705fn is_explicit_date(s: &str) -> bool {
706    match s.split_once('T') {
707        None => is_calendar_date(s),
708        Some((date, time)) => is_iso_calendar(date) && is_rfc3339_time(time),
709    }
710}
711
712/// A calendar date in one of the explicit layouts without a time.
713fn is_calendar_date(s: &str) -> bool {
714    if is_iso_calendar(s) {
715        return true;
716    }
717    let parts: Vec<&str> = s.split('/').collect();
718    let digits = |part: &str| part.bytes().all(|b| b.is_ascii_digit());
719    match parts.as_slice() {
720        [y, m, d] if y.len() == 4 && m.len() == 2 && d.len() == 2 && [y, m, d].iter().all(|p| digits(p)) => {
721            let (year, month, day) = (parse_part(y), parse_part(m), parse_part(d));
722            valid_ymd(year, month, day)
723        }
724        [a, b, y] if a.len() == 2 && b.len() == 2 && y.len() == 4 && [a, b, y].iter().all(|p| digits(p)) => {
725            let year = parse_part(y);
726            // MM/DD/YYYY and DD/MM/YYYY are both accepted layouts.
727            valid_ymd(year, parse_part(a), parse_part(b)) || valid_ymd(year, parse_part(b), parse_part(a))
728        }
729        _ => false,
730    }
731}
732
733/// `YYYY-MM-DD` with a calendar date that exists.
734fn is_iso_calendar(s: &str) -> bool {
735    is_valid_date_iso(&Value::String(s.to_string()))
736}
737
738/// The digits of a part that `is_calendar_date` has checked to be ASCII digits.
739fn parse_part(part: &str) -> i64 {
740    part.parse().expect("the caller checked the part is ASCII digits")
741}
742
743fn valid_ymd(year: i64, month: i64, day: i64) -> bool {
744    (1..=12).contains(&month) && day >= 1 && day <= days_in_month(year, month)
745}
746
747/// `HH:MM:SS` followed by `Z` or a `+HH:MM` / `-HH:MM` offset, as the RFC 3339 layout requires.
748fn is_rfc3339_time(time: &str) -> bool {
749    let bytes = time.as_bytes();
750    if bytes.len() < 9 {
751        return false;
752    }
753    let clock = &bytes[..8];
754    let shape = clock[2] == b':' && clock[5] == b':' && [0, 1, 3, 4, 6, 7].iter().all(|&i| clock[i].is_ascii_digit());
755    if !shape {
756        return false;
757    }
758    let (hour, minute, second) = (parse_bytes(&clock[0..2]), parse_bytes(&clock[3..5]), parse_bytes(&clock[6..8]));
759    if hour > 23 || minute > 59 || second > 59 {
760        return false;
761    }
762    match &bytes[8..] {
763        b"Z" => true,
764        [sign, rest @ ..] if (*sign == b'+' || *sign == b'-') && rest.len() == 5 && rest[2] == b':' => {
765            let digit = |i: usize| rest[i].is_ascii_digit();
766            digit(0) && digit(1) && digit(3) && digit(4) && parse_bytes(&rest[0..2]) <= 23 && parse_bytes(&rest[3..5]) <= 59
767        }
768        _ => false,
769    }
770}
771
772/// The value of ASCII digits; the callers check the digits first.
773fn parse_bytes(digits: &[u8]) -> i64 {
774    digits.iter().fold(0, |value, &b| value * 10 + i64::from(b - b'0'))
775}
776
777/// Parse a date string to (year, month, day) ordinal for comparison, returning
778/// `None` when unparseable. Mirrors the JS `parseDate`/`Date` acceptance for the
779/// formats reachable from the fixtures (ISO `YYYY-MM-DD`, `YYYY/MM/DD`); other
780/// non-empty strings are accepted as a date (JS `new Date` is permissive) but get
781/// a deterministic ordinal of 0 so `enddate` comparison stays stable.
782fn parse_date(s: &str) -> Option<i64> {
783    if s.is_empty() {
784        return None;
785    }
786    // YYYY-MM-DD or YYYY/MM/DD.
787    let sep = if s.contains('-') {
788        '-'
789    } else if s.contains('/') {
790        '/'
791    } else {
792        // Non-empty: JS Date may still parse; accept with neutral ordinal.
793        return Some(0);
794    };
795    let parts: Vec<&str> = s.split(sep).collect();
796    if parts.len() == 3 {
797        if let (Ok(y), Ok(m), Ok(d)) = (
798            parts[0].parse::<i64>(),
799            parts[1].parse::<i64>(),
800            parts[2].parse::<i64>(),
801        ) {
802            if (1..=12).contains(&m) && (1..=31).contains(&d) {
803                return Some(y * 10000 + m * 100 + d);
804            }
805        }
806    }
807    // Accept other non-empty strings (JS Date permissive).
808    Some(0)
809}
810
811fn rule_date_iso(ctx: &RuleContext) -> Option<String> {
812    if ctx.rule_param == &Value::Bool(false) {
813        return None;
814    }
815    if is_empty(ctx.value) {
816        return None;
817    }
818    if !is_valid_date_iso(ctx.value) {
819        return Some(
820            message_override(ctx.messages, "dateISO")
821                .unwrap_or("Please enter a valid date in ISO format (YYYY-MM-DD).")
822                .to_string(),
823        );
824    }
825    None
826}
827
828fn is_valid_date_iso(value: &Value) -> bool {
829    let s = match value {
830        Value::String(s) => trim(s),
831        _ => return false,
832    };
833    let bytes = s.as_bytes();
834    if bytes.len() != 10 {
835        return false;
836    }
837    // YYYY-MM-DD pattern.
838    let ok_shape = bytes[..4].iter().all(|b| b.is_ascii_digit())
839        && bytes[4] == b'-'
840        && bytes[5].is_ascii_digit()
841        && bytes[6].is_ascii_digit()
842        && bytes[7] == b'-'
843        && bytes[8].is_ascii_digit()
844        && bytes[9].is_ascii_digit();
845    if !ok_shape {
846        return false;
847    }
848    // The shape check above admits only ASCII digits in these ranges.
849    let year: i64 = s[0..4].parse().expect("four ASCII digits parse as i64");
850    let month: i64 = s[5..7].parse().expect("two ASCII digits parse as i64");
851    let day: i64 = s[8..10].parse().expect("two ASCII digits parse as i64");
852    if !(1..=12).contains(&month) {
853        return false;
854    }
855    let dim = days_in_month(year, month);
856    day >= 1 && day <= dim
857}
858
859fn days_in_month(year: i64, month: i64) -> i64 {
860    match month {
861        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
862        4 | 6 | 9 | 11 => 30,
863        2 => {
864            let leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
865            if leap {
866                29
867            } else {
868                28
869            }
870        }
871        _ => 0,
872    }
873}
874
875fn rule_enddate(ctx: &RuleContext) -> Option<String> {
876    if ctx.rule_param.is_null() {
877        return None;
878    }
879    if is_empty(ctx.value) {
880        return None;
881    }
882    let end = match ctx.value {
883        Value::String(s) => parse_date(trim(s))?,
884        Value::Number(_) => 0,
885        _ => return None,
886    };
887    let param = js_string(ctx.rule_param);
888    let start_value = if param.starts_with('.') {
889        // Relative path resolution (JS enddate relative handling).
890        resolve_field_reference(&param, ctx.path_segments, ctx.row_keys, ctx.form_data)
891    } else {
892        // Absolute dotted path from root.
893        let segments: Vec<String> = param
894            .split('.')
895            .filter(|s| !s.is_empty())
896            .map(String::from)
897            .collect();
898        get_value_by_path(ctx.form_data, &segments)
899    };
900    if is_empty(start_value) {
901        return None;
902    }
903    let start = match start_value {
904        Value::String(s) => parse_date(trim(s))?,
905        Value::Number(_) => 0,
906        _ => return None,
907    };
908    if end < start {
909        return Some(
910            message_override(ctx.messages, "enddate")
911                .unwrap_or("End date must be after the start date.")
912                .to_string(),
913        );
914    }
915    None
916}
917
918// ---------------------------------------------------------------------------
919// accept
920// ---------------------------------------------------------------------------
921
922/// Whether a file object, by its MIME type (`type`, `mimeType`) or its name (`name`,
923/// `filename`), matches the accepted types.
924fn file_accepted(file: &Map<String, Value>, accept_list: &[String]) -> bool {
925    let mime = file
926        .get("type")
927        .or_else(|| file.get("mimeType"))
928        .and_then(Value::as_str)
929        .unwrap_or("");
930    let name = file
931        .get("name")
932        .or_else(|| file.get("filename"))
933        .and_then(Value::as_str)
934        .unwrap_or("");
935    (!mime.is_empty() && matches_mime_type(mime, accept_list))
936        || (!name.is_empty() && matches_extension(name, accept_list))
937}
938
939fn rule_accept(ctx: &RuleContext) -> Option<String> {
940    if ctx.rule_param.is_null() || ctx.rule_param == &Value::Bool(false) {
941        return None;
942    }
943    if is_empty(ctx.value) {
944        return None;
945    }
946    let accept_list = parse_accept_param(ctx.rule_param);
947    if accept_list.is_empty() {
948        return None;
949    }
950
951    let fail = || {
952        Some(
953            message_override(ctx.messages, "accept")
954                .unwrap_or("Please upload a file with a valid format.")
955                .to_string(),
956        )
957    };
958
959    match ctx.value {
960        Value::String(s) => {
961            if s.contains('/') {
962                if !matches_mime_type(s, &accept_list) {
963                    return fail();
964                }
965            } else if !matches_extension(s, &accept_list) {
966                return fail();
967            }
968            None
969        }
970        Value::Array(items) => {
971            for file in items {
972                if let Value::Object(obj) = file {
973                    if !file_accepted(obj, &accept_list) {
974                        return fail();
975                    }
976                }
977            }
978            None
979        }
980        Value::Object(obj) => {
981            if !file_accepted(obj, &accept_list) {
982                return fail();
983            }
984            None
985        }
986        _ => None,
987    }
988}
989
990fn extension_to_mime(ext: &str) -> Option<&'static [&'static str]> {
991    Some(match ext {
992        "jpg" | "jpeg" => &["image/jpeg"],
993        "png" => &["image/png"],
994        "gif" => &["image/gif"],
995        "webp" => &["image/webp"],
996        "svg" => &["image/svg+xml"],
997        "bmp" => &["image/bmp"],
998        "ico" => &["image/x-icon", "image/vnd.microsoft.icon"],
999        "pdf" => &["application/pdf"],
1000        "doc" => &["application/msword"],
1001        "docx" => &["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
1002        "xls" => &["application/vnd.ms-excel"],
1003        "xlsx" => &["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
1004        "ppt" => &["application/vnd.ms-powerpoint"],
1005        "pptx" => &["application/vnd.openxmlformats-officedocument.presentationml.presentation"],
1006        "txt" => &["text/plain"],
1007        "csv" => &["text/csv", "application/csv"],
1008        "mp3" => &["audio/mpeg", "audio/mp3"],
1009        "wav" => &["audio/wav", "audio/x-wav"],
1010        "ogg" => &["audio/ogg"],
1011        "flac" => &["audio/flac"],
1012        "mp4" => &["video/mp4"],
1013        "webm" => &["video/webm"],
1014        "avi" => &["video/x-msvideo"],
1015        "mov" => &["video/quicktime"],
1016        "mkv" => &["video/x-matroska"],
1017        "zip" => &["application/zip", "application/x-zip-compressed"],
1018        "rar" => &["application/x-rar-compressed", "application/vnd.rar"],
1019        "tar" => &["application/x-tar"],
1020        "gz" => &["application/gzip"],
1021        "7z" => &["application/x-7z-compressed"],
1022        "json" => &["application/json"],
1023        "xml" => &["application/xml", "text/xml"],
1024        "html" => &["text/html"],
1025        "css" => &["text/css"],
1026        "js" => &["application/javascript", "text/javascript"],
1027        _ => return None,
1028    })
1029}
1030
1031fn parse_accept_param(param: &Value) -> Vec<String> {
1032    let mut out = Vec::new();
1033    match param {
1034        Value::String(s) => {
1035            for raw in s.split(',') {
1036                let part = trim(raw).to_ascii_lowercase();
1037                if let Some(stripped) = part.strip_prefix('.') {
1038                    if let Some(mimes) = extension_to_mime(stripped) {
1039                        out.extend(mimes.iter().map(|m| m.to_string()));
1040                    } else {
1041                        out.push(part);
1042                    }
1043                } else if part.contains('/') {
1044                    out.push(part);
1045                }
1046            }
1047        }
1048        Value::Array(items) => {
1049            for item in items {
1050                out.extend(parse_accept_param(item));
1051            }
1052        }
1053        _ => {}
1054    }
1055    out
1056}
1057
1058fn matches_mime_type(mime: &str, accept_list: &[String]) -> bool {
1059    let normalized = mime.to_ascii_lowercase();
1060    for accept in accept_list {
1061        if accept == "*/*" {
1062            return true;
1063        }
1064        if let Some(prefix) = accept.strip_suffix('*') {
1065            // image/* → prefix "image/"
1066            if normalized.starts_with(prefix) {
1067                return true;
1068            }
1069        } else if accept.starts_with('.') {
1070            continue;
1071        } else if &normalized == accept {
1072            return true;
1073        }
1074    }
1075    false
1076}
1077
1078fn matches_extension(filename: &str, accept_list: &[String]) -> bool {
1079    let lower = filename.to_ascii_lowercase();
1080    let parts: Vec<&str> = lower.split('.').collect();
1081    if parts.len() < 2 {
1082        return false;
1083    }
1084    let ext = parts[parts.len() - 1];
1085    if ext.is_empty() {
1086        return false;
1087    }
1088    for accept in accept_list {
1089        if let Some(stripped) = accept.strip_prefix('.') {
1090            if stripped == ext {
1091                return true;
1092            }
1093        }
1094    }
1095    if let Some(mimes) = extension_to_mime(ext) {
1096        for mime in mimes {
1097            if matches_mime_type(mime, accept_list) {
1098                return true;
1099            }
1100        }
1101    }
1102    false
1103}
1104
1105// ---------------------------------------------------------------------------
1106// mincount / maxcount (array-level)
1107// ---------------------------------------------------------------------------
1108
1109fn rule_mincount(ctx: &RuleContext) -> Option<String> {
1110    let Parameter::Count(min) = *ctx.parameter else {
1111        unreachable!("mincount receives a checked limit")
1112    };
1113    // Counts evaluate empty collections.
1114    if count(ctx.value) >= min {
1115        return None;
1116    }
1117    let msg =
1118        message_override(ctx.messages, "mincount").unwrap_or("Please select at least {0} items.");
1119    Some(fill(msg, &min.to_string(), None))
1120}
1121
1122fn rule_maxcount(ctx: &RuleContext) -> Option<String> {
1123    let Parameter::Count(max) = *ctx.parameter else {
1124        unreachable!("maxcount receives a checked limit")
1125    };
1126    if count(ctx.value) <= max {
1127        return None;
1128    }
1129    let msg = message_override(ctx.messages, "maxcount")
1130        .unwrap_or("Please select no more than {0} items.");
1131    Some(fill(msg, &max.to_string(), None))
1132}
1133
1134/// The count of a value: array elements or object keys; a missing value, `null`
1135/// and a blank string count 0 and any other scalar counts 1.
1136fn count(value: &Value) -> u64 {
1137    match value {
1138        Value::Array(elements) => elements.len() as u64,
1139        Value::Object(keys) => keys.len() as u64,
1140        Value::Null => 0,
1141        Value::String(text) if trim(text).is_empty() => 0,
1142        _ => 1,
1143    }
1144}
1145
1146// ---------------------------------------------------------------------------
1147// unique (array-level + item-level, optional filter condition)
1148// ---------------------------------------------------------------------------
1149
1150fn rule_unique(ctx: &RuleContext) -> Option<String> {
1151    if ctx.rule_param == &Value::Bool(false) || ctx.rule_param.is_null() {
1152        return None;
1153    }
1154
1155    let filter_condition: Option<&str> = match ctx.rule_param {
1156        Value::String(s) if is_condition_expression(s) => Some(s.as_str()),
1157        _ => None,
1158    };
1159    let error_message = message_override(ctx.messages, "unique")
1160        .unwrap_or("Values must be unique.")
1161        .to_string();
1162
1163    // Array-level: the field value is the array itself.
1164    if ctx.value.is_array() || ctx.value.is_object() {
1165        let entries: Vec<(String, &Value)> = match ctx.value {
1166            Value::Array(arr) => arr
1167                .iter()
1168                .enumerate()
1169                .map(|(i, v)| (i.to_string(), v))
1170                .collect(),
1171            Value::Object(map) => map.iter().map(|(k, v)| (k.clone(), v)).collect(),
1172            _ => unreachable!(),
1173        };
1174        let mut to_check: Vec<&Value> = Vec::new();
1175        if let Some(cond) = filter_condition {
1176            for (key, element) in &entries {
1177                let mut item_path = ctx.path_segments.to_vec();
1178                item_path.push(key.clone());
1179                let mut item_rows = ctx.row_keys.to_vec();
1180                item_rows.push(ctx.path_segments.len());
1181                if !item_passes_condition(cond, &item_path, &item_rows, ctx.form_data) {
1182                    continue;
1183                }
1184                if !is_empty(element) {
1185                    to_check.push(element);
1186                }
1187            }
1188        } else if let Value::String(field_name) = ctx.rule_param {
1189            // Param is a field name within array items (owned values).
1190            let items: Vec<&Value> = entries.iter().map(|(_, value)| *value).collect();
1191            return unique_array_by_field(&items, field_name, &error_message);
1192        } else {
1193            for (_, element) in &entries {
1194                if !is_empty(element) {
1195                    to_check.push(element);
1196                }
1197            }
1198        }
1199
1200        if to_check.is_empty() {
1201            return None;
1202        }
1203        if !are_all_unique(&to_check) {
1204            return Some(error_message);
1205        }
1206        return None;
1207    }
1208
1209    // Item-level: scalar field inside a repeated group.
1210    // Linear-time check: precompute all duplicates once per group, then O(1) lookup per row.
1211    if ctx.path_segments.len() < 2 {
1212        return None;
1213    }
1214    let field_name = &ctx.path_segments[ctx.path_segments.len() - 1];
1215    let item_key = &ctx.path_segments[ctx.path_segments.len() - 2];
1216    let container_path = &ctx.path_segments[..ctx.path_segments.len() - 2];
1217    let container = get_value_by_path(ctx.form_data, container_path);
1218    if let Value::Array(_) = container {
1219        if item_key.is_empty() || !item_key.bytes().all(|b| b.is_ascii_digit()) {
1220            return None;
1221        }
1222    } else if !container.is_object() {
1223        return None;
1224    }
1225    if is_empty(ctx.value) {
1226        return None;
1227    }
1228    // If a filter condition exists and the current item fails it, skip entirely.
1229    if let Some(cond) = filter_condition {
1230        if !item_passes_condition(cond, ctx.path_segments, ctx.row_keys, ctx.form_data) {
1231            return None;
1232        }
1233    }
1234
1235    // The rows of this collection field are walked once per validation: the filter is evaluated
1236    // once per row and each row whose value an earlier row holds is recorded.
1237    let cache_key = format!(
1238        "{}\u{0}{}\u{0}{}",
1239        container_path.join("\u{0}"),
1240        field_name,
1241        filter_condition.unwrap_or("")
1242    );
1243    let mut memo = ctx.run.unique_duplicates.borrow_mut();
1244    let duplicate_set = memo.entry(cache_key).or_insert_with(|| {
1245        let rows: Vec<(String, &Value)> = match container {
1246            Value::Array(items) => items
1247                .iter()
1248                .enumerate()
1249                .map(|(i, item)| (i.to_string(), item))
1250                .collect(),
1251            Value::Object(rows) => rows.iter().map(|(key, item)| (key.clone(), item)).collect(),
1252            _ => Vec::new(),
1253        };
1254        let mut seen = HashSet::new();
1255        let mut duplicates = HashSet::new();
1256        for (key, item) in rows {
1257            let Value::Object(row) = item else { continue };
1258            let Some(item_value) = row.get(field_name.as_str()) else {
1259                continue;
1260            };
1261            if is_empty(item_value) {
1262                continue;
1263            }
1264            if let Some(cond) = filter_condition {
1265                let mut sibling_path: Vec<String> = container_path.to_vec();
1266                sibling_path.push(key.clone());
1267                sibling_path.push(field_name.clone());
1268                if !item_passes_condition(cond, &sibling_path, ctx.row_keys, ctx.form_data) {
1269                    continue;
1270                }
1271            }
1272            if !seen.insert(comparison_key(item_value)) {
1273                duplicates.insert(key);
1274            }
1275        }
1276        duplicates
1277    });
1278
1279    // O(1) lookup: is this row a duplicate?
1280    if duplicate_set.contains(item_key) {
1281        return Some(error_message);
1282    }
1283
1284    None
1285}
1286
1287/// Array-level unique by field name within object items (JS `extractFieldValues`).
1288fn unique_array_by_field(items: &[&Value], field_name: &str, error: &str) -> Option<String> {
1289    let segs: Vec<String> = field_name
1290        .split('.')
1291        .filter(|s| !s.is_empty())
1292        .map(String::from)
1293        .collect();
1294    let mut values: Vec<&Value> = Vec::new();
1295    for item in items {
1296        if item.is_object() {
1297            let v = get_value_by_path(item, &segs);
1298            if !is_empty(v) {
1299                values.push(v);
1300            }
1301        }
1302    }
1303    if values.is_empty() {
1304        return None;
1305    }
1306    if !are_all_unique(&values) {
1307        return Some(error.to_string());
1308    }
1309    None
1310}
1311
1312/// The comparison key of a value: JSON text with object members sorted by name at every depth
1313/// and numbers written as their exact value, so two values share a key exactly when they are
1314/// equal as `equalTo` compares them.
1315fn comparison_key(value: &Value) -> String {
1316    match value {
1317        Value::Null => "null".to_string(),
1318        Value::Bool(b) => b.to_string(),
1319        Value::Number(n) => number_text(n.as_f64().unwrap_or(0.0)),
1320        Value::String(s) => Value::String(s.clone()).to_string(),
1321        Value::Array(items) => {
1322            let items: Vec<String> = items.iter().map(comparison_key).collect();
1323            format!("[{}]", items.join(","))
1324        }
1325        Value::Object(members) => {
1326            let mut names: Vec<&String> = members.keys().collect();
1327            names.sort();
1328            let members: Vec<String> = names
1329                .into_iter()
1330                .map(|name| {
1331                    format!(
1332                        "{}:{}",
1333                        Value::String(name.clone()),
1334                        comparison_key(&members[name])
1335                    )
1336                })
1337                .collect();
1338            format!("{{{}}}", members.join(","))
1339        }
1340    }
1341}
1342
1343fn are_all_unique(values: &[&Value]) -> bool {
1344    let mut seen = std::collections::HashSet::new();
1345    for v in values {
1346        let key = comparison_key(v);
1347        if !seen.insert(key) {
1348            return false;
1349        }
1350    }
1351    true
1352}
1353
1354/// A condition that does not parse is false: the JS reference `evaluateCondition`
1355/// catches the parse error and returns false (packages/validator/ts/src/validate/validator.ts).
1356#[allow(clippy::disallowed_methods, reason = "a condition that does not parse is false")]
1357fn item_passes_condition(
1358    condition: &str,
1359    item_field_path: &[String],
1360    row_keys: &[usize],
1361    form_data: &Value,
1362) -> bool {
1363    Expression::evaluate(condition, form_data, item_field_path, row_keys).unwrap_or(false)
1364}
1365
1366// ---------------------------------------------------------------------------
1367// path / expression helpers shared with the validator (JS PathResolver parity).
1368// ---------------------------------------------------------------------------
1369
1370/// JS `isConditionExpression`: a string that looks like a condition.
1371pub fn is_condition_expression(value: &str) -> bool {
1372    let trimmed = trim(value);
1373    if trimmed.starts_with('.') {
1374        return true;
1375    }
1376    // `^[a-zA-Z_][a-zA-Z0-9_]*\.`
1377    if starts_identifier_dot(trimmed) {
1378        return true;
1379    }
1380    if has_comparison_operator(trimmed) {
1381        return true;
1382    }
1383    // Ternary `\?.*:`
1384    if let Some(q) = trimmed.find('?') {
1385        if trimmed[q + 1..].contains(':') {
1386            return true;
1387        }
1388    }
1389    false
1390}
1391
1392fn starts_identifier_dot(s: &str) -> bool {
1393    let mut chars = s.char_indices();
1394    match chars.next() {
1395        Some((_, c)) if c.is_ascii_alphabetic() || c == '_' => {}
1396        _ => return false,
1397    }
1398    for (_, c) in chars {
1399        if c.is_ascii_alphanumeric() || c == '_' {
1400            continue;
1401        }
1402        // The dot must come immediately after the identifier run.
1403        return c == '.';
1404    }
1405    false
1406}
1407
1408/// JS `/\s+(==|!=|>|>=|<|<=|&&|\|\||in|not\s+in)\s+/`: whitespace-bounded operator.
1409fn has_comparison_operator(s: &str) -> bool {
1410    let ops = ["==", "!=", ">=", "<=", ">", "<", "&&", "||", "in", "not in"];
1411    let bytes = s.as_bytes();
1412    let is_ws =
1413        |b: u8| b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' || b == 0x0c || b == 0x0b;
1414
1415    // Search each operator with required surrounding whitespace (one-or-more).
1416    for op in ops {
1417        let mut search_from = 0;
1418        while let Some(pos) = s[search_from..].find(op) {
1419            let start = search_from + pos;
1420            let end = start + op.len();
1421            // require at least one whitespace before start and after end
1422            let before_ok = start > 0 && is_ws(bytes[start - 1]);
1423            let after_ok = end < bytes.len() && is_ws(bytes[end]);
1424            if before_ok && after_ok {
1425                // For "not in", JS allows `not\s+in`; the literal "not in" with a
1426                // single space matches; multiple spaces are also condition-like
1427                // and handled by the engine. This is sufficient for detection.
1428                return true;
1429            }
1430            search_from = start + 1;
1431            if search_from >= s.len() {
1432                break;
1433            }
1434        }
1435    }
1436    false
1437}
1438
1439/// Read a value at concrete path segments (JS `getValueByPath`). Missing → null.
1440pub fn get_value_by_path<'a>(data: &'a Value, path: &[String]) -> &'a Value {
1441    let mut current = data;
1442    for segment in path {
1443        match current {
1444            Value::Object(map) => match map.get(segment) {
1445                Some(v) => current = v,
1446                None => return &Value::Null,
1447            },
1448            Value::Array(arr) => match segment.parse::<usize>() {
1449                Ok(i) => match arr.get(i) {
1450                    Some(v) => current = v,
1451                    None => return &Value::Null,
1452                },
1453                Err(_) => return &Value::Null,
1454            },
1455            _ => return &Value::Null,
1456        }
1457    }
1458    current
1459}
1460
1461/// Resolve a field-reference expression to its value (JS `resolveFieldReference`):
1462/// `.x` / `..x` relatives with a row as one level, a dotted path from the root and
1463/// a bare name resolved as `.name`.
1464pub fn resolve_field_reference<'a>(
1465    expression: &str,
1466    current_path: &[String],
1467    row_keys: &[usize],
1468    form_data: &'a Value,
1469) -> &'a Value {
1470    let trimmed = expression.trim();
1471    if trimmed.is_empty() {
1472        return &Value::Null;
1473    }
1474
1475    // Count leading dots.
1476    let dots = trimmed.chars().take_while(|c| *c == '.').count();
1477
1478    if dots > 0 || !trimmed.contains('.') {
1479        let segments = trimmed[dots..]
1480            .split('.')
1481            .filter(|s| !s.is_empty())
1482            .map(String::from);
1483        let mut base = relative_base(current_path, row_keys, dots.saturating_sub(1));
1484        base.extend(segments);
1485        return get_value_by_path(form_data, &base);
1486    }
1487
1488    // A dotted path resolves from the root.
1489    let segments: Vec<String> = trimmed
1490        .split('.')
1491        .filter(|s| !s.is_empty())
1492        .map(String::from)
1493        .collect();
1494    get_value_by_path(form_data, &segments)
1495}
1496
1497#[cfg(test)]
1498mod tests {
1499    use super::*;
1500
1501    #[test]
1502    fn date_rule_accepts_only_the_explicit_layouts() {
1503        for text in ["2026-02-28", "2026/02/28", "28/02/2026", "02/28/2026", "2026-02-28T10:30:00Z", "2026-02-28T10:30:00+01:00"] {
1504            assert!(is_valid_date(&serde_json::json!(text)), "{text} is a date");
1505        }
1506        for text in ["not a date", "2026-02-30", "02/30/2026", "2026-02-28T25:00:00Z", "2026-02-28T10:30:00", "2026-02-28 10:30"] {
1507            assert!(!is_valid_date(&serde_json::json!(text)), "{text} is no date");
1508        }
1509        assert!(!is_valid_date(&serde_json::json!(1_700_000_000_000_i64)), "a number is no date");
1510    }
1511
1512    #[test]
1513    fn test_comparison_key_canonical_json() {
1514        // Two objects with same content but different key order
1515        let val1 = serde_json::json!({"a": 1, "b": "test", "c": true});
1516        let val2 = serde_json::json!({"c": true, "a": 1, "b": "test"});
1517
1518        let key1 = comparison_key(&val1);
1519        let key2 = comparison_key(&val2);
1520
1521        assert_eq!(
1522            key1, key2,
1523            "Comparison keys for objects with different key order should be identical"
1524        );
1525    }
1526
1527    #[test]
1528    fn test_comparison_key_type_distinction() {
1529        let tests = vec![
1530            (serde_json::json!(1), serde_json::json!("1"), false), // number vs string
1531            (serde_json::json!(true), serde_json::json!(1), false), // bool vs number
1532            (serde_json::json!(null), serde_json::json!("null"), false), // null vs string
1533            (serde_json::json!([]), serde_json::json!("[]"), false), // array vs string
1534            (serde_json::json!(1), serde_json::json!(1), true),    // same number
1535            (serde_json::json!("1"), serde_json::json!("1"), true), // same string
1536        ];
1537
1538        for (val1, val2, should_match) in tests {
1539            let key1 = comparison_key(&val1);
1540            let key2 = comparison_key(&val2);
1541            let keys_match = key1 == key2;
1542
1543            assert_eq!(
1544                keys_match, should_match,
1545                "comparison_key({:?}) vs comparison_key({:?}): got {}, want {}",
1546                val1, val2, keys_match, should_match
1547            );
1548        }
1549    }
1550
1551    /// 100,000 distinct values are keyed once each within seconds; comparing each value with
1552    /// every earlier one makes 5 * 10^9 comparisons and does not end before the per-test timeout
1553    /// of the run-tests tool of @polyspec/kit.
1554    #[test]
1555    fn test_unique_many_values() {
1556        let mut values: Vec<Value> = (0..100_000)
1557            .map(|i| Value::String(format!("item-{i}")))
1558            .collect();
1559        assert!(are_all_unique(&values.iter().collect::<Vec<_>>()));
1560        values[99_999] = Value::String("item-0".into());
1561        assert!(!are_all_unique(&values.iter().collect::<Vec<_>>()));
1562    }
1563}
1564
1565#[cfg(test)]
1566mod email_label_tests {
1567    #[test]
1568    fn a_one_character_label_is_valid_after_the_boundaries() {
1569        // The middle slice of one or two characters is empty; the label
1570        // stands on its boundary characters alone.
1571        assert!(super::is_valid_email_label("a"));
1572        assert!(super::is_valid_email_label("ab"));
1573        assert!(!super::is_valid_email_label("-"));
1574        assert!(!super::is_valid_email_label("a-"));
1575    }
1576}