Skip to main content

oxirs_shacl/
path_executor.rs

1/// SHACL property path execution.
2///
3/// Evaluates the full set of SHACL/SPARQL property path operators over an
4/// in-memory RDF graph, with cycle prevention and result collection.
5use std::collections::{HashSet, VecDeque};
6
7// ── Graph representation ──────────────────────────────────────────────────────
8
9/// A ground RDF triple used as the graph data-source.
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct GraphTriple {
12    pub subject: String,
13    pub predicate: String,
14    pub object: String,
15}
16
17impl GraphTriple {
18    /// Create a new triple.
19    pub fn new(
20        subject: impl Into<String>,
21        predicate: impl Into<String>,
22        object: impl Into<String>,
23    ) -> Self {
24        GraphTriple {
25            subject: subject.into(),
26            predicate: predicate.into(),
27            object: object.into(),
28        }
29    }
30}
31
32/// A borrowed view of a set of RDF triples used as the evaluation graph.
33pub struct RdfGraph<'a> {
34    triples: &'a [GraphTriple],
35}
36
37impl<'a> RdfGraph<'a> {
38    /// Wrap a slice of triples as an RDF graph.
39    pub fn new(triples: &'a [GraphTriple]) -> Self {
40        RdfGraph { triples }
41    }
42
43    /// Return all objects reachable from `subject` via `predicate`.
44    pub fn objects_of(&self, subject: &str, predicate: &str) -> Vec<String> {
45        self.triples
46            .iter()
47            .filter(|t| t.subject == subject && t.predicate == predicate)
48            .map(|t| t.object.clone())
49            .collect()
50    }
51
52    /// Return all subjects that have `predicate` pointing to `object`.
53    pub fn subjects_of(&self, predicate: &str, object: &str) -> Vec<String> {
54        self.triples
55            .iter()
56            .filter(|t| t.predicate == predicate && t.object == object)
57            .map(|t| t.subject.clone())
58            .collect()
59    }
60}
61
62// ── Property path data type ───────────────────────────────────────────────────
63
64/// A SHACL/SPARQL property path expression.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum PropertyPath {
67    /// Direct predicate IRI — `sh:path <iri>`.
68    Predicate(String),
69    /// Inverse path — `sh:inversePath path`.
70    Inverse(Box<PropertyPath>),
71    /// Sequence path — `(path1 / path2 / ...)`.
72    Sequence(Vec<PropertyPath>),
73    /// Alternative path — `(path1 | path2 | ...)`.
74    Alternative(Vec<PropertyPath>),
75    /// Zero-or-more path — `path*`.
76    ZeroOrMore(Box<PropertyPath>),
77    /// One-or-more path — `path+`.
78    OneOrMore(Box<PropertyPath>),
79    /// Zero-or-one path — `path?`.
80    ZeroOrOne(Box<PropertyPath>),
81}
82
83// ── Evaluation result ─────────────────────────────────────────────────────────
84
85/// The result of evaluating a property path from a start node.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct PathResult {
88    /// All distinct nodes reachable from the start node via the path.
89    pub nodes: Vec<String>,
90    /// Total number of raw path steps traversed (may exceed `nodes.len()` when
91    /// cycles are detected and deduplicated).
92    pub steps_taken: usize,
93}
94
95impl PathResult {
96    /// Returns `true` when no nodes were reached.
97    pub fn is_empty(&self) -> bool {
98        self.nodes.is_empty()
99    }
100
101    /// Returns the number of reachable nodes.
102    pub fn len(&self) -> usize {
103        self.nodes.len()
104    }
105
106    /// Returns `true` when `node` is among the reachable nodes.
107    pub fn contains(&self, node: &str) -> bool {
108        self.nodes.iter().any(|n| n == node)
109    }
110}
111
112// ── PathExecutor ──────────────────────────────────────────────────────────────
113
114/// Evaluates SHACL property paths over an RDF graph.
115pub struct PathExecutor;
116
117impl PathExecutor {
118    /// Evaluate `path` starting from `start` over `graph`.
119    ///
120    /// Returns all reachable nodes plus traversal statistics.
121    pub fn evaluate(path: &PropertyPath, start: &str, graph: &RdfGraph<'_>) -> PathResult {
122        let mut visited: HashSet<String> = HashSet::new();
123        let mut steps = 0usize;
124        let nodes = eval_path(path, start, graph, &mut visited, &mut steps);
125        PathResult {
126            nodes,
127            steps_taken: steps,
128        }
129    }
130
131    /// Evaluate `path` and return only whether at least one node is reachable.
132    pub fn has_any(path: &PropertyPath, start: &str, graph: &RdfGraph<'_>) -> bool {
133        !Self::evaluate(path, start, graph).is_empty()
134    }
135
136    /// Evaluate `path` and check whether a specific target node is reachable.
137    pub fn reaches(path: &PropertyPath, start: &str, target: &str, graph: &RdfGraph<'_>) -> bool {
138        Self::evaluate(path, start, graph).contains(target)
139    }
140}
141
142// ── Core recursive evaluator ──────────────────────────────────────────────────
143
144fn eval_path(
145    path: &PropertyPath,
146    start: &str,
147    graph: &RdfGraph<'_>,
148    global_visited: &mut HashSet<String>,
149    steps: &mut usize,
150) -> Vec<String> {
151    match path {
152        PropertyPath::Predicate(iri) => eval_predicate(start, iri, graph, steps),
153        PropertyPath::Inverse(inner) => eval_inverse(inner, start, graph, steps),
154        PropertyPath::Sequence(parts) => eval_sequence(parts, start, graph, global_visited, steps),
155        PropertyPath::Alternative(alts) => {
156            eval_alternative(alts, start, graph, global_visited, steps)
157        }
158        PropertyPath::ZeroOrMore(inner) => eval_zero_or_more(inner, start, graph, steps),
159        PropertyPath::OneOrMore(inner) => eval_one_or_more(inner, start, graph, steps),
160        PropertyPath::ZeroOrOne(inner) => eval_zero_or_one(inner, start, graph, steps),
161    }
162}
163
164/// Direct predicate path: return all `o` such that `start pred o` ∈ graph.
165fn eval_predicate(
166    start: &str,
167    predicate: &str,
168    graph: &RdfGraph<'_>,
169    steps: &mut usize,
170) -> Vec<String> {
171    let nodes = graph.objects_of(start, predicate);
172    *steps += nodes.len();
173    dedup(nodes)
174}
175
176/// Inverse path: return all `s` such that `s pred start` ∈ graph.
177fn eval_inverse(
178    inner: &PropertyPath,
179    start: &str,
180    graph: &RdfGraph<'_>,
181    steps: &mut usize,
182) -> Vec<String> {
183    match inner {
184        PropertyPath::Predicate(iri) => {
185            let nodes = graph.subjects_of(iri, start);
186            *steps += nodes.len();
187            dedup(nodes)
188        }
189        other => {
190            // For complex inverse paths, collect forward results and swap direction.
191            // This is a best-effort approximation for nested inverse expressions.
192            let mut candidates: HashSet<String> = HashSet::new();
193            for triple in graph.triples {
194                // Try: can we reach `start` from triple.subject via `other`?
195                let mut vis = HashSet::new();
196                let reachable = eval_path(other, &triple.subject, graph, &mut vis, steps);
197                if reachable.iter().any(|n| n == start) {
198                    candidates.insert(triple.subject.clone());
199                }
200            }
201            candidates.into_iter().collect()
202        }
203    }
204}
205
206/// Sequence path: chain each path segment, feeding the output of one into the next.
207fn eval_sequence(
208    parts: &[PropertyPath],
209    start: &str,
210    graph: &RdfGraph<'_>,
211    global_visited: &mut HashSet<String>,
212    steps: &mut usize,
213) -> Vec<String> {
214    let mut frontier: Vec<String> = vec![start.to_owned()];
215    for segment in parts {
216        let mut next: HashSet<String> = HashSet::new();
217        for node in &frontier {
218            let reached = eval_path(segment, node, graph, global_visited, steps);
219            next.extend(reached);
220        }
221        frontier = next.into_iter().collect();
222    }
223    frontier
224}
225
226/// Alternative path: union of results from each alternative.
227fn eval_alternative(
228    alts: &[PropertyPath],
229    start: &str,
230    graph: &RdfGraph<'_>,
231    global_visited: &mut HashSet<String>,
232    steps: &mut usize,
233) -> Vec<String> {
234    let mut result: HashSet<String> = HashSet::new();
235    for alt in alts {
236        let reached = eval_path(alt, start, graph, global_visited, steps);
237        result.extend(reached);
238    }
239    result.into_iter().collect()
240}
241
242/// Zero-or-more path: BFS transitive closure, including the start node itself.
243fn eval_zero_or_more(
244    inner: &PropertyPath,
245    start: &str,
246    graph: &RdfGraph<'_>,
247    steps: &mut usize,
248) -> Vec<String> {
249    let mut visited: HashSet<String> = HashSet::new();
250    let mut queue: VecDeque<String> = VecDeque::new();
251    // ZeroOrMore always includes the start node (zero steps = identity).
252    visited.insert(start.to_owned());
253    queue.push_back(start.to_owned());
254
255    while let Some(current) = queue.pop_front() {
256        let mut inner_vis = HashSet::new();
257        let reached = eval_path(inner, &current, graph, &mut inner_vis, steps);
258        for node in reached {
259            if visited.insert(node.clone()) {
260                queue.push_back(node);
261            }
262        }
263    }
264    visited.into_iter().collect()
265}
266
267/// One-or-more path: BFS transitive closure, excluding the start node unless revisited.
268fn eval_one_or_more(
269    inner: &PropertyPath,
270    start: &str,
271    graph: &RdfGraph<'_>,
272    steps: &mut usize,
273) -> Vec<String> {
274    // First, collect the direct successors (one step).
275    let mut expanded: HashSet<String> = HashSet::new();
276    let mut queue: VecDeque<String> = VecDeque::new();
277
278    // Seed with one-step successors.
279    let mut seed_vis = HashSet::new();
280    let first = eval_path(inner, start, graph, &mut seed_vis, steps);
281    for node in first {
282        if expanded.insert(node.clone()) {
283            queue.push_back(node);
284        }
285    }
286
287    // BFS for additional steps.
288    while let Some(current) = queue.pop_front() {
289        let mut inner_vis = HashSet::new();
290        let reached = eval_path(inner, &current, graph, &mut inner_vis, steps);
291        for node in reached {
292            if expanded.insert(node.clone()) {
293                queue.push_back(node);
294            }
295        }
296    }
297    expanded.into_iter().collect()
298}
299
300/// Zero-or-one path: either the start node itself or a single-step successor.
301fn eval_zero_or_one(
302    inner: &PropertyPath,
303    start: &str,
304    graph: &RdfGraph<'_>,
305    steps: &mut usize,
306) -> Vec<String> {
307    let mut result: HashSet<String> = HashSet::new();
308    // Zero steps → include start.
309    result.insert(start.to_owned());
310    // One step.
311    let mut vis = HashSet::new();
312    let one_step = eval_path(inner, start, graph, &mut vis, steps);
313    result.extend(one_step);
314    result.into_iter().collect()
315}
316
317/// Remove duplicates while preserving order of first occurrence.
318fn dedup(nodes: Vec<String>) -> Vec<String> {
319    let mut seen = HashSet::new();
320    nodes
321        .into_iter()
322        .filter(|n| seen.insert(n.clone()))
323        .collect()
324}
325
326// ── Builder helpers for PropertyPath ─────────────────────────────────────────
327
328impl PropertyPath {
329    /// Convenience constructor for a predicate path.
330    pub fn predicate(iri: impl Into<String>) -> Self {
331        PropertyPath::Predicate(iri.into())
332    }
333
334    /// Convenience constructor for an inverse path.
335    pub fn inverse(inner: PropertyPath) -> Self {
336        PropertyPath::Inverse(Box::new(inner))
337    }
338
339    /// Convenience constructor for a sequence path.
340    pub fn sequence(parts: Vec<PropertyPath>) -> Self {
341        PropertyPath::Sequence(parts)
342    }
343
344    /// Convenience constructor for an alternative path.
345    pub fn alternative(alts: Vec<PropertyPath>) -> Self {
346        PropertyPath::Alternative(alts)
347    }
348
349    /// Convenience constructor for zero-or-more.
350    pub fn zero_or_more(inner: PropertyPath) -> Self {
351        PropertyPath::ZeroOrMore(Box::new(inner))
352    }
353
354    /// Convenience constructor for one-or-more.
355    pub fn one_or_more(inner: PropertyPath) -> Self {
356        PropertyPath::OneOrMore(Box::new(inner))
357    }
358
359    /// Convenience constructor for zero-or-one.
360    pub fn zero_or_one(inner: PropertyPath) -> Self {
361        PropertyPath::ZeroOrOne(Box::new(inner))
362    }
363}
364
365// ── Tests ─────────────────────────────────────────────────────────────────────
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    /// Build a small RDF graph for testing.
372    fn build_graph() -> Vec<GraphTriple> {
373        vec![
374            // type hierarchy: A → B → C → D
375            GraphTriple::new("A", "parent", "B"),
376            GraphTriple::new("B", "parent", "C"),
377            GraphTriple::new("C", "parent", "D"),
378            // label triples
379            GraphTriple::new("A", "label", "Alice"),
380            GraphTriple::new("B", "label", "Bob"),
381            GraphTriple::new("C", "label", "Carol"),
382            // sibling link A → E
383            GraphTriple::new("A", "sibling", "E"),
384            // back edge for cycle testing
385            GraphTriple::new("D", "parent", "A"),
386        ]
387    }
388
389    // ── Direct predicate path ─────────────────────────────────────────────────
390
391    #[test]
392    fn test_predicate_path_basic() {
393        let triples = build_graph();
394        let graph = RdfGraph::new(&triples);
395        let path = PropertyPath::predicate("parent");
396        let result = PathExecutor::evaluate(&path, "A", &graph);
397        assert!(result.contains("B"), "A parent B");
398        assert!(!result.contains("C"), "A does not directly parent C");
399    }
400
401    #[test]
402    fn test_predicate_path_no_match() {
403        let triples = build_graph();
404        let graph = RdfGraph::new(&triples);
405        let path = PropertyPath::predicate("nonexistent");
406        let result = PathExecutor::evaluate(&path, "A", &graph);
407        assert!(result.is_empty());
408    }
409
410    // ── Inverse path ──────────────────────────────────────────────────────────
411
412    #[test]
413    fn test_inverse_path() {
414        let triples = build_graph();
415        let graph = RdfGraph::new(&triples);
416        // ^parent from B should give A (the node that has B as parent).
417        let path = PropertyPath::inverse(PropertyPath::predicate("parent"));
418        let result = PathExecutor::evaluate(&path, "B", &graph);
419        assert!(result.contains("A"), "^parent from B gives A");
420    }
421
422    #[test]
423    fn test_inverse_path_chain() {
424        let triples = build_graph();
425        let graph = RdfGraph::new(&triples);
426        // ^parent from C should give B.
427        let path = PropertyPath::inverse(PropertyPath::predicate("parent"));
428        let result = PathExecutor::evaluate(&path, "C", &graph);
429        assert!(result.contains("B"));
430    }
431
432    // ── Sequence path ─────────────────────────────────────────────────────────
433
434    #[test]
435    fn test_sequence_path_two_hops() {
436        let triples = build_graph();
437        let graph = RdfGraph::new(&triples);
438        // parent / parent from A should reach C.
439        let path = PropertyPath::sequence(vec![
440            PropertyPath::predicate("parent"),
441            PropertyPath::predicate("parent"),
442        ]);
443        let result = PathExecutor::evaluate(&path, "A", &graph);
444        assert!(result.contains("C"), "A parent/parent C");
445        assert!(!result.contains("B"), "B not in two-hop result");
446    }
447
448    #[test]
449    fn test_sequence_path_three_hops() {
450        let triples = build_graph();
451        let graph = RdfGraph::new(&triples);
452        let path = PropertyPath::sequence(vec![
453            PropertyPath::predicate("parent"),
454            PropertyPath::predicate("parent"),
455            PropertyPath::predicate("parent"),
456        ]);
457        let result = PathExecutor::evaluate(&path, "A", &graph);
458        assert!(result.contains("D"), "A →³ D");
459    }
460
461    // ── Alternative path ──────────────────────────────────────────────────────
462
463    #[test]
464    fn test_alternative_path() {
465        let triples = build_graph();
466        let graph = RdfGraph::new(&triples);
467        // parent | sibling from A should give B and E.
468        let path = PropertyPath::alternative(vec![
469            PropertyPath::predicate("parent"),
470            PropertyPath::predicate("sibling"),
471        ]);
472        let result = PathExecutor::evaluate(&path, "A", &graph);
473        assert!(result.contains("B"), "parent gives B");
474        assert!(result.contains("E"), "sibling gives E");
475    }
476
477    #[test]
478    fn test_alternative_path_deduplication() {
479        // Both alternatives point to the same node → should appear only once.
480        let triples = vec![
481            GraphTriple::new("X", "rel1", "Y"),
482            GraphTriple::new("X", "rel2", "Y"),
483        ];
484        let graph = RdfGraph::new(&triples);
485        let path = PropertyPath::alternative(vec![
486            PropertyPath::predicate("rel1"),
487            PropertyPath::predicate("rel2"),
488        ]);
489        let result = PathExecutor::evaluate(&path, "X", &graph);
490        assert_eq!(result.len(), 1);
491        assert!(result.contains("Y"));
492    }
493
494    // ── Zero-or-more path ─────────────────────────────────────────────────────
495
496    #[test]
497    fn test_zero_or_more_includes_start() {
498        let triples = build_graph();
499        let graph = RdfGraph::new(&triples);
500        let path = PropertyPath::zero_or_more(PropertyPath::predicate("parent"));
501        let result = PathExecutor::evaluate(&path, "A", &graph);
502        // ZeroOrMore always includes the start.
503        assert!(result.contains("A"), "start node included");
504        assert!(result.contains("B"));
505        assert!(result.contains("C"));
506        assert!(result.contains("D"));
507    }
508
509    #[test]
510    fn test_zero_or_more_cycle_prevention() {
511        // Graph has a cycle A → B → C → D → A.
512        let triples = build_graph();
513        let graph = RdfGraph::new(&triples);
514        let path = PropertyPath::zero_or_more(PropertyPath::predicate("parent"));
515        // Should terminate even with the cycle and not loop forever.
516        let result = PathExecutor::evaluate(&path, "A", &graph);
517        assert!(result.len() >= 4, "all nodes reachable: {:?}", result);
518    }
519
520    // ── One-or-more path ──────────────────────────────────────────────────────
521
522    #[test]
523    fn test_one_or_more_excludes_start() {
524        let triples = build_graph();
525        let graph = RdfGraph::new(&triples);
526        let path = PropertyPath::one_or_more(PropertyPath::predicate("parent"));
527        let result = PathExecutor::evaluate(&path, "A", &graph);
528        // A may be reachable via the cycle, but direct `A` from zero steps should not appear
529        // unless it is genuinely reached via one+ steps (here it is due to D→A).
530        assert!(result.contains("B"), "B reachable in one step");
531        assert!(result.contains("C"), "C reachable in two steps");
532        assert!(result.contains("D"), "D reachable in three steps");
533    }
534
535    #[test]
536    fn test_one_or_more_no_successors() {
537        let triples = vec![GraphTriple::new("X", "p", "Y")];
538        let graph = RdfGraph::new(&triples);
539        // From Y, one+ step of 'p' yields nothing (Y has no outgoing 'p').
540        let path = PropertyPath::one_or_more(PropertyPath::predicate("p"));
541        let result = PathExecutor::evaluate(&path, "Y", &graph);
542        assert!(result.is_empty());
543    }
544
545    // ── Zero-or-one path ──────────────────────────────────────────────────────
546
547    #[test]
548    fn test_zero_or_one_includes_start_and_successor() {
549        let triples = vec![GraphTriple::new("X", "rel", "Y")];
550        let graph = RdfGraph::new(&triples);
551        let path = PropertyPath::zero_or_one(PropertyPath::predicate("rel"));
552        let result = PathExecutor::evaluate(&path, "X", &graph);
553        assert!(result.contains("X"), "zero steps → start");
554        assert!(result.contains("Y"), "one step → Y");
555    }
556
557    #[test]
558    fn test_zero_or_one_no_successors() {
559        let triples: Vec<GraphTriple> = vec![];
560        let graph = RdfGraph::new(&triples);
561        let path = PropertyPath::zero_or_one(PropertyPath::predicate("rel"));
562        let result = PathExecutor::evaluate(&path, "X", &graph);
563        // Zero steps → start node only.
564        assert_eq!(result.len(), 1);
565        assert!(result.contains("X"));
566    }
567
568    // ── Reachability helpers ───────────────────────────────────────────────────
569
570    #[test]
571    fn test_has_any_true() {
572        let triples = build_graph();
573        let graph = RdfGraph::new(&triples);
574        assert!(PathExecutor::has_any(
575            &PropertyPath::predicate("parent"),
576            "A",
577            &graph
578        ));
579    }
580
581    #[test]
582    fn test_has_any_false() {
583        let triples: Vec<GraphTriple> = vec![];
584        let graph = RdfGraph::new(&triples);
585        assert!(!PathExecutor::has_any(
586            &PropertyPath::predicate("anything"),
587            "X",
588            &graph
589        ));
590    }
591
592    #[test]
593    fn test_reaches_target() {
594        let triples = build_graph();
595        let graph = RdfGraph::new(&triples);
596        let path = PropertyPath::zero_or_more(PropertyPath::predicate("parent"));
597        assert!(PathExecutor::reaches(&path, "A", "D", &graph));
598    }
599
600    #[test]
601    fn test_reaches_target_false() {
602        let triples = build_graph();
603        let graph = RdfGraph::new(&triples);
604        let path = PropertyPath::predicate("parent");
605        // A direct parent step from A does not reach D.
606        assert!(!PathExecutor::reaches(&path, "A", "D", &graph));
607    }
608
609    // ── Steps counter ─────────────────────────────────────────────────────────
610
611    #[test]
612    fn test_steps_taken_nonzero_for_traversal() {
613        let triples = build_graph();
614        let graph = RdfGraph::new(&triples);
615        let path = PropertyPath::zero_or_more(PropertyPath::predicate("parent"));
616        let result = PathExecutor::evaluate(&path, "A", &graph);
617        // Steps should be > 0 as the graph was actually traversed.
618        assert!(result.steps_taken > 0, "steps_taken={}", result.steps_taken);
619    }
620
621    // ── Nested paths ──────────────────────────────────────────────────────────
622
623    #[test]
624    fn test_sequence_then_alternative() {
625        let triples = vec![
626            GraphTriple::new("S", "hop", "M"),
627            GraphTriple::new("M", "a", "X"),
628            GraphTriple::new("M", "b", "Y"),
629        ];
630        let graph = RdfGraph::new(&triples);
631        // hop / (a | b)
632        let path = PropertyPath::sequence(vec![
633            PropertyPath::predicate("hop"),
634            PropertyPath::alternative(vec![
635                PropertyPath::predicate("a"),
636                PropertyPath::predicate("b"),
637            ]),
638        ]);
639        let result = PathExecutor::evaluate(&path, "S", &graph);
640        assert!(result.contains("X"));
641        assert!(result.contains("Y"));
642    }
643
644    #[test]
645    fn test_inverse_then_zero_or_more() {
646        // Graph: A → B → C  with labels
647        let triples = vec![
648            GraphTriple::new("A", "child", "B"),
649            GraphTriple::new("B", "child", "C"),
650        ];
651        let graph = RdfGraph::new(&triples);
652        // ^child* from B → B, A  (inverse step then zero-or-more inverse)
653        let path =
654            PropertyPath::zero_or_more(PropertyPath::inverse(PropertyPath::predicate("child")));
655        let result = PathExecutor::evaluate(&path, "B", &graph);
656        assert!(result.contains("B"), "start included");
657        assert!(result.contains("A"), "A reaches B via child");
658    }
659
660    // ── Graph result collection ────────────────────────────────────────────────
661
662    #[test]
663    fn test_path_result_len_and_is_empty() {
664        let r = PathResult {
665            nodes: vec!["X".into(), "Y".into()],
666            steps_taken: 2,
667        };
668        assert_eq!(r.len(), 2);
669        assert!(!r.is_empty());
670
671        let empty = PathResult {
672            nodes: vec![],
673            steps_taken: 0,
674        };
675        assert!(empty.is_empty());
676        assert_eq!(empty.len(), 0);
677    }
678
679    #[test]
680    fn test_path_result_contains() {
681        let r = PathResult {
682            nodes: vec!["Alpha".into(), "Beta".into()],
683            steps_taken: 2,
684        };
685        assert!(r.contains("Alpha"));
686        assert!(!r.contains("Gamma"));
687    }
688
689    // ── Graph triple construction ─────────────────────────────────────────────
690
691    #[test]
692    fn test_graph_triple_new() {
693        let t = GraphTriple::new("s", "p", "o");
694        assert_eq!(t.subject, "s");
695        assert_eq!(t.predicate, "p");
696        assert_eq!(t.object, "o");
697    }
698
699    #[test]
700    fn test_rdf_graph_objects_of() {
701        let triples = vec![
702            GraphTriple::new("A", "p", "B"),
703            GraphTriple::new("A", "p", "C"),
704            GraphTriple::new("B", "p", "D"),
705        ];
706        let graph = RdfGraph::new(&triples);
707        let objs = graph.objects_of("A", "p");
708        assert_eq!(objs.len(), 2);
709        assert!(objs.contains(&"B".to_owned()));
710        assert!(objs.contains(&"C".to_owned()));
711    }
712
713    #[test]
714    fn test_rdf_graph_subjects_of() {
715        let triples = vec![
716            GraphTriple::new("A", "p", "Z"),
717            GraphTriple::new("B", "p", "Z"),
718        ];
719        let graph = RdfGraph::new(&triples);
720        let subs = graph.subjects_of("p", "Z");
721        assert_eq!(subs.len(), 2);
722        assert!(subs.contains(&"A".to_owned()));
723        assert!(subs.contains(&"B".to_owned()));
724    }
725
726    // ── Additional coverage ───────────────────────────────────────────────────
727
728    #[test]
729    fn test_predicate_path_multiple_objects() {
730        let triples = vec![
731            GraphTriple::new("X", "rel", "Y1"),
732            GraphTriple::new("X", "rel", "Y2"),
733            GraphTriple::new("X", "rel", "Y3"),
734        ];
735        let graph = RdfGraph::new(&triples);
736        let path = PropertyPath::predicate("rel");
737        let result = PathExecutor::evaluate(&path, "X", &graph);
738        assert_eq!(result.len(), 3);
739    }
740
741    #[test]
742    fn test_zero_or_more_single_hop() {
743        let triples = vec![GraphTriple::new("A", "p", "B")];
744        let graph = RdfGraph::new(&triples);
745        let path = PropertyPath::zero_or_more(PropertyPath::predicate("p"));
746        let result = PathExecutor::evaluate(&path, "A", &graph);
747        assert!(result.contains("A"));
748        assert!(result.contains("B"));
749    }
750
751    #[test]
752    fn test_one_or_more_single_hop() {
753        let triples = vec![GraphTriple::new("A", "p", "B")];
754        let graph = RdfGraph::new(&triples);
755        let path = PropertyPath::one_or_more(PropertyPath::predicate("p"));
756        let result = PathExecutor::evaluate(&path, "A", &graph);
757        assert!(result.contains("B"));
758        assert!(
759            !result.contains("A"),
760            "start excluded for one-or-more unless reachable via cycle"
761        );
762    }
763
764    #[test]
765    fn test_zero_or_one_at_most_one_step() {
766        let triples = vec![
767            GraphTriple::new("A", "p", "B"),
768            GraphTriple::new("B", "p", "C"),
769        ];
770        let graph = RdfGraph::new(&triples);
771        let path = PropertyPath::zero_or_one(PropertyPath::predicate("p"));
772        let result = PathExecutor::evaluate(&path, "A", &graph);
773        // Zero steps → A, one step → B; NOT two steps → C.
774        assert!(result.contains("A"));
775        assert!(result.contains("B"));
776        assert!(!result.contains("C"));
777    }
778
779    #[test]
780    fn test_alternative_path_single_element() {
781        let triples = vec![GraphTriple::new("X", "q", "Y")];
782        let graph = RdfGraph::new(&triples);
783        let path = PropertyPath::alternative(vec![PropertyPath::predicate("q")]);
784        let result = PathExecutor::evaluate(&path, "X", &graph);
785        assert!(result.contains("Y"));
786    }
787
788    #[test]
789    fn test_sequence_path_single_segment() {
790        let triples = vec![GraphTriple::new("X", "rel", "Y")];
791        let graph = RdfGraph::new(&triples);
792        let path = PropertyPath::sequence(vec![PropertyPath::predicate("rel")]);
793        let result = PathExecutor::evaluate(&path, "X", &graph);
794        assert!(result.contains("Y"));
795    }
796
797    #[test]
798    fn test_empty_graph_always_empty_result() {
799        let triples: Vec<GraphTriple> = vec![];
800        let graph = RdfGraph::new(&triples);
801        let path = PropertyPath::one_or_more(PropertyPath::predicate("p"));
802        let result = PathExecutor::evaluate(&path, "X", &graph);
803        assert!(result.is_empty());
804    }
805
806    #[test]
807    fn test_inverse_path_no_subjects() {
808        let triples = vec![GraphTriple::new("A", "p", "B")];
809        let graph = RdfGraph::new(&triples);
810        // ^p from A should find nodes that have A as object via p.  None here.
811        let path = PropertyPath::inverse(PropertyPath::predicate("p"));
812        let result = PathExecutor::evaluate(&path, "A", &graph);
813        assert!(result.is_empty());
814    }
815
816    #[test]
817    fn test_zero_or_more_deep_chain() {
818        // Linear chain depth 5: A → B → C → D → E → F.
819        let triples = vec![
820            GraphTriple::new("A", "next", "B"),
821            GraphTriple::new("B", "next", "C"),
822            GraphTriple::new("C", "next", "D"),
823            GraphTriple::new("D", "next", "E"),
824            GraphTriple::new("E", "next", "F"),
825        ];
826        let graph = RdfGraph::new(&triples);
827        let path = PropertyPath::zero_or_more(PropertyPath::predicate("next"));
828        let result = PathExecutor::evaluate(&path, "A", &graph);
829        assert_eq!(result.len(), 6, "all 6 nodes reachable: {:?}", result);
830    }
831
832    #[test]
833    fn test_property_path_equality() {
834        let p1 = PropertyPath::predicate("http://example.org/p");
835        let p2 = PropertyPath::predicate("http://example.org/p");
836        assert_eq!(p1, p2);
837    }
838
839    #[test]
840    fn test_property_path_inverse_equality() {
841        let p = PropertyPath::inverse(PropertyPath::predicate("p"));
842        let q = PropertyPath::inverse(PropertyPath::predicate("p"));
843        assert_eq!(p, q);
844    }
845
846    #[test]
847    fn test_graph_triple_hash_equality() {
848        use std::collections::HashSet;
849        let mut set = HashSet::new();
850        set.insert(GraphTriple::new("s", "p", "o"));
851        set.insert(GraphTriple::new("s", "p", "o")); // duplicate
852        assert_eq!(set.len(), 1);
853    }
854
855    #[test]
856    fn test_sequence_empty_gives_start_node() {
857        let triples: Vec<GraphTriple> = vec![];
858        let graph = RdfGraph::new(&triples);
859        let path = PropertyPath::sequence(vec![]);
860        // An empty sequence resolves to just the start node.
861        let result = PathExecutor::evaluate(&path, "S", &graph);
862        // frontier starts as ["S"], no segments to process → ["S"].
863        assert!(result.contains("S"));
864    }
865
866    #[test]
867    fn test_alternative_empty_gives_empty() {
868        let triples: Vec<GraphTriple> = vec![];
869        let graph = RdfGraph::new(&triples);
870        let path = PropertyPath::alternative(vec![PropertyPath::predicate("p")]);
871        let result = PathExecutor::evaluate(&path, "X", &graph);
872        assert!(result.is_empty());
873    }
874
875    #[test]
876    fn test_reaches_self_via_zero_or_more() {
877        let triples: Vec<GraphTriple> = vec![];
878        let graph = RdfGraph::new(&triples);
879        let path = PropertyPath::zero_or_more(PropertyPath::predicate("any"));
880        // ZeroOrMore always includes the start (zero steps).
881        assert!(PathExecutor::reaches(&path, "X", "X", &graph));
882    }
883
884    #[test]
885    fn test_steps_taken_zero_for_empty_predicate() {
886        let triples: Vec<GraphTriple> = vec![];
887        let graph = RdfGraph::new(&triples);
888        let path = PropertyPath::predicate("p");
889        let result = PathExecutor::evaluate(&path, "X", &graph);
890        assert_eq!(result.steps_taken, 0);
891    }
892}