8 Commits
41 changed files with 4411 additions and 835 deletions
+91
View File
@@ -0,0 +1,91 @@
# Publishes the konfig Gradle plugin to GitHub Packages and maven.bitsycore.com
# whenever a version tag (e.g. 0.6.0) is pushed. The tag is the single source
# of truth for the published version. A GitHub release is created afterwards.
name: Publish
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+'
workflow_dispatch:
inputs:
version:
description: 'Version to publish (e.g. 0.6.0)'
required: true
type: string
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: write # create the GitHub release
packages: write # push to GitHub Packages with GITHUB_TOKEN
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Resolve version
id: version
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${{ inputs.version }}"
fi
if [ -z "$VERSION" ]; then
echo "::error::No version could be resolved." && exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Publishing version: $VERSION"
- name: Run tests
run: ./gradlew check -Pkonfig.version=${{ steps.version.outputs.version }}
- name: Publish to GitHub Packages
env:
GPR_USER: ${{ github.actor }}
GPR_KEY: ${{ secrets.GITHUB_TOKEN }}
run: >
./gradlew publishAllPublicationsToGitHubPackagesRepository
-Pkonfig.version=${{ steps.version.outputs.version }}
--stacktrace
- name: Publish to maven.bitsycore.com
env:
BITSYCORE_MAVEN_USER: ${{ secrets.BITSYCORE_MAVEN_USER }}
BITSYCORE_MAVEN_TOKEN: ${{ secrets.BITSYCORE_MAVEN_TOKEN }}
run: >
./gradlew publishAllPublicationsToBitsycoreRepository
-Pkonfig.version=${{ steps.version.outputs.version }}
--stacktrace
# Create the GitHub release once both publishes succeeded (skipped if it
# already exists, e.g. created by hand beforehand).
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${{ steps.version.outputs.version }}"
if gh release view "$VERSION" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then
echo "Release $VERSION already exists — skipping."
else
gh release create "$VERSION" \
--repo "$GITHUB_REPOSITORY" \
--title "$VERSION" \
--generate-notes
fi
+5 -1
View File
@@ -3,4 +3,8 @@
.kotlin
.gradle
local.properties
.DS_Store
.DS_Store
# Local credential overrides — never commit tokens
gradle.properties.local
secrets.properties
+129 -37
View File
@@ -22,35 +22,68 @@ This file provides guidance to AI agents (Claude, Copilot, Codex, etc.) working
# Force re-run (skip UP-TO-DATE / cache)
./gradlew functionalTest --rerun-tasks
# Publish to GitHub Packages only (requires gpr.user + gpr.key in ~/.gradle/gradle.properties)
./gradlew publishAllPublicationsToGitHubPackagesRepository
# Publish to maven.bitsycore.com only (requires bitsycore.maven.user + bitsycore.maven.token)
./gradlew publishAllPublicationsToBitsycoreRepository
# CI: pushing a version tag (e.g. 0.6.0) publishes to BOTH repos and creates the GitHub release
# Publish only the plugin marker artifact (fixes resolution without re-uploading the jar)
./gradlew publishKonfigPluginMarkerMavenPublicationToGitHubPackagesRepository
```
> Functional tests are the primary test suite. The unit test file (`KonfigTypeTest.kt`) only covers `BuildType` resolution regex.
> Functional tests are the primary test suite. Unit tests (`src/test/`) cover the DSL
> model classes and `BuildType` resolution in isolation; they do not start real Gradle builds.
## Architecture
### What this plugin does
Generates a Kotlin `object BuildKonfig { ... }` at build time, placed in `build/generated/konfig/`, automatically wired into the consuming project's source sets. Fields can be constant across builds, overridden per build type (debug/release), or scoped to a named **dimension** (e.g. environment, region) each with their own variants.
Generates a Kotlin `object BuildKonfig { ... }` at build time, placed in `build/generated/konfig/`,
automatically wired into the consuming project's source sets. Fields can be constant across builds,
overridden per build type (debug/release), or scoped to a named **dimension** (e.g. environment,
region) each with their own variants.
### Key design constraints
**Configuration cache compatibility** is a hard requirement throughout. This means:
- Never capture `project` inside a `Provider.map {}` or `Provider.zip {}` lambda, and **never access `project` inside a `@TaskAction`** — both break caching. Use only declared `@Input`/`@OutputDirectory` properties inside task actions.
- Use `Class<T>` (`.javaObjectType`) instead of `KClass<T>` — Kotlin's `KClass` uses `SoftReference` internally which Gradle can't serialize.
- Use `gradlePropertiesPrefixedBy()` to read groups of properties — **note:** in Gradle 9.x this returns full property names as keys (prefix is NOT stripped), so always check both `dimProps["env"]` and `dimProps["konfig.dimension.env"]`.
- All DSL field values are wrapped in `Provider<T>` from the start — literals via `constantProvider(value)` (a hand-written `ConstantProvider<T>`), external values via `providers.gradleProperty()` / `providers.environmentVariable()` etc. **Never store `ProviderFactory` anywhere in the DSL object graph** — it is not config-cache serializable.
- `forceRegen` (`konfig.force` property) is evaluated eagerly at configuration time as a plain `Boolean` via `providers.gradleProperty("konfig.force").isPresent` — not inside a provider lambda — so the value is captured by value and is config-cache safe.
- Never capture `project` inside a `Provider.map {}` or `Provider.zip {}` lambda, and **never
access `project` inside a `@TaskAction`** — both break caching. Use only declared
`@Input`/`@OutputDirectory` properties inside task actions.
- Use `Class<T>` (`.javaObjectType`) instead of `KClass<T>` — Kotlin's `KClass` uses
`SoftReference` internally which Gradle can't serialize.
- Use `gradlePropertiesPrefixedBy()` to read groups of properties — **note:** in Gradle 9.x this
returns full property names as keys (prefix is NOT stripped), so always check both
`dimProps["env"]` and `dimProps["konfig.dimension.env"]`.
- All DSL field values are wrapped in `Provider<T>` from the start — literals via
`constantProvider(value)` (a hand-written `ConstantProvider<T>`), external values via
`providers.gradleProperty()` / `providers.environmentVariable()` etc. **Never store
`ProviderFactory` anywhere in the DSL object graph** — it is not config-cache serializable.
- `forceRegen` (`konfig.force` property) is evaluated eagerly at configuration time as a plain
`Boolean` via `providers.gradleProperty("konfig.force").isPresent` — not inside a provider
lambda — so the value is captured by value and is config-cache safe.
**Dimension data in task inputs** uses flat-map encoding (`"<dimName>|<fieldName>"` as map keys) in `MapProperty<String, String>` rather than a managed-type `ListProperty`. Values are type-encoded as `"TYPE:rawValue"` (e.g. `"String:hello"`, `"Int:42"`). This collapses 6 separate per-type maps down to one per scope and avoids Gradle's `@Nested` managed-type restrictions.
**Dimension data in task inputs** uses flat-map encoding (`"<dimName>|<fieldName>"` as map keys)
in `MapProperty<String, String>` rather than a managed-type `ListProperty`. Values are
type-encoded as `"TYPE:rawValue"` (e.g. `"String:hello"`, `"Int:42"`). This collapses 6 separate
per-type maps down to one per scope and avoids Gradle's `@Nested` managed-type restrictions.
### DSL design
- **`@KonfigDsl` / `@DslMarker`** is applied to all DSL scope classes to prevent accidental scope leakage.
- **`ConstantProvider<T>`** wraps literal values — no `ProviderFactory` anywhere in the DSL object graph.
- **`field()` at the top level** returns `FieldHandle<T>` which exposes `.debug(value)` and `.release(value)`, both returning `Unit` — chaining beyond the first call is intentionally impossible.
- **`debug {}` / `release {}` scope blocks** use `BuildTypedFieldDeclScope` as receiver — `field()` inside these returns `Unit`, since the build type is already fixed by the enclosing scope.
- **`common {}` block in `DimensionConfig`** — shared fallback fields for all variants; merged in plugin with variant fields taking precedence.
- **Plain `var` properties on `KonfigExtension`** — `objectPackage`, `objectName`, `objectVisibility` are user-facing `var` properties backed by internal `Property<T>` (`objectPackageProp`, `objectNameProp`, `objectVisibilityProp`) used for lazy task wiring.
- **`field()` at the top level** returns `FieldHandle<T>` which exposes `.debug(value)` and
`.release(value)`, both returning `Unit` — chaining beyond the first call is intentionally impossible.
- **`debug {}` / `release {}` scope blocks** use `BuildTypedFieldDeclScope` as receiver —
`field()` inside these returns `Unit`, since the build type is already fixed by the enclosing scope.
- **`common {}` block in `DimensionConfig`** — shared fallback fields for all variants; merged in
plugin with variant fields taking precedence.
- **Plain `var` properties on `KonfigExtension`** — `objectPackage`, `objectName`,
`objectVisibility` are user-facing `var` properties backed by internal `Property<T>`
(`objectPackageProp`, `objectNameProp`, `objectVisibilityProp`) used for lazy task wiring.
### Resolution priority for dimensions
@@ -60,43 +93,67 @@ Generates a Kotlin `object BuildKonfig { ... }` at build time, placed in `build/
4. `defaultTo` fallback declared in DSL
5. **Omitted silently** if none of the above — no crash, dimension object not generated
`resolveWithSource()` in `KonfigPlugin` returns a tab-separated `"<TAG>\t<variant>\t<reason>"` string for every dimension. This is stored as a task input (`dimensionResolutionLog`) so the task action can emit structured lifecycle/warning/error log messages without re-running resolution logic.
`resolveWithSource()` in `KonfigPlugin` returns a tab-separated `"<TAG>\t<variant>\t<reason>"`
string for every dimension. This is stored as a task input (`dimensionResolutionLog`) so the task
action can emit structured lifecycle/warning/error log messages without re-running resolution logic.
### Gradle properties understood by the plugin
| Property | Effect |
|---------------------------------------|----------------------------------------------------------------------------------|
| `-Pkonfig.buildtype=DEBUG\|RELEASE` | Forces build type; falls back to task-name detection then RELEASE |
| `-Pkonfig.dimension.<name>=<variant>` | Selects a dimension variant explicitly |
| `-Pkonfig.force` | Disables UP-TO-DATE checks — task always re-runs (any value or bare flag works) |
| `-Pkonfig.android.buildtypedetection=false` | Disables task-name build-type detection |
| `-Pkonfig.android.flavordetection=false` | Disables task-name dimension variant detection |
| Property | Effect |
|---------------------------------------------|---------------------------------------------------------------------------------|
| `-Pkonfig.buildtype=DEBUG\|RELEASE` | Forces build type; falls back to task-name detection then RELEASE |
| `-Pkonfig.dimension.<name>=<variant>` | Selects a dimension variant explicitly |
| `-Pkonfig.force` | Disables UP-TO-DATE checks — task always re-runs (any value or bare flag works) |
| `-Pkonfig.android.buildtypedetection=false` | Disables task-name build-type detection |
| `-Pkonfig.android.flavordetection=false` | Disables task-name dimension variant detection |
### File map
| File | Role |
|-------------------------|-----------------------------------------------------------------------------------------------------------------|
| `KonfigPlugin.kt` | Entry point — wires providers, registers task, auto-wires source sets, hooks compile tasks |
| `KonfigExtension.kt` | DSL (`konfig { }`) — top-level `field()`, `debug {}`, `release {}`, and `dimension()` functions |
| `DimensionConfig.kt` | DSL node for a dimension — holds variants, `common {}` block, `objectNameOverride`, `defaultVariant` |
| `VariantConfig.kt` | DSL node for a variant or common block — `field()` returns `FieldHandle<T>`; `debug {}`/`release {}` supported |
| `FieldHandle.kt` | Fluent handle returned by top-level `field()` — `.debug(value)` / `.release(value)` return `Unit` |
| `BuildTypedFieldDeclScope.kt` | Receiver for `debug {}`/`release {}` blocks — `field()` returns `Unit`, no chaining possible |
| `FieldConfig.kt` | Single typed field — holds default `Provider<T>?` and per-`BuildType` overrides; `resolve()` returns `Provider<T>?` |
| `GenerateKonfigTask.kt` | `@CacheableTask` — validates inputs, logs detection results, writes the `.kt` file |
| `BuildType.kt` | `enum` with regex-based task-name detection |
| `Visibility.kt` | `PUBLIC` / `INTERNAL` enum |
| `KonfigDsl.kt` | `@DslMarker` annotation applied to all DSL scope classes |
| File | Role |
|-------------------------------|----------------------------------------------------------------------------------------------------------------|
| `KonfigPlugin.kt` | Entry point — wires providers, registers task, auto-wires source sets, hooks compile tasks |
| `KonfigExtension.kt` | DSL (`konfig { }`) — top-level `field()`, `debug {}`, `release {}`, and `dimension()` functions |
| `DimensionConfig.kt` | DSL node for a dimension — holds variants, `common {}` block, `objectNameOverride`, `defaultVariant` |
| `VariantConfig.kt` | DSL node for a variant or common block — `field()` returns `FieldHandle<T>`; `debug {}`/`release {}` supported |
| `FieldConfig.kt` | Single typed field — holds default `Provider<T>?` and per-`BuildType` overrides; `resolve()` returns `Provider<T>?` |
| `GenerateKonfigTask.kt` | `@CacheableTask` — validates inputs, logs detection results, writes the `.kt` file |
| `BuildType.kt` | `enum` with regex-based task-name detection |
| `Visibility.kt` | `PUBLIC` / `INTERNAL` enum |
| `KonfigDsl.kt` | `@DslMarker` annotation applied to all DSL scope classes |
> Note: `FieldHandle` and `BuildTypedFieldDeclScope` are defined inside `VariantConfig.kt`, not separate files.
> `FieldHandle<T>` and `BuildTypedFieldDeclScope` are defined inside `VariantConfig.kt`, not in separate files.
### Plugin metadata
- **Plugin ID:** `com.bitsycore.konfig`
- **Group:** `com.bitsycore`
- **Version:** set via `konfig.version` in `gradle.properties` (currently `0.2.0`)
- **JVM target:** 17 (set via `sourceCompatibility` + `KotlinCompile.compilerOptions.jvmTarget`, no toolchain — avoids requiring a specific JDK installation)
- **AGP dependency:** `compileOnly("com.android.tools.build:gradle:8.0.0")` — never leaked to consumers
- **Artifact:** `konfig-gradle-plugin`
- **Version:** set via `konfig.version` in `gradle.properties` (currently `0.6.0`)
- **Repositories:** `https://maven.bitsycore.com/releases` (primary, no auth) and
`https://maven.pkg.github.com/bitsycore/bitsykonfig-gradle-plugin` (fallback, needs PAT)
- **JVM target:** 17 (set via `sourceCompatibility` + `KotlinCompile.compilerOptions.jvmTarget`,
no toolchain — avoids requiring a specific JDK installation)
- **AGP dependency:** `compileOnly("com.android.tools.build:gradle:8.7.3")` — never leaked to consumers
### Publishing
Two Maven publications are created automatically by the `kotlin-dsl` + `gradlePlugin {}` combo:
| Publication name | Artifact ID | Purpose |
|-------------------------------------|------------------------------------------|--------------------------------------|
| `pluginMaven` | `konfig-gradle-plugin` | Implementation jar + sources + POM |
| `konfigPluginMarkerMaven` | `com.bitsycore.konfig.gradle.plugin` | Marker POM that points to the impl |
**Never** use `publications { create<MavenPublication>("pluginMaven") { ... } }` — this replaces
the auto-wired publication and breaks the marker. Always configure existing publications via
`publications.withType<MavenPublication>().configureEach { ... }`.
Credentials are read from Gradle properties `gpr.user` / `gpr.key` (GitHub Packages) and
`bitsycore.maven.user` / `bitsycore.maven.token` (maven.bitsycore.com), falling back to
environment variables (`GPR_USER` / `GPR_KEY` / `BITSYCORE_MAVEN_USER` / `BITSYCORE_MAVEN_TOKEN`).
Store them in `~/.gradle/gradle.properties`, never in the project `gradle.properties`.
CI uses the repo secrets `BITSYCORE_MAVEN_USER` / `BITSYCORE_MAVEN_TOKEN` and the built-in
`GITHUB_TOKEN`.
### Logging levels used in GenerateKonfigTask
@@ -109,3 +166,38 @@ Generates a Kotlin `object BuildKonfig { ... }` at build time, placed in `build/
| Unknown/ambiguous variant | `warn` | Bad `-P` value or multiple task matches |
| Field/dim details | `info` | With `--info` |
| Config errors (bad `defaultTo`, invalid identifier) | `GradleException` | Fail fast |
## Test structure
### Unit tests (`src/test/`)
Fast, no Gradle processes. Cover DSL model classes and type resolution in isolation.
| File | What it covers |
|-------------------------------|------------------------------------------------------------------------|
| `BuildTypeExtendedTest.kt` | `BuildType.resolve()` — all regex edge cases, task names, enum values |
| `VisibilityTest.kt` | `Visibility` enum entries, ordinals, valueOf |
| `ConstantProviderTest.kt` | `constantProvider()` / `ConstantProvider` — all Provider API methods |
| `FieldConfigTest.kt` | `FieldConfig` construction, default resolution, build-type overrides |
| `VariantConfigTest.kt` | `VariantConfig` field declarations, duplicate guard, scope blocks |
| `DimensionConfigTest.kt` | `DimensionConfig` objectName derivation, variants, common block |
| `FieldHandleTest.kt` | `FieldHandle` debug/release delegation (literal and provider) |
| `BuildTypedFieldDeclScopeTest.kt` | Scope block field declarations, getOrCreateField reuse |
### Functional tests (`src/functionalTest/`)
Full Gradle TestKit builds — each test spins up a real Gradle project in a temp directory.
`FunctionalTestBase` provides shared helpers (`withProject`, `withFailingProject`, `generatedFile()`, `writeBuildGradle()`).
| File | What it covers |
|---------------------------------------|----------------------------------------------------------------------------------|
| `BasicGenerationFunctionalTest.kt` | Task success, package derivation, BUILD_TYPE, IS_DEBUG, MODULE_NAME, file header |
| `GlobalFieldsFunctionalTest.kt` | All field types, string escaping, debug/release overrides, scope blocks |
| `VisibilityAndNamingFunctionalTest.kt`| PUBLIC/INTERNAL visibility, custom objectName/objectPackage |
| `DimensionsFunctionalTest.kt` | Variant selection, objectNameOverride, defaultTo, silent omission, multi-dim |
| `CommonBlockFunctionalTest.kt` | Common fallback fields, variant override precedence, build-type scopes in common |
| `CachingFunctionalTest.kt` | UP-TO-DATE behaviour, re-execution on input changes, `-Pkonfig.force` |
| `LoggingAndValidationFunctionalTest.kt` | Lifecycle logs, validation errors, generation summary |
| `KonfigPropertiesFunctionalTest.kt` | `konfig.properties` file reading, priority over file, cache invalidation |
| `DuplicateDetectionFunctionalTest.kt` | Duplicate dimension/field name errors, same-name across variants allowed |
| `FieldTypesFunctionalTest.kt` | Long/Float/Double literal syntax, NaN/Infinity, Boolean inline vs const |
+188 -30
View File
@@ -5,35 +5,83 @@ A Gradle plugin that generates a `BuildKonfig` Kotlin object at build time — l
Fields can be constant, overridden per build type (debug/release), or scoped to named **dimensions** (e.g. environment, region) with their own variants.
**Plugin ID:** `com.bitsycore.konfig`
**Version:** `0.2.0`
**Group:** `com.bitsycore`
**Artifact:** `konfig-gradle-plugin`
**Version:** `0.6.0`
**JVM target:** 17
---
## Setup
### 1. Publish to local Maven (until published to a registry)
### 1. Configure plugin resolution
```bash
./gradlew publishToMavenLocal
```
### 2. Add to your project
The plugin is published to **maven.bitsycore.com** (no authentication) and to
**GitHub Packages** as a fallback (requires a GitHub PAT with `read:packages`).
`settings.gradle.kts`:
```kotlin
pluginManagement {
repositories {
mavenLocal()
maven("https://maven.bitsycore.com/releases")
gradlePluginPortal()
}
}
```
<details>
<summary>GitHub Packages fallback (authenticated)</summary>
Store credentials in `~/.gradle/gradle.properties` — never commit them:
```properties
gpr.user=YOUR_GITHUB_USERNAME
gpr.key=YOUR_GITHUB_PERSONAL_ACCESS_TOKEN
```
```kotlin
pluginManagement {
repositories {
maven {
name = "GitHubPackages"
url = uri("https://maven.pkg.github.com/bitsycore/bitsykonfig-gradle-plugin")
credentials {
username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GPR_USER")
password = providers.gradleProperty("gpr.key").orNull ?: System.getenv("GPR_KEY")
}
}
gradlePluginPortal()
}
}
```
</details>
### 2. Declare the plugin
Using a version catalog (`libs.versions.toml`):
```toml
[versions]
konfig = "0.6.0"
[plugins]
konfig = { id = "com.bitsycore.konfig", version.ref = "konfig" }
```
`build.gradle.kts`:
```kotlin
plugins {
id("com.bitsycore.konfig") version "0.2.0"
alias(libs.plugins.konfig)
}
```
Or inline:
```kotlin
plugins {
id("com.bitsycore.konfig") version "0.6.0"
}
```
@@ -75,9 +123,9 @@ In debug builds (`-Pkonfig.buildtype=DEBUG`), `ENABLE_LOGGING` becomes `inline v
```kotlin
konfig {
objectPackage = "com.example.app" // default: derived from group + name or projectName + moduleName
objectName = "BuildKonfig" // default: "BuildKonfig"
objectVisibility = Visibility.INTERNAL // default: Visibility.PUBLIC
objectPackage = "com.example.app" // default: derived from group + project name
objectName = "BuildKonfig" // default: "BuildKonfig"
objectVisibility = Visibility.INTERNAL // default: Visibility.PUBLIC
}
```
@@ -94,14 +142,14 @@ konfig {
### Build-type overrides
Three equivalent forms for overriding per build type:
Three equivalent forms:
```kotlin
konfig {
// Fluent handle (default + one or both overrides)
// Fluent handle — default + one or both overrides
field("BASE_URL", "https://prod.example.com").debug("https://dev.example.com")
// Scope blocks (build type is fixed — field() returns Unit, no chaining)
// Scope blocks — build type is fixed, field() returns Unit, no chaining
debug { field("MOCK_API", true) }
release { field("MOCK_API", false) }
}
@@ -113,8 +161,7 @@ konfig {
## Dimensions
Dimensions let you select a named variant at build time (e.g. `env=prod`, `env=dev`).
Each active dimension generates a nested object inside `BuildKonfig`.
Dimensions let you select a named variant at build time (e.g. `env=prod`, `env=dev`). Each active dimension generates a nested object inside `BuildKonfig`.
```kotlin
konfig {
@@ -144,7 +191,7 @@ public object BuildKonfig {
public object Env /*env*/ {
const val VARIANT: String = "dev"
inline val TIMEOUT: Boolean get() = 5 // from common {}, debug override
inline val TIMEOUT: Int get() = 5 // common {}, debug override
const val BASE_URL: String = "https://dev.example.com"
const val ANALYTICS: Boolean = false
}
@@ -159,11 +206,43 @@ Fields declared in `common {}` act as fallbacks for all variants. A variant fiel
```kotlin
dimension("env", objectNameOverride = "Environment", defaultTo = "prod") { ... }
// generates: object Environment { ... }
// generates: object Environment /*env*/ { ... }
```
If no override is given, the object name is derived from the dimension name via CamelCase conversion (`my-env` → `MyEnv`).
### Flat dimensions
`flatDimension` works exactly like `dimension`, but its fields are generated
directly at the root of the konfig object instead of a nested object. The
active variant is exposed as `<NAME>_VARIANT`:
```kotlin
konfig {
flatDimension("env", defaultTo = "prod") {
variant("prod") { field("BASE_URL", "https://prod.example.com") }
variant("dev") { field("BASE_URL", "https://dev.example.com") }
}
}
```
Generated output (with `env=prod`):
```kotlin
public object BuildKonfig {
const val BUILD_TYPE: String = "release"
// ...
// dimension: env (flat), variant: prod
const val ENV_VARIANT: String = "prod"
const val BASE_URL: String = "https://prod.example.com"
}
```
Root-level name collisions **fail the build** — a flat field may not shadow a
built-in constant (`BUILD_TYPE`, `MODULE_NAME`, `IS_DEBUG`), a global field, or
a field from another flat dimension.
---
## Variant selection
@@ -188,6 +267,30 @@ konfig.dimension.env=dev
This file is tracked as a task input — changing it invalidates the build cache.
### Task-name matching rules
Variant detection respects **camelCase word boundaries** — a variant only
matches a whole segment of the task name, never a plain substring:
- `assemblePreprodRelease` matches variant `preprod`, **not** `prod`
- `assembleProdRelease` matches variant `prod`, **not** `preprod`
- `assembleDevelopRelease` does **not** match variant `dev`
When several variants match and every match is a substring of the longest one
(e.g. `prod` inside `preProd` for `assemblePreProdRelease`), the longest wins.
Genuinely ambiguous matches are skipped with a warning.
### Selection logging
The resolved build type and every dimension decision are printed by the
`konfigInfo` task on **every** build — including fully cached / UP-TO-DATE
builds with the configuration cache enabled:
```
konfig [app]: BUILD_TYPE = release (task-name detection matched release in [assembleProdRelease])
konfig [app]: dim 'env' -> 'prod' (task-name detection: 'prod' found in [assembleProdRelease])
```
---
## Build-type detection
@@ -214,7 +317,7 @@ Build type is resolved in priority order:
### `konfig.force`
Forces the `generateKonfig` task to re-run on every build, bypassing Gradle's UP-TO-DATE and build-cache checks. Useful when generating a release build for a client or diagnosing cache issues.
Forces `generateKonfig` to re-run on every build, bypassing Gradle's UP-TO-DATE and build-cache checks.
```bash
./gradlew generateKonfig -Pkonfig.force
@@ -230,14 +333,42 @@ The flag is presence-based — any value (or no value) enables it.
```kotlin
import com.example.app.BuildKonfig
println(BuildKonfig.BUILD_TYPE) // "debug" or "release"
println(BuildKonfig.IS_DEBUG) // true (debug) or false (release)
println(BuildKonfig.Env.BASE_URL) // dimension field
println(BuildKonfig.Env.VARIANT) // "dev" or "prod"
println(BuildKonfig.BUILD_TYPE) // "debug" or "release"
println(BuildKonfig.IS_DEBUG) // true (debug) or false (release)
println(BuildKonfig.Env.BASE_URL) // dimension field
println(BuildKonfig.Env.VARIANT) // "dev" or "prod"
```
---
## Build-script queries (`konfig.isDebug`, `konfig.getCurrentDimension`)
The same recognition logic that drives generation is queryable from build
scripts — useful for wiring per-build-type dependencies in KMP projects:
```kotlin
konfig {
dimension("env", defaultTo = "prod") { /* ... */ }
}
dependencies {
if (konfig.isDebug) implementation(project(":debugImpl"))
else implementation(project(":releaseImpl"))
}
val activeEnv: String? = konfig.getCurrentDimension("env") // "prod", or null if skipped
```
| API | Type | Description |
|--------------------------------------|---------------------|------------------------------------------------|
| `konfig.isDebug` | `Boolean` | True when the resolved build type is debug |
| `konfig.isDebugProvider` | `Provider<Boolean>` | Lazy variant for provider-based wiring |
| `konfig.currentBuildType` | `Provider<String>` | `"debug"` / `"release"` |
| `konfig.getCurrentDimension(name)` | `String?` | Active variant, or `null` when skipped/unknown |
| `konfig.currentDimension(name)` | `Provider<String>` | Lazy variant (absent when skipped/unknown) |
---
## Using Gradle providers as field values
Lazy `Provider<T>` values are supported — useful for reading Gradle properties or environment variables:
@@ -260,24 +391,51 @@ The generated directory (`build/generated/konfig/`) is automatically added as a
- `org.jetbrains.kotlin.multiplatform` → `commonMain`
- `org.jetbrains.kotlin.jvm` → `main`
- `org.jetbrains.kotlin.android` → `main`
- `com.android.application` / `com.android.library` → `main`
- `com.android.application` / `com.android.library` → all variants via the
`androidComponents` Sources API (AGP 8.1+ required)
The Android wiring uses the modern variant Sources API instead of the
`AndroidSourceSet` DSL, so it keeps working on **AGP 9.2+** where
`android.sourceset.disallowProvider` defaults to `true` (passing providers to
the source-set DSL is rejected). No legacy flag needed.
The `generateKonfig` task is automatically wired as a dependency of all `compileKotlin*` and `sourcesJar` tasks.
---
## Build
## Publishing (plugin development)
```bash
# Build and publish to local Maven
# Publish to GitHub Packages (requires gpr.user + gpr.key)
./gradlew publish
# Publish only the plugin marker (fixes resolution without re-uploading the jar)
./gradlew publishKonfigPluginMarkerMavenPublicationToGitHubPackagesRepository
# Publish to local Maven for local testing
./gradlew publishToMavenLocal
```
---
## Development
```bash
# Build and publish to local Maven (primary development loop)
./gradlew publishToMavenLocal
# Run all tests
./gradlew check
# Run unit tests only
./gradlew test
# Run only functional tests
# Run functional tests (Gradle TestKit — starts real Gradle builds)
./gradlew functionalTest
# Run a specific functional test
./gradlew functionalTest --tests "*dimension with defaultTo*"
# Run all checks (test + functionalTest)
./gradlew check
# Force re-run (skip UP-TO-DATE / cache)
./gradlew functionalTest --rerun-tasks
```
+74 -5
View File
@@ -46,9 +46,11 @@ configurations[functionalTest.implementationConfigurationName]
dependencies {
compileOnly("org.jetbrains.kotlin:kotlin-gradle-plugin:$embeddedKotlinVersion")
compileOnly("com.android.tools.build:gradle:8.0.0")
// 8.7+ needed for the variant Sources API (variant.sources.kotlin) used instead of
// the AndroidSourceSet DSL (whose provider support is removed by AGP 9.2).
compileOnly("com.android.tools.build:gradle:8.7.3")
testImplementation(kotlin("test"))
"functionalTestImplementation"(gradleTestKit())
add("functionalTestImplementation", gradleTestKit())
}
val functionalTestTask = tasks.register<Test>("functionalTest") {
@@ -60,8 +62,75 @@ val functionalTestTask = tasks.register<Test>("functionalTest") {
tasks.check { dependsOn(functionalTestTask) }
// ========================================================
// MARK: Publishing
// ========================================================
fun prop(name: String): String? =
providers.gradleProperty(name).orNull
?: System.getenv(name.replace('.', '_').uppercase())
publishing {
repositories {
mavenLocal()
}
// The `kotlin-dsl` + `gradlePlugin {}` combo automatically creates two publications:
// - "pluginMaven" → the real implementation jar (groupId:artifactId:version)
// - "konfigPluginMarkerMaven" → the plugin marker (pluginId:pluginId.gradle.plugin:version)
//
// We must NOT create a third "pluginMaven" manually — that breaks the marker.
// Instead we configure the existing ones via withType.
publications.withType<MavenPublication>().configureEach {
// Only decorate the implementation publication, not the marker
if (artifactId != "com.bitsycore.konfig.gradle.plugin") {
artifactId = providers.gradleProperty("konfig.artifactId").get()
pom {
name = providers.gradleProperty("konfig.pom.name").get()
description = providers.gradleProperty("konfig.pom.description").get()
url = providers.gradleProperty("konfig.pom.url").get()
licenses {
license {
name = "MIT License"
url = "https://opensource.org/licenses/MIT"
}
}
developers {
developer {
id = providers.gradleProperty("konfig.pom.developer.id").get()
name = providers.gradleProperty("konfig.pom.developer.name").get()
url = providers.gradleProperty("konfig.pom.developer.url").get()
}
}
scm {
connection = providers.gradleProperty("konfig.pom.scm.connection").get()
developerConnection = providers.gradleProperty("konfig.pom.scm.developerConnection").get()
url = providers.gradleProperty("konfig.pom.scm.url").get()
}
}
}
}
repositories {
mavenLocal()
maven {
name = "GitHubPackages"
url = uri(providers.gradleProperty("konfig.publish.url").get())
credentials {
username = prop("gpr.user")
password = prop("gpr.key")
}
}
maven {
name = "Bitsycore"
url = uri("https://maven.bitsycore.com/releases")
credentials {
// props resolve from gradle.properties or env BITSYCORE_MAVEN_USER / BITSYCORE_MAVEN_TOKEN
username = prop("bitsycore.maven.user")
password = prop("bitsycore.maven.token")
}
}
}
}
+32 -1
View File
@@ -3,4 +3,35 @@ org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.configuration-cache=true
konfig.version=0.2.0
# =========================================================
# MARK: Publishing
# =========================================================
konfig.version=0.6.0
konfig.artifactId=konfig-gradle-plugin
konfig.publish.url=https://maven.pkg.github.com/bitsycore/bitsykonfig-gradle-plugin
# =========================================================
# MARK: POM
# =========================================================
konfig.pom.name=BuildKonfig Gradle Plugin
konfig.pom.description=Generates a BuildKonfig Kotlin object at build time - like Android BuildConfig, but for any Kotlin project (JVM, Multiplatform, Android).
konfig.pom.url=https://github.com/bitsycore/bitsykonfig-gradle-plugin
konfig.pom.developer.id=bitsycore
konfig.pom.developer.name=bitsycore
konfig.pom.developer.url=https://github.com/bitsycore
konfig.pom.scm.connection=scm:git:git://github.com/bitsycore/bitsykonfig-gradle-plugin.git
konfig.pom.scm.developerConnection=scm:git:ssh://github.com/bitsycore/bitsykonfig-gradle-plugin.git
konfig.pom.scm.url=https://github.com/bitsycore/bitsykonfig-gradle-plugin
# =========================================================
# MARK: Credentials
# =========================================================
# (keep in ~/.gradle/gradle.properties)
# gpr.user=YOUR_GITHUB_USERNAME
# gpr.key=YOUR_GITHUB_PERSONAL_ACCESS_TOKEN
@@ -0,0 +1,116 @@
package com.bitsycore.konfig
import org.gradle.testkit.runner.TaskOutcome
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Functional tests for the basic file-generation behaviour of the plugin:
* package derivation, object name, BUILD_TYPE, IS_DEBUG, MODULE_NAME,
* and the required file header.
*/
class BasicGenerationFunctionalTest : FunctionalTestBase() {
@Test fun `generateKonfig task succeeds`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertEquals(TaskOutcome.SUCCESS, result.task(":generateKonfig")?.outcome)
}
@Test fun `package is derived from group and project name`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("package com.example.test.project"))
}
@Test fun `default object name is BuildKonfig`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("object BuildKonfig {"))
}
@Test fun `RELEASE build sets BUILD_TYPE to release`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("""const val BUILD_TYPE: String = "release""""))
}
@Test fun `DEBUG build sets BUILD_TYPE to debug`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
assertTrue(dir.generatedFile().readText().contains("""const val BUILD_TYPE: String = "debug""""))
}
@Test fun `RELEASE build emits const IS_DEBUG = false`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val IS_DEBUG: Boolean = false"))
}
@Test fun `DEBUG build emits inline IS_DEBUG getter`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
assertTrue(dir.generatedFile().readText().contains("inline val IS_DEBUG: Boolean get() = true"))
}
@Test fun `MODULE_NAME const is present`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("""const val MODULE_NAME: String = """))
}
@Test fun `generated file contains RedundantVisibilityModifier suppress header`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("""@file:Suppress("RedundantVisibilityModifier")"""))
}
@Test fun `generated file contains DO NOT EDIT comment`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("DO NOT EDIT"))
}
@Test fun `no konfig block still generates file`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertEquals(TaskOutcome.SUCCESS, result.task(":generateKonfig")?.outcome)
assertTrue(dir.generatedFile().readText().contains("object BuildKonfig"))
}
}
@@ -0,0 +1,157 @@
package com.bitsycore.konfig
import org.gradle.testkit.runner.TaskOutcome
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* Functional tests for Gradle task caching and incremental build behaviour:
* UP-TO-DATE on unchanged inputs, re-execution on changed inputs,
* and the `-Pkonfig.force` bypass.
*/
class CachingFunctionalTest : FunctionalTestBase() {
// ── UP-TO-DATE on unchanged inputs ────────────────────────────────────────
@Test fun `task is UP-TO-DATE on second run with unchanged inputs`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("FOO", "bar") }
""")
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE")
run(args)
assertEquals(TaskOutcome.UP_TO_DATE, run(args).task(":generateKonfig")?.outcome)
}
@Test fun `task is UP-TO-DATE for empty konfig block`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {}
""")
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE")
run(args)
assertEquals(TaskOutcome.UP_TO_DATE, run(args).task(":generateKonfig")?.outcome)
}
// ── Re-executes when inputs change ────────────────────────────────────────
@Test fun `task re-runs after build type changes`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val second = run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
assertNotEquals(TaskOutcome.UP_TO_DATE, second.task(":generateKonfig")?.outcome)
}
@Test fun `task re-runs after field value changes`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("FOO", "first") }
""")
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE")
run(args)
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("FOO", "second") }
""")
assertNotEquals(TaskOutcome.UP_TO_DATE, run(args).task(":generateKonfig")?.outcome)
}
@Test fun `task re-runs after dimension variant property changes`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("URL", "https://prod.example.com") }
variant("dev") { field("URL", "https://dev.example.com") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
val second = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=dev"))
assertNotEquals(TaskOutcome.UP_TO_DATE, second.task(":generateKonfig")?.outcome)
}
@Test fun `task re-runs after konfig_properties file changes`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("URL", "https://prod.example.com") }
variant("dev") { field("URL", "https://dev.example.com") }
}
}
""")
val propsFile = dir.resolve("konfig.properties")
propsFile.writeText("konfig.dimension.env=dev\n")
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE")
run(args)
propsFile.writeText("konfig.dimension.env=prod\n")
assertNotEquals(TaskOutcome.UP_TO_DATE, run(args).task(":generateKonfig")?.outcome)
}
// ── -Pkonfig.force ────────────────────────────────────────────────────────
@Test fun `konfig force prevents UP-TO-DATE on second run`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""")
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.force")
run(args)
assertNotEquals(TaskOutcome.UP_TO_DATE, run(args).task(":generateKonfig")?.outcome)
}
@Test fun `without konfig force task stays UP-TO-DATE`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""")
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE")
run(args)
assertEquals(TaskOutcome.UP_TO_DATE, run(args).task(":generateKonfig")?.outcome)
}
@Test fun `removing konfig force restores UP-TO-DATE behaviour`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""")
// Run once without force to prime the cache
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
// Second run also without force → UP-TO-DATE
assertEquals(
TaskOutcome.UP_TO_DATE,
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE")).task(":generateKonfig")?.outcome
)
}
// ── Generated output survives subsequent UP-TO-DATE runs ─────────────────
@Test fun `generated file content is unchanged after UP-TO-DATE run`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("STABLE", "value") }
""")
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE")
run(args)
val textAfterFirst = dir.generatedFile().readText()
run(args) // UP-TO-DATE
assertEquals(textAfterFirst, dir.generatedFile().readText())
assertTrue(textAfterFirst.contains("""const val STABLE: String = "value""""))
}
}
@@ -0,0 +1,199 @@
package com.bitsycore.konfig
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Functional tests for the `common {}` block inside a dimension.
*
* The common block provides fallback fields for all variants — a variant field
* with the same name must take precedence over the common field.
*/
class CommonBlockFunctionalTest : FunctionalTestBase() {
// ── Common field is present when variant has no override ──────────────────
@Test fun `common field appears when active variant does not define it`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
common { field("TIMEOUT", 30) }
variant("dev") { field("URL", "https://dev.example.com") }
variant("prod") { field("URL", "https://prod.example.com") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=dev"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("const val TIMEOUT: Int = 30"))
assertTrue(text.contains("""const val URL: String = "https://dev.example.com""""))
}
@Test fun `common field appears for both variants`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
common { field("RETRIES", 3) }
variant("dev") { field("URL", "https://dev.example.com") }
variant("prod") { field("URL", "https://prod.example.com") }
}
}
""")
// dev variant
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=dev"))
assertTrue(dir.generatedFile().readText().contains("const val RETRIES: Int = 3"))
// prod variant
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
assertTrue(dir.generatedFile().readText().contains("const val RETRIES: Int = 3"))
}
// ── Variant field overrides common field ─────────────────────────────────
@Test fun `variant field overrides common field with same name`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
common { field("TIMEOUT", 30) }
variant("dev") { field("TIMEOUT", 5) }
variant("prod") { /* uses common TIMEOUT */ }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=dev"))
assertTrue(dir.generatedFile().readText().contains("const val TIMEOUT: Int = 5"))
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
assertTrue(dir.generatedFile().readText().contains("const val TIMEOUT: Int = 30"))
}
// ── Common block supports build-type scopes ───────────────────────────────
@Test fun `common debug scope applies for DEBUG build`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
common {
field("TIMEOUT", 30)
debug { field("TIMEOUT", 2) }
}
variant("dev") { field("URL", "https://dev.example.com") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG", "-Pkonfig.dimension.env=dev"))
assertTrue(dir.generatedFile().readText().contains("const val TIMEOUT: Int = 2"))
}
@Test fun `common debug scope does not affect RELEASE build`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
common {
field("TIMEOUT", 30)
debug { field("TIMEOUT", 2) }
}
variant("prod") { field("URL", "https://prod.example.com") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
assertTrue(dir.generatedFile().readText().contains("const val TIMEOUT: Int = 30"))
}
@Test fun `common release scope applies for RELEASE build`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
common {
field("LOG", "info")
release { field("LOG", "error") }
}
variant("prod") { field("URL", "https://prod.example.com") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
assertTrue(dir.generatedFile().readText().contains("""const val LOG: String = "error""""))
}
// ── Common-only dimension (no variant-specific fields) ────────────────────
@Test fun `dimension with only common fields and empty variant generates common fields`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
common {
field("BASE", "https://base.example.com")
field("TIMEOUT", 10)
}
variant("any") { /* intentionally empty */ }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=any"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("""const val BASE: String = "https://base.example.com""""))
assertTrue(text.contains("const val TIMEOUT: Int = 10"))
}
// ── Common fields from multiple common{} calls ────────────────────────────
@Test fun `multiple common blocks merge their fields`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
common { field("A", "first") }
common { field("B", "second") }
variant("x") { /* empty */ }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=x"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("""const val A: String = "first""""))
assertTrue(text.contains("""const val B: String = "second""""))
}
// ── Common does not bleed into unrelated dimension ────────────────────────
@Test fun `common fields in one dimension do not appear in another dimension`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
common { field("SHARED", "env-shared") }
variant("prod") { field("URL", "https://prod.example.com") }
}
dimension("region", defaultTo = "eu") {
variant("eu") { field("CDN", "https://eu-cdn.example.com") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
val text = dir.generatedFile().readText()
// SHARED should be under Env object
val envBlock = text.substringAfter("object Env")
val regionBlock = if (text.contains("object Region")) text.substringAfter("object Region") else ""
assertTrue(envBlock.contains("SHARED"))
assertFalse(regionBlock.contains("SHARED"))
}
}
@@ -0,0 +1,273 @@
package com.bitsycore.konfig
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Functional tests for the `dimension()` DSL:
* explicit property selection, objectNameOverride, defaultTo fallback,
* silent omission when no variant is resolved, variant field overrides,
* VARIANT const, missing fields, and multiple dimensions.
*/
class DimensionsFunctionalTest : FunctionalTestBase() {
// ── Explicit property selection ───────────────────────────────────────────
@Test fun `explicit property selects correct variant`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("SERVER_URL", "https://prod.example.com") }
variant("dev") { field("SERVER_URL", "https://dev.example.com") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=dev"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("object Env /*env*/"))
assertTrue(text.contains("""const val VARIANT: String = "dev""""))
assertTrue(text.contains("""const val SERVER_URL: String = "https://dev.example.com""""))
}
@Test fun `selecting prod variant generates prod fields`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("SERVER_URL", "https://prod.example.com") }
variant("dev") { field("SERVER_URL", "https://dev.example.com") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("""const val VARIANT: String = "prod""""))
assertTrue(text.contains("""const val SERVER_URL: String = "https://prod.example.com""""))
assertFalse(text.contains("dev.example.com"))
}
// ── objectNameOverride ────────────────────────────────────────────────────
@Test fun `objectNameOverride is used instead of derived name`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env", objectNameOverride = "Environment") {
variant("prod") { field("X", "y") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("object Environment /*env*/"))
assertFalse(text.contains("object Env /*"))
}
// ── defaultTo fallback ────────────────────────────────────────────────────
@Test fun `defaultTo is used when no property is set`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("region", defaultTo = "eu") {
variant("eu") { field("BASE_URL", "https://eu.example.com") }
variant("us") { field("BASE_URL", "https://us.example.com") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("object Region /*region*/"))
assertTrue(text.contains("""const val VARIANT: String = "eu""""))
assertTrue(text.contains("""const val BASE_URL: String = "https://eu.example.com""""))
}
@Test fun `explicit property overrides defaultTo`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("region", defaultTo = "eu") {
variant("eu") { field("BASE_URL", "https://eu.example.com") }
variant("us") { field("BASE_URL", "https://us.example.com") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.region=us"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("""const val VARIANT: String = "us""""))
assertFalse(text.contains("eu.example.com"))
}
// ── Silent omission ───────────────────────────────────────────────────────
@Test fun `dimension without resolved variant is omitted silently`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("X", "y") }
}
field("ALWAYS", "here")
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
assertFalse(text.contains("object Env"))
assertTrue(text.contains("""const val ALWAYS: String = "here""""))
}
// ── Variant field debug/release overrides ─────────────────────────────────
@Test fun `variant field debug override applied for DEBUG build`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("dev") {
field("URL", "https://dev.example.com").debug("https://dev-debug.example.com")
}
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG", "-Pkonfig.dimension.env=dev"))
assertTrue(dir.generatedFile().readText()
.contains("""const val URL: String = "https://dev-debug.example.com""""))
}
@Test fun `variant field release override applied for RELEASE build`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") {
field("URL", "https://default.example.com").release("https://prod.example.com")
}
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
assertTrue(dir.generatedFile().readText()
.contains("""const val URL: String = "https://prod.example.com""""))
}
// ── Missing fields ─────────────────────────────────────────────────────────
@Test fun `field defined in variant A does not appear when variant B is active`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("a") { field("ONLY_IN_A", true) }
variant("b") { /* no ONLY_IN_A */ }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=b"))
assertFalse(dir.generatedFile().readText().contains("ONLY_IN_A"))
}
// ── Multiple dimensions ────────────────────────────────────────────────────
@Test fun `two dimensions both appear in output when both resolved`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("SERVER", "https://prod.example.com") }
}
dimension("region") {
variant("eu") { field("CDN", "https://eu-cdn.example.com") }
}
}
""")
run(listOf(
"generateKonfig",
"-Pkonfig.buildtype=RELEASE",
"-Pkonfig.dimension.env=prod",
"-Pkonfig.dimension.region=eu"
))
val text = dir.generatedFile().readText()
assertTrue(text.contains("object Env /*env*/"))
assertTrue(text.contains("object Region /*region*/"))
assertTrue(text.contains("""const val SERVER: String = "https://prod.example.com""""))
assertTrue(text.contains("""const val CDN: String = "https://eu-cdn.example.com""""))
}
@Test fun `one dimension resolved and one skipped coexist correctly`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env", defaultTo = "prod") {
variant("prod") { field("SERVER", "https://prod.example.com") }
}
dimension("region") { // no defaultTo, no property
variant("eu") { field("CDN", "https://eu-cdn.example.com") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("object Env /*env*/"))
assertFalse(text.contains("object Region"))
}
// ── CamelCase objectName derivation ──────────────────────────────────────
@Test fun `hyphenated dimension name becomes CamelCase object name`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("build-env", defaultTo = "prod") {
variant("prod") { field("X", "y") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("object BuildEnv /*build-env*/"))
}
@Test fun `underscore dimension name becomes CamelCase object name`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("my_region", defaultTo = "eu") {
variant("eu") { field("X", "y") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("object MyRegion /*my_region*/"))
}
// ── VARIANT const is always present ──────────────────────────────────────
@Test fun `VARIANT const is always present in dimension object`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env", defaultTo = "staging") {
variant("staging") { /* no fields */ }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("""const val VARIANT: String = "staging""""))
}
}
@@ -0,0 +1,138 @@
package com.bitsycore.konfig
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Functional tests for duplicate-name detection at configuration time.
* The plugin must fail fast with a clear error when the same name is
* declared more than once for dimensions, global fields, or variant fields.
*/
class DuplicateDetectionFunctionalTest : FunctionalTestBase() {
// ── Duplicate dimension name ───────────────────────────────────────────────
@Test fun `duplicate dimension name fails configuration with clear error`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") { variant("prod") { field("X", "y") } }
dimension("env") { variant("dev") { field("X", "z") } }
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("env"))
}
// ── Duplicate global field name ───────────────────────────────────────────
@Test fun `duplicate global field name fails configuration`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("API_URL", "https://prod.example.com")
field("API_URL", "https://dev.example.com")
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("API_URL"))
}
@Test fun `duplicate global field of different types fails configuration`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("FLAG", true)
field("FLAG", "stringNow")
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("FLAG"))
}
// ── Duplicate variant field name ──────────────────────────────────────────
@Test fun `duplicate field name within variant fails configuration`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") {
field("URL", "https://a.example.com")
field("URL", "https://b.example.com")
}
}
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
assertTrue(result.output.contains("URL"))
}
@Test fun `same field name in different variants is allowed`() = withProject { dir, run ->
// Different variants may each define the same field name — that is the whole point
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("URL", "https://prod.example.com") }
variant("dev") { field("URL", "https://dev.example.com") }
}
}
""")
// Should succeed without any exception
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=dev"))
assertTrue(result.output.contains("generateKonfig"))
}
@Test fun `same field name in different dimensions is allowed`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env", defaultTo = "prod") {
variant("prod") { field("URL", "https://prod.example.com") }
}
dimension("region", defaultTo = "eu") {
variant("eu") { field("URL", "https://eu.example.com") }
}
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("generateKonfig"))
}
// ── Duplicate dimension name — error message ──────────────────────────────
@Test fun `duplicate dimension error message contains dimension name`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("my-dim") { variant("a") { field("X", "1") } }
dimension("my-dim") { variant("b") { field("X", "2") } }
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("my-dim"))
}
// ── Duplicate global field — error message ────────────────────────────────
@Test fun `duplicate global field error message contains field name`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("UNIQUE_KEY", "first")
field("UNIQUE_KEY", "second")
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("UNIQUE_KEY"))
}
}
@@ -0,0 +1,216 @@
package com.bitsycore.konfig
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Functional tests for every supported field type emitted by the plugin:
* String, Boolean, Int, Long, Float, Double — including correct Kotlin
* literal syntax (suffixes, special Float/Double values, escaping).
*/
class FieldTypesFunctionalTest : FunctionalTestBase() {
// ── Long ─────────────────────────────────────────────────────────────────
@Test fun `Long field emits L suffix`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("BIG", 9_000_000_000L)
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val BIG: Long = 9000000000L"))
}
@Test fun `Long zero emits L suffix`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("Z", 0L) }
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val Z: Long = 0L"))
}
@Test fun `Long max value emits correctly`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("MAX", Long.MAX_VALUE) }
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val MAX: Long = 9223372036854775807L"))
}
// ── Float ─────────────────────────────────────────────────────────────────
@Test fun `Float field emits f suffix`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("RATIO", 1.5f) }
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("const val RATIO: Float = "))
assertTrue(text.contains("f"))
}
@Test fun `Float NaN emits Float_NaN`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("NAN", Float.NaN) }
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val NAN: Float = Float.NaN"))
}
@Test fun `Float POSITIVE_INFINITY emits Float_POSITIVE_INFINITY`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("INF", Float.POSITIVE_INFINITY) }
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText()
.contains("const val INF: Float = Float.POSITIVE_INFINITY"))
}
@Test fun `Float NEGATIVE_INFINITY emits Float_NEGATIVE_INFINITY`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("NINF", Float.NEGATIVE_INFINITY) }
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText()
.contains("const val NINF: Float = Float.NEGATIVE_INFINITY"))
}
// ── Double ────────────────────────────────────────────────────────────────
@Test fun `Double field emits no suffix`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("PI", 3.14159) }
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("const val PI: Double = 3.14159"))
assertFalse(text.contains("3.14159f"))
}
@Test fun `Double NaN emits Double_NaN`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("NAN", Double.NaN) }
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val NAN: Double = Double.NaN"))
}
@Test fun `Double POSITIVE_INFINITY emits Double_POSITIVE_INFINITY`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("INF", Double.POSITIVE_INFINITY) }
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText()
.contains("const val INF: Double = Double.POSITIVE_INFINITY"))
}
@Test fun `Double NEGATIVE_INFINITY emits Double_NEGATIVE_INFINITY`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("NINF", Double.NEGATIVE_INFINITY) }
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText()
.contains("const val NINF: Double = Double.NEGATIVE_INFINITY"))
}
// ── Boolean (DEBUG inline val vs RELEASE const val) ───────────────────────
@Test fun `Boolean true in RELEASE is const val`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("B", true) }
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val B: Boolean = true"))
}
@Test fun `Boolean false in DEBUG is inline val getter`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("B", false) }
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
assertTrue(dir.generatedFile().readText().contains("inline val B: Boolean get() = false"))
}
// ── All types together ────────────────────────────────────────────────────
@Test fun `all field types appear correctly in a single object`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("STR", "hello")
field("BOOL", true)
field("INT", 42)
field("LONG", 99L)
field("FLOAT", 2.5f)
field("DOUBLE", 1.23)
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("""const val STR: String = "hello""""))
assertTrue(text.contains("const val BOOL: Boolean = true"))
assertTrue(text.contains("const val INT: Int = 42"))
assertTrue(text.contains("const val LONG: Long = 99L"))
assertTrue(text.contains("const val FLOAT: Float = ") && text.contains("f"))
assertTrue(text.contains("const val DOUBLE: Double = 1.23"))
}
// ── Types in dimension fields ─────────────────────────────────────────────
@Test fun `Long field inside dimension variant is emitted correctly`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env", defaultTo = "prod") {
variant("prod") { field("TOKEN_TTL", 3_600_000L) }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val TOKEN_TTL: Long = 3600000L"))
}
@Test fun `Double field inside dimension variant is emitted correctly`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env", defaultTo = "prod") {
variant("prod") { field("THRESHOLD", 0.95) }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val THRESHOLD: Double = 0.95"))
}
}
@@ -0,0 +1,100 @@
package com.bitsycore.konfig
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Functional tests for `flatDimension` — fields generated at the root of the
* konfig object, with hard failure on root-level name collisions.
*/
class FlatDimensionFunctionalTest : FunctionalTestBase() {
@Test fun `flat dimension fields are generated at root without nested object`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
flatDimension("env", defaultTo = "prod") {
variant("prod") { field("API_URL", "https://prod.example.com") }
variant("dev") { field("API_URL", "https://dev.example.com") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val content = dir.generatedFile().readText()
assertTrue(content.contains("""const val ENV_VARIANT: String = "prod""""), "root variant const missing:\n$content")
assertTrue(content.contains("""const val API_URL: String = "https://prod.example.com""""), "root field missing:\n$content")
assertFalse(content.contains("object Env"), "flat dimension must not generate a nested object:\n$content")
}
@Test fun `flat dimension colliding with global field fails the build`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("API_URL", "global")
flatDimension("env", defaultTo = "prod") {
variant("prod") { field("API_URL", "https://prod.example.com") }
}
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("collides"), "expected collision error, got:\n${result.output}")
assertTrue(result.output.contains("API_URL"))
}
@Test fun `flat dimension colliding with built-in constant fails the build`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
flatDimension("env", defaultTo = "prod") {
variant("prod") { field("BUILD_TYPE", "oops") }
}
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("collides"), "expected collision error, got:\n${result.output}")
assertTrue(result.output.contains("BUILD_TYPE"))
}
@Test fun `two flat dimensions with colliding fields fail the build`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
flatDimension("env", defaultTo = "prod") {
variant("prod") { field("URL", "a") }
}
flatDimension("region", defaultTo = "eu") {
variant("eu") { field("URL", "b") }
}
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("collides"), "expected collision error, got:\n${result.output}")
}
@Test fun `flat and nested dimensions can coexist`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
flatDimension("env", defaultTo = "prod") {
variant("prod") { field("API_URL", "https://prod.example.com") }
}
dimension("region", defaultTo = "eu") {
variant("eu") { field("DC", "fra") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val content = dir.generatedFile().readText()
assertTrue(content.contains("const val ENV_VARIANT"))
assertTrue(content.contains("object Region"))
assertTrue(content.contains("""const val DC: String = "fra""""))
}
}
@@ -0,0 +1,71 @@
package com.bitsycore.konfig
import org.gradle.testkit.runner.BuildResult
import org.gradle.testkit.runner.GradleRunner
import java.io.File
import kotlin.io.path.createTempDirectory
/**
* Base class for all functional tests.
*
* Provides:
* - [withProject] — creates a temp Gradle project, runs it, then cleans up.
* - [File.generatedFile] — walks the output dir to find the generated `.kt` file.
* - [File.writeBuildGradle] — shorthand for writing a `build.gradle.kts`.
*/
abstract class FunctionalTestBase {
/**
* Creates a fresh temp project directory, invokes [block] with it, then deletes it.
*
* @param block receives `(projectDir, run)` where `run(args)` executes Gradle and
* returns the [BuildResult] (throws on build failure by default).
*/
protected fun withProject(block: (projectDir: File, run: (List<String>) -> BuildResult) -> Unit) {
val projectDir = createTempDirectory("konfig-ft").toFile()
try {
projectDir.resolve("settings.gradle.kts")
.writeText("""rootProject.name = "test-project"""")
val run = { args: List<String> ->
GradleRunner.create()
.withProjectDir(projectDir)
.withPluginClasspath()
.withArguments(args)
.build()
}
block(projectDir, run)
} finally {
projectDir.deleteRecursively()
}
}
/**
* Same as [withProject] but Gradle is invoked with `buildAndFail()` so a build
* failure does NOT throw — the returned [BuildResult] carries the failed output.
*/
protected fun withFailingProject(block: (projectDir: File, run: (List<String>) -> BuildResult) -> Unit) {
val projectDir = createTempDirectory("konfig-ft-fail").toFile()
try {
projectDir.resolve("settings.gradle.kts")
.writeText("""rootProject.name = "test-project"""")
val run = { args: List<String> ->
GradleRunner.create()
.withProjectDir(projectDir)
.withPluginClasspath()
.withArguments(args)
.buildAndFail()
}
block(projectDir, run)
} finally {
projectDir.deleteRecursively()
}
}
/** Finds the single generated `.kt` file inside the project's output tree. */
protected fun File.generatedFile(): File =
walkTopDown().first { it.isFile && it.extension == "kt" }
/** Writes a minimal `build.gradle.kts` applying the konfig plugin. */
protected fun File.writeBuildGradle(content: String) =
resolve("build.gradle.kts").writeText(content.trimIndent())
}
@@ -0,0 +1,301 @@
package com.bitsycore.konfig
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Functional tests for top-level (global) field declarations:
* String, Boolean, Int — defaults, debug overrides, release overrides,
* and the debug/release scope block syntax.
*/
class GlobalFieldsFunctionalTest : FunctionalTestBase() {
// ── String fields ────────────────────────────────────────────────────────
@Test fun `string field is generated with correct const val`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("API_URL", "https://api.example.com")
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText()
.contains("""const val API_URL: String = "https://api.example.com""""))
}
@Test fun `string field with quotes is escaped correctly`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("APP_NAME", "My \"App\"")
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText()
.contains("""const val APP_NAME: String = "My \"App\"""""))
}
@Test fun `string field with backslash is escaped correctly`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("PATH", "C:\\Users\\dev")
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("const val PATH: String = "))
assertTrue(text.contains("\\\\"))
}
@Test fun `string field with dollar sign is escaped correctly`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("TEMPLATE", "price: ${'$'}99")
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
// Dollar in string literals must be escaped as \$
assertTrue(text.contains("""\${'$'}""") || text.contains("""\$"""))
}
@Test fun `empty string field is generated`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("EMPTY", "")
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("""const val EMPTY: String = """""))
}
// ── Boolean fields ────────────────────────────────────────────────────────
@Test fun `boolean false field emits const val in RELEASE`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("LOGGING", false)
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val LOGGING: Boolean = false"))
}
@Test fun `boolean true field emits const val in RELEASE`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("FLAG", true)
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val FLAG: Boolean = true"))
}
@Test fun `boolean field emits inline val getter in DEBUG`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("LOGGING", false)
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
assertTrue(dir.generatedFile().readText().contains("inline val LOGGING: Boolean get() = false"))
}
// ── Int fields ────────────────────────────────────────────────────────────
@Test fun `int field is generated with correct const val`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("TIMEOUT", 30)
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val TIMEOUT: Int = 30"))
}
@Test fun `zero int field is generated`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("ZERO", 0)
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val ZERO: Int = 0"))
}
@Test fun `negative int field is generated`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("NEG", -1)
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("const val NEG: Int = -1"))
}
// ── Debug overrides ───────────────────────────────────────────────────────
@Test fun `string debug override is applied for DEBUG build`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("BASE_URL", "https://prod.example.com").debug("https://dev.example.com")
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
assertTrue(dir.generatedFile().readText()
.contains("""const val BASE_URL: String = "https://dev.example.com""""))
}
@Test fun `string debug override is NOT applied for RELEASE build`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("BASE_URL", "https://prod.example.com").debug("https://dev.example.com")
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("""const val BASE_URL: String = "https://prod.example.com""""))
assertFalse(text.contains("dev.example.com"))
}
@Test fun `boolean debug override emits inline val getter in DEBUG`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("LOGGING", false).debug(true)
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
assertTrue(dir.generatedFile().readText().contains("inline val LOGGING: Boolean get() = true"))
}
@Test fun `int debug override is applied for DEBUG build`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("TIMEOUT", 30).debug(5)
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
assertTrue(dir.generatedFile().readText().contains("const val TIMEOUT: Int = 5"))
}
// ── Release overrides ─────────────────────────────────────────────────────
@Test fun `string release override is applied for RELEASE build`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("URL", "https://default.example.com").release("https://prod.example.com")
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText()
.contains("""const val URL: String = "https://prod.example.com""""))
}
@Test fun `release override is NOT applied for DEBUG build`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("URL", "https://default.example.com").release("https://prod.example.com")
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
assertTrue(dir.generatedFile().readText()
.contains("""const val URL: String = "https://default.example.com""""))
}
// ── debug {} / release {} block syntax ───────────────────────────────────
@Test fun `debug scope block sets field for DEBUG build`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
debug {
field("LOG_LEVEL", "verbose")
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
assertTrue(dir.generatedFile().readText()
.contains("""const val LOG_LEVEL: String = "verbose""""))
}
@Test fun `debug scope block field is absent for RELEASE build`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
debug {
field("DEBUG_ONLY", "yes")
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertFalse(dir.generatedFile().readText().contains("DEBUG_ONLY"))
}
@Test fun `release scope block sets field for RELEASE build`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
release {
field("LOG_LEVEL", "error")
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText()
.contains("""const val LOG_LEVEL: String = "error""""))
}
@Test fun `multiple global fields all appear in output`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("A", "alpha")
field("B", 42)
field("C", true)
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("""const val A: String = "alpha""""))
assertTrue(text.contains("const val B: Int = 42"))
assertTrue(text.contains("const val C: Boolean = true"))
}
}
@@ -1,643 +0,0 @@
package com.bitsycore.konfig
import org.gradle.testkit.runner.BuildResult
import org.gradle.testkit.runner.GradleRunner
import org.gradle.testkit.runner.TaskOutcome
import java.io.File
import kotlin.io.path.createTempDirectory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@Suppress("FunctionName")
class KonfigPluginFunctionalTest {
private fun withProject(block: (projectDir: File, run: (List<String>) -> BuildResult) -> Unit) {
val projectDir = createTempDirectory("konfig-ft").toFile()
try {
projectDir.resolve("settings.gradle.kts").writeText("""rootProject.name = "test-project"""")
val run = { args: List<String> ->
GradleRunner.create()
.withProjectDir(projectDir)
.withPluginClasspath()
.withArguments(args)
.build()
}
block(projectDir, run)
} finally {
projectDir.deleteRecursively()
}
}
private fun File.generatedBuildKonfig(): File =
walkTopDown().first { it.isFile && it.extension == "kt" }
// ─── Basic generation ─────────────────────────────────────────────────────
@Test fun `generateKonfig produces file with correct package and BUILD_TYPE`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""".trimIndent())
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertEquals(TaskOutcome.SUCCESS, result.task(":generateKonfig")?.outcome)
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("package com.example.test.project"))
assertTrue(text.contains("object BuildKonfig {"))
assertTrue(text.contains("""const val BUILD_TYPE: String = "release""""))
}
@Test fun `debug build type generates BUILD_TYPE debug`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {}
""".trimIndent())
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("""const val BUILD_TYPE: String = "debug""""))
}
// ─── Global fields ────────────────────────────────────────────────────────
@Test fun `string fields are generated correctly`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("API_URL", "https://api.example.com")
field("APP_NAME", "My \"App\"")
}
""".trimIndent())
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("""const val API_URL: String = "https://api.example.com""""))
assertTrue(text.contains("""const val APP_NAME: String = "My \"App\"""""))
}
@Test fun `boolean and int fields are generated correctly`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("LOGGING", false)
field("TIMEOUT", 30)
}
""".trimIndent())
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("const val LOGGING: Boolean = false"))
assertTrue(text.contains("const val TIMEOUT: Int = 30"))
}
@Test fun `global field debug override applied for debug build`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("BASE_URL", "https://prod.example.com").debug("https://dev.example.com")
field("LOGGING", false).debug(true)
}
""".trimIndent())
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("""const val BASE_URL: String = "https://dev.example.com""""))
assertTrue(text.contains("inline val LOGGING: Boolean get() = true"))
}
@Test fun `release build uses default when no release override`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("BASE_URL", "https://prod.example.com").debug("https://dev.example.com")
}
""".trimIndent())
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("""const val BASE_URL: String = "https://prod.example.com""""))
}
// ─── Visibility & naming ──────────────────────────────────────────────────
@Test fun `internal visibility prefixes object`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
objectVisibility = com.bitsycore.konfig.Visibility.INTERNAL
}
""".trimIndent())
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("internal object BuildKonfig {"))
}
@Test fun `custom objectName is used`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
objectName = "AppConfig"
}
""".trimIndent())
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("object AppConfig {"))
}
// ─── Dimensions ───────────────────────────────────────────────────────────
@Test fun `dimension with explicit property generates nested object`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") {
field("SERVER_URL", "https://prod.example.com")
}
variant("dev") {
field("SERVER_URL", "https://dev.example.com")
}
}
}
""".trimIndent())
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=dev"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("object Env /*env*/"))
assertTrue(text.contains("""const val VARIANT: String = "dev""""))
assertTrue(text.contains("""const val SERVER_URL: String = "https://dev.example.com""""))
}
@Test fun `dimension with objectNameOverride uses override name`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env", objectNameOverride = "Environment") {
variant("prod") { field("X", "y") }
}
}
""".trimIndent())
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("object Environment /*env*/"))
}
@Test fun `dimension with defaultTo uses default variant when no property set`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("region", defaultTo = "eu") {
variant("eu") { field("BASE_URL", "https://eu.example.com") }
variant("us") { field("BASE_URL", "https://us.example.com") }
}
}
""".trimIndent())
// No konfig.dimension.region property — falls back to defaultTo = "eu"
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("object Region /*region*/"))
assertTrue(text.contains("""const val VARIANT: String = "eu""""))
assertTrue(text.contains("""const val BASE_URL: String = "https://eu.example.com""""))
}
@Test fun `dimension without active variant is omitted silently`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") { // no defaultTo
variant("prod") { field("X", "y") }
}
field("ALWAYS", "here")
}
""".trimIndent())
// No konfig.dimension.env property → dimension omitted, no crash
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedBuildKonfig().readText()
assertFalse(text.contains("object Env"))
assertTrue(text.contains("""const val ALWAYS: String = "here""""))
}
@Test fun `variant field debug override applied inside dimension`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("dev") {
field("URL", "https://dev.example.com").debug("https://dev-debug.example.com")
}
}
}
""".trimIndent())
run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG", "-Pkonfig.dimension.env=dev"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("""const val URL: String = "https://dev-debug.example.com""""))
}
@Test fun `missing field in variant does not appear in output`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("a") { field("ONLY_IN_A", true) }
variant("b") { /* ONLY_IN_A not defined here */ }
}
}
""".trimIndent())
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=b"))
val text = dir.generatedBuildKonfig().readText()
assertFalse(text.contains("ONLY_IN_A"))
}
// ─── Caching ─────────────────────────────────────────────────────────────
@Test fun `task is UP-TO-DATE on second run`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("FOO", "bar") }
""".trimIndent())
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE")
run(args)
val second = run(args)
assertEquals(TaskOutcome.UP_TO_DATE, second.task(":generateKonfig")?.outcome)
}
// ─── Logging & validation ─────────────────────────────────────────────────
@Test fun `build type source is logged at lifecycle level`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {}
""".trimIndent())
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
assertTrue(result.output.contains("BUILD_TYPE = debug"))
assertTrue(result.output.contains("explicit property -Pkonfig.buildtype=DEBUG"))
}
@Test fun `dimension active variant and reason are logged`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("X", "y") }
variant("dev") { field("X", "z") }
}
}
""".trimIndent())
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=dev"))
assertTrue(result.output.contains("dim 'env'"))
assertTrue(result.output.contains("'dev'"))
assertTrue(result.output.contains("konfig.dimension.env=dev"))
}
@Test fun `skipped dimension is logged`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") { // no defaultTo, no property set
variant("prod") { field("X", "y") }
}
}
""".trimIndent())
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("dim 'env'"))
assertTrue(result.output.contains("skipped"))
}
@Test fun `unknown variant in property emits a warning`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("X", "y") }
}
}
""".trimIndent())
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=typo"))
assertTrue(result.output.contains("typo"))
assertTrue(result.output.contains("not a known variant"))
}
@Test fun `invalid defaultTo fails build with clear error`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env", defaultTo = "nonexistent") {
variant("prod") { field("X", "y") }
}
}
""".trimIndent())
val result = runCatching {
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
}
assertTrue(result.isFailure || result.getOrNull()?.output?.contains("nonexistent") == true)
}
@Test fun `invalid field name fails build with clear error`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("INVALID NAME", "oops")
}
""".trimIndent())
val result = runCatching {
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
}
assertTrue(result.isFailure)
}
@Test fun `generation summary is logged at lifecycle level`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env", defaultTo = "prod") {
variant("prod") { field("URL", "https://prod.example.com") }
}
field("API_KEY", "abc123")
}
""".trimIndent())
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
// Summary line always present
assertTrue(result.output.contains("generated BuildKonfig.kt"))
assertTrue(result.output.contains("1 global field"))
assertTrue(result.output.contains("1 dimension"))
}
// ─── Generated file header ────────────────────────────────────────────────
@Test fun `generated file contains Suppress RedundantVisibilityModifier header`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {}
""".trimIndent())
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("""@file:Suppress("RedundantVisibilityModifier")"""))
}
// ─── VARIANT visibility ───────────────────────────────────────────────────
@Test fun `VARIANT Object has visibility prefix when objectVisibility is INTERNAL`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
objectVisibility = com.bitsycore.konfig.Visibility.INTERNAL
dimension("env", defaultTo = "prod") {
variant("prod") { field("X", "y") }
}
}
""".trimIndent())
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("internal object Env"))
}
// ─── Duplicate name detection ─────────────────────────────────────────────
@Test fun `duplicate dimension name fails build with clear error`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") { variant("prod") { field("X", "y") } }
dimension("env") { variant("dev") { field("X", "z") } }
}
""".trimIndent())
val result = runCatching {
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
}
assertTrue(result.isFailure || result.getOrNull()?.output?.contains("env") == true)
}
@Test fun `duplicate global field name fails build with clear error`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("API_URL", "https://prod.example.com")
field("API_URL", "https://dev.example.com")
}
""".trimIndent())
val result = runCatching {
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
}
assertTrue(result.isFailure)
}
@Test fun `duplicate variant field name fails build with clear error`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") {
field("URL", "https://a.example.com")
field("URL", "https://b.example.com")
}
}
}
""".trimIndent())
val result = runCatching {
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
}
assertTrue(result.isFailure)
}
// ─── konfig.properties file ───────────────────────────────────────────────
@Test fun `konfig_properties file sets dimension variant as fallback`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("SERVER_URL", "https://prod.example.com") }
variant("dev") { field("SERVER_URL", "https://dev.example.com") }
}
}
""".trimIndent())
dir.resolve("konfig.properties").writeText("konfig.dimension.env=dev\n")
// No -P property — should pick up from konfig.properties
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("""const val VARIANT: String = "dev""""))
assertTrue(text.contains("""const val SERVER_URL: String = "https://dev.example.com""""))
}
@Test fun `gradle property overrides konfig_properties file`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("SERVER_URL", "https://prod.example.com") }
variant("dev") { field("SERVER_URL", "https://dev.example.com") }
}
}
""".trimIndent())
dir.resolve("konfig.properties").writeText("konfig.dimension.env=dev\n")
// -P property takes priority over konfig.properties
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("""const val VARIANT: String = "prod""""))
}
@Test fun `konfig_properties file change invalidates cache`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("SERVER_URL", "https://prod.example.com") }
variant("dev") { field("SERVER_URL", "https://dev.example.com") }
}
}
""".trimIndent())
val propsFile = dir.resolve("konfig.properties")
propsFile.writeText("konfig.dimension.env=dev\n")
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE")
run(args)
propsFile.writeText("konfig.dimension.env=prod\n")
val second = run(args)
// After changing the file, task should NOT be UP-TO-DATE
assertFalse(second.task(":generateKonfig")?.outcome == TaskOutcome.UP_TO_DATE)
val text = dir.generatedBuildKonfig().readText()
assertTrue(text.contains("""const val VARIANT: String = "prod""""))
}
// ─── Validation completeness ──────────────────────────────────────────────
@Test fun `invalid field name in boolean dimension field fails build`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") {
field("INVALID NAME", true)
}
}
}
""".trimIndent())
val result = runCatching {
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
}
assertTrue(result.isFailure)
}
// ─── Force regen ──────────────────────────────────────────────────────────
@Test fun `konfig force prevents task from being UP-TO-DATE on second run`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""".trimIndent())
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.force")
run(args)
val second = run(args)
assertFalse(
second.task(":generateKonfig")?.outcome == TaskOutcome.UP_TO_DATE,
"Expected generateKonfig to re-run when -Pkonfig.force is set, but it was UP_TO_DATE"
)
}
@Test fun `without konfig force task is UP-TO-DATE on second run`() = withProject { dir, run ->
dir.resolve("build.gradle.kts").writeText("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
""".trimIndent())
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE")
run(args)
val second = run(args)
assertEquals(
TaskOutcome.UP_TO_DATE,
second.task(":generateKonfig")?.outcome,
"Expected generateKonfig to be UP_TO_DATE on second run without -Pkonfig.force"
)
}
}
@@ -0,0 +1,150 @@
package com.bitsycore.konfig
import org.gradle.testkit.runner.TaskOutcome
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* Functional tests for `konfig.properties` file support:
* file-based variant selection, Gradle property priority override,
* and cache invalidation when the file changes.
*/
class KonfigPropertiesFunctionalTest : FunctionalTestBase() {
private fun buildScript() = """
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("SERVER_URL", "https://prod.example.com") }
variant("dev") { field("SERVER_URL", "https://dev.example.com") }
variant("staging") { field("SERVER_URL", "https://staging.example.com") }
}
}
"""
// ── Basic file reading ────────────────────────────────────────────────────
@Test fun `konfig_properties sets dimension variant`() = withProject { dir, run ->
dir.writeBuildGradle(buildScript())
dir.resolve("konfig.properties").writeText("konfig.dimension.env=dev\n")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("""const val VARIANT: String = "dev""""))
assertTrue(text.contains("""const val SERVER_URL: String = "https://dev.example.com""""))
}
@Test fun `konfig_properties sets staging variant`() = withProject { dir, run ->
dir.writeBuildGradle(buildScript())
dir.resolve("konfig.properties").writeText("konfig.dimension.env=staging\n")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText()
.contains("""const val VARIANT: String = "staging""""))
}
// ── Gradle property takes priority over file ──────────────────────────────
@Test fun `Gradle property overrides konfig_properties`() = withProject { dir, run ->
dir.writeBuildGradle(buildScript())
dir.resolve("konfig.properties").writeText("konfig.dimension.env=dev\n")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("""const val VARIANT: String = "prod""""))
assertTrue(text.contains("prod.example.com"))
}
// ── No konfig.properties → falls through to defaultTo or skipped ─────────
@Test fun `without konfig_properties and no property dimension uses defaultTo`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env", defaultTo = "prod") {
variant("prod") { field("SERVER_URL", "https://prod.example.com") }
variant("dev") { field("SERVER_URL", "https://dev.example.com") }
}
}
""")
// no konfig.properties, no -P property
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText()
.contains("""const val VARIANT: String = "prod""""))
}
// ── Cache invalidation ────────────────────────────────────────────────────
@Test fun `task re-runs after konfig_properties file content changes`() = withProject { dir, run ->
dir.writeBuildGradle(buildScript())
val propsFile = dir.resolve("konfig.properties")
propsFile.writeText("konfig.dimension.env=dev\n")
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE")
run(args)
propsFile.writeText("konfig.dimension.env=prod\n")
val second = run(args)
assertNotEquals(TaskOutcome.UP_TO_DATE, second.task(":generateKonfig")?.outcome)
assertTrue(dir.generatedFile().readText().contains("""const val VARIANT: String = "prod""""))
}
@Test fun `task is UP-TO-DATE when konfig_properties file is unchanged`() = withProject { dir, run ->
dir.writeBuildGradle(buildScript())
dir.resolve("konfig.properties").writeText("konfig.dimension.env=dev\n")
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE")
run(args)
assertEquals(TaskOutcome.UP_TO_DATE, run(args).task(":generateKonfig")?.outcome)
}
@Test fun `adding konfig_properties after first run triggers re-execution`() = withProject { dir, run ->
dir.writeBuildGradle(buildScript())
// First run: no properties file, dimension skipped
val args = listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE")
run(args)
// Now add the file
dir.resolve("konfig.properties").writeText("konfig.dimension.env=prod\n")
val second = run(args)
assertNotEquals(TaskOutcome.UP_TO_DATE, second.task(":generateKonfig")?.outcome)
assertTrue(dir.generatedFile().readText().contains("""const val VARIANT: String = "prod""""))
}
// ── Multiple dimensions in properties file ────────────────────────────────
@Test fun `konfig_properties can set multiple dimensions`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("SERVER", "https://prod.example.com") }
variant("dev") { field("SERVER", "https://dev.example.com") }
}
dimension("region") {
variant("eu") { field("CDN", "https://eu-cdn.example.com") }
variant("us") { field("CDN", "https://us-cdn.example.com") }
}
}
""")
dir.resolve("konfig.properties").writeText(
"konfig.dimension.env=dev\nkonfig.dimension.region=us\n"
)
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("""const val SERVER: String = "https://dev.example.com""""))
assertTrue(text.contains("""const val CDN: String = "https://us-cdn.example.com""""))
}
}
@@ -0,0 +1,211 @@
package com.bitsycore.konfig
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Functional tests for lifecycle logging and build-time validation errors:
* BUILD_TYPE source message, dimension resolution log, skipped-dimension log,
* unknown variant warning, invalid defaultTo error, invalid field names,
* and generation summary.
*/
class LoggingAndValidationFunctionalTest : FunctionalTestBase() {
// ── BUILD_TYPE lifecycle log ──────────────────────────────────────────────
@Test fun `build type source is logged at lifecycle level`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=DEBUG"))
assertTrue(result.output.contains("BUILD_TYPE = debug"))
assertTrue(result.output.contains("explicit property -Pkonfig.buildtype=DEBUG"))
}
@Test fun `build type defaults to release and logs reason`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {}
""")
// No -Pkonfig.buildtype → should fall back to RELEASE
val result = run(listOf("generateKonfig"))
assertTrue(result.output.contains("BUILD_TYPE = release"))
}
// ── Dimension resolution log ──────────────────────────────────────────────
@Test fun `active dimension variant and reason are logged`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("X", "y") }
variant("dev") { field("X", "z") }
}
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=dev"))
assertTrue(result.output.contains("dim 'env'"))
assertTrue(result.output.contains("'dev'"))
assertTrue(result.output.contains("konfig.dimension.env=dev"))
}
// ── Skipped dimension log ─────────────────────────────────────────────────
@Test fun `skipped dimension is logged at lifecycle level`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("X", "y") }
}
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("dim 'env'"))
assertTrue(result.output.contains("skipped"))
}
// ── Unknown variant warning ───────────────────────────────────────────────
@Test fun `unknown variant in property emits a warning`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("X", "y") }
}
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=typo"))
assertTrue(result.output.contains("typo"))
assertTrue(result.output.contains("not a known variant"))
}
// ── Generation summary ────────────────────────────────────────────────────
@Test fun `generation summary is logged with field and dimension counts`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env", defaultTo = "prod") {
variant("prod") { field("URL", "https://prod.example.com") }
}
field("API_KEY", "abc123")
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("generated BuildKonfig.kt"))
assertTrue(result.output.contains("1 global field"))
assertTrue(result.output.contains("1 dimension"))
}
@Test fun `generation summary says no global fields when none declared`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("no global fields"))
}
@Test fun `generation summary says no dimensions when none declared`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig { field("K", "v") }
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("no dimensions"))
}
@Test fun `generation summary counts multiple global fields correctly`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("A", "1")
field("B", "2")
field("C", "3")
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("3 global field"))
}
// ── Validation errors ─────────────────────────────────────────────────────
@Test fun `invalid global field name fails build`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("INVALID NAME", "oops")
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("INVALID NAME") || result.output.contains("not a valid Kotlin identifier"))
}
@Test fun `invalid field name starting with digit fails build`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("1INVALID", "oops")
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("1INVALID") || result.output.contains("not a valid Kotlin identifier"))
}
@Test fun `invalid field name in dimension variant fails build`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") {
field("INVALID NAME", true)
}
}
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE", "-Pkonfig.dimension.env=prod"))
assertTrue(result.output.contains("INVALID NAME") || result.output.contains("not a valid Kotlin identifier"))
}
@Test fun `invalid defaultTo variant fails build with error message`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env", defaultTo = "nonexistent") {
variant("prod") { field("X", "y") }
}
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("nonexistent"))
}
@Test fun `invalid objectName fails build`() = withFailingProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
objectName = "123Invalid"
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("123Invalid") || result.output.contains("not a valid Kotlin identifier"))
}
}
@@ -0,0 +1,99 @@
package com.bitsycore.konfig
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Functional tests for task-name variant detection edge cases:
* prod vs preprod differentiation, camelCase flavors, and the konfigInfo
* task always logging the selection (even on cached builds).
*/
class VariantDetectionFunctionalTest : FunctionalTestBase() {
private val buildScript = """
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("URL", "https://prod.example.com") }
variant("preprod") { field("URL", "https://preprod.example.com") }
}
}
tasks.register("assemblePreprodRelease") { dependsOn("generateKonfig") }
tasks.register("assembleProdRelease") { dependsOn("generateKonfig") }
"""
@Test fun `preprod task selects preprod not prod`() = withProject { dir, run ->
dir.writeBuildGradle(buildScript)
val result = run(listOf("assemblePreprodRelease"))
assertTrue(result.output.contains("dim 'env' -> 'preprod'"), "expected preprod selection:\n${result.output}")
val content = dir.generatedFile().readText()
assertTrue(content.contains("https://preprod.example.com"), "wrong URL generated:\n$content")
}
@Test fun `prod task selects prod not ambiguous`() = withProject { dir, run ->
dir.writeBuildGradle(buildScript)
val result = run(listOf("assembleProdRelease"))
assertTrue(result.output.contains("dim 'env' -> 'prod'"), "expected prod selection:\n${result.output}")
val content = dir.generatedFile().readText()
assertTrue(content.contains("https://prod.example.com"), "wrong URL generated:\n$content")
}
@Test fun `camelCase flavor preProd wins longest match over prod`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env") {
variant("prod") { field("URL", "https://prod.example.com") }
variant("preProd") { field("URL", "https://preprod.example.com") }
}
}
tasks.register("assemblePreProdRelease") { dependsOn("generateKonfig") }
""")
val result = run(listOf("assemblePreProdRelease"))
assertTrue(result.output.contains("dim 'env' -> 'preProd'"), "expected preProd selection:\n${result.output}")
}
@Test fun `selection is logged even when generateKonfig is up-to-date`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
field("X", "y")
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
// Second run: generateKonfig is UP-TO-DATE, but konfigInfo must still log.
val second = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(second.output.contains("UP-TO-DATE"), "expected cached generate task:\n${second.output}")
assertTrue(second.output.contains("BUILD_TYPE = release"), "selection must be logged on cached builds:\n${second.output}")
}
@Test fun `isDebug and getCurrentDimension are usable in build scripts`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
dimension("env", defaultTo = "prod") {
variant("prod") { field("X", "y") }
}
}
tasks.register("konfigQuery") {
dependsOn("generateKonfig")
val debug = konfig.isDebug
val env = konfig.getCurrentDimension("env")
val missing = konfig.getCurrentDimension("nope")
doLast {
println("QUERY isDebug=" + debug)
println("QUERY env=" + env)
println("QUERY missing=" + missing)
}
}
""")
val result = run(listOf("konfigQuery", "-Pkonfig.buildtype=DEBUG"))
assertTrue(result.output.contains("QUERY isDebug=true"), result.output)
assertTrue(result.output.contains("QUERY env=prod"), result.output)
assertTrue(result.output.contains("QUERY missing=null"), result.output)
}
}
@@ -0,0 +1,154 @@
package com.bitsycore.konfig
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Functional tests for object visibility and custom naming:
* [objectVisibility] (PUBLIC / INTERNAL), [objectName], [objectPackage],
* and the interaction between visibility and nested dimension objects.
*/
class VisibilityAndNamingFunctionalTest : FunctionalTestBase() {
// ── Default visibility (PUBLIC) ───────────────────────────────────────────
@Test fun `default visibility emits public object`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("public object BuildKonfig {"))
}
// ── INTERNAL visibility ───────────────────────────────────────────────────
@Test fun `INTERNAL visibility prefixes top-level object`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
objectVisibility = com.bitsycore.konfig.types.Visibility.INTERNAL
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("internal object BuildKonfig {"))
}
@Test fun `INTERNAL visibility also prefixes nested dimension object`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
objectVisibility = com.bitsycore.konfig.types.Visibility.INTERNAL
dimension("env", defaultTo = "prod") {
variant("prod") { field("X", "y") }
}
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("internal object Env"))
}
@Test fun `INTERNAL visibility does not appear twice for top-level object`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
objectVisibility = com.bitsycore.konfig.types.Visibility.INTERNAL
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
// "internal object BuildKonfig" must appear exactly once
val count = text.split("internal object BuildKonfig").size - 1
assertTrue(count == 1, "Expected exactly one 'internal object BuildKonfig', found $count")
}
// ── Custom objectName ─────────────────────────────────────────────────────
@Test fun `custom objectName replaces BuildKonfig`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
objectName = "AppConfig"
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val text = dir.generatedFile().readText()
assertTrue(text.contains("object AppConfig {"))
assertFalse(text.contains("object BuildKonfig"))
}
@Test fun `custom objectName is reflected in generated file name`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
objectName = "Cfg"
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val file = dir.generatedFile()
assertTrue(file.name == "Cfg.kt", "Expected file name 'Cfg.kt', got '${file.name}'")
}
@Test fun `custom objectName combined with INTERNAL visibility`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
objectName = "MyConf"
objectVisibility = com.bitsycore.konfig.types.Visibility.INTERNAL
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("internal object MyConf {"))
}
// ── Custom objectPackage ──────────────────────────────────────────────────
@Test fun `custom objectPackage overrides derived package`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
objectPackage = "org.myapp.generated"
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(dir.generatedFile().readText().contains("package org.myapp.generated"))
}
@Test fun `custom objectPackage is reflected in output directory path`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
objectPackage = "io.test.pkg"
}
""")
run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
val file = dir.generatedFile()
// The file's parent path should reflect the package segments
assertTrue(file.path.contains("io") && file.path.contains("test") && file.path.contains("pkg"),
"Expected package path in '${file.path}'")
}
// ── generation summary log ────────────────────────────────────────────────
@Test fun `generation summary references correct object name`() = withProject { dir, run ->
dir.writeBuildGradle("""
plugins { id("com.bitsycore.konfig") }
group = "com.example"
konfig {
objectName = "MyConfig"
}
""")
val result = run(listOf("generateKonfig", "-Pkonfig.buildtype=RELEASE"))
assertTrue(result.output.contains("MyConfig.kt"))
}
}
@@ -1,11 +1,14 @@
package com.bitsycore.konfig
import com.bitsycore.konfig.types.BuildType
import com.bitsycore.konfig.types.Visibility
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputDirectory
@@ -37,11 +40,10 @@ abstract class GenerateKonfigTask : DefaultTask() {
@get:Input abstract val objectName: Property<String>
@get:Input abstract val objectVisibility: Property<Visibility>
/** Human-readable explanation of why the current build type was chosen. */
@get:Input abstract val buildTypeSource: Property<String>
/**
* Resolution log for every declared dimension, keyed by dimension name.
* Resolution log entries with the ERROR tag only (configuration errors), keyed by
* dimension name. Full logs (with task-name-dependent reasons) live on [KonfigInfoTask]
* so they don't bust this task's up-to-date check.
* Each value is tab-separated: `"<TAG>\t<variant>\t<reason>"`.
*/
@get:Input abstract val dimensionResolutionLog: MapProperty<String, String>
@@ -59,6 +61,8 @@ abstract class GenerateKonfigTask : DefaultTask() {
/** Ordered list of active dimension names. */
@get:Input abstract val activeDimensionNames: ListProperty<String>
/** Names of dimensions declared with `flatDimension` (fields emitted at the root). */
@get:Input abstract val flatDimensionNames: SetProperty<String>
/** `dimName -> Kotlin object name`. */
@get:Input abstract val dimensionObjectNames: MapProperty<String, String>
/** `dimName -> selected variant`. */
@@ -84,7 +88,6 @@ abstract class GenerateKonfigTask : DefaultTask() {
val isDebug = btVal == BuildType.DEBUG
validate(mod, objName, pkg)
logResolution(mod, btVal, objName, pkg)
val outDir = outputDirectory.get().asFile
if (outDir.exists()) outDir.deleteRecursively()
@@ -96,6 +99,9 @@ abstract class GenerateKonfigTask : DefaultTask() {
val dimNames = activeDimensionNames.get()
val dimObjN = dimensionObjectNames.get()
val dimVars = dimensionActiveVariants.get()
val flatDims = flatDimensionNames.get()
checkRootCollisions(mod, dimNames.filter { it in flatDims }, gFields, dFields, dimVars)
val content = buildString {
appendLine("""@file:Suppress("RedundantVisibilityModifier")""")
@@ -121,22 +127,32 @@ abstract class GenerateKonfigTask : DefaultTask() {
}
for (dimName in dimNames) {
val dimObjName = dimObjN[dimName] ?: continue
val activeVariant = dimVars[dimName] ?: continue
val prefix = "$dimName|"
val fields = dFields
.filterKeys { it.startsWith(prefix) }
.mapKeys { (k, _) -> k.removePrefix(prefix) }
appendLine()
appendLine(" ${visPrefix}object $dimObjName /*$dimName*/ {")
appendLine()
appendLine(" const val VARIANT: String = \"$activeVariant\"")
if (fields.isNotEmpty()) {
if (dimName in flatDims) {
// Flat dimension: fields live directly on the root object.
appendLine()
appendEncodedFields(" ", fields, btVal)
appendLine(" // dimension: $dimName (flat), variant: $activeVariant")
appendLine(" const val ${dimName.toVariantConstName()}: String = \"$activeVariant\"")
if (fields.isNotEmpty()) {
appendEncodedFields(" ", fields, btVal)
}
} else {
val dimObjName = dimObjN[dimName] ?: continue
appendLine()
appendLine(" ${visPrefix}object $dimObjName /*$dimName*/ {")
appendLine()
appendLine(" const val VARIANT: String = \"$activeVariant\"")
if (fields.isNotEmpty()) {
appendLine()
appendEncodedFields(" ", fields, btVal)
}
appendLine(" }")
}
appendLine(" }")
logger.info("konfig [$mod]: dim '$dimName' fields: ${fields.keys.sorted().joinToString()}")
}
@@ -156,7 +172,9 @@ abstract class GenerateKonfigTask : DefaultTask() {
logger.info("konfig [$mod]: output -> ${outFile.absolutePath}")
}
// ── Validation ────────────────────────────────────────────────────────────
// ==============================================================================
// MARK: Validation
// ==============================================================================
private fun validate(mod: String, objName: String, pkg: String) {
val errors = mutableListOf<String>()
@@ -193,37 +211,60 @@ abstract class GenerateKonfigTask : DefaultTask() {
}
}
// ── Logging ───────────────────────────────────────────────────────────────
// ==============================================================================
// MARK: Flat dimension collision detection
// ==============================================================================
private fun logResolution(mod: String, btVal: BuildType, objName: String, pkg: String) {
logger.lifecycle("konfig [$mod]: BUILD_TYPE = ${btVal.name.lowercase()} (${buildTypeSource.get()})")
/**
* Fails the build when a flat dimension would generate a root-level name that
* already exists (base constants, global fields, or another flat dimension).
*/
private fun checkRootCollisions(
mod: String,
activeFlatDims: List<String>,
gFields: Map<String, String>,
dFields: Map<String, String>,
dimVars: Map<String, String>,
) {
if (activeFlatDims.isEmpty()) return
val resolutionLog = dimensionResolutionLog.get()
if (resolutionLog.isEmpty()) {
logger.info("konfig [$mod]: no dimensions declared")
} else {
val maxDimLen = resolutionLog.keys.maxOf { it.length }
resolutionLog.entries
.sortedBy { (n, enc) -> "${if (enc.startsWith("OK")) "0" else "1"}_$n" }
.forEach { (dimName, encoded) ->
val parts = encoded.split("\t", limit = 3)
val tag = parts[0]
val variant = parts.getOrElse(1) { "" }
val reason = parts.getOrElse(2) { "" }
val padded = dimName.padEnd(maxDimLen)
when (tag) {
"OK" -> logger.lifecycle("konfig [$mod]: dim '$padded' -> '$variant' ($reason)")
"SKIP" -> logger.lifecycle("konfig [$mod]: dim '$padded' -> skipped ($reason)")
"WARN_UNKNOWN", "WARN_AMBIGUOUS" -> logger.warn("konfig [$mod]: dim '$dimName' -- $reason")
"ERROR" -> logger.error("konfig [$mod]: dim '$dimName' -- ERROR: $reason")
}
val owners = mutableMapOf<String, String>()
listOf("BUILD_TYPE", "MODULE_NAME", "IS_DEBUG").forEach { owners[it] = "built-in constant" }
gFields.keys.forEach { owners[it] = "global field" }
val errors = mutableListOf<String>()
for (dimName in activeFlatDims) {
if (dimVars[dimName] == null) continue
val prefix = "$dimName|"
val rootNames = buildList {
add(dimName.toVariantConstName())
addAll(dFields.keys.filter { it.startsWith(prefix) }.map { it.removePrefix(prefix) })
}
for (name in rootNames) {
val existing = owners[name]
if (existing != null) {
errors += "flat dimension '$dimName' generates '$name' which collides with $existing"
} else {
owners[name] = "flat dimension '$dimName'"
}
}
}
logger.info("konfig [$mod]: object = $pkg.$objName")
if (errors.isNotEmpty()) {
throw GradleException(buildString {
appendLine("konfig [$mod]: flat dimension name collisions:")
errors.forEach { appendLine(" - $it") }
}.trimEnd())
}
}
// ── Code-generation helpers ───────────────────────────────────────────────
/** `"my-env"` → `"MY_ENV_VARIANT"` — root constant holding the active variant of a flat dimension. */
private fun String.toVariantConstName(): String =
uppercase().replace(Regex("[^A-Z0-9]"), "_") + "_VARIANT"
// ==============================================================================
// MARK: Helpers
// ==============================================================================
/**
* Appends `const val` lines for each entry in [fields].
@@ -1,5 +1,12 @@
package com.bitsycore.konfig
import com.bitsycore.konfig.configs.BuildTypedFieldDeclScope
import com.bitsycore.konfig.configs.DimensionConfig
import com.bitsycore.konfig.configs.FieldConfig
import com.bitsycore.konfig.configs.VariantConfig
import com.bitsycore.konfig.types.BuildType
import com.bitsycore.konfig.types.KonfigDsl
import com.bitsycore.konfig.types.Visibility
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property
@@ -8,18 +15,20 @@ import javax.inject.Inject
@KonfigDsl
abstract class KonfigExtension @Inject constructor(
private val objects: ObjectFactory,
objects: ObjectFactory,
) {
// ── Backing Gradle properties (used by the plugin for lazy wiring) ────────
// ==============================================================================
// MARK: Settings Internal
// ==============================================================================
internal val objectPackageProp: Property<String> = objects.property(String::class.java)
internal val objectNameProp: Property<String> = objects.property(String::class.java)
internal val objectVisibilityProp: Property<Visibility> = objects.property(Visibility::class.java)
/** Output directory — kept as [DirectoryProperty] for full Gradle lazy semantics. */
val outputDir: DirectoryProperty = objects.directoryProperty()
// ── User-facing var properties ────────────────────────────────────────────
// ==============================================================================
// MARK: User facing Settings
// ==============================================================================
/** Package for the generated object (e.g. `"com.example.app"`). */
var objectPackage: String
@@ -36,18 +45,25 @@ abstract class KonfigExtension @Inject constructor(
get() = objectVisibilityProp.get()
set(value) = objectVisibilityProp.set(value)
// ── DSL internals ─────────────────────────────────────────────────────────
/** Output directory — kept as [DirectoryProperty] for full Gradle lazy semantics. */
val outputDir: DirectoryProperty = objects.directoryProperty()
// ==============================================================================
// MARK: DSL Internal
// ==============================================================================
@PublishedApi
internal val dimensions: MutableList<DimensionConfig> = mutableListOf()
/** Backing store for global fields — reuses [VariantConfig] for its field/debug/release logic. */
/** Backing store for global fields — reuses [com.bitsycore.konfig.configs.VariantConfig] for its field/debug/release logic. */
@PublishedApi
internal val globalScope: VariantConfig = VariantConfig("\$global")
internal val globalFields: List<FieldConfig<*>> get() = globalScope.fields
// ─── Dimension DSL ────────────────────────────────────────────────────────
// ==============================================================================
// MARK: Top Level DSL
// ==============================================================================
fun dimension(
name: String,
@@ -63,7 +79,26 @@ abstract class KonfigExtension @Inject constructor(
dimensions.add(d)
}
// ─── Global field DSL — delegates to globalScope ──────────────────────────
/**
* Declares a dimension whose fields are generated directly at the root of the
* konfig object instead of inside a nested `object`.
*
* The selected variant is exposed as `<NAME>_VARIANT` (dimension name uppercased).
* Name collisions with global fields, base constants or other flat dimensions
* fail the build.
*/
fun flatDimension(
name: String,
defaultTo: String? = null,
config: DimensionConfig.() -> Unit
) {
require(dimensions.none { it.dimensionName == name }) {
"konfig: dimension '$name' is already declared"
}
val d = DimensionConfig(name, objectNameOverride = null, defaultVariant = defaultTo, flat = true)
config(d)
dimensions.add(d)
}
inline fun <reified T : Any> field(
name: String,
@@ -77,4 +112,46 @@ abstract class KonfigExtension @Inject constructor(
fun debug(block: BuildTypedFieldDeclScope.() -> Unit) = globalScope.debug(block)
fun release(block: BuildTypedFieldDeclScope.() -> Unit) = globalScope.release(block)
// ==============================================================================
// MARK: Build-script queries
// ==============================================================================
/** Wired by the plugin at apply time — same resolution chain as the generated object. */
internal lateinit var buildTypeProviderInternal: Provider<BuildType>
/** Wired by the plugin at apply time — resolves a dimension with the same recognition logic. */
internal lateinit var dimensionResolverInternal: (String) -> Provider<String>
/** Resolved build type as a lazy provider (`"debug"` / `"release"`). */
val currentBuildType: Provider<String>
get() = buildTypeProviderInternal.map { it.value }
/** Lazy provider variant of [isDebug], for provider-based wiring. */
val isDebugProvider: Provider<Boolean>
get() = buildTypeProviderInternal.map { it == BuildType.DEBUG }
/**
* True when the resolved build type is debug — uses the exact same recognition
* as the generated object, so it can drive build-script decisions such as
* KMP `debugImplementation`-style wiring:
*
* ```kotlin
* dependencies {
* if (konfig.isDebug) implementation(project(":debugImpl"))
* else implementation(project(":releaseImpl"))
* }
* ```
*/
val isDebug: Boolean
get() = isDebugProvider.get()
/**
* Lazy provider for the active variant of dimension [name].
* The provider has no value when the dimension is skipped or unknown.
*/
fun currentDimension(name: String): Provider<String> = dimensionResolverInternal(name)
/** Active variant of dimension [name], or `null` when skipped/unknown. */
fun getCurrentDimension(name: String): String? = currentDimension(name).orNull
}
@@ -0,0 +1,62 @@
package com.bitsycore.konfig
import com.bitsycore.konfig.types.BuildType
import org.gradle.api.DefaultTask
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.TaskAction
import org.gradle.work.DisableCachingByDefault
/**
* Logs the resolved build type and dimension variants on EVERY build.
*
* [GenerateKonfigTask] only logs when it actually executes, so a cached /
* up-to-date build would silently hide which variant is active. This task is
* untracked (never up-to-date, never cached) and `generateKonfig` depends on
* it, guaranteeing the selection is always visible in the logs — even on a
* fully cached build with the configuration cache enabled.
*/
@DisableCachingByDefault(because = "pure logging task, must run on every build")
abstract class KonfigInfoTask : DefaultTask() {
@get:Internal abstract val moduleName: Property<String>
@get:Internal abstract val buildType: Property<BuildType>
@get:Internal abstract val buildTypeSource: Property<String>
/** Resolution log per dimension: `"<TAG>\t<variant>\t<reason>"`. */
@get:Internal abstract val dimensionResolutionLog: MapProperty<String, String>
init {
// Never skipped: no declared outputs + explicit upToDateWhen false.
outputs.upToDateWhen { false }
}
@TaskAction
fun report() {
val mod = moduleName.get()
logger.lifecycle("konfig [$mod]: BUILD_TYPE = ${buildType.get().name.lowercase()} (${buildTypeSource.get()})")
val resolutionLog = dimensionResolutionLog.get()
if (resolutionLog.isEmpty()) {
logger.info("konfig [$mod]: no dimensions declared")
return
}
val maxDimLen = resolutionLog.keys.maxOf { it.length }
resolutionLog.entries
.sortedBy { (n, enc) -> "${if (enc.startsWith("OK")) "0" else "1"}_$n" }
.forEach { (dimName, encoded) ->
val parts = encoded.split("\t", limit = 3)
val tag = parts[0]
val variant = parts.getOrElse(1) { "" }
val reason = parts.getOrElse(2) { "" }
val padded = dimName.padEnd(maxDimLen)
when (tag) {
"OK" -> logger.lifecycle("konfig [$mod]: dim '$padded' -> '$variant' ($reason)")
"SKIP" -> logger.lifecycle("konfig [$mod]: dim '$padded' -> skipped ($reason)")
"WARN_UNKNOWN", "WARN_AMBIGUOUS" -> logger.warn("konfig [$mod]: dim '$dimName' -- $reason")
"ERROR" -> logger.error("konfig [$mod]: dim '$dimName' -- ERROR: $reason")
}
}
}
}
@@ -2,7 +2,12 @@
package com.bitsycore.konfig
import com.android.build.api.dsl.CommonExtension
import com.android.build.api.variant.AndroidComponentsExtension
import com.bitsycore.konfig.configs.DimensionConfig
import com.bitsycore.konfig.configs.FieldConfig
import com.bitsycore.konfig.types.BuildType
import com.bitsycore.konfig.types.Visibility
import com.bitsycore.konfig.types.containsWordCamelCase
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.provider.Provider
@@ -128,20 +133,55 @@ class KonfigPlugin : Plugin<Project> {
}
}
// =========================================================================
// MARK: Build-script query wiring (konfig.isDebug / konfig.getCurrentDimension)
// =========================================================================
extension.buildTypeProviderInternal = buildTypeProvider
extension.dimensionResolverInternal = { dimName ->
combinedProps.map { ctx ->
extension.dimensions.firstOrNull { it.dimensionName == dimName }?.let { dim ->
resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames)
}
}
}
// =========================================================================
// MARK: Task Registration
// =========================================================================
val forceRegen = project.providers.gradleProperty("konfig.force").isPresent
// Untracked logging task: runs on EVERY build (even fully cached ones) so the
// selected build type / dimension variants always appear in the logs.
val infoTask = project.tasks.register("konfigInfo", KonfigInfoTask::class.java) {
description = "Prints the resolved konfig build type and dimension variants."
group = "konfig"
moduleName.set(project.name)
buildType.set(buildTypeProvider)
buildTypeSource.set(buildTypeSourceProvider)
dimensionResolutionLog.set(dimensionResolutionLogProvider)
}
val generateTask = project.tasks.register("generateKonfig", GenerateKonfigTask::class.java).apply {
configure {
if (forceRegen) outputs.upToDateWhen { false }
dependsOn(infoTask)
moduleName.set(project.name)
buildType.set(buildTypeProvider)
buildTypeSource.set(buildTypeSourceProvider)
dimensionResolutionLog.set(dimensionResolutionLogProvider)
// Only ERROR entries: full logs (task-name dependent) are on konfigInfo,
// keeping this task's inputs stable across invocations.
dimensionResolutionLog.set(
dimensionResolutionLogProvider.map { log ->
log.filterValues { it.startsWith("ERROR") }
}
)
flatDimensionNames.set(
project.providers.provider {
extension.dimensions.filter { it.flat }.map { it.dimensionName }.toSet()
}
)
outputDirectory.set(extension.outputDir)
objectPackage.set(extension.objectPackageProp)
objectName.set(extension.objectNameProp)
@@ -198,9 +238,15 @@ class KonfigPlugin : Plugin<Project> {
}
listOf("com.android.application", "com.android.library").forEach { androidPluginId ->
project.plugins.withId(androidPluginId) {
@Suppress("UnstableApiUsage")
(project.extensions.findByName("android") as? CommonExtension<*, *, *, *>)
?.sourceSets?.findByName("main")?.kotlin?.srcDir(extension.outputDir)
// AGP 9.2 flips android.sourceset.disallowProvider to true: passing a
// provider (like a DirectoryProperty) to the AndroidSourceSet DSL fails.
// Register the generated directory through the variant Sources API instead.
project.extensions.findByType(AndroidComponentsExtension::class.java)
?.onVariants { variant ->
val vOutDir = extension.outputDir.get().asFile
vOutDir.mkdirs()
variant.sources.kotlin?.addStaticSourceDirectory(vOutDir.absolutePath)
}
}
}
@@ -292,9 +338,16 @@ class KonfigPlugin : Plugin<Project> {
// Priority 3: task-name detection
if (flavorDetect && taskNames.isNotEmpty()) {
val matches = dim.variants.keys.filter { variant ->
taskNames.any { task -> task.contains(variant, ignoreCase = true) }
val rawMatches = dim.variants.keys.filter { variant ->
taskNames.any { task -> task.containsWordCamelCase(variant) }
}
// When every match is a substring of the longest one (e.g. 'prod' inside
// 'preProd' for task assemblePreProdRelease), the longest is the real one.
val matches = if (rawMatches.size > 1) {
val longest = rawMatches.maxBy { it.length }
if (rawMatches.all { it == longest || longest.contains(it, ignoreCase = true) }) listOf(longest)
else rawMatches
} else rawMatches
when (matches.size) {
1 -> return "OK\t${matches.first()}\ttask-name detection: '${matches.first()}' " +
"found in [${taskNames.joinToString()}]"
@@ -360,7 +413,9 @@ class KonfigPlugin : Plugin<Project> {
return field.fieldName to encoded
}
// ── Merge helpers ─────────────────────────────────────────────────────────
// ==============================================================================
// MARK: Fields Merge
// ==============================================================================
/**
* Returns a merged list of fields: [commonFields] as the base, with any field in
@@ -375,7 +430,9 @@ class KonfigPlugin : Plugin<Project> {
return commonFields.filter { it.fieldName !in variantNames } + variantFields
}
// ── Package-name helpers ──────────────────────────────────────────────────
// ==============================================================================
// MARK: Package Utilities
// ==============================================================================
private fun defaultPackageName(projectName: String, projectGroup: String): String {
val group = projectGroup
@@ -1,4 +1,6 @@
package com.bitsycore.konfig
package com.bitsycore.konfig.configs
import com.bitsycore.konfig.types.KonfigDsl
/**
* DSL scope for a dimension declaration.
@@ -26,6 +28,8 @@ class DimensionConfig @PublishedApi internal constructor(
val dimensionName: String,
val objectNameOverride: String?,
val defaultVariant: String?,
/** When true, fields are generated at the root of the konfig object instead of a nested object. */
val flat: Boolean = false,
) {
/** All named variants. */
@PublishedApi
@@ -35,9 +39,11 @@ class DimensionConfig @PublishedApi internal constructor(
* Shared fields that apply to **all** variants as a fallback.
* A variant field with the same name takes precedence over a common field.
*/
internal val commonConfig: VariantConfig = VariantConfig("\$common")
internal val commonConfig: VariantConfig = VariantConfig($$"$common")
// ── DSL ──────────────────────────────────────────────────────────────────
// ==============================================================================
// MARK: DSL
// ==============================================================================
/**
* Declares a named variant. Can be called multiple times for the same name
@@ -1,6 +1,10 @@
package com.bitsycore.konfig
package com.bitsycore.konfig.configs
import com.bitsycore.konfig.types.BuildType
import org.gradle.api.Transformer
import org.gradle.api.provider.Provider
import org.gradle.api.specs.Spec
import java.util.function.BiFunction
/**
* Holds the resolved configuration for a single generated field.
@@ -53,14 +57,14 @@ private class ConstantProvider<T : Any>(private val value: T) : Provider<T> {
override fun getOrElse(defaultValue: T): T = value
override fun isPresent(): Boolean = true
override fun <S : Any> map(transformer: org.gradle.api.Transformer<out S?, in T>): Provider<S> =
override fun <S : Any> map(transformer: Transformer<out S?, in T>): Provider<S> =
ConstantProvider(transformer.transform(value)!!)
override fun <S : Any> flatMap(transformer: org.gradle.api.Transformer<out Provider<out S>?, in T>): Provider<S> =
override fun <S : Any> flatMap(transformer: Transformer<out Provider<out S>?, in T>): Provider<S> =
@Suppress("UNCHECKED_CAST")
(transformer.transform(value) as Provider<S>)
override fun filter(spec: org.gradle.api.specs.Spec<in T>): Provider<T> =
override fun filter(spec: Spec<in T>): Provider<T> =
if (spec.isSatisfiedBy(value)) this else throw NoSuchElementException("Provider has no value")
override fun orElse(value: T): Provider<T> = this
@@ -71,6 +75,6 @@ private class ConstantProvider<T : Any>(private val value: T) : Provider<T> {
override fun <B : Any, R : Any> zip(
right: Provider<B>,
combiner: java.util.function.BiFunction<in T, in B, out R?>
combiner: BiFunction<in T, in B, out R?>
): Provider<R> = ConstantProvider(combiner.apply(value, right.get())!!)
}
@@ -1,8 +1,12 @@
package com.bitsycore.konfig
package com.bitsycore.konfig.configs
import com.bitsycore.konfig.types.BuildType
import com.bitsycore.konfig.types.KonfigDsl
import org.gradle.api.provider.Provider
// ── Fluent handle returned by top-level field() declarations ─────────────────
// ==============================================================================
// MARK: FieldHandle with modifier
// ==============================================================================
/**
* Returned by `field(name, default)` when called at the top level of a [VariantConfig].
@@ -14,17 +18,18 @@ import org.gradle.api.provider.Provider
* ```
* Not available inside `debug {}` / `release {}` blocks — those return [Unit].
*/
@KonfigDsl
class FieldHandle<T : Any> @PublishedApi internal constructor(
@PublishedApi internal val field: FieldConfig<T>
) {
fun debug(value: T): Unit { field.debug(value) }
fun debug(value: Provider<T>): Unit { field.debug(value) }
fun release(value: T): Unit { field.release(value) }
fun release(value: Provider<T>): Unit { field.release(value) }
fun debug(value: T): Unit = field.debug(value)
fun debug(value: Provider<T>): Unit = field.debug(value)
fun release(value: T): Unit = field.release(value)
fun release(value: Provider<T>): Unit = field.release(value)
}
// ── Scope for fields declared inside debug {} / release {} blocks ─────────────
// ==============================================================================
// MARK: BuildType Scope
// ==============================================================================
/**
* Receiver of `debug { ... }` and `release { ... }` blocks inside [VariantConfig].
@@ -46,7 +51,9 @@ class BuildTypedFieldDeclScope @PublishedApi internal constructor(
}
}
// ── Main variant / common scope ───────────────────────────────────────────────
// ==============================================================================
// MARK: Variant
// ==============================================================================
/**
* DSL scope for a variant (or the `common {}` block inside a dimension).
@@ -1,4 +1,4 @@
package com.bitsycore.konfig
package com.bitsycore.konfig.types
import kotlin.enums.enumEntries
@@ -1,4 +1,4 @@
package com.bitsycore.konfig
package com.bitsycore.konfig.types
@DslMarker
annotation class KonfigDsl
@@ -0,0 +1,26 @@
package com.bitsycore.konfig.types
/**
* Case-insensitive word match respecting camelCase segment boundaries.
*
* A candidate occurrence only counts when it starts AND ends on a word segment
* boundary, so `"assemblePreprodRelease"` matches variant `"preprod"` but NOT
* variant `"prod"` (which is a plain substring of `Preprod`), and
* `"assembleDevelopRelease"` does not match variant `"dev"`.
*
* Boundary rules for an occurrence at index `i` of length `n` in [this]:
* - start: `i == 0`, or the previous char is not a letter/digit, or `this[i]` is uppercase
* - end: the match reaches the end, or the next char is not a letter/digit, or is uppercase
*/
internal fun String.containsWordCamelCase(word: String): Boolean {
if (word.isEmpty()) return false
var vIndex = indexOf(word, 0, ignoreCase = true)
while (vIndex >= 0) {
val vStartOk = vIndex == 0 || !this[vIndex - 1].isLetterOrDigit() || this[vIndex].isUpperCase()
val vEnd = vIndex + word.length
val vEndOk = vEnd >= length || !this[vEnd].isLetterOrDigit() || this[vEnd].isUpperCase()
if (vStartOk && vEndOk) return true
vIndex = indexOf(word, vIndex + 1, ignoreCase = true)
}
return false
}
@@ -1,4 +1,4 @@
package com.bitsycore.konfig
package com.bitsycore.konfig.types
enum class Visibility {
PUBLIC, INTERNAL
@@ -0,0 +1,173 @@
package com.bitsycore.konfig
import com.bitsycore.konfig.types.BuildType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Full coverage for [BuildType.resolve].
* Covers exact enum-name matching, lowercase keywords, typical Android / KMP task
* names, ambiguous inputs, false-positive substring guards, edge cases, and the
* [BuildType.value] property.
*/
class BuildTypeExtendedTest {
// ── Enum name resolution ─────────────────────────────────────────────────
@Test fun `exact uppercase DEBUG resolves to DEBUG`() {
assertEquals(BuildType.DEBUG, BuildType.resolve("DEBUG"))
}
@Test fun `exact uppercase RELEASE resolves to RELEASE`() {
assertEquals(BuildType.RELEASE, BuildType.resolve("RELEASE"))
}
@Test fun `mixed case DeBuG does not match enum name and falls through to regex`() {
// Not an exact enum name match; regex will catch it via "Debug" pattern
assertNull(BuildType.resolve("DeBuG"))
}
// ── Lowercase keyword ────────────────────────────────────────────────────
@Test fun `standalone lowercase debug resolves DEBUG`() {
assertEquals(BuildType.DEBUG, BuildType.resolve("debug"))
}
@Test fun `standalone lowercase release resolves RELEASE`() {
assertEquals(BuildType.RELEASE, BuildType.resolve("release"))
}
// ── Typical Android task names ───────────────────────────────────────────
@Test fun `assembleDebug resolves DEBUG`() {
assertEquals(BuildType.DEBUG, BuildType.resolve("assembleDebug"))
}
@Test fun `assembleRelease resolves RELEASE`() {
assertEquals(BuildType.RELEASE, BuildType.resolve("assembleRelease"))
}
@Test fun `compileDebugKotlin resolves DEBUG`() {
assertEquals(BuildType.DEBUG, BuildType.resolve("compileDebugKotlin"))
}
@Test fun `compileReleaseKotlin resolves RELEASE`() {
assertEquals(BuildType.RELEASE, BuildType.resolve("compileReleaseKotlin"))
}
@Test fun `bundleReleaseAar resolves RELEASE`() {
assertEquals(BuildType.RELEASE, BuildType.resolve("bundleReleaseAar"))
}
@Test fun `generateDebugSources resolves DEBUG`() {
assertEquals(BuildType.DEBUG, BuildType.resolve("generateDebugSources"))
}
@Test fun `processDebugResources resolves DEBUG`() {
assertEquals(BuildType.DEBUG, BuildType.resolve("processDebugResources"))
}
@Test fun `processReleaseResources resolves RELEASE`() {
assertEquals(BuildType.RELEASE, BuildType.resolve("processReleaseResources"))
}
@Test fun `mergeDebugAssets resolves DEBUG`() {
assertEquals(BuildType.DEBUG, BuildType.resolve("mergeDebugAssets"))
}
@Test fun `mergeReleaseAssets resolves RELEASE`() {
assertEquals(BuildType.RELEASE, BuildType.resolve("mergeReleaseAssets"))
}
@Test fun `packageDebug resolves DEBUG`() {
assertEquals(BuildType.DEBUG, BuildType.resolve("packageDebug"))
}
@Test fun `packageRelease resolves RELEASE`() {
assertEquals(BuildType.RELEASE, BuildType.resolve("packageRelease"))
}
// ── KMP / JVM task names ─────────────────────────────────────────────────
@Test fun `compileKotlinJvm returns null`() {
assertNull(BuildType.resolve("compileKotlinJvm"))
}
@Test fun `compileKotlin returns null`() {
assertNull(BuildType.resolve("compileKotlin"))
}
@Test fun `jar returns null`() {
assertNull(BuildType.resolve("jar"))
}
@Test fun `test returns null`() {
assertNull(BuildType.resolve("test"))
}
// ── Ambiguous task names (both keywords present) ─────────────────────────
@Test fun `debugRelease is ambiguous returns null`() {
assertNull(BuildType.resolve("debugRelease"))
}
@Test fun `releaseDebug is ambiguous returns null`() {
assertNull(BuildType.resolve("releaseDebug"))
}
@Test fun `compileDebugReleaseKotlin is ambiguous returns null`() {
assertNull(BuildType.resolve("compileDebugReleaseKotlin"))
}
// ── False-positive guards (words that contain debug/release as substring) ─
@Test fun `debuggable does not resolve DEBUG`() {
// "debuggable" has lowercase 'g' immediately after "debug" → regex rejects it
assertNull(BuildType.resolve("debuggable"))
}
@Test fun `prereleased does not resolve RELEASE`() {
// "release" is preceded by lowercase 'e' in "prere[lease]" — but "release"
// starts after "prere", let's verify actual behaviour via the regex:
// lookbehind (?<![a-z]) fails because char before 'r' of "release" is 'e'
assertNull(BuildType.resolve("prereleased"))
}
@Test fun `released does not resolve RELEASE`() {
// "released" — 'd' after "release" is NOT a lowercase letter... wait,
// actually the regex checks (?![a-z]) so 'd' fails the lookahead.
assertNull(BuildType.resolve("released"))
}
@Test fun `debugMode resolves DEBUG`() {
// 'M' after "debug" — uppercase, not [a-z], so lookahead passes
assertEquals(BuildType.DEBUG, BuildType.resolve("debugMode"))
}
// ── Empty / whitespace ───────────────────────────────────────────────────
@Test fun `empty string returns null`() {
assertNull(BuildType.resolve(""))
}
@Test fun `blank string returns null`() {
assertNull(BuildType.resolve(" "))
}
// ── Value property ───────────────────────────────────────────────────────
@Test fun `DEBUG value is debug`() {
assertEquals("debug", BuildType.DEBUG.value)
}
@Test fun `RELEASE value is release`() {
assertEquals("release", BuildType.RELEASE.value)
}
// ── Enum entries count ───────────────────────────────────────────────────
@Test fun `exactly two build types exist`() {
assertEquals(2, BuildType.entries.size)
}
}
@@ -0,0 +1,160 @@
package com.bitsycore.konfig
import com.bitsycore.konfig.configs.BuildTypedFieldDeclScope
import com.bitsycore.konfig.configs.VariantConfig
import com.bitsycore.konfig.configs.constantProvider
import com.bitsycore.konfig.types.BuildType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Unit tests for [BuildTypedFieldDeclScope].
*
* This scope is the receiver inside `debug { }` / `release { }` blocks on a
* [VariantConfig]. It pins the [BuildType] and delegates to
* [VariantConfig.getOrCreateField] so overrides land on the correct entry.
*/
class BuildTypedFieldDeclScopeTest {
private fun makeScope(buildType: BuildType, variantName: String = "v"): Pair<BuildTypedFieldDeclScope, VariantConfig> {
val owner = VariantConfig(variantName)
val scope = BuildTypedFieldDeclScope(buildType, owner)
return scope to owner
}
// ── buildType is stored ──────────────────────────────────────────────────
@Test fun `DEBUG scope stores DEBUG build type`() {
val (scope, _) = makeScope(BuildType.DEBUG)
assertEquals(BuildType.DEBUG, scope.buildType)
}
@Test fun `RELEASE scope stores RELEASE build type`() {
val (scope, _) = makeScope(BuildType.RELEASE)
assertEquals(BuildType.RELEASE, scope.buildType)
}
// ── owner is stored ──────────────────────────────────────────────────────
@Test fun `owner is the supplied VariantConfig`() {
val owner = VariantConfig("myVariant")
val scope = BuildTypedFieldDeclScope(BuildType.DEBUG, owner)
assertEquals(owner, scope.owner)
}
// ── field(name, literal) in DEBUG scope ──────────────────────────────────
@Test fun `field in DEBUG scope creates override for DEBUG`() {
val (scope, owner) = makeScope(BuildType.DEBUG)
scope.field("LOG", "verbose")
val fc = owner.fields.first { it.fieldName == "LOG" }
assertEquals("verbose", fc.resolve(BuildType.DEBUG)!!.get())
}
@Test fun `field in DEBUG scope does not set RELEASE override`() {
val (scope, owner) = makeScope(BuildType.DEBUG)
scope.field("LOG", "verbose")
val fc = owner.fields.first { it.fieldName == "LOG" }
assertNull(fc.resolve(BuildType.RELEASE))
}
// ── field(name, literal) in RELEASE scope ────────────────────────────────
@Test fun `field in RELEASE scope creates override for RELEASE`() {
val (scope, owner) = makeScope(BuildType.RELEASE)
scope.field("LOG", "error")
val fc = owner.fields.first { it.fieldName == "LOG" }
assertEquals("error", fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `field in RELEASE scope does not set DEBUG override`() {
val (scope, owner) = makeScope(BuildType.RELEASE)
scope.field("LOG", "error")
val fc = owner.fields.first { it.fieldName == "LOG" }
assertNull(fc.resolve(BuildType.DEBUG))
}
// ── field(name, provider) ────────────────────────────────────────────────
@Test fun `field with provider in DEBUG scope stores provider`() {
val (scope, owner) = makeScope(BuildType.DEBUG)
scope.field("KEY", constantProvider("devKey"))
val fc = owner.fields.first { it.fieldName == "KEY" }
assertEquals("devKey", fc.resolve(BuildType.DEBUG)!!.get())
}
@Test fun `field with provider in RELEASE scope stores provider`() {
val (scope, owner) = makeScope(BuildType.RELEASE)
scope.field("KEY", constantProvider("prodKey"))
val fc = owner.fields.first { it.fieldName == "KEY" }
assertEquals("prodKey", fc.resolve(BuildType.RELEASE)!!.get())
}
// ── getOrCreateField reuse ────────────────────────────────────────────────
@Test fun `same field name in debug and release scopes shares one FieldConfig`() {
val owner = VariantConfig("v")
val dbgScope = BuildTypedFieldDeclScope(BuildType.DEBUG, owner)
val relScope = BuildTypedFieldDeclScope(BuildType.RELEASE, owner)
dbgScope.field("LOG", "verbose")
relScope.field("LOG", "error")
// Only one FieldConfig should exist
assertEquals(1, owner.fields.size)
val fc = owner.fields[0]
assertEquals("verbose", fc.resolve(BuildType.DEBUG)!!.get())
assertEquals("error", fc.resolve(BuildType.RELEASE)!!.get())
}
// ── Multiple fields in one scope block ───────────────────────────────────
@Test fun `multiple fields in debug scope are all created`() {
val (scope, owner) = makeScope(BuildType.DEBUG)
scope.field("A", "alpha")
scope.field("B", 42)
scope.field("C", true)
assertEquals(3, owner.fields.size)
}
@Test fun `multiple fields in release scope are independent`() {
val (scope, owner) = makeScope(BuildType.RELEASE)
scope.field("X", 100)
scope.field("Y", 200)
assertEquals(100, owner.fields.first { it.fieldName == "X" }.resolve(BuildType.RELEASE)!!.get())
assertEquals(200, owner.fields.first { it.fieldName == "Y" }.resolve(BuildType.RELEASE)!!.get())
}
// ── Int / Boolean / Long scoped fields ───────────────────────────────────
@Test fun `Int field in debug scope resolves correctly`() {
val (scope, owner) = makeScope(BuildType.DEBUG)
scope.field("TIMEOUT", 5)
assertEquals(5, owner.fields[0].resolve(BuildType.DEBUG)!!.get())
}
@Test fun `Boolean field in release scope resolves correctly`() {
val (scope, owner) = makeScope(BuildType.RELEASE)
scope.field("FLAG", false)
assertEquals(false, owner.fields[0].resolve(BuildType.RELEASE)!!.get())
}
@Test fun `Long field in debug scope resolves correctly`() {
val (scope, owner) = makeScope(BuildType.DEBUG)
scope.field("BIG", 123_456_789_000L)
assertEquals(123_456_789_000L, owner.fields[0].resolve(BuildType.DEBUG)!!.get())
}
// ── Scope used via VariantConfig.debug{} / release{} ─────────────────────
@Test fun `VariantConfig debug block reaches correct scope`() {
val v = VariantConfig("test")
v.debug { field("M", "debugMsg") }
assertEquals("debugMsg", v.fields.first { it.fieldName == "M" }.resolve(BuildType.DEBUG)!!.get())
}
@Test fun `VariantConfig release block reaches correct scope`() {
val v = VariantConfig("test")
v.release { field("M", "releaseMsg") }
assertEquals("releaseMsg", v.fields.first { it.fieldName == "M" }.resolve(BuildType.RELEASE)!!.get())
}
}
@@ -0,0 +1,157 @@
package com.bitsycore.konfig
import com.bitsycore.konfig.configs.constantProvider
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Tests for [constantProvider] and the internal [ConstantProvider] it creates.
*
* The [ConstantProvider] class is private; we access its behaviour through the
* public [constantProvider] factory and the [org.gradle.api.provider.Provider] interface.
*/
class ConstantProviderTest {
// ── get / getOrNull / isPresent ──────────────────────────────────────────
@Test fun `String provider get returns value`() {
val p = constantProvider("hello")
assertEquals("hello", p.get())
}
@Test fun `Int provider get returns value`() {
val p = constantProvider(42)
assertEquals(42, p.get())
}
@Test fun `Boolean provider get returns true`() {
val p = constantProvider(true)
assertEquals(true, p.get())
}
@Test fun `Boolean provider get returns false`() {
val p = constantProvider(false)
assertEquals(false, p.get())
}
@Test fun `Long provider get returns value`() {
val p = constantProvider(Long.MAX_VALUE)
assertEquals(Long.MAX_VALUE, p.get())
}
@Test fun `Double provider get returns value`() {
val p = constantProvider(3.14)
assertEquals(3.14, p.get())
}
@Test fun `Float provider get returns value`() {
val p = constantProvider(1.5f)
assertEquals(1.5f, p.get())
}
@Test fun `isPresent is always true`() {
assertTrue(constantProvider("anything").isPresent)
}
@Test fun `getOrNull returns value not null`() {
val p = constantProvider("value")
assertNotNull(p.getOrNull())
assertEquals("value", p.getOrNull())
}
@Test fun `getOrElse returns stored value ignoring default`() {
val p = constantProvider("stored")
assertEquals("stored", p.getOrElse("fallback"))
}
// ── map ──────────────────────────────────────────────────────────────────
@Test fun `map transforms value`() {
val p = constantProvider("hello")
val mapped = p.map { it.uppercase() }
assertEquals("HELLO", mapped.get())
}
@Test fun `map on Int transforms to String`() {
val p = constantProvider(7)
val mapped = p.map { "num=$it" }
assertEquals("num=7", mapped.get())
}
@Test fun `nested map chains correctly`() {
val p = constantProvider(2)
val result = p.map { it * 3 }.map { it + 1 }
assertEquals(7, result.get())
}
// ── flatMap ──────────────────────────────────────────────────────────────
@Test fun `flatMap returns inner provider value`() {
val p = constantProvider("base")
val result = p.flatMap { constantProvider("flat:$it") }
assertEquals("flat:base", result.get())
}
// ── filter ───────────────────────────────────────────────────────────────
@Test fun `filter passes when spec is satisfied`() {
val p = constantProvider(10)
val filtered = p.filter { it > 5 }
assertEquals(10, filtered.get())
}
@Test fun `filter throws when spec is not satisfied`() {
val p = constantProvider(3)
var threw = false
try {
p.filter { it > 5 }.get()
} catch (_: NoSuchElementException) {
threw = true
}
assertTrue(threw)
}
// ── orElse ───────────────────────────────────────────────────────────────
@Test fun `orElse with value returns stored value`() {
val p = constantProvider("original")
assertEquals("original", p.orElse("other").get())
}
@Test fun `orElse with provider returns stored value`() {
val p = constantProvider("original")
assertEquals("original", p.orElse(constantProvider("other")).get())
}
// ── zip ──────────────────────────────────────────────────────────────────
@Test fun `zip combines two providers`() {
val p1 = constantProvider("Hello")
val p2 = constantProvider("World")
val zipped = p1.zip(p2) { a, b -> "$a $b" }
assertEquals("Hello World", zipped.get())
}
@Test fun `zip with Int providers produces sum`() {
val p1 = constantProvider(3)
val p2 = constantProvider(4)
val sum = p1.zip(p2) { a, b -> a + b }
assertEquals(7, sum.get())
}
// ── identity / equality ──────────────────────────────────────────────────
@Test fun `two providers with same value each return that value independently`() {
val p1 = constantProvider("same")
val p2 = constantProvider("same")
assertEquals(p1.get(), p2.get())
}
@Test fun `provider wrapping empty string returns empty string`() {
val p = constantProvider("")
assertEquals("", p.get())
assertTrue(p.isPresent)
}
}
@@ -0,0 +1,198 @@
package com.bitsycore.konfig
import com.bitsycore.konfig.configs.DimensionConfig
import com.bitsycore.konfig.types.BuildType
import kotlin.test.*
/**
* Unit tests for [DimensionConfig].
*
* Covers:
* - Construction: dimensionName, objectNameOverride, defaultVariant
* - objectName() derivation (camelCase, override, separators)
* - variant() DSL: creation, retrieval, merging
* - common() block: shared fallback fields
* - Interaction between common and variant fields (variant wins)
*/
class DimensionConfigTest {
private fun makeDim(
name: String,
objectNameOverride: String? = null,
defaultTo: String? = null,
) = DimensionConfig(name, objectNameOverride, defaultTo)
// ── Construction ─────────────────────────────────────────────────────────
@Test fun `dimensionName is stored`() {
val d = makeDim("env")
assertEquals("env", d.dimensionName)
}
@Test fun `objectNameOverride is stored when provided`() {
val d = makeDim("env", objectNameOverride = "Environment")
assertEquals("Environment", d.objectNameOverride)
}
@Test fun `objectNameOverride is null when not provided`() {
val d = makeDim("env")
assertNull(d.objectNameOverride)
}
@Test fun `defaultVariant is stored`() {
val d = makeDim("env", defaultTo = "prod")
assertEquals("prod", d.defaultVariant)
}
@Test fun `defaultVariant is null when not provided`() {
val d = makeDim("env")
assertNull(d.defaultVariant)
}
// ── objectName() ─────────────────────────────────────────────────────────
@Test fun `objectName returns override when set`() {
val d = makeDim("env", objectNameOverride = "MyEnv")
assertEquals("MyEnv", d.objectName())
}
@Test fun `objectName capitalises simple lowercase name`() {
val d = makeDim("env")
assertEquals("Env", d.objectName())
}
@Test fun `objectName capitalises each part split by dash`() {
val d = makeDim("build-env")
assertEquals("BuildEnv", d.objectName())
}
@Test fun `objectName capitalises each part split by underscore`() {
val d = makeDim("my_env")
assertEquals("MyEnv", d.objectName())
}
@Test fun `objectName capitalises each part split by space`() {
val d = makeDim("my env")
assertEquals("MyEnv", d.objectName())
}
@Test fun `objectName handles mixed separators`() {
val d = makeDim("build_my-env")
assertEquals("BuildMyEnv", d.objectName())
}
@Test fun `objectName for single uppercase word keeps first letter uppercase`() {
val d = makeDim("REGION")
assertEquals("Region", d.objectName())
}
@Test fun `objectName for already camel-case single word`() {
val d = makeDim("region")
assertEquals("Region", d.objectName())
}
// ── variant() ────────────────────────────────────────────────────────────
@Test fun `variant creates new VariantConfig`() {
val d = makeDim("env")
d.variant("prod") { field("URL", "https://prod.example.com") }
assertEquals(1, d.variants.size)
assertNotNull(d.variants["prod"])
}
@Test fun `variant stores correct variantName`() {
val d = makeDim("env")
d.variant("dev") {}
assertEquals("dev", d.variants["dev"]!!.variantName)
}
@Test fun `multiple variants are stored`() {
val d = makeDim("env")
d.variant("prod") {}
d.variant("dev") {}
d.variant("staging") {}
assertEquals(3, d.variants.size)
}
@Test fun `calling variant with same name merges fields`() {
val d = makeDim("env")
d.variant("prod") { field("URL", "https://prod.example.com") }
d.variant("prod") { field("KEY", "secret") }
val v = d.variants["prod"]!!
// Same VariantConfig instance reused — two fields total
assertEquals(2, v.fields.size)
}
@Test fun `variant fields are accessible via VariantConfig`() {
val d = makeDim("env")
d.variant("prod") { field("URL", "https://prod.example.com") }
val url = d.variants["prod"]!!.fields[0].resolve(BuildType.RELEASE)!!.get()
assertEquals("https://prod.example.com", url)
}
// ── common() ─────────────────────────────────────────────────────────────
@Test fun `common block adds fields to commonConfig`() {
val d = makeDim("env")
d.common { field("TIMEOUT", 30) }
assertEquals(1, d.commonConfig.fields.size)
assertEquals("TIMEOUT", d.commonConfig.fields[0].fieldName)
}
@Test fun `common block can be called multiple times and merges`() {
val d = makeDim("env")
d.common { field("TIMEOUT", 30) }
d.common { field("RETRIES", 3) }
assertEquals(2, d.commonConfig.fields.size)
}
@Test fun `common block supports debug scope`() {
val d = makeDim("env")
d.common {
field("TIMEOUT", 30)
debug { field("TIMEOUT", 5) }
}
val fc = d.commonConfig.fields.first { it.fieldName == "TIMEOUT" }
assertEquals(5, fc.resolve(BuildType.DEBUG)!!.get())
assertEquals(30, fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `common block supports release scope`() {
val d = makeDim("env")
d.common {
field("LOG", "info")
release { field("LOG", "error") }
}
val fc = d.commonConfig.fields.first { it.fieldName == "LOG" }
assertEquals("info", fc.resolve(BuildType.DEBUG)!!.get())
assertEquals("error", fc.resolve(BuildType.RELEASE)!!.get())
}
// ── Fresh dimension has empty collections ─────────────────────────────────
@Test fun `fresh DimensionConfig has no variants`() {
val d = makeDim("env")
assertTrue(d.variants.isEmpty())
}
@Test fun `fresh DimensionConfig commonConfig has no fields`() {
val d = makeDim("env")
assertTrue(d.commonConfig.fields.isEmpty())
}
// ── Dimension with only common (no variants) ──────────────────────────────
@Test fun `dimension with only common block and no variants is valid`() {
val d = makeDim("env")
d.common { field("BASE_URL", "https://default.example.com") }
assertEquals(0, d.variants.size)
assertEquals(1, d.commonConfig.fields.size)
}
// ── objectName for dimension used as nested object ────────────────────────
@Test fun `objectName is consistent on repeated calls`() {
val d = makeDim("my-env")
assertEquals(d.objectName(), d.objectName())
}
}
@@ -0,0 +1,172 @@
package com.bitsycore.konfig
import com.bitsycore.konfig.configs.FieldConfig
import com.bitsycore.konfig.configs.constantProvider
import com.bitsycore.konfig.types.BuildType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/**
* Unit tests for [FieldConfig]: construction, default resolution,
* build-type overrides (literal and provider), and resolve() priority.
*/
class FieldConfigTest {
// ── Construction ─────────────────────────────────────────────────────────
@Test fun `fieldName is stored correctly`() {
val fc = FieldConfig("MY_FIELD", String::class.java, null)
assertEquals("MY_FIELD", fc.fieldName)
}
@Test fun `type is stored as javaObjectType`() {
val fc = FieldConfig("F", Int::class.javaObjectType, null)
assertEquals(Int::class.javaObjectType, fc.type)
}
@Test fun `default provider is null when not supplied`() {
val fc = FieldConfig("F", String::class.java, null)
// resolve with no overrides and no default → null
assertNull(fc.resolve(BuildType.DEBUG))
assertNull(fc.resolve(BuildType.RELEASE))
}
// ── Default resolution ───────────────────────────────────────────────────
@Test fun `resolve returns default when no override for DEBUG`() {
val fc = FieldConfig("F", String::class.java, constantProvider("default"))
val resolved = fc.resolve(BuildType.DEBUG)
assertNotNull(resolved)
assertEquals("default", resolved.get())
}
@Test fun `resolve returns default when no override for RELEASE`() {
val fc = FieldConfig("F", String::class.java, constantProvider("default"))
val resolved = fc.resolve(BuildType.RELEASE)
assertNotNull(resolved)
assertEquals("default", resolved.get())
}
// ── BuildType override via literal value ─────────────────────────────────
@Test fun `debug(literal) overrides for DEBUG`() {
val fc = FieldConfig("F", String::class.java, constantProvider("default"))
fc.debug("debugValue")
assertEquals("debugValue", fc.resolve(BuildType.DEBUG)!!.get())
}
@Test fun `debug(literal) does not affect RELEASE`() {
val fc = FieldConfig("F", String::class.java, constantProvider("default"))
fc.debug("debugValue")
assertEquals("default", fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `release(literal) overrides for RELEASE`() {
val fc = FieldConfig("F", String::class.java, constantProvider("default"))
fc.release("releaseValue")
assertEquals("releaseValue", fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `release(literal) does not affect DEBUG`() {
val fc = FieldConfig("F", String::class.java, constantProvider("default"))
fc.release("releaseValue")
assertEquals("default", fc.resolve(BuildType.DEBUG)!!.get())
}
@Test fun `both debug and release overrides are stored independently`() {
val fc = FieldConfig("F", String::class.java, constantProvider("default"))
fc.debug("d")
fc.release("r")
assertEquals("d", fc.resolve(BuildType.DEBUG)!!.get())
assertEquals("r", fc.resolve(BuildType.RELEASE)!!.get())
}
// ── BuildType override via Provider ──────────────────────────────────────
@Test fun `debug(provider) stores provider for DEBUG`() {
val fc = FieldConfig("F", String::class.java, constantProvider("default"))
fc.debug(constantProvider("fromProvider"))
assertEquals("fromProvider", fc.resolve(BuildType.DEBUG)!!.get())
}
@Test fun `release(provider) stores provider for RELEASE`() {
val fc = FieldConfig("F", String::class.java, constantProvider("default"))
fc.release(constantProvider("releaseProvider"))
assertEquals("releaseProvider", fc.resolve(BuildType.RELEASE)!!.get())
}
// ── Override replaces previous override ─────────────────────────────────
@Test fun `second debug call overwrites first`() {
val fc = FieldConfig("F", String::class.java, constantProvider("default"))
fc.debug("first")
fc.debug("second")
assertEquals("second", fc.resolve(BuildType.DEBUG)!!.get())
}
@Test fun `second release call overwrites first`() {
val fc = FieldConfig("F", String::class.java, constantProvider("default"))
fc.release("first")
fc.release("second")
assertEquals("second", fc.resolve(BuildType.RELEASE)!!.get())
}
// ── Int / Boolean / Long field types ────────────────────────────────────
@Test fun `Int field resolves default correctly`() {
val fc = FieldConfig("TIMEOUT", Int::class.javaObjectType, constantProvider(30))
assertEquals(30, fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `Int field debug override resolves correctly`() {
val fc = FieldConfig("TIMEOUT", Int::class.javaObjectType, constantProvider(30))
fc.debug(5)
assertEquals(5, fc.resolve(BuildType.DEBUG)!!.get())
assertEquals(30, fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `Boolean field default resolves correctly`() {
val fc = FieldConfig("FLAG", Boolean::class.javaObjectType, constantProvider(true))
assertEquals(true, fc.resolve(BuildType.DEBUG)!!.get())
assertEquals(true, fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `Boolean field release override resolves correctly`() {
val fc = FieldConfig("FLAG", Boolean::class.javaObjectType, constantProvider(true))
fc.release(false)
assertEquals(true, fc.resolve(BuildType.DEBUG)!!.get())
assertEquals(false, fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `Long field default resolves correctly`() {
val fc = FieldConfig("BIG", Long::class.javaObjectType, constantProvider(9_999_999_999L))
assertEquals(9_999_999_999L, fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `Double field default resolves correctly`() {
val fc = FieldConfig("PI", Double::class.javaObjectType, constantProvider(3.14))
assertEquals(3.14, fc.resolve(BuildType.DEBUG)!!.get())
}
@Test fun `Float field default resolves correctly`() {
val fc = FieldConfig("F", Float::class.javaObjectType, constantProvider(1.5f))
assertEquals(1.5f, fc.resolve(BuildType.DEBUG)!!.get())
}
// ── Override map is initially empty ─────────────────────────────────────
@Test fun `buildTypeOverrides is empty on fresh FieldConfig`() {
val fc = FieldConfig("F", String::class.java, null)
assertEquals(0, fc.buildTypeOverrides.size)
}
@Test fun `buildTypeOverrides grows as overrides are set`() {
val fc = FieldConfig("F", String::class.java, constantProvider("x"))
fc.debug("d")
assertEquals(1, fc.buildTypeOverrides.size)
fc.release("r")
assertEquals(2, fc.buildTypeOverrides.size)
}
}
@@ -0,0 +1,147 @@
package com.bitsycore.konfig
import com.bitsycore.konfig.configs.FieldConfig
import com.bitsycore.konfig.configs.FieldHandle
import com.bitsycore.konfig.configs.constantProvider
import com.bitsycore.konfig.types.BuildType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* Unit tests for [FieldHandle].
*
* [FieldHandle] is a thin wrapper that delegates directly to the underlying
* [FieldConfig]. These tests verify that every overload correctly mutates the
* backing [FieldConfig] and that the resolver sees those changes.
*/
class FieldHandleTest {
private fun stringHandle(default: String = "default"): Pair<FieldHandle<String>, FieldConfig<String>> {
val fc = FieldConfig("F", String::class.java, constantProvider(default))
return FieldHandle(fc) to fc
}
private fun intHandle(default: Int = 0): Pair<FieldHandle<Int>, FieldConfig<Int>> {
val fc = FieldConfig("N", Int::class.javaObjectType, constantProvider(default))
return FieldHandle(fc) to fc
}
// ── debug(literal) ───────────────────────────────────────────────────────
@Test fun `debug literal sets DEBUG override`() {
val (handle, fc) = stringHandle()
handle.debug("devValue")
assertEquals("devValue", fc.resolve(BuildType.DEBUG)!!.get())
}
@Test fun `debug literal does not touch RELEASE`() {
val (handle, fc) = stringHandle("default")
handle.debug("devValue")
assertEquals("default", fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `debug literal on Int handle sets DEBUG override`() {
val (handle, fc) = intHandle(30)
handle.debug(5)
assertEquals(5, fc.resolve(BuildType.DEBUG)!!.get())
assertEquals(30, fc.resolve(BuildType.RELEASE)!!.get())
}
// ── release(literal) ─────────────────────────────────────────────────────
@Test fun `release literal sets RELEASE override`() {
val (handle, fc) = stringHandle()
handle.release("prodValue")
assertEquals("prodValue", fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `release literal does not touch DEBUG`() {
val (handle, fc) = stringHandle("default")
handle.release("prodValue")
assertEquals("default", fc.resolve(BuildType.DEBUG)!!.get())
}
// ── debug(provider) ──────────────────────────────────────────────────────
@Test fun `debug provider sets DEBUG override`() {
val (handle, fc) = stringHandle()
handle.debug(constantProvider("fromProvider"))
assertEquals("fromProvider", fc.resolve(BuildType.DEBUG)!!.get())
}
@Test fun `debug provider does not touch RELEASE`() {
val (handle, fc) = stringHandle("default")
handle.debug(constantProvider("fromProvider"))
assertEquals("default", fc.resolve(BuildType.RELEASE)!!.get())
}
// ── release(provider) ────────────────────────────────────────────────────
@Test fun `release provider sets RELEASE override`() {
val (handle, fc) = stringHandle()
handle.release(constantProvider("relProv"))
assertEquals("relProv", fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `release provider does not touch DEBUG`() {
val (handle, fc) = stringHandle("default")
handle.release(constantProvider("relProv"))
assertEquals("default", fc.resolve(BuildType.DEBUG)!!.get())
}
// ── Both overrides set ───────────────────────────────────────────────────
@Test fun `debug and release both set resolve independently`() {
val (handle, fc) = stringHandle("default")
handle.debug("d")
handle.release("r")
assertEquals("d", fc.resolve(BuildType.DEBUG)!!.get())
assertEquals("r", fc.resolve(BuildType.RELEASE)!!.get())
}
// ── Handle wraps correct FieldConfig ─────────────────────────────────────
@Test fun `handle field property points to the same FieldConfig`() {
val fc = FieldConfig("F", String::class.java, constantProvider("x"))
val handle = FieldHandle(fc)
// Mutation through handle should be visible via fc
handle.debug("changed")
assertEquals("changed", fc.resolve(BuildType.DEBUG)!!.get())
}
// ── No-override scenarios ────────────────────────────────────────────────
@Test fun `handle with no debug or release set returns null for scope-only field`() {
val fc = FieldConfig<String>("F", String::class.java, null)
@Suppress("UNUSED_VARIABLE") val handle = FieldHandle(fc)
// No overrides set — resolve returns null
assertNull(fc.resolve(BuildType.DEBUG))
assertNull(fc.resolve(BuildType.RELEASE))
}
// ── Override replacement ─────────────────────────────────────────────────
@Test fun `second debug call replaces first override`() {
val (handle, fc) = stringHandle()
handle.debug("first")
handle.debug("second")
assertEquals("second", fc.resolve(BuildType.DEBUG)!!.get())
}
@Test fun `second release call replaces first override`() {
val (handle, fc) = stringHandle()
handle.release("first")
handle.release("second")
assertEquals("second", fc.resolve(BuildType.RELEASE)!!.get())
}
// ── Provider and literal mixed ────────────────────────────────────────────
@Test fun `literal debug then provider debug - provider wins`() {
val (handle, fc) = stringHandle()
handle.debug("literal")
handle.debug(constantProvider("provider"))
assertEquals("provider", fc.resolve(BuildType.DEBUG)!!.get())
}
}
@@ -1,40 +0,0 @@
package com.bitsycore.konfig
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class KonfigTypeTest {
@Test fun `exact enum name resolves DEBUG`() {
assertEquals(BuildType.DEBUG, BuildType.resolve("DEBUG"))
}
@Test fun `exact enum name resolves RELEASE`() {
assertEquals(BuildType.RELEASE, BuildType.resolve("RELEASE"))
}
@Test fun `task name containing debug resolves DEBUG`() {
assertEquals(BuildType.DEBUG, BuildType.resolve("compileDebugKotlin"))
}
@Test fun `task name containing release resolves RELEASE`() {
assertEquals(BuildType.RELEASE, BuildType.resolve("assembleRelease"))
}
@Test fun `ambiguous input returns null`() {
assertNull(BuildType.resolve("debugRelease"))
}
@Test fun `unrelated string returns null`() {
assertNull(BuildType.resolve("compileKotlinJvm"))
}
@Test fun `lowercase debug resolves DEBUG`() {
assertEquals(BuildType.DEBUG, BuildType.resolve("debug"))
}
@Test fun `lowercase release resolves RELEASE`() {
assertEquals(BuildType.RELEASE, BuildType.resolve("release"))
}
}
@@ -0,0 +1,62 @@
package com.bitsycore.konfig
import com.bitsycore.konfig.types.containsWordCamelCase
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Tests for camelCase-boundary word matching used by dimension variant detection.
* The critical property: variants that are substrings of other variants
* (prod / preprod) must never cross-match.
*/
class TaskNameMatchingTest {
// ── Prod vs Preprod differentiation ─────────────────────────────────────────
@Test fun `preprod task does not match prod variant`() {
assertFalse("assemblePreprodRelease".containsWordCamelCase("prod"))
assertFalse(":app:assemblePreprodDebug".containsWordCamelCase("prod"))
}
@Test fun `preprod task matches preprod variant`() {
assertTrue("assemblePreprodRelease".containsWordCamelCase("preprod"))
assertTrue(":app:assemblePreprodDebug".containsWordCamelCase("preprod"))
}
@Test fun `prod task matches prod variant only`() {
assertTrue("assembleProdRelease".containsWordCamelCase("prod"))
assertFalse("assembleProdRelease".containsWordCamelCase("preprod"))
}
// ── CamelCase variant names ─────────────────────────────────────────────────
@Test fun `camelCase variant matches its camelCase segment`() {
assertTrue("assemblePreProdRelease".containsWordCamelCase("preProd"))
// "Prod" is a legitimate camelCase segment inside PreProd — the resolver's
// longest-match rule (tested functionally) disambiguates this case.
assertTrue("assemblePreProdRelease".containsWordCamelCase("prod"))
}
@Test fun `prefix variant does not match longer word`() {
assertFalse("assembleDevelopRelease".containsWordCamelCase("dev"))
assertTrue("assembleDevRelease".containsWordCamelCase("dev"))
}
// ── Boundaries ──────────────────────────────────────────────────────────────
@Test fun `matches at string start and end`() {
assertTrue("prodRelease".containsWordCamelCase("prod"))
assertTrue("assembleProd".containsWordCamelCase("prod"))
assertTrue("prod".containsWordCamelCase("prod"))
}
@Test fun `matches with non-letter separators`() {
assertTrue("app:prod:assemble".containsWordCamelCase("prod"))
assertTrue("assemble-prod-release".containsWordCamelCase("prod"))
}
@Test fun `empty word never matches`() {
assertFalse("assembleProdRelease".containsWordCamelCase(""))
}
}
@@ -0,0 +1,219 @@
package com.bitsycore.konfig
import com.bitsycore.konfig.configs.VariantConfig
import com.bitsycore.konfig.configs.constantProvider
import com.bitsycore.konfig.types.BuildType
import kotlin.test.*
/**
* Unit tests for [VariantConfig].
*
* Covers:
* - Basic field declaration (literal and provider default)
* - Duplicate field guard
* - [FieldHandle] fluent API wired through the config
* - debug {} / release {} block scopes
* - getOrCreateField (creates on first call, retrieves on subsequent calls)
* - Multiple fields coexist without interference
*/
class VariantConfigTest {
private fun makeVariant(name: String = "test") = VariantConfig(name)
// ── variantName ──────────────────────────────────────────────────────────
@Test fun `variantName is stored`() {
val v = makeVariant("prod")
assertEquals("prod", v.variantName)
}
// ── field with literal default ────────────────────────────────────────────
@Test fun `field with String default is added to fields list`() {
val v = makeVariant()
v.field("URL", "https://example.com")
assertEquals(1, v.fields.size)
assertEquals("URL", v.fields[0].fieldName)
}
@Test fun `field with Int default is added`() {
val v = makeVariant()
v.field("TIMEOUT", 30)
assertEquals("TIMEOUT", v.fields[0].fieldName)
}
@Test fun `field with Boolean default is added`() {
val v = makeVariant()
v.field("LOGS_ENABLED", true)
assertEquals("LOGS_ENABLED", v.fields[0].fieldName)
}
@Test fun `field with Long default is added`() {
val v = makeVariant()
v.field("BIG_NUM", 9_000_000_000L)
assertEquals(9_000_000_000L, v.fields[0].resolve(BuildType.RELEASE)!!.get())
}
@Test fun `field with Double default is added`() {
val v = makeVariant()
v.field("PI", 3.14159)
assertEquals(3.14159, v.fields[0].resolve(BuildType.DEBUG)!!.get())
}
@Test fun `field with Float default is added`() {
val v = makeVariant()
v.field("F", 2.0f)
assertEquals(2.0f, v.fields[0].resolve(BuildType.DEBUG)!!.get())
}
// ── field with Provider default ───────────────────────────────────────────
@Test fun `field with provider default stores provider`() {
val v = makeVariant()
v.field("API_KEY", constantProvider("secret"))
val resolved = v.fields[0].resolve(BuildType.RELEASE)
assertNotNull(resolved)
assertEquals("secret", resolved.get())
}
// ── FieldHandle returned by field() ──────────────────────────────────────
@Test fun `field returns non-null FieldHandle`() {
val v = makeVariant()
val handle = v.field("X", "val")
assertNotNull(handle)
}
@Test fun `FieldHandle debug sets debug override`() {
val v = makeVariant()
v.field("URL", "prod").debug("dev")
val fc = v.fields[0]
assertEquals("dev", fc.resolve(BuildType.DEBUG)!!.get())
assertEquals("prod", fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `FieldHandle release sets release override`() {
val v = makeVariant()
v.field("URL", "default").release("rel")
val fc = v.fields[0]
assertEquals("default", fc.resolve(BuildType.DEBUG)!!.get())
assertEquals("rel", fc.resolve(BuildType.RELEASE)!!.get())
}
@Test fun `FieldHandle debug and release can be chained independently`() {
val v = makeVariant()
val handle = v.field("LOG", "info")
handle.debug("verbose")
handle.release("error")
val fc = v.fields[0]
assertEquals("verbose", fc.resolve(BuildType.DEBUG)!!.get())
assertEquals("error", fc.resolve(BuildType.RELEASE)!!.get())
}
// ── Duplicate field guard ────────────────────────────────────────────────
@Test fun `declaring duplicate field name throws IllegalArgumentException`() {
val v = makeVariant()
v.field("DUPE", "first")
assertFailsWith<IllegalArgumentException> {
v.field("DUPE", "second")
}
}
@Test fun `duplicate guard error message contains field name`() {
val v = makeVariant("myVariant")
v.field("DUPE", "first")
val ex = assertFailsWith<IllegalArgumentException> {
v.field("DUPE", "second")
}
assertTrue(ex.message!!.contains("DUPE"))
}
@Test fun `duplicate guard error message contains variant name`() {
val v = makeVariant("myVariant")
v.field("DUPE", "first")
val ex = assertFailsWith<IllegalArgumentException> {
v.field("DUPE", "second")
}
assertTrue(ex.message!!.contains("myVariant"))
}
@Test fun `fields with different names do not trigger duplicate error`() {
val v = makeVariant()
v.field("A", "1")
v.field("B", "2")
assertEquals(2, v.fields.size)
}
// ── debug {} block ───────────────────────────────────────────────────────
@Test fun `debug block sets override for DEBUG build type`() {
val v = makeVariant()
v.debug { field("LOG", "verbose") }
val fc = v.fields.first { it.fieldName == "LOG" }
assertEquals("verbose", fc.resolve(BuildType.DEBUG)!!.get())
assertNull(fc.resolve(BuildType.RELEASE))
}
@Test fun `debug block with multiple fields stores all`() {
val v = makeVariant()
v.debug {
field("A", "alpha")
field("B", 99)
}
assertEquals(2, v.fields.size)
assertEquals("alpha", v.fields.first { it.fieldName == "A" }.resolve(BuildType.DEBUG)!!.get())
assertEquals(99, v.fields.first { it.fieldName == "B" }.resolve(BuildType.DEBUG)!!.get())
}
// ── release {} block ─────────────────────────────────────────────────────
@Test fun `release block sets override for RELEASE build type`() {
val v = makeVariant()
v.release { field("LOG", "error") }
val fc = v.fields.first { it.fieldName == "LOG" }
assertEquals("error", fc.resolve(BuildType.RELEASE)!!.get())
assertNull(fc.resolve(BuildType.DEBUG))
}
@Test fun `release block with provider stores provider`() {
val v = makeVariant()
v.release { field("KEY", constantProvider("relProv")) }
val fc = v.fields.first { it.fieldName == "KEY" }
assertEquals("relProv", fc.resolve(BuildType.RELEASE)!!.get())
}
// ── debug + release blocks for same field name ───────────────────────────
@Test fun `field can appear in both debug and release blocks`() {
val v = makeVariant()
v.debug { field("LOG", "verbose") }
v.release { field("LOG", "error") }
// getOrCreateField reuses existing FieldConfig
assertEquals(1, v.fields.size)
val fc = v.fields[0]
assertEquals("verbose", fc.resolve(BuildType.DEBUG)!!.get())
assertEquals("error", fc.resolve(BuildType.RELEASE)!!.get())
}
// ── Multiple independent fields ──────────────────────────────────────────
@Test fun `multiple fields are independent`() {
val v = makeVariant()
v.field("URL", "https://api.example.com")
v.field("TIMEOUT", 30)
v.field("DEBUG_MODE", false)
assertEquals(3, v.fields.size)
assertEquals("https://api.example.com", v.fields[0].resolve(BuildType.RELEASE)!!.get())
assertEquals(30, v.fields[1].resolve(BuildType.RELEASE)!!.get())
assertEquals(false, v.fields[2].resolve(BuildType.RELEASE)!!.get())
}
// ── Empty variant ────────────────────────────────────────────────────────
@Test fun `fresh VariantConfig has no fields`() {
val v = makeVariant()
assertTrue(v.fields.isEmpty())
}
}
@@ -0,0 +1,61 @@
package com.bitsycore.konfig
import com.bitsycore.konfig.types.Visibility
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
/**
* Tests for the [Visibility] enum.
*/
class VisibilityTest {
@Test fun `enum contains exactly two entries`() {
assertEquals(2, Visibility.entries.size)
}
@Test fun `PUBLIC entry exists`() {
assertEquals(Visibility.PUBLIC, Visibility.valueOf("PUBLIC"))
}
@Test fun `INTERNAL entry exists`() {
assertEquals(Visibility.INTERNAL, Visibility.valueOf("INTERNAL"))
}
@Test fun `PUBLIC and INTERNAL are distinct`() {
assertNotEquals(Visibility.PUBLIC, Visibility.INTERNAL)
}
@Test fun `ordinal of PUBLIC is 0`() {
assertEquals(0, Visibility.PUBLIC.ordinal)
}
@Test fun `ordinal of INTERNAL is 1`() {
assertEquals(1, Visibility.INTERNAL.ordinal)
}
@Test fun `name of PUBLIC is PUBLIC`() {
assertEquals("PUBLIC", Visibility.PUBLIC.name)
}
@Test fun `name of INTERNAL is INTERNAL`() {
assertEquals("INTERNAL", Visibility.INTERNAL.name)
}
@Test fun `entries list order is PUBLIC then INTERNAL`() {
val entries = Visibility.entries
assertEquals(Visibility.PUBLIC, entries[0])
assertEquals(Visibility.INTERNAL, entries[1])
}
@Test fun `valueOf is case-sensitive and throws on lowercase`() {
var threw = false
try {
Visibility.valueOf("public")
} catch (_: IllegalArgumentException) {
threw = true
}
assertTrue(threw)
}
}