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