Skip to main content

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}