Extend condition expression language with ||, !, numeric comparisons, contains, and matches

Replace flat Vec<Clause> parser with AST-based recursive descent parser
supporting full operator precedence (&& binds tighter than ||, ! is prefix).
New operators: >, <, >=, <= (numeric), contains (substring/array membership),
matches (regex, validated at parse time). Simplify ConditionSyntaxRule to
delegate entirely to parse_condition(). Public API unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-05 08:20:07 -05:00
parent 2d3ae34059
commit 8a1d1a3ff4
2 changed files with 786 additions and 95 deletions

View file

@ -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<ConditionExpr>),
And(Vec<ConditionExpr>),
Or(Vec<ConditionExpr>),
}
#[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<Vec<Clause>, 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<Vec<Token>, 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<char> = 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<Token>,
pos: usize,
}
impl Parser {
fn new(tokens: Vec<Token>) -> Self {
Self { tokens, pos: 0 }
}
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn advance(&mut self) -> Option<Token> {
let tok = self.tokens.get(self.pos).cloned();
if tok.is_some() {
self.pos += 1;
}
tok
}
fn parse_expr(&mut self) -> Result<ConditionExpr, ArcError> {
self.parse_or()
}
fn parse_or(&mut self) -> Result<ConditionExpr, ArcError> {
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<ConditionExpr, ArcError> {
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<ConditionExpr, ArcError> {
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<ConditionExpr, ArcError> {
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<ConditionExpr, ArcError> {
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<Vec<Clause>, 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());
}
}

View file

@ -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]