mirror of
https://github.com/bitsycore/bitsykonfig-gradle-plugin.git
synced 2026-10-07 12:37:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
768479a9c5 | ||
|
|
e8ab8f405b | ||
|
|
9d3ac485d4 | ||
|
|
3d553bcaf3 | ||
|
|
fe6f20121f | ||
|
|
b29b853db6 | ||
|
|
6dbdc93122 | ||
|
|
7fbbe1a92a | ||
|
|
3c8f57581f | ||
|
|
534f085367 | ||
|
|
40a4f86972 | ||
|
|
dc61526931 | ||
|
|
73b8e449d5 | ||
|
|
9a45658442 | ||
|
|
9e66a86870 | ||
|
|
e9c049ad7b | ||
|
|
a629692fb7 | ||
|
|
ac626fe191 | ||
|
|
a91597da39 | ||
|
|
cb615118e9 | ||
|
|
4b93f4e9bb | ||
|
|
6d0e9c61ad | ||
|
|
bafb4156e5 |
@@ -0,0 +1,43 @@
|
||||
name: Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master, main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: check-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- java: '17'
|
||||
kotlin: '2.3.0'
|
||||
agp: '8.13.2'
|
||||
- java: '25'
|
||||
kotlin: '2.4.0'
|
||||
agp: '9.3.2'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ matrix.java }}
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- uses: android-actions/setup-android@v3
|
||||
- name: Install Android test SDK
|
||||
run: sdkmanager 'platforms;android-35' 'build-tools;35.0.0' 'build-tools;36.0.0'
|
||||
- name: Check and compile consumer projects
|
||||
run: >-
|
||||
./gradlew check
|
||||
-Pkonfig.test.kotlinVersion=${{ matrix.kotlin }}
|
||||
-Pkonfig.test.androidVersion=${{ matrix.agp }}
|
||||
--stacktrace
|
||||
@@ -0,0 +1,97 @@
|
||||
# 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: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install Android test SDK
|
||||
run: sdkmanager 'platforms;android-35' 'build-tools;35.0.0' 'build-tools;36.0.0'
|
||||
|
||||
- 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
@@ -3,4 +3,8 @@
|
||||
.kotlin
|
||||
.gradle
|
||||
local.properties
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# Local credential overrides - never commit tokens
|
||||
gradle.properties.local
|
||||
secrets.properties
|
||||
@@ -0,0 +1,27 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="publishToMavenLocal" type="GradleRunConfiguration" factoryName="Gradle">
|
||||
<ExternalSystemSettings>
|
||||
<option name="executionName" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="externalSystemIdString" value="GRADLE" />
|
||||
<option name="scriptParameters" value="" />
|
||||
<option name="taskDescriptions">
|
||||
<list />
|
||||
</option>
|
||||
<option name="taskNames">
|
||||
<list>
|
||||
<option value="publishToMavenLocal" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="vmOptions" />
|
||||
</ExternalSystemSettings>
|
||||
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
|
||||
<DebugAllEnabled>false</DebugAllEnabled>
|
||||
<RunAsTest>false</RunAsTest>
|
||||
<GradleProfilingDisabled>false</GradleProfilingDisabled>
|
||||
<GradleCoverageDisabled>false</GradleCoverageDisabled>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,28 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="testAll" type="GradleRunConfiguration" factoryName="Gradle">
|
||||
<ExternalSystemSettings>
|
||||
<option name="executionName" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="externalSystemIdString" value="GRADLE" />
|
||||
<option name="scriptParameters" value="" />
|
||||
<option name="taskDescriptions">
|
||||
<list />
|
||||
</option>
|
||||
<option name="taskNames">
|
||||
<list>
|
||||
<option value=":test" />
|
||||
<option value=":functionalTest" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="vmOptions" />
|
||||
</ExternalSystemSettings>
|
||||
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
|
||||
<DebugAllEnabled>false</DebugAllEnabled>
|
||||
<RunAsTest>false</RunAsTest>
|
||||
<GradleProfilingDisabled>false</GradleProfilingDisabled>
|
||||
<GradleCoverageDisabled>false</GradleCoverageDisabled>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -1,87 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Agent like Claude Code, Copilit, Codex when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Build & publish to local Maven (primary development loop)
|
||||
./gradlew publishToMavenLocal
|
||||
|
||||
# Run unit tests only
|
||||
./gradlew test
|
||||
|
||||
# Run functional tests (Gradle TestKit — starts real Gradle builds)
|
||||
./gradlew functionalTest
|
||||
|
||||
# Run a single 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
|
||||
```
|
||||
|
||||
> Functional tests are the primary test suite. The unit test file (`KonfigTypeTest.kt`) only covers `BuildType` resolution regex.
|
||||
|
||||
## 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.
|
||||
|
||||
### 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 `providers.provider { value }`, external values via `providers.gradleProperty()` / `providers.environmentVariable()` etc. Gradle's config cache serialises the **resolved values** stored in `@Input MapProperty` instances, not the lambda closures that produced them, so the design is correct by construction. External providers are properly tracked as cache inputs through the Provider chain. The only unsafe pattern is bypassing the Provider API entirely (e.g. calling `System.getenv("KEY")` directly inside `field()` or `dimension()`).
|
||||
|
||||
**Dimension data in task inputs** uses flat-map encoding (`"<dimName>|<fieldName>"` as map keys) in `MapProperty<String, T>` rather than a managed-type `ListProperty`. This avoids Gradle's `@Nested` managed-type restrictions.
|
||||
|
||||
### Resolution priority for dimensions
|
||||
|
||||
1. Explicit Gradle property: `-Pkonfig.dimension.<name>=<variant>`
|
||||
2. Task-name detection (variant name substring match, if not disabled by `konfig.android.flavordetection=false`)
|
||||
3. `defaultTo` fallback declared in DSL
|
||||
4. **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.
|
||||
|
||||
### 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()` and `dimension()` functions |
|
||||
| `DimensionConfig.kt` | DSL node for a dimension — holds variants, `objectNameOverride`, `defaultVariant`, computes Kotlin object name |
|
||||
| `VariantConfig.kt` | DSL node for a variant — holds `List<FieldConfig<*>>` |
|
||||
| `FieldConfig.kt` | Single typed field with optional `debug()`/`release()` overrides |
|
||||
| `GenerateKonfigTask.kt` | `@CacheableTask` — validates inputs, logs detection results, writes the `.kt` file |
|
||||
| `BuildType.kt` | `enum` with regex-based task-name detection and Gradle attribute rules |
|
||||
| `Visibility.kt` | `PUBLIC` / `INTERNAL` enum |
|
||||
|
||||
Stub files (`VariantFieldConfig.kt`, `VariantDimensionConfig.kt`, `VariantValue.kt`, `DimensionTaskInput.kt`) are empty package declarations left from a previous design — they can be deleted.
|
||||
|
||||
### Plugin metadata
|
||||
|
||||
- **Plugin ID:** `com.bitsycore.konfig`
|
||||
- **Group:** `com.bitsycore`
|
||||
- **Version:** set via `konfig.version` in `gradle.properties`
|
||||
- **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
|
||||
|
||||
### Logging levels used in GenerateKonfigTask
|
||||
|
||||
| Output | Level | When |
|
||||
|-----------------------------------------------------|-------------------|-----------------------------------------|
|
||||
| `BUILD_TYPE = release (…reason…)` | `lifecycle` | Always |
|
||||
| `dim 'env' -> 'dev' (…reason…)` | `lifecycle` | Active dimension |
|
||||
| `dim 'env' -> skipped (…reason…)` | `lifecycle` | No variant resolved |
|
||||
| `generated BuildKonfig.kt (…)` | `lifecycle` | Always (summary) |
|
||||
| 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 |
|
||||
@@ -0,0 +1,238 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to AI agents working in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Build & publish to local Maven (primary development loop)
|
||||
./gradlew publishToMavenLocal
|
||||
|
||||
# Run unit tests only
|
||||
./gradlew test
|
||||
|
||||
# Run functional tests (Gradle TestKit - starts real Gradle builds)
|
||||
./gradlew functionalTest
|
||||
|
||||
# Run a single 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
|
||||
|
||||
# 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.7.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. 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.
|
||||
|
||||
Android application/library projects register a generator per variant, such as
|
||||
`generateProdDebugKonfig`, with output under `build/generated/konfig/prodDebug/`.
|
||||
AGP's `debuggable` and exact flavor-dimension metadata drive these tasks.
|
||||
JVM/KMP retain shared generation; conflicting task-name selections fail unless
|
||||
explicitly overridden. `generateKonfig` and `konfigInfo` aggregate variant tasks
|
||||
on standalone Android projects. KMP Android targets retain the commonMain object.
|
||||
|
||||
### 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 declared
|
||||
task 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.
|
||||
Collections use `"Value:<Kotlin type>\n<initializer>"`. `FieldValueType` snapshots
|
||||
generic types into strings/lists; never retain KType/KClass in the DSL graph.
|
||||
Fresh array-containing values use `"Getter:<Kotlin type>\n<initializer>"` when
|
||||
`copyArraysOnAccess` is enabled. `specializeArrays` defaults to true; explicit
|
||||
primitive arrays always retain their type. Sets, enum references, and unsigned
|
||||
scalar numbers are also supported.
|
||||
|
||||
Nullable DSL fields use `NullFieldValue` inside a non-null constant provider to
|
||||
distinguish explicit null from an absent Gradle provider. Check this marker by
|
||||
type, not singleton identity: configuration-cache restoration may recreate it.
|
||||
`fieldClass<T>()` immediately converts nullable type reflection to a Java Class.
|
||||
|
||||
**Output ownership:** `generatedFile` and `ownershipFile` are managed
|
||||
`@OutputFile` properties. `outputDirectory` is `@Internal`; never annotate the
|
||||
shared directory as an output or delete it recursively. `sourceDirectory` is
|
||||
an internal directory view zipped from `generatedFile` and `outputDirectory` so
|
||||
source consumers inherit the file's producer dependency. Android registers that
|
||||
view with `addGeneratedSourceDirectory`. Keep the file producer in this chain.
|
||||
The ownership record stores a relative path; validate all inputs before replacing
|
||||
files and preserve unrelated files on both execution and build-cache restoration.
|
||||
|
||||
### 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.
|
||||
|
||||
### Resolution priority for dimensions
|
||||
|
||||
1. Explicit Gradle property: `-Pkonfig.dimension.<name>=<variant>`
|
||||
2. `konfig.properties` file in the project directory: `konfig.dimension.<name>=<variant>`
|
||||
3. Exact Android flavor-dimension metadata, or camelCase task-name matching for shared JVM/KMP generation (unless `konfig.android.flavordetection=false`)
|
||||
4. `defaultTo` fallback declared in DSL
|
||||
5. **Omitted silently** if none of the above - no crash, dimension object not generated
|
||||
|
||||
`strictResolution = true` rejects unknown explicit build types, unknown dimension
|
||||
property names, and missing/unknown selections. Per-dimension `required = true`
|
||||
enforces just that dimension's selection. Both default to false. The per-dimension
|
||||
`androidDimension` alias defaults to its Konfig name and only affects Android
|
||||
metadata lookup. `validateVariantSchema = true` checks effective field names and
|
||||
declared types across all variants/build types, including common fallbacks and
|
||||
provider presence; its default is false.
|
||||
|
||||
`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 |
|
||||
|
||||
### 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 |
|
||||
| `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 |
|
||||
|
||||
> `FieldHandle<T>` and `BuildTypedFieldDeclScope` are defined inside `VariantConfig.kt`, not in separate files.
|
||||
|
||||
### Plugin metadata
|
||||
|
||||
- **Plugin ID:** `com.bitsycore.konfig`
|
||||
- **Group:** `com.bitsycore`
|
||||
- **Artifact:** `konfig-gradle-plugin`
|
||||
- **Version:** set via `konfig.version` in `gradle.properties` (currently `0.7.0`)
|
||||
- **Repositories:** `https://maven.bitsycore.com/releases` (primary, no auth) and
|
||||
`https://maven.pkg.github.com/bitsycore/bitsykonfig` (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
|
||||
|
||||
| Output | Level | When |
|
||||
|-----------------------------------------------------|-------------------|-----------------------------------------|
|
||||
| `BUILD_TYPE = release (…reason…)` | `lifecycle` | Always |
|
||||
| `dim 'env' -> 'dev' (…reason…)` | `lifecycle` | Active dimension |
|
||||
| `dim 'env' -> skipped (…reason…)` | `lifecycle` | No variant resolved |
|
||||
| `generated BuildKonfig.kt (…)` | `lifecycle` | Always (summary) |
|
||||
| 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 |
|
||||
@@ -0,0 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.0 — 2026-09-06
|
||||
|
||||
- Added opt-in `strictResolution`, per-dimension `required`, and cross-variant `validateVariantSchema` checks.
|
||||
- Added `androidDimension` aliases for differently named Android flavor dimensions.
|
||||
- Added explicit nullable fields, sets through `setOf`, enum references, and unsigned scalar numbers.
|
||||
- Added `specializeArrays` and `copyArraysOnAccess` settings; existing array behavior remains the default.
|
||||
- Added list, map, and array fields using `listOf`, `mapOf`, and `arrayOf`, including nested, nullable, empty, and provider-backed values.
|
||||
- Optimized arrays of non-null primitive elements into primitive arrays, such as `Array<Int>` becoming `IntArray`.
|
||||
- Added `Byte`, `Short`, and `Char` fields and fixed `Long.MIN_VALUE` generation.
|
||||
- Fixed standalone Android projects to generate separate configuration for each variant, including simultaneous debug/release and flavor builds. KMP keeps one shared configuration in `commonMain`.
|
||||
- Changed Android detection to use variant metadata: build types follow `debuggable`, and automatic flavor selection uses the Konfig dimension name or its `androidDimension` alias. Explicit properties still take precedence.
|
||||
- Changed Android output to `build/generated/konfig/<variant>/` with tasks such as `generateProdDebugKonfig`. `generateKonfig` and `konfigInfo` now run all variant tasks; custom paths and task integrations may need updating.
|
||||
- Fixed conflicting JVM/KMP task selections to fail clearly instead of silently choosing the wrong configuration. Use separate builds or explicit selection properties to resolve conflicts.
|
||||
- Excluded project-path segments from task-name detection, so `:prod:compileKotlin` no longer selects the `prod` variant by itself.
|
||||
- Added validation for invalid Kotlin names, package names, reserved fields, name collisions, unsupported field types, and incompatible field override types. Previously accepted invalid configurations may now fail earlier.
|
||||
- Fixed default package derivation for unusual project/group names and escaping of module names, variant names, and generated comments.
|
||||
- Made regeneration and build-cache restoration preserve neighboring files. Renames remove only the previously owned generated file, and existing user files are protected from overwriting.
|
||||
- Fixed generated-source dependencies for compilation, Android lint, and source publication.
|
||||
- Kept the existing DSL syntax and project-wide query APIs. On Android, `konfig.isDebug` and dimension queries describe one project-wide selection; use `androidComponents.onVariants` for decisions that vary by Android variant.
|
||||
- Added regression tests, JVM/KMP/Android consumer integration tests, and a CI check workflow. Configuration-cache reuse and Android integration are checked on AGP 8.13.2 and 9.3.2.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Bitsycore
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,88 @@
|
||||
Project checkup — updated 6 September 2026
|
||||
|
||||
All six findings from the initial review have been addressed. The collection
|
||||
support was added in commit b29b853; this report describes the follow-up fixes.
|
||||
|
||||
**Resolved findings**
|
||||
|
||||
| Original finding | Fix |
|
||||
| --- | --- |
|
||||
| P1: Shared configuration selected the wrong Android variant | Standalone Android application/library projects now register separate generation/logging tasks and directories per variant. Build type follows AGP's debuggable flag; dimensions use exact Android flavor-dimension metadata. JVM/KMP shared generation rejects conflicting automatic selections; explicit properties remain available. |
|
||||
| P1: Output-directory cleanup could delete unrelated files | Gradle owns two output files: generated Kotlin and a relative ownership record. The directory is not an output. Renames remove only the previous owned file, and cache restoration preserves neighbors. Existing user files and paths escaping through symbolic links are rejected. Validation completes before replacement. |
|
||||
| P2: Incomplete identifier validation | Object names, active dimension object names, flat variant constants, fields, and package segments are validated for Kotlin identifier rules, hard keywords, and underscore-only names. Default package derivation also sanitizes invalid segments. Internal dimension delimiters/control characters and ambiguous variant-name tabs are rejected. |
|
||||
| P2: Reserved-name and object collisions | Global fields cannot reuse BUILD_TYPE, MODULE_NAME, or IS_DEBUG. Nested dimensions reserve VARIANT. Nested object names, global fields, and flat fields share one collision check. |
|
||||
| P2: Unescaped metadata | Module and selected-variant strings use the shared Kotlin literal encoder. Dimension comments escape controls and neutralize block-comment delimiters. A compiler/runtime test verifies the original values survive. |
|
||||
| P2: Missing source-generation dependencies | JVM/KMP use a source-directory provider linked to the managed generated-file output. Android registers that view with addGeneratedSourceDirectory: Java sources for external KGP, Kotlin sources for AGP built-in Kotlin. Compile, lint, and source publication consume the producer dependency. |
|
||||
|
||||
The Android registration follows the official
|
||||
[AGP generated-source API](https://developer.android.com/reference/tools/gradle-api/8.7/com/android/build/api/variant/SourceDirectories).
|
||||
|
||||
**Verification and maintenance**
|
||||
|
||||
The checkup-fix validation passed all 327 tests (179 unit and 148 functional), with
|
||||
zero failures or skips, using Kotlin 2.4.0 and AGP 9.3.2 for consumer integration.
|
||||
The Kotlin 2.3.0 / AGP 8.13.2 consumer integration run also passed. Local runs used
|
||||
JDK 25; the additional JDK 17 leg is configured in CI. `git diff --check` passed.
|
||||
|
||||
- Regression tests cover invalid names, every generated scope's reserved names,
|
||||
incompatible shared selections, metadata compilation/runtime values, failed
|
||||
generation preserving the previous file, custom-directory neighbors, output
|
||||
renaming, and build-cache restoration.
|
||||
- Consumer integration tests package a temporary Maven artifact, so the optional
|
||||
Kotlin/Android APIs are resolved as they are for a published plugin. These test
|
||||
real JVM and KMP compilation and source publication with configuration caching.
|
||||
- Android integration tests build simultaneous prod/debug and preProd/release
|
||||
AARs and inspect the compiled constants. They also run lint and source
|
||||
publication from a clean build, reuse configuration caching, and invoke the
|
||||
aggregate generation task alongside variant builds.
|
||||
- A pull-request/push check workflow covers Kotlin 2.3.0 with AGP 8.13.2 and Kotlin
|
||||
2.4.0 with AGP 9.3.2, on JDK 17/25 respectively. AGP 9.3.2 consumer tests use
|
||||
Gradle 9.5.0 to satisfy its minimum version; the project wrapper remains 9.4.1.
|
||||
- Android integration requires SDK platform 35; CI installs it. The Android test
|
||||
explicitly skips on developer machines without that SDK.
|
||||
- The README's incorrect debug output example is corrected. The compiler-test
|
||||
helper is shared by collection and metadata regression tests.
|
||||
|
||||
**Behavior to account for when upgrading**
|
||||
|
||||
Android generation writes under build/generated/konfig/<variant>/, or the same
|
||||
variant child beneath a custom outputDir. Tasks are named generateProdDebugKonfig
|
||||
and konfigProdDebugInfo, for example. generateKonfig and konfigInfo aggregate the
|
||||
variant tasks. Explicit build-type/dimension properties retain higher priority.
|
||||
|
||||
KMP retains one object in commonMain, including with Android targets. Conflicting
|
||||
automatic selections fail; aggregate/custom tasks without selection in their
|
||||
names require explicit properties when defaults are unsuitable. Project-wide
|
||||
isDebug/currentDimension queries represent one selection and cannot describe
|
||||
several different Android variants simultaneously.
|
||||
|
||||
Invalid identifiers and conflicting automatic selections that previously
|
||||
produced broken or incorrect source now fail with an actionable error.
|
||||
|
||||
**Additional features implemented after the checkup**
|
||||
|
||||
- Opt-in strict resolution, required dimensions, and cross-variant field-schema validation.
|
||||
- Android flavor-dimension aliases.
|
||||
- Explicit nullable fields, sets, enum references, and unsigned scalar numbers.
|
||||
- Boxed-array selection and fresh array-containing values on access.
|
||||
|
||||
Per-platform configuration was deferred at the user's request. KMP retains its
|
||||
shared object and existing const/inline generation.
|
||||
|
||||
Feature validation passed a full check of 331 tests with Kotlin 2.4.0 / AGP 9.3.2.
|
||||
A follow-up run passed all eight feature/consumer tests with Kotlin 2.3.0 /
|
||||
AGP 8.13.2, including one additional array-policy cache-invalidation test and
|
||||
expanded provider-schema, constant-preservation, and Android alias-priority checks.
|
||||
Both runs had zero failures or skips.
|
||||
|
||||
**Optional feature proposals remaining**
|
||||
|
||||
These were roadmap suggestions, not defects, and were not added in this fix pass:
|
||||
|
||||
| Proposal | Purpose |
|
||||
| --- | --- |
|
||||
| Configurable propertiesFile | Share an explicitly selected, tracked configuration file across modules |
|
||||
| Target/source-set selection and automatic-wiring toggle | Support custom KMP layouts and integrations |
|
||||
| Structured konfigInfo output and configurable verbosity | Export resolution decisions to CI and control normal log output |
|
||||
|
||||
The initial diagnostic projects remain under ignored build/checkup.
|
||||
@@ -0,0 +1,599 @@
|
||||
# buildkonfig-gradle-plugin
|
||||
|
||||
A Gradle plugin that generates a `BuildKonfig` Kotlin object at build time - like Android's `BuildConfig`, but for any Kotlin project (JVM, Multiplatform, Android).
|
||||
|
||||
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`
|
||||
**Group:** `com.bitsycore`
|
||||
**Artifact:** `konfig-gradle-plugin`
|
||||
**Version:** `0.7.0`
|
||||
**JVM target:** 17
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Configure plugin resolution
|
||||
|
||||
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 {
|
||||
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")
|
||||
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.7.0"
|
||||
|
||||
[plugins]
|
||||
konfig = { id = "com.bitsycore.konfig", version.ref = "konfig" }
|
||||
```
|
||||
|
||||
`build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.konfig)
|
||||
}
|
||||
```
|
||||
|
||||
Or inline:
|
||||
|
||||
```kotlin
|
||||
plugins {
|
||||
id("com.bitsycore.konfig") version "0.7.0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Basic usage
|
||||
|
||||
The plugin auto-detects your project's `group` and `name` to set a default package. The generated file is placed in `build/generated/konfig/` and wired into your source sets automatically.
|
||||
|
||||
```kotlin
|
||||
konfig {
|
||||
field("APP_NAME", "My App")
|
||||
field("VERSION_CODE", 42)
|
||||
field("ENABLE_LOGGING", false).debug(true)
|
||||
}
|
||||
```
|
||||
|
||||
Generated output (`BuildKonfig.kt`):
|
||||
|
||||
```kotlin
|
||||
public object BuildKonfig {
|
||||
const val BUILD_TYPE: String = "release"
|
||||
const val MODULE_NAME: String = "my-app"
|
||||
const val IS_DEBUG: Boolean = false
|
||||
|
||||
const val APP_NAME: String = "My App"
|
||||
const val VERSION_CODE: Int = 42
|
||||
const val ENABLE_LOGGING: Boolean = false
|
||||
}
|
||||
```
|
||||
|
||||
In debug builds (`-Pkonfig.buildtype=DEBUG`), `ENABLE_LOGGING` becomes `inline val ENABLE_LOGGING: Boolean get() = true`.
|
||||
|
||||
---
|
||||
|
||||
## Configuration reference
|
||||
|
||||
### Object settings
|
||||
|
||||
```kotlin
|
||||
konfig {
|
||||
objectPackage = "com.example.app" // default: derived from group + project name
|
||||
objectName = "BuildKonfig" // default: "BuildKonfig"
|
||||
objectVisibility = Visibility.INTERNAL // default: Visibility.PUBLIC
|
||||
}
|
||||
```
|
||||
|
||||
### Supported field types
|
||||
|
||||
| Kotlin type | Example |
|
||||
|-------------|--------------------------------------------|
|
||||
| `String` | `field("BASE_URL", "https://example.com")` |
|
||||
| `Boolean` | `field("FEATURE_X", false)` |
|
||||
| `Int` | `field("TIMEOUT", 30)` |
|
||||
| `Long` | `field("MAX_SIZE", 1_000_000L)` |
|
||||
| `Float` | `field("RATIO", 1.5f)` |
|
||||
| `Double` | `field("PI", 3.14159)` |
|
||||
| `Byte`, `Short`, `Char` | `field("SEPARATOR", ':')` |
|
||||
| `List<T>` | `field("HOSTS", listOf("api.example.com"))` |
|
||||
| `Set<T>` | `field("FEATURES", setOf("search", "export"))` |
|
||||
| `Map<K, V>` | `field("PORTS", mapOf("https" to 443))` |
|
||||
| `Array<T>` | `field("REGIONS", arrayOf("eu", "us"))` |
|
||||
| Primitive arrays | `field("RETRIES", intArrayOf(1, 3, 5))` |
|
||||
| Nullable types | `field<String?>("OPTIONAL_URL", null)` |
|
||||
| Enum values | `field("DAY", java.time.DayOfWeek.MONDAY)` (JVM) |
|
||||
| `UByte`, `UShort`, `UInt`, `ULong` | `field("MAX_ID", ULong.MAX_VALUE)` |
|
||||
|
||||
### Lists, sets, maps, and arrays
|
||||
|
||||
Collections generate ordinary `val` properties initialized with `listOf`,
|
||||
`setOf`, `mapOf`, or an array factory. They work with providers, build-type overrides,
|
||||
dimension variants, `common {}`, and flat dimensions:
|
||||
|
||||
```kotlin
|
||||
konfig {
|
||||
field("HOSTS", listOf("api.example.com")).debug(listOf("localhost"))
|
||||
field("PORTS", mapOf("http" to 80, "https" to 443))
|
||||
field("REGIONS", arrayOf("eu", "us"))
|
||||
field("RETRIES", arrayOf(1, 3, 5))
|
||||
field("EMPTY", emptyList<String>())
|
||||
field("ROUTES", mapOf("primary" to listOf("/health", "/status")))
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
val HOSTS: List<String> = listOf<String>("api.example.com")
|
||||
val PORTS: Map<String, Int> = mapOf<String, Int>("http" to 80, "https" to 443)
|
||||
val REGIONS: Array<String> = arrayOf<String>("eu", "us")
|
||||
val RETRIES: IntArray = intArrayOf(1, 3, 5)
|
||||
val EMPTY: List<String> = listOf<String>()
|
||||
val ROUTES: Map<String, List<String>> = mapOf<String, List<String>>("primary" to listOf<String>("/health", "/status"))
|
||||
```
|
||||
|
||||
Arrays of non-null primitive elements are specialized: `Array<Int>` generates
|
||||
`IntArray` with `intArrayOf`. The same applies to Boolean, Byte, Short, Char,
|
||||
Long, Float, and Double. Existing primitive arrays retain their primitive type.
|
||||
Arrays with nullable elements such as `Array<Int?>` stay generic. Specialization also applies
|
||||
inside nested collections, so `List<Array<Int>>` generates `List<IntArray>`.
|
||||
|
||||
Empty collections retain the type supplied in the DSL. Nullable elements,
|
||||
nested lists/sets/maps/arrays, and mixed values explicitly typed as `Any` are
|
||||
supported; values must ultimately be supported scalars or collections.
|
||||
Custom objects other than enums are rejected. Lists, sets, and maps expose read-only Kotlin
|
||||
interfaces; generated arrays are mutable. Collection properties are not `const`.
|
||||
Build-type scope overrides must keep the declared field type.
|
||||
|
||||
### Array policies
|
||||
|
||||
```kotlin
|
||||
konfig {
|
||||
specializeArrays = false // Keep Array<Int> as Array<Int>; default is true
|
||||
copyArraysOnAccess = true // Return fresh array-containing values; default is false
|
||||
field("RETRIES", arrayOf(1, 3, 5))
|
||||
}
|
||||
```
|
||||
|
||||
Explicit primitive arrays such as `intArrayOf(1, 2)` stay primitive under either
|
||||
policy. Both policies apply recursively inside collections. With copying enabled,
|
||||
an array-containing field uses a getter that recreates the whole value on every
|
||||
access. This prevents a caller's array mutations from affecting later reads, at
|
||||
the cost of allocations. Fields without arrays retain their usual declarations.
|
||||
|
||||
### Nullable fields, enums, and unsigned numbers
|
||||
|
||||
```kotlin
|
||||
konfig {
|
||||
field<String?>("OPTIONAL_URL", null).debug("http://localhost")
|
||||
field<String?>("TOKEN", providers.gradleProperty("token"))
|
||||
field("MAX_ID", ULong.MAX_VALUE)
|
||||
}
|
||||
```
|
||||
|
||||
An explicit `null` generates a present nullable `val`. An absent Gradle provider
|
||||
still omits the field; Gradle providers cannot carry a present null. Specify the
|
||||
nullable type explicitly, including in `debug {}` / `release {}` declarations
|
||||
that override a nullable field. Nullable properties are ordinary `val` properties.
|
||||
|
||||
Enums generate a qualified reference such as `java.time.DayOfWeek.MONDAY` and
|
||||
support nullable values and nesting in collections. The enum must be accessible
|
||||
both from the build script and from the consuming source set. A build-script-only
|
||||
enum is not automatically copied into application code. Unsigned scalar numbers
|
||||
and collections of those numbers are supported; unsigned array classes are not.
|
||||
|
||||
### Build-type overrides
|
||||
|
||||
Three equivalent forms:
|
||||
|
||||
```kotlin
|
||||
konfig {
|
||||
// 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
|
||||
debug { field("MOCK_API", true) }
|
||||
release { field("MOCK_API", false) }
|
||||
}
|
||||
```
|
||||
|
||||
> `field()` inside `debug {}` / `release {}` blocks intentionally returns `Unit` - the build type is already fixed by the enclosing scope, so `.debug()` / `.release()` chaining is impossible by design.
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
|
||||
```kotlin
|
||||
konfig {
|
||||
dimension("env", defaultTo = "prod") {
|
||||
common {
|
||||
field("TIMEOUT", 30)
|
||||
debug { field("TIMEOUT", 5) }
|
||||
}
|
||||
variant("prod") {
|
||||
field("BASE_URL", "https://prod.example.com")
|
||||
field("ANALYTICS", true)
|
||||
}
|
||||
variant("dev") {
|
||||
field("BASE_URL", "https://dev.example.com")
|
||||
field("ANALYTICS", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Generated output (with `env=dev`, debug build):
|
||||
|
||||
```kotlin
|
||||
public object BuildKonfig {
|
||||
const val BUILD_TYPE: String = "debug"
|
||||
// ...
|
||||
|
||||
public object Env /*env*/ {
|
||||
const val VARIANT: String = "dev"
|
||||
const val TIMEOUT: Int = 5 // common {}, debug override
|
||||
const val BASE_URL: String = "https://dev.example.com"
|
||||
inline val ANALYTICS: Boolean get() = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `common {}` block
|
||||
|
||||
Fields declared in `common {}` act as fallbacks for all variants. A variant field with the same name takes precedence over the common field.
|
||||
|
||||
### Custom object name
|
||||
|
||||
```kotlin
|
||||
dimension("env", objectNameOverride = "Environment", defaultTo = "prod") { ... }
|
||||
// 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.
|
||||
|
||||
Global fields also cannot reuse built-in names. Nested dimensions reserve
|
||||
`VARIANT`, and their object names must be unique within the root object.
|
||||
Object names, field names, and package segments must be valid Kotlin identifiers;
|
||||
keywords and underscore-only names are rejected before generation.
|
||||
|
||||
---
|
||||
|
||||
## Variant selection
|
||||
|
||||
Variants are resolved in priority order:
|
||||
|
||||
| Priority | Source | Example |
|
||||
|----------|--------------------------|------------------------------------------|
|
||||
| 1 | Gradle property | `-Pkonfig.dimension.env=dev` |
|
||||
| 2 | `konfig.properties` file | `konfig.dimension.env=dev` |
|
||||
| 3 | Android flavor metadata, or task names for JVM/KMP | Android flavor dimension `env=dev`, or `assembleDevDebug` |
|
||||
| 4 | `defaultTo` in DSL | `dimension("env", defaultTo = "prod")` |
|
||||
| - | Omitted silently | No variant → no nested object generated |
|
||||
|
||||
### `konfig.properties` file
|
||||
|
||||
Place a `konfig.properties` file in your project directory:
|
||||
|
||||
```properties
|
||||
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 within a single task and every match is a substring
|
||||
of the longest one (e.g. `prod` inside `preProd` for `assemblePreProdRelease`),
|
||||
the longest wins. Conflicting selections across tasks fail with a clear error;
|
||||
`assembleProdRelease assemblePreProdRelease` never silently selects preProd.
|
||||
Project-path segments such as `:prod:` are excluded from task-name detection.
|
||||
|
||||
Android application/library projects select flavors by the exact Android
|
||||
dimension name, so `dimension("env")` maps to the Android `env` flavor dimension.
|
||||
Explicit Gradle properties and `konfig.properties` still take precedence.
|
||||
|
||||
### Strict resolution and schema validation
|
||||
|
||||
All new validation settings are opt-in:
|
||||
|
||||
```kotlin
|
||||
konfig {
|
||||
strictResolution = true
|
||||
validateVariantSchema = true
|
||||
dimension("env", defaultTo = "prod") {
|
||||
required = true
|
||||
androidDimension = "environment"
|
||||
common { field("TIMEOUT", 30) }
|
||||
variant("prod") { field("URL", "https://example.com") }
|
||||
variant("dev") { field("URL", "http://localhost") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `strictResolution` rejects unknown explicit build types, unknown dimension
|
||||
property names, and missing or unknown dimension selections. A valid `defaultTo`
|
||||
satisfies the selection requirement. Existing build-type defaults still apply
|
||||
when no build type is explicitly supplied.
|
||||
- `required` requires a selection for just that dimension, even when global
|
||||
strict resolution is disabled. Its default is `false`.
|
||||
- `validateVariantSchema` checks effective field names and declared types across
|
||||
every variant and both DEBUG/RELEASE contexts, including inactive variants and
|
||||
`common {}` fallback fields. It also checks global fields across build types.
|
||||
Missing provider values count as absent fields. Field values may differ.
|
||||
- `androidDimension` maps a Konfig dimension to an Android flavor dimension with
|
||||
a different name. It defaults to the Konfig dimension name. Explicit selection
|
||||
properties still use the Konfig name, such as `-Pkonfig.dimension.env=dev`, and
|
||||
override Android metadata. The alias does not change JVM/KMP task-name matching.
|
||||
|
||||
### 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
|
||||
|
||||
Build type is resolved in priority order:
|
||||
|
||||
| Priority | Source | Example |
|
||||
|----------|---------------------|-----------------------------------|
|
||||
| 1 | Explicit property | `-Pkonfig.buildtype=DEBUG` |
|
||||
| 2 | Android variant metadata, or task names for JVM/KMP | A debuggable Android variant → `DEBUG` |
|
||||
| - | Default | `RELEASE` |
|
||||
|
||||
Android application/library variants generate separate objects, so aggregate
|
||||
tasks and simultaneous debug/release builds are supported. Custom Android build
|
||||
types use their `debuggable` setting.
|
||||
|
||||
JVM/KMP projects share one object. Conflicting debug/release task requests fail;
|
||||
run separate builds or set `-Pkonfig.buildtype` explicitly. For aggregate/custom
|
||||
tasks whose names contain no selection, use explicit build-type/dimension
|
||||
properties when a result other than the defaults is required. KMP keeps this
|
||||
shared behavior in `commonMain`, including when it has an Android target.
|
||||
|
||||
---
|
||||
|
||||
## Gradle properties
|
||||
|
||||
| Property | Effect |
|
||||
|---------------------------------------------|--------------------------------------------------|
|
||||
| `-Pkonfig.buildtype=DEBUG\|RELEASE` | Forces build type |
|
||||
| `-Pkonfig.dimension.<name>=<variant>` | Selects a dimension variant |
|
||||
| `-Pkonfig.force` | Disables UP-TO-DATE checks - task always re-runs |
|
||||
| `-Pkonfig.android.buildtypedetection=false` | Disables task-name build-type detection |
|
||||
| `-Pkonfig.android.flavordetection=false` | Disables task-name dimension-variant detection |
|
||||
|
||||
### `konfig.force`
|
||||
|
||||
Forces `generateKonfig` to re-run on every build, bypassing Gradle's UP-TO-DATE and build-cache checks.
|
||||
|
||||
```bash
|
||||
./gradlew generateKonfig -Pkonfig.force
|
||||
./gradlew assembleRelease -Pkonfig.force
|
||||
```
|
||||
|
||||
The flag is presence-based - any value (or no value) enables it.
|
||||
|
||||
---
|
||||
|
||||
## Accessing generated values
|
||||
|
||||
```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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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) |
|
||||
|
||||
These queries describe one project-wide selection. Android generation uses
|
||||
each variant's metadata, so use `androidComponents.onVariants` for decisions
|
||||
that must vary within a simultaneous Android build. A single `isDebug` query
|
||||
cannot represent both debug and release; conflicting task-name selections fail
|
||||
unless an explicit project-wide build type is supplied.
|
||||
|
||||
---
|
||||
|
||||
## Using Gradle providers as field values
|
||||
|
||||
Lazy `Provider<T>` values are supported - useful for reading Gradle properties or environment variables:
|
||||
|
||||
```kotlin
|
||||
konfig {
|
||||
field("API_KEY", providers.gradleProperty("myapp.apiKey"))
|
||||
field("CI_BUILD", providers.environmentVariable("CI").map { it.toBoolean() })
|
||||
}
|
||||
```
|
||||
|
||||
> Do not call `System.getenv()` or `project.findProperty()` directly inside `field()` - these bypass the Provider API and break configuration cache.
|
||||
|
||||
---
|
||||
|
||||
## Auto source-set wiring
|
||||
|
||||
The generated directory (`build/generated/konfig/`) is automatically added as a Kotlin source set for:
|
||||
|
||||
- `org.jetbrains.kotlin.multiplatform` → `commonMain`
|
||||
- `org.jetbrains.kotlin.jvm` → `main`
|
||||
- `com.android.application` / `com.android.library` → one generated directory per
|
||||
variant, including projects using `org.jetbrains.kotlin.android` or AGP built-in Kotlin
|
||||
|
||||
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.
|
||||
|
||||
Source directories carry the generating task dependency. Android uses
|
||||
`addGeneratedSourceDirectory`; JVM/KMP source sets use task-backed directory
|
||||
providers. Compilation, lint, and source publication therefore receive generated
|
||||
sources without task-name dependency heuristics.
|
||||
|
||||
On Android, tasks such as `generateProdDebugKonfig` write beneath
|
||||
`build/generated/konfig/prodDebug/`. `generateKonfig` runs all variant generators,
|
||||
and `konfigInfo` runs all variant logging tasks. On JVM/KMP, these retain their
|
||||
single-object behavior.
|
||||
|
||||
`outputDir` remains configurable. The generator and build cache own only the
|
||||
generated file and an ownership record under `build/konfig-state`; other files in
|
||||
the source directory are preserved. Renaming the object/package removes its
|
||||
previous generated file. Validation completes before files are replaced, and an
|
||||
existing file without the generator header is never overwritten.
|
||||
|
||||
---
|
||||
|
||||
## Publishing (plugin development)
|
||||
|
||||
```bash
|
||||
# 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 unit tests only
|
||||
./gradlew test
|
||||
|
||||
# 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
|
||||
```
|
||||
+80
-5
@@ -9,6 +9,7 @@ version = providers.gradleProperty("konfig.version").get()
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
@@ -45,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") {
|
||||
@@ -55,12 +58,84 @@ val functionalTestTask = tasks.register<Test>("functionalTest") {
|
||||
group = "verification"
|
||||
testClassesDirs = functionalTest.output.classesDirs
|
||||
classpath = functionalTest.runtimeClasspath
|
||||
listOf("kotlinVersion", "androidVersion").forEach { setting ->
|
||||
providers.gradleProperty("konfig.test.$setting").orNull?.let {
|
||||
systemProperty("konfig.test.$setting", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -3,4 +3,35 @@ org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
konfig.version=0.1.0
|
||||
# =========================================================
|
||||
# MARK: Publishing
|
||||
# =========================================================
|
||||
|
||||
konfig.version=0.7.0
|
||||
konfig.artifactId=konfig-gradle-plugin
|
||||
konfig.publish.url=https://maven.pkg.github.com/bitsycore/bitsykonfig
|
||||
|
||||
# =========================================================
|
||||
# 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
|
||||
|
||||
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.git
|
||||
konfig.pom.scm.developerConnection=scm:git:ssh://github.com/bitsycore/bitsykonfig.git
|
||||
konfig.pom.scm.url=https://github.com/bitsycore/bitsykonfig
|
||||
|
||||
# =========================================================
|
||||
# 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,160 @@
|
||||
package com.bitsycore.konfig
|
||||
|
||||
import org.gradle.testkit.runner.TaskOutcome
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CollectionsFunctionalTest : FunctionalTestBase() {
|
||||
@Test fun `collections compile and preserve values including empty nullable and nested types`() = withProject { dir, run ->
|
||||
dir.writeCompilingProject("""
|
||||
konfig {
|
||||
field("WORDS", listOf("quote\"", "line\n", "\\path"))
|
||||
field("EMPTY_LIST", emptyList<String>())
|
||||
field("EMPTY_MAP", emptyMap<String, Int>())
|
||||
field("NESTED", mapOf("items" to listOf(1, 2)))
|
||||
field("NULLS", listOf<String?>(null, "present"))
|
||||
field("MIXED", listOf<Any>("text", 42, true, listOf(1), (-128).toByte(), 7.toShort()))
|
||||
field("KEYS", mapOf(1 to "one", 2 to "two"))
|
||||
field("STRINGS", arrayOf("a", "b"))
|
||||
field("EMPTY_ARRAY", emptyArray<String>())
|
||||
field("BOXED", arrayOf(1, 2))
|
||||
field("NULL_ARRAY", arrayOf<Int?>(1, null))
|
||||
field("ARRAYS", listOf(arrayOf(1, 2)))
|
||||
field("MIN", Long.MIN_VALUE)
|
||||
}
|
||||
""", """
|
||||
import com.example.BuildKonfig as K
|
||||
fun main() {
|
||||
check(K.WORDS == listOf("quote\"", "line\n", "\\path"))
|
||||
val emptyList: List<String> = K.EMPTY_LIST
|
||||
val emptyMap: Map<String, Int> = K.EMPTY_MAP
|
||||
check(emptyList.isEmpty() && emptyMap.isEmpty())
|
||||
check(K.NESTED == mapOf("items" to listOf(1, 2)))
|
||||
check(K.NULLS == listOf(null, "present"))
|
||||
check(K.MIXED == listOf("text", 42, true, listOf(1), (-128).toByte(), 7.toShort()))
|
||||
check(K.KEYS == mapOf(1 to "one", 2 to "two"))
|
||||
check(K.STRINGS.contentEquals(arrayOf("a", "b")))
|
||||
val emptyArray: Array<String> = K.EMPTY_ARRAY
|
||||
check(emptyArray.isEmpty())
|
||||
val optimized: IntArray = K.BOXED
|
||||
check(optimized.contentEquals(intArrayOf(1, 2)))
|
||||
check(K.NULL_ARRAY.contentEquals(arrayOf<Int?>(1, null)))
|
||||
val nested: List<IntArray> = K.ARRAYS
|
||||
check(nested.single().contentEquals(intArrayOf(1, 2)))
|
||||
check(K.MIN == Long.MIN_VALUE)
|
||||
}
|
||||
""")
|
||||
assertEquals(TaskOutcome.SUCCESS, run(listOf("verifyGenerated")).task(":verifyGenerated")?.outcome)
|
||||
}
|
||||
|
||||
@Test fun `all primitive arrays compile with primitive factories and retain boundary values`() = withProject { dir, run ->
|
||||
dir.writeCompilingProject("""
|
||||
konfig {
|
||||
field("BOOLS", booleanArrayOf(true, false))
|
||||
field("BYTES", byteArrayOf(-128, 127))
|
||||
field("SHORTS", shortArrayOf(-32768, 32767))
|
||||
field("CHARS", charArrayOf('\'', '\\', '\n', '\u0000', '\uD800'))
|
||||
field("INTS", intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE))
|
||||
field("LONGS", longArrayOf(Long.MIN_VALUE, Long.MAX_VALUE))
|
||||
field("FLOATS", floatArrayOf(Float.NaN, Float.POSITIVE_INFINITY, -0.0f))
|
||||
field("DOUBLES", doubleArrayOf(Double.NaN, Double.NEGATIVE_INFINITY, -0.0))
|
||||
field("EMPTY", intArrayOf())
|
||||
}
|
||||
""", """
|
||||
import com.example.BuildKonfig as K
|
||||
fun main() {
|
||||
check(K.BOOLS.contentEquals(booleanArrayOf(true, false)))
|
||||
check(K.BYTES.contentEquals(byteArrayOf(-128, 127)))
|
||||
check(K.SHORTS.contentEquals(shortArrayOf(-32768, 32767)))
|
||||
check(K.CHARS.contentEquals(charArrayOf('\'', '\\', '\n', '\u0000', '\uD800')))
|
||||
check(K.INTS.contentEquals(intArrayOf(Int.MIN_VALUE, Int.MAX_VALUE)))
|
||||
check(K.LONGS.contentEquals(longArrayOf(Long.MIN_VALUE, Long.MAX_VALUE)))
|
||||
check(K.FLOATS.contentEquals(floatArrayOf(Float.NaN, Float.POSITIVE_INFINITY, -0.0f)))
|
||||
check(K.DOUBLES.contentEquals(doubleArrayOf(Double.NaN, Double.NEGATIVE_INFINITY, -0.0)))
|
||||
check(K.EMPTY.isEmpty())
|
||||
}
|
||||
""")
|
||||
run(listOf("verifyGenerated"))
|
||||
val text = dir.resolve("build/generated/konfig/com/example/BuildKonfig.kt").readText()
|
||||
listOf("boolean", "byte", "short", "char", "int", "long", "float", "double").forEach {
|
||||
assertTrue(text.contains("${it}ArrayOf("), text)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `collections support handles scopes common fields and flat dimensions`() = withProject { dir, run ->
|
||||
dir.writeCompilingProject("""
|
||||
konfig {
|
||||
field("GLOBAL", listOf("release")).debug(listOf("debug"))
|
||||
release { field("SCOPED", mapOf("mode" to "release")) }
|
||||
debug { field("SCOPED", mapOf("mode" to "debug")) }
|
||||
dimension("env", defaultTo = "prod") {
|
||||
common { field("FALLBACK", listOf(1, 2)) }
|
||||
variant("prod") { field("HOSTS", arrayOf("prod")) }
|
||||
}
|
||||
flatDimension("region", defaultTo = "eu") {
|
||||
common { field("REGIONS", mapOf("id" to 1)) }
|
||||
variant("eu") { field("PORTS", intArrayOf(443)) }
|
||||
}
|
||||
}
|
||||
""", """
|
||||
import com.example.BuildKonfig as K
|
||||
fun main() {
|
||||
val mode = if (K.IS_DEBUG) "debug" else "release"
|
||||
check(K.GLOBAL == listOf(mode))
|
||||
check(K.SCOPED == mapOf("mode" to mode))
|
||||
check(K.Env.FALLBACK == listOf(1, 2))
|
||||
check(K.Env.HOSTS.contentEquals(arrayOf("prod")))
|
||||
check(K.REGIONS == mapOf("id" to 1))
|
||||
check(K.PORTS.contentEquals(intArrayOf(443)))
|
||||
}
|
||||
""")
|
||||
run(listOf("verifyGenerated", "-Pkonfig.buildtype=DEBUG"))
|
||||
run(listOf("verifyGenerated", "-Pkonfig.buildtype=RELEASE"))
|
||||
}
|
||||
|
||||
@Test fun `provider collections reuse configuration cache and react to changed inputs`() = withProject { dir, run ->
|
||||
dir.writeBuildGradle("""
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig {
|
||||
objectPackage = "com.example"
|
||||
field("VALUES", providers.gradleProperty("items").map { it.split(",") })
|
||||
field("MISSING", providers.gradleProperty("missing").map { listOf(it) })
|
||||
field("PORTS", providers.gradleProperty("port").map { intArrayOf(it.toInt()) })
|
||||
field("MAPPING", providers.gradleProperty("items").map { mapOf(it to listOf(1, 2)) })
|
||||
field("LITERAL", mapOf("nested" to listOf(arrayOf(1, 2))))
|
||||
}
|
||||
""")
|
||||
val args = listOf("generateKonfig", "--configuration-cache", "-Pitems=a,b", "-Pport=443")
|
||||
run(args)
|
||||
val second = run(args)
|
||||
assertTrue(second.output.contains("Configuration cache entry reused"), second.output)
|
||||
assertEquals(TaskOutcome.UP_TO_DATE, second.task(":generateKonfig")?.outcome)
|
||||
val changed = run(args.filterNot { it.startsWith("-Pitems=") } + "-Pitems=c")
|
||||
assertEquals(TaskOutcome.SUCCESS, changed.task(":generateKonfig")?.outcome)
|
||||
val text = dir.resolve("build/generated/konfig/com/example/BuildKonfig.kt").readText()
|
||||
assertTrue(text.contains("listOf<String>(\"c\")"))
|
||||
assertFalse(text.contains("MISSING"))
|
||||
}
|
||||
|
||||
@Test fun `unsupported collection element type fails with useful error`() = withFailingProject { dir, run ->
|
||||
dir.writeBuildGradle("""
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig { field("FILES", listOf(java.io.File("example"))) }
|
||||
""")
|
||||
assertTrue(run(listOf("generateKonfig")).output.contains("unsupported field type"))
|
||||
}
|
||||
|
||||
@Test fun `scope override cannot change a collection element type`() = withFailingProject { dir, run ->
|
||||
dir.writeBuildGradle("""
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig {
|
||||
field("VALUES", listOf(1))
|
||||
debug { field("VALUES", listOf("one")) }
|
||||
}
|
||||
""")
|
||||
assertTrue(run(listOf("generateKonfig")).output.contains("must use the same type"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,198 @@
|
||||
package com.bitsycore.konfig
|
||||
|
||||
import java.io.File
|
||||
import java.net.URLClassLoader
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.Properties
|
||||
import java.util.jar.JarEntry
|
||||
import java.util.jar.JarOutputStream
|
||||
import org.gradle.testkit.runner.TaskOutcome
|
||||
import org.gradle.testkit.runner.GradleRunner
|
||||
import org.junit.Assume.assumeTrue
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConsumerIntegrationFunctionalTest : FunctionalTestBase() {
|
||||
private val kotlinVersion = System.getProperty("konfig.test.kotlinVersion", "2.3.0")
|
||||
private val androidVersion = System.getProperty("konfig.test.androidVersion", "9.3.2")
|
||||
|
||||
@Test fun `JVM compilation and source publication carry generation dependencies`() = withProject { dir, _ ->
|
||||
val run = consumerRunner(dir)
|
||||
dir.consumerSettings()
|
||||
dir.resolve("src/main/kotlin/Use.kt").apply { parentFile.mkdirs(); writeText("val value = com.example.BuildKonfig.VALUE") }
|
||||
dir.writeConsumerBuildGradle("""
|
||||
plugins {
|
||||
kotlin("jvm") version "$kotlinVersion"
|
||||
id("com.bitsycore.konfig")
|
||||
}
|
||||
repositories { mavenCentral() }
|
||||
java { withSourcesJar() }
|
||||
konfig { objectPackage = "com.example"; field("VALUE", listOf("ok")) }
|
||||
""")
|
||||
val args = listOf("compileKotlin", "sourcesJar", "--configuration-cache")
|
||||
assertEquals(TaskOutcome.SUCCESS, run(args).task(":generateKonfig")?.outcome)
|
||||
val second = run(args)
|
||||
assertTrue(second.output.contains("Configuration cache entry reused"), second.output)
|
||||
assertEquals(TaskOutcome.UP_TO_DATE, second.task(":generateKonfig")?.outcome)
|
||||
ZipFile(dir.resolve("build/libs/test-project-sources.jar")).use { jar ->
|
||||
assertNotNull(jar.getEntry("com/example/BuildKonfig.kt"))
|
||||
}
|
||||
run(listOf("clean"))
|
||||
assertNotNull(run(listOf("sourcesJar")).task(":generateKonfig"))
|
||||
}
|
||||
|
||||
@Test fun `KMP commonMain compilation and source publication carry generation dependencies`() = withProject { dir, _ ->
|
||||
val run = consumerRunner(dir)
|
||||
dir.consumerSettings()
|
||||
dir.resolve("src/commonMain/kotlin/Use.kt").apply { parentFile.mkdirs(); writeText("val value = com.example.BuildKonfig.VALUE") }
|
||||
dir.writeConsumerBuildGradle("""
|
||||
plugins {
|
||||
kotlin("multiplatform") version "$kotlinVersion"
|
||||
id("com.bitsycore.konfig")
|
||||
}
|
||||
repositories { mavenCentral() }
|
||||
kotlin { jvm() }
|
||||
konfig { objectPackage = "com.example"; field("VALUE", 42) }
|
||||
""")
|
||||
val args = listOf("compileKotlinJvm", "allMetadataJar", "jvmSourcesJar", "--configuration-cache")
|
||||
assertNotNull(run(args).task(":generateKonfig"))
|
||||
assertTrue(run(args).output.contains("Configuration cache entry reused"))
|
||||
}
|
||||
|
||||
@Test fun `Android variants compile independently and wire lint and sources from a clean build`() = withProject { dir, _ ->
|
||||
val run = consumerRunner(dir, android = true)
|
||||
val sdk = sequenceOf(System.getenv("ANDROID_HOME"), System.getenv("ANDROID_SDK_ROOT"),
|
||||
"${System.getProperty("user.home")}/AppData/Local/Android/Sdk").filterNotNull().map(::File)
|
||||
.firstOrNull { it.resolve("platforms/android-35/android.jar").isFile }
|
||||
assumeTrue("Android SDK platform 35 is required for Android integration tests", sdk != null)
|
||||
dir.consumerSettings()
|
||||
dir.resolve("local.properties").writeText("sdk.dir=${sdk!!.invariantSeparatorsPath}")
|
||||
dir.resolve("src/main/AndroidManifest.xml").apply { parentFile.mkdirs(); writeText("<manifest />") }
|
||||
dir.resolve("src/main/kotlin/Use.kt").apply { parentFile.mkdirs(); writeText("val value = com.example.BuildKonfig.Env.URL") }
|
||||
val externalKotlin = if (androidVersion.startsWith("8.")) "kotlin(\"android\") version \"$kotlinVersion\"" else ""
|
||||
dir.writeConsumerBuildGradle("""
|
||||
plugins {
|
||||
id("com.android.library") version "$androidVersion"
|
||||
$externalKotlin
|
||||
id("com.bitsycore.konfig")
|
||||
}
|
||||
repositories { google(); mavenCentral() }
|
||||
android {
|
||||
namespace = "com.example"
|
||||
compileSdk = 35
|
||||
defaultConfig { minSdk = 21 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
flavorDimensions += "environment"
|
||||
productFlavors {
|
||||
create("prod") { dimension = "environment" }
|
||||
create("preProd") { dimension = "environment" }
|
||||
}
|
||||
publishing { singleVariant("prodRelease") { withSourcesJar() } }
|
||||
}
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile>().configureEach {
|
||||
compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
konfig {
|
||||
objectPackage = "com.example"
|
||||
dimension("env") {
|
||||
androidDimension = "environment"
|
||||
required = true
|
||||
variant("prod") { field("URL", "production") }
|
||||
variant("preProd") { field("URL", "preproduction") }
|
||||
}
|
||||
}
|
||||
""")
|
||||
val args = listOf("assembleProdDebug", "assemblePreProdRelease", "--configuration-cache")
|
||||
val first = run(args)
|
||||
assertNotNull(first.task(":generateProdDebugKonfig"))
|
||||
assertNotNull(first.task(":generatePreProdReleaseKonfig"))
|
||||
assertEquals(null, first.task(":generateKonfig"))
|
||||
dir.assertAndroidConstants("prod-debug", "debug", "prod", "production")
|
||||
dir.assertAndroidConstants("preProd-release", "release", "preProd", "preproduction")
|
||||
assertTrue(run(args).output.contains("Configuration cache entry reused"))
|
||||
run(listOf("clean"))
|
||||
val analysis = run(listOf("lintProdDebug", "sourceProdReleaseJar"))
|
||||
assertNotNull(analysis.task(":generateProdDebugKonfig"))
|
||||
assertNotNull(analysis.task(":generateProdReleaseKonfig"))
|
||||
val sourceJars = dir.resolve("build").walkTopDown().filter { it.isFile && it.name.endsWith("sources.jar") }.toList()
|
||||
assertTrue(sourceJars.any { file -> ZipFile(file).use { it.getEntry("com/example/BuildKonfig.kt") != null } })
|
||||
val aggregate = run(listOf("generateKonfig", "assembleProdDebug", "assemblePreProdRelease", "--configuration-cache"))
|
||||
listOf("ProdDebug", "ProdRelease", "PreProdDebug", "PreProdRelease").forEach {
|
||||
assertNotNull(aggregate.task(":generate${it}Konfig"))
|
||||
}
|
||||
val explicitArgs = listOf("generateProdDebugKonfig", "-Pkonfig.dimension.env=preProd", "--configuration-cache")
|
||||
run(explicitArgs)
|
||||
assertTrue(run(explicitArgs).output.contains("Configuration cache entry reused"))
|
||||
assertTrue(dir.resolve("build/generated/konfig/prodDebug/com/example/BuildKonfig.kt").readText()
|
||||
.contains("const val VARIANT: String = \"preProd\""))
|
||||
dir.resolve("konfig.properties").writeText("konfig.dimension.env=prod")
|
||||
run(listOf("generatePreProdReleaseKonfig"))
|
||||
assertTrue(dir.resolve("build/generated/konfig/preProdRelease/com/example/BuildKonfig.kt").readText()
|
||||
.contains("const val VARIANT: String = \"prod\""))
|
||||
}
|
||||
|
||||
private fun File.consumerSettings() {
|
||||
writePluginRepository()
|
||||
resolve("settings.gradle.kts").writeText("""
|
||||
pluginManagement { repositories { maven { url = uri("repo") }; google(); mavenCentral(); gradlePluginPortal() } }
|
||||
rootProject.name = "test-project"
|
||||
""".trimIndent())
|
||||
}
|
||||
|
||||
// Resolve a real plugin artifact instead of TestKit's isolated injected classloader.
|
||||
private fun consumerRunner(dir: File, android: Boolean = false): (List<String>) -> org.gradle.testkit.runner.BuildResult = { args ->
|
||||
val runner = GradleRunner.create().withProjectDir(dir).withArguments(args + "--stacktrace")
|
||||
if (android && !androidVersion.startsWith("8.")) runner.withGradleVersion("9.5.0")
|
||||
runner.build()
|
||||
}
|
||||
|
||||
private fun File.writeConsumerBuildGradle(script: String) {
|
||||
writeBuildGradle(script.replace("id(\"com.bitsycore.konfig\")", "id(\"com.bitsycore.konfig\") version \"0.0-test\""))
|
||||
}
|
||||
|
||||
private fun File.writePluginRepository() {
|
||||
val metadata = Properties().apply {
|
||||
this@ConsumerIntegrationFunctionalTest.javaClass.classLoader
|
||||
.getResourceAsStream("plugin-under-test-metadata.properties")!!.use { load(it) }
|
||||
}
|
||||
val artifact = resolve("repo/com/bitsycore/test-plugin/0.0-test").apply { mkdirs() }
|
||||
JarOutputStream(artifact.resolve("test-plugin-0.0-test.jar").outputStream()).use { jar ->
|
||||
metadata.getProperty("implementation-classpath").split(File.pathSeparator).map(::File).filter { it.isDirectory }.forEach { root ->
|
||||
root.walkTopDown().filter { it.isFile }.forEach { file ->
|
||||
jar.putNextEntry(JarEntry(file.relativeTo(root).invariantSeparatorsPath))
|
||||
file.inputStream().use { it.copyTo(jar) }
|
||||
jar.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
val pomPrefix = "<project><modelVersion>4.0.0</modelVersion>"
|
||||
artifact.resolve("test-plugin-0.0-test.pom").writeText(
|
||||
"$pomPrefix<groupId>com.bitsycore</groupId><artifactId>test-plugin</artifactId><version>0.0-test</version></project>")
|
||||
val marker = resolve("repo/com/bitsycore/konfig/com.bitsycore.konfig.gradle.plugin/0.0-test").apply { mkdirs() }
|
||||
marker.resolve("com.bitsycore.konfig.gradle.plugin-0.0-test.pom").writeText("""
|
||||
$pomPrefix<groupId>com.bitsycore.konfig</groupId><artifactId>com.bitsycore.konfig.gradle.plugin</artifactId>
|
||||
<version>0.0-test</version><packaging>pom</packaging><dependencies><dependency>
|
||||
<groupId>com.bitsycore</groupId><artifactId>test-plugin</artifactId><version>0.0-test</version>
|
||||
</dependency></dependencies></project>
|
||||
""".trimIndent())
|
||||
}
|
||||
|
||||
private fun File.assertAndroidConstants(variant: String, buildType: String, flavor: String, url: String) {
|
||||
val aar = resolve("build/outputs/aar/test-project-$variant.aar")
|
||||
assertTrue(aar.isFile, aar.path)
|
||||
val classes = resolve("$variant-classes.jar")
|
||||
ZipFile(aar).use { archive -> classes.writeBytes(archive.getInputStream(archive.getEntry("classes.jar")).readBytes()) }
|
||||
URLClassLoader(arrayOf(classes.toURI().toURL()), javaClass.classLoader).use { loader ->
|
||||
val root = loader.loadClass("com.example.BuildKonfig")
|
||||
val env = loader.loadClass("com.example.BuildKonfig\$Env")
|
||||
assertEquals(buildType, root.getField("BUILD_TYPE").get(null))
|
||||
assertEquals(flavor, env.getField("VARIANT").get(null))
|
||||
assertEquals(url, env.getField("URL").get(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,164 @@
|
||||
package com.bitsycore.konfig
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import org.gradle.testkit.runner.TaskOutcome
|
||||
|
||||
class FeatureOptionsFunctionalTest : FunctionalTestBase() {
|
||||
@Test fun `strict and required selections reject missing and unknown values`() = withProject { dir, run ->
|
||||
fun script(setting: String) = """
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig {
|
||||
$setting
|
||||
dimension("env") { variant("prod") { field("URL", "ok") } }
|
||||
}
|
||||
"""
|
||||
dir.writeBuildGradle(script("strictResolution = true"))
|
||||
assertTrue(runner(dir, listOf("generateKonfig")).buildAndFail().output.contains("selection is required"))
|
||||
assertTrue(runner(dir, listOf("generateKonfig", "-Pkonfig.dimension.env=typo")).buildAndFail().output.contains("not a known variant"))
|
||||
assertTrue(runner(dir, listOf("generateKonfig", "-Pkonfig.dimension.env=prod", "-Pkonfig.dimension.typo=prod")).buildAndFail().output.contains("unknown dimension"))
|
||||
assertTrue(runner(dir, listOf("generateKonfig", "-Pkonfig.buildtype=typo")).buildAndFail().output.contains("unknown build type"))
|
||||
val args = listOf("generateKonfig", "-Pkonfig.dimension.env=prod", "--configuration-cache")
|
||||
run(args)
|
||||
assertTrue(run(args).output.contains("Configuration cache entry reused"))
|
||||
dir.resolve("konfig.properties").writeText("konfig.dimension.env=typo")
|
||||
assertTrue(runner(dir, listOf("generateKonfig")).buildAndFail().output.contains("not a known variant"))
|
||||
run(args) // explicit selection overrides the invalid file value
|
||||
dir.resolve("konfig.properties").writeText("")
|
||||
dir.writeBuildGradle(script("").replace("dimension(\"env\") {", "dimension(\"env\") { required = true;"))
|
||||
assertTrue(runner(dir, listOf("generateKonfig")).buildAndFail().output.contains("selection is required"))
|
||||
dir.writeBuildGradle(script(""))
|
||||
run(listOf("generateKonfig")) // optional remains the default
|
||||
}
|
||||
|
||||
@Test fun `schema validation includes inactive variants common fields and build types`() = withProject { dir, run ->
|
||||
val base = """
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig {
|
||||
validateVariantSchema = true
|
||||
dimension("env", defaultTo = "prod") {
|
||||
common { field("SHARED", 1) }
|
||||
variant("prod") { field("URL", "ok") }
|
||||
variant("dev") { REPLACEMENT }
|
||||
}
|
||||
}
|
||||
"""
|
||||
for (replacement in listOf("", "field(\"URL\", 42)", "debug { field(\"URL\", \"dev\") }")) {
|
||||
dir.writeBuildGradle(base.replace("REPLACEMENT", replacement))
|
||||
val failed = runner(dir, listOf("generateKonfig")).buildAndFail()
|
||||
assertTrue(failed.output.contains("schema"), failed.output)
|
||||
}
|
||||
dir.writeBuildGradle(base.replace("REPLACEMENT", "field(\"URL\", \"dev\")"))
|
||||
run(listOf("generateKonfig", "--configuration-cache"))
|
||||
assertTrue(run(listOf("generateKonfig", "--configuration-cache")).output.contains("Configuration cache entry reused"))
|
||||
dir.writeBuildGradle(base.replace("REPLACEMENT", "field(\"URL\", providers.gradleProperty(\"devUrl\"))"))
|
||||
assertTrue(runner(dir, listOf("generateKonfig")).buildAndFail().output.contains("missing=[URL]"))
|
||||
run(listOf("generateKonfig", "-PdevUrl=dev", "--configuration-cache"))
|
||||
dir.writeBuildGradle(base.replace("REPLACEMENT", "").replace("validateVariantSchema = true", "validateVariantSchema = false"))
|
||||
run(listOf("generateKonfig"))
|
||||
dir.writeBuildGradle("""
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig { validateVariantSchema = true; debug { field("ONLY_DEBUG", 1) } }
|
||||
""")
|
||||
assertTrue(runner(dir, listOf("generateKonfig")).buildAndFail().output.contains("global fields differ"))
|
||||
}
|
||||
|
||||
@Test fun `nullable set enum and unsigned values compile and survive configuration caching`() = withProject { dir, run ->
|
||||
dir.writeCompilingProject("""
|
||||
konfig {
|
||||
field<String?>("NULL_TEXT", null)
|
||||
field<Int?>("NUMBER", 7).debug(null)
|
||||
field<String?>("PROVIDED", providers.provider { "provided" })
|
||||
field<String?>("ABSENT", providers.gradleProperty("missing"))
|
||||
field("CONST_TEXT", providers.provider { "constant" })
|
||||
field("INLINE_FLAG", true)
|
||||
field<Set<String?>>("NAMES", setOf("a", null, "a"))
|
||||
field("EMPTY", emptySet<Int>())
|
||||
field("DAY", java.time.DayOfWeek.MONDAY)
|
||||
field("DAYS", setOf(java.time.DayOfWeek.MONDAY, java.time.DayOfWeek.FRIDAY))
|
||||
field("UB", UByte.MAX_VALUE)
|
||||
field("US", UShort.MAX_VALUE)
|
||||
field("UI", UInt.MAX_VALUE)
|
||||
field("UL", ULong.MAX_VALUE)
|
||||
field("UNSIGNED", listOf(0uL, ULong.MAX_VALUE))
|
||||
dimension("env", defaultTo = "prod") {
|
||||
common { field<String?>("OPTIONAL", null) }
|
||||
variant("prod") { debug { field<List<Int>?>("ITEMS", null) } }
|
||||
}
|
||||
}
|
||||
""", """
|
||||
import com.example.BuildKonfig
|
||||
const val copiedConstant: String = BuildKonfig.CONST_TEXT
|
||||
fun main() {
|
||||
check(copiedConstant == "constant" && BuildKonfig.INLINE_FLAG)
|
||||
check(BuildKonfig.NULL_TEXT == null)
|
||||
check(BuildKonfig.NUMBER == null)
|
||||
check(BuildKonfig.PROVIDED == "provided")
|
||||
check(BuildKonfig.NAMES == setOf("a", null))
|
||||
check(BuildKonfig.EMPTY.isEmpty())
|
||||
check(BuildKonfig.DAY == java.time.DayOfWeek.MONDAY)
|
||||
check(BuildKonfig.DAYS.contains(java.time.DayOfWeek.FRIDAY))
|
||||
check(BuildKonfig.UB == UByte.MAX_VALUE && BuildKonfig.US == UShort.MAX_VALUE)
|
||||
check(BuildKonfig.UI == UInt.MAX_VALUE && BuildKonfig.UL == ULong.MAX_VALUE)
|
||||
check(BuildKonfig.UNSIGNED.last() == ULong.MAX_VALUE)
|
||||
check(BuildKonfig.Env.OPTIONAL == null && BuildKonfig.Env.ITEMS == null)
|
||||
}
|
||||
""")
|
||||
val args = listOf("verifyGenerated", "-Pkonfig.buildtype=DEBUG", "--configuration-cache")
|
||||
run(args)
|
||||
assertTrue(!dir.generatedFile().readText().contains("ABSENT"))
|
||||
assertTrue(dir.generatedFile().readText().contains("inline val INLINE_FLAG: Boolean get() = true"))
|
||||
val reused = run(args)
|
||||
assertTrue(reused.output.contains("Configuration cache entry reused"), reused.output)
|
||||
assertEquals(TaskOutcome.UP_TO_DATE, reused.task(":generateKonfig")?.outcome)
|
||||
}
|
||||
|
||||
@Test fun `boxed arrays and fresh nested arrays preserve declared runtime types`() = withProject { dir, run ->
|
||||
dir.writeCompilingProject("""
|
||||
konfig {
|
||||
specializeArrays = false
|
||||
copyArraysOnAccess = true
|
||||
field("BOXED", arrayOf(1, 2))
|
||||
field("PRIMITIVE", intArrayOf(3, 4))
|
||||
field("NESTED", mapOf("items" to listOf(arrayOf(5))))
|
||||
field("DYNAMIC", listOf<Any>(intArrayOf(6)))
|
||||
field<Array<Int>?>("NULL_ARRAY", null)
|
||||
}
|
||||
""", """
|
||||
import com.example.BuildKonfig
|
||||
fun main() {
|
||||
val boxed: Array<Int> = BuildKonfig.BOXED
|
||||
boxed[0] = 99
|
||||
check(BuildKonfig.BOXED[0] == 1)
|
||||
val primitive: IntArray = BuildKonfig.PRIMITIVE
|
||||
primitive[0] = 99
|
||||
check(BuildKonfig.PRIMITIVE[0] == 3)
|
||||
BuildKonfig.NESTED.getValue("items")[0][0] = 99
|
||||
check(BuildKonfig.NESTED.getValue("items")[0][0] == 5)
|
||||
check(BuildKonfig.DYNAMIC[0] is IntArray)
|
||||
check(BuildKonfig.NULL_ARRAY == null)
|
||||
}
|
||||
""")
|
||||
run(listOf("verifyGenerated", "--configuration-cache"))
|
||||
assertTrue(run(listOf("verifyGenerated", "--configuration-cache")).output.contains("Configuration cache entry reused"))
|
||||
}
|
||||
|
||||
@Test fun `array policy changes invalidate generation while defaults preserve shared arrays`() = withProject { dir, run ->
|
||||
fun script(settings: String) = """
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig {
|
||||
$settings
|
||||
field("VALUES", arrayOf(1, 2))
|
||||
}
|
||||
"""
|
||||
val args = listOf("generateKonfig", "--configuration-cache")
|
||||
dir.writeBuildGradle(script(""))
|
||||
run(args)
|
||||
assertTrue(dir.generatedFile().readText().contains("val VALUES: IntArray = intArrayOf(1, 2)"))
|
||||
dir.writeBuildGradle(script("specializeArrays = false; copyArraysOnAccess = true"))
|
||||
assertEquals(TaskOutcome.SUCCESS, run(args).task(":generateKonfig")?.outcome)
|
||||
assertTrue(dir.generatedFile().readText().contains("val VALUES: Array<Int> get() = arrayOf<Int>(1, 2)"))
|
||||
assertEquals(TaskOutcome.UP_TO_DATE, run(args).task(":generateKonfig")?.outcome)
|
||||
}
|
||||
}
|
||||
@@ -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,92 @@
|
||||
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 {
|
||||
|
||||
protected fun runner(projectDir: File, args: List<String>): GradleRunner = GradleRunner.create()
|
||||
.withProjectDir(projectDir).withPluginClasspath().withArguments(args + "--stacktrace")
|
||||
|
||||
/**
|
||||
* 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> ->
|
||||
runner(projectDir, 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> ->
|
||||
runner(projectDir, 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 =
|
||||
resolve("build/generated/konfig").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())
|
||||
/** Compile and execute the generated code using the compiler bundled with TestKit's Gradle. */
|
||||
protected fun File.writeCompilingProject(dsl: String, assertions: String) {
|
||||
resolve("Check.kt").writeText(assertions.trimIndent())
|
||||
writeBuildGradle("""
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig { objectPackage = "com.example" }
|
||||
${dsl.trimIndent()}
|
||||
val compilerLib = gradle.gradleHomeDir!!.resolve("lib")
|
||||
val stdlib = compilerLib.listFiles()!!.first { it.name.startsWith("kotlin-stdlib-") }
|
||||
val generated = layout.buildDirectory.dir("generated/konfig")
|
||||
val classes = layout.buildDirectory.dir("verified-classes")
|
||||
val compileGenerated = tasks.register<JavaExec>("compileGenerated") {
|
||||
dependsOn("generateKonfig")
|
||||
classpath = files(fileTree(compilerLib) { include("*.jar") })
|
||||
mainClass.set("org.jetbrains.kotlin.cli.jvm.K2JVMCompiler")
|
||||
args("-no-stdlib", "-no-reflect", "-classpath", stdlib.absolutePath,
|
||||
"-d", classes.get().asFile.absolutePath,
|
||||
generated.get().asFile.absolutePath, file("Check.kt").absolutePath)
|
||||
}
|
||||
tasks.register<JavaExec>("verifyGenerated") {
|
||||
dependsOn(compileGenerated)
|
||||
classpath = files(classes, stdlib)
|
||||
mainClass.set("CheckKt")
|
||||
}
|
||||
""")
|
||||
}
|
||||
}
|
||||
@@ -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,640 +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("const val LOGGING: Boolean = 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.set(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.set("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"))
|
||||
}
|
||||
|
||||
// ─── Attribute schema ─────────────────────────────────────────────────────
|
||||
|
||||
@Test fun `attribute schema rules are registered automatically`() = withProject { dir, run ->
|
||||
dir.resolve("build.gradle.kts").writeText("""
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
group = "com.example"
|
||||
konfig {}
|
||||
|
||||
tasks.register("checkSchema") {
|
||||
doLast {
|
||||
val schema = project.dependencies.attributesSchema
|
||||
val attr = com.bitsycore.konfig.BuildType.ATTRIBUTE
|
||||
schema.getMatchingStrategy(attr)
|
||||
println("SCHEMA_OK")
|
||||
}
|
||||
}
|
||||
""".trimIndent())
|
||||
|
||||
val result = run(listOf("checkSchema"))
|
||||
|
||||
assertEquals(TaskOutcome.SUCCESS, result.task(":checkSchema")?.outcome)
|
||||
assertTrue(result.output.contains("SCHEMA_OK"))
|
||||
}
|
||||
|
||||
// ─── 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 const 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.set(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 const val VARIANT"))
|
||||
}
|
||||
|
||||
// ─── 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)
|
||||
}
|
||||
}
|
||||
@@ -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,147 @@
|
||||
package com.bitsycore.konfig
|
||||
|
||||
import org.gradle.testkit.runner.TaskOutcome
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SafetyFunctionalTest : FunctionalTestBase() {
|
||||
@Test fun `metadata escaping produces compilable Kotlin and preserves values`() = withProject { dir, run ->
|
||||
dir.resolve("settings.gradle.kts").writeText("rootProject.name = \"module\\\$value\"")
|
||||
dir.writeCompilingProject("""
|
||||
val selected = "qa\"\\path\n${'$'}{'$'}value"
|
||||
konfig {
|
||||
dimension("env*/comment", objectNameOverride = "Env", defaultTo = selected) {
|
||||
variant(selected) { field("VALUE", "ok") }
|
||||
}
|
||||
flatDimension("region", defaultTo = selected) { variant(selected) {} }
|
||||
}
|
||||
""", """
|
||||
import com.example.BuildKonfig as K
|
||||
fun main() {
|
||||
check(K.MODULE_NAME == "module${'$'}{'$'}value")
|
||||
check(K.Env.VARIANT == "qa\"\\path\n${'$'}{'$'}value")
|
||||
check(K.REGION_VARIANT == K.Env.VARIANT)
|
||||
}
|
||||
""")
|
||||
run(listOf("verifyGenerated"))
|
||||
}
|
||||
|
||||
@Test fun `invalid Kotlin names fail before writing sources`() = withFailingProject { dir, run ->
|
||||
val cases = listOf(
|
||||
"objectName = \"when\"", "objectName = \"___\"",
|
||||
"objectPackage = \"com.123example\"", "objectPackage = \"com.class\"",
|
||||
"field(\"when\", 1)", "field(\"_\", 1)",
|
||||
"dimension(\"env\", objectNameOverride = \"Invalid-Name\", defaultTo = \"prod\") { variant(\"prod\") {} }",
|
||||
"flatDimension(\"123env\", defaultTo = \"prod\") { variant(\"prod\") {} }",
|
||||
"dimension(\"env|other\", defaultTo = \"prod\") { variant(\"prod\") {} }",
|
||||
)
|
||||
for (dsl in cases) {
|
||||
dir.writeBuildGradle("""
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig { $dsl }
|
||||
""")
|
||||
val result = run(listOf("generateKonfig"))
|
||||
assertTrue(result.output.contains("not valid") || result.output.contains("invalid") || result.output.contains("not a valid"), result.output)
|
||||
assertFalse(dir.resolve("build/generated/konfig").walkTopDown().any { it.extension == "kt" })
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `all generated scopes reject collisions`() = withFailingProject { dir, run ->
|
||||
val cases = listOf(
|
||||
"field(\"BUILD_TYPE\", \"custom\")",
|
||||
"field(\"MODULE_NAME\", \"custom\")",
|
||||
"field(\"IS_DEBUG\", false)",
|
||||
"dimension(\"env\", defaultTo = \"prod\") { variant(\"prod\") { field(\"VARIANT\", \"custom\") } }",
|
||||
"dimension(\"one\", \"Same\", \"prod\") { variant(\"prod\") {} }; dimension(\"two\", \"Same\", \"prod\") { variant(\"prod\") {} }",
|
||||
"dimension(\"env\", defaultTo = \"prod\") { variant(\"prod\") {} }; field(\"Env\", 1)",
|
||||
"dimension(\"env\", defaultTo = \"prod\") { variant(\"prod\") {} }; flatDimension(\"flat\", \"prod\") { variant(\"prod\") { field(\"Env\", 1) } }",
|
||||
)
|
||||
for (dsl in cases) {
|
||||
dir.writeBuildGradle("""
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig { $dsl }
|
||||
""")
|
||||
assertTrue(run(listOf("generateKonfig")).output.contains("collides"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `shared directory neighbors survive regeneration renaming and cache restoration`() = withProject { dir, run ->
|
||||
val neighbor = dir.resolve("shared/Keep.kt").apply { parentFile.mkdirs(); writeText("// user source") }
|
||||
dir.writeBuildGradle("""
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig {
|
||||
objectPackage = "com.example"
|
||||
objectName = providers.gradleProperty("name").get()
|
||||
outputDir.set(layout.projectDirectory.dir("shared"))
|
||||
field("VALUE", 12)
|
||||
}
|
||||
""")
|
||||
val args = listOf("generateKonfig", "--build-cache", "--configuration-cache")
|
||||
run(args + "-Pname=Alpha")
|
||||
val alpha = dir.resolve("shared/com/example/Alpha.kt")
|
||||
val beta = dir.resolve("shared/com/example/Beta.kt")
|
||||
assertTrue(alpha.delete())
|
||||
assertEquals(TaskOutcome.FROM_CACHE, run(args + "-Pname=Alpha").task(":generateKonfig")?.outcome)
|
||||
assertEquals("// user source", neighbor.readText())
|
||||
run(args + "-Pname=Beta")
|
||||
assertTrue(beta.isFile)
|
||||
assertFalse(alpha.exists())
|
||||
run(args + "-Pname=Alpha")
|
||||
assertTrue(alpha.isFile)
|
||||
assertFalse(beta.exists())
|
||||
assertEquals("// user source", neighbor.readText())
|
||||
}
|
||||
|
||||
@Test fun `validation failure preserves the previous generated source`() = withProject { dir, run ->
|
||||
val script = """
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig { field("VALUE", 1) }
|
||||
"""
|
||||
dir.writeBuildGradle(script)
|
||||
run(listOf("generateKonfig"))
|
||||
val generated = dir.generatedFile()
|
||||
val before = generated.readText()
|
||||
dir.writeBuildGradle(script + """
|
||||
konfig { flatDimension("env", "prod") { variant("prod") { field("VALUE", 2) } } }
|
||||
""")
|
||||
assertTrue(runner(dir, listOf("generateKonfig")).buildAndFail().output.contains("collides"))
|
||||
assertEquals(before, generated.readText())
|
||||
}
|
||||
|
||||
@Test fun `generation never overwrites an existing user source`() = withFailingProject { dir, run ->
|
||||
val source = dir.resolve("shared/com/example/BuildKonfig.kt").apply { parentFile.mkdirs(); writeText("// user source") }
|
||||
dir.writeBuildGradle("""
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig { objectPackage = "com.example"; outputDir.set(layout.projectDirectory.dir("shared")) }
|
||||
""")
|
||||
assertTrue(run(listOf("generateKonfig", "--no-build-cache")).output.contains("refusing to overwrite"))
|
||||
assertEquals("// user source", source.readText())
|
||||
}
|
||||
|
||||
@Test fun `conflicting shared build types fail but an explicit selection works`() = withProject { dir, run ->
|
||||
dir.writeBuildGradle("""
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
tasks.register("assembleDebug") { dependsOn("generateKonfig") }
|
||||
tasks.register("assembleRelease") { dependsOn("generateKonfig") }
|
||||
""")
|
||||
val args = listOf("assembleDebug", "assembleRelease")
|
||||
assertTrue(runner(dir, args).buildAndFail().output.contains("conflicting debug and release"))
|
||||
run(args + "-Pkonfig.buildtype=DEBUG")
|
||||
assertTrue(dir.generatedFile().readText().contains("BUILD_TYPE: String = \"debug\""))
|
||||
}
|
||||
|
||||
@Test fun `separate prod and preProd requests conflict instead of longest match winning`() = withProject { dir, run ->
|
||||
dir.writeBuildGradle("""
|
||||
plugins { id("com.bitsycore.konfig") }
|
||||
konfig { dimension("env") { variant("prod") {}; variant("preProd") {} } }
|
||||
tasks.register("assembleProdRelease") { dependsOn("generateKonfig") }
|
||||
tasks.register("assemblePreProdRelease") { dependsOn("generateKonfig") }
|
||||
""")
|
||||
val args = listOf("assembleProdRelease", "assemblePreProdRelease")
|
||||
assertTrue(runner(dir, args).buildAndFail().output.contains("conflicting variants"))
|
||||
run(args + "-Pkonfig.dimension.env=prod")
|
||||
assertTrue(dir.generatedFile().readText().contains("VARIANT: String = \"prod\""))
|
||||
}
|
||||
}
|
||||
@@ -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,51 +0,0 @@
|
||||
package com.bitsycore.konfig
|
||||
|
||||
import org.gradle.api.attributes.Attribute
|
||||
import org.gradle.api.attributes.AttributeCompatibilityRule
|
||||
import org.gradle.api.attributes.AttributeDisambiguationRule
|
||||
import org.gradle.api.attributes.CompatibilityCheckDetails
|
||||
import org.gradle.api.attributes.MultipleCandidatesDetails
|
||||
import kotlin.enums.enumEntries
|
||||
|
||||
enum class BuildType(val value: String) {
|
||||
DEBUG("debug"),
|
||||
RELEASE("release");
|
||||
|
||||
companion object {
|
||||
val ATTRIBUTE: Attribute<BuildType> = Attribute.of("com.bitsycore.buildtype", BuildType::class.java)
|
||||
private val DEBUG_REGEX = Regex("""(?<![a-z])debug(?![a-z])|(?<![A-Z])Debug(?![a-z])""")
|
||||
private val RELEASE_REGEX = Regex("""(?<![a-z])release(?![a-z])|(?<![A-Z])Release(?![a-z])""")
|
||||
|
||||
fun resolve(value: String): BuildType? {
|
||||
enumEntries<BuildType>().firstOrNull { it.name == value }?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val debugResult = DEBUG_REGEX.containsMatchIn(value)
|
||||
val releaseResult = RELEASE_REGEX.containsMatchIn(value)
|
||||
|
||||
return when {
|
||||
debugResult && releaseResult.not() -> DEBUG
|
||||
releaseResult && debugResult.not() -> RELEASE
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CompatibilityRule : AttributeCompatibilityRule<BuildType> {
|
||||
override fun execute(details: CompatibilityCheckDetails<BuildType>) {
|
||||
details.compatible()
|
||||
}
|
||||
}
|
||||
|
||||
class DisambiguationRule : AttributeDisambiguationRule<BuildType> {
|
||||
override fun execute(details: MultipleCandidatesDetails<BuildType>) {
|
||||
val consumerValue = details.consumerValue
|
||||
if (consumerValue != null && details.candidateValues.contains(consumerValue)) {
|
||||
details.closestMatch(consumerValue)
|
||||
} else if (details.candidateValues.contains(RELEASE)) {
|
||||
details.closestMatch(RELEASE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.bitsycore.konfig
|
||||
|
||||
import org.gradle.api.provider.ProviderFactory
|
||||
|
||||
class DimensionConfig @PublishedApi internal constructor(
|
||||
val dimensionName: String,
|
||||
val objectNameOverride: String?,
|
||||
val defaultVariant: String?,
|
||||
private val providerFactory: ProviderFactory
|
||||
) {
|
||||
@PublishedApi
|
||||
internal val variants: MutableMap<String, VariantConfig> = mutableMapOf()
|
||||
|
||||
fun variant(name: String, config: VariantConfig.() -> Unit) {
|
||||
val v = VariantConfig(name, providerFactory)
|
||||
config(v)
|
||||
variants[name] = v
|
||||
}
|
||||
|
||||
/** Derived Kotlin object name: override or CamelCase of dimensionName. */
|
||||
fun objectName(): String = objectNameOverride
|
||||
?: dimensionName
|
||||
.split(Regex("[-_\\s]+"))
|
||||
.joinToString("") { part ->
|
||||
part.lowercase().replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.bitsycore.konfig
|
||||
|
||||
import org.gradle.api.provider.Provider
|
||||
import org.gradle.api.provider.ProviderFactory
|
||||
|
||||
class FieldConfig<T : Any> @PublishedApi internal constructor(
|
||||
val fieldName: String,
|
||||
internal val type: Class<T>,
|
||||
private val providerFactory: ProviderFactory,
|
||||
internal val default: Provider<T>
|
||||
) {
|
||||
internal val buildTypeOverrides: MutableMap<BuildType, Provider<T>> = mutableMapOf()
|
||||
|
||||
fun debug(value: T) {
|
||||
buildTypeOverrides[BuildType.DEBUG] = providerFactory.provider { value }
|
||||
}
|
||||
|
||||
fun debug(value: Provider<T>) {
|
||||
buildTypeOverrides[BuildType.DEBUG] = value
|
||||
}
|
||||
|
||||
fun release(value: T) {
|
||||
buildTypeOverrides[BuildType.RELEASE] = providerFactory.provider { value }
|
||||
}
|
||||
|
||||
fun release(value: Provider<T>) {
|
||||
buildTypeOverrides[BuildType.RELEASE] = value
|
||||
}
|
||||
|
||||
internal fun resolve(buildType: BuildType): Provider<T> =
|
||||
buildTypeOverrides[buildType] ?: default
|
||||
}
|
||||
@@ -1,318 +1,354 @@
|
||||
package com.bitsycore.konfig
|
||||
|
||||
import com.bitsycore.konfig.types.BuildType
|
||||
import com.bitsycore.konfig.types.Visibility
|
||||
import com.bitsycore.konfig.types.isValidKotlinIdentifier
|
||||
import com.bitsycore.konfig.types.toVariantConstName
|
||||
import com.bitsycore.konfig.types.toKotlinStringLiteral
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
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
|
||||
import org.gradle.api.tasks.Internal
|
||||
import org.gradle.api.tasks.OutputFile
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
/**
|
||||
* Generates the `BuildKonfig` (or custom named) Kotlin object.
|
||||
*
|
||||
* Dimension data is encoded into flat maps keyed by "<dimensionName>|<fieldName>" so that
|
||||
* all inputs are plain Gradle property types — no managed-type nesting required.
|
||||
* Fields are stored in flat [MapProperty]<String, String> maps where each value is
|
||||
* type-encoded as `"<TYPE>:<literal>"` (e.g. `"String:hello"`, `"Int:42"`).
|
||||
* Dimension fields use keys of the form `"<dimName>|<fieldName>"`.
|
||||
* Collections use `"Value:<Kotlin type>\n<initializer>"`, with literals escaped by
|
||||
* the encoder. Only strings, not reflection types or collection instances, reach task inputs.
|
||||
* This collapses 6 separate per-type maps down to one per scope.
|
||||
*/
|
||||
@CacheableTask
|
||||
abstract class GenerateKonfigTask : DefaultTask() {
|
||||
|
||||
@get:Input abstract val packageName: Property<String>
|
||||
@get:Input abstract val moduleName: Property<String>
|
||||
@get:Input abstract val objectName: Property<String>
|
||||
@get:Input abstract val objectVisibility: Property<Visibility>
|
||||
@get:Input abstract val buildType: Property<BuildType>
|
||||
// ==========================================================
|
||||
// MARK: General Settings
|
||||
// ==========================================================
|
||||
|
||||
// ── Detection sources (for logging) ──────────────────────────────────────
|
||||
/** Human-readable explanation of why the current build type was chosen. */
|
||||
@get:Input abstract val buildTypeSource: Property<String>
|
||||
@get:Input abstract val moduleName: Property<String>
|
||||
@get:Input abstract val buildType: Property<BuildType>
|
||||
|
||||
// ==========================================================
|
||||
// MARK: Object Settings
|
||||
// ==========================================================
|
||||
|
||||
@get:Input abstract val objectPackage: Property<String>
|
||||
@get:Input abstract val objectName: Property<String>
|
||||
@get:Input abstract val objectVisibility: Property<Visibility>
|
||||
|
||||
/**
|
||||
* 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>"`.
|
||||
* TAG values: OK | WARN_UNKNOWN | WARN_AMBIGUOUS | SKIP | ERROR
|
||||
*/
|
||||
@get:Input abstract val dimensionResolutionLog: MapProperty<String, String>
|
||||
|
||||
// ── Global (top-level) fields ─────────────────────────────────────────────
|
||||
@get:Input abstract val globalStringFields: MapProperty<String, String>
|
||||
@get:Input abstract val globalBooleanFields: MapProperty<String, Boolean>
|
||||
@get:Input abstract val globalIntFields: MapProperty<String, Int>
|
||||
@get:Input abstract val globalLongFields: MapProperty<String, Long>
|
||||
@get:Input abstract val globalFloatFields: MapProperty<String, Float>
|
||||
@get:Input abstract val globalDoubleFields: MapProperty<String, Double>
|
||||
// ==========================================================
|
||||
// MARK: Fields
|
||||
// ==========================================================
|
||||
|
||||
// ── Dimension metadata ────────────────────────────────────────────────────
|
||||
/** Ordered list of active dimension names, e.g. ["env", "region"]. */
|
||||
/** Global (top-level) fields: `fieldName -> "TYPE:value"`. */
|
||||
@get:Input abstract val globalFields: MapProperty<String, String>
|
||||
|
||||
// ==========================================================
|
||||
// MARK: Dimensions
|
||||
// ==========================================================
|
||||
|
||||
/** Ordered list of active dimension names. */
|
||||
@get:Input abstract val activeDimensionNames: ListProperty<String>
|
||||
/** dimName -> Kotlin object name, e.g. "env" -> "Environment". */
|
||||
/** 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, e.g. "env" -> "dev". */
|
||||
/** `dimName -> selected variant`. */
|
||||
@get:Input abstract val dimensionActiveVariants: MapProperty<String, String>
|
||||
|
||||
// ── Dimension fields (key = "<dimName>|<fieldName>") ─────────────────────
|
||||
@get:Input abstract val dimensionStringFields: MapProperty<String, String>
|
||||
@get:Input abstract val dimensionBooleanFields: MapProperty<String, Boolean>
|
||||
@get:Input abstract val dimensionIntFields: MapProperty<String, Int>
|
||||
@get:Input abstract val dimensionLongFields: MapProperty<String, Long>
|
||||
@get:Input abstract val dimensionFloatFields: MapProperty<String, Float>
|
||||
@get:Input abstract val dimensionDoubleFields: MapProperty<String, Double>
|
||||
/** Dimension fields: `"<dimName>|<fieldName>" -> "TYPE:value"`. */
|
||||
@get:Input abstract val dimensionFields: MapProperty<String, String>
|
||||
|
||||
@get:OutputDirectory abstract val outputDirectory: DirectoryProperty
|
||||
// Register only the generated file as output: Gradle must never own neighboring files.
|
||||
@get:Internal abstract val outputDirectory: DirectoryProperty
|
||||
/** A directory view that carries generatedFile's producer without owning the directory. */
|
||||
@get:Internal abstract val sourceDirectory: DirectoryProperty
|
||||
@get:OutputFile abstract val ownershipFile: RegularFileProperty
|
||||
@get:OutputFile abstract val generatedFile: RegularFileProperty
|
||||
|
||||
init {
|
||||
generatedFile.convention(outputDirectory.zip(objectPackage) { dir, pkg -> dir.dir(pkg.replace('.', '/')) }
|
||||
.zip(objectName) { dir, name -> dir.file("$name.kt") })
|
||||
sourceDirectory.convention(generatedFile.zip(outputDirectory) { _, directory -> directory })
|
||||
outputs.doNotCacheIf("Output ownership must be checked or a renamed output cleaned up") {
|
||||
val record = ownershipFile.get().asFile
|
||||
val target = generatedFile.get().asFile
|
||||
!target.canonicalFile.toPath().startsWith(outputDirectory.get().asFile.canonicalFile.toPath()) ||
|
||||
(target.exists() && !target.readText().contains(GENERATED_MARKER)) ||
|
||||
(record.exists() && record.readText() != "${objectPackage.get().replace('.', '/')}/${objectName.get()}.kt")
|
||||
}
|
||||
}
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val pkg = packageName.get()
|
||||
val objName = objectName.get()
|
||||
val btVal = buildType.get()
|
||||
val mod = moduleName.get()
|
||||
val vis = objectVisibility.get()
|
||||
val pkg = objectPackage.get()
|
||||
val objName = objectName.get()
|
||||
val btVal = buildType.get()
|
||||
val mod = moduleName.get()
|
||||
val vis = objectVisibility.get()
|
||||
val visPrefix = when (vis) {
|
||||
Visibility.INTERNAL -> "internal "
|
||||
Visibility.PUBLIC -> "public "
|
||||
}
|
||||
val isDebug = btVal == BuildType.DEBUG
|
||||
Visibility.INTERNAL -> "internal "
|
||||
Visibility.PUBLIC -> "public "
|
||||
}
|
||||
|
||||
val isDebug = btVal == BuildType.DEBUG
|
||||
|
||||
// ── Step 1: validate ─────────────────────────────────────────────────
|
||||
validate(mod, objName, pkg)
|
||||
|
||||
// ── Step 2: log detection results ────────────────────────────────────
|
||||
logResolution(mod, btVal, objName, pkg)
|
||||
|
||||
// ── Step 3: generate ─────────────────────────────────────────────────
|
||||
val outDir = outputDirectory.get().asFile
|
||||
if (outDir.exists()) outDir.deleteRecursively()
|
||||
val pkgDir = outDir.resolve(pkg.replace('.', '/'))
|
||||
pkgDir.mkdirs()
|
||||
|
||||
val gStr = globalStringFields.get()
|
||||
val gBool = globalBooleanFields.get()
|
||||
val gInt = globalIntFields.get()
|
||||
val gLong = globalLongFields.get()
|
||||
val gFlt = globalFloatFields.get()
|
||||
val gDbl = globalDoubleFields.get()
|
||||
val gFields = globalFields.get()
|
||||
val dFields = dimensionFields.get()
|
||||
val dimNames = activeDimensionNames.get()
|
||||
val dimObjN = dimensionObjectNames.get()
|
||||
val dimVars = dimensionActiveVariants.get()
|
||||
val flatDims = flatDimensionNames.get()
|
||||
|
||||
val dStr = dimensionStringFields.get()
|
||||
val dBool = dimensionBooleanFields.get()
|
||||
val dInt = dimensionIntFields.get()
|
||||
val dLong = dimensionLongFields.get()
|
||||
val dFlt = dimensionFloatFields.get()
|
||||
val dDbl = dimensionDoubleFields.get()
|
||||
|
||||
val dimNames = activeDimensionNames.get()
|
||||
val dimObjNames = dimensionObjectNames.get()
|
||||
val dimVariants = dimensionActiveVariants.get()
|
||||
|
||||
val globalFieldCount = gStr.size + gBool.size + gInt.size + gLong.size + gFlt.size + gDbl.size
|
||||
checkRootCollisions(mod, dimNames, gFields, dFields, dimVars)
|
||||
|
||||
val content = buildString {
|
||||
appendLine("@file:Suppress(\"RedundantVisibilityModifier\")")
|
||||
appendLine("""@file:Suppress("RedundantVisibilityModifier")""")
|
||||
appendLine()
|
||||
appendLine("package $pkg")
|
||||
appendLine()
|
||||
appendLine("// Generated by buildkonfig-gradle-plugin -- do not edit")
|
||||
appendLine("// ============= DO NOT EDIT =============")
|
||||
appendLine("// Generated by buildkonfig-gradle-plugin ")
|
||||
appendLine("// =======================================")
|
||||
appendLine()
|
||||
appendLine("${visPrefix}object $objName {")
|
||||
appendLine()
|
||||
appendLine(" const val BUILD_TYPE: String = \"${btVal.name.lowercase()}\"")
|
||||
appendLine(" const val MODULE_NAME: String = \"$mod\"")
|
||||
appendLine(""" const val BUILD_TYPE: String = "${btVal.name.lowercase()}"""")
|
||||
appendLine(" const val MODULE_NAME: String = ${mod.toKotlinStringLiteral()}")
|
||||
if (isDebug)
|
||||
appendLine(" inline val IS_DEBUG: Boolean get() = true")
|
||||
else
|
||||
appendLine(" const val IS_DEBUG: Boolean = false")
|
||||
|
||||
// Global fields
|
||||
if (gStr.isNotEmpty() || gBool.isNotEmpty() || gInt.isNotEmpty() ||
|
||||
gLong.isNotEmpty() || gFlt.isNotEmpty() || gDbl.isNotEmpty()
|
||||
) {
|
||||
if (gFields.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendFields(" ", gStr, gBool, gInt, gLong, gFlt, gDbl)
|
||||
appendEncodedFields(" ", gFields, btVal)
|
||||
}
|
||||
|
||||
// Dimension nested objects
|
||||
for (dimName in dimNames) {
|
||||
val dimObjName = dimObjNames[dimName] ?: continue
|
||||
val activeVariant = dimVariants[dimName] ?: continue
|
||||
val activeVariant = dimVars[dimName] ?: continue
|
||||
val prefix = "$dimName|"
|
||||
val fields = dFields
|
||||
.filterKeys { it.startsWith(prefix) }
|
||||
.mapKeys { (k, _) -> k.removePrefix(prefix) }
|
||||
|
||||
val sF = dStr.filterKeys { it.startsWith(prefix) }.mapKeys { (k, _) -> k.removePrefix(prefix) }
|
||||
val bF = dBool.filterKeys { it.startsWith(prefix) }.mapKeys { (k, _) -> k.removePrefix(prefix) }
|
||||
val iF = dInt.filterKeys { it.startsWith(prefix) }.mapKeys { (k, _) -> k.removePrefix(prefix) }
|
||||
val lF = dLong.filterKeys { it.startsWith(prefix) }.mapKeys { (k, _) -> k.removePrefix(prefix) }
|
||||
val fF = dFlt.filterKeys { it.startsWith(prefix) }.mapKeys { (k, _) -> k.removePrefix(prefix) }
|
||||
val dF = dDbl.filterKeys { it.startsWith(prefix) }.mapKeys { (k, _) -> k.removePrefix(prefix) }
|
||||
|
||||
appendLine()
|
||||
appendLine(" ${visPrefix}object $dimObjName /*$dimName*/ {")
|
||||
appendLine()
|
||||
appendLine(" ${visPrefix}const val VARIANT: String = \"$activeVariant\"")
|
||||
if (sF.isNotEmpty() || bF.isNotEmpty() || iF.isNotEmpty() ||
|
||||
lF.isNotEmpty() || fF.isNotEmpty() || dF.isNotEmpty()
|
||||
) {
|
||||
if (dimName in flatDims) {
|
||||
// Flat dimension: fields live directly on the root object.
|
||||
appendLine()
|
||||
appendFields(" ", sF, bF, iF, lF, fF, dF)
|
||||
appendLine(" // dimension: ${dimName.safeComment()} (flat), variant: ${activeVariant.safeComment()}")
|
||||
appendLine(" const val ${dimName.toVariantConstName()}: String = ${activeVariant.toKotlinStringLiteral()}")
|
||||
if (fields.isNotEmpty()) {
|
||||
appendEncodedFields(" ", fields, btVal)
|
||||
}
|
||||
} else {
|
||||
val dimObjName = dimObjN[dimName] ?: continue
|
||||
appendLine()
|
||||
appendLine(" ${visPrefix}object $dimObjName /*${dimName.safeComment()}*/ {")
|
||||
appendLine()
|
||||
appendLine(" const val VARIANT: String = ${activeVariant.toKotlinStringLiteral()}")
|
||||
if (fields.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendEncodedFields(" ", fields, btVal)
|
||||
}
|
||||
appendLine(" }")
|
||||
}
|
||||
appendLine(" }")
|
||||
|
||||
// Log dimension field details at --info level
|
||||
val dimFields = sF.keys + bF.keys + iF.keys + lF.keys + fF.keys + dF.keys
|
||||
logger.info("konfig [$mod]: dim '$dimName' fields: ${dimFields.sorted().joinToString()}")
|
||||
logger.info("konfig [$mod]: dim '$dimName' fields: ${fields.keys.sorted().joinToString()}")
|
||||
}
|
||||
|
||||
appendLine()
|
||||
appendLine("}")
|
||||
}
|
||||
|
||||
val outFile = pkgDir.resolve("$objName.kt")
|
||||
outFile.writeText(content)
|
||||
val outFile = generatedFile.get().asFile
|
||||
val rootPath = outDir.canonicalFile.toPath()
|
||||
if (!outFile.canonicalFile.toPath().startsWith(rootPath))
|
||||
throw GradleException("konfig: generated file escapes outputDirectory through a symbolic link")
|
||||
if (outFile.exists() && !outFile.readText().contains(GENERATED_MARKER))
|
||||
throw GradleException("konfig: refusing to overwrite non-generated file '$outFile'")
|
||||
pkgDir.mkdirs()
|
||||
val temporary = Files.createTempFile(pkgDir.toPath(), ".konfig-", ".tmp")
|
||||
try {
|
||||
Files.writeString(temporary, content)
|
||||
Files.move(temporary, outFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary)
|
||||
}
|
||||
val record = ownershipFile.get().asFile
|
||||
if (record.exists()) {
|
||||
val previous = outDir.resolve(record.readText())
|
||||
if (previous.canonicalFile != outFile.canonicalFile && previous.isFile &&
|
||||
previous.canonicalFile.toPath().startsWith(rootPath) && previous.readText().contains(GENERATED_MARKER)) {
|
||||
Files.delete(previous.toPath())
|
||||
}
|
||||
}
|
||||
record.parentFile.mkdirs()
|
||||
record.writeText("${pkg.replace('.', '/')}/$objName.kt")
|
||||
|
||||
// Generation summary (always visible)
|
||||
val dimSummary = if (dimNames.isEmpty()) "no dimensions"
|
||||
val dimSummary = if (dimNames.isEmpty()) "no dimensions"
|
||||
else "${dimNames.size} dimension(s): ${dimNames.joinToString { "'$it'" }}"
|
||||
val fieldSummary = if (globalFieldCount == 0) "no global fields"
|
||||
else "$globalFieldCount global field(s)"
|
||||
val fieldSummary = if (gFields.isEmpty()) "no global fields"
|
||||
else "${gFields.size} global field(s)"
|
||||
logger.lifecycle("konfig [$mod]: generated $objName.kt ($fieldSummary, $dimSummary)")
|
||||
logger.info("konfig [$mod]: output -> ${outFile.absolutePath}")
|
||||
}
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────
|
||||
// ==============================================================================
|
||||
// MARK: Validation
|
||||
// ==============================================================================
|
||||
|
||||
private fun validate(mod: String, objName: String, pkg: String) {
|
||||
// Check for ERROR-tagged dimension resolutions (e.g. bad defaultTo)
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
dimensionResolutionLog.get().forEach { (dimName, encoded) ->
|
||||
val parts = encoded.split("\t", limit = 3)
|
||||
if (parts[0] == "ERROR") {
|
||||
errors += "dimension '$dimName': ${parts.getOrElse(2) { "unknown error" }}"
|
||||
}
|
||||
if (encoded.split("\t").first() == "ERROR")
|
||||
errors += "dimension '$dimName': ${encoded.split("\t", limit = 3).getOrElse(2) { "unknown error" }}"
|
||||
}
|
||||
|
||||
// Validate objectName is a valid Kotlin identifier
|
||||
if (!objName.isValidKotlinIdentifier()) {
|
||||
if (!objName.isValidKotlinIdentifier())
|
||||
errors += "objectName '$objName' is not a valid Kotlin identifier"
|
||||
}
|
||||
|
||||
// Validate all field names
|
||||
(globalStringFields.get().keys +
|
||||
globalBooleanFields.get().keys +
|
||||
globalIntFields.get().keys +
|
||||
globalLongFields.get().keys +
|
||||
globalFloatFields.get().keys +
|
||||
globalDoubleFields.get().keys
|
||||
).forEach { name ->
|
||||
globalFields.get().keys.forEach { name ->
|
||||
if (!name.isValidKotlinIdentifier())
|
||||
errors += "global field name '$name' is not a valid Kotlin identifier"
|
||||
}
|
||||
|
||||
dimensionStringFields.get().keys.forEach { key ->
|
||||
val fieldName = key.substringAfter("|")
|
||||
if (!fieldName.isValidKotlinIdentifier())
|
||||
errors += "field name '$fieldName' is not a valid Kotlin identifier"
|
||||
}
|
||||
(dimensionBooleanFields.get().keys +
|
||||
dimensionIntFields.get().keys +
|
||||
dimensionLongFields.get().keys +
|
||||
dimensionFloatFields.get().keys +
|
||||
dimensionDoubleFields.get().keys
|
||||
).forEach { key ->
|
||||
dimensionFields.get().keys.forEach { key ->
|
||||
val fieldName = key.substringAfter("|")
|
||||
if (!fieldName.isValidKotlinIdentifier())
|
||||
errors += "field name '$fieldName' is not a valid Kotlin identifier"
|
||||
}
|
||||
|
||||
// Validate package segments
|
||||
pkg.split(".").forEach { segment ->
|
||||
if (segment.isEmpty() || !segment.all { it.isLetterOrDigit() || it == '_' })
|
||||
if (!segment.isValidKotlinIdentifier())
|
||||
errors += "package segment '$segment' in '$pkg' is not valid"
|
||||
}
|
||||
val flat = flatDimensionNames.get()
|
||||
activeDimensionNames.get().forEach { dimension ->
|
||||
val name = if (dimension in flat) dimension.toVariantConstName() else dimensionObjectNames.get()[dimension].orEmpty()
|
||||
if (!name.isValidKotlinIdentifier()) errors += "dimension '$dimension' generates invalid Kotlin identifier '$name'"
|
||||
}
|
||||
|
||||
if (errors.isNotEmpty()) {
|
||||
val message = buildString {
|
||||
throw GradleException(buildString {
|
||||
appendLine("konfig [$mod]: configuration errors:")
|
||||
errors.forEach { appendLine(" - $it") }
|
||||
}
|
||||
throw GradleException(message.trimEnd())
|
||||
}.trimEnd())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logging ───────────────────────────────────────────────────────────────
|
||||
// ==============================================================================
|
||||
// MARK: Generated scope collision detection
|
||||
// ==============================================================================
|
||||
|
||||
private fun logResolution(mod: String, btVal: BuildType, objName: String, pkg: String) {
|
||||
// Build type
|
||||
logger.lifecycle("konfig [$mod]: BUILD_TYPE = ${btVal.name.lowercase()} (${buildTypeSource.get()})")
|
||||
|
||||
// Dimensions
|
||||
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 { (dimName, encoded) ->
|
||||
// Show active dimensions before skipped ones
|
||||
val tag = encoded.split("\t").first()
|
||||
"${if (tag == "OK") "0" else "1"}_$dimName"
|
||||
}.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" ->
|
||||
// Already caught in validate(), but log it anyway for clarity
|
||||
logger.error("konfig [$mod]: dim '$dimName' -- ERROR: $reason")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Object info at --info level
|
||||
logger.info("konfig [$mod]: object = $pkg.$objName")
|
||||
}
|
||||
|
||||
// ── Code-generation helpers ───────────────────────────────────────────────
|
||||
|
||||
private fun StringBuilder.appendFields(
|
||||
indent: String,
|
||||
stringFields: Map<String, String>,
|
||||
booleanFields: Map<String, Boolean>,
|
||||
intFields: Map<String, Int>,
|
||||
longFields: Map<String, Long>,
|
||||
floatFields: Map<String, Float>,
|
||||
doubleFields: Map<String, Double>
|
||||
/**
|
||||
* Checks built-in constants, global/flat fields, nested object names and VARIANT.
|
||||
*/
|
||||
private fun checkRootCollisions(
|
||||
mod: String,
|
||||
activeDims: List<String>,
|
||||
gFields: Map<String, String>,
|
||||
dFields: Map<String, String>,
|
||||
dimVars: Map<String, String>,
|
||||
) {
|
||||
stringFields.forEach { (k, v) -> appendLine("${indent}const val $k: String = ${v.toKotlinStringLiteral()}") }
|
||||
booleanFields.forEach { (k, v) -> appendLine("${indent}const val $k: Boolean = $v") }
|
||||
intFields.forEach { (k, v) -> appendLine("${indent}const val $k: Int = $v") }
|
||||
longFields.forEach { (k, v) -> appendLine("${indent}const val $k: Long = ${v}L") }
|
||||
floatFields.forEach { (k, v) -> appendLine("${indent}const val $k: Float = ${v.toKotlinFloat()}") }
|
||||
doubleFields.forEach { (k, v) -> appendLine("${indent}const val $k: Double = ${v.toKotlinDouble()}") }
|
||||
}
|
||||
|
||||
private fun String.toKotlinStringLiteral(): String {
|
||||
val escaped = buildString {
|
||||
this@toKotlinStringLiteral.forEach { c ->
|
||||
when (c) {
|
||||
'\\' -> append("\\\\")
|
||||
'"' -> append("\\\"")
|
||||
'\n' -> append("\\n")
|
||||
'\r' -> append("\\r")
|
||||
'\t' -> append("\\t")
|
||||
'$' -> append("\\\$")
|
||||
else -> append(c)
|
||||
}
|
||||
val owners = mutableMapOf<String, String>()
|
||||
listOf("BUILD_TYPE", "MODULE_NAME", "IS_DEBUG").forEach { owners[it] = "built-in constant" }
|
||||
val errors = mutableListOf<String>()
|
||||
fun claim(name: String, owner: String) {
|
||||
val existing = owners.putIfAbsent(name, owner)
|
||||
if (existing != null) errors += "$owner generates '$name' which collides with $existing"
|
||||
}
|
||||
gFields.keys.forEach { claim(it, "global field") }
|
||||
val flatDims = flatDimensionNames.get()
|
||||
for (dimName in activeDims) {
|
||||
if (dimVars[dimName] == null) continue
|
||||
val prefix = "$dimName|"
|
||||
if (dimName !in flatDims) {
|
||||
val dimObject = dimensionObjectNames.get().getValue(dimName)
|
||||
claim(dimObject, "dimension '$dimName'")
|
||||
if (dimObject == objectName.get()) errors += "dimension '$dimName' reuses enclosing object name '$dimObject'"
|
||||
if (dFields.containsKey("${prefix}VARIANT")) errors += "dimension '$dimName' field 'VARIANT' collides with built-in constant"
|
||||
continue
|
||||
}
|
||||
val rootNames = buildList {
|
||||
add(dimName.toVariantConstName())
|
||||
addAll(dFields.keys.filter { it.startsWith(prefix) }.map { it.removePrefix(prefix) })
|
||||
}
|
||||
for (name in rootNames) {
|
||||
claim(name, "flat dimension '$dimName'")
|
||||
}
|
||||
}
|
||||
return "\"$escaped\""
|
||||
|
||||
if (errors.isNotEmpty()) {
|
||||
throw GradleException(buildString {
|
||||
appendLine("konfig [$mod]: name collisions:")
|
||||
errors.forEach { appendLine(" - $it") }
|
||||
}.trimEnd())
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.safeComment(): String = toKotlinStringLiteral().removeSurrounding("\"")
|
||||
.replace("/*", "/ *").replace("*/", "* /")
|
||||
|
||||
private companion object {
|
||||
const val GENERATED_MARKER = "// Generated by buildkonfig-gradle-plugin"
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// MARK: Helpers
|
||||
// ==============================================================================
|
||||
|
||||
/**
|
||||
* Appends `const val` lines for each entry in [fields].
|
||||
* Values are type-encoded strings: `"String:..."`, `"Boolean:..."`, etc.
|
||||
*/
|
||||
private fun StringBuilder.appendEncodedFields(indent: String, fields: Map<String, String>, btVal: BuildType) {
|
||||
fields.forEach { (name, encoded) ->
|
||||
val colon = encoded.indexOf(':')
|
||||
val type = encoded.substring(0, colon)
|
||||
val raw = encoded.substring(colon + 1)
|
||||
val line = when (type) {
|
||||
"String" -> """${indent}const val $name: String = ${raw.toKotlinStringLiteral()}"""
|
||||
"Boolean" -> when(btVal) {
|
||||
BuildType.DEBUG -> """${indent}inline val $name: Boolean get() = $raw"""
|
||||
BuildType.RELEASE -> """${indent}const val $name: Boolean = $raw"""
|
||||
}
|
||||
"Int" -> """${indent}const val $name: Int = $raw"""
|
||||
"Long" -> """${indent}const val $name: Long = ${if (raw == Long.MIN_VALUE.toString()) "Long.MIN_VALUE" else "${raw}L"}"""
|
||||
"Float" -> """${indent}const val $name: Float = ${raw.toFloat().toKotlinFloat()}"""
|
||||
"Double" -> """${indent}const val $name: Double = ${raw.toDouble().toKotlinDouble()}"""
|
||||
"Value" -> "${indent}val $name: ${raw.substringBefore('\n')} = ${raw.substringAfter('\n')}"
|
||||
"Getter" -> "${indent}val $name: ${raw.substringBefore('\n')} get() = ${raw.substringAfter('\n')}"
|
||||
else -> return@forEach // unknown type - skip
|
||||
}
|
||||
appendLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.toKotlinFloat(): String = when {
|
||||
@@ -333,8 +369,4 @@ abstract class GenerateKonfigTask : DefaultTask() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.isValidKotlinIdentifier(): Boolean =
|
||||
isNotEmpty()
|
||||
&& first().let { it.isLetter() || it == '_' }
|
||||
&& all { it.isLetterOrDigit() || it == '_' }
|
||||
}
|
||||
|
||||
@@ -1,70 +1,172 @@
|
||||
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.isValidDimensionName
|
||||
import com.bitsycore.konfig.types.Visibility
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.model.ObjectFactory
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.provider.Provider
|
||||
import org.gradle.api.provider.ProviderFactory
|
||||
import javax.inject.Inject
|
||||
|
||||
@KonfigDsl
|
||||
abstract class KonfigExtension @Inject constructor(
|
||||
private val objects: ObjectFactory,
|
||||
@PublishedApi
|
||||
internal val providers: ProviderFactory
|
||||
objects: ObjectFactory,
|
||||
) {
|
||||
|
||||
val packageName: Property<String> = objects.property(String::class.java)
|
||||
val objectName: Property<String> = objects.property(String::class.java)
|
||||
val objectVisibility: Property<Visibility> = objects.property(Visibility::class.java)
|
||||
val outputDir: DirectoryProperty = objects.directoryProperty()
|
||||
// ==============================================================================
|
||||
// MARK: Settings Internal
|
||||
// ==============================================================================
|
||||
|
||||
@PublishedApi
|
||||
internal val dimensions: MutableList<DimensionConfig> = mutableListOf()
|
||||
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)
|
||||
|
||||
@PublishedApi
|
||||
internal val globalFields: MutableList<FieldConfig<*>> = mutableListOf()
|
||||
// ==============================================================================
|
||||
// MARK: User facing Settings
|
||||
// ==============================================================================
|
||||
|
||||
// ─── Dimension DSL ───────────────────────────────────────────────────────
|
||||
/** Package for the generated object (e.g. `"com.example.app"`). */
|
||||
var objectPackage: String
|
||||
get() = objectPackageProp.get()
|
||||
set(value) = objectPackageProp.set(value)
|
||||
|
||||
fun dimension(
|
||||
name: String,
|
||||
objectNameOverride: String? = null,
|
||||
defaultTo: String? = null,
|
||||
config: DimensionConfig.() -> Unit
|
||||
) {
|
||||
require(dimensions.none { it.dimensionName == name }) {
|
||||
"konfig: dimension '$name' is already declared"
|
||||
}
|
||||
val d = DimensionConfig(name, objectNameOverride, defaultTo, providers)
|
||||
config(d)
|
||||
dimensions.add(d)
|
||||
}
|
||||
/** Name of the generated Kotlin object (default: `"BuildKonfig"`). */
|
||||
var objectName: String
|
||||
get() = objectNameProp.get()
|
||||
set(value) = objectNameProp.set(value)
|
||||
|
||||
// ─── Global field DSL ────────────────────────────────────────────────────
|
||||
/** Visibility of the generated object (default: [Visibility.PUBLIC]). */
|
||||
var objectVisibility: Visibility
|
||||
get() = objectVisibilityProp.get()
|
||||
set(value) = objectVisibilityProp.set(value)
|
||||
|
||||
inline fun <reified T : Any> field(
|
||||
name: String,
|
||||
default: T,
|
||||
noinline config: (FieldConfig<T>.() -> Unit)? = null
|
||||
) {
|
||||
require(globalFields.none { it.fieldName == name }) {
|
||||
"konfig: global field '$name' is already declared"
|
||||
}
|
||||
val f = FieldConfig(name, T::class.javaObjectType, providers, providers.provider { default })
|
||||
config?.invoke(f)
|
||||
globalFields.add(f)
|
||||
}
|
||||
/** Output directory - kept as [DirectoryProperty] for full Gradle lazy semantics. */
|
||||
val outputDir: DirectoryProperty = objects.directoryProperty()
|
||||
|
||||
inline fun <reified T : Any> field(
|
||||
name: String,
|
||||
default: Provider<T>,
|
||||
noinline config: (FieldConfig<T>.() -> Unit)? = null
|
||||
) {
|
||||
require(globalFields.none { it.fieldName == name }) {
|
||||
"konfig: global field '$name' is already declared"
|
||||
}
|
||||
val f = FieldConfig(name, T::class.javaObjectType, providers, default)
|
||||
config?.invoke(f)
|
||||
globalFields.add(f)
|
||||
}
|
||||
/** Reject missing/unknown dimension selections and invalid explicit build types. */
|
||||
var strictResolution: Boolean = false
|
||||
|
||||
/** Check effective field names/types across all variants and both build types. */
|
||||
var validateVariantSchema: Boolean = false
|
||||
|
||||
/** Specialize Array<Int> and other non-null primitive arrays. Explicit IntArray stays primitive. */
|
||||
var specializeArrays: Boolean = true
|
||||
|
||||
/** Recreate array-containing values on each access, including arrays nested in collections. */
|
||||
var copyArraysOnAccess: Boolean = false
|
||||
|
||||
// ==============================================================================
|
||||
// MARK: DSL Internal
|
||||
// ==============================================================================
|
||||
|
||||
@PublishedApi
|
||||
internal val dimensions: MutableList<DimensionConfig> = mutableListOf()
|
||||
|
||||
/** 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
|
||||
|
||||
// ==============================================================================
|
||||
// MARK: Top Level DSL
|
||||
// ==============================================================================
|
||||
|
||||
fun dimension(
|
||||
name: String,
|
||||
objectNameOverride: String? = null,
|
||||
defaultTo: String? = null,
|
||||
config: DimensionConfig.() -> Unit
|
||||
) {
|
||||
require(name.isValidDimensionName()) { "konfig: invalid dimension name '$name' (blank, delimiter, or control character)" }
|
||||
require(dimensions.none { it.dimensionName == name }) {
|
||||
"konfig: dimension '$name' is already declared"
|
||||
}
|
||||
val d = DimensionConfig(name, objectNameOverride, defaultTo)
|
||||
config(d)
|
||||
dimensions.add(d)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(name.isValidDimensionName()) { "konfig: invalid dimension name '$name' (blank, delimiter, or control character)" }
|
||||
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> field(
|
||||
name: String,
|
||||
default: T,
|
||||
) = globalScope.field(name, default)
|
||||
|
||||
inline fun <reified T> field(
|
||||
name: String,
|
||||
default: Provider<T & Any>,
|
||||
) = globalScope.field(name, default)
|
||||
|
||||
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,50 +2,51 @@
|
||||
|
||||
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.NullFieldValue
|
||||
import com.bitsycore.konfig.configs.FieldConfig
|
||||
import com.bitsycore.konfig.types.BuildType
|
||||
import com.bitsycore.konfig.types.Visibility
|
||||
import com.bitsycore.konfig.types.CollectionLiteral
|
||||
import com.bitsycore.konfig.types.containsWordCamelCase
|
||||
import com.bitsycore.konfig.types.isValidKotlinIdentifier
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.api.provider.Provider
|
||||
import org.gradle.api.tasks.TaskProvider
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
|
||||
import org.jetbrains.kotlin.gradle.dsl.KotlinSingleTargetExtension
|
||||
import java.util.*
|
||||
|
||||
/** Holds all typed dimension field maps produced by a single resolution pass. */
|
||||
private data class DimensionFieldMaps(
|
||||
val strings: Map<String, String> = emptyMap(),
|
||||
val booleans: Map<String, Boolean> = emptyMap(),
|
||||
val ints: Map<String, Int> = emptyMap(),
|
||||
val longs: Map<String, Long> = emptyMap(),
|
||||
val floats: Map<String, Float> = emptyMap(),
|
||||
val doubles: Map<String, Double> = emptyMap(),
|
||||
)
|
||||
|
||||
/** Bundles all inputs needed to resolve dimension variants in a config-cache-safe provider chain. */
|
||||
private data class DimensionContext(
|
||||
val gradleProps: Map<String, String>,
|
||||
val fileProps: Map<String, String>,
|
||||
val flavorDetect: Boolean,
|
||||
val taskNames: List<String>,
|
||||
val androidFlavors: Map<String, String> = emptyMap(),
|
||||
val strict: Boolean = false,
|
||||
)
|
||||
|
||||
class KonfigPlugin : Plugin<Project> {
|
||||
|
||||
override fun apply(project: Project) {
|
||||
project.dependencies.attributesSchema {
|
||||
attribute(BuildType.ATTRIBUTE) {
|
||||
compatibilityRules.add(BuildType.CompatibilityRule::class.java)
|
||||
disambiguationRules.add(BuildType.DisambiguationRule::class.java)
|
||||
}
|
||||
}
|
||||
val projectName = project.name
|
||||
val projectGroup = project.providers.provider { project.group.toString() }
|
||||
val defaultPkgProvider = projectGroup.map { group -> defaultPackageName(projectName, group) }
|
||||
|
||||
val extension = project.extensions.create("konfig", KonfigExtension::class.java).apply {
|
||||
packageName.convention(project.provider { defaultPackageName(project) })
|
||||
objectName.convention(project.provider { "BuildKonfig" })
|
||||
objectVisibility.convention(project.provider { Visibility.PUBLIC })
|
||||
objectPackageProp.convention(defaultPkgProvider)
|
||||
objectNameProp.convention(project.providers.provider { "BuildKonfig" })
|
||||
objectVisibilityProp.convention(project.providers.provider { Visibility.PUBLIC })
|
||||
outputDir.convention(project.layout.buildDirectory.dir("generated/konfig"))
|
||||
}
|
||||
|
||||
// ── konfig.properties file (tracked as task input) ────────────────────
|
||||
// =========================================================================
|
||||
// MARK: konfig.properties
|
||||
// =========================================================================
|
||||
|
||||
val konfigPropertiesFile = project.layout.projectDirectory.file("konfig.properties")
|
||||
val konfigPropertiesProvider: Provider<Map<String, String>> = project.providers
|
||||
.fileContents(konfigPropertiesFile)
|
||||
@@ -60,34 +61,48 @@ class KonfigPlugin : Plugin<Project> {
|
||||
}
|
||||
.orElse(emptyMap())
|
||||
|
||||
// ── Config-cache-safe providers ───────────────────────────────────────
|
||||
val taskNamesProvider = project.provider { project.gradle.startParameter.taskNames }
|
||||
val dimensionPropsProvider = project.providers.gradlePropertiesPrefixedBy("konfig.dimension.")
|
||||
// =========================================================================
|
||||
// MARK: Cache Safe Providers
|
||||
// =========================================================================
|
||||
|
||||
val taskNamesList = project.gradle.startParameter.taskNames
|
||||
val taskNamesProvider = project.providers.provider { taskNamesList }
|
||||
val dimensionPropsProvider = project.providers.gradlePropertiesPrefixedBy("konfig.dimension.")
|
||||
|
||||
val buildTypeDetectionEnabled: Provider<Boolean> = project.providers
|
||||
.gradleProperty("konfig.android.buildtypedetection")
|
||||
.map { it != "false" }
|
||||
.orElse(project.provider { true })
|
||||
.orElse(project.providers.provider { true })
|
||||
|
||||
val flavorDetectionEnabled: Provider<Boolean> = project.providers
|
||||
.gradleProperty("konfig.android.flavordetection")
|
||||
.map { it != "false" }
|
||||
.orElse(project.provider { true })
|
||||
.orElse(project.providers.provider { true })
|
||||
|
||||
// =========================================================================
|
||||
// MARK: Build Type Provider
|
||||
// =========================================================================
|
||||
|
||||
// ── Build-type provider with source tracking ──────────────────────────
|
||||
val rawBuildTypeProp = project.providers.gradleProperty("konfig.buildtype")
|
||||
|
||||
val buildTypeProvider: Provider<BuildType> = rawBuildTypeProp
|
||||
.map { BuildType.resolve(it) ?: BuildType.RELEASE }
|
||||
val strictProvider = project.providers.provider { extension.strictResolution }
|
||||
val explicitBuildType = rawBuildTypeProp.zip(strictProvider) { raw, strict ->
|
||||
val resolved = BuildType.resolve(raw)
|
||||
require(!strict || resolved != null) { "konfig: unknown build type '$raw'; use DEBUG or RELEASE" }
|
||||
resolved ?: BuildType.RELEASE
|
||||
}
|
||||
val buildTypeProvider: Provider<BuildType> = explicitBuildType
|
||||
.orElse(
|
||||
buildTypeDetectionEnabled.zip(taskNamesProvider) { enabled, names ->
|
||||
if (enabled) BuildType.resolve(names.joinToString(" ")) ?: BuildType.RELEASE
|
||||
if (enabled) BuildType.resolveTasks(names) ?: BuildType.RELEASE
|
||||
else BuildType.RELEASE
|
||||
}
|
||||
)
|
||||
|
||||
// =========================================================================
|
||||
// Encodes WHY the build type was chosen; stored as a task input for execution-time logging.
|
||||
// Format: plain descriptive string.
|
||||
// =========================================================================
|
||||
|
||||
val buildTypeSourceProvider: Provider<String> = rawBuildTypeProp
|
||||
.map { raw ->
|
||||
val resolved = BuildType.resolve(raw)
|
||||
@@ -100,7 +115,7 @@ class KonfigPlugin : Plugin<Project> {
|
||||
!enabled -> "detection disabled by konfig.android.buildtypedetection=false, using RELEASE"
|
||||
names.isEmpty() -> "no tasks running, using RELEASE"
|
||||
else -> {
|
||||
val resolved = BuildType.resolve(names.joinToString(" "))
|
||||
val resolved = BuildType.resolveTasks(names)
|
||||
if (resolved != null)
|
||||
"task-name detection matched ${resolved.name.lowercase()} in [${names.joinToString()}]"
|
||||
else
|
||||
@@ -117,109 +132,179 @@ class KonfigPlugin : Plugin<Project> {
|
||||
.zip(flavorDetectionEnabled) { (gp, fp), fe -> Triple(gp, fp, fe) }
|
||||
.zip(taskNamesProvider) { (gp, fp, fe), names ->
|
||||
DimensionContext(gradleProps = gp, fileProps = fp, flavorDetect = fe, taskNames = names)
|
||||
}
|
||||
}.zip(strictProvider) { ctx, strict -> ctx.copy(strict = strict) }
|
||||
|
||||
// Encodes resolution status for EVERY declared dimension (including skipped ones).
|
||||
// Format per entry: "<TAG>\t<variant>\t<reason>"
|
||||
// TAG = OK | WARN_UNKNOWN | WARN_AMBIGUOUS | SKIP | ERROR
|
||||
val dimensionResolutionLogProvider: Provider<Map<String, String>> =
|
||||
buildTypeProvider.zip(combinedProps) { _, ctx ->
|
||||
// =========================================================================
|
||||
// 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, ctx.androidFlavors, ctx.strict)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MARK: Task Registration
|
||||
// =========================================================================
|
||||
|
||||
val forceRegen = project.providers.gradleProperty("konfig.force").isPresent
|
||||
|
||||
fun registerGeneration(
|
||||
taskName: String,
|
||||
infoName: String,
|
||||
buildTypeProvider: Provider<BuildType>,
|
||||
buildTypeSourceProvider: Provider<String>,
|
||||
combinedProps: Provider<DimensionContext>,
|
||||
): TaskProvider<GenerateKonfigTask> {
|
||||
val dimensionResolutionLogProvider = buildTypeProvider.zip(combinedProps) { _, ctx ->
|
||||
extension.dimensions.associate { dim ->
|
||||
dim.dimensionName to resolveWithSource(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames)
|
||||
dim.dimensionName to resolveWithSource(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames, ctx.androidFlavors, ctx.strict)
|
||||
} + buildMap {
|
||||
if (extension.validateVariantSchema) validateSchemas(extension).forEachIndexed { index, error ->
|
||||
put("schema[$index]", "ERROR\t\t$error")
|
||||
}
|
||||
if (ctx.strict) (ctx.gradleProps.keys + ctx.fileProps.keys.filter { it.startsWith("konfig.dimension.") })
|
||||
.map { it.removePrefix("konfig.dimension.") }.distinct()
|
||||
.filter { name -> extension.dimensions.none { it.dimensionName == name } }
|
||||
.forEach { put(it, "ERROR\t\tunknown dimension '$it'") }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task registration ─────────────────────────────────────────────────
|
||||
val generateTask = project.tasks.register("generateKonfig", GenerateKonfigTask::class.java).apply {
|
||||
configure {
|
||||
// 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(infoName, 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)
|
||||
outputDirectory.set(extension.outputDir)
|
||||
packageName.set(extension.packageName)
|
||||
objectName.set(extension.objectName)
|
||||
objectVisibility.set(extension.objectVisibility)
|
||||
}
|
||||
|
||||
// ── Global fields ─────────────────────────────────────────────
|
||||
globalStringFields.set(resolveGlobalFields(buildTypeProvider, extension, String::class.javaObjectType))
|
||||
globalBooleanFields.set(resolveGlobalFields(buildTypeProvider, extension, Boolean::class.javaObjectType))
|
||||
globalIntFields.set(resolveGlobalFields(buildTypeProvider, extension, Int::class.javaObjectType))
|
||||
globalLongFields.set(resolveGlobalFields(buildTypeProvider, extension, Long::class.javaObjectType))
|
||||
globalFloatFields.set(resolveGlobalFields(buildTypeProvider, extension, Float::class.javaObjectType))
|
||||
globalDoubleFields.set(resolveGlobalFields(buildTypeProvider, extension, Double::class.javaObjectType))
|
||||
return project.tasks.register(taskName, GenerateKonfigTask::class.java).apply {
|
||||
configure {
|
||||
if (forceRegen) outputs.upToDateWhen { false }
|
||||
dependsOn(infoTask)
|
||||
|
||||
// ── Dimension metadata ────────────────────────────────────────
|
||||
activeDimensionNames.set(
|
||||
buildTypeProvider.zip(combinedProps) { _, ctx ->
|
||||
extension.dimensions.mapNotNull { dim ->
|
||||
resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames)
|
||||
?: return@mapNotNull null
|
||||
dim.dimensionName
|
||||
moduleName.set(project.name)
|
||||
buildType.set(buildTypeProvider)
|
||||
// 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") }
|
||||
}
|
||||
}
|
||||
)
|
||||
dimensionObjectNames.set(
|
||||
buildTypeProvider.map {
|
||||
extension.dimensions.associate { dim -> dim.dimensionName to dim.objectName() }
|
||||
}
|
||||
)
|
||||
dimensionActiveVariants.set(
|
||||
buildTypeProvider.zip(combinedProps) { _, ctx ->
|
||||
extension.dimensions.mapNotNull { dim ->
|
||||
val sv = resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames)
|
||||
?: return@mapNotNull null
|
||||
dim.dimensionName to sv
|
||||
}.toMap()
|
||||
}
|
||||
)
|
||||
)
|
||||
flatDimensionNames.set(
|
||||
project.providers.provider {
|
||||
extension.dimensions.filter { it.flat }.map { it.dimensionName }.toSet()
|
||||
}
|
||||
)
|
||||
outputDirectory.set(extension.outputDir)
|
||||
ownershipFile.set(project.layout.buildDirectory.file("konfig-state/$taskName.txt"))
|
||||
objectPackage.set(extension.objectPackageProp)
|
||||
objectName.set(extension.objectNameProp)
|
||||
objectVisibility.set(extension.objectVisibilityProp)
|
||||
|
||||
// ── Dimension fields: single pass over all dims/variants/fields ─
|
||||
val dimFieldMapsProvider = resolveDimensionFields(buildTypeProvider, combinedProps, extension)
|
||||
dimensionStringFields.set(dimFieldMapsProvider.map { it.strings })
|
||||
dimensionBooleanFields.set(dimFieldMapsProvider.map { it.booleans })
|
||||
dimensionIntFields.set(dimFieldMapsProvider.map { it.ints })
|
||||
dimensionLongFields.set(dimFieldMapsProvider.map { it.longs })
|
||||
dimensionFloatFields.set(dimFieldMapsProvider.map { it.floats })
|
||||
dimensionDoubleFields.set(dimFieldMapsProvider.map { it.doubles })
|
||||
// ── Global fields ─────────────────────────────────────────────
|
||||
globalFields.set(resolveFields(buildTypeProvider, extension.globalFields, extension))
|
||||
|
||||
// ── Dimension metadata ────────────────────────────────────────
|
||||
activeDimensionNames.set(
|
||||
buildTypeProvider.zip(combinedProps) { _, ctx ->
|
||||
extension.dimensions.mapNotNull { dim ->
|
||||
resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames, ctx.androidFlavors, ctx.strict)
|
||||
?: return@mapNotNull null
|
||||
dim.dimensionName
|
||||
}
|
||||
}
|
||||
)
|
||||
dimensionObjectNames.set(
|
||||
buildTypeProvider.map {
|
||||
extension.dimensions.associate { dim -> dim.dimensionName to dim.objectName() }
|
||||
}
|
||||
)
|
||||
dimensionActiveVariants.set(
|
||||
buildTypeProvider.zip(combinedProps) { _, ctx ->
|
||||
extension.dimensions.mapNotNull { dim ->
|
||||
val sv = resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames, ctx.androidFlavors, ctx.strict)
|
||||
?: return@mapNotNull null
|
||||
dim.dimensionName to sv
|
||||
}.toMap()
|
||||
}
|
||||
)
|
||||
|
||||
// ── Dimension fields ──────────────────────────────────────────
|
||||
dimensionFields.set(resolveDimensionFields(buildTypeProvider, combinedProps, extension))
|
||||
}
|
||||
}
|
||||
}
|
||||
val generateTask = registerGeneration("generateKonfig", "konfigInfo", buildTypeProvider, buildTypeSourceProvider, combinedProps)
|
||||
|
||||
// =========================================================================
|
||||
// MARK: Auto Sourceset
|
||||
// =========================================================================
|
||||
|
||||
// ── Auto-wire generated sources into Kotlin source sets ───────────────
|
||||
project.plugins.withId("org.jetbrains.kotlin.multiplatform") {
|
||||
project.extensions.findByType(KotlinMultiplatformExtension::class.java)
|
||||
?.sourceSets?.findByName("commonMain")?.kotlin?.srcDir(extension.outputDir)
|
||||
?.sourceSets?.findByName("commonMain")?.kotlin?.srcDir(generateTask.flatMap { it.sourceDirectory })
|
||||
}
|
||||
project.plugins.withId("org.jetbrains.kotlin.jvm") {
|
||||
project.extensions.findByType(KotlinSingleTargetExtension::class.java)
|
||||
?.sourceSets?.findByName("main")?.kotlin?.srcDir(extension.outputDir)
|
||||
}
|
||||
project.plugins.withId("org.jetbrains.kotlin.android") {
|
||||
project.extensions.findByType(KotlinSingleTargetExtension::class.java)
|
||||
?.sourceSets?.findByName("main")?.kotlin?.srcDir(extension.outputDir)
|
||||
?.sourceSets?.findByName("main")?.kotlin?.srcDir(generateTask.flatMap { it.sourceDirectory })
|
||||
}
|
||||
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 ->
|
||||
// KMP shares one object through the task-backed commonMain source directory.
|
||||
if (project.plugins.hasPlugin("org.jetbrains.kotlin.multiplatform")) return@onVariants
|
||||
val variantName = variant.name
|
||||
val suffix = variantName.replaceFirstChar { it.uppercase() }
|
||||
val androidBuildType = if (variant.debuggable) BuildType.DEBUG else BuildType.RELEASE
|
||||
val flavors = variant.productFlavors.toMap()
|
||||
val selectedType = explicitBuildType
|
||||
.orElse(buildTypeDetectionEnabled.map { if (it) androidBuildType else BuildType.RELEASE })
|
||||
val source = rawBuildTypeProp.map { "explicit property -Pkonfig.buildtype=$it" }
|
||||
.orElse(buildTypeDetectionEnabled.map { enabled ->
|
||||
if (enabled) "Android variant '$variantName' (debuggable=${androidBuildType == BuildType.DEBUG})"
|
||||
else "detection disabled, using RELEASE"
|
||||
})
|
||||
val context = combinedProps.map { ctx -> ctx.copy(taskNames = emptyList(), androidFlavors = flavors) }
|
||||
val variantTask = registerGeneration("generate${suffix}Konfig", "konfig${suffix}Info", selectedType, source, context)
|
||||
// External KGP (AGP 8) consumes generated Kotlin through the Java source API;
|
||||
// AGP's built-in Kotlin consumes the dedicated Kotlin source API.
|
||||
val sources = if (project.plugins.hasPlugin("org.jetbrains.kotlin.android")) variant.sources.java
|
||||
else variant.sources.kotlin ?: variant.sources.java
|
||||
sources?.addGeneratedSourceDirectory(variantTask, GenerateKonfigTask::sourceDirectory)
|
||||
variantTask.configure {
|
||||
outputDirectory.set(extension.outputDir.dir(variantName))
|
||||
// Replace AGP's default directory with the configurable directory, retaining
|
||||
// the @OutputFile producer. Gradle never owns/deletes neighboring sources.
|
||||
sourceDirectory.set(generatedFile.zip(outputDirectory) { _, directory -> directory })
|
||||
}
|
||||
generateTask.configure { enabled = false; dependsOn(variantTask) }
|
||||
project.tasks.named("konfigInfo").configure {
|
||||
enabled = false
|
||||
dependsOn("konfig${suffix}Info")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hook compile tasks ────────────────────────────────────────────────
|
||||
project.tasks.configureEach {
|
||||
if (
|
||||
name.contains("sourcesJar") || name.contains("SourcesJar")
|
||||
|| name.contains("compileKotlin")
|
||||
|| (name.startsWith("compile") && name.contains("Kotlin"))
|
||||
|| (name.startsWith("compile") && name.contains("Main"))
|
||||
) {
|
||||
dependsOn(generateTask)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── Resolution helpers ────────────────────────────────────────────────────
|
||||
// ==============================================
|
||||
// MARK: Resolution helpers
|
||||
// ==============================================
|
||||
|
||||
/**
|
||||
* Resolves the active variant for a dimension (used for actual field resolution).
|
||||
@@ -230,12 +315,15 @@ class KonfigPlugin : Plugin<Project> {
|
||||
gradleProps: Map<String, String>,
|
||||
fileProps: Map<String, String>,
|
||||
flavorDetect: Boolean,
|
||||
taskNames: List<String>
|
||||
taskNames: List<String>,
|
||||
androidFlavors: Map<String, String> = emptyMap(),
|
||||
strict: Boolean = false,
|
||||
): String? {
|
||||
val encoded = resolveWithSource(dim, gradleProps, fileProps, flavorDetect, taskNames)
|
||||
val encoded = resolveWithSource(dim, gradleProps, fileProps, flavorDetect, taskNames, androidFlavors, strict)
|
||||
val parts = encoded.split("\t", limit = 3)
|
||||
return when (parts[0]) {
|
||||
"OK" -> parts.getOrNull(1)?.takeIf { it.isNotEmpty() }
|
||||
"ERROR" -> throw org.gradle.api.GradleException("konfig: dimension '${dim.dimensionName}': ${parts.getOrElse(2) { "resolution failed" }}")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -244,18 +332,31 @@ class KonfigPlugin : Plugin<Project> {
|
||||
* Resolves a dimension and encodes the result as a tab-separated string for logging.
|
||||
*
|
||||
* Format: `"<TAG>\t<variant>\t<reason>"`
|
||||
* - TAG = `OK` — active, variant resolved successfully
|
||||
* - TAG = `WARN_UNKNOWN` — property set but value is not a known variant
|
||||
* - TAG = `WARN_AMBIGUOUS` — multiple variants matched task names
|
||||
* - TAG = `SKIP` — no active variant could be determined
|
||||
* - TAG = `ERROR` — configuration error (e.g. invalid defaultTo)
|
||||
* - TAG = `OK` - active, variant resolved successfully
|
||||
* - TAG = `WARN_UNKNOWN` - property set but value is not a known variant
|
||||
* - TAG = `WARN_AMBIGUOUS` - multiple variants matched task names
|
||||
* - TAG = `SKIP` - no active variant could be determined
|
||||
* - TAG = `ERROR` - configuration error (e.g. invalid defaultTo)
|
||||
*/
|
||||
private fun resolveWithSource(
|
||||
dim: DimensionConfig,
|
||||
gradleProps: Map<String, String>,
|
||||
fileProps: Map<String, String>,
|
||||
flavorDetect: Boolean,
|
||||
taskNames: List<String>
|
||||
taskNames: List<String>,
|
||||
androidFlavors: Map<String, String> = emptyMap(),
|
||||
strict: Boolean = false,
|
||||
): String {
|
||||
val result = resolveUnchecked(dim, gradleProps, fileProps, flavorDetect, taskNames, androidFlavors)
|
||||
if ((strict || dim.required) && (result.startsWith("SKIP") || result.startsWith("WARN"))) {
|
||||
return "ERROR\t\tdimension selection is required: " + result.substringAfter('\t').substringAfter('\t')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun resolveUnchecked(
|
||||
dim: DimensionConfig, gradleProps: Map<String, String>, fileProps: Map<String, String>,
|
||||
flavorDetect: Boolean, taskNames: List<String>, androidFlavors: Map<String, String>,
|
||||
): String {
|
||||
// Validate defaultTo at resolution time
|
||||
if (dim.defaultVariant != null && !dim.variants.containsKey(dim.defaultVariant)) {
|
||||
@@ -287,16 +388,27 @@ class KonfigPlugin : Plugin<Project> {
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: task-name detection
|
||||
// Priority 3: exact Android flavor mapping, or task names for shared generation.
|
||||
if (flavorDetect) {
|
||||
val flavor = androidFlavors[dim.androidDimension]
|
||||
if (flavor != null) return if (flavor in dim.variants) "OK\t$flavor\tAndroid flavor '${dim.androidDimension}=$flavor'"
|
||||
else "ERROR\t\tAndroid flavor '$flavor' is not a known variant for dimension '${dim.dimensionName}'"
|
||||
}
|
||||
|
||||
// Shared generation: task-name detection
|
||||
if (flavorDetect && taskNames.isNotEmpty()) {
|
||||
val matches = dim.variants.keys.filter { variant ->
|
||||
taskNames.any { task -> task.contains(variant, ignoreCase = true) }
|
||||
}
|
||||
// Resolve overlap within EACH task; separate prod and preProd tasks conflict.
|
||||
val matches = taskNames.flatMap { qualifiedTask ->
|
||||
val task = qualifiedTask.substringAfterLast(':')
|
||||
val raw = dim.variants.keys.filter { task.containsWordCamelCase(it) }
|
||||
val longest = raw.maxByOrNull { it.length }
|
||||
if (longest != null && raw.all { longest.contains(it, ignoreCase = true) }) listOf(longest) else raw
|
||||
}.distinct()
|
||||
when (matches.size) {
|
||||
1 -> return "OK\t${matches.first()}\ttask-name detection: '${matches.first()}' " +
|
||||
"found in [${taskNames.joinToString()}]"
|
||||
in 2..Int.MAX_VALUE -> return "WARN_AMBIGUOUS\t\ttask names matched multiple variants " +
|
||||
"${matches.sorted()} in [${taskNames.joinToString()}] -- dimension skipped"
|
||||
in 2..Int.MAX_VALUE -> return "ERROR\t\tconflicting variants ${matches.sorted()} " +
|
||||
"in tasks [${taskNames.joinToString()}]. Run separate builds or select -P$propKey explicitly."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,69 +429,111 @@ class KonfigPlugin : Plugin<Project> {
|
||||
return "SKIP\t\t${hints.joinToString(", ")}"
|
||||
}
|
||||
|
||||
private fun <T : Any> resolveGlobalFields(
|
||||
private fun resolveFields(
|
||||
buildType: Provider<BuildType>,
|
||||
fields: List<FieldConfig<*>>,
|
||||
extension: KonfigExtension,
|
||||
type: Class<T>
|
||||
): Provider<Map<String, T>> = buildType.map { bt ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
extension.globalFields
|
||||
.filter { it.type == type }
|
||||
.mapNotNull { field ->
|
||||
val f = field as FieldConfig<T>
|
||||
f.resolve(bt).orNull?.let { f.fieldName to it }
|
||||
}
|
||||
.toMap()
|
||||
): Provider<Map<String, String>> = buildType.map { bt ->
|
||||
fields.mapNotNull { field -> encodeField(field, bt, extension) }.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-pass resolution of all dimension fields across all types.
|
||||
* Iterates each dimension's active variant once and partitions fields by type,
|
||||
* rather than running 6 separate passes (one per type).
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun resolveDimensionFields(
|
||||
buildType: Provider<BuildType>,
|
||||
combined: Provider<DimensionContext>,
|
||||
extension: KonfigExtension,
|
||||
): Provider<DimensionFieldMaps> =
|
||||
): Provider<Map<String, String>> =
|
||||
buildType.zip(combined) { bt, ctx ->
|
||||
val strings = mutableMapOf<String, String>()
|
||||
val booleans = mutableMapOf<String, Boolean>()
|
||||
val ints = mutableMapOf<String, Int>()
|
||||
val longs = mutableMapOf<String, Long>()
|
||||
val floats = mutableMapOf<String, Float>()
|
||||
val doubles = mutableMapOf<String, Double>()
|
||||
|
||||
for (dim in extension.dimensions) {
|
||||
val sv = resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames) ?: continue
|
||||
val vc = dim.variants[sv] ?: continue
|
||||
val prefix = "${dim.dimensionName}|"
|
||||
for (field in vc.fields) {
|
||||
val key = "$prefix${field.fieldName}"
|
||||
when (field.type) {
|
||||
String::class.javaObjectType -> (field as FieldConfig<String>).resolve(bt).orNull?.let { strings[key] = it }
|
||||
Boolean::class.javaObjectType -> (field as FieldConfig<Boolean>).resolve(bt).orNull?.let { booleans[key] = it }
|
||||
Int::class.javaObjectType -> (field as FieldConfig<Int>).resolve(bt).orNull?.let { ints[key] = it }
|
||||
Long::class.javaObjectType -> (field as FieldConfig<Long>).resolve(bt).orNull?.let { longs[key] = it }
|
||||
Float::class.javaObjectType -> (field as FieldConfig<Float>).resolve(bt).orNull?.let { floats[key] = it }
|
||||
Double::class.javaObjectType -> (field as FieldConfig<Double>).resolve(bt).orNull?.let { doubles[key] = it }
|
||||
buildMap {
|
||||
for (dim in extension.dimensions) {
|
||||
val sv = resolveActiveVariant(dim, ctx.gradleProps, ctx.fileProps, ctx.flavorDetect, ctx.taskNames, ctx.androidFlavors, ctx.strict) ?: continue
|
||||
val vc = dim.variants[sv] ?: continue
|
||||
val prefix = "${dim.dimensionName}|"
|
||||
for (field in mergeVariantFields(dim.commonConfig.fields, vc.fields)) {
|
||||
encodeField(field, bt, extension)?.let { (name, value) -> put("$prefix$name", value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
DimensionFieldMaps(strings, booleans, ints, longs, floats, doubles)
|
||||
}
|
||||
|
||||
// ── Package-name helpers ──────────────────────────────────────────────────
|
||||
/** Encodes a [FieldConfig] value for [buildType] as `"TYPE:rawValue"`, or `null` if absent. */
|
||||
private fun encodeField(field: FieldConfig<*>, buildType: BuildType, extension: KonfigExtension): Pair<String, String>? {
|
||||
val value = field.resolve(buildType)?.orNull ?: return null
|
||||
val type = requireNotNull(field.valueType) { "konfig: missing type for field '${field.fieldName}'" }
|
||||
if (type.nullable || type.name !in setOf("String", "Boolean", "Int", "Long", "Float", "Double")) {
|
||||
val literal = CollectionLiteral(extension.specializeArrays)
|
||||
val tag = if (extension.copyArraysOnAccess && containsArray(value)) "Getter" else "Value"
|
||||
val encoded = "$tag:${literal.type(type)}\n${literal.value(if (value is NullFieldValue) null else value, type)}"
|
||||
return field.fieldName to encoded
|
||||
}
|
||||
val encoded = when (value) {
|
||||
is String -> "String:$value"
|
||||
is Boolean -> "Boolean:$value"
|
||||
is Int -> "Int:$value"
|
||||
is Long -> "Long:$value"
|
||||
is Float -> "Float:$value"
|
||||
is Double -> "Double:$value"
|
||||
else -> error("konfig: unsupported value type '${value.javaClass.name}' for field '${field.fieldName}'")
|
||||
}
|
||||
return field.fieldName to encoded
|
||||
}
|
||||
|
||||
private fun defaultPackageName(project: Project): String {
|
||||
val group = project.group.toString()
|
||||
private fun containsArray(value: Any?): Boolean = when (value) {
|
||||
null -> false
|
||||
is Map<*, *> -> value.any { containsArray(it.key) || containsArray(it.value) }
|
||||
is Collection<*> -> value.any { containsArray(it) }
|
||||
else -> value.javaClass.isArray
|
||||
}
|
||||
|
||||
private fun validateSchemas(extension: KonfigExtension): List<String> = buildList {
|
||||
fun schema(fields: List<FieldConfig<*>>, bt: BuildType) = fields
|
||||
.filter { it.resolve(bt)?.isPresent == true }.associate { it.fieldName to it.valueType }
|
||||
val globalDebug = schema(extension.globalFields, BuildType.DEBUG)
|
||||
if (globalDebug != schema(extension.globalFields, BuildType.RELEASE))
|
||||
add("global fields differ between DEBUG and RELEASE")
|
||||
extension.dimensions.forEach { dim ->
|
||||
val schemas = dim.variants.flatMap { (name, config) -> BuildType.entries.map { bt ->
|
||||
"$name/${bt.name}" to schema(mergeVariantFields(dim.commonConfig.fields, config.fields), bt)
|
||||
} }
|
||||
val baseline = schemas.firstOrNull() ?: return@forEach
|
||||
schemas.drop(1).filter { it.second != baseline.second }.forEach { (name, fields) ->
|
||||
val missing = baseline.second.keys - fields.keys
|
||||
val extra = fields.keys - baseline.second.keys
|
||||
val changed = fields.keys.intersect(baseline.second.keys).filter { fields[it] != baseline.second[it] }
|
||||
add("dimension '${dim.dimensionName}' schema '$name' differs from '${baseline.first}': missing=$missing, extra=$extra, different types=$changed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// MARK: Fields Merge
|
||||
// ==============================================================================
|
||||
|
||||
/**
|
||||
* Returns a merged list of fields: [commonFields] as the base, with any field in
|
||||
* [variantFields] taking precedence (variant fields win by name).
|
||||
*/
|
||||
private fun mergeVariantFields(
|
||||
commonFields: List<FieldConfig<*>>,
|
||||
variantFields: List<FieldConfig<*>>
|
||||
): List<FieldConfig<*>> {
|
||||
if (commonFields.isEmpty()) return variantFields
|
||||
val variantNames = variantFields.map { it.fieldName }.toHashSet()
|
||||
return commonFields.filter { it.fieldName !in variantNames } + variantFields
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// MARK: Package Utilities
|
||||
// ==============================================================================
|
||||
|
||||
private fun defaultPackageName(projectName: String, projectGroup: String): String {
|
||||
val group = projectGroup
|
||||
.lowercase()
|
||||
.replace("-", ".")
|
||||
.takeIf { it.isNotBlank() && it != "unspecified" }
|
||||
val artifact = project.name
|
||||
val artifact = projectName
|
||||
.replace("-", ".")
|
||||
.replace(Regex("[^A-Za-z0-9.]"), "")
|
||||
return if (group != null) "$group.$artifact" else artifact.ensureValidPackage()
|
||||
return (if (group != null) "$group.$artifact" else artifact).ensureValidPackage()
|
||||
}
|
||||
|
||||
private fun String.ensureValidPackage(): String = split(".")
|
||||
@@ -387,9 +541,10 @@ class KonfigPlugin : Plugin<Project> {
|
||||
.joinToString(".") { segment ->
|
||||
val cleaned = segment.replace(Regex("[^A-Za-z0-9_]"), "")
|
||||
when {
|
||||
cleaned.isBlank() -> "_"
|
||||
cleaned.isBlank() -> "generated"
|
||||
cleaned.first().isDigit() -> "_$cleaned"
|
||||
!cleaned.lowercase().isValidKotlinIdentifier() -> "_${cleaned}pkg"
|
||||
else -> cleaned
|
||||
}
|
||||
}.lowercase()
|
||||
}.lowercase().ifEmpty { "generated" }
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package com.bitsycore.konfig
|
||||
|
||||
import org.gradle.api.provider.Provider
|
||||
import org.gradle.api.provider.ProviderFactory
|
||||
|
||||
class VariantConfig @PublishedApi internal constructor(
|
||||
val variantName: String,
|
||||
@PublishedApi internal val providerFactory: ProviderFactory
|
||||
) {
|
||||
@PublishedApi
|
||||
internal val fields: MutableList<FieldConfig<*>> = mutableListOf()
|
||||
|
||||
inline fun <reified T : Any> field(
|
||||
name: String,
|
||||
default: T,
|
||||
noinline config: (FieldConfig<T>.() -> Unit)? = null
|
||||
) {
|
||||
require(fields.none { it.fieldName == name }) {
|
||||
"konfig: field '$name' is already declared in variant '$variantName'"
|
||||
}
|
||||
val f = FieldConfig(name, T::class.javaObjectType, providerFactory, providerFactory.provider { default })
|
||||
config?.invoke(f)
|
||||
fields.add(f)
|
||||
}
|
||||
|
||||
inline fun <reified T : Any> field(
|
||||
name: String,
|
||||
default: Provider<T>,
|
||||
noinline config: (FieldConfig<T>.() -> Unit)? = null
|
||||
) {
|
||||
require(fields.none { it.fieldName == name }) {
|
||||
"konfig: field '$name' is already declared in variant '$variantName'"
|
||||
}
|
||||
val f = FieldConfig(name, T::class.javaObjectType, providerFactory, default)
|
||||
config?.invoke(f)
|
||||
fields.add(f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.bitsycore.konfig.configs
|
||||
|
||||
import com.bitsycore.konfig.types.KonfigDsl
|
||||
|
||||
/**
|
||||
* DSL scope for a dimension declaration.
|
||||
*
|
||||
* Only [variant] and [common] are available here. Scoping (`debug {}`, `release {}`)
|
||||
* is done inside each [variant] or [common] block.
|
||||
*
|
||||
* ```kotlin
|
||||
* dimension("env", defaultTo = "prod") {
|
||||
* common {
|
||||
* field("TIMEOUT", 30)
|
||||
* debug { field("TIMEOUT", 5) }
|
||||
* }
|
||||
* variant("prod") {
|
||||
* field("URL", "https://prod.example.com")
|
||||
* }
|
||||
* variant("dev") {
|
||||
* field("URL", "https://dev.example.com")
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@KonfigDsl
|
||||
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,
|
||||
) {
|
||||
/** Require a selected variant even when global strict resolution is disabled. */
|
||||
var required: Boolean = false
|
||||
|
||||
/** Android flavor dimension to read; defaults to this Konfig dimension's name. */
|
||||
var androidDimension: String = dimensionName
|
||||
set(value) {
|
||||
require(value.isNotBlank() && value.none { it.isISOControl() }) { "konfig: Android dimension alias must not be blank or contain control characters" }
|
||||
field = value
|
||||
}
|
||||
/** All named variants. */
|
||||
@PublishedApi
|
||||
internal val variants: MutableMap<String, VariantConfig> = mutableMapOf()
|
||||
|
||||
/**
|
||||
* 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")
|
||||
|
||||
// ==============================================================================
|
||||
// MARK: DSL
|
||||
// ==============================================================================
|
||||
|
||||
/**
|
||||
* Declares a named variant. Can be called multiple times for the same name
|
||||
* to merge additional fields.
|
||||
*/
|
||||
fun variant(name: String, config: VariantConfig.() -> Unit) {
|
||||
require(name.isNotEmpty() && '\t' !in name) { "konfig: variant name must be nonempty and cannot contain tabs" }
|
||||
val v = variants.getOrPut(name) { VariantConfig(name) }
|
||||
config(v)
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares shared fields that act as fallback values for all variants.
|
||||
* Fields declared here are used when the active variant does not define
|
||||
* a field with the same name.
|
||||
*
|
||||
* ```kotlin
|
||||
* common {
|
||||
* field("TIMEOUT", 30)
|
||||
* debug { field("TIMEOUT", 5) }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
fun common(config: VariantConfig.() -> Unit) = config(commonConfig)
|
||||
|
||||
/** Derived Kotlin object name: override or CamelCase of dimensionName. */
|
||||
fun objectName(): String = objectNameOverride
|
||||
?: dimensionName
|
||||
.split(Regex("[-_\\s]+"))
|
||||
.joinToString("") { part ->
|
||||
part.lowercase().replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.bitsycore.konfig.configs
|
||||
|
||||
import com.bitsycore.konfig.types.BuildType
|
||||
import com.bitsycore.konfig.types.FieldValueType
|
||||
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.
|
||||
*
|
||||
* Values are stored as [Provider]<T> so lazy sources (e.g. Gradle property providers)
|
||||
* are supported alongside plain constants. [org.gradle.api.provider.ProviderFactory]
|
||||
* is intentionally NOT stored here - it is not configuration-cache serializable and
|
||||
* must never flow into the object graph captured by task input providers.
|
||||
*/
|
||||
class FieldConfig<T> @PublishedApi internal constructor(
|
||||
val fieldName: String,
|
||||
internal val type: Class<T>,
|
||||
/**
|
||||
* The unconditional default value, or `null` if this field was declared only inside
|
||||
* a scope block (debug/release) and has no fallback outside that scope.
|
||||
*/
|
||||
internal val default: Provider<out Any>?,
|
||||
@PublishedApi internal val valueType: FieldValueType? = null,
|
||||
) {
|
||||
@PublishedApi
|
||||
internal val buildTypeOverrides: MutableMap<BuildType, Provider<out Any>> = mutableMapOf()
|
||||
|
||||
// ── BuildType overrides ───────────────────────────────────────────────────
|
||||
|
||||
fun debug(value: T) { buildTypeOverrides[BuildType.DEBUG] = constantProvider(value ?: NullFieldValue) }
|
||||
fun debug(value: Provider<T & Any>) { buildTypeOverrides[BuildType.DEBUG] = value }
|
||||
fun release(value: T) { buildTypeOverrides[BuildType.RELEASE] = constantProvider(value ?: NullFieldValue) }
|
||||
fun release(value: Provider<T & Any>) { buildTypeOverrides[BuildType.RELEASE] = value }
|
||||
|
||||
// ── Resolution ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves the effective value for [buildType].
|
||||
* Returns `null` if no applicable value exists (field is absent for this context).
|
||||
*/
|
||||
internal fun resolve(buildType: BuildType): Provider<out Any>? =
|
||||
buildTypeOverrides[buildType] ?: default
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a minimal [Provider] that always returns [value].
|
||||
* Avoids storing [org.gradle.api.provider.ProviderFactory] anywhere in the DSL
|
||||
* object graph (not config-cache serializable).
|
||||
*/
|
||||
@PublishedApi
|
||||
internal fun <T : Any> constantProvider(value: T): Provider<T> = ConstantProvider(value)
|
||||
|
||||
private class ConstantProvider<T : Any>(private val value: T) : Provider<T> {
|
||||
override fun get(): T = value
|
||||
override fun getOrNull(): T = value
|
||||
override fun getOrElse(defaultValue: T): T = value
|
||||
override fun isPresent(): Boolean = true
|
||||
|
||||
override fun <S : Any> map(transformer: Transformer<out S?, in T>): Provider<S> =
|
||||
ConstantProvider(transformer.transform(value)!!)
|
||||
|
||||
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: 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
|
||||
override fun orElse(provider: Provider<out T>): Provider<T> = this
|
||||
|
||||
@Suppress("unused") // called reflectively by older Gradle versions
|
||||
fun forUseAtConfigurationTime(): Provider<T> = this
|
||||
|
||||
override fun <B : Any, R : Any> zip(
|
||||
right: Provider<B>,
|
||||
combiner: BiFunction<in T, in B, out R?>
|
||||
): Provider<R> = ConstantProvider(combiner.apply(value, right.get())!!)
|
||||
}
|
||||
|
||||
/** Explicit null is present; absent Gradle providers still omit a field. */
|
||||
@PublishedApi
|
||||
internal object NullFieldValue
|
||||
|
||||
@PublishedApi
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
internal inline fun <reified T> fieldClass(): Class<T> =
|
||||
(kotlin.reflect.typeOf<T>().classifier as kotlin.reflect.KClass<*>).javaObjectType as Class<T>
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.bitsycore.konfig.configs
|
||||
|
||||
import com.bitsycore.konfig.types.BuildType
|
||||
import com.bitsycore.konfig.types.KonfigDsl
|
||||
import com.bitsycore.konfig.types.FieldValueType
|
||||
import org.gradle.api.provider.Provider
|
||||
import kotlin.reflect.typeOf
|
||||
|
||||
// ==============================================================================
|
||||
// MARK: FieldHandle with modifier
|
||||
// ==============================================================================
|
||||
|
||||
/**
|
||||
* Returned by `field(name, default)` when called at the top level of a [VariantConfig].
|
||||
*
|
||||
* Allows fluent `.debug(value)` / `.release(value)` overrides:
|
||||
* ```kotlin
|
||||
* field("TIMEOUT", 30).debug(5)
|
||||
* field("URL", "https://prod.example.com").debug("https://dev.example.com")
|
||||
* ```
|
||||
* Not available inside `debug {}` / `release {}` blocks - those return [Unit].
|
||||
*/
|
||||
class FieldHandle<T> @PublishedApi internal constructor(
|
||||
@PublishedApi internal val field: FieldConfig<T>
|
||||
) {
|
||||
fun debug(value: T): Unit = field.debug(value)
|
||||
fun debug(value: Provider<T & Any>): Unit = field.debug(value)
|
||||
fun release(value: T): Unit = field.release(value)
|
||||
fun release(value: Provider<T & Any>): Unit = field.release(value)
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// MARK: BuildType Scope
|
||||
// ==============================================================================
|
||||
|
||||
/**
|
||||
* Receiver of `debug { ... }` and `release { ... }` blocks inside [VariantConfig].
|
||||
*
|
||||
* `field()` here returns [Unit] - no `.debug()`/`.release()` chaining is possible
|
||||
* because the build type is already fixed by the enclosing scope.
|
||||
*/
|
||||
@KonfigDsl
|
||||
class BuildTypedFieldDeclScope @PublishedApi internal constructor(
|
||||
@PublishedApi internal val buildType: BuildType,
|
||||
@PublishedApi internal val owner: VariantConfig
|
||||
) {
|
||||
inline fun <reified T> field(name: String, value: T) {
|
||||
owner.getOrCreateField<T>(name).buildTypeOverrides[buildType] = constantProvider(value ?: NullFieldValue)
|
||||
}
|
||||
|
||||
inline fun <reified T> field(name: String, value: Provider<T & Any>) {
|
||||
owner.getOrCreateField<T>(name).buildTypeOverrides[buildType] = value
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// MARK: Variant
|
||||
// ==============================================================================
|
||||
|
||||
/**
|
||||
* DSL scope for a variant (or the `common {}` block inside a dimension).
|
||||
*
|
||||
* ```kotlin
|
||||
* variant("prod") {
|
||||
* field("URL", "https://prod.example.com")
|
||||
* field("TIMEOUT", 30).debug(5)
|
||||
* debug { field("LOG_LEVEL", "verbose") }
|
||||
* release { field("LOG_LEVEL", "error") }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@KonfigDsl
|
||||
class VariantConfig @PublishedApi internal constructor(val variantName: String) {
|
||||
|
||||
@PublishedApi
|
||||
internal val fields: MutableList<FieldConfig<*>> = mutableListOf()
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────
|
||||
|
||||
@PublishedApi
|
||||
internal inline fun <reified T> getOrCreateField(name: String): FieldConfig<T> {
|
||||
val existing = fields.firstOrNull { it.fieldName == name }
|
||||
if (existing != null) {
|
||||
require(existing.valueType == FieldValueType.from(typeOf<T>())) {
|
||||
"konfig: field '$name' must use the same type in every build-type scope"
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return existing as FieldConfig<T>
|
||||
}
|
||||
val fc = FieldConfig(name, fieldClass<T>(), null, FieldValueType.from(typeOf<T>()))
|
||||
fields.add(fc)
|
||||
return fc
|
||||
}
|
||||
|
||||
// ── Top-level field declarations ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Declares a field with an unconditional default value.
|
||||
* Returns a [FieldHandle] to optionally set `.debug(value)` / `.release(value)` build-type overrides.
|
||||
*/
|
||||
inline fun <reified T> field(name: String, default: T): FieldHandle<T> {
|
||||
require(fields.none { it.fieldName == name }) {
|
||||
"konfig: field '$name' is already declared in variant '$variantName'"
|
||||
}
|
||||
val fc = FieldConfig(name, fieldClass<T>(), constantProvider(default ?: NullFieldValue), FieldValueType.from(typeOf<T>()))
|
||||
fields.add(fc)
|
||||
return FieldHandle(fc)
|
||||
}
|
||||
|
||||
inline fun <reified T> field(name: String, default: Provider<T & Any>): FieldHandle<T> {
|
||||
require(fields.none { it.fieldName == name }) {
|
||||
"konfig: field '$name' is already declared in variant '$variantName'"
|
||||
}
|
||||
val fc = FieldConfig(name, fieldClass<T>(), default, FieldValueType.from(typeOf<T>()))
|
||||
fields.add(fc)
|
||||
return FieldHandle(fc)
|
||||
}
|
||||
|
||||
// ── Build-type scope blocks ───────────────────────────────────────────────
|
||||
|
||||
fun debug(block: BuildTypedFieldDeclScope.() -> Unit) =
|
||||
BuildTypedFieldDeclScope(BuildType.DEBUG, this).block()
|
||||
|
||||
fun release(block: BuildTypedFieldDeclScope.() -> Unit) =
|
||||
BuildTypedFieldDeclScope(BuildType.RELEASE, this).block()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.bitsycore.konfig.types
|
||||
|
||||
import kotlin.enums.enumEntries
|
||||
import org.gradle.api.GradleException
|
||||
|
||||
enum class BuildType(val value: String) {
|
||||
DEBUG("debug"),
|
||||
RELEASE("release");
|
||||
|
||||
companion object {
|
||||
private val DEBUG_REGEX = Regex("""(?<![a-z])debug(?![a-z])|(?<![A-Z])Debug(?![a-z])""")
|
||||
private val RELEASE_REGEX = Regex("""(?<![a-z])release(?![a-z])|(?<![A-Z])Release(?![a-z])""")
|
||||
|
||||
internal fun resolveTasks(names: List<String>): BuildType? {
|
||||
val tasks = names.map { it.substringAfterLast(':') }
|
||||
val debug = tasks.any { it == "DEBUG" || DEBUG_REGEX.containsMatchIn(it) }
|
||||
val release = tasks.any { it == "RELEASE" || RELEASE_REGEX.containsMatchIn(it) }
|
||||
if (debug && release) throw GradleException(
|
||||
"konfig: conflicting debug and release tasks $names cannot share one generated object. " +
|
||||
"Run separate builds or select -Pkonfig.buildtype explicitly."
|
||||
)
|
||||
return when { debug -> DEBUG; release -> RELEASE; else -> null }
|
||||
}
|
||||
|
||||
fun resolve(value: String): BuildType? {
|
||||
enumEntries<BuildType>().firstOrNull { it.name == value }?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val debugResult = DEBUG_REGEX.containsMatchIn(value)
|
||||
val releaseResult = RELEASE_REGEX.containsMatchIn(value)
|
||||
|
||||
return when {
|
||||
debugResult && releaseResult.not() -> DEBUG
|
||||
releaseResult && debugResult.not() -> RELEASE
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.bitsycore.konfig.types
|
||||
|
||||
import java.lang.reflect.Array as JavaArray
|
||||
|
||||
/** Produces code only from supported values and validated type descriptors. */
|
||||
internal class CollectionLiteral(private val specializeArrays: Boolean = true) {
|
||||
private val primitives = setOf("Boolean", "Byte", "Short", "Char", "Int", "Long", "Float", "Double")
|
||||
|
||||
fun type(type: FieldValueType): String {
|
||||
val element = type.arguments.singleOrNull()
|
||||
val base = if (specializeArrays && type.name == "Array" && element != null && !element.nullable && element.name in primitives) {
|
||||
"${element.name}Array"
|
||||
} else {
|
||||
type.name + if (type.arguments.isEmpty()) "" else type.arguments.joinToString(", ", "<", ">", transform = ::type)
|
||||
}
|
||||
return base + if (type.nullable) "?" else ""
|
||||
}
|
||||
|
||||
fun value(value: Any?, declared: FieldValueType): String {
|
||||
if (value == null) {
|
||||
require(declared.nullable) { "konfig: null is not valid for ${type(declared)}" }
|
||||
return "null"
|
||||
}
|
||||
if (declared.name == "Any") return dynamicValue(value)
|
||||
if (declared.enumType) {
|
||||
require(value is Enum<*> && value.declaringJavaClass.canonicalName == declared.name) {
|
||||
"konfig: expected enum ${type(declared)}"
|
||||
}
|
||||
return enumLiteral(value)
|
||||
}
|
||||
return when (declared.name) {
|
||||
"List", "Set" -> {
|
||||
require(if (declared.name == "Set") value is Set<*> else value is List<*>) { "konfig: expected ${type(declared)}" }
|
||||
val element = declared.arguments.single()
|
||||
(value as Collection<*>).joinToString(", ", "${declared.name.lowercase()}Of<${type(element)}>(", ")") { value(it, element) }
|
||||
}
|
||||
"Map" -> {
|
||||
require(value is Map<*, *>) { "konfig: expected ${type(declared)}" }
|
||||
val (key, item) = declared.arguments
|
||||
value.entries.joinToString(", ", "mapOf<${type(key)}, ${type(item)}>(", ")") {
|
||||
"${value(it.key, key)} to ${value(it.value, item)}"
|
||||
}
|
||||
}
|
||||
"Array", "BooleanArray", "ByteArray", "ShortArray", "CharArray", "IntArray", "LongArray", "FloatArray", "DoubleArray" -> {
|
||||
require(value.javaClass.isArray) { "konfig: expected ${type(declared)}" }
|
||||
val element = if (declared.name == "Array") declared.arguments.single()
|
||||
else FieldValueType(declared.name.removeSuffix("Array"))
|
||||
val primitive = (specializeArrays || declared.name != "Array") && !element.nullable && element.name in primitives
|
||||
val factory = if (primitive) "${element.name.replaceFirstChar { it.lowercase() }}ArrayOf"
|
||||
else "arrayOf<${type(element)}>"
|
||||
(0 until JavaArray.getLength(value)).joinToString(", ", "$factory(", ")") {
|
||||
value(JavaArray.get(value, it), element)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
require(value.javaClass.simpleName == declared.name ||
|
||||
(declared.name == "Int" && value is Int) ||
|
||||
(declared.name == "Char" && value is Char)) {
|
||||
"konfig: expected ${type(declared)}, got ${value.javaClass.simpleName}"
|
||||
}
|
||||
scalar(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dynamicValue(value: Any): String = when (value) {
|
||||
is Set<*> -> value(value, FieldValueType("Set", listOf(FieldValueType("Any", nullable = true))))
|
||||
is List<*> -> value(value, FieldValueType("List", listOf(FieldValueType("Any", nullable = true))))
|
||||
is Map<*, *> -> value(value, FieldValueType("Map", List(2) { FieldValueType("Any", nullable = true) }))
|
||||
else -> if (value.javaClass.isArray) {
|
||||
val component = value.javaClass.componentType
|
||||
val element = if (component.isPrimitive) FieldValueType(when (component.name) {
|
||||
"int" -> "Int"
|
||||
"char" -> "Char"
|
||||
else -> component.name.replaceFirstChar { it.uppercase() }
|
||||
}) else FieldValueType("Any", nullable = true)
|
||||
value(value, if (component.isPrimitive) FieldValueType("${element.name}Array") else FieldValueType("Array", listOf(element)))
|
||||
} else scalar(value)
|
||||
}
|
||||
|
||||
private fun scalar(value: Any): String = when (value) {
|
||||
is Enum<*> -> enumLiteral(value)
|
||||
is UByte -> "${value}u.toUByte()"
|
||||
is UShort -> "${value}u.toUShort()"
|
||||
is UInt -> "${value}u"
|
||||
is ULong -> "${value}uL"
|
||||
is String -> value.toKotlinStringLiteral()
|
||||
is Char -> "'${escape(value, charLiteral = true)}'"
|
||||
is Boolean, is Int -> value.toString()
|
||||
is Byte -> "($value).toByte()"
|
||||
is Short -> "($value).toShort()"
|
||||
is Long -> if (value == Long.MIN_VALUE) "Long.MIN_VALUE" else "${value}L"
|
||||
is Float -> when {
|
||||
value.isNaN() -> "Float.NaN"
|
||||
value == Float.POSITIVE_INFINITY -> "Float.POSITIVE_INFINITY"
|
||||
value == Float.NEGATIVE_INFINITY -> "Float.NEGATIVE_INFINITY"
|
||||
else -> "${value}f"
|
||||
}
|
||||
is Double -> when {
|
||||
value.isNaN() -> "Double.NaN"
|
||||
value == Double.POSITIVE_INFINITY -> "Double.POSITIVE_INFINITY"
|
||||
value == Double.NEGATIVE_INFINITY -> "Double.NEGATIVE_INFINITY"
|
||||
else -> value.toString()
|
||||
}
|
||||
else -> error("konfig: unsupported collection value type '${value.javaClass.name}'")
|
||||
}
|
||||
|
||||
private fun enumLiteral(value: Enum<*>): String {
|
||||
val name = requireNotNull(value.declaringJavaClass.canonicalName) { "konfig: enum must have a qualified name" }
|
||||
require(name.split('.').all { it.isValidKotlinIdentifier() } && value.name.isValidKotlinIdentifier()) {
|
||||
"konfig: enum '$name.${value.name}' must have Kotlin-compatible names"
|
||||
}
|
||||
return "$name.${value.name}"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun String.toKotlinStringLiteral(): String =
|
||||
map { escape(it, charLiteral = false) }.joinToString("", "\"", "\"")
|
||||
|
||||
private fun escape(c: Char, charLiteral: Boolean): String = when (c) {
|
||||
'\\' -> "\\\\"
|
||||
'\"' -> if (charLiteral) "\"" else "\\\""
|
||||
'\'' -> if (charLiteral) "\\'" else "'"
|
||||
'\n' -> "\\n"
|
||||
'\r' -> "\\r"
|
||||
'\t' -> "\\t"
|
||||
'$' -> if (charLiteral) "$" else "\\$"
|
||||
else -> if (c.code < 32 || c.code == 127 || c.isSurrogate()) "\\u${c.code.toString(16).padStart(4, '0')}" else c.toString()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.bitsycore.konfig.types
|
||||
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.KType
|
||||
|
||||
/** A serializable snapshot of a DSL type. Never retain KType/KClass in the DSL graph. */
|
||||
@PublishedApi
|
||||
internal data class FieldValueType(
|
||||
val name: String,
|
||||
val arguments: List<FieldValueType> = emptyList(),
|
||||
val nullable: Boolean = false,
|
||||
val enumType: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
fun from(type: KType): FieldValueType {
|
||||
val klass = type.classifier as? KClass<*>
|
||||
?: error("konfig: unsupported field type '$type'")
|
||||
val javaType = klass.java
|
||||
val name = when {
|
||||
javaType.isArray && !javaType.componentType.isPrimitive -> "Array"
|
||||
List::class.java.isAssignableFrom(javaType) -> "List"
|
||||
Map::class.java.isAssignableFrom(javaType) -> "Map"
|
||||
Set::class.java.isAssignableFrom(javaType) -> "Set"
|
||||
javaType.isEnum -> requireNotNull(klass.qualifiedName) { "konfig: enum types must have a qualified name" }
|
||||
else -> klass.simpleName
|
||||
}
|
||||
require(javaType.isEnum || name in supportedNames) { "konfig: unsupported field type '$type'" }
|
||||
if (javaType.isEnum) require(name!!.split('.').all { it.isValidKotlinIdentifier() }) {
|
||||
"konfig: enum type '$name' must have a Kotlin-compatible qualified name"
|
||||
}
|
||||
return FieldValueType(name!!, type.arguments.map {
|
||||
it.type?.let(::from) ?: FieldValueType("Any", nullable = true)
|
||||
}, type.isMarkedNullable, javaType.isEnum)
|
||||
}
|
||||
|
||||
private val supportedNames = setOf(
|
||||
"String", "Boolean", "Byte", "Short", "Char", "Int", "Long", "Float", "Double", "Any",
|
||||
"Set", "UByte", "UShort", "UInt", "ULong",
|
||||
"List", "Map", "Array", "BooleanArray", "ByteArray", "ShortArray", "CharArray",
|
||||
"IntArray", "LongArray", "FloatArray", "DoubleArray",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.bitsycore.konfig.types
|
||||
|
||||
@DslMarker
|
||||
annotation class KonfigDsl
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.bitsycore.konfig.types
|
||||
|
||||
internal fun String.isValidKotlinIdentifier(): Boolean =
|
||||
isNotEmpty() && any { it != '_' } && this !in kotlinKeywords &&
|
||||
first().let { it.isLetter() || it == '_' } && all { it.isLetterOrDigit() || it == '_' }
|
||||
|
||||
private val kotlinKeywords = setOf(
|
||||
"as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", "interface",
|
||||
"is", "null", "object", "package", "return", "super", "this", "throw", "true", "try", "typealias",
|
||||
"typeof", "val", "var", "when", "while",
|
||||
)
|
||||
|
||||
/** Dimensions are also used as property keys and delimiters in task input maps. */
|
||||
internal fun String.isValidDimensionName(): Boolean =
|
||||
isNotBlank() && none { it == '|' || it.isISOControl() }
|
||||
|
||||
internal fun String.toVariantConstName(): String =
|
||||
uppercase().replace(Regex("[^A-Z0-9]"), "_") + "_VARIANT"
|
||||
@@ -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
-1
@@ -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,21 @@
|
||||
package com.bitsycore.konfig
|
||||
|
||||
import com.bitsycore.konfig.types.isValidKotlinIdentifier
|
||||
import com.bitsycore.konfig.types.BuildType
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class KotlinNamesTest {
|
||||
@Test fun `hard keywords numeric starts and underscore-only identifiers are invalid`() {
|
||||
listOf("class", "when", "true", "typeof", "_", "___", "1bad", "", "a-b").forEach {
|
||||
assertFalse(it.isValidKotlinIdentifier(), it)
|
||||
}
|
||||
listOf("_valid", "Env", "VALUE", "value2", "café").forEach { assertTrue(it.isValidKotlinIdentifier(), it) }
|
||||
}
|
||||
|
||||
@Test fun `project paths do not select build types`() {
|
||||
assertNull(BuildType.resolveTasks(listOf(":debug:compileKotlin", ":release:compileKotlin")))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user