package runner import ( "errors" "fmt" "strconv" "strings" "unicode" ) // ValidateCondition checks that expr is empty or a supported comparison. func ValidateCondition(expr string) error { trimmed := strings.TrimSpace(expr) if trimmed == "" { return nil } _, err := parseCondition(trimmed) return err } const skipDetailMaxValueLen = 80 // EvaluateCondition evaluates a run-if expression against the current var map. // Empty expr is always true. Eval failures return false with an error. // On success, detail is a compact comparison (e.g. "$UpdateCount=3 >= 1"). // On a false result, detail is a human-readable skip explanation. func EvaluateCondition(expr string, vars map[string]string) (ok bool, detail string, err error) { trimmed := strings.TrimSpace(expr) if trimmed == "" { return true, "", nil } parsed, err := parseCondition(trimmed) if err != nil { return false, "", err } detail = formatEvaluatedCondition(parsed, vars) leftValue := resolveOperand(parsed.left, vars) rightValue := resolveOperand(parsed.right, vars) ok, err = compareOperands(leftValue, parsed.op, rightValue) if err != nil { return false, detail, err } if !ok { detail = formatSkipDetail(parsed, vars) } return ok, detail, nil } func formatEvaluatedCondition(parsed parsedCondition, vars map[string]string) string { return fmt.Sprintf("%s %s %s", formatOperandForDisplay(parsed.left, vars), parsed.op, formatOperandForDisplay(parsed.right, vars), ) } // formatSkipDetail builds a user-facing explanation when a run-if condition is false. func formatSkipDetail(parsed parsedCondition, vars map[string]string) string { rightDisplay := formatOperandValueForSkip(parsed.right, vars) if parsed.left.kind == operandVar { value := "" if vars != nil { value = vars[parsed.left.varName] } if strings.TrimSpace(value) == "" { return fmt.Sprintf("$%s was unset, which does not meet run condition (%s %s)", parsed.left.varName, parsed.op, rightDisplay) } return fmt.Sprintf("$%s was %s, which does not meet run condition (%s %s)", parsed.left.varName, truncateForSkipDetail(value), parsed.op, rightDisplay) } return fmt.Sprintf("run condition not met (%s %s %s)", formatOperandValueForSkip(parsed.left, vars), parsed.op, rightDisplay, ) } func formatOperandForDisplay(operand conditionOperand, vars map[string]string) string { if operand.kind == operandVar { value := "" if vars != nil { value = vars[operand.varName] } return fmt.Sprintf("$%s=%s", operand.varName, value) } return operand.literal } func formatOperandValueForSkip(operand conditionOperand, vars map[string]string) string { if operand.kind == operandVar { value := "" if vars != nil { value = vars[operand.varName] } if strings.TrimSpace(value) == "" { return "unset" } return truncateForSkipDetail(value) } return operand.literal } func truncateForSkipDetail(value string) string { trimmed := strings.TrimSpace(value) runes := []rune(trimmed) if len(runes) <= skipDetailMaxValueLen { return trimmed } return string(runes[:skipDetailMaxValueLen]) + "…" } type conditionOp string const ( opEQ conditionOp = "==" opNE conditionOp = "!=" opGE conditionOp = ">=" opLE conditionOp = "<=" opGT conditionOp = ">" opLT conditionOp = "<" ) type conditionOperand struct { kind operandKind varName string literal string isNumeric bool number float64 } type operandKind int const ( operandVar operandKind = iota operandLiteral ) type parsedCondition struct { left conditionOperand op conditionOp right conditionOperand } func parseCondition(expr string) (parsedCondition, error) { remaining := strings.TrimSpace(expr) if strings.HasPrefix(strings.ToLower(remaining), "if ") || strings.EqualFold(remaining, "if") { if len(remaining) < 3 { return parsedCondition{}, errors.New("condition is empty after if") } remaining = strings.TrimSpace(remaining[2:]) } if remaining == "" { return parsedCondition{}, errors.New("condition is empty") } left, rest, err := parseOperand(remaining) if err != nil { return parsedCondition{}, err } rest = strings.TrimSpace(rest) op, afterOp, err := parseOperator(rest) if err != nil { return parsedCondition{}, err } right, trailing, err := parseOperand(strings.TrimSpace(afterOp)) if err != nil { return parsedCondition{}, err } if strings.TrimSpace(trailing) != "" { return parsedCondition{}, fmt.Errorf("unexpected trailing input %q", strings.TrimSpace(trailing)) } return parsedCondition{left: left, op: op, right: right}, nil } func parseOperator(input string) (conditionOp, string, error) { operators := []conditionOp{opEQ, opNE, opGE, opLE, opGT, opLT} for _, op := range operators { prefix := string(op) if strings.HasPrefix(input, prefix) { return op, input[len(prefix):], nil } } return "", "", errors.New("expected comparison operator (==, !=, >=, <=, >, <)") } func parseOperand(input string) (conditionOperand, string, error) { input = strings.TrimSpace(input) if input == "" { return conditionOperand{}, "", errors.New("expected operand") } if input[0] == '$' { name, rest, err := parseDollarName(input) if err != nil { return conditionOperand{}, "", err } if err := ValidateVariableName(name); err != nil { return conditionOperand{}, "", err } return conditionOperand{kind: operandVar, varName: name}, rest, nil } if input[0] == '"' || input[0] == '\'' { quote := input[0] var builder strings.Builder i := 1 for i < len(input) { ch := input[i] if ch == quote { return conditionOperand{ kind: operandLiteral, literal: builder.String(), }, input[i+1:], nil } builder.WriteByte(ch) i++ } return conditionOperand{}, "", errors.New("unclosed string literal") } end := 0 for end < len(input) { ch := rune(input[end]) if unicode.IsSpace(ch) { break } // Stop before a comparison operator that is not part of a number sign. if end > 0 && (ch == '=' || ch == '!' || ch == '>' || ch == '<') { break } if end == 0 && (ch == '=' || ch == '!' || ch == '>' || ch == '<') { break } end++ } if end == 0 { return conditionOperand{}, "", errors.New("expected operand") } token := input[:end] if number, err := strconv.ParseFloat(token, 64); err == nil { return conditionOperand{ kind: operandLiteral, literal: token, isNumeric: true, number: number, }, input[end:], nil } return conditionOperand{kind: operandLiteral, literal: token}, input[end:], nil } func parseDollarName(input string) (string, string, error) { if !strings.HasPrefix(input, "$") { return "", "", errors.New("expected $variable") } rest := input[1:] if rest == "" { return "", "", errors.New("expected variable name after $") } if rest[0] == '{' { closeIdx := strings.IndexByte(rest, '}') if closeIdx < 0 { return "", "", errors.New("unclosed ${variable}") } name := rest[1:closeIdx] return name, rest[closeIdx+1:], nil } end := 0 for end < len(rest) { ch := rest[end] if (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' { end++ continue } break } if end == 0 { return "", "", errors.New("expected variable name after $") } return rest[:end], rest[end:], nil } func resolveOperand(operand conditionOperand, vars map[string]string) conditionOperand { if operand.kind != operandVar { return operand } value := "" if vars != nil { value = vars[operand.varName] } resolved := conditionOperand{kind: operandLiteral, literal: value} if number, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil && strings.TrimSpace(value) != "" { resolved.isNumeric = true resolved.number = number } return resolved } func compareOperands(left conditionOperand, op conditionOp, right conditionOperand) (bool, error) { if left.isNumeric && right.isNumeric { switch op { case opEQ: return left.number == right.number, nil case opNE: return left.number != right.number, nil case opGE: return left.number >= right.number, nil case opLE: return left.number <= right.number, nil case opGT: return left.number > right.number, nil case opLT: return left.number < right.number, nil } } // Numeric ops with a non-numeric side evaluate to false (e.g. unset >= 1). if op == opGE || op == opLE || op == opGT || op == opLT { return false, nil } switch op { case opEQ: return left.literal == right.literal, nil case opNE: return left.literal != right.literal, nil default: return false, fmt.Errorf("unsupported operator %q", op) } }