Skip to main content

json_eval_rs/jsoneval/
layout.rs

1use super::JSONEval;
2use crate::jsoneval::path_utils;
3use crate::jsoneval::types::{LayoutOverlayEntry, ResolvedLayoutResult};
4use crate::time_block;
5
6use indexmap::IndexMap;
7use serde_json::Value;
8
9impl JSONEval {
10    /// Resolve layout references, return overlay entries.
11    ///
12    /// Unlike old version: does NOT mutate evaluated_schema.
13    /// Returns list of overlay entries describing delta properties per element.
14    ///
15    /// # Arguments
16    ///
17    /// * `evaluate` - If true, runs evaluation before resolving layout.
18    pub fn resolve_layout(&mut self, evaluate: bool) -> Result<ResolvedLayoutResult, String> {
19        if evaluate {
20            let data_str = serde_json::to_string(&self.data)
21                .map_err(|e| format!("Failed to serialize data: {}", e))?;
22            self.evaluate(&data_str, None, None, None)?;
23        }
24
25        Ok(self.resolve_layout_internal())
26    }
27
28    fn resolve_layout_internal(&mut self) -> ResolvedLayoutResult {
29        time_block!("  resolve_layout_internal()", {
30            let layout_paths = self.layout_paths.clone();
31            let mut all_entries = ResolvedLayoutResult::new();
32
33            // Phase 1: Collect resolved elements (tree) for each layout path
34            // Stores in temp Vec so we don't mutate evaluated_schema
35            time_block!("    resolve_layout_elements", {
36                for layout_path in layout_paths.iter() {
37                    let resolved_tree = self.resolve_elements_tree(layout_path);
38                    let entries = Self::tree_to_overlays(&resolved_tree, layout_path, false, false);
39                    all_entries.extend(entries);
40                }
41            });
42
43            // Phase 2: Hidden paths from parent cascade → schema definitions
44            time_block!("    sync_layout_hidden_to_schema", {
45                self.sync_layout_hidden_to_schema(&all_entries);
46            });
47
48            all_entries
49        })
50    }
51
52    // ── Phase 1 helpers ─────────────────────────────────────────────
53
54    /// Resolve $ref in elements tree, return full resolved tree (no parent cascade yet).
55    /// Returns Vec<(resolved_element, schema_ref_path)> — one per element at this level.
56    fn resolve_elements_tree(&self, layout_elements_path: &str) -> Vec<(Value, String)> {
57        let normalized_path = path_utils::normalize_to_json_pointer(layout_elements_path);
58
59        let elements = if let Some(Value::Array(arr)) = self.schema.pointer(&normalized_path) {
60            arr.clone()
61        } else {
62            return Vec::new();
63        };
64
65        let mut result = Vec::with_capacity(elements.len());
66        for element in elements.into_iter() {
67            let (resolved, schema_ref) = self.resolve_element_ref_recursive(element, "");
68            result.push((resolved, schema_ref));
69        }
70        result
71    }
72
73    /// Resolve an element's $ref recursively, returning (resolved_element, schema_ref_path).
74    /// schema_ref_path is the outermost $ref target (empty if no $ref).
75    ///
76    /// The resolved element is a "full tree" — $ref expanded, metadata injected,
77    /// nested elements arrays also resolved. Used for parent condition cascade.
78    fn resolve_element_ref_recursive(&self, element: Value, _path_context: &str) -> (Value, String) {
79        // Resolve this element's $ref
80        let (mut resolved, ref_path) = self.resolve_element_ref(element);
81
82        // Recursively resolve nested elements
83        if let Value::Object(ref mut map) = resolved {
84            if let Some(Value::Array(elements)) = map.get("elements").cloned() {
85                let mut resolved_nested = Vec::with_capacity(elements.len());
86                for nested_element in elements.into_iter() {
87                    let (nested_resolved, _) =
88                        self.resolve_element_ref_recursive(nested_element, "");
89                    resolved_nested.push(nested_resolved);
90                }
91                map.insert("elements".to_string(), Value::Array(resolved_nested));
92            }
93        }
94
95        (resolved, ref_path)
96    }
97
98    /// Resolve $ref in a single element. Returns (resolved_element, schema_ref_path).
99    /// Does NOT recurse into nested elements.
100    fn resolve_element_ref(&self, element: Value) -> (Value, String) {
101        match element {
102            Value::Object(mut map) => {
103                let has_ref = map.get("$ref").is_some();
104                let ref_path = if has_ref {
105                    map.get("$ref").and_then(Value::as_str).unwrap_or("").to_string()
106                } else {
107                    String::new()
108                };
109
110                if let Some(Value::String(ref_str)) = map.get("$ref").cloned() {
111                    // Resolve the $ref to an actual schema pointer first
112                    let normalized_path = if ref_str.starts_with('#') || ref_str.starts_with('/') {
113                        path_utils::normalize_to_json_pointer(&ref_str).into_owned()
114                    } else {
115                        let schema_pointer =
116                            path_utils::dot_notation_to_schema_pointer(&ref_str);
117                        let schema_path =
118                            path_utils::normalize_to_json_pointer(&schema_pointer).into_owned();
119
120                        if self.evaluated_schema.pointer(&schema_path).is_some() {
121                            schema_path
122                        } else {
123                            format!("/properties/{}", ref_str.replace('.', "/properties/"))
124                        }
125                    };
126
127                    // Build $fullpath from the actual resolved pointer (not the raw $ref string).
128                    // This ensures $fullpath always reflects the true schema field path.
129                    let dotted_path = path_utils::pointer_to_dot_notation(&normalized_path);
130                    let last_segment = dotted_path.split('.').last().unwrap_or(&dotted_path);
131
132                    map.insert("$fullpath".to_string(), Value::String(dotted_path.clone()));
133                    map.insert("$path".to_string(), Value::String(last_segment.to_string()));
134                    map.insert("$parentHide".to_string(), Value::Bool(false));
135
136                    if let Some(referenced_value) = self.evaluated_schema.pointer(&normalized_path) {
137                        let resolved = referenced_value.clone();
138
139                        if let Value::Object(mut resolved_map) = resolved {
140                            map.remove("$ref");
141
142                            if let Some(Value::Object(layout_obj)) = resolved_map.remove("$layout") {
143                                let mut result = layout_obj.clone();
144                                for (key, value) in resolved_map {
145                                    if key != "type" || !result.contains_key("type") {
146                                        result.insert(key, value);
147                                    }
148                                }
149                                // Ensure $fullpath from the map (actual ref path) wins
150                                for (key, value) in map {
151                                    result.insert(key, value);
152                                }
153                                return (Value::Object(result), dotted_path);
154                            } else {
155                                for (key, value) in map {
156                                    resolved_map.insert(key, value);
157                                }
158                                return (Value::Object(resolved_map), dotted_path);
159                            }
160                        } else {
161                            return (resolved, dotted_path);
162                        }
163                    }
164                }
165
166                (Value::Object(map), ref_path)
167            }
168            _ => (element, String::new()),
169        }
170    }
171
172    /// Convert a resolved elements tree into flat overlay entries with parent condition cascade.
173    ///
174    /// Walks the tree recursively. At each level:
175    ///   1. Determine parent state (hidden/disabled propagated from above)
176    ///   2. Merge element's own condition with inherited state
177    ///   3. Emit one LayoutOverlayEntry per element with full overlay delta
178    ///
179    /// The `base_ref_path` is the parent's `schema_ref_path` (empty for root).
180    fn tree_to_overlays(
181        tree: &[(Value, String)],
182        layout_path: &str,
183        parent_hidden: bool,
184        parent_disabled: bool,
185    ) -> ResolvedLayoutResult {
186        let mut entries = ResolvedLayoutResult::new();
187
188        for (idx, (element, ref_path)) in tree.iter().enumerate() {
189            let element_idx = idx;
190            let mut overlay = IndexMap::new();
191
192            // ── Build thin overlay: only delta props (skip structural keys) ──
193            if let Value::Object(map) = element {
194                // Keys that should NOT appear in overlay:
195                // - $ref: handled by compact schema
196                // - elements: handled by child overlays
197                // - properties/items/required: schema structure, not layout
198                const EXCLUDED: &[&str] = &["$ref", "elements", "properties", "items", "required", "additionalProperties"];
199                for (key, value) in map {
200                    if !EXCLUDED.contains(&key.as_str()) {
201                        overlay.insert(key.clone(), value.clone());
202                    }
203                }
204
205                // ── Inject $fullpath for ALL elements (ref and non-ref) ──
206                if !overlay.contains_key("$fullpath") {
207                    if !ref_path.is_empty() {
208                        // $ref element: use the actual schema ref target dotted path
209                        let last_segment = ref_path.split('.').last().unwrap_or(ref_path);
210                        overlay.insert("$fullpath".to_string(), Value::String(ref_path.clone()));
211                        overlay.insert("$path".to_string(), Value::String(last_segment.to_string()));
212                    } else {
213                        // Non-$ref (inline layout container): build a clean positional path by
214                        // stripping the structural /$layout/elements suffix from layout_path
215                        // so it reads as a field-relative path, not an internal layout pointer.
216                        //
217                        // e.g. "#/properties/form/$layout/elements" → "form" → "form.0"
218                        //      "#/form/$layout/elements/2/elements" → "form.2" → "form.2.0"
219                        let base = Self::layout_path_to_field_path(layout_path);
220                        let fullpath = if base.is_empty() {
221                            format!("{}", element_idx)
222                        } else {
223                            format!("{}.{}", base, element_idx)
224                        };
225                        let last_segment = fullpath.split('.').last().unwrap_or(&fullpath).to_string();
226                        overlay.insert("$fullpath".to_string(), Value::String(fullpath));
227                        overlay.insert("$path".to_string(), Value::String(last_segment));
228                    }
229                }
230
231                // Override $parentHide — may exist from ref resolution as false,
232                // but tree_to_overlays owns the correct parent_hidden value.
233                overlay.insert("$parentHide".to_string(), Value::Bool(parent_hidden));
234
235                // ── Parent condition cascade ──
236                let mut element_hidden = parent_hidden;
237                let mut element_disabled = parent_disabled;
238
239                // Element's own condition (from overlay now)
240                if let Some(Value::Object(cond)) = overlay.get("condition") {
241                    if let Some(Value::Bool(h)) = cond.get("hidden") {
242                        element_hidden = element_hidden || *h;
243                    }
244                    if let Some(Value::Bool(d)) = cond.get("disabled") {
245                        element_disabled = element_disabled || *d;
246                    }
247                }
248
249                // Element's own hideLayout (from overlay now)
250                if let Some(Value::Object(hide)) = overlay.get("hideLayout") {
251                    if let Some(Value::Bool(all)) = hide.get("all") {
252                        if *all {
253                            element_hidden = true;
254                        }
255                    }
256                }
257                
258                // Only show condition cascade if parent has state OR element has state
259                let show_condition_cascade = parent_hidden || parent_disabled || element_hidden || element_disabled;
260
261                // $parentHide already set above
262
263                // ── Condition cascade: if parent OR element has state, emit merged condition ──
264                if show_condition_cascade {
265                    let mut merged_cond = serde_json::Map::new();
266                    if let Some(Value::Object(existing)) = overlay.get("condition") {
267                        for (k, v) in existing.iter() {
268                            merged_cond.insert(k.clone(), v.clone());
269                        }
270                    }
271                    // Merge in hidden states from parent and element
272                    if parent_hidden || element_hidden {
273                        merged_cond.insert("hidden".to_string(), Value::Bool(true));
274                    }
275                    if parent_disabled || element_disabled {
276                        merged_cond.insert("disabled".to_string(), Value::Bool(true));
277                    }
278                    overlay.insert("condition".to_string(), Value::Object(merged_cond));
279
280                    // Also push hideLayout cascade for layout containers
281                    if (parent_hidden || element_hidden)
282                        && (element.get("hideLayout").is_some() || element.get("type").is_some())
283                    {
284                        let mut hide_layout = if let Some(Value::Object(h)) = element.get("hideLayout")
285                        {
286                            h.clone()
287                        } else {
288                            serde_json::Map::new()
289                        };
290                        hide_layout.insert("all".to_string(), Value::Bool(true));
291                        overlay.insert("hideLayout".to_string(), Value::Object(hide_layout));
292                    }
293                }
294
295                // ── Recurse into nested elements ──
296                if let Some(Value::Array(children)) = element.get("elements") {
297                    // Build nested tree from children
298                    let child_tree: Vec<(Value, String)> = children
299                        .iter()
300                        .map(|c| {
301                            if let Value::Object(m) = c {
302                                (c.clone(),
303                                    m.get("$fullpath")
304                                        .and_then(Value::as_str)
305                                        .unwrap_or("")
306                                        .to_string(),
307                                )
308                            } else {
309                                (c.clone(), String::new())
310                            }
311                        })
312                        .collect();
313                    
314                    let child_layout_path = format!("{}/{}/elements", layout_path.trim_end_matches('/'), element_idx);
315                    
316                    // Recurse into children with element_hidden/element_disabled as parent state
317                    let child_overlays = Self::tree_to_overlays(
318                        &child_tree,
319                        &child_layout_path,
320                        element_hidden,
321                        element_disabled,
322                    );
323                    entries.extend(child_overlays);
324                }
325            }
326
327            entries.push(LayoutOverlayEntry {
328                layout_path: layout_path.to_string(),
329                element_idx,
330                schema_ref_path: ref_path.clone(),
331                overlay,
332            });
333        }
334
335        entries
336    }
337
338    // ── Phase 2: Schema hidden sync ──
339
340    /// Sync hidden state from overlay entries to evaluated_schema definitions.
341    /// Collects schema paths where overlay has condition.hidden: true,
342    /// writes condition.hidden = true to those definitions in evaluated_schema.
343    fn sync_layout_hidden_to_schema(&mut self, entries: &[LayoutOverlayEntry]) {
344        self.layout_synced_paths.clear();
345
346        for entry in entries {
347            if let Some(Value::Object(cond)) = entry.overlay.get("condition") {
348                if cond
349                    .get("hidden")
350                    .and_then(|v| v.as_bool())
351                    .unwrap_or(false)
352                    && !entry.schema_ref_path.is_empty()
353                {
354                    self.sync_hidden_to_schema_at(&entry.schema_ref_path);
355                }
356            }
357            // Also track hideLayout cascade
358            if let Some(Value::Object(hide_layout)) = entry.overlay.get("hideLayout") {
359                if hide_layout
360                    .get("all")
361                    .and_then(|v| v.as_bool())
362                    .unwrap_or(false)
363                    && !entry.schema_ref_path.is_empty()
364                {
365                    self.sync_hidden_to_schema_at(&entry.schema_ref_path);
366                }
367            }
368        }
369    }
370
371    /// Write condition.hidden=true to schema node at dotted path.
372    /// Only writes condition.hidden (not hideLayout) — hideLayout is for
373    /// layout container elements, not leaf fields. Writing hideLayout would
374    /// stick across re-evaluations and cause false cascade.
375    fn sync_hidden_to_schema_at(&mut self, schema_path_dot: &str) {
376        let schema_pointer = path_utils::dot_notation_to_schema_pointer(schema_path_dot);
377        let normalized_pointer = path_utils::normalize_to_json_pointer(&schema_pointer);
378
379        let target_pointer = if self.evaluated_schema.pointer(&normalized_pointer).is_some() {
380            normalized_pointer.into_owned()
381        } else {
382            format!("/properties{}", normalized_pointer)
383        };
384
385        self.layout_synced_paths.push(target_pointer.clone());
386
387        if let Some(schema_node) = self.evaluated_schema.pointer_mut(&target_pointer) {
388            if let Value::Object(map) = schema_node {
389                let mut condition = if let Some(Value::Object(c)) = map.get("condition") {
390                    c.clone()
391                } else {
392                    serde_json::Map::new()
393                };
394                condition.insert("hidden".to_string(), Value::Bool(true));
395                map.insert("condition".to_string(), Value::Object(condition));
396            }
397        }
398    }
399
400    // ── Private helpers ──────────────────────────────────────────────────────
401
402    /// Convert a layout elements path to a clean field-relative dotted path
403    /// by stripping all structural `/$layout/elements` segments and the leading
404    /// `/properties` prefix.
405    ///
406    /// ## Examples
407    ///
408    /// ```text
409    /// "#/properties/form/$layout/elements"       → "form"
410    /// "#/form/$layout/elements"                  → "form"
411    /// "#/properties/form/$layout/elements/2/elements" → "form.2"
412    /// "#/a/properties/b/$layout/elements"        → "a.b"
413    /// ```
414    fn layout_path_to_field_path(layout_path: &str) -> String {
415        // Strip leading `#` or `#/`
416        let raw = if layout_path.starts_with("#/") {
417            &layout_path[2..]
418        } else if layout_path.starts_with('#') {
419            &layout_path[1..]
420        } else if layout_path.starts_with('/') {
421            &layout_path[1..]
422        } else {
423            layout_path
424        };
425
426        // Walk segments, dropping "properties", "$layout", "elements" structural tokens
427        let parts: Vec<&str> = raw.split('/').collect();
428        let mut out: Vec<&str> = Vec::new();
429        let mut i = 0;
430        while i < parts.len() {
431            let seg = parts[i];
432            match seg {
433                "" | "properties" | "$layout" | "elements" | "additionalProperties" => {}
434                _ => out.push(seg),
435            }
436            i += 1;
437        }
438
439        out.join(".")
440    }
441}