Skip to main content

fakecloud_stepfunctions/
choice.rs

1use serde_json::Value;
2
3use crate::io_processing::resolve_path;
4
5/// Evaluate a Choice state's rules against the input and return the Next state name.
6/// Returns None if no rule matches and there's no Default.
7pub fn evaluate_choice(state_def: &Value, input: &Value) -> Option<String> {
8    if let Some(choices) = state_def["Choices"].as_array() {
9        for choice in choices {
10            if evaluate_rule(choice, input) {
11                return choice["Next"].as_str().map(|s| s.to_string());
12            }
13        }
14    }
15
16    // Fall through to Default
17    state_def["Default"].as_str().map(|s| s.to_string())
18}
19
20/// Evaluate a single choice rule (may be compound via And/Or/Not).
21fn evaluate_rule(rule: &Value, input: &Value) -> bool {
22    // Logical operators
23    if let Some(and_rules) = rule["And"].as_array() {
24        return and_rules.iter().all(|r| evaluate_rule(r, input));
25    }
26    if let Some(or_rules) = rule["Or"].as_array() {
27        return or_rules.iter().any(|r| evaluate_rule(r, input));
28    }
29    if rule.get("Not").is_some() {
30        return !evaluate_rule(&rule["Not"], input);
31    }
32
33    // Get the variable value
34    let variable = match rule["Variable"].as_str() {
35        Some(v) => v,
36        None => return false,
37    };
38    let value = resolve_path(input, variable);
39
40    // Presence/type checks
41    if let Some(expected) = rule.get("IsPresent") {
42        let is_present = !value.is_null();
43        return expected.as_bool().unwrap_or(false) == is_present;
44    }
45    if let Some(expected) = rule.get("IsNull") {
46        let is_null = value.is_null();
47        return expected.as_bool().unwrap_or(false) == is_null;
48    }
49    if let Some(expected) = rule.get("IsNumeric") {
50        let is_numeric = value.is_number();
51        return expected.as_bool().unwrap_or(false) == is_numeric;
52    }
53    if let Some(expected) = rule.get("IsString") {
54        let is_string = value.is_string();
55        return expected.as_bool().unwrap_or(false) == is_string;
56    }
57    if let Some(expected) = rule.get("IsBoolean") {
58        let is_boolean = value.is_boolean();
59        return expected.as_bool().unwrap_or(false) == is_boolean;
60    }
61    if let Some(expected) = rule.get("IsTimestamp") {
62        let is_ts = value
63            .as_str()
64            .map(|s| chrono::DateTime::parse_from_rfc3339(s).is_ok())
65            .unwrap_or(false);
66        return expected.as_bool().unwrap_or(false) == is_ts;
67    }
68
69    // String comparisons
70    if let Some(expected) = rule["StringEquals"].as_str() {
71        return value.as_str() == Some(expected);
72    }
73    if let Some(path) = rule["StringEqualsPath"].as_str() {
74        let other = resolve_path(input, path);
75        return value.as_str().is_some() && value.as_str() == other.as_str();
76    }
77    if let Some(expected) = rule["StringLessThan"].as_str() {
78        return value.as_str().is_some_and(|v| v < expected);
79    }
80    if let Some(expected) = rule["StringGreaterThan"].as_str() {
81        return value.as_str().is_some_and(|v| v > expected);
82    }
83    if let Some(expected) = rule["StringLessThanEquals"].as_str() {
84        return value.as_str().is_some_and(|v| v <= expected);
85    }
86    if let Some(expected) = rule["StringGreaterThanEquals"].as_str() {
87        return value.as_str().is_some_and(|v| v >= expected);
88    }
89    if let Some(pattern) = rule["StringMatches"].as_str() {
90        return value.as_str().is_some_and(|v| string_matches(v, pattern));
91    }
92
93    // Numeric comparisons
94    if let Some(expected) = rule["NumericEquals"].as_f64() {
95        return value.as_f64() == Some(expected);
96    }
97    if let Some(path) = rule["NumericEqualsPath"].as_str() {
98        let other = resolve_path(input, path);
99        return value.as_f64().is_some() && value.as_f64() == other.as_f64();
100    }
101    if let Some(expected) = rule["NumericLessThan"].as_f64() {
102        return value.as_f64().is_some_and(|v| v < expected);
103    }
104    if let Some(expected) = rule["NumericGreaterThan"].as_f64() {
105        return value.as_f64().is_some_and(|v| v > expected);
106    }
107    if let Some(expected) = rule["NumericLessThanEquals"].as_f64() {
108        return value.as_f64().is_some_and(|v| v <= expected);
109    }
110    if let Some(expected) = rule["NumericGreaterThanEquals"].as_f64() {
111        return value.as_f64().is_some_and(|v| v >= expected);
112    }
113
114    // Boolean comparisons
115    if let Some(expected) = rule["BooleanEquals"].as_bool() {
116        return value.as_bool() == Some(expected);
117    }
118    if let Some(path) = rule["BooleanEqualsPath"].as_str() {
119        let other = resolve_path(input, path);
120        return value.as_bool().is_some() && value.as_bool() == other.as_bool();
121    }
122
123    // Timestamp comparisons
124    if let Some(expected) = rule["TimestampEquals"].as_str() {
125        return compare_timestamps(&value, expected, |a, b| a == b);
126    }
127    if let Some(expected) = rule["TimestampLessThan"].as_str() {
128        return compare_timestamps(&value, expected, |a, b| a < b);
129    }
130    if let Some(expected) = rule["TimestampGreaterThan"].as_str() {
131        return compare_timestamps(&value, expected, |a, b| a > b);
132    }
133    if let Some(expected) = rule["TimestampLessThanEquals"].as_str() {
134        return compare_timestamps(&value, expected, |a, b| a <= b);
135    }
136    if let Some(expected) = rule["TimestampGreaterThanEquals"].as_str() {
137        return compare_timestamps(&value, expected, |a, b| a >= b);
138    }
139
140    false
141}
142
143/// Compare two RFC3339 timestamps using the provided comparison function.
144fn compare_timestamps<F>(value: &Value, expected: &str, cmp: F) -> bool
145where
146    F: Fn(chrono::DateTime<chrono::FixedOffset>, chrono::DateTime<chrono::FixedOffset>) -> bool,
147{
148    let val_str = match value.as_str() {
149        Some(s) => s,
150        None => return false,
151    };
152    let val_ts = match chrono::DateTime::parse_from_rfc3339(val_str) {
153        Ok(t) => t,
154        Err(_) => return false,
155    };
156    let exp_ts = match chrono::DateTime::parse_from_rfc3339(expected) {
157        Ok(t) => t,
158        Err(_) => return false,
159    };
160    cmp(val_ts, exp_ts)
161}
162
163/// Glob-style pattern matching for StringMatches.
164/// Supports `*` (matches any sequence) and `\*` (literal asterisk).
165fn string_matches(value: &str, pattern: &str) -> bool {
166    let mut pattern_chars: Vec<char> = pattern.chars().collect();
167    let value_chars: Vec<char> = value.chars().collect();
168
169    // Preprocess: handle escaped asterisks
170    let mut segments: Vec<PatternSegment> = Vec::new();
171    let mut current = String::new();
172    let mut i = 0;
173    while i < pattern_chars.len() {
174        if pattern_chars[i] == '\\' && i + 1 < pattern_chars.len() && pattern_chars[i + 1] == '*' {
175            current.push('*');
176            i += 2;
177        } else if pattern_chars[i] == '*' {
178            if !current.is_empty() {
179                segments.push(PatternSegment::Literal(current.clone()));
180                current.clear();
181            }
182            segments.push(PatternSegment::Wildcard);
183            i += 1;
184        } else {
185            current.push(pattern_chars[i]);
186            i += 1;
187        }
188    }
189    if !current.is_empty() {
190        segments.push(PatternSegment::Literal(current));
191    }
192
193    // Use cleaned-up pattern_chars for matching
194    pattern_chars = Vec::new();
195    for seg in &segments {
196        match seg {
197            PatternSegment::Literal(s) => {
198                for c in s.chars() {
199                    pattern_chars.push(c);
200                }
201            }
202            PatternSegment::Wildcard => {
203                pattern_chars.push('\0'); // sentinel for wildcard
204            }
205        }
206    }
207
208    // DP matching
209    let m = value_chars.len();
210    let n = pattern_chars.len();
211    let mut dp = vec![vec![false; n + 1]; m + 1];
212    dp[0][0] = true;
213
214    // Handle leading wildcards
215    for j in 1..=n {
216        if pattern_chars[j - 1] == '\0' {
217            dp[0][j] = dp[0][j - 1];
218        }
219    }
220
221    for i in 1..=m {
222        for j in 1..=n {
223            if pattern_chars[j - 1] == '\0' {
224                dp[i][j] = dp[i][j - 1] || dp[i - 1][j];
225            } else if pattern_chars[j - 1] == value_chars[i - 1] {
226                dp[i][j] = dp[i - 1][j - 1];
227            }
228        }
229    }
230
231    dp[m][n]
232}
233
234enum PatternSegment {
235    Literal(String),
236    Wildcard,
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use serde_json::json;
243
244    #[test]
245    fn test_string_equals() {
246        let rule = json!({
247            "Variable": "$.status",
248            "StringEquals": "active",
249            "Next": "Active"
250        });
251        let input = json!({"status": "active"});
252        assert!(evaluate_rule(&rule, &input));
253
254        let input = json!({"status": "inactive"});
255        assert!(!evaluate_rule(&rule, &input));
256    }
257
258    #[test]
259    fn test_numeric_greater_than() {
260        let rule = json!({
261            "Variable": "$.count",
262            "NumericGreaterThan": 10,
263            "Next": "High"
264        });
265        let input = json!({"count": 15});
266        assert!(evaluate_rule(&rule, &input));
267
268        let input = json!({"count": 5});
269        assert!(!evaluate_rule(&rule, &input));
270    }
271
272    #[test]
273    fn test_boolean_equals() {
274        let rule = json!({
275            "Variable": "$.enabled",
276            "BooleanEquals": true,
277            "Next": "Enabled"
278        });
279        let input = json!({"enabled": true});
280        assert!(evaluate_rule(&rule, &input));
281
282        let input = json!({"enabled": false});
283        assert!(!evaluate_rule(&rule, &input));
284    }
285
286    #[test]
287    fn test_and_operator() {
288        let rule = json!({
289            "And": [
290                {"Variable": "$.a", "NumericGreaterThan": 0},
291                {"Variable": "$.b", "NumericLessThan": 100}
292            ],
293            "Next": "Both"
294        });
295        let input = json!({"a": 5, "b": 50});
296        assert!(evaluate_rule(&rule, &input));
297
298        let input = json!({"a": -1, "b": 50});
299        assert!(!evaluate_rule(&rule, &input));
300    }
301
302    #[test]
303    fn test_or_operator() {
304        let rule = json!({
305            "Or": [
306                {"Variable": "$.status", "StringEquals": "active"},
307                {"Variable": "$.status", "StringEquals": "pending"}
308            ],
309            "Next": "Valid"
310        });
311        let input = json!({"status": "active"});
312        assert!(evaluate_rule(&rule, &input));
313
314        let input = json!({"status": "closed"});
315        assert!(!evaluate_rule(&rule, &input));
316    }
317
318    #[test]
319    fn test_not_operator() {
320        let rule = json!({
321            "Not": {
322                "Variable": "$.status",
323                "StringEquals": "closed"
324            },
325            "Next": "Open"
326        });
327        let input = json!({"status": "active"});
328        assert!(evaluate_rule(&rule, &input));
329
330        let input = json!({"status": "closed"});
331        assert!(!evaluate_rule(&rule, &input));
332    }
333
334    #[test]
335    fn test_is_present() {
336        let rule = json!({
337            "Variable": "$.optional",
338            "IsPresent": true,
339            "Next": "HasField"
340        });
341        let input = json!({"optional": "value"});
342        assert!(evaluate_rule(&rule, &input));
343
344        let input = json!({"other": "value"});
345        assert!(!evaluate_rule(&rule, &input));
346    }
347
348    #[test]
349    fn test_is_null() {
350        let rule = json!({
351            "Variable": "$.field",
352            "IsNull": true,
353            "Next": "Null"
354        });
355        let input = json!({"field": null});
356        assert!(evaluate_rule(&rule, &input));
357
358        let input = json!({"field": "value"});
359        assert!(!evaluate_rule(&rule, &input));
360    }
361
362    #[test]
363    fn test_is_numeric() {
364        let rule = json!({
365            "Variable": "$.value",
366            "IsNumeric": true,
367            "Next": "Number"
368        });
369        let input = json!({"value": 42});
370        assert!(evaluate_rule(&rule, &input));
371
372        let input = json!({"value": "not a number"});
373        assert!(!evaluate_rule(&rule, &input));
374    }
375
376    #[test]
377    fn test_string_matches() {
378        assert!(string_matches("hello world", "hello*"));
379        assert!(string_matches("hello world", "*world"));
380        assert!(string_matches("hello world", "hello*world"));
381        assert!(string_matches("hello world", "*"));
382        assert!(!string_matches("hello world", "goodbye*"));
383        assert!(string_matches("log-2024-01-15.txt", "log-*.txt"));
384    }
385
386    #[test]
387    fn test_evaluate_choice_with_default() {
388        let state_def = json!({
389            "Type": "Choice",
390            "Choices": [
391                {
392                    "Variable": "$.status",
393                    "StringEquals": "active",
394                    "Next": "ActivePath"
395                }
396            ],
397            "Default": "DefaultPath"
398        });
399        let input = json!({"status": "unknown"});
400        assert_eq!(
401            evaluate_choice(&state_def, &input),
402            Some("DefaultPath".to_string())
403        );
404    }
405
406    #[test]
407    fn test_evaluate_choice_matching() {
408        let state_def = json!({
409            "Type": "Choice",
410            "Choices": [
411                {
412                    "Variable": "$.value",
413                    "NumericGreaterThan": 100,
414                    "Next": "High"
415                },
416                {
417                    "Variable": "$.value",
418                    "NumericLessThanEquals": 100,
419                    "Next": "Low"
420                }
421            ],
422            "Default": "Unknown"
423        });
424        let input = json!({"value": 150});
425        assert_eq!(
426            evaluate_choice(&state_def, &input),
427            Some("High".to_string())
428        );
429
430        let input = json!({"value": 50});
431        assert_eq!(evaluate_choice(&state_def, &input), Some("Low".to_string()));
432    }
433
434    #[test]
435    fn test_evaluate_choice_no_match_no_default() {
436        let state_def = json!({
437            "Type": "Choice",
438            "Choices": [
439                {
440                    "Variable": "$.status",
441                    "StringEquals": "active",
442                    "Next": "Active"
443                }
444            ]
445        });
446        let input = json!({"status": "closed"});
447        assert_eq!(evaluate_choice(&state_def, &input), None);
448    }
449
450    #[test]
451    fn test_numeric_equals_path() {
452        let rule = json!({
453            "Variable": "$.a",
454            "NumericEqualsPath": "$.b",
455            "Next": "Equal"
456        });
457        let input = json!({"a": 42, "b": 42});
458        assert!(evaluate_rule(&rule, &input));
459
460        let input = json!({"a": 42, "b": 99});
461        assert!(!evaluate_rule(&rule, &input));
462    }
463
464    #[test]
465    fn test_timestamp_comparisons() {
466        let rule = json!({
467            "Variable": "$.ts",
468            "TimestampLessThan": "2024-06-01T00:00:00Z",
469            "Next": "Before"
470        });
471        let input = json!({"ts": "2024-01-15T12:00:00Z"});
472        assert!(evaluate_rule(&rule, &input));
473
474        let input = json!({"ts": "2024-12-01T00:00:00Z"});
475        assert!(!evaluate_rule(&rule, &input));
476    }
477
478    #[test]
479    fn test_string_less_than() {
480        let rule = json!({
481            "Variable": "$.name",
482            "StringLessThan": "beta",
483            "Next": "Before"
484        });
485        let input = json!({"name": "alpha"});
486        assert!(evaluate_rule(&rule, &input));
487
488        let input = json!({"name": "gamma"});
489        assert!(!evaluate_rule(&rule, &input));
490    }
491}