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    if let Some(result) = evaluate_logical(rule, input) {
23        return result;
24    }
25
26    let variable = match rule["Variable"].as_str() {
27        Some(v) => v,
28        None => return false,
29    };
30    let value = resolve_path(input, variable);
31
32    if let Some(result) = evaluate_presence_or_type(rule, input, variable, &value) {
33        return result;
34    }
35    if let Some(result) = evaluate_string_comparison(rule, input, &value) {
36        return result;
37    }
38    if let Some(result) = evaluate_numeric_comparison(rule, input, &value) {
39        return result;
40    }
41    if let Some(result) = evaluate_boolean_comparison(rule, input, &value) {
42        return result;
43    }
44    if let Some(result) = evaluate_timestamp_comparison(rule, &value) {
45        return result;
46    }
47
48    false
49}
50
51fn evaluate_logical(rule: &Value, input: &Value) -> Option<bool> {
52    if let Some(and_rules) = rule["And"].as_array() {
53        return Some(and_rules.iter().all(|r| evaluate_rule(r, input)));
54    }
55    if let Some(or_rules) = rule["Or"].as_array() {
56        return Some(or_rules.iter().any(|r| evaluate_rule(r, input)));
57    }
58    if rule.get("Not").is_some() {
59        return Some(!evaluate_rule(&rule["Not"], input));
60    }
61    None
62}
63
64fn evaluate_presence_or_type(
65    rule: &Value,
66    input: &Value,
67    variable: &str,
68    value: &Value,
69) -> Option<bool> {
70    if let Some(expected) = rule.get("IsPresent") {
71        // resolve_path returns Value::Null for both missing and null fields,
72        // so check the parent object directly.
73        let is_present = field_exists_in_input(input, variable);
74        return Some(expected.as_bool().unwrap_or(false) == is_present);
75    }
76    if let Some(expected) = rule.get("IsNull") {
77        return Some(expected.as_bool().unwrap_or(false) == value.is_null());
78    }
79    if let Some(expected) = rule.get("IsNumeric") {
80        return Some(expected.as_bool().unwrap_or(false) == value.is_number());
81    }
82    if let Some(expected) = rule.get("IsString") {
83        return Some(expected.as_bool().unwrap_or(false) == value.is_string());
84    }
85    if let Some(expected) = rule.get("IsBoolean") {
86        return Some(expected.as_bool().unwrap_or(false) == value.is_boolean());
87    }
88    if let Some(expected) = rule.get("IsTimestamp") {
89        let is_ts = value
90            .as_str()
91            .map(|s| chrono::DateTime::parse_from_rfc3339(s).is_ok())
92            .unwrap_or(false);
93        return Some(expected.as_bool().unwrap_or(false) == is_ts);
94    }
95    None
96}
97
98fn evaluate_string_comparison(rule: &Value, input: &Value, value: &Value) -> Option<bool> {
99    if let Some(expected) = rule["StringEquals"].as_str() {
100        return Some(value.as_str() == Some(expected));
101    }
102    if let Some(path) = rule["StringEqualsPath"].as_str() {
103        let other = resolve_path(input, path);
104        return Some(value.as_str().is_some() && value.as_str() == other.as_str());
105    }
106    if let Some(expected) = rule["StringLessThan"].as_str() {
107        return Some(value.as_str().is_some_and(|v| v < expected));
108    }
109    if let Some(expected) = rule["StringGreaterThan"].as_str() {
110        return Some(value.as_str().is_some_and(|v| v > expected));
111    }
112    if let Some(expected) = rule["StringLessThanEquals"].as_str() {
113        return Some(value.as_str().is_some_and(|v| v <= expected));
114    }
115    if let Some(expected) = rule["StringGreaterThanEquals"].as_str() {
116        return Some(value.as_str().is_some_and(|v| v >= expected));
117    }
118    if let Some(pattern) = rule["StringMatches"].as_str() {
119        return Some(value.as_str().is_some_and(|v| string_matches(v, pattern)));
120    }
121    None
122}
123
124fn evaluate_numeric_comparison(rule: &Value, input: &Value, value: &Value) -> Option<bool> {
125    if let Some(expected) = rule["NumericEquals"].as_f64() {
126        return Some(value.as_f64() == Some(expected));
127    }
128    if let Some(path) = rule["NumericEqualsPath"].as_str() {
129        let other = resolve_path(input, path);
130        return Some(value.as_f64().is_some() && value.as_f64() == other.as_f64());
131    }
132    if let Some(expected) = rule["NumericLessThan"].as_f64() {
133        return Some(value.as_f64().is_some_and(|v| v < expected));
134    }
135    if let Some(expected) = rule["NumericGreaterThan"].as_f64() {
136        return Some(value.as_f64().is_some_and(|v| v > expected));
137    }
138    if let Some(expected) = rule["NumericLessThanEquals"].as_f64() {
139        return Some(value.as_f64().is_some_and(|v| v <= expected));
140    }
141    if let Some(expected) = rule["NumericGreaterThanEquals"].as_f64() {
142        return Some(value.as_f64().is_some_and(|v| v >= expected));
143    }
144    None
145}
146
147fn evaluate_boolean_comparison(rule: &Value, input: &Value, value: &Value) -> Option<bool> {
148    if let Some(expected) = rule["BooleanEquals"].as_bool() {
149        return Some(value.as_bool() == Some(expected));
150    }
151    if let Some(path) = rule["BooleanEqualsPath"].as_str() {
152        let other = resolve_path(input, path);
153        return Some(value.as_bool().is_some() && value.as_bool() == other.as_bool());
154    }
155    None
156}
157
158fn evaluate_timestamp_comparison(rule: &Value, value: &Value) -> Option<bool> {
159    if let Some(expected) = rule["TimestampEquals"].as_str() {
160        return Some(compare_timestamps(value, expected, |a, b| a == b));
161    }
162    if let Some(expected) = rule["TimestampLessThan"].as_str() {
163        return Some(compare_timestamps(value, expected, |a, b| a < b));
164    }
165    if let Some(expected) = rule["TimestampGreaterThan"].as_str() {
166        return Some(compare_timestamps(value, expected, |a, b| a > b));
167    }
168    if let Some(expected) = rule["TimestampLessThanEquals"].as_str() {
169        return Some(compare_timestamps(value, expected, |a, b| a <= b));
170    }
171    if let Some(expected) = rule["TimestampGreaterThanEquals"].as_str() {
172        return Some(compare_timestamps(value, expected, |a, b| a >= b));
173    }
174    None
175}
176
177/// Compare two RFC3339 timestamps using the provided comparison function.
178fn compare_timestamps<F>(value: &Value, expected: &str, cmp: F) -> bool
179where
180    F: Fn(chrono::DateTime<chrono::FixedOffset>, chrono::DateTime<chrono::FixedOffset>) -> bool,
181{
182    let val_str = match value.as_str() {
183        Some(s) => s,
184        None => return false,
185    };
186    let val_ts = match chrono::DateTime::parse_from_rfc3339(val_str) {
187        Ok(t) => t,
188        Err(_) => return false,
189    };
190    let exp_ts = match chrono::DateTime::parse_from_rfc3339(expected) {
191        Ok(t) => t,
192        Err(_) => return false,
193    };
194    cmp(val_ts, exp_ts)
195}
196
197/// Glob-style pattern matching for StringMatches.
198/// Supports `*` (matches any sequence) and `\*` (literal asterisk).
199fn string_matches(value: &str, pattern: &str) -> bool {
200    let compiled = compile_glob_pattern(pattern);
201    glob_dp_match(&value.chars().collect::<Vec<_>>(), &compiled)
202}
203
204/// Compile a glob pattern into a token vector where `GlobToken::Wildcard`
205/// represents `*` and `GlobToken::Char(c)` represents a literal character
206/// (including escaped `\*`).
207fn compile_glob_pattern(pattern: &str) -> Vec<GlobToken> {
208    let mut out = Vec::new();
209    let chars: Vec<char> = pattern.chars().collect();
210    let mut i = 0;
211    while i < chars.len() {
212        if chars[i] == '\\' && i + 1 < chars.len() && chars[i + 1] == '*' {
213            out.push(GlobToken::Char('*'));
214            i += 2;
215        } else if chars[i] == '*' {
216            out.push(GlobToken::Wildcard);
217            i += 1;
218        } else {
219            out.push(GlobToken::Char(chars[i]));
220            i += 1;
221        }
222    }
223    out
224}
225
226/// Match a compiled glob pattern against a value using dynamic programming.
227fn glob_dp_match(value: &[char], pattern: &[GlobToken]) -> bool {
228    let m = value.len();
229    let n = pattern.len();
230    let mut dp = vec![vec![false; n + 1]; m + 1];
231    dp[0][0] = true;
232
233    for j in 1..=n {
234        if matches!(pattern[j - 1], GlobToken::Wildcard) {
235            dp[0][j] = dp[0][j - 1];
236        }
237    }
238
239    for i in 1..=m {
240        for j in 1..=n {
241            match pattern[j - 1] {
242                GlobToken::Wildcard => {
243                    dp[i][j] = dp[i][j - 1] || dp[i - 1][j];
244                }
245                GlobToken::Char(c) if c == value[i - 1] => {
246                    dp[i][j] = dp[i - 1][j - 1];
247                }
248                GlobToken::Char(_) => {}
249            }
250        }
251    }
252
253    dp[m][n]
254}
255
256#[derive(Clone, Copy)]
257enum GlobToken {
258    Char(char),
259    Wildcard,
260}
261
262/// Check if a field referenced by a JsonPath expression actually exists in the input,
263/// including fields explicitly set to null. This is different from resolve_path which
264/// returns Value::Null for both missing and null fields.
265/// Handles both object fields ($.foo.bar) and array indices ($.items[0]).
266fn field_exists_in_input(root: &Value, path: &str) -> bool {
267    if path == "$" {
268        return true;
269    }
270    let path = path.strip_prefix("$.").unwrap_or(path);
271    let parts: Vec<&str> = path.split('.').collect();
272    let mut current = root;
273
274    for (i, part) in parts.iter().enumerate() {
275        let is_last = i == parts.len() - 1;
276
277        // Check for array index syntax: field[idx]
278        if let Some(bracket_pos) = part.find('[') {
279            let field_name = &part[..bracket_pos];
280            // Ensure closing bracket exists and is at the end of the segment
281            if !part.ends_with(']') {
282                return false; // malformed segment like "foo[0]extra"
283            }
284            let close_bracket = part.len() - 1;
285            if close_bracket <= bracket_pos {
286                return false;
287            }
288            let idx_str = &part[bracket_pos + 1..close_bracket];
289
290            match current.get(field_name) {
291                Some(arr) => {
292                    if let Ok(idx) = idx_str.parse::<usize>() {
293                        if is_last {
294                            return arr.as_array().is_some_and(|a| idx < a.len());
295                        }
296                        match arr.get(idx) {
297                            Some(v) => current = v,
298                            None => return false,
299                        }
300                    } else {
301                        return false;
302                    }
303                }
304                None => return false,
305            }
306        } else if is_last {
307            return match current.as_object() {
308                Some(obj) => obj.contains_key(*part),
309                None => false,
310            };
311        } else {
312            match current.get(*part) {
313                Some(v) => current = v,
314                None => return false,
315            }
316        }
317    }
318    false
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use serde_json::json;
325
326    #[test]
327    fn test_string_equals() {
328        let rule = json!({
329            "Variable": "$.status",
330            "StringEquals": "active",
331            "Next": "Active"
332        });
333        let input = json!({"status": "active"});
334        assert!(evaluate_rule(&rule, &input));
335
336        let input = json!({"status": "inactive"});
337        assert!(!evaluate_rule(&rule, &input));
338    }
339
340    #[test]
341    fn test_numeric_greater_than() {
342        let rule = json!({
343            "Variable": "$.count",
344            "NumericGreaterThan": 10,
345            "Next": "High"
346        });
347        let input = json!({"count": 15});
348        assert!(evaluate_rule(&rule, &input));
349
350        let input = json!({"count": 5});
351        assert!(!evaluate_rule(&rule, &input));
352    }
353
354    #[test]
355    fn test_boolean_equals() {
356        let rule = json!({
357            "Variable": "$.enabled",
358            "BooleanEquals": true,
359            "Next": "Enabled"
360        });
361        let input = json!({"enabled": true});
362        assert!(evaluate_rule(&rule, &input));
363
364        let input = json!({"enabled": false});
365        assert!(!evaluate_rule(&rule, &input));
366    }
367
368    #[test]
369    fn test_and_operator() {
370        let rule = json!({
371            "And": [
372                {"Variable": "$.a", "NumericGreaterThan": 0},
373                {"Variable": "$.b", "NumericLessThan": 100}
374            ],
375            "Next": "Both"
376        });
377        let input = json!({"a": 5, "b": 50});
378        assert!(evaluate_rule(&rule, &input));
379
380        let input = json!({"a": -1, "b": 50});
381        assert!(!evaluate_rule(&rule, &input));
382    }
383
384    #[test]
385    fn test_or_operator() {
386        let rule = json!({
387            "Or": [
388                {"Variable": "$.status", "StringEquals": "active"},
389                {"Variable": "$.status", "StringEquals": "pending"}
390            ],
391            "Next": "Valid"
392        });
393        let input = json!({"status": "active"});
394        assert!(evaluate_rule(&rule, &input));
395
396        let input = json!({"status": "closed"});
397        assert!(!evaluate_rule(&rule, &input));
398    }
399
400    #[test]
401    fn test_not_operator() {
402        let rule = json!({
403            "Not": {
404                "Variable": "$.status",
405                "StringEquals": "closed"
406            },
407            "Next": "Open"
408        });
409        let input = json!({"status": "active"});
410        assert!(evaluate_rule(&rule, &input));
411
412        let input = json!({"status": "closed"});
413        assert!(!evaluate_rule(&rule, &input));
414    }
415
416    #[test]
417    fn test_is_present() {
418        let rule = json!({
419            "Variable": "$.optional",
420            "IsPresent": true,
421            "Next": "HasField"
422        });
423        let input = json!({"optional": "value"});
424        assert!(evaluate_rule(&rule, &input));
425
426        let input = json!({"other": "value"});
427        assert!(!evaluate_rule(&rule, &input));
428    }
429
430    #[test]
431    fn test_is_present_with_array_index() {
432        let rule = json!({
433            "Variable": "$.items[0]",
434            "IsPresent": true,
435            "Next": "HasItem"
436        });
437        let input = json!({"items": [10, 20, 30]});
438        assert!(evaluate_rule(&rule, &input));
439
440        let input = json!({"items": []});
441        assert!(!evaluate_rule(&rule, &input));
442    }
443
444    #[test]
445    fn test_is_present_with_null_value() {
446        // A field that is explicitly set to null should be considered "present"
447        let rule = json!({
448            "Variable": "$.optional",
449            "IsPresent": true,
450            "Next": "HasField"
451        });
452        let input = json!({"optional": null});
453        assert!(evaluate_rule(&rule, &input));
454    }
455
456    #[test]
457    fn test_is_null() {
458        let rule = json!({
459            "Variable": "$.field",
460            "IsNull": true,
461            "Next": "Null"
462        });
463        let input = json!({"field": null});
464        assert!(evaluate_rule(&rule, &input));
465
466        let input = json!({"field": "value"});
467        assert!(!evaluate_rule(&rule, &input));
468    }
469
470    #[test]
471    fn test_is_numeric() {
472        let rule = json!({
473            "Variable": "$.value",
474            "IsNumeric": true,
475            "Next": "Number"
476        });
477        let input = json!({"value": 42});
478        assert!(evaluate_rule(&rule, &input));
479
480        let input = json!({"value": "not a number"});
481        assert!(!evaluate_rule(&rule, &input));
482    }
483
484    #[test]
485    fn test_string_matches() {
486        assert!(string_matches("hello world", "hello*"));
487        assert!(string_matches("hello world", "*world"));
488        assert!(string_matches("hello world", "hello*world"));
489        assert!(string_matches("hello world", "*"));
490        assert!(!string_matches("hello world", "goodbye*"));
491        assert!(string_matches("log-2024-01-15.txt", "log-*.txt"));
492    }
493
494    #[test]
495    fn test_evaluate_choice_with_default() {
496        let state_def = json!({
497            "Type": "Choice",
498            "Choices": [
499                {
500                    "Variable": "$.status",
501                    "StringEquals": "active",
502                    "Next": "ActivePath"
503                }
504            ],
505            "Default": "DefaultPath"
506        });
507        let input = json!({"status": "unknown"});
508        assert_eq!(
509            evaluate_choice(&state_def, &input),
510            Some("DefaultPath".to_string())
511        );
512    }
513
514    #[test]
515    fn test_evaluate_choice_matching() {
516        let state_def = json!({
517            "Type": "Choice",
518            "Choices": [
519                {
520                    "Variable": "$.value",
521                    "NumericGreaterThan": 100,
522                    "Next": "High"
523                },
524                {
525                    "Variable": "$.value",
526                    "NumericLessThanEquals": 100,
527                    "Next": "Low"
528                }
529            ],
530            "Default": "Unknown"
531        });
532        let input = json!({"value": 150});
533        assert_eq!(
534            evaluate_choice(&state_def, &input),
535            Some("High".to_string())
536        );
537
538        let input = json!({"value": 50});
539        assert_eq!(evaluate_choice(&state_def, &input), Some("Low".to_string()));
540    }
541
542    #[test]
543    fn test_evaluate_choice_no_match_no_default() {
544        let state_def = json!({
545            "Type": "Choice",
546            "Choices": [
547                {
548                    "Variable": "$.status",
549                    "StringEquals": "active",
550                    "Next": "Active"
551                }
552            ]
553        });
554        let input = json!({"status": "closed"});
555        assert_eq!(evaluate_choice(&state_def, &input), None);
556    }
557
558    #[test]
559    fn test_numeric_equals_path() {
560        let rule = json!({
561            "Variable": "$.a",
562            "NumericEqualsPath": "$.b",
563            "Next": "Equal"
564        });
565        let input = json!({"a": 42, "b": 42});
566        assert!(evaluate_rule(&rule, &input));
567
568        let input = json!({"a": 42, "b": 99});
569        assert!(!evaluate_rule(&rule, &input));
570    }
571
572    #[test]
573    fn test_timestamp_comparisons() {
574        let rule = json!({
575            "Variable": "$.ts",
576            "TimestampLessThan": "2024-06-01T00:00:00Z",
577            "Next": "Before"
578        });
579        let input = json!({"ts": "2024-01-15T12:00:00Z"});
580        assert!(evaluate_rule(&rule, &input));
581
582        let input = json!({"ts": "2024-12-01T00:00:00Z"});
583        assert!(!evaluate_rule(&rule, &input));
584    }
585
586    #[test]
587    fn test_string_less_than() {
588        let rule = json!({
589            "Variable": "$.name",
590            "StringLessThan": "beta",
591            "Next": "Before"
592        });
593        let input = json!({"name": "alpha"});
594        assert!(evaluate_rule(&rule, &input));
595
596        let input = json!({"name": "gamma"});
597        assert!(!evaluate_rule(&rule, &input));
598    }
599}