Skip to main content

uni_cypher/
lib.rs

1pub mod ast;
2mod grammar;
3pub mod locy_ast;
4
5pub use grammar::{ParseError, parse, parse_expression, parse_locy};
6
7#[cfg(test)]
8mod tests {
9    use super::*;
10
11    #[test]
12    fn test_comprehension_with_complex_where() {
13        let test_cases = vec![
14            // Basic boolean operators
15            (
16                "AND operator",
17                "RETURN [x IN range(1,100) WHERE x > 10 AND x < 50 | x * 2] AS result",
18            ),
19            (
20                "OR operator",
21                "RETURN [x IN nodes WHERE x.active OR x.admin | x.name] AS result",
22            ),
23            (
24                "XOR operator",
25                "RETURN [x IN items WHERE x.flag1 XOR x.flag2 | x.id] AS result",
26            ),
27            // Nested conditions
28            (
29                "Parenthesized OR with AND",
30                "RETURN [x IN list WHERE (x > 0 AND x < 10) OR x = 100 | x] AS result",
31            ),
32            (
33                "Complex nested",
34                "RETURN [x IN data WHERE (x.a AND x.b) OR (x.c AND NOT x.d) | x.value] AS result",
35            ),
36            (
37                "Triple nesting",
38                "RETURN [x IN items WHERE ((x.a OR x.b) AND x.c) OR (x.d AND NOT x.e) | x] AS result",
39            ),
40            // NOT operator variations
41            (
42                "NOT with AND",
43                "RETURN [x IN list WHERE NOT x.deleted AND x.active | x] AS result",
44            ),
45            (
46                "NOT with OR",
47                "RETURN [x IN list WHERE NOT (x.a OR x.b) | x] AS result",
48            ),
49            (
50                "Multiple NOT",
51                "RETURN [x IN list WHERE NOT x.a AND NOT x.b | x] AS result",
52            ),
53            // Filter-only (no map expression) - Previously broken!
54            (
55                "Filter-only with AND",
56                "RETURN [x IN list WHERE x > 5 AND x < 10] AS filtered",
57            ),
58            (
59                "Filter-only with OR",
60                "RETURN [x IN list WHERE x < 0 OR x > 100] AS outliers",
61            ),
62            (
63                "Filter-only complex",
64                "RETURN [x IN data WHERE (x.status = 'active' AND x.verified) OR x.admin] AS users",
65            ),
66            // Pattern comprehensions with complex WHERE
67            (
68                "Pattern with AND",
69                "RETURN [(a)-[:KNOWS]->(b) WHERE b.age > 21 AND b.active | b.name] AS friends",
70            ),
71            (
72                "Pattern with OR",
73                "RETURN [(n)-[:LIKES|LOVES]->(m) WHERE m.public OR n.friend | m] AS items",
74            ),
75            (
76                "Pattern complex",
77                "RETURN [p = (a)-[r]->(b) WHERE (r.weight > 5 AND b.score > 10) OR a.vip | p] AS paths",
78            ),
79            // Combining different comparison operators
80            (
81                "Multiple comparisons",
82                "RETURN [x IN items WHERE x.price > 10 AND x.price < 100 AND x.inStock | x] AS affordable",
83            ),
84            (
85                "String operators",
86                "RETURN [x IN names WHERE x STARTS WITH 'A' AND NOT x ENDS WITH 'z' | x] AS filtered",
87            ),
88            (
89                "IN with AND",
90                "RETURN [x IN numbers WHERE x IN [1,2,3] AND x % 2 = 0 | x * 10] AS even",
91            ),
92            // Property access in complex conditions
93            (
94                "Nested properties",
95                "RETURN [x IN items WHERE x.meta.active AND (x.meta.score > 5 OR x.priority) | x.id] AS result",
96            ),
97            (
98                "Property with NULL",
99                "RETURN [x IN items WHERE x.prop IS NOT NULL AND x.prop > 0 | x] AS valid",
100            ),
101            // All three operators combined
102            (
103                "AND OR XOR mix",
104                "RETURN [x IN list WHERE (x.a AND x.b) OR (x.c XOR x.d) | x] AS result",
105            ),
106            (
107                "Complex mix",
108                "RETURN [x IN data WHERE (x.flag1 OR x.flag2) AND NOT (x.flag3 XOR x.flag4) | x.value] AS result",
109            ),
110        ];
111
112        println!("\n=== Testing Complex Comprehension WHERE Clauses ===\n");
113
114        for (name, query) in test_cases.iter() {
115            match parse(query) {
116                Ok(_) => println!("✅ {}: PASSED", name),
117                Err(e) => panic!("❌ {} FAILED: {:?}\nQuery: {}", name, e, query),
118            }
119        }
120
121        println!(
122            "\n✅ All {} complex comprehension tests passed!",
123            test_cases.len()
124        );
125    }
126
127    #[test]
128    fn test_parse_version_as_of() {
129        let q = parse("MATCH (n) RETURN n VERSION AS OF 'snap123'").unwrap();
130        match q {
131            ast::Query::TimeTravel { query, spec } => {
132                assert!(matches!(*query, ast::Query::Single(_)));
133                assert_eq!(spec, ast::TimeTravelSpec::Version("snap123".to_string()));
134            }
135            _ => panic!("Expected TimeTravel query, got {:?}", q),
136        }
137    }
138
139    #[test]
140    fn test_parse_timestamp_as_of() {
141        let q = parse("MATCH (n) RETURN n TIMESTAMP AS OF '2025-02-01T12:00:00Z'").unwrap();
142        match q {
143            ast::Query::TimeTravel { query, spec } => {
144                assert!(matches!(*query, ast::Query::Single(_)));
145                assert_eq!(
146                    spec,
147                    ast::TimeTravelSpec::Timestamp("2025-02-01T12:00:00Z".to_string())
148                );
149            }
150            _ => panic!("Expected TimeTravel query, got {:?}", q),
151        }
152    }
153
154    #[test]
155    fn test_parse_version_as_of_with_union() {
156        let q =
157            parse("MATCH (n:A) RETURN n UNION MATCH (m:B) RETURN m VERSION AS OF 'snap1'").unwrap();
158        match q {
159            ast::Query::TimeTravel { query, spec } => {
160                assert!(matches!(*query, ast::Query::Union { .. }));
161                assert_eq!(spec, ast::TimeTravelSpec::Version("snap1".to_string()));
162            }
163            _ => panic!("Expected TimeTravel query, got {:?}", q),
164        }
165    }
166
167    #[test]
168    fn test_parse_no_time_travel() {
169        let q = parse("MATCH (n) RETURN n").unwrap();
170        assert!(matches!(q, ast::Query::Single(_)));
171    }
172
173    #[test]
174    fn test_parse_or_relationship_types() {
175        let q = parse("MATCH (n)-[r:KNOWS|HATES]->(x) RETURN r").unwrap();
176        if let ast::Query::Single(single) = q
177            && let ast::Clause::Match(match_clause) = &single.clauses[0]
178            && let ast::PatternElement::Relationship(rel) =
179                &match_clause.pattern.paths[0].elements[1]
180        {
181            assert_eq!(rel.types, vec!["KNOWS", "HATES"]);
182            println!("Parsed types: {:?}", rel.types);
183            return;
184        }
185        panic!("Could not find relationship pattern with OR types");
186    }
187
188    #[test]
189    fn test_parse_vlp_relationship_variable() {
190        // Test that VLP patterns preserve the relationship variable
191        let q = parse("MATCH (a)-[r*1..1]->(b) RETURN r").unwrap();
192        if let ast::Query::Single(single) = q
193            && let ast::Clause::Match(match_clause) = &single.clauses[0]
194            && let ast::PatternElement::Relationship(rel) =
195                &match_clause.pattern.paths[0].elements[1]
196        {
197            assert_eq!(
198                rel.variable,
199                Some("r".to_string()),
200                "VLP should preserve relationship variable 'r'"
201            );
202            assert!(rel.range.is_some(), "VLP should have range");
203            let range = rel.range.as_ref().unwrap();
204            assert_eq!(range.min, Some(1));
205            assert_eq!(range.max, Some(1));
206            println!(
207                "VLP relationship: variable={:?}, range={:?}",
208                rel.variable, rel.range
209            );
210            return;
211        }
212        panic!("Could not find VLP relationship pattern");
213    }
214}
215
216#[cfg(test)]
217mod locy_tests {
218    use super::*;
219
220    // ══════════════════════════════════════════════════════════════════════
221    // Step 1: Cypher passthrough
222    // ══════════════════════════════════════════════════════════════════════
223
224    #[test]
225    fn test_locy_cypher_passthrough_match_return() {
226        let program = parse_locy("MATCH (n) RETURN n").unwrap();
227        assert!(program.module.is_none());
228        assert!(program.uses.is_empty());
229        assert_eq!(program.statements.len(), 1);
230        assert!(
231            matches!(&program.statements[0], locy_ast::LocyStatement::Cypher(_)),
232            "Expected Cypher passthrough, got: {:?}",
233            program.statements[0]
234        );
235    }
236
237    #[test]
238    fn test_locy_cypher_passthrough_create() {
239        let program = parse_locy("CREATE (n:Person {name: 'Alice'})").unwrap();
240        assert_eq!(program.statements.len(), 1);
241        assert!(matches!(
242            &program.statements[0],
243            locy_ast::LocyStatement::Cypher(_)
244        ));
245    }
246
247    #[test]
248    fn test_locy_cypher_passthrough_union() {
249        let program = parse_locy("MATCH (n:A) RETURN n UNION MATCH (m:B) RETURN m").unwrap();
250        assert_eq!(program.statements.len(), 1);
251        if let locy_ast::LocyStatement::Cypher(q) = &program.statements[0] {
252            assert!(matches!(q, ast::Query::Union { .. }));
253        } else {
254            panic!("Expected Cypher union");
255        }
256    }
257
258    #[test]
259    fn test_locy_cypher_passthrough_multi_clause() {
260        let program = parse_locy("MATCH (n) WHERE n.age > 21 WITH n RETURN n.name").unwrap();
261        assert_eq!(program.statements.len(), 1);
262        if let locy_ast::LocyStatement::Cypher(ast::Query::Single(stmt)) = &program.statements[0] {
263            assert_eq!(stmt.clauses.len(), 3); // MATCH, WITH, RETURN
264        } else {
265            panic!("Expected Cypher single query with 3 clauses");
266        }
267    }
268
269    // ══════════════════════════════════════════════════════════════════════
270    // Step 2: CREATE RULE ... YIELD (minimal)
271    // ══════════════════════════════════════════════════════════════════════
272
273    #[test]
274    fn test_locy_create_rule_minimal() {
275        let program =
276            parse_locy("CREATE RULE reachable AS MATCH (a)-[:KNOWS]->(b) YIELD a, b").unwrap();
277        assert_eq!(program.statements.len(), 1);
278        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
279            assert_eq!(rule.name.parts, vec!["reachable"]);
280            assert!(rule.priority.is_none());
281            assert!(!rule.match_pattern.paths.is_empty());
282            assert!(rule.where_conditions.is_empty());
283            if let locy_ast::RuleOutput::Yield(yc) = &rule.output {
284                let items = &yc.items;
285                assert_eq!(items.len(), 2);
286                assert!(!items[0].is_key);
287                assert!(!items[1].is_key);
288            } else {
289                panic!("Expected Yield output");
290            }
291        } else {
292            panic!("Expected Rule statement");
293        }
294    }
295
296    // ══════════════════════════════════════════════════════════════════════
297    // Step 3: PRIORITY clause
298    // ══════════════════════════════════════════════════════════════════════
299
300    #[test]
301    fn test_locy_rule_priority() {
302        let program =
303            parse_locy("CREATE RULE r PRIORITY 2 AS MATCH (a)-[:E]->(b) YIELD a").unwrap();
304        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
305            assert_eq!(rule.priority, Some(2));
306        } else {
307            panic!("Expected Rule");
308        }
309    }
310
311    // ══════════════════════════════════════════════════════════════════════
312    // Step 4: Unary IS reference
313    // ══════════════════════════════════════════════════════════════════════
314
315    #[test]
316    fn test_locy_is_reference_unary() {
317        let program =
318            parse_locy("CREATE RULE test AS MATCH (n) WHERE n IS suspicious YIELD n").unwrap();
319        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
320            assert_eq!(rule.where_conditions.len(), 1);
321            if let locy_ast::RuleCondition::IsReference(is_ref) = &rule.where_conditions[0] {
322                assert_eq!(is_ref.subjects, vec!["n"]);
323                assert_eq!(is_ref.rule_name.parts, vec!["suspicious"]);
324                assert!(is_ref.target.is_none());
325                assert!(!is_ref.negated);
326            } else {
327                panic!("Expected IsReference");
328            }
329        } else {
330            panic!("Expected Rule");
331        }
332    }
333
334    // ══════════════════════════════════════════════════════════════════════
335    // Step 5: IS NOT reference
336    // ══════════════════════════════════════════════════════════════════════
337
338    #[test]
339    fn test_locy_is_not_reference() {
340        let program =
341            parse_locy("CREATE RULE test AS MATCH (n) WHERE n IS NOT clean YIELD n").unwrap();
342        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
343            if let locy_ast::RuleCondition::IsReference(is_ref) = &rule.where_conditions[0] {
344                assert!(is_ref.negated);
345                assert_eq!(is_ref.rule_name.parts, vec!["clean"]);
346            } else {
347                panic!("Expected IsReference");
348            }
349        } else {
350            panic!("Expected Rule");
351        }
352    }
353
354    #[test]
355    fn test_locy_not_is_reference_prefix() {
356        let program =
357            parse_locy("CREATE RULE test AS MATCH (n) WHERE NOT n IS clean YIELD n").unwrap();
358        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
359            if let locy_ast::RuleCondition::IsReference(is_ref) = &rule.where_conditions[0] {
360                assert!(is_ref.negated);
361                assert_eq!(is_ref.rule_name.parts, vec!["clean"]);
362            } else {
363                panic!("Expected IsReference");
364            }
365        } else {
366            panic!("Expected Rule");
367        }
368    }
369
370    // ══════════════════════════════════════════════════════════════════════
371    // Step 6: Binary IS ... TO
372    // ══════════════════════════════════════════════════════════════════════
373
374    #[test]
375    fn test_locy_is_reference_binary() {
376        let program = parse_locy(
377            "CREATE RULE test AS MATCH (a)-[:E]->(b) WHERE a IS reachable TO b YIELD a, b",
378        )
379        .unwrap();
380        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
381            if let locy_ast::RuleCondition::IsReference(is_ref) = &rule.where_conditions[0] {
382                assert_eq!(is_ref.subjects, vec!["a"]);
383                assert_eq!(is_ref.rule_name.parts, vec!["reachable"]);
384                assert_eq!(is_ref.target, Some("b".to_string()));
385                assert!(!is_ref.negated);
386            } else {
387                panic!("Expected IsReference");
388            }
389        } else {
390            panic!("Expected Rule");
391        }
392    }
393
394    // ══════════════════════════════════════════════════════════════════════
395    // Step 7: Tuple IS reference
396    // ══════════════════════════════════════════════════════════════════════
397
398    #[test]
399    fn test_locy_is_reference_tuple() {
400        let program = parse_locy(
401            "CREATE RULE test AS MATCH (x)-[:E]->(y) WHERE (x, y, cost) IS control YIELD x",
402        )
403        .unwrap();
404        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
405            if let locy_ast::RuleCondition::IsReference(is_ref) = &rule.where_conditions[0] {
406                assert_eq!(is_ref.subjects, vec!["x", "y", "cost"]);
407                assert_eq!(is_ref.rule_name.parts, vec!["control"]);
408                assert!(is_ref.target.is_none());
409            } else {
410                panic!("Expected IsReference");
411            }
412        } else {
413            panic!("Expected Rule");
414        }
415    }
416
417    // ══════════════════════════════════════════════════════════════════════
418    // Step 8: Mixed WHERE conditions
419    // ══════════════════════════════════════════════════════════════════════
420
421    #[test]
422    fn test_locy_mixed_where_conditions() {
423        let program =
424            parse_locy("CREATE RULE test AS MATCH (n) WHERE n IS reachable, n.age > 18 YIELD n")
425                .unwrap();
426        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
427            assert_eq!(rule.where_conditions.len(), 2);
428            assert!(matches!(
429                &rule.where_conditions[0],
430                locy_ast::RuleCondition::IsReference(_)
431            ));
432            assert!(matches!(
433                &rule.where_conditions[1],
434                locy_ast::RuleCondition::Expression(_)
435            ));
436        } else {
437            panic!("Expected Rule");
438        }
439    }
440
441    // ══════════════════════════════════════════════════════════════════════
442    // Step 9: ALONG with prev
443    // ══════════════════════════════════════════════════════════════════════
444
445    #[test]
446    fn test_locy_along_clause() {
447        let program = parse_locy(
448            "CREATE RULE test AS MATCH (a)-[:E]->(b) ALONG hops = prev.hops + 1 YIELD a, b, hops",
449        )
450        .unwrap();
451        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
452            assert_eq!(rule.along.len(), 1);
453            assert_eq!(rule.along[0].name, "hops");
454            // The expression should be a BinaryOp(PrevRef("hops"), Add, Cypher(1))
455            if let locy_ast::LocyExpr::BinaryOp { left, op, right } = &rule.along[0].expr {
456                assert!(matches!(left.as_ref(), locy_ast::LocyExpr::PrevRef(f) if f == "hops"));
457                assert_eq!(*op, locy_ast::LocyBinaryOp::Add);
458                assert!(matches!(right.as_ref(), locy_ast::LocyExpr::Cypher(_)));
459            } else {
460                panic!("Expected BinaryOp, got: {:?}", rule.along[0].expr);
461            }
462        } else {
463            panic!("Expected Rule");
464        }
465    }
466
467    // ══════════════════════════════════════════════════════════════════════
468    // Step 10: FOLD
469    // ══════════════════════════════════════════════════════════════════════
470
471    #[test]
472    fn test_locy_fold_clause() {
473        let program = parse_locy(
474            "CREATE RULE test AS MATCH (a)-[:E]->(b) FOLD total = SUM(s) YIELD a, total",
475        )
476        .unwrap();
477        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
478            assert_eq!(rule.fold.len(), 1);
479            assert_eq!(rule.fold[0].name, "total");
480            if let ast::Expr::FunctionCall { name, .. } = &rule.fold[0].aggregate {
481                assert_eq!(name.to_uppercase(), "SUM");
482            } else {
483                panic!("Expected FunctionCall, got: {:?}", rule.fold[0].aggregate);
484            }
485        } else {
486            panic!("Expected Rule");
487        }
488    }
489
490    // ══════════════════════════════════════════════════════════════════════
491    // Step 11: BEST BY
492    // ══════════════════════════════════════════════════════════════════════
493
494    #[test]
495    fn test_locy_best_by_clause() {
496        let program =
497            parse_locy("CREATE RULE test AS MATCH (a)-[:E]->(b) BEST BY cost ASC YIELD a, b")
498                .unwrap();
499        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
500            let best_by = rule.best_by.as_ref().unwrap();
501            assert_eq!(best_by.items.len(), 1);
502            assert!(best_by.items[0].ascending);
503        } else {
504            panic!("Expected Rule");
505        }
506    }
507
508    #[test]
509    fn test_locy_best_by_desc() {
510        let program =
511            parse_locy("CREATE RULE test AS MATCH (a)-[:E]->(b) BEST BY cost DESC YIELD a, b")
512                .unwrap();
513        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
514            let best_by = rule.best_by.as_ref().unwrap();
515            assert!(!best_by.items[0].ascending);
516        } else {
517            panic!("Expected Rule");
518        }
519    }
520
521    // ══════════════════════════════════════════════════════════════════════
522    // Step 12: DERIVE pattern
523    // ══════════════════════════════════════════════════════════════════════
524
525    #[test]
526    fn test_locy_derive_forward() {
527        let program =
528            parse_locy("CREATE RULE test AS MATCH (a)-[:KNOWS]->(b) DERIVE (a)-[:FRIEND]->(b)")
529                .unwrap();
530        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
531            if let locy_ast::RuleOutput::Derive(locy_ast::DeriveClause::Patterns(pats)) =
532                &rule.output
533            {
534                assert_eq!(pats.len(), 1);
535                assert_eq!(pats[0].direction, ast::Direction::Outgoing);
536            } else {
537                panic!("Expected Derive Patterns terminal");
538            }
539        } else {
540            panic!("Expected Rule");
541        }
542    }
543
544    // ══════════════════════════════════════════════════════════════════════
545    // Step 13: DERIVE MERGE
546    // ══════════════════════════════════════════════════════════════════════
547
548    #[test]
549    fn test_locy_derive_merge() {
550        let program =
551            parse_locy("CREATE RULE test AS MATCH (a)-[:SAME]->(b) DERIVE MERGE a, b").unwrap();
552        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
553            if let locy_ast::RuleOutput::Derive(locy_ast::DeriveClause::Merge(a, b)) = &rule.output
554            {
555                assert_eq!(a, "a");
556                assert_eq!(b, "b");
557            } else {
558                panic!("Expected Derive Merge");
559            }
560        } else {
561            panic!("Expected Rule");
562        }
563    }
564
565    // ══════════════════════════════════════════════════════════════════════
566    // Step 14: DERIVE NEW
567    // ══════════════════════════════════════════════════════════════════════
568
569    #[test]
570    fn test_locy_derive_new_backward() {
571        let program =
572            parse_locy("CREATE RULE test AS MATCH (c) DERIVE (NEW x:Country)<-[:IN]-(c)").unwrap();
573        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
574            if let locy_ast::RuleOutput::Derive(locy_ast::DeriveClause::Patterns(pats)) =
575                &rule.output
576            {
577                assert_eq!(pats[0].direction, ast::Direction::Incoming);
578                assert!(pats[0].source.is_new);
579                assert_eq!(pats[0].source.variable, "x");
580                assert_eq!(pats[0].source.labels, vec!["Country"]);
581            } else {
582                panic!("Expected Derive Patterns");
583            }
584        } else {
585            panic!("Expected Rule");
586        }
587    }
588
589    // ══════════════════════════════════════════════════════════════════════
590    // Step 15: YIELD with KEY
591    // ══════════════════════════════════════════════════════════════════════
592
593    #[test]
594    fn test_locy_yield_with_key() {
595        let program =
596            parse_locy("CREATE RULE test AS MATCH (a)-[:E]->(b) YIELD KEY a, KEY b, cost").unwrap();
597        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
598            if let locy_ast::RuleOutput::Yield(yc) = &rule.output {
599                let items = &yc.items;
600                assert_eq!(items.len(), 3);
601                assert!(items[0].is_key);
602                assert!(items[1].is_key);
603                assert!(!items[2].is_key);
604            } else {
605                panic!("Expected Yield output");
606            }
607        } else {
608            panic!("Expected Rule");
609        }
610    }
611
612    // ══════════════════════════════════════════════════════════════════════
613    // Step 16: QUERY (goal-directed)
614    // ══════════════════════════════════════════════════════════════════════
615
616    #[test]
617    fn test_locy_goal_query() {
618        let program = parse_locy("QUERY reachable WHERE a.name = 'Alice' RETURN b").unwrap();
619        assert_eq!(program.statements.len(), 1);
620        if let locy_ast::LocyStatement::GoalQuery(gq) = &program.statements[0] {
621            assert_eq!(gq.rule_name.parts, vec!["reachable"]);
622            assert!(gq.return_clause.is_some());
623        } else {
624            panic!("Expected GoalQuery, got: {:?}", program.statements[0]);
625        }
626    }
627
628    #[test]
629    fn test_locy_goal_query_no_return() {
630        let program = parse_locy("QUERY reachable WHERE a.name = 'Alice'").unwrap();
631        if let locy_ast::LocyStatement::GoalQuery(gq) = &program.statements[0] {
632            assert!(gq.return_clause.is_none());
633        } else {
634            panic!("Expected GoalQuery");
635        }
636    }
637
638    // ══════════════════════════════════════════════════════════════════════
639    // Step 17: ASSUME ... THEN
640    // ══════════════════════════════════════════════════════════════════════
641
642    #[test]
643    fn test_locy_assume_block() {
644        let program = parse_locy("ASSUME { CREATE (x:Temp) } THEN { MATCH (n) RETURN n }").unwrap();
645        assert_eq!(program.statements.len(), 1);
646        if let locy_ast::LocyStatement::AssumeBlock(ab) = &program.statements[0] {
647            assert_eq!(ab.mutations.len(), 1);
648            assert!(matches!(&ab.mutations[0], ast::Clause::Create(_)));
649            assert_eq!(ab.body.len(), 1);
650            assert!(matches!(&ab.body[0], locy_ast::LocyStatement::Cypher(_)));
651        } else {
652            panic!("Expected AssumeBlock, got: {:?}", program.statements[0]);
653        }
654    }
655
656    // ══════════════════════════════════════════════════════════════════════
657    // Step 18: ABDUCE
658    // ══════════════════════════════════════════════════════════════════════
659
660    #[test]
661    fn test_locy_abduce_query() {
662        let program = parse_locy("ABDUCE NOT reachable WHERE a.name = 'Alice' RETURN b").unwrap();
663        assert_eq!(program.statements.len(), 1);
664        if let locy_ast::LocyStatement::AbduceQuery(aq) = &program.statements[0] {
665            assert!(aq.negated);
666            assert_eq!(aq.rule_name.parts, vec!["reachable"]);
667            assert!(aq.return_clause.is_some());
668        } else {
669            panic!("Expected AbduceQuery, got: {:?}", program.statements[0]);
670        }
671    }
672
673    #[test]
674    fn test_locy_abduce_query_positive() {
675        let program = parse_locy("ABDUCE reachable WHERE a.name = 'Bob'").unwrap();
676        if let locy_ast::LocyStatement::AbduceQuery(aq) = &program.statements[0] {
677            assert!(!aq.negated);
678            assert!(aq.return_clause.is_none());
679        } else {
680            panic!("Expected AbduceQuery");
681        }
682    }
683
684    // ══════════════════════════════════════════════════════════════════════
685    // Step 19: EXPLAIN RULE
686    // ══════════════════════════════════════════════════════════════════════
687
688    #[test]
689    fn test_locy_explain_rule() {
690        let program = parse_locy("EXPLAIN RULE reachable WHERE a.name = 'Alice'").unwrap();
691        assert_eq!(program.statements.len(), 1);
692        if let locy_ast::LocyStatement::ExplainRule(eq) = &program.statements[0] {
693            assert_eq!(eq.rule_name.parts, vec!["reachable"]);
694            assert!(eq.return_clause.is_none());
695        } else {
696            panic!("Expected ExplainRule, got: {:?}", program.statements[0]);
697        }
698    }
699
700    // ══════════════════════════════════════════════════════════════════════
701    // Step 20: MODULE / USE
702    // ══════════════════════════════════════════════════════════════════════
703
704    #[test]
705    fn test_locy_module_use() {
706        let program =
707            parse_locy("MODULE acme.compliance\nUSE acme.common\nMATCH (n) RETURN n").unwrap();
708        assert!(program.module.is_some());
709        assert_eq!(
710            program.module.as_ref().unwrap().name.parts,
711            vec!["acme", "compliance"]
712        );
713        assert_eq!(program.uses.len(), 1);
714        assert_eq!(program.uses[0].name.parts, vec!["acme", "common"]);
715        assert_eq!(program.statements.len(), 1);
716        assert!(matches!(
717            &program.statements[0],
718            locy_ast::LocyStatement::Cypher(_)
719        ));
720    }
721
722    #[test]
723    fn test_locy_module_multiple_uses() {
724        let program =
725            parse_locy("MODULE mymod\nUSE dep1\nUSE dep2.sub\nMATCH (n) RETURN n").unwrap();
726        assert_eq!(program.uses.len(), 2);
727        assert_eq!(program.uses[0].name.parts, vec!["dep1"]);
728        assert_eq!(program.uses[1].name.parts, vec!["dep2", "sub"]);
729    }
730
731    // ══════════════════════════════════════════════════════════════════════
732    // Bonus: Complex multi-clause rule
733    // ══════════════════════════════════════════════════════════════════════
734
735    #[test]
736    fn test_locy_complex_rule_all_clauses() {
737        let program = parse_locy(
738            "CREATE RULE shortest_path PRIORITY 1 AS \
739             MATCH (a)-[:EDGE {weight: w}]->(b) \
740             WHERE a IS reachable TO b, w > 0 \
741             ALONG dist = prev.dist + w \
742             FOLD total = SUM(dist) \
743             BEST BY dist ASC \
744             YIELD KEY a, KEY b, dist",
745        )
746        .unwrap();
747        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
748            assert_eq!(rule.name.parts, vec!["shortest_path"]);
749            assert_eq!(rule.priority, Some(1));
750            assert_eq!(rule.where_conditions.len(), 2);
751            assert_eq!(rule.along.len(), 1);
752            assert_eq!(rule.fold.len(), 1);
753            let best_by = rule.best_by.as_ref().unwrap();
754            assert_eq!(best_by.items.len(), 1);
755            assert!(best_by.items[0].ascending);
756            if let locy_ast::RuleOutput::Yield(yc) = &rule.output {
757                let items = &yc.items;
758                assert_eq!(items.len(), 3);
759                assert!(items[0].is_key);
760                assert!(items[1].is_key);
761                assert!(!items[2].is_key);
762            } else {
763                panic!("Expected Yield");
764            }
765        } else {
766            panic!("Expected Rule");
767        }
768    }
769
770    // ══════════════════════════════════════════════════════════════════════
771    // Bonus: DERIVE command (top-level)
772    // ══════════════════════════════════════════════════════════════════════
773
774    #[test]
775    fn test_locy_derive_command() {
776        let program = parse_locy("DERIVE reachable WHERE a.name = 'Alice'").unwrap();
777        assert_eq!(program.statements.len(), 1);
778        if let locy_ast::LocyStatement::DeriveCommand(dc) = &program.statements[0] {
779            assert_eq!(dc.rule_name.parts, vec!["reachable"]);
780            assert!(dc.where_expr.is_some());
781        } else {
782            panic!("Expected DeriveCommand, got: {:?}", program.statements[0]);
783        }
784    }
785
786    #[test]
787    fn test_locy_derive_command_no_where() {
788        let program = parse_locy("DERIVE reachable").unwrap();
789        if let locy_ast::LocyStatement::DeriveCommand(dc) = &program.statements[0] {
790            assert!(dc.where_expr.is_none());
791        } else {
792            panic!("Expected DeriveCommand");
793        }
794    }
795}