Clarify run-if skip reasons with the resolved variable value.

Failed conditions now explain skips in plain language (e.g. UpdateCount was 0) and truncate long captures so misconfigured apt listings cannot flood the UI.
This commit is contained in:
2026-07-19 21:59:35 +02:00
parent eade2ef0c7
commit 0e06063c1d
8 changed files with 865 additions and 46 deletions
+330
View File
@@ -0,0 +1,330 @@
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)
}
}