import type { Aggregate, ConditionStep, Predicate } from "./types.js"; /** * Resolves a dotted path. `a.b[0].c` indexes arrays, `a[*].b` flattens. * Returns an array of matches (empty when nothing matches). */ export function resolvePath(root: unknown, path: string): unknown[] { const tokens = tokenize(path); let current: unknown[] = [root]; for (const token of tokens) { const next: unknown[] = []; for (const value of current) { if (value === null || value === undefined) continue; if (token === "*") { if (Array.isArray(value)) next.push(...value); else if (typeof value === "object") next.push(...Object.values(value)); } else if (Array.isArray(value) && /^\d+$/.test(token)) { const item = value[Number(token)]; if (item !== undefined) next.push(item); } else if (typeof value === "object" && Object.prototype.hasOwnProperty.call(value, token)) { next.push((value as Record)[token]); } } current = next; } return current; } function tokenize(path: string): string[] { const tokens: string[] = []; for (const part of path.split(".")) { if (!part) throw new Error(`invalid path '${path}'`); const re = /([^[\]]+)|\[(\d+|\*)\]/g; let m: RegExpExecArray | null; let consumed = 0; while ((m = re.exec(part))) { tokens.push((m[1] ?? m[2]) as string); consumed += m[0].length; } if (consumed !== part.length) throw new Error(`invalid path '${path}'`); } return tokens; } /** `{{path}}` substitution. Objects are JSON-encoded; missing paths become empty strings. */ export function renderTemplate(template: string, context: unknown): string { return template.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (_m, path: string) => { const values = resolvePath(context, path); if (values.length === 0) return ""; const value = values.length === 1 ? values[0] : values; if (value === null || value === undefined) return ""; return typeof value === "object" ? JSON.stringify(value) : String(value); }); } export function aggregate(input: unknown[], how: Aggregate): unknown { // A single array match (path pointing at an array) aggregates over its elements. const values = input.length === 1 && Array.isArray(input[0]) ? input[0] : input; if (how === "count") return values.length; if (how === "first") return values[0]; if (how === "last") return values[values.length - 1]; const nums = values.map(Number).filter((n) => Number.isFinite(n)); if (nums.length === 0) return undefined; switch (how) { case "max": return Math.max(...nums); case "min": return Math.min(...nums); case "sum": return nums.reduce((a, b) => a + b, 0); case "avg": return nums.reduce((a, b) => a + b, 0) / nums.length; } } export function evaluatePredicate(predicate: Predicate, context: unknown): boolean { const matches = resolvePath(context, predicate.path); if (predicate.op === "exists") { const present = matches.some((v) => v !== null && v !== undefined && v !== ""); return predicate.value === false ? !present : present; } const subject = aggregate(matches, predicate.aggregate ?? "first"); if (subject === undefined) return false; const expected = predicate.value; switch (predicate.op) { case "contains": if (Array.isArray(subject)) return subject.some((v) => v === expected); return String(subject).includes(String(expected)); case "==": return looseEquals(subject, expected); case "!=": return !looseEquals(subject, expected); default: { const a = Number(subject); const b = Number(expected); if (!Number.isFinite(a) || !Number.isFinite(b)) return false; if (predicate.op === ">") return a > b; if (predicate.op === ">=") return a >= b; if (predicate.op === "<") return a < b; return a <= b; } } } function looseEquals(a: unknown, b: unknown): boolean { if (typeof a === "number" || typeof b === "number") return Number(a) === Number(b); if (typeof a === "boolean" || typeof b === "boolean") return String(a) === String(b); return a === b; } export function evaluateCondition(step: ConditionStep, context: unknown): boolean { const all = step.all ?? []; const any = step.any ?? []; if (all.length === 0 && any.length === 0) throw new Error(`condition '${step.id}' has no predicates`); const allOk = all.every((p) => evaluatePredicate(p, context)); const anyOk = any.length === 0 || any.some((p) => evaluatePredicate(p, context)); return allOk && anyOk; }