Skip to main content

uni_cypher/
lib.rs

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