Skip to main content

ipfrs_tensorlogic/
ipld_path.rs

1//! IPLD path resolution for TensorLogic structures
2//!
3//! Supports deterministic traversal of JSON-serialized IPLD blocks along paths
4//! like:
5//!
6//! ```text
7//!   /rule/<cid>/head/functor
8//!   /rule/<cid>/head/args/0
9//!   /rule/<cid>/body/0/functor
10//!   /fact/<cid>/predicate
11//!   /fact/<cid>/args/1
12//! ```
13//!
14//! The resolver operates on the raw block bytes produced by
15//! [`crate::ipld_codec`], which encodes blocks as JSON internally (even though
16//! the CID carries the DAG-CBOR codec stamp).  Each path segment is applied
17//! iteratively: numeric segments index into arrays; string segments key into
18//! objects.
19
20use std::collections::HashMap;
21
22/// Resolved value at an IPLD path
23#[derive(Debug, Clone, PartialEq)]
24pub enum IpldPathValue {
25    /// A string value (includes functor names, variable names, atom values, …)
26    String(String),
27    /// A numeric value (f64 covers integers and floats uniformly)
28    Number(f64),
29    /// An ordered list of values
30    Array(Vec<IpldPathValue>),
31    /// A map of string keys to values
32    Object(HashMap<String, IpldPathValue>),
33    /// Null / absent
34    Null,
35}
36
37impl IpldPathValue {
38    /// Convenience: return inner string if this is `IpldPathValue::String`
39    pub fn as_str(&self) -> Option<&str> {
40        match self {
41            IpldPathValue::String(s) => Some(s.as_str()),
42            _ => None,
43        }
44    }
45
46    /// Convenience: return inner f64 if this is `IpldPathValue::Number`
47    pub fn as_number(&self) -> Option<f64> {
48        match self {
49            IpldPathValue::Number(n) => Some(*n),
50            _ => None,
51        }
52    }
53}
54
55/// Errors that can occur during IPLD path resolution
56#[derive(Debug, thiserror::Error)]
57pub enum PathError {
58    #[error("Invalid path: {0}")]
59    InvalidPath(String),
60    #[error("Path segment not found: {0}")]
61    NotFound(String),
62    #[error("Type mismatch at {0}: expected {1}")]
63    TypeMismatch(String, String),
64    #[error("Index out of bounds: {0}")]
65    IndexOutOfBounds(usize),
66    #[error("Deserialization error: {0}")]
67    Deserialization(String),
68}
69
70/// IPLD path resolver for TensorLogic block data
71///
72/// All methods are stateless pure functions that operate on raw block bytes.
73pub struct IpldPathResolver;
74
75impl IpldPathResolver {
76    /// Resolve an IPLD path against a stored rule block.
77    ///
78    /// `block_data` must be the raw bytes of a block encoded by
79    /// [`crate::ipld_codec::rule_to_block`].
80    ///
81    /// `path` is the *full* path string, e.g.
82    /// `"/rule/bafkrei.../head/args/0"`.  The first two segments (`rule` and
83    /// the CID) are skipped; traversal begins at the third segment.
84    ///
85    /// # Examples
86    ///
87    /// ```rust,ignore
88    /// let val = IpldPathResolver::resolve_rule_path(block_data, "/rule/bafk.../head/functor")?;
89    /// assert_eq!(val, IpldPathValue::String("grandparent".to_string()));
90    /// ```
91    pub fn resolve_rule_path(block_data: &[u8], path: &str) -> Result<IpldPathValue, PathError> {
92        let segments = Self::parse_path(path);
93
94        // Expect at least /rule/<cid>/…  (i.e. ≥ 3 segments after splitting)
95        // segments[0] == "rule", segments[1] == <cid>, segments[2..] == traversal
96        if segments.len() < 2 {
97            return Err(PathError::InvalidPath(format!(
98                "Rule path must start with /rule/<cid>/…; got: {}",
99                path
100            )));
101        }
102        if segments[0] != "rule" {
103            return Err(PathError::InvalidPath(format!(
104                "Expected 'rule' as first segment, got '{}'",
105                segments[0]
106            )));
107        }
108
109        let root: serde_json::Value = serde_json::from_slice(block_data)
110            .map_err(|e| PathError::Deserialization(e.to_string()))?;
111
112        // Traverse segments[2..] (skip "rule" and the CID)
113        let traversal = &segments[2..];
114        Self::traverse(&root, traversal)
115    }
116
117    /// Resolve a path against a stored fact block.
118    ///
119    /// `block_data` must be the raw bytes of a block encoded by
120    /// [`crate::ipld_codec::fact_to_block`].
121    ///
122    /// `path` is the full path, e.g. `"/fact/bafk.../predicate"`.  The first
123    /// two segments are skipped.
124    pub fn resolve_fact_path(block_data: &[u8], path: &str) -> Result<IpldPathValue, PathError> {
125        let segments = Self::parse_path(path);
126
127        if segments.len() < 2 {
128            return Err(PathError::InvalidPath(format!(
129                "Fact path must start with /fact/<cid>/…; got: {}",
130                path
131            )));
132        }
133        if segments[0] != "fact" {
134            return Err(PathError::InvalidPath(format!(
135                "Expected 'fact' as first segment, got '{}'",
136                segments[0]
137            )));
138        }
139
140        let root: serde_json::Value = serde_json::from_slice(block_data)
141            .map_err(|e| PathError::Deserialization(e.to_string()))?;
142
143        let traversal = &segments[2..];
144        Self::traverse(&root, traversal)
145    }
146
147    /// Parse a path string into non-empty segments by splitting on `/`.
148    ///
149    /// Leading and trailing slashes are stripped; consecutive slashes are
150    /// treated as a single separator.
151    pub fn parse_path(path: &str) -> Vec<String> {
152        path.split('/')
153            .filter(|s| !s.is_empty())
154            .map(|s| s.to_string())
155            .collect()
156    }
157
158    // ── Private helpers ───────────────────────────────────────────────────────
159
160    /// Recursively traverse `segments` starting from `current`.
161    fn traverse(
162        current: &serde_json::Value,
163        segments: &[String],
164    ) -> Result<IpldPathValue, PathError> {
165        if segments.is_empty() {
166            return Self::json_to_ipld(current);
167        }
168
169        let seg = &segments[0];
170        let rest = &segments[1..];
171
172        match current {
173            serde_json::Value::Object(map) => {
174                let child = map.get(seg.as_str()).ok_or_else(|| {
175                    PathError::NotFound(format!("Key '{}' not found in object", seg))
176                })?;
177                Self::traverse(child, rest)
178            }
179            serde_json::Value::Array(arr) => {
180                let idx: usize = seg.parse().map_err(|_| {
181                    PathError::TypeMismatch(
182                        seg.clone(),
183                        "numeric index for array traversal".to_string(),
184                    )
185                })?;
186                let child = arr.get(idx).ok_or(PathError::IndexOutOfBounds(idx))?;
187                Self::traverse(child, rest)
188            }
189            other => Err(PathError::TypeMismatch(
190                seg.clone(),
191                format!(
192                    "object or array (cannot descend into {})",
193                    Self::json_type_name(other)
194                ),
195            )),
196        }
197    }
198
199    /// Convert a `serde_json::Value` leaf into an [`IpldPathValue`].
200    fn json_to_ipld(value: &serde_json::Value) -> Result<IpldPathValue, PathError> {
201        match value {
202            serde_json::Value::Null => Ok(IpldPathValue::Null),
203            serde_json::Value::Bool(b) => Ok(IpldPathValue::Number(if *b { 1.0 } else { 0.0 })),
204            serde_json::Value::Number(n) => {
205                let f = n.as_f64().ok_or_else(|| {
206                    PathError::Deserialization(format!("Cannot convert number {} to f64", n))
207                })?;
208                Ok(IpldPathValue::Number(f))
209            }
210            serde_json::Value::String(s) => Ok(IpldPathValue::String(s.clone())),
211            serde_json::Value::Array(arr) => {
212                let items = arr
213                    .iter()
214                    .map(Self::json_to_ipld)
215                    .collect::<Result<Vec<_>, _>>()?;
216                Ok(IpldPathValue::Array(items))
217            }
218            serde_json::Value::Object(map) => {
219                let kv = map
220                    .iter()
221                    .map(|(k, v)| Self::json_to_ipld(v).map(|ipld| (k.clone(), ipld)))
222                    .collect::<Result<HashMap<_, _>, _>>()?;
223                Ok(IpldPathValue::Object(kv))
224            }
225        }
226    }
227
228    /// Return a human-readable type name for a JSON value (for error messages).
229    fn json_type_name(v: &serde_json::Value) -> &'static str {
230        match v {
231            serde_json::Value::Null => "null",
232            serde_json::Value::Bool(_) => "bool",
233            serde_json::Value::Number(_) => "number",
234            serde_json::Value::String(_) => "string",
235            serde_json::Value::Array(_) => "array",
236            serde_json::Value::Object(_) => "object",
237        }
238    }
239}
240
241// ─── Tests ───────────────────────────────────────────────────────────────────
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::ipld_codec::{
247        fact_to_block, predicate_to_fact_ipld, rule_to_block, rule_to_rule_ipld,
248    };
249    use crate::ir::{Constant, Predicate, Rule, Term};
250
251    /// Build a rule: grandparent(X, Z) :- parent(X, Y), parent(Y, Z)
252    fn grandparent_rule() -> Rule {
253        let head = Predicate::new(
254            "grandparent".to_string(),
255            vec![Term::Var("X".to_string()), Term::Var("Z".to_string())],
256        );
257        let body = vec![
258            Predicate::new(
259                "parent".to_string(),
260                vec![Term::Var("X".to_string()), Term::Var("Y".to_string())],
261            ),
262            Predicate::new(
263                "parent".to_string(),
264                vec![Term::Var("Y".to_string()), Term::Var("Z".to_string())],
265            ),
266        ];
267        Rule::new(head, body)
268    }
269
270    /// Encode a rule as block bytes
271    fn rule_bytes(rule: &Rule) -> Vec<u8> {
272        let ipld = rule_to_rule_ipld(rule).expect("rule_to_rule_ipld");
273        let block = rule_to_block(&ipld).expect("rule_to_block");
274        block.data().to_vec()
275    }
276
277    /// Encode a fact as block bytes
278    fn fact_bytes(pred: &Predicate) -> Vec<u8> {
279        let ipld = predicate_to_fact_ipld(pred).expect("predicate_to_fact_ipld");
280        let block = fact_to_block(&ipld).expect("fact_to_block");
281        block.data().to_vec()
282    }
283
284    #[test]
285    fn test_resolve_head_functor() {
286        let rule = grandparent_rule();
287        let data = rule_bytes(&rule);
288        let cid = "bafktest000";
289
290        let val =
291            IpldPathResolver::resolve_rule_path(&data, &format!("/rule/{}/head/functor", cid))
292                .expect("resolve head/functor");
293
294        assert_eq!(val, IpldPathValue::String("grandparent".to_string()));
295    }
296
297    #[test]
298    fn test_resolve_head_arg_by_index() {
299        let rule = grandparent_rule();
300        let data = rule_bytes(&rule);
301        let cid = "bafktest001";
302
303        // head args are Variable nodes; args[0] is X, args[1] is Z
304        let val0 =
305            IpldPathResolver::resolve_rule_path(&data, &format!("/rule/{}/head/args/0/name", cid))
306                .expect("head/args/0/name");
307
308        assert_eq!(val0, IpldPathValue::String("X".to_string()));
309
310        let val1 =
311            IpldPathResolver::resolve_rule_path(&data, &format!("/rule/{}/head/args/1/name", cid))
312                .expect("head/args/1/name");
313
314        assert_eq!(val1, IpldPathValue::String("Z".to_string()));
315    }
316
317    #[test]
318    fn test_resolve_body_goal() {
319        let rule = grandparent_rule();
320        let data = rule_bytes(&rule);
321        let cid = "bafktest002";
322
323        // body[0] is parent(X, Y) – functor is "parent"
324        let val =
325            IpldPathResolver::resolve_rule_path(&data, &format!("/rule/{}/body/0/functor", cid))
326                .expect("body/0/functor");
327
328        assert_eq!(val, IpldPathValue::String("parent".to_string()));
329
330        // body[1] is parent(Y, Z)
331        let val2 =
332            IpldPathResolver::resolve_rule_path(&data, &format!("/rule/{}/body/1/functor", cid))
333                .expect("body/1/functor");
334
335        assert_eq!(val2, IpldPathValue::String("parent".to_string()));
336    }
337
338    #[test]
339    fn test_invalid_path_error_wrong_prefix() {
340        let rule = grandparent_rule();
341        let data = rule_bytes(&rule);
342
343        let err = IpldPathResolver::resolve_rule_path(&data, "/fact/bafk/head").unwrap_err();
344        assert!(
345            matches!(err, PathError::InvalidPath(_)),
346            "Expected InvalidPath, got {:?}",
347            err
348        );
349    }
350
351    #[test]
352    fn test_invalid_path_too_short() {
353        let rule = grandparent_rule();
354        let data = rule_bytes(&rule);
355
356        let err = IpldPathResolver::resolve_rule_path(&data, "/rule").unwrap_err();
357        assert!(
358            matches!(err, PathError::InvalidPath(_)),
359            "Expected InvalidPath for short path, got {:?}",
360            err
361        );
362    }
363
364    #[test]
365    fn test_index_out_of_bounds() {
366        let rule = grandparent_rule();
367        let data = rule_bytes(&rule);
368        let cid = "bafktest003";
369
370        // head has 2 args; index 99 must fail
371        let err =
372            IpldPathResolver::resolve_rule_path(&data, &format!("/rule/{}/head/args/99", cid))
373                .unwrap_err();
374
375        assert!(
376            matches!(err, PathError::IndexOutOfBounds(99)),
377            "Expected IndexOutOfBounds(99), got {:?}",
378            err
379        );
380    }
381
382    #[test]
383    fn test_key_not_found() {
384        let rule = grandparent_rule();
385        let data = rule_bytes(&rule);
386        let cid = "bafktest004";
387
388        let err = IpldPathResolver::resolve_rule_path(
389            &data,
390            &format!("/rule/{}/head/nonexistent_key", cid),
391        )
392        .unwrap_err();
393
394        assert!(
395            matches!(err, PathError::NotFound(_)),
396            "Expected NotFound, got {:?}",
397            err
398        );
399    }
400
401    #[test]
402    fn test_fact_path_predicate() {
403        let pred = Predicate::new(
404            "parent".to_string(),
405            vec![
406                Term::Const(Constant::String("alice".to_string())),
407                Term::Const(Constant::String("bob".to_string())),
408            ],
409        );
410        let data = fact_bytes(&pred);
411        let cid = "bafktest005";
412
413        let val = IpldPathResolver::resolve_fact_path(&data, &format!("/fact/{}/predicate", cid))
414            .expect("fact/predicate");
415
416        assert_eq!(val, IpldPathValue::String("parent".to_string()));
417    }
418
419    #[test]
420    fn test_fact_path_arg_by_index() {
421        let pred = Predicate::new(
422            "likes".to_string(),
423            vec![
424                Term::Const(Constant::String("alice".to_string())),
425                Term::Const(Constant::String("chocolate".to_string())),
426            ],
427        );
428        let data = fact_bytes(&pred);
429        let cid = "bafktest006";
430
431        // args[1] is "chocolate" – stored as TermIpld::Atom { value: "chocolate" }
432        let val =
433            IpldPathResolver::resolve_fact_path(&data, &format!("/fact/{}/args/1/value", cid))
434                .expect("fact/args/1/value");
435
436        assert_eq!(val, IpldPathValue::String("chocolate".to_string()));
437    }
438
439    #[test]
440    fn test_parse_path_strips_leading_slash() {
441        let segments = IpldPathResolver::parse_path("/rule/bafk/head/args/0");
442        assert_eq!(segments, vec!["rule", "bafk", "head", "args", "0"]);
443    }
444
445    #[test]
446    fn test_parse_path_no_leading_slash() {
447        let segments = IpldPathResolver::parse_path("rule/bafk/head");
448        assert_eq!(segments, vec!["rule", "bafk", "head"]);
449    }
450
451    #[test]
452    fn test_rule_body_arg_resolution() {
453        let rule = grandparent_rule();
454        let data = rule_bytes(&rule);
455        let cid = "bafktest007";
456
457        // body[0]/args[0] == Variable {name: "X"}
458        let val = IpldPathResolver::resolve_rule_path(
459            &data,
460            &format!("/rule/{}/body/0/args/0/name", cid),
461        )
462        .expect("body/0/args/0/name");
463
464        assert_eq!(val, IpldPathValue::String("X".to_string()));
465    }
466}