import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit"; import type { CheckFailure } from "../types"; import type { ExpectationResult, RawValueExpectation, ValueExpectation, ValueMatcher } from "./types"; import { mismatchFailure } from "./failure"; import { MATCHER_KEY_SET } from "./keys"; export function applyValueMatcher( actual: unknown, matcher: ValueMatcher, options: { stringifyNonString?: boolean } = {}, ): boolean { for (const [key, expected] of Object.entries(matcher)) { if (expected === undefined) continue; switch (key) { case "contains": if (!stringValue(actual, options).includes(expected as string)) return false; break; case "empty": { const empty = isEmptyValue(actual); if (expected !== empty) return false; break; } case "equals": if (!isEqual(actual, expected)) return false; break; case "exists": if (expected) { if (actual === undefined) return false; } else { if (actual !== undefined) return false; } break; case "gt": if (!compareNumber(actual, expected as number, (left, right) => left > right)) return false; break; case "gte": if (!compareNumber(actual, expected as number, (left, right) => left >= right)) return false; break; case "lt": if (!compareNumber(actual, expected as number, (left, right) => left < right)) return false; break; case "lte": if (!compareNumber(actual, expected as number, (left, right) => left <= right)) return false; break; case "regex": if (!new RegExp(expected as string).test(stringValue(actual, options))) return false; break; } } return true; } export function checkValueExpectation( actual: unknown, expectation: undefined | ValueExpectation, options: { message?: string; path: string; phase: CheckFailure["phase"]; stringifyNonString?: boolean }, ): ExpectationResult { if (expectation === undefined) return { failure: null, matched: true }; if (applyValueMatcher(actual, expectation, { stringifyNonString: options.stringifyNonString })) { return { failure: null, matched: true }; } return { failure: mismatchFailure( options.phase, options.path, displayValueExpectation(expectation), actual, options.message ?? `${options.path} mismatch`, ), matched: false, }; } export function displayValueExpectation(expectation: ValueExpectation): unknown { const entries = Object.entries(expectation).filter(([, value]) => value !== undefined); if (entries.length === 1 && entries[0]?.[0] === "equals") return entries[0][1]; return expectation; } export function evaluateJsonPath(json: unknown, path: string): unknown { if (!path.startsWith("$.")) return undefined; const segments = path.slice(2).split("."); let current: unknown = json; for (const seg of segments) { const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg); if (bracketMatch) { if (current === null || current === undefined) return undefined; current = (current as Record)[bracketMatch[1]!]; const idx = parseInt(bracketMatch[2]!, 10); if (!Array.isArray(current) || idx >= current.length) return undefined; current = current[idx]; } else { if (current === null || current === undefined) return undefined; current = (current as Record)[seg]; } } return current; } export function isValueMatcherObject(value: unknown): value is ValueMatcher { return isPlainObject(value) && Object.keys(value).some((key) => MATCHER_KEY_SET.has(key)); } export function isValueMatcherPrimitive(value: unknown): value is boolean | null | number | string { return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"; } export function resolveValueExpectation(raw: RawValueExpectation): ValueExpectation; export function resolveValueExpectation(raw: RawValueExpectation | undefined): undefined | ValueExpectation; export function resolveValueExpectation(raw: RawValueExpectation | undefined): undefined | ValueExpectation { if (raw === undefined) return undefined; if (isValueMatcherObject(raw)) return raw; return { equals: raw }; } function compareNumber( actual: unknown, expected: number, compare: (actual: number, expected: number) => boolean, ): boolean { const value = Number(actual); return Number.isFinite(value) && compare(value, expected); } function isEmptyValue(value: unknown): boolean { return isNil(value) || value === "" || (Array.isArray(value) && value.length === 0) || isEmptyObject(value); } function stringValue(actual: unknown, options: { stringifyNonString?: boolean }): string { if (!options.stringifyNonString || typeof actual === "string") return String(actual); if (actual !== null && typeof actual === "object") return JSON.stringify(actual); return String(actual); }