diff --git a/crates/arc-workflows/src/condition.rs b/crates/arc-workflows/src/condition.rs index 3fe8ad9e1..d65e56cee 100644 --- a/crates/arc-workflows/src/condition.rs +++ b/crates/arc-workflows/src/condition.rs @@ -1,77 +1,300 @@ /// Condition expression evaluator for edge guards (spec Section 10). /// -/// Grammar: `ConditionExpr ::= Clause ('&&' Clause)*`, `Clause ::= Key Op Literal`, -/// `Op ::= '=' | '!='`. +/// Grammar: +/// ```text +/// Expr ::= OrExpr +/// OrExpr ::= AndExpr ('||' AndExpr)* +/// AndExpr ::= UnaryExpr ('&&' UnaryExpr)* +/// UnaryExpr ::= '!' UnaryExpr | Clause +/// Clause ::= Key Op Literal | Key (bare key = truthy) +/// Op ::= '=' | '!=' | '>' | '<' | '>=' | '<=' +/// | 'contains' | 'matches' +/// ``` use crate::context::Context; use crate::error::ArcError; use crate::outcome::Outcome; -#[derive(Debug, Clone, PartialEq, Eq)] +// --------------------------------------------------------------------------- +// AST +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq)] +enum ConditionExpr { + Clause(Clause), + Not(Box), + And(Vec), + Or(Vec), +} + +#[derive(Debug, Clone, PartialEq)] struct Clause { key: String, op: Op, value: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] enum Op { Eq, NotEq, + Gt, + Lt, + Gte, + Lte, + Contains, + Matches, Truthy, } -fn parse_clauses(expr: &str) -> Result, ArcError> { - let expr = expr.trim(); - if expr.is_empty() { +// --------------------------------------------------------------------------- +// Tokenizer +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq)] +enum Token { + Word(String), + OpEq, // = + OpNotEq, // != + OpGt, // > + OpLt, // < + OpGte, // >= + OpLte, // <= + And, // && + Or, // || + Not, // ! + Contains, // contains + Matches, // matches +} + +fn tokenize(input: &str) -> Result, ArcError> { + let input = input.trim(); + if input.is_empty() { return Ok(Vec::new()); } - expr.split("&&") - .filter(|part| !part.trim().is_empty()) - .map(|part| { - let part = part.trim(); - if let Some(pos) = part.find("!=") { - let key = part[..pos].trim().to_string(); - let value = part[pos + 2..].trim().to_string(); - if key.is_empty() { - return Err(ArcError::Parse(format!( - "empty key in condition clause: {part:?}" - ))); - } - Ok(Clause { - key, - op: Op::NotEq, - value, - }) - } else if let Some(pos) = part.find('=') { - let key = part[..pos].trim().to_string(); - let value = part[pos + 1..].trim().to_string(); - if key.is_empty() { - return Err(ArcError::Parse(format!( - "empty key in condition clause: {part:?}" - ))); - } - Ok(Clause { - key, - op: Op::Eq, - value, - }) - } else { - // Bare key: truthiness check - let key = part.to_string(); - if key.is_empty() { - return Err(ArcError::Parse(format!( - "empty key in condition clause: {part:?}" - ))); - } - Ok(Clause { - key, - op: Op::Truthy, - value: String::new(), - }) + let chars: Vec = input.chars().collect(); + let len = chars.len(); + let mut i = 0; + let mut tokens = Vec::new(); + + while i < len { + // Skip whitespace + if chars[i].is_whitespace() { + i += 1; + continue; + } + + // Two-char operators (longest match first) + if i + 1 < len { + let two = format!("{}{}", chars[i], chars[i + 1]); + match two.as_str() { + "&&" => { tokens.push(Token::And); i += 2; continue; } + "||" => { tokens.push(Token::Or); i += 2; continue; } + "!=" => { tokens.push(Token::OpNotEq); i += 2; continue; } + ">=" => { tokens.push(Token::OpGte); i += 2; continue; } + "<=" => { tokens.push(Token::OpLte); i += 2; continue; } + _ => {} } - }) - .collect() + } + + // Single-char operators + match chars[i] { + '=' => { tokens.push(Token::OpEq); i += 1; continue; } + '>' => { tokens.push(Token::OpGt); i += 1; continue; } + '<' => { tokens.push(Token::OpLt); i += 1; continue; } + '!' => { tokens.push(Token::Not); i += 1; continue; } + _ => {} + } + + // Word: everything up to whitespace or operator char + let start = i; + while i < len && !chars[i].is_whitespace() && !is_op_char(chars[i]) { + i += 1; + } + if i == start { + return Err(ArcError::Parse(format!( + "unexpected character '{}' in condition expression", + chars[i] + ))); + } + let word: String = chars[start..i].iter().collect(); + + // Recognize keyword operators only when they appear between words + // (not as the first or last token, and not adjacent to another operator) + match word.as_str() { + "contains" if is_word_operator_context(&tokens) => { + tokens.push(Token::Contains); + } + "matches" if is_word_operator_context(&tokens) => { + tokens.push(Token::Matches); + } + _ => { + tokens.push(Token::Word(word)); + } + } + } + + Ok(tokens) +} + +fn is_op_char(c: char) -> bool { + matches!(c, '=' | '!' | '>' | '<' | '&' | '|') +} + +/// Word operators (`contains`, `matches`) are recognized when preceded by a Word token. +fn is_word_operator_context(tokens: &[Token]) -> bool { + matches!(tokens.last(), Some(Token::Word(_))) +} + +// --------------------------------------------------------------------------- +// Parser (recursive descent) +// --------------------------------------------------------------------------- + +struct Parser { + tokens: Vec, + pos: usize, +} + +impl Parser { + fn new(tokens: Vec) -> Self { + Self { tokens, pos: 0 } + } + + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.pos) + } + + fn advance(&mut self) -> Option { + let tok = self.tokens.get(self.pos).cloned(); + if tok.is_some() { + self.pos += 1; + } + tok + } + + fn parse_expr(&mut self) -> Result { + self.parse_or() + } + + fn parse_or(&mut self) -> Result { + let mut children = vec![self.parse_and()?]; + while self.peek() == Some(&Token::Or) { + self.advance(); + children.push(self.parse_and()?); + } + if children.len() == 1 { + Ok(children.pop().expect("just checked length")) + } else { + Ok(ConditionExpr::Or(children)) + } + } + + fn parse_and(&mut self) -> Result { + let mut children = vec![self.parse_unary()?]; + while self.peek() == Some(&Token::And) { + self.advance(); + children.push(self.parse_unary()?); + } + if children.len() == 1 { + Ok(children.pop().expect("just checked length")) + } else { + Ok(ConditionExpr::And(children)) + } + } + + fn parse_unary(&mut self) -> Result { + if self.peek() == Some(&Token::Not) { + self.advance(); + let inner = self.parse_unary()?; + return Ok(ConditionExpr::Not(Box::new(inner))); + } + self.parse_clause() + } + + fn parse_clause(&mut self) -> Result { + let key = match self.advance() { + Some(Token::Word(w)) => w, + Some(other) => { + return Err(ArcError::Parse(format!( + "expected key, got {other:?} in condition expression" + ))); + } + None => { + return Err(ArcError::Parse( + "unexpected end of condition expression".to_string(), + )); + } + }; + + // Check for operator + let op = match self.peek() { + Some(Token::OpEq) => Some(Op::Eq), + Some(Token::OpNotEq) => Some(Op::NotEq), + Some(Token::OpGt) => Some(Op::Gt), + Some(Token::OpLt) => Some(Op::Lt), + Some(Token::OpGte) => Some(Op::Gte), + Some(Token::OpLte) => Some(Op::Lte), + Some(Token::Contains) => Some(Op::Contains), + Some(Token::Matches) => Some(Op::Matches), + _ => None, + }; + + let Some(op) = op else { + // Bare key → truthy + return Ok(ConditionExpr::Clause(Clause { + key, + op: Op::Truthy, + value: String::new(), + })); + }; + + self.advance(); // consume the operator + + // Value: must be a Word + let value = match self.advance() { + Some(Token::Word(w)) => w, + Some(other) => { + return Err(ArcError::Parse(format!( + "expected value after operator, got {other:?}" + ))); + } + None => { + // Allow empty value for `=` and `!=` (backward compat: `missing_key=`) + if op == Op::Eq || op == Op::NotEq { + String::new() + } else { + return Err(ArcError::Parse( + "expected value after operator".to_string(), + )); + } + } + }; + + // Validate regex at parse time + if op == Op::Matches { + regex::Regex::new(&value).map_err(|e| { + ArcError::Parse(format!("invalid regex pattern '{value}': {e}")) + })?; + } + + Ok(ConditionExpr::Clause(Clause { key, op, value })) + } +} + +fn parse_expression(expr: &str) -> Result { + let tokens = tokenize(expr)?; + if tokens.is_empty() { + return Ok(ConditionExpr::And(Vec::new())); + } + let mut parser = Parser::new(tokens); + let result = parser.parse_expr()?; + if parser.pos < parser.tokens.len() { + return Err(ArcError::Parse(format!( + "unexpected token {:?} in condition expression", + parser.tokens[parser.pos] + ))); + } + Ok(result) } /// Parse and validate a condition expression. @@ -80,10 +303,14 @@ fn parse_clauses(expr: &str) -> Result, ArcError> { /// /// Returns an error if the expression contains invalid syntax. pub fn parse_condition(expr: &str) -> Result<(), ArcError> { - parse_clauses(expr)?; + parse_expression(expr)?; Ok(()) } +// --------------------------------------------------------------------------- +// Evaluator +// --------------------------------------------------------------------------- + fn resolve_key(key: &str, outcome: &Outcome, context: &Context) -> String { if key == "outcome" { return outcome.status.to_string(); @@ -105,6 +332,34 @@ fn resolve_key(key: &str, outcome: &Outcome, context: &Context) -> String { .map_or_else(String::new, |val| json_value_to_string(&val)) } +fn resolve_key_value( + key: &str, + outcome: &Outcome, + context: &Context, +) -> serde_json::Value { + if key == "outcome" { + return serde_json::Value::String(outcome.status.to_string()); + } + if key == "preferred_label" { + return outcome + .preferred_label + .as_deref() + .map_or(serde_json::Value::Null, |s| { + serde_json::Value::String(s.to_string()) + }); + } + if let Some(path) = key.strip_prefix("context.") { + if let Some(val) = context.get(key) { + return val; + } + if let Some(val) = context.get(path) { + return val; + } + return serde_json::Value::Null; + } + context.get(key).unwrap_or(serde_json::Value::Null) +} + fn json_value_to_string(val: &serde_json::Value) -> String { match val { serde_json::Value::String(s) => s.clone(), @@ -115,26 +370,88 @@ fn json_value_to_string(val: &serde_json::Value) -> String { } } +fn is_truthy(s: &str) -> bool { + !s.is_empty() && s != "false" && s != "0" +} + +fn eval_expr(expr: &ConditionExpr, outcome: &Outcome, context: &Context) -> bool { + match expr { + ConditionExpr::And(children) => { + if children.is_empty() { + return true; + } + children.iter().all(|c| eval_expr(c, outcome, context)) + } + ConditionExpr::Or(children) => { + children.iter().any(|c| eval_expr(c, outcome, context)) + } + ConditionExpr::Not(inner) => !eval_expr(inner, outcome, context), + ConditionExpr::Clause(clause) => eval_clause(clause, outcome, context), + } +} + +fn eval_clause(clause: &Clause, outcome: &Outcome, context: &Context) -> bool { + match &clause.op { + Op::Truthy => { + let resolved = resolve_key(&clause.key, outcome, context); + is_truthy(&resolved) + } + Op::Eq => { + let resolved = resolve_key(&clause.key, outcome, context); + resolved == clause.value + } + Op::NotEq => { + let resolved = resolve_key(&clause.key, outcome, context); + resolved != clause.value + } + Op::Gt | Op::Lt | Op::Gte | Op::Lte => { + let resolved = resolve_key(&clause.key, outcome, context); + let lhs: f64 = match resolved.parse() { + Ok(v) => v, + Err(_) => return false, + }; + let rhs: f64 = match clause.value.parse() { + Ok(v) => v, + Err(_) => return false, + }; + match &clause.op { + Op::Gt => lhs > rhs, + Op::Lt => lhs < rhs, + Op::Gte => lhs >= rhs, + Op::Lte => lhs <= rhs, + _ => unreachable!(), + } + } + Op::Contains => { + let raw = resolve_key_value(&clause.key, outcome, context); + match &raw { + serde_json::Value::Array(arr) => arr.iter().any(|elem| { + json_value_to_string(elem) == clause.value + }), + _ => { + let s = json_value_to_string(&raw); + s.contains(&clause.value) + } + } + } + Op::Matches => { + let resolved = resolve_key(&clause.key, outcome, context); + // Regex was validated at parse time, so unwrap is safe + regex::Regex::new(&clause.value) + .map(|re| re.is_match(&resolved)) + .unwrap_or(false) + } + } +} + /// Evaluate a condition expression against an outcome and context. /// Empty conditions always return true. #[must_use] pub fn evaluate_condition(expr: &str, outcome: &Outcome, context: &Context) -> bool { - let Ok(clauses) = parse_clauses(expr) else { + let Ok(parsed) = parse_expression(expr) else { return false; }; - - if clauses.is_empty() { - return true; - } - - clauses.iter().all(|clause| { - let resolved = resolve_key(&clause.key, outcome, context); - match clause.op { - Op::Eq => resolved == clause.value, - Op::NotEq => resolved != clause.value, - Op::Truthy => !resolved.is_empty() && resolved != "false" && resolved != "0", - } - }) + eval_expr(&parsed, outcome, context) } #[cfg(test)] @@ -149,6 +466,10 @@ mod tests { } } + // ----------------------------------------------------------------------- + // Phase 0: Existing behavior preserved + // ----------------------------------------------------------------------- + #[test] fn empty_condition_is_true() { let outcome = make_outcome(StageStatus::Success); @@ -350,4 +671,319 @@ mod tests { &context )); } + + // ----------------------------------------------------------------------- + // Phase 0: AST structure tests + // ----------------------------------------------------------------------- + + #[test] + fn parse_eq_into_clause() { + let expr = parse_expression("outcome=success").unwrap(); + assert_eq!( + expr, + ConditionExpr::Clause(Clause { + key: "outcome".to_string(), + op: Op::Eq, + value: "success".to_string(), + }) + ); + } + + #[test] + fn parse_and_into_and_node() { + let expr = parse_expression("a=1 && b=2").unwrap(); + assert_eq!( + expr, + ConditionExpr::And(vec![ + ConditionExpr::Clause(Clause { + key: "a".to_string(), + op: Op::Eq, + value: "1".to_string(), + }), + ConditionExpr::Clause(Clause { + key: "b".to_string(), + op: Op::Eq, + value: "2".to_string(), + }), + ]) + ); + } + + #[test] + fn parse_bare_key_into_truthy() { + let expr = parse_expression("some_flag").unwrap(); + assert_eq!( + expr, + ConditionExpr::Clause(Clause { + key: "some_flag".to_string(), + op: Op::Truthy, + value: String::new(), + }) + ); + } + + #[test] + fn parse_not_eq_into_clause() { + let expr = parse_expression("outcome!=fail").unwrap(); + assert_eq!( + expr, + ConditionExpr::Clause(Clause { + key: "outcome".to_string(), + op: Op::NotEq, + value: "fail".to_string(), + }) + ); + } + + // ----------------------------------------------------------------------- + // Phase 1: Numeric comparisons + // ----------------------------------------------------------------------- + + #[test] + fn numeric_gt() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("score", serde_json::json!(90)); + assert!(evaluate_condition("context.score > 80", &outcome, &context)); + context.set("score", serde_json::json!(70)); + assert!(!evaluate_condition("context.score > 80", &outcome, &context)); + } + + #[test] + fn numeric_gte() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("score", serde_json::json!(80)); + assert!(evaluate_condition("context.score >= 80", &outcome, &context)); + } + + #[test] + fn numeric_lte() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("score", serde_json::json!(80)); + assert!(evaluate_condition("context.score <= 80", &outcome, &context)); + } + + #[test] + fn numeric_lt() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("count", serde_json::json!(3)); + assert!(evaluate_condition("context.count < 5", &outcome, &context)); + } + + #[test] + fn numeric_float() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("ratio", serde_json::json!(0.75)); + assert!(evaluate_condition( + "context.ratio > 0.5", + &outcome, + &context + )); + } + + #[test] + fn numeric_non_numeric_returns_false() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("score", serde_json::json!("not_a_number")); + assert!(!evaluate_condition( + "context.score > 80", + &outcome, + &context + )); + } + + #[test] + fn parse_numeric_comparisons() { + assert!(parse_condition("x > 5").is_ok()); + assert!(parse_condition("x >= 5").is_ok()); + assert!(parse_condition("x < 5").is_ok()); + assert!(parse_condition("x <= 5").is_ok()); + } + + // ----------------------------------------------------------------------- + // Phase 2: contains operator + // ----------------------------------------------------------------------- + + #[test] + fn contains_substring() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("message", serde_json::json!("an error occurred")); + assert!(evaluate_condition( + "context.message contains error", + &outcome, + &context + )); + context.set("message", serde_json::json!("all good")); + assert!(!evaluate_condition( + "context.message contains error", + &outcome, + &context + )); + } + + #[test] + fn contains_case_sensitive() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("message", serde_json::json!("an error occurred")); + assert!(!evaluate_condition( + "context.message contains Error", + &outcome, + &context + )); + } + + #[test] + fn contains_json_array() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("tags", serde_json::json!(["urgent", "low"])); + assert!(evaluate_condition( + "context.tags contains urgent", + &outcome, + &context + )); + assert!(!evaluate_condition( + "context.tags contains critical", + &outcome, + &context + )); + } + + #[test] + fn parse_contains() { + assert!(parse_condition("x contains y").is_ok()); + } + + // ----------------------------------------------------------------------- + // Phase 3: matches operator (regex) + // ----------------------------------------------------------------------- + + #[test] + fn matches_regex() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("version", serde_json::json!("v2.0")); + assert!(evaluate_condition( + r"context.version matches ^v\d+", + &outcome, + &context + )); + context.set("version", serde_json::json!("beta")); + assert!(!evaluate_condition( + r"context.version matches ^v\d+", + &outcome, + &context + )); + } + + #[test] + fn matches_invalid_regex_fails_parse() { + assert!(parse_condition("x matches [bad").is_err()); + } + + #[test] + fn parse_matches() { + assert!(parse_condition("x matches ^ok$").is_ok()); + } + + // ----------------------------------------------------------------------- + // Phase 4: OR (||) + // ----------------------------------------------------------------------- + + #[test] + fn or_disjunction() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + assert!(evaluate_condition( + "outcome=success || outcome=partial_success", + &outcome, + &context + )); + let outcome = make_outcome(StageStatus::Fail); + assert!(!evaluate_condition( + "outcome=success || outcome=partial_success", + &outcome, + &context + )); + } + + #[test] + fn or_precedence_and_binds_tighter() { + // a=1 && b=2 || c=3 is (a=1 AND b=2) OR c=3 + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("a", serde_json::json!("0")); + context.set("b", serde_json::json!("2")); + context.set("c", serde_json::json!("3")); + // a=1 is false, b=2 is true => AND is false; c=3 is true => OR is true + assert!(evaluate_condition( + "a=1 && b=2 || c=3", + &outcome, + &context + )); + } + + #[test] + fn or_precedence_right_and() { + // a=1 || b=2 && c=3 is a=1 OR (b=2 AND c=3) + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("a", serde_json::json!("0")); + context.set("b", serde_json::json!("2")); + context.set("c", serde_json::json!("0")); + // a=1 false; b=2 true, c=3 false => AND false; OR false + assert!(!evaluate_condition( + "a=1 || b=2 && c=3", + &outcome, + &context + )); + } + + #[test] + fn parse_or() { + assert!(parse_condition("a=1 || b=2").is_ok()); + } + + // ----------------------------------------------------------------------- + // Phase 5: NOT (!) + // ----------------------------------------------------------------------- + + #[test] + fn not_negation() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + assert!(evaluate_condition("!outcome=fail", &outcome, &context)); + assert!(!evaluate_condition("!outcome=success", &outcome, &context)); + } + + #[test] + fn not_missing_key_is_true() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + assert!(evaluate_condition("!missing_key", &outcome, &context)); + } + + #[test] + fn not_with_and() { + let outcome = make_outcome(StageStatus::Success); + let context = Context::new(); + context.set("ready", serde_json::json!("true")); + assert!(evaluate_condition( + "!outcome=fail && context.ready=true", + &outcome, + &context + )); + } + + #[test] + fn parse_not() { + assert!(parse_condition("!x=y").is_ok()); + } } diff --git a/crates/arc-workflows/src/validation/rules.rs b/crates/arc-workflows/src/validation/rules.rs index c79a33d45..c6a8c599f 100644 --- a/crates/arc-workflows/src/validation/rules.rs +++ b/crates/arc-workflows/src/validation/rules.rs @@ -308,29 +308,6 @@ impl LintRule for ConditionSyntaxRule { if condition.is_empty() { continue; } - for clause in condition.split("&&") { - let clause = clause.trim(); - if clause.is_empty() { - continue; - } - // A clause must contain = or != operator, or be a bare key (truthy check) - let has_operator = clause.contains("!=") || clause.contains('='); - if !has_operator && clause.contains(' ') && !clause.starts_with("context.") { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Error, - message: format!( - "Invalid condition clause '{clause}' on edge {} -> {}", - edge.from, edge.to - ), - node_id: None, - edge: Some((edge.from.clone(), edge.to.clone())), - fix: Some("Use key=value or key!=value syntax".to_string()), - }); - } - } - // Also validate via the condition parser to catch malformed expressions - // that pass the static check (e.g. empty key like "=value") if let Err(e) = parse_condition(condition) { diagnostics.push(Diagnostic { rule: self.name().to_string(), @@ -341,7 +318,11 @@ impl LintRule for ConditionSyntaxRule { ), node_id: None, edge: Some((edge.from.clone(), edge.to.clone())), - fix: Some("Fix the condition expression syntax".to_string()), + fix: Some( + "Use key=value, key!=value, key>value, key contains value, \ + key matches pattern, or bare key syntax" + .to_string(), + ), }); } } @@ -1967,7 +1948,7 @@ mod tests { assert!(d.is_empty()); } - // --- condition_syntax: context-prefixed clause with spaces is valid --- + // --- condition_syntax: context-prefixed clause with spaces is rejected --- #[test] fn condition_syntax_rule_context_prefix_with_space() { @@ -1980,8 +1961,9 @@ mod tests { g.edges = vec![edge]; let rule = ConditionSyntaxRule; let d = rule.apply(&g); - // context.-prefixed clauses are allowed even with spaces - assert!(d.is_empty()); + // "context.foo bar" has an unexpected trailing word — parse error + assert_eq!(d.len(), 1); + assert_eq!(d[0].severity, Severity::Error); } // --- terminal_node: by "Exit" capitalized id --- @@ -2632,7 +2614,7 @@ mod tests { g.edges = vec![edge]; let rule = ConditionSyntaxRule; let d = rule.apply(&g); - // Static check passes (has '=' operator) but parse_condition catches empty key + // parse_condition catches empty key assert_eq!(d.len(), 1); assert_eq!(d[0].severity, Severity::Error); assert!(d[0].message.contains("failed parse")); @@ -2652,6 +2634,79 @@ mod tests { assert!(d.is_empty()); } + // --- condition_syntax: new operators accepted --- + + #[test] + fn condition_syntax_rule_accepts_or() { + let mut g = minimal_graph(); + let mut edge = Edge::new("start", "exit"); + edge.attrs.insert( + "condition".to_string(), + AttrValue::String("outcome=success || outcome=fail".to_string()), + ); + g.edges = vec![edge]; + let rule = ConditionSyntaxRule; + let d = rule.apply(&g); + assert!(d.is_empty()); + } + + #[test] + fn condition_syntax_rule_accepts_not() { + let mut g = minimal_graph(); + let mut edge = Edge::new("start", "exit"); + edge.attrs.insert( + "condition".to_string(), + AttrValue::String("!outcome=fail".to_string()), + ); + g.edges = vec![edge]; + let rule = ConditionSyntaxRule; + let d = rule.apply(&g); + assert!(d.is_empty()); + } + + #[test] + fn condition_syntax_rule_accepts_contains() { + let mut g = minimal_graph(); + let mut edge = Edge::new("start", "exit"); + edge.attrs.insert( + "condition".to_string(), + AttrValue::String("context.x contains y".to_string()), + ); + g.edges = vec![edge]; + let rule = ConditionSyntaxRule; + let d = rule.apply(&g); + assert!(d.is_empty()); + } + + #[test] + fn condition_syntax_rule_accepts_numeric() { + let mut g = minimal_graph(); + let mut edge = Edge::new("start", "exit"); + edge.attrs.insert( + "condition".to_string(), + AttrValue::String("context.score > 80".to_string()), + ); + g.edges = vec![edge]; + let rule = ConditionSyntaxRule; + let d = rule.apply(&g); + assert!(d.is_empty()); + } + + #[test] + fn condition_syntax_rule_rejects_invalid_regex() { + let mut g = minimal_graph(); + let mut edge = Edge::new("start", "exit"); + edge.attrs.insert( + "condition".to_string(), + AttrValue::String("context.x matches [bad".to_string()), + ); + g.edges = vec![edge]; + let rule = ConditionSyntaxRule; + let d = rule.apply(&g); + assert_eq!(d.len(), 1); + assert_eq!(d[0].severity, Severity::Error); + } + // script_absolute_cd rule tests #[test]