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    #[test]
613    fn test_locy_yield_key_with_property() {
614        let program = parse_locy(
615            "CREATE RULE r AS MATCH (e:Event) YIELD KEY e.action, KEY e.outcome, n AS support",
616        )
617        .unwrap();
618        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
619            if let locy_ast::RuleOutput::Yield(yc) = &rule.output {
620                let items = &yc.items;
621                assert_eq!(items.len(), 3);
622                assert!(items[0].is_key);
623                assert_eq!(
624                    items[0].expr,
625                    ast::Expr::Property(
626                        Box::new(ast::Expr::Variable("e".to_string())),
627                        "action".to_string()
628                    )
629                );
630                assert!(items[1].is_key);
631                assert_eq!(
632                    items[1].expr,
633                    ast::Expr::Property(
634                        Box::new(ast::Expr::Variable("e".to_string())),
635                        "outcome".to_string()
636                    )
637                );
638                assert!(!items[2].is_key);
639                assert_eq!(items[2].alias, Some("support".to_string()));
640            } else {
641                panic!("Expected Yield output");
642            }
643        } else {
644            panic!("Expected Rule");
645        }
646    }
647
648    #[test]
649    fn test_locy_yield_key_with_alias() {
650        let program =
651            parse_locy("CREATE RULE r AS MATCH (e:Event) YIELD KEY e.action AS act").unwrap();
652        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
653            if let locy_ast::RuleOutput::Yield(yc) = &rule.output {
654                assert!(yc.items[0].is_key);
655                assert_eq!(yc.items[0].alias, Some("act".to_string()));
656            } else {
657                panic!("Expected Yield output");
658            }
659        } else {
660            panic!("Expected Rule");
661        }
662    }
663
664    // ══════════════════════════════════════════════════════════════════════
665    // Step 16: QUERY (goal-directed)
666    // ══════════════════════════════════════════════════════════════════════
667
668    #[test]
669    fn test_locy_goal_query() {
670        let program = parse_locy("QUERY reachable WHERE a.name = 'Alice' RETURN b").unwrap();
671        assert_eq!(program.statements.len(), 1);
672        if let locy_ast::LocyStatement::GoalQuery(gq) = &program.statements[0] {
673            assert_eq!(gq.rule_name.parts, vec!["reachable"]);
674            assert!(gq.return_clause.is_some());
675        } else {
676            panic!("Expected GoalQuery, got: {:?}", program.statements[0]);
677        }
678    }
679
680    #[test]
681    fn test_locy_goal_query_no_return() {
682        let program = parse_locy("QUERY reachable WHERE a.name = 'Alice'").unwrap();
683        if let locy_ast::LocyStatement::GoalQuery(gq) = &program.statements[0] {
684            assert!(gq.return_clause.is_none());
685        } else {
686            panic!("Expected GoalQuery");
687        }
688    }
689
690    // ══════════════════════════════════════════════════════════════════════
691    // Step 17: ASSUME ... THEN
692    // ══════════════════════════════════════════════════════════════════════
693
694    #[test]
695    fn test_locy_assume_block() {
696        let program = parse_locy("ASSUME { CREATE (x:Temp) } THEN { MATCH (n) RETURN n }").unwrap();
697        assert_eq!(program.statements.len(), 1);
698        if let locy_ast::LocyStatement::AssumeBlock(ab) = &program.statements[0] {
699            assert_eq!(ab.mutations.len(), 1);
700            assert!(matches!(&ab.mutations[0], ast::Clause::Create(_)));
701            assert_eq!(ab.body.len(), 1);
702            assert!(matches!(&ab.body[0], locy_ast::LocyStatement::Cypher(_)));
703        } else {
704            panic!("Expected AssumeBlock, got: {:?}", program.statements[0]);
705        }
706    }
707
708    // ══════════════════════════════════════════════════════════════════════
709    // Step 18: ABDUCE
710    // ══════════════════════════════════════════════════════════════════════
711
712    #[test]
713    fn test_locy_abduce_query() {
714        let program = parse_locy("ABDUCE NOT reachable WHERE a.name = 'Alice' RETURN b").unwrap();
715        assert_eq!(program.statements.len(), 1);
716        if let locy_ast::LocyStatement::AbduceQuery(aq) = &program.statements[0] {
717            assert!(aq.negated);
718            assert_eq!(aq.rule_name.parts, vec!["reachable"]);
719            assert!(aq.return_clause.is_some());
720        } else {
721            panic!("Expected AbduceQuery, got: {:?}", program.statements[0]);
722        }
723    }
724
725    #[test]
726    fn test_locy_abduce_query_positive() {
727        let program = parse_locy("ABDUCE reachable WHERE a.name = 'Bob'").unwrap();
728        if let locy_ast::LocyStatement::AbduceQuery(aq) = &program.statements[0] {
729            assert!(!aq.negated);
730            assert!(aq.return_clause.is_none());
731        } else {
732            panic!("Expected AbduceQuery");
733        }
734    }
735
736    // ══════════════════════════════════════════════════════════════════════
737    // Step 19: EXPLAIN RULE
738    // ══════════════════════════════════════════════════════════════════════
739
740    #[test]
741    fn test_locy_explain_rule() {
742        let program = parse_locy("EXPLAIN RULE reachable WHERE a.name = 'Alice'").unwrap();
743        assert_eq!(program.statements.len(), 1);
744        if let locy_ast::LocyStatement::ExplainRule(eq) = &program.statements[0] {
745            assert_eq!(eq.rule_name.parts, vec!["reachable"]);
746            assert!(eq.return_clause.is_none());
747        } else {
748            panic!("Expected ExplainRule, got: {:?}", program.statements[0]);
749        }
750    }
751
752    // ══════════════════════════════════════════════════════════════════════
753    // Step 20: MODULE / USE
754    // ══════════════════════════════════════════════════════════════════════
755
756    #[test]
757    fn test_locy_module_use() {
758        let program =
759            parse_locy("MODULE acme.compliance\nUSE acme.common\nMATCH (n) RETURN n").unwrap();
760        assert!(program.module.is_some());
761        assert_eq!(
762            program.module.as_ref().unwrap().name.parts,
763            vec!["acme", "compliance"]
764        );
765        assert_eq!(program.uses.len(), 1);
766        assert_eq!(program.uses[0].name.parts, vec!["acme", "common"]);
767        assert_eq!(program.statements.len(), 1);
768        assert!(matches!(
769            &program.statements[0],
770            locy_ast::LocyStatement::Cypher(_)
771        ));
772    }
773
774    #[test]
775    fn test_locy_module_multiple_uses() {
776        let program =
777            parse_locy("MODULE mymod\nUSE dep1\nUSE dep2.sub\nMATCH (n) RETURN n").unwrap();
778        assert_eq!(program.uses.len(), 2);
779        assert_eq!(program.uses[0].name.parts, vec!["dep1"]);
780        assert_eq!(program.uses[1].name.parts, vec!["dep2", "sub"]);
781    }
782
783    // ══════════════════════════════════════════════════════════════════════
784    // Bonus: Complex multi-clause rule
785    // ══════════════════════════════════════════════════════════════════════
786
787    #[test]
788    fn test_locy_complex_rule_all_clauses() {
789        let program = parse_locy(
790            "CREATE RULE shortest_path PRIORITY 1 AS \
791             MATCH (a)-[:EDGE {weight: w}]->(b) \
792             WHERE a IS reachable TO b, w > 0 \
793             ALONG dist = prev.dist + w \
794             FOLD total = SUM(dist) \
795             BEST BY dist ASC \
796             YIELD KEY a, KEY b, dist",
797        )
798        .unwrap();
799        if let locy_ast::LocyStatement::Rule(rule) = &program.statements[0] {
800            assert_eq!(rule.name.parts, vec!["shortest_path"]);
801            assert_eq!(rule.priority, Some(1));
802            assert_eq!(rule.where_conditions.len(), 2);
803            assert_eq!(rule.along.len(), 1);
804            assert_eq!(rule.fold.len(), 1);
805            let best_by = rule.best_by.as_ref().unwrap();
806            assert_eq!(best_by.items.len(), 1);
807            assert!(best_by.items[0].ascending);
808            if let locy_ast::RuleOutput::Yield(yc) = &rule.output {
809                let items = &yc.items;
810                assert_eq!(items.len(), 3);
811                assert!(items[0].is_key);
812                assert!(items[1].is_key);
813                assert!(!items[2].is_key);
814            } else {
815                panic!("Expected Yield");
816            }
817        } else {
818            panic!("Expected Rule");
819        }
820    }
821
822    // ══════════════════════════════════════════════════════════════════════
823    // Bonus: DERIVE command (top-level)
824    // ══════════════════════════════════════════════════════════════════════
825
826    #[test]
827    fn test_locy_derive_command() {
828        let program = parse_locy("DERIVE reachable WHERE a.name = 'Alice'").unwrap();
829        assert_eq!(program.statements.len(), 1);
830        if let locy_ast::LocyStatement::DeriveCommand(dc) = &program.statements[0] {
831            assert_eq!(dc.rule_name.parts, vec!["reachable"]);
832            assert!(dc.where_expr.is_some());
833        } else {
834            panic!("Expected DeriveCommand, got: {:?}", program.statements[0]);
835        }
836    }
837
838    #[test]
839    fn test_locy_derive_command_no_where() {
840        let program = parse_locy("DERIVE reachable").unwrap();
841        if let locy_ast::LocyStatement::DeriveCommand(dc) = &program.statements[0] {
842            assert!(dc.where_expr.is_none());
843        } else {
844            panic!("Expected DeriveCommand");
845        }
846    }
847}