Skip to main content

json_eval_rs/jsoneval/
validation.rs

1use super::JSONEval;
2use crate::jsoneval::cancellation::CancellationToken;
3use crate::jsoneval::json_parser;
4use crate::jsoneval::path_utils;
5use crate::jsoneval::types::{ValidationError, ValidationResult};
6
7use crate::time_block;
8
9use indexmap::IndexMap;
10use serde_json::Value;
11
12impl JSONEval {
13    /// Invalidate the validation cache
14    pub(crate) fn invalidate_validation_cache(&self) {
15        let mut cache = match self.validation_cache.write() {
16            Ok(c) => c,
17            Err(poisoned) => poisoned.into_inner(),
18        };
19        cache.clear();
20    }
21
22    /// Validate data against schema rules
23    pub fn validate(
24        &mut self,
25        data: &str,
26        context: Option<&str>,
27        paths: Option<&[String]>,
28        token: Option<&CancellationToken>,
29        validate_readonly: Option<bool>,
30        include_subforms: Option<bool>,
31    ) -> Result<ValidationResult, String> {
32        let validate_ro = validate_readonly.unwrap_or(false);
33        let inc_subforms = include_subforms.unwrap_or(false);
34        if let Some(t) = token {
35            if t.is_cancelled() {
36                return Err("Cancelled".to_string());
37            }
38        }
39
40        // Fast path: if no path filtering and data/context are identical to last validation,
41        // return cached full result immediately.
42        if paths.is_none() || paths.is_some_and(|p| p.is_empty()) {
43            if let Ok(cache) = self.validation_cache.read() {
44                if let Some(cached) =
45                    cache.get_cached_full_result(data, context, validate_ro, inc_subforms)
46                {
47                    return Ok(cached);
48                }
49            }
50        }
51
52        time_block!("validate() [total]", {
53            // Acquire lock for synchronous execution
54            let _lock = self.eval_lock.lock().unwrap();
55            let _static_guard = self
56                .engine
57                .bind_static_arrays_scope(std::sync::Arc::clone(&self.static_arrays));
58
59            // Parse and update data
60            let (data_value, context_value) = time_block!("  parse data & context", {
61                let d = json_parser::parse_json_str(data)?;
62                let c = if let Some(ctx) = context {
63                    json_parser::parse_json_str(ctx)?
64                } else {
65                    Value::Object(serde_json::Map::new())
66                };
67                Ok::<_, String>((d, c))
68            })?;
69
70            // Update context
71            self.context = context_value.clone();
72
73            // Update eval_data with new data/context
74            time_block!("  replace_data_and_context", {
75                self.eval_data
76                    .replace_data_and_context(data_value.clone(), context_value.clone());
77            });
78
79            // Drop lock before calling evaluate_others which needs mutable access
80            drop(_lock);
81
82            // Re-evaluate rule evaluations to ensure fresh values
83            // This ensures all rule.$evaluation expressions are re-computed
84            time_block!("  evaluate_others", {
85                self.evaluate_others(paths, token);
86            });
87
88            time_block!("  ensure_layout_resolved", {
89                self.ensure_layout_resolved();
90            });
91
92            let mut errors: IndexMap<String, ValidationError> = IndexMap::new();
93
94            let layout_state = self.layout_state.read().unwrap();
95            let layout_hidden_refs = &layout_state.layout_hidden_refs;
96            let layout_disabled_refs = &layout_state.layout_disabled_refs;
97            let mut hidden_cache =
98                std::collections::HashMap::with_capacity(self.fields_with_rules.len());
99            let mut readonly_cache =
100                std::collections::HashMap::with_capacity(self.fields_with_rules.len());
101
102            // Use pre-parsed fields_with_rules from schema parsing (no runtime collection needed)
103            // This list was collected during schema parse and contains all fields with rules
104            time_block!("  fields_with_rules loop", {
105                for field_path in self.fields_with_rules.iter() {
106                    // Check if we should validate this path (path filtering)
107                    if let Some(filter_paths) = paths {
108                        if !filter_paths.is_empty()
109                            && !filter_paths.iter().any(|p| {
110                                field_path.starts_with(p.as_str())
111                                    || p.starts_with(field_path.as_str())
112                            })
113                        {
114                            continue;
115                        }
116                    }
117
118                    if let Some(t) = token {
119                        if t.is_cancelled() {
120                            return Err("Cancelled".to_string());
121                        }
122                    }
123
124                    self.validate_field_cached(
125                        field_path,
126                        &data_value,
127                        layout_hidden_refs,
128                        layout_disabled_refs,
129                        &mut hidden_cache,
130                        &mut readonly_cache,
131                        validate_ro,
132                        &mut errors,
133                    );
134                }
135            });
136
137            drop(layout_state);
138
139            if inc_subforms {
140                let initial_eval_cache = self.eval_cache.clone();
141                let initial_static_arrays = std::sync::Arc::clone(&self.static_arrays);
142                let subform_keys: Vec<String> = self.subforms.keys().cloned().collect();
143
144                for subform_path in subform_keys {
145                    if let Some(t) = token {
146                        if t.is_cancelled() {
147                            return Err("Cancelled".to_string());
148                        }
149                    }
150
151                    let data_ptr = path_utils::schema_path_to_data_pointer(&subform_path);
152                    let subform_dot_path = data_ptr.trim_start_matches('/').replace('/', ".");
153
154                    let schema_pointer = if subform_path.starts_with("#/") {
155                        &subform_path[1..]
156                    } else if subform_path.starts_with('#') {
157                        &subform_path[1..]
158                    } else {
159                        &subform_path
160                    };
161
162                    let original_field_key = subform_path
163                        .split('/')
164                        .filter(|seg| !seg.is_empty() && *seg != "properties")
165                        .last()
166                        .unwrap_or(&subform_path)
167                        .to_string();
168
169                    let root_key = path_utils::get_value_by_pointer(&self.schema, schema_pointer)
170                        .and_then(|node| node.get("itemsRootKey"))
171                        .and_then(|v| v.as_str())
172                        .unwrap_or(&original_field_key)
173                        .to_string();
174
175                    let item_count = self
176                        .eval_data
177                        .data()
178                        .pointer(&data_ptr)
179                        .and_then(Value::as_array)
180                        .map(|a| a.len())
181                        .unwrap_or(0);
182
183                    if item_count == 0 {
184                        continue;
185                    }
186
187                    for idx in 0..item_count {
188                        if let Some(t) = token {
189                            if t.is_cancelled() {
190                                return Err("Cancelled".to_string());
191                            }
192                        }
193
194                        let item_prefix = format!("{}.{}.", subform_dot_path, idx);
195                        let item_path_exact = format!("{}.{}", subform_dot_path, idx);
196
197                        let sub_paths: Option<Vec<String>> = if let Some(filter_paths) = paths {
198                            if filter_paths.is_empty() {
199                                None
200                            } else {
201                                let applies = filter_paths.iter().any(|p| {
202                                    p == &item_path_exact
203                                        || p == &subform_dot_path
204                                        || p.starts_with(&item_prefix)
205                                        || subform_dot_path.starts_with(p.as_str())
206                                });
207                                if !applies {
208                                    continue;
209                                }
210
211                                let has_whole_match = filter_paths.iter().any(|p| {
212                                    p == &item_path_exact
213                                        || p == &subform_dot_path
214                                        || subform_dot_path.starts_with(p.as_str())
215                                });
216
217                                if has_whole_match {
218                                    None
219                                } else {
220                                    let mapped: Vec<String> = filter_paths
221                                        .iter()
222                                        .filter_map(|p| {
223                                            p.strip_prefix(&item_prefix)
224                                                .map(|sub| format!("{}.{}", root_key, sub))
225                                        })
226                                        .collect();
227
228                                    if mapped.is_empty() {
229                                        continue;
230                                    }
231                                    Some(mapped)
232                                }
233                            }
234                        } else {
235                            None
236                        };
237
238                        let sub_paths_ref = sub_paths.as_deref();
239
240                        let sub_result = self.with_item_cache_swap(
241                            &subform_path,
242                            idx,
243                            data_value.clone(),
244                            context_value.clone(),
245                            false,
246                            |sf| {
247                                sf.validate_pre_set(
248                                    None,
249                                    sub_paths_ref,
250                                    token,
251                                    validate_readonly,
252                                    true,
253                                )
254                            },
255                        )?;
256
257                        let root_prefix = format!("{}.", root_key);
258                        for (field_name, mut error) in sub_result.errors {
259                            let sub_field =
260                                if let Some(stripped) = field_name.strip_prefix(&root_prefix) {
261                                    stripped
262                                } else if field_name == root_key {
263                                    ""
264                                } else {
265                                    &field_name
266                                };
267
268                            let parent_path = if sub_field.is_empty() {
269                                format!("{}.{}", subform_dot_path, idx)
270                            } else {
271                                format!("{}.{}.{}", subform_dot_path, idx, sub_field)
272                            };
273
274                            if let Some(ref c) = error.code {
275                                if c == &format!("{}.{}", field_name, error.rule_type)
276                                    || c == &format!("{}.{}", sub_field, error.rule_type)
277                                {
278                                    error.code =
279                                        Some(format!("{}.{}", parent_path, error.rule_type));
280                                }
281                            }
282
283                            errors.insert(parent_path, error);
284                        }
285                    }
286                }
287
288                // Reset scratch subforms state to avoid holding duplicate trees in memory
289                for subform in self.subforms.values_mut() {
290                    subform.eval_data = crate::jsoneval::eval_data::EvalData::new(Value::Null);
291                    subform.evaluated_schema = (*subform.schema).clone();
292                    subform.eval_cache.clear();
293                    subform.static_arrays = std::sync::Arc::new(IndexMap::new());
294                    subform
295                        .engine
296                        .set_static_arrays(std::sync::Arc::clone(&subform.static_arrays));
297                    subform.invalidate_layout_cache();
298                    subform.invalidate_validation_cache();
299                }
300                self.eval_cache = initial_eval_cache;
301                self.static_arrays = initial_static_arrays;
302                self.engine
303                    .set_static_arrays(std::sync::Arc::clone(&self.static_arrays));
304                self.invalidate_layout_cache();
305            }
306
307            let has_error = !errors.is_empty();
308            let result = ValidationResult { has_error, errors };
309
310            if paths.is_none() || paths.is_some_and(|p| p.is_empty()) {
311                if let Ok(mut cache) = self.validation_cache.write() {
312                    cache.save_full_result(
313                        data.to_string(),
314                        context.map(|s| s.to_string()),
315                        validate_ro,
316                        inc_subforms,
317                        result.clone(),
318                    );
319                }
320            } else if let Ok(mut cache) = self.validation_cache.write() {
321                cache.invalidate_full_result();
322            }
323
324            Ok(result)
325        })
326    }
327
328    /// Validate using the data already present in `eval_data` (set by `with_item_cache_swap`).
329    ///
330    /// Skips JSON parsing and `replace_data_and_context` — use this inside the
331    /// cache-swap closure to avoid redundant work when the subform data is already set.
332    pub(crate) fn validate_pre_set(
333        &mut self,
334        data_value: Option<&Value>,
335        paths: Option<&[String]>,
336        token: Option<&CancellationToken>,
337        validate_readonly: Option<bool>,
338        re_evaluate_others: bool,
339    ) -> Result<crate::ValidationResult, String> {
340        let validate_ro = validate_readonly.unwrap_or(false);
341        let _static_guard = self
342            .engine
343            .bind_static_arrays_scope(std::sync::Arc::clone(&self.static_arrays));
344        if re_evaluate_others {
345            // Re-evaluate rule evaluations with the current (already-set) data.
346            self.evaluate_others(paths, token);
347        }
348
349        self.ensure_layout_resolved();
350
351        let mut errors: IndexMap<String, ValidationError> = IndexMap::new();
352
353        let layout_state = self.layout_state.read().unwrap();
354        let layout_hidden_refs = &layout_state.layout_hidden_refs;
355        let layout_disabled_refs = &layout_state.layout_disabled_refs;
356        let mut hidden_cache =
357            std::collections::HashMap::with_capacity(self.fields_with_rules.len());
358        let mut readonly_cache =
359            std::collections::HashMap::with_capacity(self.fields_with_rules.len());
360
361        let target_data = if let Some(dv) = data_value {
362            if dv.is_object()
363                && self.fields_with_rules.iter().any(|f| {
364                    let root = f.split('.').next().unwrap_or(f);
365                    dv.get(root).is_some()
366                })
367            {
368                dv
369            } else {
370                self.eval_data.data()
371            }
372        } else {
373            self.eval_data.data()
374        };
375
376        for field_path in self.fields_with_rules.iter() {
377            if let Some(filter_paths) = paths {
378                if !filter_paths.is_empty()
379                    && !filter_paths.iter().any(|p| {
380                        field_path.starts_with(p.as_str()) || p.starts_with(field_path.as_str())
381                    })
382                {
383                    continue;
384                }
385            }
386            if let Some(t) = token {
387                if t.is_cancelled() {
388                    return Err("Cancelled".to_string());
389                }
390            }
391            self.validate_field_cached(
392                field_path,
393                target_data,
394                layout_hidden_refs,
395                layout_disabled_refs,
396                &mut hidden_cache,
397                &mut readonly_cache,
398                validate_ro,
399                &mut errors,
400            );
401        }
402
403        drop(layout_state);
404
405        let has_error = !errors.is_empty();
406        Ok(crate::ValidationResult { has_error, errors })
407    }
408
409    /// Validate a single field that has rules (convenience wrapper without external cache)
410    #[allow(dead_code)]
411    pub(crate) fn validate_field(
412        &self,
413        field_path: &str,
414        data: &Value,
415        validate_readonly: bool,
416        errors: &mut IndexMap<String, ValidationError>,
417    ) {
418        let layout_state = self.layout_state.read().unwrap();
419        let mut hidden_cache = std::collections::HashMap::new();
420        let mut readonly_cache = std::collections::HashMap::new();
421        self.validate_field_cached(
422            field_path,
423            data,
424            &layout_state.layout_hidden_refs,
425            &layout_state.layout_disabled_refs,
426            &mut hidden_cache,
427            &mut readonly_cache,
428            validate_readonly,
429            errors,
430        );
431    }
432
433    /// Validate a single field that has rules, with pre-acquired layout refs and caches
434    #[allow(clippy::too_many_arguments)]
435    pub(crate) fn validate_field_cached(
436        &self,
437        field_path: &str,
438        data: &Value,
439        layout_hidden_refs: &indexmap::IndexSet<String>,
440        layout_disabled_refs: &indexmap::IndexSet<String>,
441        hidden_cache: &mut std::collections::HashMap<String, bool>,
442        readonly_cache: &mut std::collections::HashMap<String, bool>,
443        validate_readonly: bool,
444        errors: &mut IndexMap<String, ValidationError>,
445    ) {
446        // Skip if already has error
447        if errors.contains_key(field_path) {
448            return;
449        }
450
451        // Resolve schema for this field
452        let schema_path = path_utils::dot_notation_to_schema_pointer(field_path);
453        let pointer_path = schema_path.trim_start_matches('#');
454
455        // Try to get schema, if not found, try with /properties/ prefix for standard JSON Schema
456        let (field_schema, resolved_path) = match self.evaluated_schema.pointer(pointer_path) {
457            Some(s) => (s, pointer_path.to_string()),
458            None => {
459                let alt_path = format!("/properties{}", pointer_path);
460                match self.evaluated_schema.pointer(&alt_path) {
461                    Some(s) => (s, alt_path),
462                    None => return,
463                }
464            }
465        };
466
467        // Skip hidden fields using cached layout & schema lookup
468        let is_hidden =
469            self.is_effective_hidden_with_cache(&resolved_path, layout_hidden_refs, hidden_cache);
470        if is_hidden {
471            if let Ok(mut cache) = self.validation_cache.write() {
472                cache.update_field(
473                    field_path.to_string(),
474                    Value::Null,
475                    true,
476                    validate_readonly,
477                    Value::Null,
478                    None,
479                );
480            }
481            return;
482        }
483
484        // Skip readonly / disabled fields unless validate_readonly is true
485        if !validate_readonly {
486            let is_readonly = self.is_effective_readonly_with_cache(
487                &resolved_path,
488                layout_disabled_refs,
489                readonly_cache,
490            );
491            if is_readonly {
492                if let Ok(mut cache) = self.validation_cache.write() {
493                    cache.update_field(
494                        field_path.to_string(),
495                        Value::Null,
496                        false,
497                        false,
498                        Value::Null,
499                        None,
500                    );
501                }
502                return;
503            }
504        }
505
506        if let Value::Object(schema_map) = field_schema {
507            // Get rules object
508            let rules_val = match schema_map.get("rules") {
509                Some(r @ Value::Object(_)) => r,
510                _ => return,
511            };
512            let rules = rules_val.as_object().unwrap();
513
514            // Get field data
515            let field_data = self.get_field_data(field_path, data);
516
517            // Check field cache
518            let cached_lookup = if let Ok(cache) = self.validation_cache.read() {
519                cache.check_field_cache(
520                    field_path,
521                    &field_data,
522                    false,
523                    validate_readonly,
524                    rules_val,
525                )
526            } else {
527                None
528            };
529
530            if let Some(cached_error) = cached_lookup {
531                if let Some(err) = cached_error {
532                    errors.insert(field_path.to_string(), err);
533                }
534                return;
535            }
536
537            // Validate each rule
538            let mut field_error: Option<ValidationError> = None;
539            time_block!("    validate_rules loop", {
540                for (rule_name, rule_value) in rules {
541                    self.validate_rule(
542                        field_path,
543                        rule_name,
544                        rule_value,
545                        &field_data,
546                        schema_map,
547                        field_schema,
548                        errors,
549                    );
550                    if let Some(err) = errors.get(field_path) {
551                        field_error = Some(err.clone());
552                        break;
553                    }
554                }
555            });
556
557            if let Ok(mut cache) = self.validation_cache.write() {
558                cache.update_field(
559                    field_path.to_string(),
560                    field_data,
561                    false,
562                    validate_readonly,
563                    rules_val.clone(),
564                    field_error,
565                );
566            }
567        }
568    }
569
570    /// Get data value for a field path
571    pub(crate) fn get_field_data(&self, field_path: &str, data: &Value) -> Value {
572        let mut current = data;
573
574        for part in field_path.split('.') {
575            match current {
576                Value::Object(map) => {
577                    current = map.get(part).unwrap_or(&Value::Null);
578                }
579                _ => return Value::Null,
580            }
581        }
582
583        current.clone()
584    }
585
586    /// Validate a single rule
587    #[allow(clippy::too_many_arguments)]
588    pub(crate) fn validate_rule(
589        &self,
590        field_path: &str,
591        rule_name: &str,
592        rule_value: &Value,
593        field_data: &Value,
594        schema_map: &serde_json::Map<String, Value>,
595        _schema: &Value,
596        errors: &mut IndexMap<String, ValidationError>,
597    ) {
598        // Skip if already has error
599        if errors.contains_key(field_path) {
600            return;
601        }
602
603        let schema_type = schema_map
604            .get("type")
605            .and_then(|t| t.as_str())
606            .unwrap_or("");
607
608        // The rule_value passed in already reflects the evaluated rule from evaluated_schema
609        let evaluated_rule = rule_value;
610
611        // Extract rule active status, message, etc
612        // Logic depends on rule structure (object with value/message or direct value)
613        let (rule_active, rule_message, rule_code, rule_data) = match evaluated_rule {
614            Value::Object(rule_obj) => {
615                let active = rule_obj.get("value").unwrap_or(&Value::Bool(false));
616
617                // Handle message - could be string or object with "value"
618                let message = match rule_obj.get("message") {
619                    Some(Value::String(s)) => s.clone(),
620                    Some(Value::Object(msg_obj)) if msg_obj.contains_key("value") => msg_obj
621                        .get("value")
622                        .and_then(|v| v.as_str())
623                        .unwrap_or("Validation failed")
624                        .to_string(),
625                    Some(msg_val) => msg_val.as_str().unwrap_or("Validation failed").to_string(),
626                    None => "Validation failed".to_string(),
627                };
628
629                let code = rule_obj
630                    .get("code")
631                    .and_then(|c| c.as_str())
632                    .map(|s| s.to_string());
633
634                // Handle data - extract "value" from objects with $evaluation
635                let data = rule_obj.get("data").map(|d| {
636                    if let Value::Object(data_obj) = d {
637                        let mut cleaned_data = serde_json::Map::new();
638                        for (key, value) in data_obj {
639                            // If value is an object with only "value" key, extract it
640                            if let Value::Object(val_obj) = value {
641                                if val_obj.len() == 1 && val_obj.contains_key("value") {
642                                    cleaned_data.insert(key.clone(), val_obj["value"].clone());
643                                } else {
644                                    cleaned_data.insert(key.clone(), value.clone());
645                                }
646                            } else {
647                                cleaned_data.insert(key.clone(), value.clone());
648                            }
649                        }
650                        Value::Object(cleaned_data)
651                    } else {
652                        d.clone()
653                    }
654                });
655
656                (active.clone(), message, code, data)
657            }
658            _ => (
659                evaluated_rule.clone(),
660                "Validation failed".to_string(),
661                None,
662                None,
663            ),
664        };
665
666        // Generate default code if not provided
667        let error_code = rule_code.or_else(|| Some(format!("{}.{}", field_path, rule_name)));
668
669        let is_empty = matches!(field_data, Value::Null)
670            || (field_data.is_string() && field_data.as_str().unwrap_or("").is_empty())
671            || (field_data.is_array() && field_data.as_array().unwrap().is_empty());
672
673        match rule_name {
674            "required" => {
675                if rule_active == Value::Bool(true) {
676                    if is_empty {
677                        errors.insert(
678                            field_path.to_string(),
679                            ValidationError {
680                                rule_type: "required".to_string(),
681                                message: rule_message,
682                                code: error_code,
683                                pattern: None,
684                                field_value: None,
685                                data: build_error_data(
686                                    rule_name,
687                                    &rule_active,
688                                    rule_data,
689                                    schema_map,
690                                ),
691                            },
692                        );
693                    }
694                }
695            }
696            "minLength" | "maxLength" | "minValue" | "maxValue" => {
697                if rule_value_fails(rule_name, &rule_active, field_data, is_empty, schema_type) {
698                    errors.insert(
699                        field_path.to_string(),
700                        ValidationError {
701                            rule_type: rule_name.to_string(),
702                            message: rule_message,
703                            code: error_code,
704                            pattern: None,
705                            field_value: None,
706                            data: build_error_data(rule_name, &rule_active, rule_data, schema_map),
707                        },
708                    );
709                }
710            }
711
712            "pattern" => {
713                if !is_empty {
714                    if let Some(pattern) = rule_active.as_str() {
715                        if let Some(text) = field_data.as_str() {
716                            let cached_regex = if let Ok(cache) = self.regex_cache.read() {
717                                cache.get(pattern).cloned()
718                            } else {
719                                None
720                            };
721                            let regex = match cached_regex {
722                                Some(r) => r,
723                                None => {
724                                    let mut cache = self.regex_cache.write().unwrap();
725                                    cache
726                                        .entry(pattern.to_string())
727                                        .or_insert_with(|| {
728                                            regex::Regex::new(pattern).unwrap_or_else(|_| {
729                                                regex::Regex::new("(?:)").unwrap()
730                                            })
731                                        })
732                                        .clone()
733                                }
734                            };
735                            if !regex.is_match(text) {
736                                errors.insert(
737                                    field_path.to_string(),
738                                    ValidationError {
739                                        rule_type: "pattern".to_string(),
740                                        message: rule_message,
741                                        code: error_code,
742                                        pattern: Some(pattern.to_string()),
743                                        field_value: Some(text.to_string()),
744                                        data: build_error_data(
745                                            rule_name,
746                                            &rule_active,
747                                            rule_data,
748                                            schema_map,
749                                        ),
750                                    },
751                                );
752                            }
753                        }
754                    }
755                }
756            }
757            "evaluation" => {
758                // Handle array of evaluation rules
759                // Format: "evaluation": [{ "code": "...", "message": "...", "$evaluation": {...} }]
760                if let Value::Array(eval_array) = evaluated_rule {
761                    for (idx, eval_item) in eval_array.iter().enumerate() {
762                        if let Value::Object(eval_obj) = eval_item {
763                            // Get the evaluated value (should be in "value" key after evaluation)
764                            let eval_result = eval_obj.get("value").unwrap_or(&Value::Bool(true));
765
766                            // Check if result is falsy
767                            let is_falsy = match eval_result {
768                                Value::Bool(false) => true,
769                                Value::Null => true,
770                                Value::Number(n) => n.as_f64() == Some(0.0),
771                                Value::String(s) => s.is_empty(),
772                                Value::Array(a) => a.is_empty(),
773                                _ => false,
774                            };
775
776                            if is_falsy {
777                                let eval_code = eval_obj
778                                    .get("code")
779                                    .and_then(|c| c.as_str())
780                                    .map(|s| s.to_string())
781                                    .or_else(|| Some(format!("{}.evaluation.{}", field_path, idx)));
782
783                                let eval_message = eval_obj
784                                    .get("message")
785                                    .and_then(|m| m.as_str())
786                                    .unwrap_or("Validation failed")
787                                    .to_string();
788
789                                let eval_data = eval_obj.get("data").cloned();
790
791                                errors.insert(
792                                    field_path.to_string(),
793                                    ValidationError {
794                                        rule_type: "evaluation".to_string(),
795                                        message: eval_message,
796                                        code: eval_code,
797                                        pattern: None,
798                                        field_value: None,
799                                        data: build_error_data(
800                                            "evaluation",
801                                            &Value::Null,
802                                            eval_data,
803                                            schema_map,
804                                        ),
805                                    },
806                                );
807
808                                // Stop at first failure
809                                break;
810                            }
811                        }
812                    }
813                }
814            }
815            _ => {
816                if rule_value_fails(rule_name, &rule_active, field_data, is_empty, schema_type) {
817                    errors.insert(
818                        field_path.to_string(),
819                        ValidationError {
820                            rule_type: "evaluation".to_string(),
821                            message: rule_message,
822                            code: error_code,
823                            pattern: None,
824                            field_value: None,
825                            data: build_error_data(rule_name, &rule_active, rule_data, schema_map),
826                        },
827                    );
828                }
829            }
830        }
831    }
832
833    /// Returns `true` if `field_data` fails any of the dep field's schema rules.
834    ///
835    /// Rules are evaluated on-demand: compiled `LogicId`s from `self.evaluations` (set at
836    /// construction time) are executed directly against `scope_data`, completely bypassing
837    /// `evaluated_schema`. This avoids stale-cache issues during table dependency checks.
838    /// Unlike `validate_field`, this also evaluates the `required` rule on-demand.
839    pub(crate) fn dep_fails_schema_rules(
840        &self,
841        field_path: &str,
842        field_data: &Value,
843        scope_data: &Value,
844    ) -> bool {
845        let schema_pointer = path_utils::dot_notation_to_schema_pointer(field_path);
846        let pointer = schema_pointer.trim_start_matches('#');
847
848        let field_schema = match self.schema.pointer(pointer) {
849            Some(s) => s,
850            None => {
851                let alt_pointer = format!("/properties{}", pointer);
852                match self.schema.pointer(&alt_pointer) {
853                    Some(s) => s,
854                    None => return false,
855                }
856            }
857        };
858
859        let schema_map = match field_schema.as_object() {
860            Some(m) => m,
861            None => return false,
862        };
863
864        let rules = match schema_map.get("rules") {
865            Some(Value::Object(r)) => r,
866            _ => return false,
867        };
868
869        let schema_type = schema_map
870            .get("type")
871            .and_then(|t| t.as_str())
872            .unwrap_or("");
873
874        let is_empty = matches!(field_data, Value::Null)
875            || field_data.as_str().map_or(false, |s| s.is_empty())
876            || field_data.as_array().map_or(false, |a| a.is_empty());
877
878        for (rule_name, rule_value) in rules {
879            // Resolve the rule's active value on-demand.
880            // If a compiled LogicId exists in self.evaluations for this rule path, run it fresh
881            // against scope_data. Otherwise fall back to the static "value" from the raw schema.
882            let rule_eval_key = format!("#{}/rules/{}", pointer, rule_name);
883            let rule_active: Value = if let Some(logic_id) = self.evaluations.get(&rule_eval_key) {
884                let empty_ctx = Value::Object(serde_json::Map::new());
885                self.engine
886                    .run_with_context(logic_id, scope_data, &empty_ctx)
887                    .unwrap_or(Value::Null)
888            } else {
889                match rule_value {
890                    Value::Object(obj) => obj.get("value").cloned().unwrap_or(Value::Null),
891                    other => other.clone(),
892                }
893            };
894
895            if rule_value_fails(rule_name, &rule_active, field_data, is_empty, schema_type) {
896                return true;
897            }
898        }
899
900        false
901    }
902}
903
904/// Pure rule-check: returns `true` if `rule_active` indicates `field_data` fails the rule.
905///
906/// This is the shared comparison kernel used by both `validate_rule` (full validation path)
907/// and `dep_fails_schema_rules` (on-demand dep checking). It is intentionally free of any
908/// schema/cache lookups — callers are responsible for resolving `rule_active` beforehand.
909///
910/// Handles: `required`, `minLength`, `maxLength`, `minValue`, `maxValue`, and custom/dynamic.
911/// Does NOT handle: `pattern` (needs regex cache), `evaluation` array format (complex structure).
912fn rule_value_fails(
913    rule_name: &str,
914    rule_active: &Value,
915    field_data: &Value,
916    is_empty: bool,
917    schema_type: &str,
918) -> bool {
919    let coerce_num = |v: &Value| -> Option<f64> {
920        if let Some(n) = v.as_f64() {
921            return Some(n);
922        }
923        if matches!(schema_type, "number" | "integer") {
924            if let Some(s) = v.as_str() {
925                return s.trim().parse::<f64>().ok();
926            }
927        }
928        None
929    };
930
931    match rule_name {
932        "required" => is_empty && matches!(rule_active, Value::Bool(true)),
933        "minLength" => {
934            if is_empty {
935                false
936            } else if let Some(min) = rule_active.as_u64() {
937                let len = match field_data {
938                    Value::String(s) => s.len(),
939                    Value::Array(a) => a.len(),
940                    _ => 0,
941                };
942                len < min as usize
943            } else {
944                false
945            }
946        }
947        "maxLength" => {
948            if is_empty {
949                false
950            } else if let Some(max) = rule_active.as_u64() {
951                let len = match field_data {
952                    Value::String(s) => s.len(),
953                    Value::Array(a) => a.len(),
954                    _ => 0,
955                };
956                len > max as usize
957            } else {
958                false
959            }
960        }
961        "minValue" => {
962            if is_empty {
963                false
964            } else if let Some(min) = rule_active.as_f64() {
965                coerce_num(field_data).map_or(false, |v| v < min)
966            } else {
967                false
968            }
969        }
970        "maxValue" => {
971            if is_empty {
972                false
973            } else if let Some(max) = rule_active.as_f64() {
974                coerce_num(field_data).map_or(false, |v| v > max)
975            } else {
976                false
977            }
978        }
979        // pattern and evaluation array are handled by their specific callers
980        "pattern" | "evaluation" => false,
981        _ => {
982            // Custom/dynamic rule: falsy rule_active = constraint not met = field invalid
983            if is_empty {
984                false
985            } else {
986                matches!(rule_active, Value::Bool(false) | Value::Null)
987                    || rule_active.as_f64() == Some(0.0)
988                    || rule_active.as_str().map_or(false, |s| s.is_empty())
989                    || rule_active.as_array().map_or(false, |a| a.is_empty())
990            }
991        }
992    }
993}
994
995/// Helper to construct and merge the `data` property of a `ValidationError`.
996///
997/// Populates:
998/// - Field metadata: `title` and `description` from the field schema
999/// - Rule constraints: `minValue`, `maxValue`, `minLength`, `maxLength` for the failing rule and companion rules
1000/// - Merged schema evaluation data from `rule_data`
1001fn build_error_data(
1002    rule_name: &str,
1003    rule_active: &Value,
1004    rule_data: Option<Value>,
1005    schema_map: &serde_json::Map<String, Value>,
1006) -> Option<Value> {
1007    let mut data_map = serde_json::Map::new();
1008
1009    // 1. Field metadata (title and description)
1010    if let Some(title) = schema_map.get("title") {
1011        if !title.is_null() {
1012            data_map.insert("title".to_string(), title.clone());
1013        }
1014    }
1015    if let Some(description) = schema_map.get("description") {
1016        if !description.is_null() {
1017            data_map.insert("description".to_string(), description.clone());
1018        }
1019    }
1020
1021    let get_rule_val = |r: &Value| -> Option<Value> {
1022        match r {
1023            Value::Object(obj) => obj.get("value").cloned(),
1024            other if !other.is_null() => Some(other.clone()),
1025            _ => None,
1026        }
1027    };
1028
1029    // 2. Error rule constraints based on the failing rule
1030    match rule_name {
1031        "required" => {
1032            data_map.insert("required".to_string(), Value::Bool(true));
1033        }
1034        "minValue" => {
1035            data_map.insert("minValue".to_string(), rule_active.clone());
1036        }
1037        "maxValue" => {
1038            data_map.insert("maxValue".to_string(), rule_active.clone());
1039        }
1040        "minLength" => {
1041            data_map.insert("minLength".to_string(), rule_active.clone());
1042        }
1043        "maxLength" => {
1044            data_map.insert("maxLength".to_string(), rule_active.clone());
1045        }
1046        _ => {}
1047    }
1048
1049    // 3. Companion rules from schema_map["rules"]
1050    if matches!(
1051        rule_name,
1052        "minValue" | "maxValue" | "minLength" | "maxLength"
1053    ) {
1054        if let Some(Value::Object(rules)) = schema_map.get("rules") {
1055            match rule_name {
1056                "minValue" => {
1057                    if let Some(max_rule) = rules.get("maxValue") {
1058                        if let Some(val) = get_rule_val(max_rule) {
1059                            data_map.insert("maxValue".to_string(), val);
1060                        }
1061                    }
1062                }
1063                "maxValue" => {
1064                    if let Some(min_rule) = rules.get("minValue") {
1065                        if let Some(val) = get_rule_val(min_rule) {
1066                            data_map.insert("minValue".to_string(), val);
1067                        }
1068                    }
1069                }
1070                "minLength" => {
1071                    if let Some(max_rule) = rules.get("maxLength") {
1072                        if let Some(val) = get_rule_val(max_rule) {
1073                            data_map.insert("maxLength".to_string(), val);
1074                        }
1075                    }
1076                }
1077                "maxLength" => {
1078                    if let Some(min_rule) = rules.get("minLength") {
1079                        if let Some(val) = get_rule_val(min_rule) {
1080                            data_map.insert("minLength".to_string(), val);
1081                        }
1082                    }
1083                }
1084                _ => {}
1085            }
1086        }
1087    }
1088
1089    // 4. Merge schema evaluation data from rule_data
1090    if let Some(rule_d) = rule_data {
1091        if let Value::Object(schema_data) = rule_d {
1092            for (k, v) in schema_data {
1093                data_map.insert(k, v);
1094            }
1095        } else if data_map.is_empty() {
1096            return Some(rule_d);
1097        }
1098    }
1099
1100    if data_map.is_empty() {
1101        None
1102    } else {
1103        Some(Value::Object(data_map))
1104    }
1105}