Skip to main content

swf_core/models/
expression.rs

1use serde::{Deserialize, Serialize};
2
3/// Represents a runtime expression following the Serverless Workflow DSL `${...}` syntax.
4///
5/// Runtime expressions are used throughout the specification to reference workflow
6/// data, context, and other dynamic values at runtime.
7#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
8pub struct RuntimeExpression(String);
9
10impl RuntimeExpression {
11    /// Creates a new RuntimeExpression from a string
12    pub fn new(expr: &str) -> Self {
13        RuntimeExpression(expr.to_string())
14    }
15
16    /// Creates a RuntimeExpression and normalizes it (adds `${}` if missing)
17    pub fn normalized(expr: &str) -> Self {
18        RuntimeExpression(normalize_expr(expr))
19    }
20
21    /// Returns the raw expression value as a string slice
22    pub fn as_str(&self) -> &str {
23        &self.0
24    }
25
26    /// Checks if the expression is in strict form (enclosed in `${ }`)
27    pub fn is_strict(&self) -> bool {
28        is_strict_expr(&self.0)
29    }
30
31    /// Checks if the expression appears to be syntactically valid.
32    ///
33    /// This performs basic structural validation (proper `${}` enclosure
34    /// or bare expression form). Full jq syntax validation would require
35    /// a jq parser.
36    pub fn is_valid(&self) -> bool {
37        is_valid_expr(&self.0)
38    }
39
40    /// Returns the expression content without the `${}` enclosure.
41    /// If the expression is not in strict form, returns it as-is.
42    pub fn sanitize(&self) -> String {
43        sanitize_expr(&self.0)
44    }
45
46    /// Returns the expression in normalized form (with `${}` enclosure).
47    pub fn normalize(&self) -> RuntimeExpression {
48        RuntimeExpression(normalize_expr(&self.0))
49    }
50}
51
52impl std::fmt::Display for RuntimeExpression {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "{}", self.0)
55    }
56}
57
58impl From<&str> for RuntimeExpression {
59    fn from(s: &str) -> Self {
60        RuntimeExpression(s.to_string())
61    }
62}
63
64impl From<String> for RuntimeExpression {
65    fn from(s: String) -> Self {
66        RuntimeExpression(s)
67    }
68}
69
70impl AsRef<str> for RuntimeExpression {
71    fn as_ref(&self) -> &str {
72        &self.0
73    }
74}
75
76/// Checks if the string is a strict runtime expression (enclosed in `${ }`)
77pub fn is_strict_expr(expression: &str) -> bool {
78    expression.starts_with("${") && expression.ends_with('}')
79}
80
81/// Sanitizes the expression by removing `${}` enclosure if present
82/// and replacing single quotes with double quotes.
83pub fn sanitize_expr(expression: &str) -> String {
84    let mut expr = expression.to_string();
85
86    // Remove `${}` enclosure if present, properly handling nested braces
87    if expr.starts_with("${") && expr.ends_with('}') {
88        // Count braces from the end to find the matching } for ${
89        // Walk from the end backwards to find the outermost } that balances with ${
90        let chars: Vec<char> = expr.chars().collect();
91        let inner = &chars[2..chars.len() - 1]; // strip ${ and last }
92
93        // Check if the inner content has balanced braces
94        let mut depth = 0i32;
95        let mut balanced = true;
96        for &ch in inner {
97            match ch {
98                '{' => depth += 1,
99                '}' => depth -= 1,
100                _ => {}
101            }
102            if depth < 0 {
103                balanced = false;
104                break;
105            }
106        }
107        if depth != 0 {
108            balanced = false;
109        }
110
111        if balanced {
112            // Simple case: inner braces are balanced, just strip ${ and last }
113            expr = expr[2..expr.len() - 1].trim().to_string();
114        } else {
115            // Complex case: the last } is part of an inner object, not the ${} closer
116            // Find the true closing } for ${ by tracking depth from the beginning
117            let mut depth = 0i32;
118            let mut end_pos = None;
119            for (i, &ch) in chars.iter().enumerate().skip(2) {
120                match ch {
121                    '{' => depth += 1,
122                    '}' => {
123                        depth -= 1;
124                        if depth < 0 {
125                            end_pos = Some(i);
126                            break;
127                        }
128                    }
129                    _ => {}
130                }
131            }
132            if let Some(pos) = end_pos {
133                expr = expr[2..pos].trim().to_string();
134            }
135        }
136    }
137
138    // Replace single-quoted strings with double-quoted strings,
139    // but only when the single quotes denote a JQ string literal (not inside a double-quoted string).
140    // We must NOT replace single quotes that appear inside double-quoted strings,
141    // as they may be part of JQ string interpolation like "Hello '\(.name)'"
142    expr = replace_single_quoted_strings(&expr);
143
144    expr
145}
146
147/// Normalizes the expression by adding `${}` enclosure if not already present.
148pub fn normalize_expr(expr: &str) -> String {
149    if expr.starts_with("${") {
150        expr.to_string()
151    } else {
152        format!("${{{}}}", expr)
153    }
154}
155
156/// Performs basic structural validation of a runtime expression.
157///
158/// Checks that:
159/// - If the expression starts with `${`, it must end with `}`
160/// - The expression is not empty after sanitization
161/// - Basic bracket matching
162pub fn is_valid_expr(expression: &str) -> bool {
163    if expression.is_empty() {
164        return false;
165    }
166
167    // If starts with ${, must end with }
168    if expression.starts_with("${") {
169        if !expression.ends_with('}') {
170            return false;
171        }
172        // Check for balanced braces inside
173        let inner = &expression[2..expression.len() - 1];
174        if inner.is_empty() {
175            return false;
176        }
177        return has_balanced_brackets(inner);
178    }
179
180    // Non-strict form: just check it's not empty
181    !expression.trim().is_empty()
182}
183
184/// Replaces single-quoted JQ string literals with double-quoted ones,
185/// while preserving single quotes that appear inside double-quoted strings.
186///
187/// JQ uses single-quoted strings like `'hello'`, but jaq only supports double-quoted
188/// strings. However, single quotes inside double-quoted strings (like `"it's"` or
189/// `"'\(.name)'"`) must be preserved.
190fn replace_single_quoted_strings(expr: &str) -> String {
191    let mut result = String::with_capacity(expr.len());
192    let chars: Vec<char> = expr.chars().collect();
193    let mut i = 0;
194
195    while i < chars.len() {
196        match chars[i] {
197            '"' => {
198                // Inside a double-quoted string — copy everything as-is
199                result.push('"');
200                i += 1;
201                while i < chars.len() {
202                    result.push(chars[i]);
203                    if chars[i] == '"' && (i == 0 || chars[i - 1] != '\\') {
204                        i += 1;
205                        break;
206                    }
207                    i += 1;
208                }
209            }
210            '\'' => {
211                // Start of a single-quoted string — replace with double quotes
212                result.push('"');
213                i += 1;
214                while i < chars.len() {
215                    if chars[i] == '\'' && (i == 0 || chars[i - 1] != '\\') {
216                        result.push('"');
217                        i += 1;
218                        break;
219                    }
220                    // Escape any double quotes inside the single-quoted string
221                    if chars[i] == '"' {
222                        result.push_str("\\\"");
223                    } else {
224                        result.push(chars[i]);
225                    }
226                    i += 1;
227                }
228            }
229            _ => {
230                result.push(chars[i]);
231                i += 1;
232            }
233        }
234    }
235
236    result
237}
238
239/// Checks if brackets are balanced in an expression
240fn has_balanced_brackets(expr: &str) -> bool {
241    let mut stack: Vec<char> = Vec::new();
242    let mut in_string = false;
243    let mut escape_next = false;
244
245    for ch in expr.chars() {
246        if escape_next {
247            escape_next = false;
248            continue;
249        }
250        if ch == '\\' {
251            escape_next = true;
252            continue;
253        }
254        if ch == '"' {
255            in_string = !in_string;
256            continue;
257        }
258        if in_string {
259            continue;
260        }
261        match ch {
262            '{' | '(' | '[' => stack.push(ch),
263            '}' if stack.pop() != Some('{') => return false,
264            ')' if stack.pop() != Some('(') => return false,
265            ']' if stack.pop() != Some('[') => return false,
266            _ => {}
267        }
268    }
269
270    stack.is_empty()
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_is_strict_expr() {
279        assert!(is_strict_expr("${.foo}"));
280        assert!(is_strict_expr("${ .foo.bar }"));
281        assert!(!is_strict_expr(".foo"));
282        assert!(!is_strict_expr("$ {.foo}"));
283    }
284
285    #[test]
286    fn test_sanitize_expr() {
287        assert_eq!(sanitize_expr("${.foo}"), ".foo");
288        assert_eq!(sanitize_expr("${ .foo }"), ".foo");
289        assert_eq!(sanitize_expr(".foo"), ".foo");
290        assert_eq!(sanitize_expr("${.foo['bar']}"), ".foo[\"bar\"]");
291    }
292
293    #[test]
294    fn test_normalize_expr() {
295        assert_eq!(normalize_expr(".foo"), "${.foo}");
296        assert_eq!(normalize_expr("${.foo}"), "${.foo}");
297        assert_eq!(normalize_expr(" .foo "), "${ .foo }");
298    }
299
300    #[test]
301    fn test_is_valid_expr() {
302        assert!(is_valid_expr("${.foo}"));
303        assert!(is_valid_expr("${.foo.bar}"));
304        assert!(is_valid_expr(".foo"));
305        assert!(!is_valid_expr(""));
306        assert!(!is_valid_expr("${}"));
307        assert!(!is_valid_expr("${.foo"));
308        assert!(!is_valid_expr("${.foo]}"));
309    }
310
311    #[test]
312    fn test_runtime_expression_new() {
313        let expr = RuntimeExpression::new("${.foo}");
314        assert_eq!(expr.as_str(), "${.foo}");
315        assert!(expr.is_strict());
316        assert!(expr.is_valid());
317    }
318
319    #[test]
320    fn test_runtime_expression_normalized() {
321        let expr = RuntimeExpression::normalized(".foo");
322        assert_eq!(expr.as_str(), "${.foo}");
323        assert!(expr.is_strict());
324    }
325
326    #[test]
327    fn test_runtime_expression_sanitize() {
328        let expr = RuntimeExpression::new("${.foo.bar}");
329        assert_eq!(expr.sanitize(), ".foo.bar");
330    }
331
332    #[test]
333    fn test_runtime_expression_normalize() {
334        let expr = RuntimeExpression::new(".foo");
335        let normalized = expr.normalize();
336        assert_eq!(normalized.as_str(), "${.foo}");
337    }
338
339    #[test]
340    fn test_runtime_expression_display() {
341        let expr = RuntimeExpression::new("${.foo}");
342        assert_eq!(format!("{}", expr), "${.foo}");
343    }
344
345    #[test]
346    fn test_runtime_expression_from_str() {
347        let expr: RuntimeExpression = "${.bar}".into();
348        assert_eq!(expr.as_str(), "${.bar}");
349    }
350
351    #[test]
352    fn test_runtime_expression_serde() {
353        let expr = RuntimeExpression::new("${.foo}");
354        let json = serde_json::to_string(&expr).unwrap();
355        assert_eq!(json, "\"${.foo}\"");
356
357        let deserialized: RuntimeExpression = serde_json::from_str(&json).unwrap();
358        assert_eq!(deserialized, expr);
359    }
360
361    #[test]
362    fn test_balanced_brackets() {
363        assert!(has_balanced_brackets(".foo.bar"));
364        assert!(has_balanced_brackets(".foo[0]"));
365        assert!(has_balanced_brackets(".foo[\"bar\"]"));
366        assert!(has_balanced_brackets(".foo | {a: .b}"));
367        assert!(!has_balanced_brackets(".foo[}"));
368        assert!(!has_balanced_brackets(".foo]}"));
369    }
370
371    // Additional tests matching Go SDK's runtime_expression_test.go
372
373    #[test]
374    fn test_is_strict_expr_edge_cases() {
375        // Matches Go SDK IsStrictExpr tests
376        assert!(is_strict_expr("${.some.path}"), "strict expr with braces");
377        assert!(!is_strict_expr("${.some.path"), "missing closing brace");
378        assert!(!is_strict_expr(".some.path}"), "missing opening brace");
379        assert!(!is_strict_expr(""), "empty string");
380        assert!(!is_strict_expr(".some.path"), "no braces at all");
381        assert!(
382            is_strict_expr("${  .some.path   }"),
383            "with spaces but still correct"
384        );
385        assert!(is_strict_expr("${}"), "only braces");
386    }
387
388    #[test]
389    fn test_sanitize_expr_edge_cases() {
390        // Matches Go SDK SanitizeExpr tests
391        assert_eq!(
392            sanitize_expr("${ 'some.path' }"),
393            "\"some.path\"",
394            "remove braces and replace single quotes"
395        );
396        assert_eq!(
397            sanitize_expr(".some.path"),
398            ".some.path",
399            "already sanitized, no braces"
400        );
401        assert_eq!(
402            sanitize_expr("${ 'foo' + 'bar' }"),
403            "\"foo\" + \"bar\"",
404            "multiple single quotes"
405        );
406        assert_eq!(sanitize_expr("${    }"), "", "only braces with spaces");
407        assert_eq!(
408            sanitize_expr("'some.path'"),
409            "\"some.path\"",
410            "no braces, just single quotes to replace"
411        );
412        assert_eq!(sanitize_expr(""), "", "nothing to sanitize");
413    }
414
415    #[test]
416    fn test_is_valid_expr_edge_cases() {
417        // Matches Go SDK IsValidExpr tests
418        assert!(is_valid_expr("${ .foo }"), "valid expression - simple path");
419        assert!(
420            is_valid_expr("${ .arr[0] }"),
421            "valid expression - array slice"
422        );
423        assert!(
424            !is_valid_expr("${ .foo( }"),
425            "invalid syntax - unbalanced parens"
426        );
427        assert!(is_valid_expr(".bar"), "no braces but valid JQ");
428        assert!(!is_valid_expr(""), "empty expression");
429        assert!(!is_valid_expr("${ .arr[ }"), "invalid bracket usage");
430    }
431
432    #[test]
433    fn test_sanitize_expr_nested_object() {
434        // Nested object literal inside ${} - the key test for balanced braces handling
435        assert_eq!(
436            sanitize_expr("${ {a:1, b:2, c:3} | del(.a,.c) }"),
437            "{a:1, b:2, c:3} | del(.a,.c)"
438        );
439        assert_eq!(
440            sanitize_expr("${ {processed: {colors: [], indexes: []}} }"),
441            "{processed: {colors: [], indexes: []}}"
442        );
443    }
444
445    #[test]
446    fn test_sanitize_expr_nested_object_with_pipe() {
447        // Object with pipe operator
448        assert_eq!(sanitize_expr("${ {x: .foo} | .x }"), "{x: .foo} | .x");
449    }
450
451    #[test]
452    fn test_sanitize_expr_simple_vs_complex() {
453        // Simple expression: inner braces are balanced → strip ${ and last }
454        assert_eq!(sanitize_expr("${ .foo.bar }"), ".foo.bar");
455        // Complex expression: inner braces unbalanced → use depth tracking
456        assert_eq!(sanitize_expr("${ .foo | {a: .b} }"), ".foo | {a: .b}");
457    }
458
459    #[test]
460    fn test_sanitize_expr_deeply_nested() {
461        // Deeply nested objects
462        assert_eq!(sanitize_expr("${ {a: {b: {c: 1}}} }"), "{a: {b: {c: 1}}}");
463    }
464
465    #[test]
466    fn test_sanitize_expr_if_then_else_object() {
467        // if-then-else returning an object
468        assert_eq!(
469            sanitize_expr("${ if .x then {a: 1} else {b: 2} end }"),
470            "if .x then {a: 1} else {b: 2} end"
471        );
472    }
473}