mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
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:
parent
2d3ae34059
commit
8a1d1a3ff4
2 changed files with 786 additions and 95 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue