product_farm_farmscript/
lib.rs

1//! FarmScript - Human-friendly expression language for Product-FARM
2//!
3//! FarmScript compiles to JSON Logic for execution, providing a natural
4//! syntax for writing business rules and expressions.
5//!
6//! # Example
7//! ```text
8//! // FarmScript
9//! alert_acknowledged and time_since_alert_secs < 120
10//!
11//! // Compiles to JSON Logic
12//! {"and": [{"var": "alert_acknowledged"}, {"<": [{"var": "time_since_alert_secs"}, 120]}]}
13//! ```
14//!
15//! # Features
16//!
17//! - Natural language operators: `is`, `isnt`, `equals`, `same_as`
18//! - Path-style variables: `/users/count`
19//! - Safe division: `a /? b` (returns 0), `a /! b` (returns null)
20//! - Template strings: `` `Hello {name}!` ``
21//! - SQL-like queries: `from items where x > 0 select x * 2`
22//! - Method chaining: `items.filter(x => x > 0).map(x => x * 2)`
23//! - Truthy check: `x?`
24//! - Null coalescing: `a ?? b`
25
26mod token;
27mod lexer;
28mod ast;
29mod parser;
30mod compiler;
31mod builtins;
32
33pub use token::{Token, TokenKind, Span};
34pub use lexer::Lexer;
35pub use ast::{Expr, BinaryOp, UnaryOp, Literal};
36pub use parser::{Parser, ParseError};
37pub use compiler::{Compiler, CompileError, CompileOptions};
38pub use builtins::{BuiltinFn, BUILTINS, FnCategory, get_builtin};
39
40use serde_json::Value as JsonValue;
41
42/// Compile FarmScript source to JSON Logic
43pub fn compile(source: &str) -> Result<JsonValue, CompileError> {
44    compile_with_options(source, &CompileOptions::default())
45}
46
47/// Compile FarmScript source to JSON Logic with options
48pub fn compile_with_options(source: &str, options: &CompileOptions) -> Result<JsonValue, CompileError> {
49    let lexer = Lexer::new(source);
50    let mut parser = Parser::new(lexer);
51    let ast = parser.parse()?;
52    let compiler = Compiler::new(options.clone());
53    compiler.compile(&ast)
54}
55
56/// Parse FarmScript source to AST (for inspection/transformation)
57pub fn parse(source: &str) -> Result<Expr, ParseError> {
58    let lexer = Lexer::new(source);
59    let mut parser = Parser::new(lexer);
60    parser.parse()
61}
62
63/// Tokenize FarmScript source (for debugging)
64pub fn tokenize(source: &str) -> Vec<Token> {
65    let mut lexer = Lexer::new(source);
66    lexer.collect_tokens()
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn test_simple_comparison() {
75        let result = compile("x < 10").unwrap();
76        assert_eq!(result, serde_json::json!({"<": [{"var": "x"}, 10]}));
77    }
78
79    #[test]
80    fn test_and_expression() {
81        let result = compile("a and b").unwrap();
82        assert_eq!(result, serde_json::json!({"and": [{"var": "a"}, {"var": "b"}]}));
83    }
84
85    #[test]
86    fn test_complex_expression() {
87        let result = compile("alert_acknowledged and time_since_alert_secs < 120").unwrap();
88        assert_eq!(
89            result,
90            serde_json::json!({
91                "and": [
92                    {"var": "alert_acknowledged"},
93                    {"<": [{"var": "time_since_alert_secs"}, 120]}
94                ]
95            })
96        );
97    }
98
99    #[test]
100    fn test_clamp_expression() {
101        let result = compile("clamp(0, 100, max_possible_score * (positive_signals - negative_signals * 0.5))").unwrap();
102        // Should produce: max(0, min(100, ...))
103        let json_str = serde_json::to_string(&result).unwrap();
104        assert!(json_str.contains("max"));
105        assert!(json_str.contains("min"));
106    }
107
108    #[test]
109    fn test_if_chain() {
110        let source = r#"
111            if critical_failures > 0 then "strong_no_hire"
112            else if overall_score >= 85 then "strong_hire"
113            else if overall_score >= 65 then "hire"
114            else if overall_score >= 45 then "no_hire"
115            else "strong_no_hire"
116        "#;
117        let result = compile(source).unwrap();
118        let json_str = serde_json::to_string(&result).unwrap();
119        assert!(json_str.contains("if"));
120        assert!(json_str.contains("strong_hire"));
121    }
122
123    #[test]
124    fn test_path_variable() {
125        let result = compile("/users/active/count").unwrap();
126        assert_eq!(result, serde_json::json!({"var": "users.active.count"}));
127    }
128
129    #[test]
130    fn test_safe_division() {
131        let result = compile("revenue /? expenses").unwrap();
132        // Should produce: if expenses == 0 then 0 else revenue / expenses
133        let json_str = serde_json::to_string(&result).unwrap();
134        assert!(json_str.contains("if"));
135        assert!(json_str.contains("/"));
136    }
137
138    #[test]
139    fn test_method_chain() {
140        let result = compile("items.filter(x => x > 0).map(x => x * 2)").unwrap();
141        let json_str = serde_json::to_string(&result).unwrap();
142        assert!(json_str.contains("filter"));
143        assert!(json_str.contains("map"));
144    }
145
146    #[test]
147    fn test_equality_synonyms() {
148        // All should compile to strict equality
149        let sources = ["a === b", "a is b", "a eq b", "a equals b", "a same_as b"];
150        for source in sources {
151            let result = compile(source).unwrap();
152            assert_eq!(
153                result,
154                serde_json::json!({"===": [{"var": "a"}, {"var": "b"}]}),
155                "Failed for: {}", source
156            );
157        }
158    }
159
160    #[test]
161    fn test_in_operator() {
162        let result = compile("x in [1, 2, 3]").unwrap();
163        assert_eq!(result, serde_json::json!({"in": [{"var": "x"}, [1, 2, 3]]}));
164    }
165
166    #[test]
167    fn test_contains_method() {
168        let result = compile("[1, 2, 3].contains(x)").unwrap();
169        assert_eq!(result, serde_json::json!({"in": [{"var": "x"}, [1, 2, 3]]}));
170    }
171
172    #[test]
173    fn test_truthy_operator() {
174        let result = compile("x?").unwrap();
175        assert_eq!(result, serde_json::json!({"!!": {"var": "x"}}));
176    }
177
178    #[test]
179    fn test_null_coalesce() {
180        let result = compile("a ?? b").unwrap();
181        // if a != null then a else b
182        let json_str = serde_json::to_string(&result).unwrap();
183        assert!(json_str.contains("if"));
184        assert!(json_str.contains("!="));
185    }
186
187    #[test]
188    fn test_db_outage_detect_quick_response() {
189        // Real expression from fixtures
190        let source = "alert_acknowledged and time_since_alert_secs < 120";
191        let result = compile(source).unwrap();
192        assert_eq!(
193            result,
194            serde_json::json!({
195                "and": [
196                    {"var": "alert_acknowledged"},
197                    {"<": [{"var": "time_since_alert_secs"}, 120]}
198                ]
199            })
200        );
201    }
202
203    #[test]
204    fn test_db_outage_compute_signal_score() {
205        // Real expression from fixtures (simplified)
206        let source = "clamp(0, 100, max_possible_score * (positive_signals - negative_signals * 0.5))";
207        let result = compile(source).unwrap();
208        // Verify structure
209        let obj = result.as_object().unwrap();
210        assert!(obj.contains_key("max")); // clamp uses max(..., min(...))
211    }
212
213    #[test]
214    fn test_db_outage_compute_recommendation() {
215        // Real expression from fixtures
216        let source = r#"
217            if critical_failures > 0 then "strong_no_hire"
218            else if overall_score >= 85 then "strong_hire"
219            else if overall_score >= 65 then "hire"
220            else if overall_score >= 45 then "no_hire"
221            else "strong_no_hire"
222        "#;
223        let result = compile(source).unwrap();
224        let obj = result.as_object().unwrap();
225        assert!(obj.contains_key("if"));
226    }
227}