diff --git a/core/src/main/scala/com/avsystem/commons/SharedExtensions.scala b/core/src/main/scala/com/avsystem/commons/SharedExtensions.scala index a22d1056a..f1c85d262 100644 --- a/core/src/main/scala/com/avsystem/commons/SharedExtensions.scala +++ b/core/src/main/scala/com/avsystem/commons/SharedExtensions.scala @@ -1,14 +1,14 @@ package com.avsystem.commons import com.avsystem.commons.concurrent.RunNowEC -import com.avsystem.commons.misc._ +import com.avsystem.commons.misc.* import scala.annotation.{nowarn, tailrec} import scala.collection.{AbstractIterator, BuildFrom, Factory, mutable} trait SharedExtensions { - import com.avsystem.commons.SharedExtensionsUtils._ + import com.avsystem.commons.SharedExtensionsUtils.* implicit def universalOps[A](a: A): UniversalOps[A] = new UniversalOps(a) @@ -461,6 +461,27 @@ object SharedExtensionsUtils extends SharedExtensions { */ def toOptArg: OptArg[A] = if (tr.isFailure) OptArg.Empty else OptArg(tr.get) + + /** + * Apply side-effect only if Try is a failure. The provided `action` function will be called with the + * throwable from the failure case, allowing you to perform operations like logging or error handling. + * + * Non-fatal exceptions thrown by the `action` function are caught and ignored, ensuring that this method + * always returns the original Try instance regardless of what happens in the action. + * + * Don't use .failed projection, because it unnecessarily creates Exception in case of Success, + * which is an expensive operation. + */ + def tapFailure(action: Throwable => Unit): Try[A] = tr match { + case Success(_) => tr + case Failure(throwable) => + try action(throwable) + catch { + case NonFatal(_) => // ignore non-fatal exceptions thrown by the action + } + tr + + } } class LazyTryOps[A](private val tr: () => Try[A]) extends AnyVal { @@ -502,7 +523,7 @@ object SharedExtensionsUtils extends SharedExtensions { class PartialFunctionOps[A, B](private val pf: PartialFunction[A, B]) extends AnyVal { - import PartialFunctionOps._ + import PartialFunctionOps.* /** * The same thing as `orElse` but with arguments flipped. @@ -638,7 +659,7 @@ object SharedExtensionsUtils extends SharedExtensions { class MapOps[M[X, Y] <: BMap[X, Y], K, V](private val map: M[K, V]) extends AnyVal { - import MapOps._ + import MapOps.* def getOpt(key: K): Opt[V] = map.get(key).toOpt diff --git a/core/src/test/scala/com/avsystem/commons/misc/SharedExtensionsTest.scala b/core/src/test/scala/com/avsystem/commons/misc/SharedExtensionsTest.scala index b8b67a755..ced554ccf 100644 --- a/core/src/test/scala/com/avsystem/commons/misc/SharedExtensionsTest.scala +++ b/core/src/test/scala/com/avsystem/commons/misc/SharedExtensionsTest.scala @@ -1,11 +1,10 @@ package com.avsystem.commons.misc +import com.avsystem.commons.CommonAliases.* +import com.avsystem.commons.SharedExtensions.* import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers -import com.avsystem.commons.SharedExtensions._ -import com.avsystem.commons.CommonAliases._ - class SharedExtensionsTest extends AnyFunSuite with Matchers { test("mkMap") { List.range(0, 3).mkMap(identity, _.toString) shouldEqual @@ -81,7 +80,7 @@ class SharedExtensionsTest extends AnyFunSuite with Matchers { } test("Future.transformWith") { - import com.avsystem.commons.concurrent.RunNowEC.Implicits._ + import com.avsystem.commons.concurrent.RunNowEC.Implicits.* val ex = new Exception assert(Future.successful(42).transformWith(t => Future.successful(t.get - 1)).value.contains(Success(41))) assert(Future.successful(42).transformWith(_ => Future.failed(ex)).value.contains(Failure(ex))) @@ -206,4 +205,46 @@ class SharedExtensionsTest extends AnyFunSuite with Matchers { | abc | abc""".stripMargin) } + + test("Try.tapFailure - Success case") { + var actionCalled = false + val successTry = Success(42) + val result = successTry.tapFailure(_ => actionCalled = true) + + assert(!actionCalled, "Action should not be called for Success") + assert(result === successTry, "Original Success should be returned") + } + + test("Try.tapFailure - Failure case") { + var capturedThrowable: Throwable = null + val exception = new RuntimeException("test exception") + val failureTry = Failure(exception) + + val result = failureTry.tapFailure(t => capturedThrowable = t) + + assert(capturedThrowable === exception, "Action should be called with the exception") + assert(result === failureTry, "Original Failure should be returned") + } + + test("Try.tapFailure - Exception in action") { + val originalException = new RuntimeException("original exception") + val actionException = new RuntimeException("action exception") + val failureTry = Failure(originalException) + + val result = failureTry.tapFailure(_ => throw actionException) + + assert(result === failureTry, "Original Failure should be returned even if action throws") + } + + test("Try.tapFailure - Fatal exception in action") { + val originalException = new RuntimeException("original exception") + val fatalException = new OutOfMemoryError("fatal exception") + val failureTry = Failure(originalException) + + val thrown = intercept[OutOfMemoryError] { + failureTry.tapFailure(_ => throw fatalException) + } + + assert(thrown === fatalException, "Fatal exception should propagate out of tapFailure") + } }