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    let id = node.id();
92    let operator = node.operator_name();
93    let json_pointer = build_pointer(parent_pointer, parent_op, arg_index);
94    out.insert(
95        id,
96        NodeInfo {
97            operator: operator.clone(),
98            arg_index,
99            json_pointer: json_pointer.clone(),
100        },
101    );
102
103    // Children of an `Array` form pointers like "/<idx>"; for every other
104    // variant the current node's operator name is the pointer prefix.
105    let child_parent_op = if matches!(node, CompiledNode::Array { .. }) {
106        None
107    } else {
108        operator.as_deref()
109    };
110
111    node.visit_indexed_children(&mut |i, child| {
112        walk(child, child_parent_op, Some(i), &json_pointer, out);
113    });
114}
115
116#[inline]
117fn build_pointer(parent_pointer: &str, parent_op: Option<&str>, arg_index: Option<u32>) -> String {
118    match (parent_op, arg_index) {
119        (Some(op), Some(idx)) => format!("{}/{}/{}", parent_pointer, op, idx),
120        // Child of an Array (no operator key) — JSON pointer "/idx".
121        (None, Some(idx)) => format!("{}/{}", parent_pointer, idx),
122        _ => parent_pointer.to_string(),
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    fn engine() -> crate::Engine {
129        crate::Engine::new()
130    }
131
132    #[test]
133    fn resolve_root_only() {
134        // Use a rule with a `var` that survives static evaluation as the root.
135        let compiled = engine().compile(r#"{"==": [{"var": "x"}, 1]}"#).unwrap();
136        let root_id = compiled.root.id();
137        let steps = compiled.resolve_node_ids(&[root_id]);
138        assert_eq!(steps.len(), 1);
139        assert_eq!(steps[0].node_id, root_id);
140        assert_eq!(steps[0].operator.as_deref(), Some("=="));
141        assert_eq!(steps[0].arg_index, None);
142        assert_eq!(steps[0].json_pointer, "");
143    }
144
145    #[test]
146    fn resolve_empty_path_returns_empty() {
147        let compiled = engine().compile(r#"{"==": [{"var": "x"}, 1]}"#).unwrap();
148        assert!(compiled.resolve_node_ids(&[]).is_empty());
149    }
150
151    #[test]
152    fn resolve_unknown_ids_are_skipped() {
153        let compiled = engine().compile(r#"{"==": [{"var": "x"}, 1]}"#).unwrap();
154        // u32::MAX won't exist in the tree.
155        assert!(compiled.resolve_node_ids(&[u32::MAX]).is_empty());
156    }
157
158    #[test]
159    fn resolve_via_evaluation_error() {
160        // {"+": ["x", 1]} — the string-vs-number arithmetic raises NaN.
161        let engine = engine();
162        let compiled = engine.compile(r#"{"+": ["x", 1]}"#).unwrap();
163        let arena = bumpalo::Bump::new();
164        let data = datavalue::DataValue::from_str("null", &arena).unwrap();
165        let err = engine.evaluate(&compiled, data, &arena).unwrap_err();
166        // The merged Error should carry a non-empty path now.
167        let steps = err.resolve_path(&compiled);
168        assert!(
169            !steps.is_empty(),
170            "expected resolved path for arithmetic failure, got {:?}",
171            err
172        );
173        // First step (root-to-leaf) is the outermost operator.
174        assert_eq!(steps[0].operator.as_deref(), Some("+"));
175        assert_eq!(steps[0].json_pointer, "");
176    }
177}