diff --git a/src/test/java/com/databricks/jdbc/comparator/ComparisonResult.java b/src/test/java/com/databricks/jdbc/comparator/ComparisonResult.java index c9b0afb89..76f8dc8b7 100644 --- a/src/test/java/com/databricks/jdbc/comparator/ComparisonResult.java +++ b/src/test/java/com/databricks/jdbc/comparator/ComparisonResult.java @@ -28,9 +28,11 @@ public boolean hasDifferences() { public String csvSummary() { List parts = new ArrayList<>(); - // Exception vs ResultSet — already concise, keep as is + // Exception vs ResultSet — already concise, keep as is. Legacy diffs use the " vs class " + // marker; deep error comparison uses the stable "Error one-sided: " prefix (which survives + // regardless of whether the non-throwing side renders to a class, a scalar, or null). for (String d : metadataDifferences) { - if (d.contains(" vs class ")) { + if (d.contains(" vs class ") || d.startsWith("Error one-sided: ")) { parts.add(d); } } @@ -52,6 +54,14 @@ public String csvSummary() { .count(); if (rowMismatches > 0) parts.add(rowMismatches + " data mismatches"); + // Count error-field mismatches (e.g., "Error SQLState mismatch: ...") emitted by + // ErrorComparator for both-threw cases. + long errorMismatches = + dataDifferences.stream() + .filter(d -> d.startsWith("Error ") && d.contains("mismatch")) + .count(); + if (errorMismatches > 0) parts.add(errorMismatches + " error mismatches"); + return parts.isEmpty() ? "" : String.join(", ", parts); } diff --git a/src/test/java/com/databricks/jdbc/comparator/ResultSetComparator.java b/src/test/java/com/databricks/jdbc/comparator/ResultSetComparator.java index 7d55d9574..6d6b0221c 100644 --- a/src/test/java/com/databricks/jdbc/comparator/ResultSetComparator.java +++ b/src/test/java/com/databricks/jdbc/comparator/ResultSetComparator.java @@ -1,5 +1,9 @@ package com.databricks.jdbc.comparator; +import com.databricks.jdbc.comparator.error.CapturedOutcome; +import com.databricks.jdbc.comparator.error.ErrorComparator; +import com.databricks.jdbc.comparator.error.ErrorComparison; +import com.databricks.jdbc.comparator.error.ErrorPolicy; import java.io.IOException; import java.io.Reader; import java.sql.ResultSet; @@ -121,6 +125,10 @@ public static ComparisonResult compare( } else { result.dataDifferences = compareData(rs1, rs2); } + } else if (compareErrorsDeeply( + queryType, queryOrMethod, methodArgs, result1, result2, result)) { + // Deep error comparison handled it (at least one side is a Throwable and the + // ERROR_COMPARISON_MODE gate is on). Diffs, if any, were folded into `result`. } else if (!(result1 instanceof ResultSet) && !(result2 instanceof ResultSet)) { // Both are not of type ResultSet if (result1 == null || !resultIsSame(result1, result2)) { @@ -146,7 +154,46 @@ public static ComparisonResult compare( return result; } + /** + * When {@code ERROR_COMPARISON_MODE} is on and at least one side is a {@link Throwable}, compares + * the errors deeply (class + SQLState + code + serverCode + message) and folds any diffs into + * {@code result}. Returns true when it handled the comparison; false (a no-op) when the gate is + * off or neither side threw, so the legacy branches below take over unchanged. + */ + private static boolean compareErrorsDeeply( + String queryType, + String queryOrMethod, + Object[] methodArgs, + Object result1, + Object result2, + ComparisonResult result) { + ErrorPolicy policy = ErrorPolicy.active(); + if (!policy.isDeepComparisonEnabled()) { + return false; + } + if (!(result1 instanceof Throwable) && !(result2 instanceof Throwable)) { + return false; + } + CapturedOutcome left = toOutcome(result1); + CapturedOutcome right = toOutcome(result2); + ErrorComparison comparison = + ErrorComparator.compare(left, right, ResultSetComparator::describeResult); + result.metadataDifferences.addAll(comparison.metadataDiffs); + result.dataDifferences.addAll(comparison.dataDiffs); + return true; + } + + private static CapturedOutcome toOutcome(Object result) { + if (result instanceof Throwable) { + return CapturedOutcome.threw((Throwable) result); + } + return CapturedOutcome.returned(result); + } + private static String describeResult(Object result) { + if (result == null) { + return "null"; + } if (result instanceof ResultSet) { try { ResultSet rs = (ResultSet) result; diff --git a/src/test/java/com/databricks/jdbc/comparator/error/CapturedOutcome.java b/src/test/java/com/databricks/jdbc/comparator/error/CapturedOutcome.java new file mode 100644 index 000000000..9cffc038c --- /dev/null +++ b/src/test/java/com/databricks/jdbc/comparator/error/CapturedOutcome.java @@ -0,0 +1,64 @@ +package com.databricks.jdbc.comparator.error; + +/** + * What one side of a comparison did for a single JDBC call: it {@link Kind#THREW threw}, {@link + * Kind#RETURNED returned} a value, or returned {@link Kind#NULL null}. + * + *

This lets a thrown error be treated as a first-class, comparable result rather than + * propagating and aborting the case. Capture a call with {@link Captures#capture}. + */ +public final class CapturedOutcome { + + public enum Kind { + THREW, + RETURNED, + NULL + } + + private final Kind kind; + private final Object value; // non-null only when RETURNED (ResultSet, update count, Boolean, ...) + private final Throwable throwable; // non-null only when THREW + private final ErrorFacts error; // non-null only when THREW + + private CapturedOutcome(Kind kind, Object value, Throwable throwable, ErrorFacts error) { + this.kind = kind; + this.value = value; + this.throwable = throwable; + this.error = error; + } + + public static CapturedOutcome threw(Throwable t) { + return new CapturedOutcome(Kind.THREW, null, t, ErrorFacts.from(t)); + } + + public static CapturedOutcome returned(Object value) { + return value == null ? nul() : new CapturedOutcome(Kind.RETURNED, value, null, null); + } + + public static CapturedOutcome nul() { + return new CapturedOutcome(Kind.NULL, null, null, null); + } + + public Kind kind() { + return kind; + } + + public boolean threw() { + return kind == Kind.THREW; + } + + /** The returned value (may be null); meaningful only when {@link #kind()} is RETURNED. */ + public Object value() { + return value; + } + + /** The raw Throwable; non-null only when {@link #threw()} is true. */ + public Throwable throwable() { + return throwable; + } + + /** The extracted error fields; non-null only when {@link #threw()} is true. */ + public ErrorFacts error() { + return error; + } +} diff --git a/src/test/java/com/databricks/jdbc/comparator/error/Captures.java b/src/test/java/com/databricks/jdbc/comparator/error/Captures.java new file mode 100644 index 000000000..a6e469a8c --- /dev/null +++ b/src/test/java/com/databricks/jdbc/comparator/error/Captures.java @@ -0,0 +1,53 @@ +package com.databricks.jdbc.comparator.error; + +import com.databricks.jdbc.comparator.ComparisonResult; +import java.util.function.Function; + +/** + * Helper that lets a suite provider capture a JDBC call's outcome (value or thrown error) instead + * of letting the error propagate and abort the case. + * + *

Wrap ONLY the single driver call in {@link #capture} — provider bookkeeping (building SQL, + * opening connections, temp files) must stay outside, so a harness bug still propagates and fails + * the test loudly rather than being silently captured. + */ +public final class Captures { + + private Captures() {} + + @FunctionalInterface + public interface JdbcCall { + Object call() throws Exception; + } + + /** Runs a driver call, capturing any Throwable as a {@link CapturedOutcome}. */ + public static CapturedOutcome capture(JdbcCall call) { + try { + return CapturedOutcome.returned(call.call()); + } catch (Throwable t) { + return CapturedOutcome.threw(t); + } + } + + /** + * Compares two captured outcomes and folds the result into a {@link ComparisonResult}. Error + * diffs land in the metadata/data difference lists in the exact formats {@code csvSummary()} + * understands. + * + * @param describeReturned renders the non-throwing side of a one-sided diff (see {@link + * ErrorComparator#compare}) + */ + public static ComparisonResult compareCall( + String queryType, + String queryOrMethod, + Object[] args, + CapturedOutcome left, + CapturedOutcome right, + Function describeReturned) { + ComparisonResult result = new ComparisonResult(queryType, queryOrMethod, args); + ErrorComparison comparison = ErrorComparator.compare(left, right, describeReturned); + result.metadataDifferences.addAll(comparison.metadataDiffs); + result.dataDifferences.addAll(comparison.dataDiffs); + return result; + } +} diff --git a/src/test/java/com/databricks/jdbc/comparator/error/ErrorComparator.java b/src/test/java/com/databricks/jdbc/comparator/error/ErrorComparator.java new file mode 100644 index 000000000..fad1b03af --- /dev/null +++ b/src/test/java/com/databricks/jdbc/comparator/error/ErrorComparator.java @@ -0,0 +1,95 @@ +package com.databricks.jdbc.comparator.error; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * Compares two {@link CapturedOutcome}s field-by-field and produces an {@link ErrorComparison} + * whose diff strings are kept by {@code ComparisonResult.csvSummary()}: + * + *

+ */ +public final class ErrorComparator { + + private ErrorComparator() {} + + /** + * @param describeReturned renders the non-throwing side for one-sided diffs. Receives the + * returned value, which may be {@code null} for the NULL outcome, so it must be null-safe. + */ + public static ErrorComparison compare( + CapturedOutcome left, CapturedOutcome right, Function describeReturned) { + List metadataDiffs = new ArrayList<>(); + List dataDiffs = new ArrayList<>(); + + boolean leftThrew = left.threw(); + boolean rightThrew = right.threw(); + + if (!leftThrew && !rightThrew) { + return new ErrorComparison(ErrorComparison.Verdict.NOT_APPLICABLE, metadataDiffs, dataDiffs); + } + + if (leftThrew != rightThrew) { + metadataDiffs.add(oneSidedDiff(left, right, describeReturned)); + return new ErrorComparison(ErrorComparison.Verdict.ONE_SIDED, metadataDiffs, dataDiffs); + } + + // Both threw — compare the raw fields. No normalization or format assumptions. + ErrorFacts l = left.error(); + ErrorFacts r = right.error(); + + if (!l.exceptionClass.equals(r.exceptionClass)) { + dataDiffs.add("Error class mismatch: " + l.simpleClassName() + " vs " + r.simpleClassName()); + } + if (!equalsNullSafe(l.sqlState, r.sqlState)) { + dataDiffs.add("Error SQLState mismatch: " + l.sqlState + " vs " + r.sqlState); + } + if (l.vendorCode != r.vendorCode) { + dataDiffs.add("Error code mismatch: " + l.vendorCode + " vs " + r.vendorCode); + } + if (!equalsNullSafe(l.message, r.message)) { + dataDiffs.add("Error message mismatch: '" + l.message + "' vs '" + r.message + "'"); + } + + ErrorComparison.Verdict verdict = + dataDiffs.isEmpty() ? ErrorComparison.Verdict.MATCH : ErrorComparison.Verdict.MISMATCH; + return new ErrorComparison(verdict, metadataDiffs, dataDiffs); + } + + private static boolean equalsNullSafe(Object a, Object b) { + return a == null ? b == null : a.equals(b); + } + + /** + * Stable prefix on one-sided diffs so {@code csvSummary()} always keeps them (regardless of + * whether the non-throwing side renders to a ResultSet, a scalar, or {@code null}). + */ + public static final String ONE_SIDED_PREFIX = "Error one-sided: "; + + /** + * Builds a one-sided diff string carrying {@link #ONE_SIDED_PREFIX}. The thrower is rendered with + * its extracted facts; the non-throwing side via {@code describeReturned} (which may render to + * {@code "null"}). Left/right order is preserved so the report shows which side threw. + */ + private static String oneSidedDiff( + CapturedOutcome left, CapturedOutcome right, Function describeReturned) { + String body = + left.threw() + ? renderThrower(left.error()) + " vs " + describeReturned.apply(right.value()) + : describeReturned.apply(left.value()) + " vs " + renderThrower(right.error()); + return ONE_SIDED_PREFIX + body; + } + + private static String renderThrower(ErrorFacts f) { + return f.exceptionClass + " (SQLSTATE=" + f.sqlState + ", code=" + f.vendorCode + ")"; + } +} diff --git a/src/test/java/com/databricks/jdbc/comparator/error/ErrorComparatorTest.java b/src/test/java/com/databricks/jdbc/comparator/error/ErrorComparatorTest.java new file mode 100644 index 000000000..1e2ab6064 --- /dev/null +++ b/src/test/java/com/databricks/jdbc/comparator/error/ErrorComparatorTest.java @@ -0,0 +1,152 @@ +package com.databricks.jdbc.comparator.error; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.databricks.jdbc.comparator.ComparisonResult; +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +/** Unit tests for the pure error capture/compare logic (no JDBC connection required). */ +public class ErrorComparatorTest { + + // ---- ErrorFacts extraction ---- + + @Test + void extractsSqlStateAndVendorCodeFromSqlException() { + ErrorFacts f = ErrorFacts.from(new SQLException("boom", "42P01", 500593)); + assertEquals("java.sql.SQLException", f.exceptionClass); + assertEquals("42P01", f.sqlState); + assertEquals(500593, f.vendorCode); + assertEquals("boom", f.message); + } + + @Test + void nonSqlThrowableHasNullStateAndZeroCode() { + ErrorFacts f = ErrorFacts.from(new RuntimeException("plain")); + assertNull(f.sqlState); + assertEquals(0, f.vendorCode); + } + + @Test + void nullMessageIsNpeSafe() { + ErrorFacts f = ErrorFacts.from(new RuntimeException((String) null)); + assertNull(f.message); + } + + // ---- Verdicts ---- + + @Test + void bothReturnedIsNotApplicable() { + ErrorComparison c = + ErrorComparator.compare( + CapturedOutcome.returned("a"), CapturedOutcome.returned("b"), o -> "class RS"); + assertEquals(ErrorComparison.Verdict.NOT_APPLICABLE, c.verdict); + assertFalse(c.hasDiffs()); + } + + @Test + void identicalErrorsMatch() { + CapturedOutcome l = CapturedOutcome.threw(new SQLException("not found", "42P01", 1)); + CapturedOutcome r = CapturedOutcome.threw(new SQLException("not found", "42P01", 1)); + ErrorComparison c = ErrorComparator.compare(l, r, o -> "class RS"); + assertEquals(ErrorComparison.Verdict.MATCH, c.verdict); + assertFalse(c.hasDiffs()); + } + + @Test + void differingSqlStateIsMismatch() { + CapturedOutcome l = CapturedOutcome.threw(new SQLException("x", "42P01", 1)); + CapturedOutcome r = CapturedOutcome.threw(new SQLException("x", "08000", 1)); + ErrorComparison c = ErrorComparator.compare(l, r, o -> "class RS"); + assertEquals(ErrorComparison.Verdict.MISMATCH, c.verdict); + assertTrue( + c.dataDiffs.stream().anyMatch(d -> d.equals("Error SQLState mismatch: 42P01 vs 08000"))); + } + + @Test + void differingMessageIsMismatch() { + CapturedOutcome l = CapturedOutcome.threw(new SQLException("left message", "42P01", 1)); + CapturedOutcome r = CapturedOutcome.threw(new SQLException("right message", "42P01", 1)); + ErrorComparison c = ErrorComparator.compare(l, r, o -> "class RS"); + assertEquals(ErrorComparison.Verdict.MISMATCH, c.verdict); + assertTrue(c.dataDiffs.stream().anyMatch(d -> d.startsWith("Error message mismatch:"))); + } + + @Test + void oneSidedCarriesStablePrefixThrowerLeft() { + CapturedOutcome thrower = CapturedOutcome.threw(new SQLException("boom", "42P01", 0)); + CapturedOutcome returned = CapturedOutcome.returned("rs"); + ErrorComparison c = + ErrorComparator.compare(thrower, returned, o -> "class com.example.RS (5 rows)"); + assertEquals(ErrorComparison.Verdict.ONE_SIDED, c.verdict); + assertEquals(1, c.metadataDiffs.size()); + assertTrue(c.metadataDiffs.get(0).startsWith(ErrorComparator.ONE_SIDED_PREFIX)); + } + + @Test + void oneSidedCarriesStablePrefixThrowerRight() { + CapturedOutcome returned = CapturedOutcome.returned("rs"); + CapturedOutcome thrower = CapturedOutcome.threw(new SQLException("boom", "42P01", 0)); + ErrorComparison c = + ErrorComparator.compare(returned, thrower, o -> "class com.example.RS (5 rows)"); + assertEquals(ErrorComparison.Verdict.ONE_SIDED, c.verdict); + assertEquals(1, c.metadataDiffs.size()); + assertTrue(c.metadataDiffs.get(0).startsWith(ErrorComparator.ONE_SIDED_PREFIX)); + } + + @Test + void oneSidedThrowVsNullDoesNotThrowAndCarriesPrefix() { + // One side throws, the other returns null -> describer receives null. Must not NPE, and the + // diff must carry the stable prefix (the " vs class " marker does NOT survive here). + CapturedOutcome thrower = CapturedOutcome.threw(new SQLException("boom", "42P01", 0)); + CapturedOutcome nullSide = CapturedOutcome.returned(null); + ErrorComparison c = + ErrorComparator.compare(thrower, nullSide, o -> o == null ? "null" : "class " + o); + assertEquals(ErrorComparison.Verdict.ONE_SIDED, c.verdict); + assertEquals(1, c.metadataDiffs.size()); + assertTrue(c.metadataDiffs.get(0).startsWith(ErrorComparator.ONE_SIDED_PREFIX)); + assertTrue(c.metadataDiffs.get(0).endsWith(" vs null")); + } + + @Test + void csvSummaryKeepsOneSidedThrowVsNull() { + // The regression from review: a throw-vs-null one-sided diff must survive into csvSummary(). + CapturedOutcome thrower = CapturedOutcome.threw(new SQLException("boom", "42P01", 0)); + CapturedOutcome nullSide = CapturedOutcome.returned(null); + ErrorComparison c = + ErrorComparator.compare(thrower, nullSide, o -> o == null ? "null" : "class " + o); + ComparisonResult result = new ComparisonResult("t", "q", new Object[0]); + result.metadataDifferences.addAll(c.metadataDiffs); + result.dataDifferences.addAll(c.dataDiffs); + assertTrue(result.hasDifferences()); + assertFalse( + result.csvSummary().isEmpty(), "one-sided throw-vs-null must appear in CSV summary"); + assertTrue(result.csvSummary().contains(ErrorComparator.ONE_SIDED_PREFIX)); + } + + @Test + void offModeDisablesDeepComparison() { + assertFalse(ErrorPolicy.of(ErrorPolicy.Mode.OFF).isDeepComparisonEnabled()); + assertTrue(ErrorPolicy.of(ErrorPolicy.Mode.SHADOW).isDeepComparisonEnabled()); + } + + @Test + void unrecognizedModeDefaultsToOffInsteadOfThrowing() { + String prev = System.getProperty("ERROR_COMPARISON_MODE"); + try { + System.setProperty("ERROR_COMPARISON_MODE", "shaddow"); // typo + ErrorPolicy p = ErrorPolicy.active(); + assertEquals(ErrorPolicy.Mode.OFF, p.mode()); + assertFalse(p.isDeepComparisonEnabled()); + } finally { + if (prev == null) { + System.clearProperty("ERROR_COMPARISON_MODE"); + } else { + System.setProperty("ERROR_COMPARISON_MODE", prev); + } + } + } +} diff --git a/src/test/java/com/databricks/jdbc/comparator/error/ErrorComparison.java b/src/test/java/com/databricks/jdbc/comparator/error/ErrorComparison.java new file mode 100644 index 000000000..9e72ca0aa --- /dev/null +++ b/src/test/java/com/databricks/jdbc/comparator/error/ErrorComparison.java @@ -0,0 +1,43 @@ +package com.databricks.jdbc.comparator.error; + +import java.util.List; + +/** + * The outcome of comparing two {@link CapturedOutcome}s: a {@link Verdict} plus the diff strings to + * fold into a {@link com.databricks.jdbc.comparator.ComparisonResult}. + * + *

Diff strings are pre-formatted in the exact shapes {@code ComparisonResult.csvSummary()} + * understands (see {@link ErrorComparator}), so callers just append them to the appropriate + * differences list. + */ +public final class ErrorComparison { + + public enum Verdict { + /** Both threw and every compared field matched (or was tolerated). */ + MATCH, + /** Both threw but at least one compared field differed. */ + MISMATCH, + /** One side threw, the other returned/returned-null. */ + ONE_SIDED, + /** Neither side threw — fall back to normal data/metadata comparison. */ + NOT_APPLICABLE + } + + public final Verdict verdict; + + /** Diffs destined for {@code metadataDifferences} (one-sided "... vs class ..." strings). */ + public final List metadataDiffs; + + /** Diffs destined for {@code dataDifferences} ("Error mismatch: ..." strings). */ + public final List dataDiffs; + + public ErrorComparison(Verdict verdict, List metadataDiffs, List dataDiffs) { + this.verdict = verdict; + this.metadataDiffs = metadataDiffs; + this.dataDiffs = dataDiffs; + } + + public boolean hasDiffs() { + return !metadataDiffs.isEmpty() || !dataDiffs.isEmpty(); + } +} diff --git a/src/test/java/com/databricks/jdbc/comparator/error/ErrorFacts.java b/src/test/java/com/databricks/jdbc/comparator/error/ErrorFacts.java new file mode 100644 index 000000000..09c0b7798 --- /dev/null +++ b/src/test/java/com/databricks/jdbc/comparator/error/ErrorFacts.java @@ -0,0 +1,46 @@ +package com.databricks.jdbc.comparator.error; + +import java.sql.SQLException; + +/** + * The comparable fields read from a thrown {@link Throwable}, so an error can be compared + * field-by-field the way a ResultSet's columns are. + * + *

Every field is populated defensively — a null message or a non-SQL Throwable produces a + * well-formed {@code ErrorFacts} rather than an NPE. All fields come from real accessors; we make + * no assumptions about the message's internal format. + */ +public final class ErrorFacts { + + public final String exceptionClass; + public final String sqlState; + public final int vendorCode; + public final String message; + + private ErrorFacts(String exceptionClass, String sqlState, int vendorCode, String message) { + this.exceptionClass = exceptionClass; + this.sqlState = sqlState; + this.vendorCode = vendorCode; + this.message = message; + } + + /** Reads comparable fields from a thrown Throwable. Never throws, never returns null. */ + public static ErrorFacts from(Throwable t) { + String exceptionClass = t.getClass().getName(); + String message = t.getMessage(); + String sqlState = null; + int vendorCode = 0; + if (t instanceof SQLException) { + SQLException sqlException = (SQLException) t; + sqlState = sqlException.getSQLState(); + vendorCode = sqlException.getErrorCode(); + } + return new ErrorFacts(exceptionClass, sqlState, vendorCode, message); + } + + /** Short class name for concise diff strings (e.g. "DatabricksValidationException"). */ + public String simpleClassName() { + int lastDot = exceptionClass.lastIndexOf('.'); + return lastDot >= 0 ? exceptionClass.substring(lastDot + 1) : exceptionClass; + } +} diff --git a/src/test/java/com/databricks/jdbc/comparator/error/ErrorPolicy.java b/src/test/java/com/databricks/jdbc/comparator/error/ErrorPolicy.java new file mode 100644 index 000000000..5f3a1a8f3 --- /dev/null +++ b/src/test/java/com/databricks/jdbc/comparator/error/ErrorPolicy.java @@ -0,0 +1,73 @@ +package com.databricks.jdbc.comparator.error; + +/** + * Controls whether errors are compared deeply, via the {@code ERROR_COMPARISON_MODE} gate. + * + *

Modes advance with the rollout: {@code off} (legacy class-only check) → {@code shadow} + * (compare and report, never fail CI) → {@code authoritative} (un-baselined error diffs fail the + * build). Read the active policy via {@link #active()}. + * + *

Message normalization and tolerance/baseline handling are intentionally omitted for now — we + * compare the raw fields (class, SQLState, vendor code, message) and will add tolerance later, from + * observed shadow-run data, only if it proves necessary. + */ +public final class ErrorPolicy { + + public enum Mode { + OFF, + SHADOW, + AUTHORITATIVE + } + + private static final String MODE_PROPERTY = "ERROR_COMPARISON_MODE"; + + private final Mode mode; + + private ErrorPolicy(Mode mode) { + this.mode = mode; + } + + /** + * Resolves the active policy from system properties. Defaults to {@link Mode#OFF} for null, + * empty, or unrecognized values (the latter logs a warning) so a misconfigured flag never aborts + * a comparison run. + */ + public static ErrorPolicy active() { + return new ErrorPolicy(parseMode(System.getProperty(MODE_PROPERTY))); + } + + public static ErrorPolicy of(Mode mode) { + return new ErrorPolicy(mode); + } + + private static Mode parseMode(String raw) { + if (raw == null || raw.isEmpty()) { + return Mode.OFF; + } + switch (raw.trim().toLowerCase()) { + case "shadow": + return Mode.SHADOW; + case "authoritative": + return Mode.AUTHORITATIVE; + case "off": + return Mode.OFF; + default: + // Fail safe: a typo in the flag must not abort the comparison run. Default to OFF + // (legacy behavior) and warn, rather than throwing from every compare() call. + System.err.println( + "[comparator] Unknown ERROR_COMPARISON_MODE '" + + raw + + "' (expected off|shadow|authoritative); defaulting to off."); + return Mode.OFF; + } + } + + public Mode mode() { + return mode; + } + + /** True when deep error comparison should run at all (shadow or authoritative). */ + public boolean isDeepComparisonEnabled() { + return mode != Mode.OFF; + } +}