diff --git a/core/datasource/build.gradle.kts b/core/datasource/build.gradle.kts index 8df1d5a9f9..7e80091db4 100644 --- a/core/datasource/build.gradle.kts +++ b/core/datasource/build.gradle.kts @@ -1,4 +1,6 @@ +import com.tangem.plugin.configuration.configurations.EnvironmentConfigGenerator import com.tangem.plugin.configuration.configurations.extension.kaptForObfuscatingVariants +import com.tangem.plugin.configuration.model.BuildType plugins { alias(deps.plugins.android.library) @@ -10,14 +12,53 @@ plugins { id("configuration") } +abstract class GenerateEnvironmentConfigTask : DefaultTask() { + + @get:InputFile + abstract val configFile: RegularFileProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val input = configFile.get().asFile + require(input.exists()) { "Config file not found: ${input.absolutePath}" } + logger.lifecycle("Generating EnvironmentConfig from ${input.name}") + EnvironmentConfigGenerator.generate(input, outputDir.get().asFile) + } +} + android { namespace = "com.tangem.datasource" + sourceSets["main"].java.srcDir(layout.buildDirectory.dir("generated/source/environment-config")) + room { schemaDirectory("$projectDir/schemas") } } +androidComponents { + onVariants { variant -> + val buildType = BuildType.values().firstOrNull { it.id == variant.buildType } ?: BuildType.Debug + val configFile = rootProject.file( + "app/src/main/assets/tangem-app-config/config_${buildType.environment}.json", + ) + + tasks.register( + "generateEnvironmentConfig${variant.name.replaceFirstChar { it.uppercaseChar() }}", + ) { + this.configFile.set(configFile) + outputDir.set(layout.buildDirectory.dir("generated/source/environment-config")) + } + } +} + +tasks.named("preBuild") { + dependsOn(tasks.matching { it.name.startsWith("generateEnvironmentConfig") }) +} + tasks.withType().configureEach { useJUnitPlatform() } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt new file mode 100644 index 0000000000..56df165e72 --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/converter/GeneratedEnvironmentConfigConverter.kt @@ -0,0 +1,181 @@ +package com.tangem.datasource.local.config.environment.converter + +import com.tangem.blockchain.common.* +import com.tangem.datasource.local.config.environment.EnvironmentConfig +import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig +import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig.AppsFlyer +import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig.DevExpress +import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig.Express +import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig.GetBlockAccessTokens +import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig.P2pApiKey +import com.tangem.datasource.local.config.environment.generated.GeneratedEnvironmentConfig.TonCenterApiKey +import com.tangem.datasource.local.config.environment.models.ExpressModel +import com.tangem.datasource.local.config.environment.models.P2PKeys + +/** + * Converts [GeneratedEnvironmentConfig] to [EnvironmentConfig] + * + * This converter maps the auto-generated config (from JSON) to the domain model. + * The generated config has nested objects that mirror the JSON structure. + */ +internal object GeneratedEnvironmentConfigConverter { + + fun convert(): EnvironmentConfig { + return EnvironmentConfig( + moonPayApiKey = GeneratedEnvironmentConfig.moonPayApiKey, + moonPayApiSecretKey = GeneratedEnvironmentConfig.moonPayApiSecretKey, + mercuryoWidgetId = GeneratedEnvironmentConfig.mercuryoWidgetId, + mercuryoSecret = GeneratedEnvironmentConfig.mercuryoSecret, + blockchainSdkConfig = createBlockchainSdkConfig(), + amplitudeApiKey = GeneratedEnvironmentConfig.amplitudeApiKey, + appsFlyerApiKey = AppsFlyer.appsFlyerDevKey, + appsAppId = AppsFlyer.appsFlyerAppID, + walletConnectProjectId = GeneratedEnvironmentConfig.walletConnectProjectId, + express = createExpressModel( + apiKey = Express.apiKey, + signVerifierPublicKey = Express.signVerifierPublicKey, + ), + devExpress = createExpressModel( + apiKey = DevExpress.apiKey, + signVerifierPublicKey = DevExpress.signVerifierPublicKey, + ), + stakeKitApiKey = GeneratedEnvironmentConfig.stakeKitApiKey, + p2pApiKey = createP2PKeys(), + blockAidApiKey = GeneratedEnvironmentConfig.blockaidApiKey, + tangemApiKey = GeneratedEnvironmentConfig.tangemApiKey, + tangemApiKeyDev = GeneratedEnvironmentConfig.tangemApiKeyDev, + tangemApiKeyStage = GeneratedEnvironmentConfig.tangemApiKeyStage, + yieldModuleApiKey = GeneratedEnvironmentConfig.yieldModuleApiKey, + yieldModuleApiKeyDev = GeneratedEnvironmentConfig.yieldModuleApiKeyDev, + bffStaticToken = GeneratedEnvironmentConfig.bffStaticToken, + bffStaticTokenDev = GeneratedEnvironmentConfig.bffStaticTokenDev, + gaslessTxApiKeyDev = GeneratedEnvironmentConfig.gaslessTxApiKeyDev, + gaslessTxApiKey = GeneratedEnvironmentConfig.gaslessTxApiKey, + ) + } + + private fun createExpressModel(apiKey: String?, signVerifierPublicKey: String?): ExpressModel? { + return if (!apiKey.isNullOrEmpty() && !signVerifierPublicKey.isNullOrEmpty()) { + ExpressModel(apiKey = apiKey, signVerifierPublicKey = signVerifierPublicKey) + } else { + null + } + } + + private fun createP2PKeys(): P2PKeys? { + val mainnet = P2pApiKey.mainnet + val hoodi = P2pApiKey.hoodi + return if (mainnet.isNotEmpty() && hoodi.isNotEmpty()) { + P2PKeys(mainnet = mainnet, hoodi = hoodi) + } else { + null + } + } + + private fun createBlockchainSdkConfig(): BlockchainSdkConfig { + return BlockchainSdkConfig( + blockchairCredentials = BlockchairCredentials( + apiKey = GeneratedEnvironmentConfig.blockchairApiKeys, + authToken = GeneratedEnvironmentConfig.blockchairAuthorizationToken, + ), + blockcypherTokens = GeneratedEnvironmentConfig.blockcypherTokens.toSet(), + quickNodeSolanaCredentials = QuickNodeCredentials( + apiKey = GeneratedEnvironmentConfig.quiknodeApiKey, + subdomain = GeneratedEnvironmentConfig.quiknodeSubdomain, + ), + quickNodeBscCredentials = QuickNodeCredentials( + apiKey = GeneratedEnvironmentConfig.bscQuiknodeApiKey, + subdomain = GeneratedEnvironmentConfig.bscQuiknodeSubdomain, + ), + quickNodePlasmaCredentials = QuickNodeCredentials( + apiKey = GeneratedEnvironmentConfig.quiknodePlasmaApiKey, + subdomain = GeneratedEnvironmentConfig.quiknodePlasmaSubdomain, + ), + quickNodeMonadCredentials = QuickNodeCredentials( + apiKey = GeneratedEnvironmentConfig.quiknodeMonadApiKey, + subdomain = GeneratedEnvironmentConfig.quiknodeMonadSubdomain, + ), + infuraProjectId = GeneratedEnvironmentConfig.infuraProjectId, + tronGridApiKey = GeneratedEnvironmentConfig.tronGridApiKey, + nowNodeCredentials = NowNodeCredentials(apiKey = GeneratedEnvironmentConfig.nowNodesApiKey), + getBlockCredentials = createGetBlockCredentials(), + kaspaSecondaryApiUrl = GeneratedEnvironmentConfig.kaspaSecondaryApiUrl, + tonCenterCredentials = TonCenterCredentials( + mainnetApiKey = TonCenterApiKey.mainnet, + testnetApiKey = TonCenterApiKey.testnet, + ), + chiaFireAcademyApiKey = GeneratedEnvironmentConfig.chiaFireAcademyApiKey, + chiaTangemApiKey = GeneratedEnvironmentConfig.chiaTangemApiKey, + hederaArkhiaApiKey = GeneratedEnvironmentConfig.hederaArkhiaKey, + polygonScanApiKey = GeneratedEnvironmentConfig.polygonScanApiKey, + bittensorDwellirApiKey = GeneratedEnvironmentConfig.bittensorDwellirKey, + bittensorOnfinalityApiKey = GeneratedEnvironmentConfig.bittensorOnfinalityKey, + dwellirApiKey = GeneratedEnvironmentConfig.dwellirApiKey, + koinosProApiKey = GeneratedEnvironmentConfig.koinosProApiKey, + alephiumApiKey = GeneratedEnvironmentConfig.alephiumTangemApiKey, + moralisApiKey = GeneratedEnvironmentConfig.moralisApiKey, + etherscanApiKey = GeneratedEnvironmentConfig.etherscanApiKey, + blinkApiKey = GeneratedEnvironmentConfig.blinkApiKey, + tatumApiKey = GeneratedEnvironmentConfig.tatumApiKey, + ) + } + + private fun createGetBlockCredentials(): GetBlockCredentials { + return GetBlockCredentials( + xrp = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Xrp.jsonRpc), + cardano = GetBlockAccessToken(rosetta = GetBlockAccessTokens.Cardano.rosetta), + avalanche = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Avalanche.jsonRpc), + eth = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Ethereum.jsonRpc), + etc = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.EthereumClassic.jsonRpc), + fantom = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Fantom.jsonRpc), + rsk = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Rsk.jsonRpc), + bsc = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Bsc.jsonRpc), + polygon = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Polygon.jsonRpc), + gnosis = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Xdai.jsonRpc), + cronos = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Cronos.jsonRpc), + solana = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Solana.jsonRpc), + ton = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Ton.jsonRpc), + tron = GetBlockAccessToken(rest = GetBlockAccessTokens.Tron.rest), + cosmos = GetBlockAccessToken(rest = GetBlockAccessTokens.CosmosHub.rest), + near = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Near.jsonRpc), + aptos = GetBlockAccessToken(rest = GetBlockAccessTokens.Aptos.rest), + dogecoin = GetBlockAccessToken( + jsonRpc = GetBlockAccessTokens.Dogecoin.jsonRpc, + blockBookRest = GetBlockAccessTokens.Dogecoin.blockBookRest, + ), + litecoin = GetBlockAccessToken( + jsonRpc = GetBlockAccessTokens.Litecoin.jsonRpc, + blockBookRest = GetBlockAccessTokens.Litecoin.blockBookRest, + ), + dash = GetBlockAccessToken( + jsonRpc = GetBlockAccessTokens.Dash.jsonRpc, + blockBookRest = GetBlockAccessTokens.Dash.blockBookRest, + ), + bitcoin = GetBlockAccessToken( + jsonRpc = GetBlockAccessTokens.Bitcoin.jsonRpc, + blockBookRest = GetBlockAccessTokens.Bitcoin.blockBookRest, + ), + algorand = GetBlockAccessToken(rest = GetBlockAccessTokens.Algorand.rest), + zkSyncEra = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Zksync.jsonRpc), + polygonZkEvm = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.PolygonZkevm.jsonRpc), + base = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Base.jsonRpc), + blast = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Blast.jsonRpc), + filecoin = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Filecoin.jsonRpc), + arbitrum = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.ArbitrumOne.jsonRpc), + bitcoinCash = GetBlockAccessToken( + jsonRpc = GetBlockAccessTokens.BitcoinCash.jsonRpc, + blockBookRest = GetBlockAccessTokens.BitcoinCash.blockBookRest, + ), + kusama = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Kusama.jsonRpc), + moonbeam = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Moonbeam.jsonRpc), + optimism = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Optimism.jsonRpc), + polkadot = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Polkadot.jsonRpc), + shibarium = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Shibarium.jsonRpc), + sui = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Sui.jsonRpc), + telos = GetBlockAccessToken(jsonRpc = GetBlockAccessTokens.Telos.jsonRpc), + tezos = GetBlockAccessToken(rest = GetBlockAccessTokens.Tezos.rest), + monad = GetBlockAccessToken(rest = GetBlockAccessTokens.Monad.rest), + stellar = GetBlockAccessToken(rest = GetBlockAccessTokens.Stellar.rest), + ) + } +} \ No newline at end of file diff --git a/gradle/dependencies.toml b/gradle/dependencies.toml index 4c87fdffe7..68f64e61cf 100644 --- a/gradle/dependencies.toml +++ b/gradle/dependencies.toml @@ -102,6 +102,7 @@ markdownComposeView = "0.5.4" usedesk = "4.4.0" sumsub = "1.38.0" haze = "1.7.1" +kotlinpoet = "1.18.1" # endregion Other libraries # region Tools @@ -149,6 +150,7 @@ agconnect = { id = "com.huawei.agconnect", version.ref = "agconnect" } gradle-android = { module = "com.android.tools.build:gradle", version.ref = "androidGradlePlugin" } gradle-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } gradle-detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" } +gradle-kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } # end region Classpath # region AndroidX diff --git a/plugins/configuration/build.gradle.kts b/plugins/configuration/build.gradle.kts index a8bc7da8a0..4d9ccbce70 100644 --- a/plugins/configuration/build.gradle.kts +++ b/plugins/configuration/build.gradle.kts @@ -17,6 +17,15 @@ dependencies { implementation(deps.gradle.kotlin) implementation(deps.gradle.android) implementation(deps.gradle.detekt) + implementation(deps.gradle.kotlinpoet) + implementation(deps.kotlin.serialization) + + testImplementation(deps.test.junit5) + testImplementation(deps.test.truth) +} + +tasks.withType { + useJUnitPlatform() } gradlePlugin { diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/EnvironmentConfigGenerator.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/EnvironmentConfigGenerator.kt new file mode 100644 index 0000000000..89206fda87 --- /dev/null +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/configurations/EnvironmentConfigGenerator.kt @@ -0,0 +1,165 @@ +package com.tangem.plugin.configuration.configurations + +import com.squareup.kotlinpoet.* +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import kotlinx.serialization.json.* +import java.io.File + +/** + * Generator for environment configuration Kotlin object from JSON file. + * Automatically parses JSON structure and generates corresponding Kotlin code. + * +[REDACTED_AUTHOR] + */ +object EnvironmentConfigGenerator { + + private const val PACKAGE_NAME = "com.tangem.datasource.local.config.environment.generated" + private const val CLASS_NAME = "GeneratedEnvironmentConfig" + + /** + * Generates GeneratedEnvironmentConfig object from JSON file. + * + * @param inputFile JSON configuration file + * @param outputDir Output directory for generated Kotlin file + */ + fun generate(inputFile: File, outputDir: File) { + val jsonText = inputFile.readText() + val json = Json.parseToJsonElement(jsonText).jsonObject + + val objectBuilder = TypeSpec.objectBuilder(CLASS_NAME) + .addKdoc("Generated from ${inputFile.name}\nAuto-generated - do not edit manually.") + + // Iterate over all JSON keys and generate properties + json.entries.forEach { (key, value) -> + addPropertyFromJsonValue(objectBuilder, key, value) + } + + val fileSpec = FileSpec.builder(PACKAGE_NAME, CLASS_NAME) + .indent(" ") // Use 4 spaces for indentation + .addType(objectBuilder.build()) + .build() + + outputDir.mkdirs() + fileSpec.writeTo(outputDir) + + // Post-process generated file + val generatedFile = File(outputDir, PACKAGE_NAME.replace('.', '/') + "/$CLASS_NAME.kt") + if (generatedFile.exists()) { + val content = generatedFile.readText() + val fixedContent = content + // Add suppress annotation at file level + .replaceFirst( + "package $PACKAGE_NAME", + "@file:Suppress(\n" + + " \"MaximumLineLength\",\n" + + " \"MaxLineLength\",\n" + + " \"Indentation\",\n" + + ")\n\npackage $PACKAGE_NAME" + ) + // Remove redundant public modifiers + .replace("public object ", "object ") + .replace("public val ", "val ") + .replace("public const val ", "const val ") + generatedFile.writeText(fixedContent) + } + } + + /** + * Adds a property to the TypeSpec based on the JSON value type + */ + private fun addPropertyFromJsonValue(builder: TypeSpec.Builder, name: String, value: JsonElement) { + when (value) { + is JsonPrimitive -> { + when { + value.isString -> { + val stringValue = value.content + val isNullable = stringValue.isEmpty() + val propertySpec = PropertySpec.builder(name, STRING.copy(nullable = isNullable)) + .initializer(if (isNullable) "null" else "%S", stringValue) + + // Add const modifier for non-nullable strings + if (!isNullable) { + propertySpec.addModifiers(KModifier.CONST) + } + + builder.addProperty(propertySpec.build()) + } + value.booleanOrNull != null -> { + builder.addProperty( + PropertySpec.builder(name, BOOLEAN) + .addModifiers(KModifier.CONST) + .initializer("%L", value.boolean) + .build() + ) + } + value.longOrNull != null -> { + builder.addProperty( + PropertySpec.builder(name, LONG) + .addModifiers(KModifier.CONST) + .initializer("%L", value.long) + .build() + ) + } + value.doubleOrNull != null -> { + builder.addProperty( + PropertySpec.builder(name, DOUBLE) + .addModifiers(KModifier.CONST) + .initializer("%L", value.double) + .build() + ) + } + else -> { + // Null value + builder.addProperty( + PropertySpec.builder(name, STRING.copy(nullable = true)) + .initializer("null") + .build() + ) + } + } + } + is JsonArray -> { + val listType = LIST.parameterizedBy(STRING) + val values = value.map { it.jsonPrimitive.content } + builder.addProperty( + PropertySpec.builder(name, listType) + .initializer( + CodeBlock.builder() + .add("listOf(\n") + .apply { + values.forEach { v -> + add(" %S,\n", v) + } + } + .add(")") + .build() + ) + .build() + ) + } + is JsonObject -> { + // Generate nested object with proper naming (convert dashes to camelCase) + val nestedClassName = name.toPascalCase() + val nestedObjectBuilder = TypeSpec.objectBuilder(nestedClassName) + + value.entries.forEach { (nestedKey, nestedValue) -> + addPropertyFromJsonValue(nestedObjectBuilder, nestedKey, nestedValue) + } + + builder.addType(nestedObjectBuilder.build()) + } + } + } + + /** + * Converts a string to PascalCase, handling dashes and underscores. + * Examples: "cosmos-hub" -> "CosmosHub", "polygon-zkevm" -> "PolygonZkevm" + */ + private fun String.toPascalCase(): String { + return this.split("-", "_") + .filter { it.isNotEmpty() } + .joinToString("") { part -> + part.replaceFirstChar { it.uppercase() } + } + } +} diff --git a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt index 0ab5e8f747..c15761c0d9 100644 --- a/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt +++ b/plugins/configuration/src/main/kotlin/com/tangem/plugin/configuration/model/BuildType.kt @@ -1,11 +1,11 @@ package com.tangem.plugin.configuration.model -internal enum class BuildType( +enum class BuildType( val id: String, - val appIdSuffix: String? = null, - val versionSuffix: String? = null, - val obfuscating: Boolean = false, - val configFields: List, + internal val appIdSuffix: String? = null, + internal val versionSuffix: String? = null, + internal val obfuscating: Boolean = false, + internal val configFields: List, ) { /** @@ -117,4 +117,19 @@ internal enum class BuildType( BuildConfigField.ABTestsEnabled(isEnabled = false), ), ), + ; + + /** Returns the environment value (dev/prod) for this build type */ + val environment: String + get() { + val environmentField = configFields + .filterIsInstance() + .firstOrNull() + + requireNotNull(environmentField) { + "BuildType '$id' must have a BuildConfigField.Environment in configFields" + } + + return environmentField.value.removeSurrounding("\"") + } } \ No newline at end of file diff --git a/plugins/configuration/src/test/kotlin/com/tangem/plugin/configuration/configurations/EnvironmentConfigGeneratorTest.kt b/plugins/configuration/src/test/kotlin/com/tangem/plugin/configuration/configurations/EnvironmentConfigGeneratorTest.kt new file mode 100644 index 0000000000..bfcccb71a8 --- /dev/null +++ b/plugins/configuration/src/test/kotlin/com/tangem/plugin/configuration/configurations/EnvironmentConfigGeneratorTest.kt @@ -0,0 +1,473 @@ +package com.tangem.plugin.configuration.configurations + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** + * Tests for [EnvironmentConfigGenerator] covering JSON parsing edge cases. + */ +class EnvironmentConfigGeneratorTest { + + @TempDir + lateinit var tempDir: File + + private lateinit var outputDir: File + + @BeforeEach + fun setup() { + outputDir = File(tempDir, "output") + } + + @Test + fun `generate handles string values correctly`() { + // Arrange + val json = """ + { + "apiKey": "test-api-key", + "baseUrl": "https://example.com" + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("""const val apiKey: String = "test-api-key"""") + assertThat(generatedCode).contains("""const val baseUrl: String = "https://example.com"""") + } + + @Test + fun `generate handles empty string as nullable`() { + // Arrange + val json = """ + { + "emptyValue": "" + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("val emptyValue: String? = null") + } + + @Test + fun `generate handles null values`() { + // Arrange + val json = """ + { + "nullValue": null + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("val nullValue: String? = null") + } + + @Test + fun `generate handles boolean values`() { + // Arrange + val json = """ + { + "isEnabled": true, + "isDisabled": false + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("const val isEnabled: Boolean = true") + assertThat(generatedCode).contains("const val isDisabled: Boolean = false") + } + + @Test + fun `generate handles integer values as Long`() { + // Arrange + val json = """ + { + "count": 42, + "negativeNumber": -100, + "largeNumber": 9223372036854775807 + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("const val count: Long = 42") + assertThat(generatedCode).contains("const val negativeNumber: Long = -100") + // KotlinPoet formats large numbers with underscores + assertThat(generatedCode).contains("const val largeNumber: Long = 9_223_372_036_854_775_807") + } + + @Test + fun `generate handles double values`() { + // Arrange + val json = """ + { + "ratio": 3.14, + "negativeDouble": -2.5 + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("const val ratio: Double = 3.14") + assertThat(generatedCode).contains("const val negativeDouble: Double = -2.5") + } + + @Test + fun `generate handles string arrays`() { + // Arrange + val json = """ + { + "items": ["one", "two", "three"] + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("val items: List = listOf(") + assertThat(generatedCode).contains(""""one",""") + assertThat(generatedCode).contains(""""two",""") + assertThat(generatedCode).contains(""""three",""") + } + + @Test + fun `generate handles empty arrays`() { + // Arrange + val json = """ + { + "emptyList": [] + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("val emptyList: List = listOf(") + } + + @Test + fun `generate handles nested objects`() { + // Arrange + val json = """ + { + "database": { + "host": "localhost", + "port": 5432 + } + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("object Database {") + assertThat(generatedCode).contains("""const val host: String = "localhost"""") + // KotlinPoet formats numbers >= 1000 with underscores + assertThat(generatedCode).contains("const val port: Long = 5_432") + } + + @Test + fun `generate handles deeply nested objects`() { + // Arrange + val json = """ + { + "level1": { + "level2": { + "level3": { + "deepValue": "deep" + } + } + } + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("object Level1 {") + assertThat(generatedCode).contains("object Level2 {") + assertThat(generatedCode).contains("object Level3 {") + assertThat(generatedCode).contains("""const val deepValue: String = "deep"""") + } + + @Test + fun `generate converts dash-separated names to PascalCase`() { + // Arrange + val json = """ + { + "cosmos-hub": { + "chainId": "cosmoshub-4" + } + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("object CosmosHub {") + } + + @Test + fun `generate converts underscore-separated names to PascalCase`() { + // Arrange + val json = """ + { + "api_config": { + "timeout": 30 + } + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("object ApiConfig {") + } + + @Test + fun `generate handles consecutive dashes in names`() { + // Arrange + val json = """ + { + "cosmos--hub": { + "testValue": "test" + } + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("object CosmosHub {") + } + + @Test + fun `generate handles trailing dash in names`() { + // Arrange + val json = """ + { + "config-": { + "testValue": "test" + } + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("object Config {") + } + + @Test + fun `generate handles special characters in string values`() { + // Arrange + val json = """ + { + "query": "SELECT * FROM users WHERE name = 'John'", + "path": "C:\\Users\\test", + "newline": "line1\nline2", + "unicode": "Hello 世界" + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("const val query: String") + assertThat(generatedCode).contains("const val path: String") + assertThat(generatedCode).contains("const val newline: String") + assertThat(generatedCode).contains("const val unicode: String") + } + + @Test + fun `generate handles arrays with special characters`() { + // Arrange + val json = """ + { + "urls": [ + "https://api.example.com/v1", + "https://api.example.com/v2?key=value&other=1" + ] + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("val urls: List = listOf(") + assertThat(generatedCode).contains(""""https://api.example.com/v1",""") + } + + @Test + fun `generate adds file suppress annotations`() { + // Arrange + val json = """ + { + "key": "value" + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("@file:Suppress(") + assertThat(generatedCode).contains(""""MaximumLineLength"""") + assertThat(generatedCode).contains(""""MaxLineLength"""") + assertThat(generatedCode).contains(""""Indentation"""") + } + + @Test + fun `generate creates proper package declaration`() { + // Arrange + val json = """ + { + "key": "value" + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("package com.tangem.datasource.local.config.environment.generated") + } + + @Test + fun `generate creates object with correct name`() { + // Arrange + val json = """ + { + "key": "value" + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("object GeneratedEnvironmentConfig {") + } + + @Test + fun `generate adds kdoc with source file reference`() { + // Arrange + val json = """ + { + "key": "value" + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).contains("Generated from") + assertThat(generatedCode).contains("Auto-generated - do not edit manually") + } + + @Test + fun `generate removes public modifiers`() { + // Arrange + val json = """ + { + "key": "value" + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + assertThat(generatedCode).doesNotContain("public object") + assertThat(generatedCode).doesNotContain("public val") + assertThat(generatedCode).doesNotContain("public const val") + } + + @Test + fun `generate handles complex real-world config`() { + // Arrange + val json = """ + { + "tangemComApiKey": "api-key-123", + "moonPayApiKey": "moon-pay-key", + "moonPayApiSecretKey": "secret-key", + "mercuryoWidgetId": "", + "blockchainSdkConfig": { + "blockchairApiKey": "blockchair-key", + "blockcypherTokens": ["token1", "token2"], + "quickNodeSolanaCredentials": { + "apiKey": "solana-key", + "subdomain": "solana-node" + } + }, + "isFeatureEnabled": true, + "maxRetryCount": 3 + } + """.trimIndent() + + // Act + val generatedCode = generateAndReadOutput(json) + + // Assert + // Top-level properties + assertThat(generatedCode).contains("""const val tangemComApiKey: String = "api-key-123"""") + assertThat(generatedCode).contains("val mercuryoWidgetId: String? = null") + assertThat(generatedCode).contains("const val isFeatureEnabled: Boolean = true") + assertThat(generatedCode).contains("const val maxRetryCount: Long = 3") + + // Nested object + assertThat(generatedCode).contains("object BlockchainSdkConfig {") + assertThat(generatedCode).contains("""const val blockchairApiKey: String = "blockchair-key"""") + assertThat(generatedCode).contains("val blockcypherTokens: List") + + // Deeply nested object + assertThat(generatedCode).contains("object QuickNodeSolanaCredentials {") + } + + private fun generateAndReadOutput(jsonContent: String): String { + val inputFile = File(tempDir, "config.json").apply { + writeText(jsonContent) + } + + EnvironmentConfigGenerator.generate(inputFile, outputDir) + + val generatedFile = File( + outputDir, + "com/tangem/datasource/local/config/environment/generated/GeneratedEnvironmentConfig.kt" + ) + + assertThat(generatedFile.exists()).isTrue() + return generatedFile.readText() + } +} + + + +