1#![allow(dead_code)]
11
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
19pub enum StepCondition {
20 Always,
22 OnPreviousSuccess,
24 OnPreviousFailure,
26 Expression(String),
30 FieldEquals {
32 field: String,
34 value: String,
36 },
37 FieldContains {
39 field: String,
41 substring: String,
43 },
44 And(Vec<StepCondition>),
46 Or(Vec<StepCondition>),
48 Not(Box<StepCondition>),
50}
51
52#[derive(Debug, Clone)]
56pub struct StepContext {
57 pub step_id: String,
59 pub previous_success: bool,
61 pub previous_output: HashMap<String, String>,
63}
64
65impl StepContext {
66 #[must_use]
68 pub fn new(step_id: impl Into<String>) -> Self {
69 Self {
70 step_id: step_id.into(),
71 previous_success: true,
72 previous_output: HashMap::new(),
73 }
74 }
75
76 #[must_use]
78 pub fn with_success(mut self, success: bool) -> Self {
79 self.previous_success = success;
80 self
81 }
82
83 #[must_use]
85 pub fn with_output(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
86 self.previous_output.insert(key.into(), value.into());
87 self
88 }
89}
90
91pub struct ConditionEvaluator;
95
96impl ConditionEvaluator {
97 #[must_use]
102 pub fn evaluate(condition: &StepCondition, ctx: &StepContext) -> bool {
103 match condition {
104 StepCondition::Always => true,
105 StepCondition::OnPreviousSuccess => ctx.previous_success,
106 StepCondition::OnPreviousFailure => !ctx.previous_success,
107 StepCondition::Expression(expr) => evaluate_expression(expr, ctx),
108 StepCondition::FieldEquals { field, value } => {
109 ctx.previous_output.get(field).map_or(false, |v| v == value)
110 }
111 StepCondition::FieldContains { field, substring } => ctx
112 .previous_output
113 .get(field)
114 .map_or(false, |v| v.contains(substring.as_str())),
115 StepCondition::And(conditions) => conditions.iter().all(|c| Self::evaluate(c, ctx)),
116 StepCondition::Or(conditions) => conditions.iter().any(|c| Self::evaluate(c, ctx)),
117 StepCondition::Not(inner) => !Self::evaluate(inner, ctx),
118 }
119 }
120}
121
122fn evaluate_expression(expr: &str, ctx: &StepContext) -> bool {
130 let expr = expr.trim();
131
132 const OPS: &[&str] = &[">=", "<=", "!=", "==", ">", "<", "contains"];
134
135 for &op in OPS {
136 if let Some(op_pos) = find_op(expr, op) {
137 let field = expr[..op_pos].trim();
138 let rhs = expr[op_pos + op.len()..].trim();
139
140 let lhs_raw = match ctx.previous_output.get(field) {
141 Some(v) => v.as_str(),
142 None => return false,
143 };
144
145 return compare_str(lhs_raw, op, rhs);
146 }
147 }
148
149 ctx.previous_output
151 .get(expr)
152 .map_or(false, |v| is_truthy(v))
153}
154
155fn find_op(expr: &str, op: &str) -> Option<usize> {
158 let bytes = expr.as_bytes();
161 let op_bytes = op.as_bytes();
162 let len = op_bytes.len();
163
164 let mut i = 0usize;
165 while i + len <= bytes.len() {
166 if &bytes[i..i + len] == op_bytes {
167 let next_ok = if len == 1 {
170 bytes.get(i + 1) != Some(&b'=')
171 } else {
172 true
173 };
174 if next_ok {
175 return Some(i);
176 }
177 }
178 i += 1;
179 }
180 None
181}
182
183fn compare_str(lhs: &str, op: &str, rhs: &str) -> bool {
186 if let (Ok(l), Ok(r)) = (lhs.parse::<f64>(), rhs.parse::<f64>()) {
188 return match op {
189 "==" => (l - r).abs() < f64::EPSILON,
190 "!=" => (l - r).abs() >= f64::EPSILON,
191 ">" => l > r,
192 "<" => l < r,
193 ">=" => l >= r,
194 "<=" => l <= r,
195 "contains" => lhs.contains(rhs),
196 _ => false,
197 };
198 }
199
200 match op {
202 "==" => lhs == rhs,
203 "!=" => lhs != rhs,
204 "contains" => lhs.contains(rhs),
205 ">" => lhs > rhs,
207 "<" => lhs < rhs,
208 ">=" => lhs >= rhs,
209 "<=" => lhs <= rhs,
210 _ => false,
211 }
212}
213
214fn is_truthy(v: &str) -> bool {
216 matches!(v.to_lowercase().as_str(), "true" | "yes" | "1")
217}
218
219impl StepCondition {
222 pub fn parse(s: &str) -> Result<Self, String> {
236 let s = s.trim();
237
238 if s.eq_ignore_ascii_case("always") {
239 return Ok(Self::Always);
240 }
241 if s.eq_ignore_ascii_case("on_success") || s.eq_ignore_ascii_case("on_previous_success") {
242 return Ok(Self::OnPreviousSuccess);
243 }
244 if s.eq_ignore_ascii_case("on_failure") || s.eq_ignore_ascii_case("on_previous_failure") {
245 return Ok(Self::OnPreviousFailure);
246 }
247
248 if let Some(rest) = s.strip_prefix("expr:") {
249 return Ok(Self::Expression(rest.trim().to_string()));
250 }
251
252 if let Some(rest) = s.strip_prefix("field_equals:") {
253 return parse_field_value_pair(rest.trim())
254 .map(|(f, v)| Self::FieldEquals { field: f, value: v });
255 }
256
257 if let Some(rest) = s.strip_prefix("field_contains:") {
258 return parse_field_value_pair(rest.trim()).map(|(f, v)| Self::FieldContains {
259 field: f,
260 substring: v,
261 });
262 }
263
264 Err(format!("Unrecognised condition string: '{s}'"))
265 }
266}
267
268fn parse_field_value_pair(s: &str) -> Result<(String, String), String> {
270 if let Some(eq_pos) = s.find('=') {
271 let field = s[..eq_pos].trim().to_string();
272 let value = s[eq_pos + 1..].trim().to_string();
273 if field.is_empty() {
274 return Err("Field name must not be empty".to_string());
275 }
276 Ok((field, value))
277 } else {
278 Err(format!("Expected 'field=value' in: '{s}'"))
279 }
280}
281
282#[cfg(test)]
285mod tests {
286 use super::*;
287
288 fn ctx_success() -> StepContext {
289 StepContext::new("step-a")
290 .with_success(true)
291 .with_output("size_mb", "250")
292 .with_output("status", "ok")
293 .with_output("codec", "h264")
294 }
295
296 fn ctx_failure() -> StepContext {
297 StepContext::new("step-b").with_success(false)
298 }
299
300 #[test]
303 fn test_always_is_true() {
304 let ctx = ctx_failure();
305 assert!(ConditionEvaluator::evaluate(&StepCondition::Always, &ctx));
306 }
307
308 #[test]
309 fn test_on_previous_success_true_when_succeeded() {
310 assert!(ConditionEvaluator::evaluate(
311 &StepCondition::OnPreviousSuccess,
312 &ctx_success()
313 ));
314 }
315
316 #[test]
317 fn test_on_previous_success_false_when_failed() {
318 assert!(!ConditionEvaluator::evaluate(
319 &StepCondition::OnPreviousSuccess,
320 &ctx_failure()
321 ));
322 }
323
324 #[test]
325 fn test_on_previous_failure_true_when_failed() {
326 assert!(ConditionEvaluator::evaluate(
327 &StepCondition::OnPreviousFailure,
328 &ctx_failure()
329 ));
330 }
331
332 #[test]
333 fn test_on_previous_failure_false_when_succeeded() {
334 assert!(!ConditionEvaluator::evaluate(
335 &StepCondition::OnPreviousFailure,
336 &ctx_success()
337 ));
338 }
339
340 #[test]
343 fn test_field_equals_match() {
344 let cond = StepCondition::FieldEquals {
345 field: "status".into(),
346 value: "ok".into(),
347 };
348 assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
349 }
350
351 #[test]
352 fn test_field_equals_no_match() {
353 let cond = StepCondition::FieldEquals {
354 field: "status".into(),
355 value: "fail".into(),
356 };
357 assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
358 }
359
360 #[test]
361 fn test_field_equals_missing_field() {
362 let cond = StepCondition::FieldEquals {
363 field: "nonexistent".into(),
364 value: "x".into(),
365 };
366 assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
367 }
368
369 #[test]
370 fn test_field_contains_match() {
371 let cond = StepCondition::FieldContains {
372 field: "codec".into(),
373 substring: "264".into(),
374 };
375 assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
376 }
377
378 #[test]
379 fn test_field_contains_no_match() {
380 let cond = StepCondition::FieldContains {
381 field: "codec".into(),
382 substring: "vp9".into(),
383 };
384 assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
385 }
386
387 #[test]
390 fn test_expression_gt_numeric() {
391 let cond = StepCondition::Expression("size_mb > 100".into());
392 assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
393 }
394
395 #[test]
396 fn test_expression_lte_numeric_false() {
397 let cond = StepCondition::Expression("size_mb <= 100".into());
398 assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
399 }
400
401 #[test]
402 fn test_expression_eq_string() {
403 let cond = StepCondition::Expression("status == ok".into());
404 assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
405 }
406
407 #[test]
408 fn test_expression_neq_string() {
409 let cond = StepCondition::Expression("status != fail".into());
410 assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
411 }
412
413 #[test]
414 fn test_expression_contains_op() {
415 let cond = StepCondition::Expression("codec contains 264".into());
416 assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
417 }
418
419 #[test]
420 fn test_expression_missing_field_returns_false() {
421 let cond = StepCondition::Expression("no_such_field > 0".into());
422 assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
423 }
424
425 #[test]
428 fn test_and_all_true() {
429 let cond = StepCondition::And(vec![
430 StepCondition::Always,
431 StepCondition::OnPreviousSuccess,
432 ]);
433 assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
434 }
435
436 #[test]
437 fn test_and_short_circuits_on_false() {
438 let cond = StepCondition::And(vec![
439 StepCondition::OnPreviousFailure, StepCondition::Always,
441 ]);
442 assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
443 }
444
445 #[test]
446 fn test_or_true_when_any_true() {
447 let cond = StepCondition::Or(vec![
448 StepCondition::OnPreviousFailure, StepCondition::OnPreviousSuccess, ]);
451 assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
452 }
453
454 #[test]
455 fn test_or_false_when_all_false() {
456 let cond = StepCondition::Or(vec![
457 StepCondition::OnPreviousFailure,
458 StepCondition::OnPreviousFailure,
459 ]);
460 assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
461 }
462
463 #[test]
464 fn test_not_negates() {
465 let cond = StepCondition::Not(Box::new(StepCondition::OnPreviousSuccess));
466 assert!(!ConditionEvaluator::evaluate(&cond, &ctx_success()));
467 assert!(ConditionEvaluator::evaluate(&cond, &ctx_failure()));
468 }
469
470 #[test]
471 fn test_nested_and_or_not() {
472 let cond = StepCondition::Or(vec![
474 StepCondition::And(vec![
475 StepCondition::Expression("size_mb > 100".into()),
476 StepCondition::FieldEquals {
477 field: "status".into(),
478 value: "ok".into(),
479 },
480 ]),
481 StepCondition::Not(Box::new(StepCondition::OnPreviousFailure)),
482 ]);
483 assert!(ConditionEvaluator::evaluate(&cond, &ctx_success()));
484 }
485
486 #[test]
489 fn test_parse_always() {
490 let c = StepCondition::parse("always").expect("parse should succeed");
491 assert!(matches!(c, StepCondition::Always));
492 }
493
494 #[test]
495 fn test_parse_on_success_short_form() {
496 let c = StepCondition::parse("on_success").expect("parse should succeed");
497 assert!(matches!(c, StepCondition::OnPreviousSuccess));
498 }
499
500 #[test]
501 fn test_parse_on_failure_long_form() {
502 let c = StepCondition::parse("on_previous_failure").expect("parse should succeed");
503 assert!(matches!(c, StepCondition::OnPreviousFailure));
504 }
505
506 #[test]
507 fn test_parse_expr() {
508 let c = StepCondition::parse("expr: size_mb > 100").expect("parse should succeed");
509 assert!(matches!(c, StepCondition::Expression(_)));
510 }
511
512 #[test]
513 fn test_parse_field_equals() {
514 let c = StepCondition::parse("field_equals: status=ok").expect("parse should succeed");
515 if let StepCondition::FieldEquals { field, value } = c {
516 assert_eq!(field, "status");
517 assert_eq!(value, "ok");
518 } else {
519 panic!("expected FieldEquals");
520 }
521 }
522
523 #[test]
524 fn test_parse_field_contains() {
525 let c = StepCondition::parse("field_contains: codec=264").expect("parse should succeed");
526 if let StepCondition::FieldContains { field, substring } = c {
527 assert_eq!(field, "codec");
528 assert_eq!(substring, "264");
529 } else {
530 panic!("expected FieldContains");
531 }
532 }
533
534 #[test]
535 fn test_parse_unknown_returns_err() {
536 let r = StepCondition::parse("nonsense_condition");
537 assert!(r.is_err());
538 }
539}