datalogic_rs/path.rs
1//! Public path-resolution surface — translates the raw `Vec<u32>` breadcrumb
2//! that [`crate::Error`] carries into structured [`PathStep`]s consumers can
3//! act on.
4
5use std::collections::HashMap;
6
7use serde::{Deserialize, Serialize};
8
9use crate::Logic;
10use crate::node::CompiledNode;
11
12/// One node along the path from the root of a compiled rule down to the
13/// failing sub-expression. Returned root-to-leaf by
14/// [`crate::Logic::resolve_node_ids`] / [`crate::Error::resolve_path`].
15///
16/// `#[non_exhaustive]` so future fields can be added in 5.x without
17/// breaking downstream — external code reads fields freely but cannot
18/// construct via struct literal. UI tooling that consumes this type
19/// over the wire can roundtrip via the derived `Serialize` /
20/// `Deserialize`.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[non_exhaustive]
23pub struct PathStep {
24 /// Compile-time node id, matching [`crate::Error::node_ids`].
25 pub node_id: u32,
26 /// Operator name at this node, when one applies. `None` for plain values
27 /// and arrays.
28 pub operator: Option<String>,
29 /// Position within the parent node's argument list. `None` for the root
30 /// step (no parent) and for non-positional contexts.
31 pub arg_index: Option<u32>,
32 /// JSONLogic-flavoured pointer from the root to this node — e.g.
33 /// `/if/0/>/0` for the `var` slot of the inner `>` inside an `if`.
34 /// Empty string for the root step.
35 pub json_pointer: String,
36}
37
38/// Internal index entry collected during the walk.
39struct NodeInfo {
40 operator: Option<String>,
41 arg_index: Option<u32>,
42 json_pointer: String,
43}
44
45impl Logic {
46 /// Translate a breadcrumb of compiled-node ids into structured
47 /// [`PathStep`]s, root-to-leaf.
48 ///
49 /// Input is the leaf-to-root breadcrumb stored on [`crate::Error::node_ids`].
50 /// Walks the compiled tree once to build an id → location index, then
51 /// resolves each input id; ids absent from the tree are skipped (defensive
52 /// against synthetic nodes from operator fast paths).
53 pub fn resolve_node_ids(&self, ids: &[u32]) -> Vec<PathStep> {
54 if ids.is_empty() {
55 return Vec::new();
56 }
57
58 let mut index: HashMap<u32, NodeInfo> = HashMap::new();
59 walk(&self.root, None, None, "", &mut index);
60
61 let mut out = Vec::with_capacity(ids.len());
62 // Breadcrumb is leaf-to-root; reverse for natural root-to-leaf reading.
63 for &id in ids.iter().rev() {
64 if let Some(ni) = index.get(&id) {
65 out.push(PathStep {
66 node_id: id,
67 operator: ni.operator.clone(),
68 arg_index: ni.arg_index,
69 json_pointer: ni.json_pointer.clone(),
70 });
71 }
72 }
73 out
74 }
75}
76
77/// Depth-first walk of a [`CompiledNode`], recording (operator, arg_index,
78/// json_pointer) for every reachable node id. `parent_op` and
79/// `parent_pointer` describe how *this* node is reached from above.
80///
81/// Recursion delegates the "what are this node's children" question to
82/// [`CompiledNode::visit_indexed_children`] so the variant match lives in
83/// exactly one place.
84fn walk(
85 node: &CompiledNode,
86 parent_op: Option<&str>,
87 arg_index: Option<u32>,
88 parent_pointer: &str,
89 out: &mut HashMap<u32, NodeInfo>,
90) {
91 // CSE memo wrappers are path-transparent: delegate before the generic
92 // body so the wrapped node's operator/pointer are recorded exactly as
93 // in an unwrapped tree (no extra "/op/0" step for the wrapper).
94 if let CompiledNode::Cse(data) = node {
95 return walk(&data.inner, parent_op, arg_index, parent_pointer, out);
96 }
97
98 let id = node.id();
99 let operator = node.operator_name().map(|c| c.into_owned());
100 let json_pointer = build_pointer(parent_pointer, parent_op, arg_index);
101
102 // Children of an `Array` form pointers like "/<idx>"; for every other
103 // variant the current node's operator name is the pointer prefix.
104 let child_parent_op = if matches!(node, CompiledNode::Array { .. }) {
105 None
106 } else {
107 operator.as_deref()
108 };
109
110 // Recurse first while borrowing `operator` / `json_pointer`, then move
111 // both owned values into the map — node ids are unique, so insertion
112 // order does not matter, and this avoids cloning them per node.
113 node.visit_indexed_children(&mut |i, child| {
114 walk(child, child_parent_op, Some(i), &json_pointer, out);
115 });
116
117 out.insert(
118 id,
119 NodeInfo {
120 operator,
121 arg_index,
122 json_pointer,
123 },
124 );
125}
126
127#[inline]
128fn build_pointer(parent_pointer: &str, parent_op: Option<&str>, arg_index: Option<u32>) -> String {
129 match (parent_op, arg_index) {
130 (Some(op), Some(idx)) => format!("{}/{}/{}", parent_pointer, op, idx),
131 // Child of an Array (no operator key) — JSON pointer "/idx".
132 (None, Some(idx)) => format!("{}/{}", parent_pointer, idx),
133 _ => parent_pointer.to_string(),
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 fn engine() -> crate::Engine {
140 crate::Engine::new()
141 }
142
143 #[test]
144 fn resolve_root_only() {
145 // Use a rule with a `var` that survives static evaluation as the root.
146 let compiled = engine().compile(r#"{"==": [{"var": "x"}, 1]}"#).unwrap();
147 let root_id = compiled.root.id();
148 let steps = compiled.resolve_node_ids(&[root_id]);
149 assert_eq!(steps.len(), 1);
150 assert_eq!(steps[0].node_id, root_id);
151 assert_eq!(steps[0].operator.as_deref(), Some("=="));
152 assert_eq!(steps[0].arg_index, None);
153 assert_eq!(steps[0].json_pointer, "");
154 }
155
156 #[test]
157 fn resolve_empty_path_returns_empty() {
158 let compiled = engine().compile(r#"{"==": [{"var": "x"}, 1]}"#).unwrap();
159 assert!(compiled.resolve_node_ids(&[]).is_empty());
160 }
161
162 #[test]
163 fn resolve_unknown_ids_are_skipped() {
164 let compiled = engine().compile(r#"{"==": [{"var": "x"}, 1]}"#).unwrap();
165 // u32::MAX won't exist in the tree.
166 assert!(compiled.resolve_node_ids(&[u32::MAX]).is_empty());
167 }
168
169 #[test]
170 fn resolve_via_evaluation_error() {
171 // {"+": ["x", 1]} — the string-vs-number arithmetic raises NaN.
172 let engine = engine();
173 let compiled = engine.compile(r#"{"+": ["x", 1]}"#).unwrap();
174 let arena = bumpalo::Bump::new();
175 let data = datavalue::DataValue::from_str("null", &arena).unwrap();
176 let err = engine.evaluate(&compiled, data, &arena).unwrap_err();
177 // The merged Error should carry a non-empty path now.
178 let steps = err.resolve_path(&compiled);
179 assert!(
180 !steps.is_empty(),
181 "expected resolved path for arithmetic failure, got {:?}",
182 err
183 );
184 // First step (root-to-leaf) is the outermost operator.
185 assert_eq!(steps[0].operator.as_deref(), Some("+"));
186 assert_eq!(steps[0].json_pointer, "");
187 }
188}