diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..71e6418 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: build test + +build: + @./mvnw clean spotless:apply package + +test: + @./mvnw test diff --git a/pom.xml b/pom.xml index b0dad8b..1d94d0c 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.zeplinko commons-lang-ext - 1.3.0 + 1.3.1-SNAPSHOT 8 @@ -42,7 +42,7 @@ org.sonatype.central central-publishing-maven-plugin - 0.4.0 + 0.9.0 true false diff --git a/src/main/java/org/zeplinko/commons/lang/ext/core/Try.java b/src/main/java/org/zeplinko/commons/lang/ext/core/Try.java index 4786fdc..a1cd506 100644 --- a/src/main/java/org/zeplinko/commons/lang/ext/core/Try.java +++ b/src/main/java/org/zeplinko/commons/lang/ext/core/Try.java @@ -1,12 +1,15 @@ package org.zeplinko.commons.lang.ext.core; import jakarta.annotation.Nonnull; +import org.zeplinko.commons.lang.ext.annotations.Preview; +import java.util.Arrays; import java.util.Objects; import java.util.Optional; import java.util.concurrent.Callable; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Predicate; /** * An immutable container that captures the successful result of a @@ -66,9 +69,8 @@ * instance inherently thread-safe as long as the wrapped value is itself * thread-safe. * - * @author Shivam Nagpal - * * @param the type of the successful value + * @author Shivam Nagpal */ public class Try extends AbstractOutcome { @@ -219,6 +221,153 @@ public Try recover(@Nonnull Function> map return compose(Try::success, mapper); } + /** + * Attempts to recover from a failure if the error satisfies the predicated + *

+ * If this Try is successful, returns it unchanged. If this Try contains an + * error that is doesn't satisfy the predicate, returns it unchanged. If this + * Try contains an error that satisfies the predicate, applies the failure + * mapper to attempt recovery. + *

+ * + * @param predicate the predicate to test the error + * @param mapper function to apply to the exception if it matches the + * specified type; should return a new Try representing the + * recovery attempt + * @return this Try if successful or if the error doesn't match the exception + * type; otherwise the result of applying the failureMapper to the error + * @throws NullPointerException if predicate or failureMapper is null + */ + public Try recover( + Predicate predicate, + Function> mapper + ) { + Objects.requireNonNull(predicate); + Objects.requireNonNull(mapper); + if (this.isFailure() && predicate.test(this.getError())) { + return mapper.apply(this.getError()); + } + return compose(Try::success, Try::failure); + } + + /** + * Attempts to recover from a failure if the error matches the specified + * exception type. + *

+ * If this Try is successful, returns it unchanged. If this Try contains an + * error that is NOT an instance of the specified exception type, returns it + * unchanged. If this Try contains an error that IS an instance of the specified + * exception type, applies the failure mapper to attempt recovery. + *

+ * + *

+ * Example usage: + *

+ * + *
+     * {
+     *     @code
+     *     Try<String> result = Try.of(() -> riskyOperation())
+     *             .recover(IOException.class, ex -> Try.success("default value"))
+     *             .recover(TimeoutException.class, ex -> Try.failure(new CustomException(ex)));
+     * }
+     * 
+ * + * @param exceptionType the class of the exception type to match against + * @param mapper function to apply to the exception if it matches the + * specified type; should return a new Try representing the + * recovery attempt + * @return this Try if successful or if the error doesn't match the exception + * type; otherwise the result of applying the failureMapper to the error + * @throws NullPointerException if exceptionType or failureMapper is null + * @see #recover(Function) + * @see #recover(Function, Class[]) + */ + public Try recover( + Class exceptionType, + Function> mapper + ) { + Objects.requireNonNull(exceptionType); + Objects.requireNonNull(mapper); + return recover(exceptionType::isInstance, mapper); + } + + /** + * Attempts to recover from a failure if the error matches any of the specified + * exception types. + *

+ * This method provides a convenient way to recover from multiple exception + * types using a single failure mapper. If this Try is successful, it returns + * unchanged. If this Try contains an error that does NOT match any of the + * specified exception types, it returns unchanged. If this Try contains an + * error that matches at least one of the specified exception types, the failure + * mapper is applied to attempt recovery. + *

+ * + *

+ * Null elements in the varargs array are automatically filtered out and ignored + * during exception type matching. + *

+ * + *

+ * Example usage: + *

+ * + *
+     * {
+     *     @code
+     *     // Recover from multiple I/O-related exceptions with a single handler
+     *     Try<String> result = Try.to(() -> readFile())
+     *             .recover(
+     *                     ex -> Try.success("default content"),
+     *                     IOException.class,
+     *                     FileNotFoundException.class,
+     *                     SocketTimeoutException.class
+     *             );
+     *
+     *     // Chain multiple recovery strategies for different exception groups
+     *     Try<Data> processed = Try.to(() -> processData())
+     *             .recover(
+     *                     ex -> Try.success(Data.empty()),
+     *                     IOException.class,
+     *                     TimeoutException.class
+     *             )
+     *             .recover(
+     *                     ex -> Try.failure(new ProcessingException(ex)),
+     *                     ValidationException.class,
+     *                     ParseException.class
+     *             );
+     * }
+     * 
+ * + * @param mapper function to apply to the exception if it matches any of + * the specified types; should return a new Try + * representing the recovery attempt + * @param exceptionTypes varargs array of exception types to match against; null + * elements are ignored + * @return this Try if successful or if the error doesn't match any exception + * type; otherwise the result of applying the failureMapper to the error + * @throws NullPointerException if failureMapper or exceptionTypes array itself + * is null + * @see #recover(Class, Function) + * @see #recover(Function) + */ + @SafeVarargs + @Preview + public final Try recover( + Function> mapper, + Class... exceptionTypes + ) { + Objects.requireNonNull(mapper); + Objects.requireNonNull(exceptionTypes); + return recover( + e -> Arrays.stream(exceptionTypes) + .filter(Objects::nonNull) + .anyMatch(exceptionType -> exceptionType.isInstance(e)), + mapper + ); + } + /** * Transforms this {@code Try} into another {@code Try} using the provided * transformation function. @@ -281,6 +430,171 @@ public Try onFailure(Consumer errorConsumer) { return onHandle(null, errorConsumer); } + /** + * Executes the provided consumer if this {@code Try} is a failure and the error + * satisfies the predicate. + *

+ * If this Try is successful, nothing happens, and this Try is returned + * unchanged. If this Try contains an error that is doesn't satisfy the + * predicate, nothing happens, and this Try is returned unchanged. If this Try + * contains an error that satisfies the predicate, the error consumer is + * executed. + *

+ * + *

+ * This method is useful for handling specific exception types differently, such + * as logging at different levels, recording metrics, or performing + * type-specific cleanup operations. + *

+ * + * @param predicate the class of the exception type to match against + * @param errorConsumer consumer to execute if the error matches the specified + * type + * @return this Try instance for method chaining + * @throws NullPointerException if predicate is null + * @see #onFailure(Consumer) + * @see #onFailure(Consumer, Class[]) + * @see #recover(Class, Function) + */ + public Try onFailure( + Predicate predicate, + Consumer errorConsumer + ) { + Objects.requireNonNull(predicate); + if (isFailure() && predicate.test(getError())) { + return onHandle(null, errorConsumer); + } + return this; + } + + /** + * Executes the provided consumer if this {@code Try} is a failure and the error + * matches the specified exception type. + *

+ * If this Try is successful, nothing happens, and this Try is returned + * unchanged. If this Try contains an error that is NOT an instance of the + * specified exception type, nothing happens, and this Try is returned + * unchanged. If this Try contains an error that IS an instance of the specified + * exception type, the error consumer is executed. + *

+ * + *

+ * This method is useful for handling specific exception types differently, such + * as logging at different levels, recording metrics, or performing + * type-specific cleanup operations. + *

+ * + *

+ * Example usage: + *

+ * + *
+     * {
+     *     @code
+     *     // Log different exception types at different levels
+     *     Try<Data> result = Try.to(() -> fetchData())
+     *             .onFailure(IOException.class, ex -> logger.error("I/O error: {}", ex.getMessage()))
+     *             .onFailure(TimeoutException.class, ex -> logger.warn("Timeout: {}", ex.getMessage()))
+     *             .onFailure(ex -> logger.error("Unexpected error", ex));
+     *
+     *     // Record type-specific metrics
+     *     Try<Response> apiResult = Try.to(() -> callApi())
+     *             .onFailure(TimeoutException.class, ex -> metrics.increment("api.timeout"))
+     *             .onFailure(IOException.class, ex -> metrics.increment("api.network_error"));
+     * }
+     * 
+ * + * @param exceptionType the class of the exception type to match against + * @param errorConsumer consumer to execute if the error matches the specified + * type + * @return this Try instance for method chaining + * @throws NullPointerException if exceptionType is null + * @see #onFailure(Consumer) + * @see #onFailure(Consumer, Class[]) + * @see #recover(Class, Function) + */ + public Try onFailure( + Class exceptionType, + Consumer errorConsumer + ) { + Objects.requireNonNull(exceptionType); + return onFailure(exceptionType::isInstance, errorConsumer); + } + + /** + * Executes the provided consumer if this {@code Try} is a failure and the error + * matches any of the specified exception types. + *

+ * This method provides a convenient way to handle multiple exception types + * using a single error consumer. If this Try is successful, nothing happens, + * and this Try is returned unchanged. If this Try contains an error that does + * NOT match any of the specified exception types, nothing happens, and this Try + * is returned unchanged. If this Try contains an error that matches at least + * one of the specified exception types, the error consumer is executed. + *

+ * + *

+ * Null elements in the varargs array are automatically filtered out and ignored + * during exception type matching. + *

+ * + *

+ * Example usage: + *

+ * + *
+     * {
+     *     @code
+     *     // Handle multiple I/O-related exceptions with a single handler
+     *     Try<String> result = Try.to(() -> readFile())
+     *             .onFailure(
+     *                     ex -> logger.error("I/O operation failed: {}", ex.getMessage()),
+     *                     IOException.class,
+     *                     FileNotFoundException.class,
+     *                     SocketTimeoutException.class
+     *             );
+     *
+     *     // Combine different error handling strategies
+     *     Try<Data> processed = Try.to(() -> processData())
+     *             .onFailure(
+     *                     ex -> metrics.increment("errors.io"),
+     *                     IOException.class,
+     *                     TimeoutException.class
+     *             )
+     *             .onFailure(
+     *                     ex -> metrics.increment("errors.validation"),
+     *                     ValidationException.class,
+     *                     ParseException.class
+     *             )
+     *             .onFailure(ex -> metrics.increment("errors.other"));
+     * }
+     * 
+ * + * @param errorConsumer consumer to execute if the error matches any of the + * specified types + * @param exceptionTypes varargs array of exception types to match against; null + * elements are ignored + * @return this Try instance for method chaining + * @throws NullPointerException if exceptionTypes array itself is null + * @see #onFailure(Consumer) + * @see #onFailure(Class, Consumer) + * @see #recover(Function, Class[]) + */ + @SafeVarargs + @Preview + public final Try onFailure( + Consumer errorConsumer, + Class... exceptionTypes + ) { + Objects.requireNonNull(exceptionTypes); + return onFailure( + e -> Arrays.stream(exceptionTypes) + .filter(Objects::nonNull) + .anyMatch(exceptionType -> exceptionType.isInstance(e)), + errorConsumer + ); + } + /** * Converts this {@code Try} into a {@code Result} object. * diff --git a/src/test/java/org/zeplinko/commons/lang/ext/core/TryTest.java b/src/test/java/org/zeplinko/commons/lang/ext/core/TryTest.java index e10d61b..89321ef 100644 --- a/src/test/java/org/zeplinko/commons/lang/ext/core/TryTest.java +++ b/src/test/java/org/zeplinko/commons/lang/ext/core/TryTest.java @@ -11,6 +11,7 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.Callable; +import java.util.function.Predicate; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; @@ -320,7 +321,6 @@ void test_givenFailure_whenFlatMapIsCalled_thenSameFailureIsReturned() { @Test void test_givenNullMapper_whenRecoverIsCalled_thenExceptionIsThrown() { Try failure = Try.failure(new RuntimeException("error")); - @SuppressWarnings("DataFlowIssue") NullPointerException nullPointerException = assertThrows( NullPointerException.class, @@ -348,6 +348,118 @@ void test_givenSuccess_whenRecoverIsCalled_thenSameSuccessIsReturned() { assertEquals("data", result.getData()); } + @Test + void test_givenNullMapper_whenPredicateRecoverIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings("DataFlowIssue") + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.recover((Predicate) IllegalArgumentException.class::isInstance, null) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenNullPredicate_whenPredicateRecoverIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings("DataFlowIssue") + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.recover((Predicate) null, e -> Try.success("ignored")) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenSuccess_whenPredicateRecoverIsCalled_thenSameSuccessIsReturned() { + Try success = Try.success("data"); + Try result = success.recover(IllegalArgumentException.class::isInstance, e -> Try.success("ignored")); + + assertTrue(result.isSuccess()); + assertEquals("data", result.getData()); + } + + @Test + void test_givenFailureWhichDoesNotSatisfyThePredicate_whenPredicateRecoverIsCalled_thenSameTryIsReturned() { + Exception error = new RuntimeException("error"); + Try failure = Try.failure(error); + Try result = failure.recover(IllegalArgumentException.class::isInstance, e -> Try.success("recovered")); + + assertTrue(result.isFailure()); + assertSame(error, result.getError()); + } + + @Test + void test_givenFailureWhichSatisfiesThePredicate_whenPredicateRecoverIsCalled_thenSameTryIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover(IllegalArgumentException.class::isInstance, e -> Try.success("recovered")); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenNullExceptionType_whenRecoverWithExceptionTypeIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings("DataFlowIssue") + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.recover((Class) null, e -> Try.success(0)) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenSuccess_whenRecoverWithExceptionTypeIsCalled_thenSameSuccessIsReturned() { + Try success = Try.success("data"); + Try result = success.recover(RuntimeException.class, e -> Try.success("recovered")); + + assertTrue(result.isSuccess()); + assertEquals("data", result.getData()); + } + + @Test + void test_givenFailureWithNonMatchingException_whenRecoverWithExceptionTypeIsCalled_thenSameFailureIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover(IllegalStateException.class, e -> Try.success("recovered")); + + assertTrue(result.isFailure()); + assertSame(error, result.getError()); + } + + @Test + void test_givenFailureWithMatchingException_whenRecoverWithExceptionTypeIsCalled_thenRecoveredTryIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover(IllegalArgumentException.class, e -> Try.success("recovered")); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithMatchingExceptionSubclass_whenRecoverWithExceptionTypeIsCalled_thenRecoveredTryIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover(RuntimeException.class, e -> Try.success("recovered")); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithMatchingException_whenRecoverWithExceptionTypeReturnsFailure_thenNewFailureIsReturned() { + Exception originalError = new IllegalArgumentException("original"); + Exception newError = new IllegalStateException("new error"); + Try failure = Try.failure(originalError); + Try result = failure.recover(IllegalArgumentException.class, e -> Try.failure(newError)); + + assertTrue(result.isFailure()); + assertSame(newError, result.getError()); + } + @Test void test_givenNullMapper_whenTransformIsCalled_thenExceptionIsThrown() { Try success = Try.success(10); @@ -440,7 +552,7 @@ void test_givenFailure_whenOnFailureIsCalled_thenConsumerIsExecuted() { } @Test - void test_givenSuccess_whenOnFailureIsCalled_thenConsumerIsExecuted() { + void test_givenSuccess_whenOnFailureIsCalled_thenConsumerIsNotExecuted() { Try success = Try.success("data"); StringBuilder consumed = new StringBuilder(); Try returnedTry = success.onFailure(e -> consumed.append(e.getMessage())); @@ -449,6 +561,368 @@ void test_givenSuccess_whenOnFailureIsCalled_thenConsumerIsExecuted() { assertEquals(0, consumed.length()); } + @Test + void test_givenNullPredicate_whenPredicateOnFailureIsCalled_thenThrowsException() { + Try failure = Try.failure(new RuntimeException("error")); + StringBuilder consumed = new StringBuilder(); + Assertions.assertThrows( + NullPointerException.class, + () -> failure.onFailure((Predicate) null, e -> consumed.append(e.getMessage())) + ); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenSuccess_whenPredicateOnFailureIsCalled_thenConsumerIsNotExecuted() { + Try success = Try.success("data"); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = success + .onFailure(IllegalArgumentException.class::isInstance, e -> consumed.append(e.getMessage())); + + assertSame(success, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenFailureWhichDoesNotSatisfyPredicate_whenPredicateOnFailureIsCalled_thenConsumerIsNotExecuted() { + Exception error = new RuntimeException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure + .onFailure(IllegalArgumentException.class::isInstance, e -> consumed.append(e.getMessage())); + + assertSame(failure, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenFailureWhichSatisfiesPredicate_whenPredicateOnFailureIsCalled_thenConsumerIsExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure + .onFailure(IllegalArgumentException.class::isInstance, e -> consumed.append(e.getMessage())); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + // ========== Tests for single exception type onFailure method ========== + + @Test + void test_givenNullExceptionType_whenOnFailureWithExceptionTypeIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings("DataFlowIssue") + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.onFailure((Class) null, e -> { + }) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenSuccess_whenOnFailureWithExceptionTypeIsCalled_thenConsumerIsNotExecuted() { + Try success = Try.success("data"); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = success.onFailure(RuntimeException.class, e -> consumed.append(e.getMessage())); + + assertSame(success, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenFailureWithNonMatchingException_whenOnFailureWithExceptionTypeIsCalled_thenConsumerIsNotExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure(IllegalStateException.class, e -> consumed.append(e.getMessage())); + + assertSame(failure, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenFailureWithMatchingException_whenOnFailureWithExceptionTypeIsCalled_thenConsumerIsExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure + .onFailure(IllegalArgumentException.class, e -> consumed.append(e.getMessage())); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureWithMatchingExceptionSubclass_whenOnFailureWithExceptionTypeIsCalled_thenConsumerIsExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure(RuntimeException.class, e -> consumed.append(e.getMessage())); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenChainedOnFailureCallsWithDifferentTypes_whenFailureMatches_thenOnlyMatchingConsumerIsExecuted() { + Exception error = new IllegalArgumentException("validation error"); + Try failure = Try.failure(error); + List log = new ArrayList<>(); + + Try result = failure + .onFailure(IllegalStateException.class, e -> log.add("state error")) + .onFailure(IllegalArgumentException.class, e -> log.add("argument error")) + .onFailure(NullPointerException.class, e -> log.add("null error")); + + assertSame(failure, result); + assertEquals(1, log.size()); + assertEquals("argument error", log.get(0)); + } + + @Test + void test_givenChainedOnFailureWithCatchAll_whenFailureOccurs_thenAllMatchingConsumersAreExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + List log = new ArrayList<>(); + + Try result = failure + .onFailure(IllegalArgumentException.class, e -> log.add("specific")) + .onFailure(RuntimeException.class, e -> log.add("general")) + .onFailure(e -> log.add("catch-all")); + + assertSame(failure, result); + assertEquals(3, log.size()); + assertEquals("specific", log.get(0)); + assertEquals("general", log.get(1)); + assertEquals("catch-all", log.get(2)); + } + + // ========== Tests for varargs exception types onFailure method ========== + + @Test + void test_givenNullExceptionTypesArray_whenOnFailureWithVarargsIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings({ "DataFlowIssue", "unchecked" }) + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.onFailure(e -> { + }, (Class[]) null) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenSuccess_whenOnFailureWithVarargsIsCalled_thenConsumerIsNotExecuted() { + Try success = Try.success("data"); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = success.onFailure( + e -> consumed.append(e.getMessage()), + RuntimeException.class, + IllegalArgumentException.class + ); + + assertSame(success, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenFailureWithNoMatchingException_whenOnFailureWithVarargsIsCalled_thenConsumerIsNotExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + IllegalStateException.class, + NullPointerException.class + ); + + assertSame(failure, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenFailureWithEmptyVarargsArray_whenOnFailureWithVarargsIsCalled_thenConsumerIsNotExecuted() { + Exception error = new RuntimeException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + new Class[0] + ); + + assertSame(failure, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenFailureWithSingleMatchingException_whenOnFailureWithVarargsIsCalled_thenConsumerIsExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + IllegalArgumentException.class + ); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureWithFirstMatchingException_whenOnFailureWithVarargsIsCalled_thenConsumerIsExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + IllegalArgumentException.class, + IllegalStateException.class, + NullPointerException.class + ); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureWithSecondMatchingException_whenOnFailureWithVarargsIsCalled_thenConsumerIsExecuted() { + Exception error = new IllegalStateException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + IllegalArgumentException.class, + IllegalStateException.class, + NullPointerException.class + ); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureWithLastMatchingException_whenOnFailureWithVarargsIsCalled_thenConsumerIsExecuted() { + Exception error = new NullPointerException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + IllegalArgumentException.class, + IllegalStateException.class, + NullPointerException.class + ); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureWithMatchingExceptionSubclass_whenOnFailureWithVarargsIsCalled_thenConsumerIsExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + RuntimeException.class, + Exception.class + ); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureAndNullExceptionTypesInVarargs_whenOnFailureWithVarargsIsCalled_thenNullsAreIgnored() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + @SuppressWarnings("unchecked") + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + null, + IllegalArgumentException.class, + null + ); + + assertSame(failure, returnedTry); + assertEquals("error", consumed.toString()); + } + + @Test + void test_givenFailureAndAllNullExceptionTypesInVarargs_whenOnFailureWithVarargsIsCalled_thenConsumerIsNotExecuted() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + StringBuilder consumed = new StringBuilder(); + @SuppressWarnings("unchecked") + Try returnedTry = failure.onFailure( + e -> consumed.append(e.getMessage()), + null, + null + ); + + assertSame(failure, returnedTry); + assertEquals(0, consumed.length()); + } + + @Test + void test_givenMultipleIOExceptions_whenOnFailureWithVarargsIsCalled_thenSingleHandlerIsExecuted() { + Exception error = new java.io.FileNotFoundException("file not found"); + Try failure = Try.failure(error); + List log = new ArrayList<>(); + Try returnedTry = failure.onFailure( + e -> log.add("I/O error: " + e.getMessage()), + java.io.IOException.class, + java.io.FileNotFoundException.class, + java.net.SocketTimeoutException.class + ); + + assertSame(failure, returnedTry); + assertEquals(1, log.size()); + assertEquals("I/O error: file not found", log.get(0)); + } + + @Test + void test_givenChainedOnFailureCallsWithVarargs_whenDifferentExceptionGroups_thenCorrectHandlerIsExecuted() { + Exception error = new IllegalArgumentException("validation error"); + Try failure = Try.failure(error); + List log = new ArrayList<>(); + + Try result = failure + .onFailure( + e -> log.add("io"), + java.io.IOException.class, + java.util.concurrent.TimeoutException.class + ) + .onFailure( + e -> log.add("validation"), + IllegalArgumentException.class, + IllegalStateException.class + ); + + assertSame(failure, result); + assertEquals(1, log.size()); + assertEquals("validation", log.get(0)); + } + + @Test + void test_givenRealWorldLoggingScenario_whenOnFailureWithMultipleTypes_thenCorrectLoggingOccurs() { + Exception error = new java.io.IOException("network error"); + Try failure = Try.failure(error); + List logMessages = new ArrayList<>(); + + Try result = failure + .onFailure(java.io.IOException.class, e -> logMessages.add("ERROR: " + e.getMessage())) + .onFailure(java.util.concurrent.TimeoutException.class, e -> logMessages.add("WARN: " + e.getMessage())) + .onFailure(e -> logMessages.add("UNKNOWN: " + e.getMessage())); + + assertSame(failure, result); + assertEquals(2, logMessages.size()); + assertEquals("ERROR: network error", logMessages.get(0)); + assertEquals("UNKNOWN: network error", logMessages.get(1)); + } + @Test void test_givenSuccess_whenToResultIsCalled_thenResultIsReturned() { Try success = Try.success("data"); @@ -468,6 +942,224 @@ void test_givenFailure_whenToResultIsCalled_thenResultIsReturned() { assertSame(error, result.getError()); } + // ========== Tests for varargs recover method ========== + + @Test + void test_givenNullFailureMapper_whenRecoverWithVarargsIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings("DataFlowIssue") + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.recover(null, RuntimeException.class, IllegalArgumentException.class) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenNullExceptionTypesArray_whenRecoverWithVarargsIsCalled_thenExceptionIsThrown() { + Try failure = Try.failure(new RuntimeException("error")); + @SuppressWarnings({ "DataFlowIssue", "unchecked" }) + NullPointerException nullPointerException = assertThrows( + NullPointerException.class, + () -> failure.recover(e -> Try.success(0), (Class[]) null) + ); + assertNotNull(nullPointerException); + } + + @Test + void test_givenSuccess_whenRecoverWithVarargsIsCalled_thenSameSuccessIsReturned() { + Try success = Try.success("data"); + Try result = success.recover( + e -> Try.success("recovered"), + RuntimeException.class, + IllegalArgumentException.class + ); + + assertTrue(result.isSuccess()); + assertEquals("data", result.getData()); + } + + @Test + void test_givenFailureWithNoMatchingException_whenRecoverWithVarargsIsCalled_thenSameFailureIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("recovered"), + IllegalStateException.class, + NullPointerException.class + ); + + assertTrue(result.isFailure()); + assertSame(error, result.getError()); + } + + @Test + void test_givenFailureWithEmptyVarargsArray_whenRecoverWithVarargsIsCalled_thenSameFailureIsReturned() { + Exception error = new RuntimeException("error"); + Try failure = Try.failure(error); + @SuppressWarnings("unchecked") + Try result = failure.recover( + e -> Try.success("recovered"), + new Class[0] // Explicitly pass empty array to call varargs version + ); + + assertTrue(result.isFailure()); + assertSame(error, result.getError()); + } + + @Test + void test_givenFailureWithSingleMatchingException_whenRecoverWithVarargsIsCalled_thenRecoveredTryIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("recovered"), + IllegalArgumentException.class + ); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithFirstMatchingException_whenRecoverWithVarargsIsCalled_thenRecoveredTryIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("recovered"), + IllegalArgumentException.class, + IllegalStateException.class, + NullPointerException.class + ); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithSecondMatchingException_whenRecoverWithVarargsIsCalled_thenRecoveredTryIsReturned() { + Exception error = new IllegalStateException("error"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("recovered"), + IllegalArgumentException.class, + IllegalStateException.class, + NullPointerException.class + ); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithLastMatchingException_whenRecoverWithVarargsIsCalled_thenRecoveredTryIsReturned() { + Exception error = new NullPointerException("error"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("recovered"), + IllegalArgumentException.class, + IllegalStateException.class, + NullPointerException.class + ); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithMatchingExceptionSubclass_whenRecoverWithVarargsIsCalled_thenRecoveredTryIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("recovered"), + RuntimeException.class, + Exception.class + ); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureWithMatchingException_whenRecoverWithVarargsReturnsFailure_thenNewFailureIsReturned() { + Exception originalError = new IllegalArgumentException("original"); + Exception newError = new IllegalStateException("new error"); + Try failure = Try.failure(originalError); + Try result = failure.recover( + e -> Try.failure(newError), + IllegalArgumentException.class, + NullPointerException.class + ); + + assertTrue(result.isFailure()); + assertSame(newError, result.getError()); + } + + @Test + void test_givenFailureAndNullExceptionTypesInVarargs_whenRecoverWithVarargsIsCalled_thenNullsAreIgnored() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + @SuppressWarnings("unchecked") + Try result = failure.recover( + e -> Try.success("recovered"), + null, + IllegalArgumentException.class, + null + ); + + assertTrue(result.isSuccess()); + assertEquals("recovered", result.getData()); + } + + @Test + void test_givenFailureAndAllNullExceptionTypesInVarargs_whenRecoverWithVarargsIsCalled_thenSameFailureIsReturned() { + Exception error = new IllegalArgumentException("error"); + Try failure = Try.failure(error); + @SuppressWarnings("unchecked") + Try result = failure.recover( + e -> Try.success("recovered"), + null, + null + ); + + assertTrue(result.isFailure()); + assertSame(error, result.getError()); + } + + @Test + void test_givenMultipleIOExceptions_whenRecoverWithVarargsIsCalled_thenSingleHandlerIsApplied() { + Exception error = new java.io.FileNotFoundException("file not found"); + Try failure = Try.failure(error); + Try result = failure.recover( + e -> Try.success("default content"), + java.io.IOException.class, + java.io.FileNotFoundException.class, + java.net.SocketTimeoutException.class + ); + + assertTrue(result.isSuccess()); + assertEquals("default content", result.getData()); + } + + @Test + void test_givenChainedRecoverCallsWithVarargs_whenDifferentExceptionGroups_thenCorrectHandlerIsApplied() { + Exception error = new IllegalArgumentException("validation error"); + Try failure = Try.failure(error); + Try result = failure + .recover( + e -> Try.success("io recovered"), + java.io.IOException.class, + java.util.concurrent.TimeoutException.class + ) + .recover( + e -> Try.success("validation recovered"), + IllegalArgumentException.class, + IllegalStateException.class + ); + + assertTrue(result.isSuccess()); + assertEquals("validation recovered", result.getData()); + } + @MethodSource("provideHashcodeCoverageCases") @ParameterizedTest void test_givenTry_whenHashcodeIsInvoked_thenRespectiveOutcomeIsReturned(Try tryObject, int expectedHashcode) {