swf_core/models/
expression.rs1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
8pub struct RuntimeExpression(String);
9
10impl RuntimeExpression {
11 pub fn new(expr: &str) -> Self {
13 RuntimeExpression(expr.to_string())
14 }
15
16 pub fn normalized(expr: &str) -> Self {
18 RuntimeExpression(normalize_expr(expr))
19 }
20
21 pub fn as_str(&self) -> &str {
23 &self.0
24 }
25
26 pub fn is_strict(&self) -> bool {
28 is_strict_expr(&self.0)
29 }
30
31 pub fn is_valid(&self) -> bool {
37 is_valid_expr(&self.0)
38 }
39
40 pub fn sanitize(&self) -> String {
43 sanitize_expr(&self.0)
44 }
45
46 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
76pub fn is_strict_expr(expression: &str) -> bool {
78 expression.starts_with("${") && expression.ends_with('}')
79}
80
81pub fn sanitize_expr(expression: &str) -> String {
84 let mut expr = expression.to_string();
85
86 if expr.starts_with("${") && expr.ends_with('}') {
88 let chars: Vec<char> = expr.chars().collect();
91 let inner = &chars[2..chars.len() - 1]; 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 expr = expr[2..expr.len() - 1].trim().to_string();
114 } else {
115 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 expr = replace_single_quoted_strings(&expr);
143
144 expr
145}
146
147pub fn normalize_expr(expr: &str) -> String {
149 if expr.starts_with("${") {
150 expr.to_string()
151 } else {
152 format!("${{{}}}", expr)
153 }
154}
155
156pub fn is_valid_expr(expression: &str) -> bool {
163 if expression.is_empty() {
164 return false;
165 }
166
167 if expression.starts_with("${") {
169 if !expression.ends_with('}') {
170 return false;
171 }
172 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 !expression.trim().is_empty()
182}
183
184fn 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 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 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 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
239fn 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 #[test]
374 fn test_is_strict_expr_edge_cases() {
375 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 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 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 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 assert_eq!(sanitize_expr("${ {x: .foo} | .x }"), "{x: .foo} | .x");
449 }
450
451 #[test]
452 fn test_sanitize_expr_simple_vs_complex() {
453 assert_eq!(sanitize_expr("${ .foo.bar }"), ".foo.bar");
455 assert_eq!(sanitize_expr("${ .foo | {a: .b} }"), ".foo | {a: .b}");
457 }
458
459 #[test]
460 fn test_sanitize_expr_deeply_nested() {
461 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 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}