diff --git a/README.md b/README.md index be33d6e..2c2a236 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ The components are in different stages of development, and some may not be fully - 🧪 Testing: The component is being tested and may have known issues. - ✅ Stable: The component is stable and ready for production use. +### [Core Components](components/core/README.md) + +| Component | Status | Module | Maven Coordinate | Description | +|----------------------------------------------|-------------------|----------------------------|-------------------------------------------|-------------------------------------------------------------------------| +| [Outcome](components/core/outcome/README.md) | 🛠 In development | `:components:core:outcome` | `net.thunderbird.components.core:outcome` | Small result type with a flexible failure type, unlike Kotlin `Result`. | + ## Contributing We welcome contributions to this repository! If you would like to contribute, please read our diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/KotlinMultiplatformExtension.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/KotlinMultiplatformExtension.kt index 5d631c2..fe99d0a 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/KotlinMultiplatformExtension.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/KotlinMultiplatformExtension.kt @@ -1,9 +1,11 @@ package net.thunderbird.gradle.plugin.library.kmp import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget +import net.thunderbird.gradle.plugin.ProjectConfig import org.gradle.api.Action import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.NamedDomainObjectProvider +import org.gradle.api.Project import org.gradle.api.artifacts.ExternalModuleDependency import org.gradle.api.artifacts.MinimalExternalModuleDependency import org.gradle.api.plugins.ExtensionAware @@ -20,6 +22,17 @@ fun KotlinMultiplatformExtension.android(configure: Action { } android { + namespaceByPath(target) minSdk = ProjectConfig.Android.sdkMin compileSdk = ProjectConfig.Android.sdkCompile diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt index 83f80b7..580a1e7 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/library/kmp/compose/LibraryKmpComposePlugin.kt @@ -3,6 +3,7 @@ package net.thunderbird.gradle.plugin.library.kmp.compose import net.thunderbird.gradle.plugin.ProjectConfig import net.thunderbird.gradle.plugin.library.kmp.android import net.thunderbird.gradle.plugin.library.kmp.androidHostTest +import net.thunderbird.gradle.plugin.library.kmp.namespaceByPath import net.thunderbird.gradle.plugin.libs import org.gradle.api.Plugin import org.gradle.api.Project @@ -48,6 +49,7 @@ class LibraryKmpComposePlugin : Plugin { } android { + namespaceByPath(target) minSdk = ProjectConfig.Android.sdkMin compileSdk = ProjectConfig.Android.sdkCompile diff --git a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt index a0c448a..97caf68 100644 --- a/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt +++ b/build-plugin/plugin/src/main/kotlin/net/thunderbird/gradle/plugin/quality/detekt/DetektPlugin.kt @@ -9,6 +9,8 @@ import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.assign import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.named +import org.gradle.kotlin.dsl.register import org.gradle.kotlin.dsl.withType /** @@ -19,6 +21,7 @@ import org.gradle.kotlin.dsl.withType class DetektPlugin : Plugin { override fun apply(target: Project) { with(target) { + pluginManager.apply("base") pluginManager.apply("dev.detekt") dependencies { @@ -56,8 +59,6 @@ class DetektPlugin : Plugin { sarif.required.set(true) markdown.required.set(true) } - - tasks.getByName("build").dependsOn(this) } withType().configureEach { @@ -70,12 +71,16 @@ class DetektPlugin : Plugin { exclude(defaultExcludes) } - register("detektAll") { + val detektAll = register("detektAll") { group = "verification" description = "Runs detekt on this project" dependsOn(tasks.withType()) } + + named("check") { + dependsOn(detektAll) + } } } } diff --git a/components/bom/build.gradle.kts b/components/bom/build.gradle.kts index 224b1eb..16d3101 100644 --- a/components/bom/build.gradle.kts +++ b/components/bom/build.gradle.kts @@ -4,7 +4,6 @@ plugins { dependencies { constraints { - // Add constraints for published components here, e.g.: - // api("net.thunderbird:some-component:${project.version}") + api(projects.components.core.outcome) } } diff --git a/components/core/README.md b/components/core/README.md new file mode 100644 index 0000000..650fd3b --- /dev/null +++ b/components/core/README.md @@ -0,0 +1,10 @@ +# Thunderbird Core Components + +Core components provide small, foundational building blocks that can be reused by higher-level +components and applications. + +## Components + +| Component | Module | Maven Coordinate | Description | +|-----------|----------------------|-------------------------------------------|----------------------------------------------------------------------------| +| [Outcome](outcome/README.md) | `:components:core:outcome` | `net.thunderbird.components.core:outcome` | Small result type with a flexible failure type, unlike Kotlin `Result`. | diff --git a/components/core/outcome/CHANGELOG.md b/components/core/outcome/CHANGELOG.md new file mode 100644 index 0000000..617d979 --- /dev/null +++ b/components/core/outcome/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## Unreleased + diff --git a/components/core/outcome/README.md b/components/core/outcome/README.md new file mode 100644 index 0000000..12b57a0 --- /dev/null +++ b/components/core/outcome/README.md @@ -0,0 +1,62 @@ +# Thunderbird Outcome Component + +Outcome provides a small result type for operations that need a domain-specific failure type. + +Use it when Kotlin `Result` is too restrictive because callers need a typed failure value instead +of only a `Throwable`. + +## Dependency + +```kotlin +dependencies { + implementation("net.thunderbird.components.core:outcome:") +} +``` + +## Usage + +```kotlin +import net.thunderbird.components.core.outcome.Outcome +import net.thunderbird.components.core.outcome.flatMapSuccess +import net.thunderbird.components.core.outcome.fold +import net.thunderbird.components.core.outcome.mapFailure +import net.thunderbird.components.core.outcome.mapSuccess + +sealed interface ReadError { + data object Missing : ReadError + data object Invalid : ReadError +} + +fun readValue(): Outcome { + return Outcome.success("42") +} + +val result = readValue() + .mapSuccess { it.toInt() } + .mapFailure { error, _ -> error } + .flatMapSuccess { value -> + if (value > 0) { + Outcome.success(value) + } else { + Outcome.failure(ReadError.Invalid) + } + } + .fold( + onSuccess = { value -> "Read value: $value" }, + onFailure = { error -> "Could not read value: $error" }, + ) +``` + +## Error Handling + +Prefer domain-specific failure types over plain strings or exceptions: + +```kotlin +sealed interface FileReadError { + data object NotFound : FileReadError + data object PermissionDenied : FileReadError + data class Unknown(val message: String) : FileReadError +} +``` + +This keeps callers exhaustive and makes expected failure states visible at the API boundary. diff --git a/components/core/outcome/api/jvm/outcome.api b/components/core/outcome/api/jvm/outcome.api new file mode 100644 index 0000000..4481233 --- /dev/null +++ b/components/core/outcome/api/jvm/outcome.api @@ -0,0 +1,55 @@ +public abstract interface class net/thunderbird/components/core/outcome/Outcome { + public static final field Companion Lnet/thunderbird/components/core/outcome/Outcome$Companion; + public fun isFailure ()Z + public fun isSuccess ()Z +} + +public final class net/thunderbird/components/core/outcome/Outcome$Companion { + public final fun failure (Ljava/lang/Object;)Lnet/thunderbird/components/core/outcome/Outcome; + public final fun success (Ljava/lang/Object;)Lnet/thunderbird/components/core/outcome/Outcome; +} + +public final class net/thunderbird/components/core/outcome/Outcome$DefaultImpls { + public static fun isFailure (Lnet/thunderbird/components/core/outcome/Outcome;)Z + public static fun isSuccess (Lnet/thunderbird/components/core/outcome/Outcome;)Z +} + +public final class net/thunderbird/components/core/outcome/Outcome$Failure : net/thunderbird/components/core/outcome/Outcome { + public fun (Ljava/lang/Object;Ljava/lang/Object;)V + public synthetic fun (Ljava/lang/Object;Ljava/lang/Object;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Object; + public final fun component2 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;Ljava/lang/Object;)Lnet/thunderbird/components/core/outcome/Outcome$Failure; + public static synthetic fun copy$default (Lnet/thunderbird/components/core/outcome/Outcome$Failure;Ljava/lang/Object;Ljava/lang/Object;ILjava/lang/Object;)Lnet/thunderbird/components/core/outcome/Outcome$Failure; + public fun equals (Ljava/lang/Object;)Z + public final fun getCause ()Ljava/lang/Object; + public final fun getError ()Ljava/lang/Object; + public fun hashCode ()I + public fun isFailure ()Z + public fun isSuccess ()Z + public fun toString ()Ljava/lang/String; +} + +public final class net/thunderbird/components/core/outcome/Outcome$Success : net/thunderbird/components/core/outcome/Outcome { + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lnet/thunderbird/components/core/outcome/Outcome$Success; + public static synthetic fun copy$default (Lnet/thunderbird/components/core/outcome/Outcome$Success;Ljava/lang/Object;ILjava/lang/Object;)Lnet/thunderbird/components/core/outcome/Outcome$Success; + public fun equals (Ljava/lang/Object;)Z + public final fun getData ()Ljava/lang/Object; + public fun hashCode ()I + public fun isFailure ()Z + public fun isSuccess ()Z + public fun toString ()Ljava/lang/String; +} + +public final class net/thunderbird/components/core/outcome/OutcomeKt { + public static final fun flatMapSuccess (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function1;)Lnet/thunderbird/components/core/outcome/Outcome; + public static final fun fold (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public static final fun handle (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public static final fun handleAsync (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun map (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lnet/thunderbird/components/core/outcome/Outcome; + public static final fun mapFailure (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function2;)Lnet/thunderbird/components/core/outcome/Outcome; + public static final fun mapSuccess (Lnet/thunderbird/components/core/outcome/Outcome;Lkotlin/jvm/functions/Function1;)Lnet/thunderbird/components/core/outcome/Outcome; +} + diff --git a/components/core/outcome/api/outcome.klib.api b/components/core/outcome/api/outcome.klib.api new file mode 100644 index 0000000..9f6fee4 --- /dev/null +++ b/components/core/outcome/api/outcome.klib.api @@ -0,0 +1,56 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +sealed interface <#A: out kotlin/Any?, #B: out kotlin/Any?> net.thunderbird.components.core.outcome/Outcome { // net.thunderbird.components.core.outcome/Outcome|null[0] + open val isFailure // net.thunderbird.components.core.outcome/Outcome.isFailure|{}isFailure[0] + open fun (): kotlin/Boolean // net.thunderbird.components.core.outcome/Outcome.isFailure.|(){}[0] + open val isSuccess // net.thunderbird.components.core.outcome/Outcome.isSuccess|{}isSuccess[0] + open fun (): kotlin/Boolean // net.thunderbird.components.core.outcome/Outcome.isSuccess.|(){}[0] + + final class <#A1: out kotlin/Any?> Failure : net.thunderbird.components.core.outcome/Outcome { // net.thunderbird.components.core.outcome/Outcome.Failure|null[0] + constructor (#A1, kotlin/Any? = ...) // net.thunderbird.components.core.outcome/Outcome.Failure.|(1:0;kotlin.Any?){}[0] + + final val cause // net.thunderbird.components.core.outcome/Outcome.Failure.cause|{}cause[0] + final fun (): kotlin/Any? // net.thunderbird.components.core.outcome/Outcome.Failure.cause.|(){}[0] + final val error // net.thunderbird.components.core.outcome/Outcome.Failure.error|{}error[0] + final fun (): #A1 // net.thunderbird.components.core.outcome/Outcome.Failure.error.|(){}[0] + + final fun component1(): #A1 // net.thunderbird.components.core.outcome/Outcome.Failure.component1|component1(){}[0] + final fun component2(): kotlin/Any? // net.thunderbird.components.core.outcome/Outcome.Failure.component2|component2(){}[0] + final fun copy(#A1 = ..., kotlin/Any? = ...): net.thunderbird.components.core.outcome/Outcome.Failure<#A1> // net.thunderbird.components.core.outcome/Outcome.Failure.copy|copy(1:0;kotlin.Any?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // net.thunderbird.components.core.outcome/Outcome.Failure.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // net.thunderbird.components.core.outcome/Outcome.Failure.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // net.thunderbird.components.core.outcome/Outcome.Failure.toString|toString(){}[0] + } + + final class <#A1: out kotlin/Any?> Success : net.thunderbird.components.core.outcome/Outcome<#A1, kotlin/Nothing> { // net.thunderbird.components.core.outcome/Outcome.Success|null[0] + constructor (#A1) // net.thunderbird.components.core.outcome/Outcome.Success.|(1:0){}[0] + + final val data // net.thunderbird.components.core.outcome/Outcome.Success.data|{}data[0] + final fun (): #A1 // net.thunderbird.components.core.outcome/Outcome.Success.data.|(){}[0] + + final fun component1(): #A1 // net.thunderbird.components.core.outcome/Outcome.Success.component1|component1(){}[0] + final fun copy(#A1 = ...): net.thunderbird.components.core.outcome/Outcome.Success<#A1> // net.thunderbird.components.core.outcome/Outcome.Success.copy|copy(1:0){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // net.thunderbird.components.core.outcome/Outcome.Success.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // net.thunderbird.components.core.outcome/Outcome.Success.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // net.thunderbird.components.core.outcome/Outcome.Success.toString|toString(){}[0] + } + + final object Companion { // net.thunderbird.components.core.outcome/Outcome.Companion|null[0] + final fun <#A2: kotlin/Any?> failure(#A2): net.thunderbird.components.core.outcome/Outcome // net.thunderbird.components.core.outcome/Outcome.Companion.failure|failure(0:0){0§}[0] + final fun <#A2: kotlin/Any?> success(#A2): net.thunderbird.components.core.outcome/Outcome<#A2, kotlin/Nothing> // net.thunderbird.components.core.outcome/Outcome.Companion.success|success(0:0){0§}[0] + } +} + +final fun <#A: kotlin/Any?, #B: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #B>).net.thunderbird.components.core.outcome/handle(kotlin/Function1<#A, kotlin/Unit>, kotlin/Function1<#B, kotlin/Unit>) // net.thunderbird.components.core.outcome/handle|handle@net.thunderbird.components.core.outcome.Outcome<0:0,0:1>(kotlin.Function1<0:0,kotlin.Unit>;kotlin.Function1<0:1,kotlin.Unit>){0§;1§}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?, #C: kotlin/Any?, #D: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #B>).net.thunderbird.components.core.outcome/map(kotlin/Function1<#A, #C>, kotlin/Function2<#B, kotlin/Any?, #D>): net.thunderbird.components.core.outcome/Outcome<#C, #D> // net.thunderbird.components.core.outcome/map|map@net.thunderbird.components.core.outcome.Outcome<0:0,0:1>(kotlin.Function1<0:0,0:2>;kotlin.Function2<0:1,kotlin.Any?,0:3>){0§;1§;2§;3§}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?, #C: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #B>).net.thunderbird.components.core.outcome/flatMapSuccess(kotlin/Function1<#A, net.thunderbird.components.core.outcome/Outcome<#C, #B>>): net.thunderbird.components.core.outcome/Outcome<#C, #B> // net.thunderbird.components.core.outcome/flatMapSuccess|flatMapSuccess@net.thunderbird.components.core.outcome.Outcome<0:0,0:1>(kotlin.Function1<0:0,net.thunderbird.components.core.outcome.Outcome<0:2,0:1>>){0§;1§;2§}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?, #C: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #B>).net.thunderbird.components.core.outcome/fold(kotlin/Function1<#A, #C>, kotlin/Function1<#B, #C>): #C // net.thunderbird.components.core.outcome/fold|fold@net.thunderbird.components.core.outcome.Outcome<0:0,0:1>(kotlin.Function1<0:0,0:2>;kotlin.Function1<0:1,0:2>){0§;1§;2§}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?, #C: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #B>).net.thunderbird.components.core.outcome/mapFailure(kotlin/Function2<#B, kotlin/Any?, #C>): net.thunderbird.components.core.outcome/Outcome<#A, #C> // net.thunderbird.components.core.outcome/mapFailure|mapFailure@net.thunderbird.components.core.outcome.Outcome<0:0,0:1>(kotlin.Function2<0:1,kotlin.Any?,0:2>){0§;1§;2§}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?, #C: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #C>).net.thunderbird.components.core.outcome/mapSuccess(kotlin/Function1<#A, #B>): net.thunderbird.components.core.outcome/Outcome<#B, #C> // net.thunderbird.components.core.outcome/mapSuccess|mapSuccess@net.thunderbird.components.core.outcome.Outcome<0:0,0:2>(kotlin.Function1<0:0,0:1>){0§;1§;2§}[0] +final suspend fun <#A: kotlin/Any?, #B: kotlin/Any?> (net.thunderbird.components.core.outcome/Outcome<#A, #B>).net.thunderbird.components.core.outcome/handleAsync(kotlin.coroutines/SuspendFunction1<#A, kotlin/Unit>, kotlin.coroutines/SuspendFunction1<#B, kotlin/Unit>) // net.thunderbird.components.core.outcome/handleAsync|handleAsync@net.thunderbird.components.core.outcome.Outcome<0:0,0:1>(kotlin.coroutines.SuspendFunction1<0:0,kotlin.Unit>;kotlin.coroutines.SuspendFunction1<0:1,kotlin.Unit>){0§;1§}[0] diff --git a/components/core/outcome/build.gradle.kts b/components/core/outcome/build.gradle.kts new file mode 100644 index 0000000..8b436eb --- /dev/null +++ b/components/core/outcome/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + alias(libs.plugins.tb.library.kmp) + alias(libs.plugins.testBalloon) +} diff --git a/components/core/outcome/src/commonMain/kotlin/net/thunderbird/components/core/outcome/Outcome.kt b/components/core/outcome/src/commonMain/kotlin/net/thunderbird/components/core/outcome/Outcome.kt new file mode 100644 index 0000000..371cbe0 --- /dev/null +++ b/components/core/outcome/src/commonMain/kotlin/net/thunderbird/components/core/outcome/Outcome.kt @@ -0,0 +1,157 @@ +package net.thunderbird.components.core.outcome + +/** + * A sealed interface representing the outcome of an operation. + * + * @param SUCCESS The type of the value when the operation succeeds. + * @param FAILURE The type of the error when the operation fails. + */ +public sealed interface Outcome { + + /** + * A successful outcome with a value of type [SUCCESS]. + * + * @param data The value of the successful outcome. + */ + public data class Success(public val data: SUCCESS) : Outcome + + /** + * A failed outcome with an error of type [FAILURE]. + * + * @param error The error of the failed outcome. + * @param cause The cause of the failed outcome. + */ + public data class Failure( + public val error: FAILURE, + public val cause: Any? = null, + ) : Outcome + + /** + * Whether the outcome is a success. + */ + public val isSuccess: Boolean + get() = this is Success + + /** + * Whether the outcome is a failure. + */ + public val isFailure: Boolean + get() = this is Failure + + public companion object { + /** + * Create a [Outcome.Success] outcome with the given value. + * + * @param data The value of the successful outcome. + */ + public fun success(data: SUCCESS): Outcome = Success(data) + + /** + * Create a [Outcome.Failure] outcome with the given error. + * + * @param error The error of the failed outcome. + */ + public fun failure(error: FAILURE): Outcome = Failure(error) + } +} + +/** + * Map the value and error of an [Outcome] to a new value. + * + * @param transformSuccess The function to transform the value of a [Outcome.Success] to a new value. + * @param transformFailure The function to transform the value of a [Outcome.Failure] to a new value. + */ +public inline fun Outcome.map( + transformSuccess: (IN_SUCCESS) -> OUT_SUCCESS, + transformFailure: (IN_FAILURE, Any?) -> OUT_FAILURE, +): Outcome { + return when (this) { + is Outcome.Success -> Outcome.Success(transformSuccess(data)) + is Outcome.Failure -> Outcome.Failure(transformFailure(error, cause)) + } +} + +/** + * Map the value of a [Outcome] to a new value. + * + * @param transformSuccess The function to transform the value of a [Outcome.Success] to a new value. + */ +public inline fun Outcome.mapSuccess( + transformSuccess: (IN_SUCCESS) -> OUT_SUCCESS, +): Outcome { + return when (this) { + is Outcome.Success -> Outcome.Success(transformSuccess(data)) + is Outcome.Failure -> this + } +} + +/** + * Flat map the value and error of an [Outcome] to a new [Outcome]. + */ +public inline fun Outcome.flatMapSuccess( + transformSuccess: (IN_SUCCESS) -> Outcome, +): Outcome { + return when (this) { + is Outcome.Success -> transformSuccess(data) + is Outcome.Failure -> this + } +} + +/** + * Map the error of a [Outcome] to a new value. + * + * @param transformFailure The function to transform the value of a [Outcome.Failure] to a new value. + */ +public inline fun Outcome.mapFailure( + transformFailure: (IN_FAILURE, Any?) -> OUT_FAILURE, +): Outcome { + return when (this) { + is Outcome.Success -> this + is Outcome.Failure -> Outcome.Failure(transformFailure(error, cause)) + } +} + +/** + * Handle the value of an [Outcome] and execute the given function. + * + * @param onSuccess The function to execute if the outcome is a [Outcome.Success]. + * @param onFailure The function to execute if the outcome is a [Outcome.Failure]. + */ +public fun Outcome.handle( + onSuccess: (SUCCESS) -> Unit, + onFailure: (FAILURE) -> Unit, +) { + when (this) { + is Outcome.Success -> onSuccess(data) + is Outcome.Failure -> onFailure(error) + } +} + +/** + * Handle the value of an [Outcome] and execute the given function. + * + * @param onSuccess The function to execute if the outcome is a [Outcome.Success]. + * @param onFailure The function to execute if the outcome is a [Outcome.Failure]. + */ +public suspend fun Outcome.handleAsync( + onSuccess: suspend (SUCCESS) -> Unit, + onFailure: suspend (FAILURE) -> Unit, +) { + when (this) { + is Outcome.Success -> onSuccess(data) + is Outcome.Failure -> onFailure(error) + } +} + +/** + * Fold the value of an [Outcome] to a new value. + */ +public inline fun Outcome.fold( + onSuccess: (SUCCESS) -> R, + onFailure: (FAILURE) -> R, +): R { + return when (this) { + is Outcome.Success -> onSuccess(data) + is Outcome.Failure -> onFailure(error) + } +} diff --git a/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt b/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt new file mode 100644 index 0000000..bd13d64 --- /dev/null +++ b/components/core/outcome/src/commonTest/kotlin/net/thunderbird/components/core/outcome/OutcomeTest.kt @@ -0,0 +1,297 @@ +package net.thunderbird.components.core.outcome + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNull +import assertk.assertions.isTrue +import de.infix.testBalloon.framework.core.testSuite + +val outcomeTest by testSuite("Outcome") { + + testSuite("factory functions") { + test("success creates a successful outcome") { + // Arrange + val outcome: Outcome = Outcome.success(42) + + // Act + val success = outcome as Outcome.Success + + // Assert + assertThat(outcome.isSuccess).isTrue() + assertThat(outcome.isFailure).isFalse() + assertThat(success.data).isEqualTo(42) + } + + test("failure creates a failed outcome") { + // Arrange + val outcome: Outcome = Outcome.failure("error") + + // Act + val failure = outcome as Outcome.Failure + + // Assert + assertThat(outcome.isFailure).isTrue() + assertThat(outcome.isSuccess).isFalse() + assertThat(failure.error).isEqualTo("error") + } + + test("failure factory creates a failure without cause") { + // Arrange + val outcome: Outcome = Outcome.failure("error") + + // Act + val failure = outcome as Outcome.Failure + + // Assert + assertThat(failure.cause).isNull() + } + + test("failure data class keeps cause") { + // Arrange + val cause = IllegalArgumentException("cause") + + // Act + val failure = Outcome.Failure("error", cause) + + // Assert + assertThat(failure.cause === cause).isTrue() + } + } + + testSuite("map") { + test("transforms success value") { + // Arrange + val outcome: Outcome = Outcome.Success(7) + + // Act + val mapped = outcome.map( + transformSuccess = { value -> value * 2 }, + transformFailure = { error, _ -> "$error!" }, + ) + + // Assert + assertThat((mapped as Outcome.Success).data).isEqualTo(14) + } + + test("transforms failure value and passes cause") { + // Arrange + val cause = IllegalStateException("cause") + val outcome: Outcome = Outcome.Failure("error", cause) + + // Act + val mapped = outcome.map( + transformSuccess = { value -> value * 2 }, + transformFailure = { error, receivedCause -> + assertThat(receivedCause).isEqualTo(cause) + "$error-transformed" + }, + ) + + // Assert + assertThat((mapped as Outcome.Failure).error).isEqualTo("error-transformed") + } + } + + testSuite("mapSuccess") { + test("transforms success value") { + // Arrange + val outcome = Outcome.Success(3) + + // Act + val mapped = outcome.mapSuccess { value -> value + 1 } + + // Assert + assertThat((mapped as Outcome.Success).data).isEqualTo(4) + } + + test("passes failure through unchanged") { + // Arrange + val cause = IllegalStateException("cause") + val outcome = Outcome.Failure("failure", cause) + + // Act + val mapped = outcome.mapSuccess { 999 } + + // Assert + assertThat(mapped === outcome).isTrue() + assertThat((mapped as Outcome.Failure).error).isEqualTo("failure") + assertThat(mapped.cause === cause).isTrue() + } + } + + testSuite("flatMapSuccess") { + test("flat maps success value") { + // Arrange + val outcome = Outcome.Success(10) + + // Act + val mapped = outcome.flatMapSuccess { value -> + if (value > 5) Outcome.Success("success") else Outcome.Failure("failure") + } + + // Assert + assertThat((mapped as Outcome.Success).data).isEqualTo("success") + } + + test("flat maps success value to failure") { + // Arrange + val cause = IllegalArgumentException("cause") + val outcome = Outcome.Success(1) + + // Act + val mapped = outcome.flatMapSuccess { + Outcome.Failure("failure", cause) + } + + // Assert + assertThat((mapped as Outcome.Failure).error).isEqualTo("failure") + assertThat(mapped.cause === cause).isTrue() + } + + test("passes failure through unchanged") { + // Arrange + val cause = IllegalStateException("cause") + val outcome: Outcome = Outcome.Failure("failure", cause) + + // Act + val mapped = outcome.flatMapSuccess { Outcome.Success("unused") } + + // Assert + assertThat(mapped === outcome).isTrue() + assertThat((mapped as Outcome.Failure).error).isEqualTo("failure") + assertThat(mapped.cause === cause).isTrue() + } + } + + testSuite("mapFailure") { + test("passes success through unchanged") { + // Arrange + val outcome = Outcome.Success("success") + + // Act + val mapped = outcome.mapFailure { error: String, _ -> "$error?" } + + // Assert + assertThat(mapped === outcome).isTrue() + assertThat((mapped as Outcome.Success).data).isEqualTo("success") + } + + test("transforms failure value and passes cause") { + // Arrange + val cause = RuntimeException("cause") + val outcome = Outcome.Failure("fail", cause) + + // Act + val mapped = outcome.mapFailure { error, receivedCause -> + assertThat(receivedCause).isEqualTo(cause) + error.length + } + + // Assert + assertThat((mapped as Outcome.Failure).error).isEqualTo(4) + } + } + + testSuite("handle") { + test("calls only success callback") { + // Arrange + var successCalledWith: Int? = null + var failureCalledWith: String? = null + val outcome = Outcome.Success(5) + + // Act + outcome.handle( + onSuccess = { successCalledWith = it }, + onFailure = { failureCalledWith = it }, + ) + + // Assert + assertThat(successCalledWith).isEqualTo(5) + assertThat(failureCalledWith).isNull() + } + + test("calls only failure callback") { + // Arrange + var successCalledWith: Int? = null + var failureCalledWith: String? = null + val outcome: Outcome = Outcome.Failure("failure") + + // Act + outcome.handle( + onSuccess = { successCalledWith = it }, + onFailure = { failureCalledWith = it }, + ) + + // Assert + assertThat(successCalledWith).isNull() + assertThat(failureCalledWith).isEqualTo("failure") + } + } + + testSuite("handleAsync") { + test("calls only suspending success callback") { + // Arrange + var successCalledWith: Int? = null + var failureCalledWith: String? = null + val outcome = Outcome.Success(1) + + // Act + outcome.handleAsync( + onSuccess = { successCalledWith = it }, + onFailure = { failureCalledWith = it }, + ) + + // Assert + assertThat(successCalledWith).isEqualTo(1) + assertThat(failureCalledWith).isNull() + } + + test("calls only suspending failure callback") { + // Arrange + var successCalledWith: Int? = null + var failureCalledWith: String? = null + val outcome: Outcome = Outcome.Failure("failure") + + // Act + outcome.handleAsync( + onSuccess = { successCalledWith = it }, + onFailure = { failureCalledWith = it }, + ) + + // Assert + assertThat(successCalledWith).isNull() + assertThat(failureCalledWith).isEqualTo("failure") + } + } + + testSuite("fold") { + test("returns success result") { + // Arrange + val outcome: Outcome = Outcome.Success(10) + + // Act + val result = outcome.fold( + onSuccess = { value -> value * 3 }, + onFailure = { -1 }, + ) + + // Assert + assertThat(result).isEqualTo(30) + } + + test("returns failure result") { + // Arrange + val outcome: Outcome = Outcome.Failure("oops") + + // Act + val result = outcome.fold( + onSuccess = { value -> value * 3 }, + onFailure = { error -> "$error!" }, + ) + + // Assert + assertThat(result).isEqualTo("oops!") + } + } +} diff --git a/components/core/outcome/src/jvmTest/kotlin/net/thunderbird/components/core/outcome/OutcomeJvmCoverageTest.kt b/components/core/outcome/src/jvmTest/kotlin/net/thunderbird/components/core/outcome/OutcomeJvmCoverageTest.kt new file mode 100644 index 0000000..c39d97e --- /dev/null +++ b/components/core/outcome/src/jvmTest/kotlin/net/thunderbird/components/core/outcome/OutcomeJvmCoverageTest.kt @@ -0,0 +1,179 @@ +package net.thunderbird.components.core.outcome + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isTrue +import de.infix.testBalloon.framework.core.testSuite + +// Kover does not attribute normal Kotlin call-site coverage to inline function bodies. +// These JVM-only tests invoke the generated static methods directly so Gradle coverage sees both branches. +// Related issue: https://github.com/Kotlin/kotlinx-kover/issues/753 +val outcomeJvmCoverageTest by testSuite("Outcome JVM coverage") { + + test("reflectively covers inline map branches") { + // Arrange + val success = Outcome.Success(7) + val cause = IllegalStateException("cause") + val failure: Outcome = Outcome.Failure("error", cause) + + // Act + val mappedSuccess = invokeMap( + outcome = success, + transformSuccess = { value: Int -> value * 2 }, + transformFailure = { error: String, _ -> "$error!" }, + ) + val mappedFailure = invokeMap( + outcome = failure, + transformSuccess = { value: Int -> value * 2 }, + transformFailure = { error: String, receivedCause -> + assertThat(receivedCause === cause).isTrue() + "$error-transformed" + }, + ) + + // Assert + assertThat((mappedSuccess as Outcome.Success).data).isEqualTo(14) + assertThat((mappedFailure as Outcome.Failure).error).isEqualTo("error-transformed") + } + + test("reflectively covers inline mapSuccess branches") { + // Arrange + val success = Outcome.Success(3) + val failure = Outcome.Failure("failure") + + // Act + val mappedSuccess = invokeMapSuccess( + outcome = success, + transformSuccess = { value: Int -> value + 1 }, + ) + val mappedFailure = invokeMapSuccess( + outcome = failure, + transformSuccess = { 999 }, + ) + + // Assert + assertThat((mappedSuccess as Outcome.Success).data).isEqualTo(4) + assertThat(mappedFailure === failure).isTrue() + } + + test("reflectively covers inline flatMapSuccess branches") { + // Arrange + val success = Outcome.Success(10) + val failure: Outcome = Outcome.Failure("failure") + + // Act + val mappedSuccess = invokeFlatMapSuccess( + outcome = success, + transformSuccess = { Outcome.Success("success") }, + ) + val mappedFailure = invokeFlatMapSuccess( + outcome = failure, + transformSuccess = { Outcome.Success("unused") }, + ) + + // Assert + assertThat((mappedSuccess as Outcome.Success).data).isEqualTo("success") + assertThat(mappedFailure === failure).isTrue() + } + + test("reflectively covers inline mapFailure branches") { + // Arrange + val success = Outcome.Success("success") + val cause = RuntimeException("cause") + val failure = Outcome.Failure("fail", cause) + + // Act + val mappedSuccess = invokeMapFailure( + outcome = success, + transformFailure = { error: String, _ -> "$error?" }, + ) + val mappedFailure = invokeMapFailure( + outcome = failure, + transformFailure = { error: String, receivedCause -> + assertThat(receivedCause === cause).isTrue() + error.length + }, + ) + + // Assert + assertThat(mappedSuccess === success).isTrue() + assertThat((mappedFailure as Outcome.Failure).error).isEqualTo(4) + } + + test("reflectively covers inline fold branches") { + // Arrange + val success = Outcome.Success(10) + val failure = Outcome.Failure("oops") + + // Act + val successResult = invokeFold( + outcome = success, + onSuccess = { value: Int -> value * 3 }, + onFailure = { -1 }, + ) + val failureResult = invokeFold( + outcome = failure, + onSuccess = { value: Int -> value * 3 }, + onFailure = { error: String -> "$error!" }, + ) + + // Assert + assertThat(successResult).isEqualTo(30) + assertThat(failureResult).isEqualTo("oops!") + } +} + +@Suppress("UNCHECKED_CAST") +private fun invokeMap( + outcome: Outcome, + transformSuccess: (IN_SUCCESS) -> OUT_SUCCESS, + transformFailure: (IN_FAILURE, Any?) -> OUT_FAILURE, +): Outcome { + return invokeOutcomeKt("map", outcome, transformSuccess, transformFailure) as Outcome +} + +@Suppress("UNCHECKED_CAST") +private fun invokeMapSuccess( + outcome: Outcome, + transformSuccess: (IN_SUCCESS) -> OUT_SUCCESS, +): Outcome { + return invokeOutcomeKt("mapSuccess", outcome, transformSuccess) as Outcome +} + +@Suppress("UNCHECKED_CAST") +private fun invokeFlatMapSuccess( + outcome: Outcome, + transformSuccess: (IN_SUCCESS) -> Outcome, +): Outcome { + return invokeOutcomeKt("flatMapSuccess", outcome, transformSuccess) as Outcome +} + +@Suppress("UNCHECKED_CAST") +private fun invokeMapFailure( + outcome: Outcome, + transformFailure: (IN_FAILURE, Any?) -> OUT_FAILURE, +): Outcome { + return invokeOutcomeKt("mapFailure", outcome, transformFailure) as Outcome +} + +@Suppress("UNCHECKED_CAST") +private fun invokeFold( + outcome: Outcome, + onSuccess: (SUCCESS) -> R, + onFailure: (FAILURE) -> R, +): R { + return invokeOutcomeKt("fold", outcome, onSuccess, onFailure) as R +} + +private fun invokeOutcomeKt( + methodName: String, + outcome: Outcome<*, *>, + vararg functions: Any, +): Any { + val functionTypes = functions.map { it.javaClass.interfaces.single() }.toTypedArray() + val method = outcomeKtClass.getDeclaredMethod(methodName, Outcome::class.java, *functionTypes) + return method.invoke(null, outcome, *functions) + ?: error("Reflective invocation of $methodName returned null.") +} + +private val outcomeKtClass: Class<*> = Class.forName("net.thunderbird.components.core.outcome.OutcomeKt") diff --git a/components/core/version.properties b/components/core/version.properties new file mode 100644 index 0000000..0e288f2 --- /dev/null +++ b/components/core/version.properties @@ -0,0 +1,3 @@ +MAJOR=0 +MINOR=1 +PATCH=0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 086a7c5..e9318f9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ kover = "0.9.8" mavenPublish = "0.37.0" robolectric = "4.16.1" spotlessPlugin = "8.7.0" +testBalloon = "1.0.1-K2.4.0" turbine = "1.2.1" [plugins] @@ -51,6 +52,7 @@ kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } ksp = { id = "com.google.devtools.ksp", version.ref = "kotlinKsp" } maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } spotless = { id = "com.diffplug.spotless", version.ref = "spotlessPlugin" } +testBalloon = { id = "de.infix.testBalloon", version.ref = "testBalloon" } # Build plugins tb-app-kmp-compose = { id = "net.thunderbird.gradle.plugin.app.kmp.compose" } @@ -91,6 +93,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDateTime" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +testBalloon-framework-core = { module = "de.infix.testBalloon:testBalloon-framework-core", version.ref = "testBalloon" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } [bundles] @@ -113,6 +116,7 @@ shared-kmp-common-test = [ "kotlin-test", "kotlinx-coroutines-test", "turbine", + "testBalloon-framework-core", ] shared-kmp-android-test = [ ] diff --git a/settings.gradle.kts b/settings.gradle.kts index cbdf384..89e8608 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,10 +18,6 @@ pluginManagement { includeBuild("build-plugin") } -include(":components:bom") - -include(":quality:konsist") - dependencyResolutionManagement { repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS repositories { @@ -40,6 +36,15 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } +include(":components:bom") + +// Core +include( + ":components:core:outcome", +) + +include(":quality:konsist") + check(JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_17)) { """ Java 17+ is required to build Thunderbird for Android.