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