Skip to main content

json_eval_rs/jsoneval/
getters.rs

1use super::JSONEval;
2use crate::jsoneval::path_utils;
3use crate::jsoneval::types::{ResolvedLayoutResult, ReturnFormat};
4use crate::time_block;
5use crate::utils::clean_float_noise_scalar;
6use serde_json::Value;
7use std::sync::Arc;
8
9impl JSONEval {
10    /// Check if a field is effectively hidden by checking its condition and all parents
11    /// Also checks for $layout.hideLayout.all on parents
12    pub(crate) fn is_effective_hidden(&self, schema_pointer: &str) -> bool {
13        let schema_pointer = schema_pointer.trim_start_matches('#');
14        if self.layout_hidden_refs.iter().any(|hidden_ref| {
15            schema_pointer == hidden_ref
16                || schema_pointer
17                    .strip_prefix(hidden_ref)
18                    .is_some_and(|suffix| {
19                        suffix.starts_with("/properties/") || suffix.starts_with("/items/")
20                    })
21        }) {
22            return true;
23        }
24
25        let mut end = schema_pointer.len();
26
27        loop {
28            let current_path = &schema_pointer[..end];
29
30            if let Some(schema_node) = self.evaluated_schema.pointer(current_path) {
31                if let Value::Object(map) = schema_node {
32                    if let Some(Value::Object(condition)) = map.get("condition") {
33                        if let Some(Value::Bool(true)) = condition.get("hidden") {
34                            return true;
35                        }
36                    }
37
38                    if let Some(Value::Object(layout)) = map.get("$layout") {
39                        if let Some(Value::Object(hide_layout)) = layout.get("hideLayout") {
40                            if let Some(Value::Bool(true)) = hide_layout.get("all") {
41                                return true;
42                            }
43                        }
44                    }
45                }
46            }
47
48            if end == 0 {
49                break;
50            }
51
52            // Move to parent: find last '/' and strip /properties or /items suffixes
53            match schema_pointer[..end].rfind('/') {
54                Some(0) | None => {
55                    end = 0;
56                }
57                Some(last_slash) => {
58                    end = last_slash;
59                    let parent = &schema_pointer[..end];
60                    if parent.ends_with("/properties") {
61                        end -= "/properties".len();
62                    } else if parent.ends_with("/items") {
63                        end -= "/items".len();
64                    }
65                }
66            }
67        }
68
69        false
70    }
71
72    /// Prune hidden values from data object recursively
73    fn prune_hidden_values(&self, data: &mut Value, current_path: &str) {
74        if let Value::Object(map) = data {
75            // Collect keys to remove to avoid borrow checker issues
76            let mut keys_to_remove = Vec::new();
77
78            for (key, value) in map.iter_mut() {
79                // Skip special keys
80                if key == "$params" || key == "$context" {
81                    continue;
82                }
83
84                // Construct schema path for this key
85                // For root fields: /properties/key
86                // For nested fields: current_path/properties/key
87                let schema_path = if current_path.is_empty() {
88                    format!("/properties/{}", key)
89                } else {
90                    format!("{}/properties/{}", current_path, key)
91                };
92
93                // Check if hidden
94                if self.is_effective_hidden(&schema_path) {
95                    keys_to_remove.push(key.clone());
96                } else {
97                    // Recurse if object
98                    if value.is_object() {
99                        self.prune_hidden_values(value, &schema_path);
100                    }
101                }
102            }
103
104            // Remove hidden keys
105            for key in keys_to_remove {
106                map.remove(&key);
107            }
108        }
109    }
110
111    /// Replace any `{"$static_array": "/$table/..."}` and `{"$static_array": "/$params/..."}` markers in `schema_output`
112    /// with the actual evaluated array data from `eval_data`.
113    ///
114    /// By iterating only over tracked `static_arrays`, we replace markers in O(markers) time
115    /// instead of requiring an expensive O(schema_nodes) recursive tree walk.
116    fn resolve_static_markers_in_value(&self, schema_output: &mut Value) {
117        for (static_key, array_arc) in self.static_arrays.iter() {
118            // Determine the schema pointer path where this marker was placed
119            let schema_path = if static_key.starts_with("/$table") {
120                &static_key["/$table".len()..] // e.g. /properties/product_benefit/...
121            } else {
122                static_key.as_str() // e.g. /$params/references/...
123            };
124
125            // Only attempt replacement if the exact path exists in the cloned schema output
126            if let Some(target_val) = schema_output.pointer_mut(schema_path) {
127                // The actual evaluated array is seamlessly stored right in the map's value
128                *target_val = (**array_arc).clone();
129            }
130        }
131    }
132
133    /// Get the evaluated schema (compact — $ref intact, no layout expansion).
134    ///
135    /// # Returns
136    ///
137    /// The evaluated schema as a JSON value, with all `$static_array` markers resolved
138    /// to their actual evaluated data.
139    pub fn get_evaluated_schema(&mut self) -> Value {
140        time_block!("get_evaluated_schema()", {
141            let mut schema = self.evaluated_schema.clone();
142            self.resolve_static_markers_in_value(&mut schema);
143            schema
144        })
145    }
146
147    /// Get layout overlay entries — the delta properties per layout element.
148    /// Consumer merges these into compact schema to get fully resolved layout.
149    pub fn get_resolved_layout(&mut self) -> ResolvedLayoutResult {
150        time_block!("get_resolved_layout()", {
151            // Check cache
152            if let Some(ref cached) = self.resolved_layout_cache {
153                return cached.as_ref().clone();
154            }
155            // Resolve and cache
156            let result = match self.resolve_layout(false) {
157                Ok(entries) => entries,
158                Err(e) => {
159                    eprintln!("Warning: Layout resolution failed: {}", e);
160                    Vec::new()
161                }
162            };
163            self.resolved_layout_cache = Some(Arc::new(result.clone()));
164            result
165        })
166    }
167
168    /// Get evaluated schema with layout overlays already applied.
169    /// Convenience: returns compact schema + overlays merged.
170    ///
171    /// Two-pass approach to handle nested elements:
172    /// 1. First pass: resolve $ref and apply overlay for entries whose target
173    ///    path exists in the compact schema (top-level elements).
174    /// 2. Second pass: apply overlay-only for entries whose path appears
175    ///    after parent $ref resolution (nested elements).
176    pub fn get_evaluated_schema_resolved(&mut self) -> Value {
177        time_block!("get_evaluated_schema_resolved()", {
178            let mut schema = self.evaluated_schema.clone();
179            let overlays = self.get_resolved_layout();
180
181            struct ResolveEntry {
182                layout_path: String,
183                element_idx: usize,
184                overlay: indexmap::IndexMap<String, Value>,
185            }
186
187            let mut entries: Vec<ResolveEntry> = overlays
188                .iter()
189                .map(|entry| {
190                    let layout_path =
191                        path_utils::normalize_to_json_pointer(&entry.layout_path).into_owned();
192                    ResolveEntry {
193                        layout_path,
194                        element_idx: entry.element_idx,
195                        overlay: entry.overlay.clone(),
196                    }
197                })
198                .collect();
199            drop(overlays);
200
201            // Sort entries shallow-first so parent elements are expanded before their children.
202            // Child entries (e.g. layout_path = ".../elements/1/elements") depend on the parent
203            // ("…/elements") being resolved first so the nested `elements` array exists in `schema`.
204            entries.sort_by(|a, b| {
205                let depth_a = a.layout_path.matches('/').count();
206                let depth_b = b.layout_path.matches('/').count();
207                depth_a
208                    .cmp(&depth_b)
209                    .then_with(|| a.element_idx.cmp(&b.element_idx))
210            });
211
212            // ── Phase 2 (mutable): resolve $ref + apply overlays (parent-first order) ──
213            // Entries are sorted shallowest layout_path first, so parent elements are
214            // expanded before any child entries that path through them.
215            for entry in entries {
216                // Resolve $ref from the current (already partially mutated) schema so that
217                // parent expansions are visible when we process child entries.
218                let resolved_value: Option<Value> = (|| -> Option<Value> {
219                    let arr = schema.pointer(&entry.layout_path)?.as_array()?;
220                    let element = arr.get(entry.element_idx)?;
221                    let ref_str = element.get("$ref")?.as_str()?;
222
223                    let ref_pointer = if ref_str.starts_with('#') || ref_str.starts_with('/') {
224                        path_utils::normalize_to_json_pointer(ref_str).into_owned()
225                    } else {
226                        let schema_pointer = path_utils::dot_notation_to_schema_pointer(ref_str);
227                        let normalized =
228                            path_utils::normalize_to_json_pointer(&schema_pointer).into_owned();
229                        if schema.pointer(&normalized).is_some() {
230                            normalized
231                        } else {
232                            format!("/properties/{}", ref_str.replace('.', "/properties/"))
233                        }
234                    };
235
236                    let mut resolved = schema.pointer(&ref_pointer)?.clone();
237
238                    // Flatten $layout into top level
239                    if let Value::Object(ref mut resolved_map) = resolved {
240                        if let Some(Value::Object(layout_obj)) = resolved_map.remove("$layout") {
241                            let mut result = layout_obj;
242                            for (key, value) in resolved_map.clone().into_iter() {
243                                if key != "type" || !result.contains_key("type") {
244                                    result.insert(key, value);
245                                }
246                            }
247                            resolved = Value::Object(result);
248                        }
249                    }
250
251                    Some(resolved)
252                })();
253
254                if let Some(Value::Array(arr)) = schema.pointer_mut(&entry.layout_path) {
255                    if entry.element_idx < arr.len() {
256                        let element = &mut arr[entry.element_idx];
257
258                        // Apply $ref resolution
259                        if let Some(resolved) = resolved_value {
260                            if let Value::Object(mut resolved_map) = resolved {
261                                if let Value::Object(mut map) = element.take() {
262                                    map.remove("$ref");
263                                    for (key, value) in map {
264                                        resolved_map.insert(key, value);
265                                    }
266                                }
267                                *element = Value::Object(resolved_map);
268                            } else {
269                                *element = resolved;
270                            }
271                        }
272
273                        // Apply overlay on top
274                        if let Value::Object(ref mut map) = element {
275                            for (k, v) in &entry.overlay {
276                                map.insert(k.clone(), v.clone());
277                            }
278                        }
279                    }
280                }
281            }
282
283            self.resolve_static_markers_in_value(&mut schema);
284            schema
285        })
286    }
287
288    /// Resolve `$static_array` markers within the subtree rooted at `schema_prefix`.
289    ///
290    /// Clones only the node at `schema_prefix` from `evaluated_schema`, then iterates
291    /// the tracked `static_arrays` list filtering to entries whose schema path is at or
292    /// under `schema_prefix`. Only those markers are replaced inside the cloned subtree;
293    /// unrelated entries are skipped entirely.
294    ///
295    /// # Examples
296    /// - `schema_prefix = "/$params/references"` → resolves only arrays nested under that key
297    /// - `schema_prefix = "/properties/foo/value"` → resolves a single marker if the field itself is one
298    fn resolve_static_markers_at_path(&self, schema_prefix: &str) -> Option<Value> {
299        let mut subtree = self.evaluated_schema.pointer(schema_prefix)?.clone();
300
301        // Pre-build "prefix/" once for the starts_with check in the loop
302        let prefix_slash = format!("{}/", schema_prefix);
303
304        for (static_key, array_arc) in self.static_arrays.iter() {
305            // Derive the absolute schema path the same way resolve_static_markers_in_value does
306            let schema_path: &str = if static_key.starts_with("/$table") {
307                &static_key["/$table".len()..]
308            } else {
309                static_key.as_str()
310            };
311
312            // Compute the path relative to the subtree root
313            let relative: &str = if schema_path == schema_prefix {
314                // The subtree root itself is the marker — replace the whole subtree
315                ""
316            } else if schema_path.starts_with(&prefix_slash) {
317                // Strip the prefix: remainder is the sub-path within the cloned subtree
318                &schema_path[schema_prefix.len()..]
319            } else {
320                continue; // Not under the requested path — skip
321            };
322
323            if relative.is_empty() {
324                subtree = (**array_arc).clone();
325            } else if let Some(target) = subtree.pointer_mut(relative) {
326                *target = (**array_arc).clone();
327            }
328        }
329
330        Some(subtree)
331    }
332
333    /// Get specific schema value by path, resolving any `$static_array` markers at or
334    /// under that path.
335    pub fn get_schema_value_by_path(&self, path: &str) -> Option<Value> {
336        let pointer_path = path_utils::dot_notation_to_schema_pointer(path);
337        self.resolve_static_markers_at_path(pointer_path.trim_start_matches('#'))
338    }
339
340    /// Get all schema values (data view)
341    /// Mutates internal data state by overriding with values from value evaluations
342    /// This corresponds to subform.get_schema_value() usage
343    pub fn get_schema_value(&mut self) -> Value {
344        // Start with current authoritative data from eval_data
345        let mut current_data = self.eval_data.data().clone();
346
347        // Ensure it's an object
348        if !current_data.is_object() {
349            current_data = Value::Object(serde_json::Map::new());
350        }
351
352        // Strip $params and $context from data
353        if let Some(obj) = current_data.as_object_mut() {
354            obj.remove("$params");
355            obj.remove("$context");
356        }
357
358        // Prune hidden values from current_data (to remove user input in hidden fields)
359        self.prune_hidden_values(&mut current_data, "");
360
361        // Override data with values from value evaluations
362        // We use value_evaluations which stores the paths of fields with .value
363        for eval_key in self.value_evaluations.iter() {
364            let clean_key = eval_key.strip_prefix('#').unwrap_or(eval_key);
365
366            // Exclude rules.*.value, options.*.value, and $params
367            if clean_key.starts_with("/$params")
368                || (clean_key.ends_with("/value")
369                    && (clean_key.contains("/rules/") || clean_key.contains("/options/")))
370            {
371                continue;
372            }
373
374            let path = clean_key.replace("/properties", "").replace("/value", "");
375
376            // Check if field is effectively hidden
377            // Schema path is clean_key without /value
378            let schema_path = clean_key.strip_suffix("/value").unwrap_or(&clean_key);
379            if self.is_effective_hidden(schema_path) {
380                continue;
381            }
382
383            // Resolve static markers at this specific pointer (handles markers at or under this path)
384            let value = match self.resolve_static_markers_at_path(clean_key) {
385                Some(v) => v,
386                None => continue,
387            };
388
389            // Parse the path and create nested structure as needed
390            let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
391
392            if path_parts.is_empty() {
393                continue;
394            }
395
396            // Navigate/create nested structure
397            let mut current = &mut current_data;
398            for (i, part) in path_parts.iter().enumerate() {
399                let is_last = i == path_parts.len() - 1;
400
401                if is_last {
402                    // Set the value at the final key
403                    if let Some(obj) = current.as_object_mut() {
404                        let should_update = match obj.get(*part) {
405                            Some(v) => v.is_null(),
406                            None => true,
407                        };
408
409                        if should_update {
410                            obj.insert(
411                                (*part).to_string(),
412                                crate::utils::clean_float_noise(value.clone()),
413                            );
414                        }
415                    }
416                } else {
417                    // Ensure current is an object, then navigate/create intermediate objects
418                    if let Some(obj) = current.as_object_mut() {
419                        if !obj.contains_key(*part) {
420                            obj.insert((*part).to_string(), Value::Object(serde_json::Map::new()));
421                        }
422
423                        current = obj.get_mut(*part).unwrap();
424                    } else {
425                        // Skip this path if current is not an object and can't be made into one
426                        break;
427                    }
428                }
429            }
430        }
431
432        // Update self.data to persist the view changes (matching backup behavior)
433        self.data = current_data.clone();
434
435        crate::utils::clean_float_noise(current_data)
436    }
437
438    /// Get all schema values as array of path-value pairs
439    /// Returns [{path: "", value: ""}, ...]
440    ///
441    /// # Returns
442    ///
443    /// Array of objects containing path (dotted notation) and value pairs from value evaluations
444    pub fn get_schema_value_array(&self) -> Value {
445        let mut result = Vec::new();
446
447        for eval_key in self.value_evaluations.iter() {
448            let clean_key = eval_key.strip_prefix('#').unwrap_or(eval_key);
449
450            // Exclude rules.*.value, options.*.value, and $params
451            if clean_key.starts_with("/$params")
452                || (clean_key.ends_with("/value")
453                    && (clean_key.contains("/rules/") || clean_key.contains("/options/")))
454            {
455                continue;
456            }
457
458            // Check if field is effectively hidden
459            let schema_path = clean_key.strip_suffix("/value").unwrap_or(&clean_key);
460            if self.is_effective_hidden(schema_path) {
461                continue;
462            }
463
464            // Convert JSON pointer to dotted notation
465            let dotted_path = clean_key
466                .replace("/properties", "")
467                .replace("/value", "")
468                .trim_start_matches('/')
469                .replace('/', ".");
470
471            if dotted_path.is_empty() {
472                continue;
473            }
474
475            // Resolve static markers at this specific pointer (handles markers at or under this path)
476            let value = match self.resolve_static_markers_at_path(clean_key) {
477                Some(v) => crate::utils::clean_float_noise(v),
478                None => continue,
479            };
480
481            // Create {path, value} object
482            let mut item = serde_json::Map::new();
483            item.insert("path".to_string(), Value::String(dotted_path));
484            item.insert("value".to_string(), value);
485            result.push(Value::Object(item));
486        }
487
488        Value::Array(result)
489    }
490
491    /// Get all schema values as object with dotted path keys
492    /// Returns {path: value, ...}
493    ///
494    /// # Returns
495    ///
496    /// Flat object with dotted notation paths as keys and evaluated values
497    pub fn get_schema_value_object(&self) -> Value {
498        let mut result = serde_json::Map::new();
499
500        for eval_key in self.value_evaluations.iter() {
501            let clean_key = eval_key.strip_prefix('#').unwrap_or(eval_key);
502
503            // Exclude rules.*.value, options.*.value, and $params
504            if clean_key.starts_with("/$params")
505                || (clean_key.ends_with("/value")
506                    && (clean_key.contains("/rules/") || clean_key.contains("/options/")))
507            {
508                continue;
509            }
510
511            // Check if field is effectively hidden
512            let schema_path = clean_key.strip_suffix("/value").unwrap_or(&clean_key);
513            if self.is_effective_hidden(schema_path) {
514                continue;
515            }
516
517            // Convert JSON pointer to dotted notation
518            let dotted_path = clean_key
519                .replace("/properties", "")
520                .replace("/value", "")
521                .trim_start_matches('/')
522                .replace('/', ".");
523
524            if dotted_path.is_empty() {
525                continue;
526            }
527
528            // Resolve static markers at this specific pointer (handles markers at or under this path)
529            let value = match self.resolve_static_markers_at_path(clean_key) {
530                Some(v) => crate::utils::clean_float_noise(v),
531                None => continue,
532            };
533
534            result.insert(dotted_path, value);
535        }
536
537        Value::Object(result)
538    }
539
540    /// Get evaluated schema without $params
541    pub fn get_evaluated_schema_without_params(&mut self) -> Value {
542        let mut schema = self.get_evaluated_schema();
543        if let Value::Object(ref mut map) = schema {
544            map.remove("$params");
545        }
546        schema
547    }
548
549    /// Get evaluated schema as MessagePack bytes
550    pub fn get_evaluated_schema_msgpack(&mut self) -> Result<Vec<u8>, String> {
551        let schema = self.get_evaluated_schema();
552        rmp_serde::to_vec(&schema).map_err(|e| format!("MessagePack serialization failed: {}", e))
553    }
554
555    /// Get value from evaluated schema by path
556    pub fn get_evaluated_schema_by_path(&mut self, path: &str) -> Option<Value> {
557        self.get_schema_value_by_path(path)
558    }
559
560    /// Get evaluated schema parts by multiple paths
561    pub fn get_evaluated_schema_by_paths(
562        &mut self,
563        paths: &[String],
564        format: Option<ReturnFormat>,
565    ) -> Value {
566        match format.unwrap_or(ReturnFormat::Nested) {
567            ReturnFormat::Nested => {
568                let mut result = Value::Object(serde_json::Map::new());
569                for path in paths {
570                    if let Some(val) = self.get_schema_value_by_path(path) {
571                        // Insert into result object at proper path nesting
572                        Self::insert_at_path(&mut result, path, val);
573                    }
574                }
575                result
576            }
577            ReturnFormat::Flat => {
578                let mut result = serde_json::Map::new();
579                for path in paths {
580                    if let Some(val) = self.get_schema_value_by_path(path) {
581                        result.insert(path.clone(), val);
582                    }
583                }
584                Value::Object(result)
585            }
586            ReturnFormat::Array => {
587                let mut result = Vec::new();
588                for path in paths {
589                    if let Some(val) = self.get_schema_value_by_path(path) {
590                        result.push(val);
591                    } else {
592                        result.push(Value::Null);
593                    }
594                }
595                Value::Array(result)
596            }
597        }
598    }
599
600    /// Get original (unevaluated) schema by path
601    pub fn get_schema_by_path(&self, path: &str) -> Option<Value> {
602        let pointer_path = path_utils::dot_notation_to_schema_pointer(path);
603        self.schema
604            .pointer(&pointer_path.trim_start_matches('#'))
605            .cloned()
606    }
607
608    /// Get original schema by multiple paths
609    pub fn get_schema_by_paths(&self, paths: &[String], format: Option<ReturnFormat>) -> Value {
610        match format.unwrap_or(ReturnFormat::Nested) {
611            ReturnFormat::Nested => {
612                let mut result = Value::Object(serde_json::Map::new());
613                for path in paths {
614                    if let Some(val) = self.get_schema_by_path(path) {
615                        Self::insert_at_path(&mut result, path, val);
616                    }
617                }
618                result
619            }
620            ReturnFormat::Flat => {
621                let mut result = serde_json::Map::new();
622                for path in paths {
623                    if let Some(val) = self.get_schema_by_path(path) {
624                        result.insert(path.clone(), val);
625                    }
626                }
627                Value::Object(result)
628            }
629            ReturnFormat::Array => {
630                let mut result = Vec::new();
631                for path in paths {
632                    if let Some(val) = self.get_schema_by_path(path) {
633                        result.push(val);
634                    } else {
635                        result.push(Value::Null);
636                    }
637                }
638                Value::Array(result)
639            }
640        }
641    }
642
643    /// Helper to insert value into nested object at dotted path
644    pub(crate) fn insert_at_path(root: &mut Value, path: &str, value: Value) {
645        let parts: Vec<&str> = path.split('.').collect();
646        let mut current = root;
647
648        for (i, part) in parts.iter().enumerate() {
649            if i == parts.len() - 1 {
650                // Last part - set value
651                if let Value::Object(map) = current {
652                    map.insert(part.to_string(), value);
653                    return; // Done
654                }
655            } else {
656                // Intermediate part - traverse or create
657                // We need to temporarily take the value or use raw pointer manipulation?
658                // serde_json pointer is read-only or requires mutable reference
659
660                if !current.is_object() {
661                    *current = Value::Object(serde_json::Map::new());
662                }
663
664                if let Value::Object(map) = current {
665                    if !map.contains_key(*part) {
666                        map.insert(part.to_string(), Value::Object(serde_json::Map::new()));
667                    }
668                    current = map.get_mut(*part).unwrap();
669                }
670            }
671        }
672    }
673
674    /// Flatten a nested object key-value pair to dotted keys
675    pub fn flatten_object(
676        prefix: &str,
677        value: &Value,
678        result: &mut serde_json::Map<String, Value>,
679    ) {
680        match value {
681            Value::Object(map) => {
682                for (k, v) in map {
683                    let new_key = if prefix.is_empty() {
684                        k.clone()
685                    } else {
686                        format!("{}.{}", prefix, k)
687                    };
688                    Self::flatten_object(&new_key, v, result);
689                }
690            }
691            _ => {
692                result.insert(prefix.to_string(), value.clone());
693            }
694        }
695    }
696
697    pub fn convert_to_format(value: Value, format: ReturnFormat) -> Value {
698        match format {
699            ReturnFormat::Nested => value,
700            ReturnFormat::Flat => {
701                let mut result = serde_json::Map::new();
702                Self::flatten_object("", &value, &mut result);
703                Value::Object(result)
704            }
705            ReturnFormat::Array => {
706                if let Value::Object(map) = value {
707                    Value::Array(map.values().cloned().collect())
708                } else if let Value::Array(arr) = value {
709                    Value::Array(arr)
710                } else {
711                    Value::Array(vec![value])
712                }
713            }
714        }
715    }
716
717    /// Evaluate and return the options for a specific field on demand.
718    ///
719    /// Accepts dotted notation (`form.occupation`), JSON pointer
720    /// (`/properties/form/properties/occupation`), or schema ref
721    /// (`#/properties/form/properties/occupation`).
722    ///
723    /// Returns `None` when the field does not have an `options` key.
724    /// Returns the resolved options value (array, URL string, or null) otherwise.
725    pub fn get_field_options(&mut self, field_path: &str) -> Option<Value> {
726        // Normalize the input to a schema pointer (e.g. #/properties/form/properties/occupation)
727        let schema_ptr = if field_path.starts_with('#') || field_path.starts_with('/') {
728            path_utils::normalize_to_json_pointer(field_path).into_owned()
729        } else {
730            path_utils::dot_notation_to_schema_pointer(field_path)
731        };
732
733        // Build the JSON pointer path to the /options node (strip leading # for serde pointer())
734        let options_schema_key = format!("{}/options", schema_ptr);
735        let options_pointer =
736            path_utils::normalize_to_json_pointer(&options_schema_key).into_owned();
737
738        // Check if the options node exists in the evaluated schema
739        let options_node = self.evaluated_schema.pointer(&options_pointer)?.clone();
740
741        // If the options node is an object with $evaluation, evaluate it now (deferred)
742        if let Value::Object(ref map) = options_node {
743            if map.contains_key("$evaluation") {
744                let eval_key = options_schema_key.clone();
745
746                if let Some(logic_id) = self.evaluations.get(&eval_key).copied() {
747                    let snap = self.eval_data.snapshot_data();
748                    if let Ok(result) = self.engine.run(&logic_id, &*snap) {
749                        let cleaned = clean_float_noise_scalar(result);
750                        if let Some(node) = self.evaluated_schema.pointer_mut(&options_pointer) {
751                            *node = cleaned.clone();
752                        }
753                        return Some(cleaned);
754                    }
755                }
756                // No compiled logic found — options cannot be resolved
757                return None;
758            }
759        }
760
761        // Check options_templates for a URL template at this field's options/url path
762        let url_pointer =
763            path_utils::normalize_to_json_pointer(&format!("{}/options/url", schema_ptr))
764                .into_owned();
765
766        let templates = self.options_templates.clone();
767        for (tmpl_url_path, tmpl_str, tmpl_params_path) in templates.iter() {
768            if *tmpl_url_path == url_pointer {
769                if let Some(params) = self.evaluated_schema.pointer(tmpl_params_path) {
770                    let params = params.clone();
771                    if let Ok(resolved_url) = self.evaluate_template(tmpl_str, &params) {
772                        if let Some(target) = self.evaluated_schema.pointer_mut(&url_pointer) {
773                            *target = Value::String(resolved_url);
774                        }
775                        return self.evaluated_schema.pointer(&options_pointer).cloned();
776                    }
777                }
778                break;
779            }
780        }
781
782        // Static options (already-evaluated array or plain value)
783        Some(options_node)
784    }
785}