diff --git a/README.md b/README.md index b56a8a7..8b6ab46 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,44 @@ cag [--new-architecture [--no-compose] [--ktlint] [--detekt]]... [--new-feature ``` When run without arguments, the command prints a short usage and suggests using `--help` or `-h` for more options. + +### CLI configuration (.cagrc) + +You can configure library and plugin versions used by the CLI via a simple INI-style config file named `.cagrc`. + +- Locations: + - Project root: `./.cagrc` + - User home: `~/.cagrc` + +- Precedence: + - Values in the project `.cagrc` override values in `~/.cagrc`. + +- Sections: + - `[new.versions]` — applied when generating new projects (e.g., `--new-project`). + - `[existing.versions]` — applied when generating into an existing project (e.g., new architecture, feature, data source, use case, or view model). + +- Keys correspond to version keys used by the generator, for example: `kotlin`, `androidGradlePlugin`, `composeBom`, `composeNavigation`, `retrofit`, `ktor`, `okhttp3`, etc. + +Example `~/.cagrc`: + +``` +[new.versions] +kotlin=2.2.10 +composeBom=2025.08.01 + +[existing.versions] +retrofit=2.11.0 +ktor=3.0.3 +``` + +Example `./.cagrc` (project overrides): + +``` +[new.versions] +composeBom=2025.09.01 + +[existing.versions] +okhttp3=4.12.0 +``` + +With the above, new projects will use `composeBom=2025.09.01` (from project), `kotlin=2.2.10` (from home). For operations on existing projects, `retrofit=2.11.0` (home) and `okhttp3=4.12.0` (project) will be applied. diff --git a/cli/src/main/kotlin/com/mitteloupe/cag/cli/Main.kt b/cli/src/main/kotlin/com/mitteloupe/cag/cli/Main.kt index db41352..1f079d5 100644 --- a/cli/src/main/kotlin/com/mitteloupe/cag/cli/Main.kt +++ b/cli/src/main/kotlin/com/mitteloupe/cag/cli/Main.kt @@ -1,5 +1,6 @@ package com.mitteloupe.cag.cli +import com.mitteloupe.cag.cli.configuration.ClientConfigurationLoader import com.mitteloupe.cag.cli.filesystem.CliFileSystemBridge import com.mitteloupe.cag.core.DirectoryFinder import com.mitteloupe.cag.core.GenerationException @@ -24,6 +25,7 @@ import com.mitteloupe.cag.core.generation.UiLayerContentGenerator import com.mitteloupe.cag.core.generation.architecture.ArchitectureModulesContentGenerator import com.mitteloupe.cag.core.generation.architecture.CoroutineModuleContentGenerator import com.mitteloupe.cag.core.generation.filesystem.FileCreator +import com.mitteloupe.cag.core.generation.versioncatalog.VersionCatalogSettingsAccessor import com.mitteloupe.cag.core.generation.versioncatalog.VersionCatalogUpdater import com.mitteloupe.cag.core.request.GenerateArchitectureRequest import com.mitteloupe.cag.core.request.GenerateFeatureRequestBuilder @@ -47,6 +49,7 @@ fun main(arguments: Array) { val projectRoot = findGradleProjectRoot(Paths.get("").toAbsolutePath().toFile()) ?: Paths.get("").toAbsolutePath().toFile() val projectModel = FilesystemProjectModel(projectRoot) val basePackage = NamespaceResolver().determineBasePackage(projectModel) + val configuration = ClientConfigurationLoader().load(projectRoot) if (argumentProcessor.isHelpRequested(arguments)) { printHelpMessage() @@ -102,6 +105,7 @@ fun main(arguments: Array) { enableRetrofit = request.enableRetrofit ) executeAndReport { + setVersionProvider(configuration.newProjectVersions) generator.generateProjectTemplate(projectTemplateRequest) } } @@ -117,6 +121,7 @@ fun main(arguments: Array) { enableDetekt = request.enableDetekt ) executeAndReport { + setVersionProvider(configuration.existingProjectVersions) generator.generateArchitecture(architectureRequest) } } @@ -136,12 +141,14 @@ fun main(arguments: Array) { .enableDetekt(requestFeature.enableDetekt) .build() executeAndReport { + setVersionProvider(configuration.existingProjectVersions) generator.generateFeature(request) } } dataSourceRequests.forEach { request -> executeAndReport { + setVersionProvider(configuration.existingProjectVersions) generator.generateDataSource( destinationRootDirectory = destinationRootDirectory, dataSourceName = request.dataSourceName, @@ -165,6 +172,7 @@ fun main(arguments: Array) { .build() executeAndReport { + setVersionProvider(configuration.existingProjectVersions) generator.generateUseCase(useCaseRequest) } } @@ -184,6 +192,7 @@ fun main(arguments: Array) { ).build() executeAndReport { + setVersionProvider(configuration.existingProjectVersions) generator.generateViewModel(viewModelRequest) } } @@ -312,3 +321,9 @@ private fun executeAndReport(operation: () -> Unit) = println("Error: ${exception.message}") exitProcess(1) } + +private fun setVersionProvider(overrides: Map) { + VersionCatalogSettingsAccessor.setProvider { key, default -> + overrides[key] ?: default + } +} diff --git a/cli/src/main/kotlin/com/mitteloupe/cag/cli/configuration/ClientConfiguration.kt b/cli/src/main/kotlin/com/mitteloupe/cag/cli/configuration/ClientConfiguration.kt new file mode 100644 index 0000000..a86d48a --- /dev/null +++ b/cli/src/main/kotlin/com/mitteloupe/cag/cli/configuration/ClientConfiguration.kt @@ -0,0 +1,10 @@ +package com.mitteloupe.cag.cli.configuration + +data class ClientConfiguration( + val newProjectVersions: Map = emptyMap(), + val existingProjectVersions: Map = emptyMap() +) { + companion object { + val EMPTY = ClientConfiguration() + } +} diff --git a/cli/src/main/kotlin/com/mitteloupe/cag/cli/configuration/ClientConfigurationLoader.kt b/cli/src/main/kotlin/com/mitteloupe/cag/cli/configuration/ClientConfigurationLoader.kt new file mode 100644 index 0000000..3ccdb05 --- /dev/null +++ b/cli/src/main/kotlin/com/mitteloupe/cag/cli/configuration/ClientConfigurationLoader.kt @@ -0,0 +1,69 @@ +package com.mitteloupe.cag.cli.configuration + +import java.io.File + +private const val FILE_NAME = ".cagrc" + +class ClientConfigurationLoader { + fun load(projectRoot: File): ClientConfiguration { + val projectFile = File(projectRoot, FILE_NAME) + val homeFile = File(System.getProperty("user.home"), FILE_NAME) + return loadFromFiles(projectFile = projectFile, homeFile = homeFile) + } + + internal fun loadFromFiles( + projectFile: File?, + homeFile: File? + ): ClientConfiguration { + val homeConfiguration = if (homeFile?.exists() == true && homeFile.isFile) parse(homeFile.readText()) else ClientConfiguration.EMPTY + val projectConfiguration = + if (projectFile?.exists() == true && projectFile.isFile) { + parse(projectFile.readText()) + } else { + ClientConfiguration.EMPTY + } + return mergeConfigurations(baseConfiguration = homeConfiguration, override = projectConfiguration) + } + + private fun mergeConfigurations( + baseConfiguration: ClientConfiguration, + override: ClientConfiguration + ): ClientConfiguration = + ClientConfiguration( + newProjectVersions = baseConfiguration.newProjectVersions + override.newProjectVersions, + existingProjectVersions = baseConfiguration.existingProjectVersions + override.existingProjectVersions + ) + + internal fun parse(text: String): ClientConfiguration { + val newProjectVersions = mutableMapOf() + val existingProjectVersions = mutableMapOf() + + var currentVersionsMap: MutableMap? = null + + text.lineSequence().forEach { rawLine -> + val line = rawLine.trim() + if (line.isEmpty() || line.startsWith("#") || line.startsWith(";")) return@forEach + + if (line.startsWith("[") && line.endsWith("]")) { + currentVersionsMap = + when (line.substring(1, line.length - 1).lowercase()) { + "new.versions" -> newProjectVersions + "existing.versions" -> existingProjectVersions + else -> null + } + return@forEach + } + + val index = line.indexOf('=') + if (index > 0 && currentVersionsMap != null) { + val key = line.take(index).trim() + val value = line.substring(index + 1).trim() + if (key.isNotEmpty() && value.isNotEmpty()) { + currentVersionsMap[key] = value + } + } + } + + return ClientConfiguration(newProjectVersions = newProjectVersions, existingProjectVersions = existingProjectVersions) + } +} diff --git a/cli/src/test/kotlin/com/mitteloupe/cag/cli/configuration/VersionConfigLoaderTest.kt b/cli/src/test/kotlin/com/mitteloupe/cag/cli/configuration/VersionConfigLoaderTest.kt new file mode 100644 index 0000000..a542948 --- /dev/null +++ b/cli/src/test/kotlin/com/mitteloupe/cag/cli/configuration/VersionConfigLoaderTest.kt @@ -0,0 +1,105 @@ +package com.mitteloupe.cag.cli.configuration + +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.io.File +import kotlin.io.path.createTempDirectory + +class VersionConfigLoaderTest { + private lateinit var classUnderTest: ClientConfigurationLoader + + @Before + fun setUp() { + classUnderTest = ClientConfigurationLoader() + } + + @Test + fun `Given INI text with new and existing sections when parse then parses values`() { + // Given + val text = + """ + # Comment + [new.versions] + kotlin=2.2.99 + composeBom=2099.01.01 + + [existing.versions] + retrofit=2.99.0 + ktor=9.9.9 + """.trimIndent() + val expectedClientConfiguration = + ClientConfiguration( + newProjectVersions = + mapOf( + "kotlin" to "2.2.99", + "composeBom" to "2099.01.01" + ), + existingProjectVersions = + mapOf( + "retrofit" to "2.99.0", + "ktor" to "9.9.9" + ) + ) + + // When + val actualConfiguration = classUnderTest.parse(text) + + // Then + assertEquals(expectedClientConfiguration, actualConfiguration) + } + + @Test + fun `Given home, project configurations when loadFromFiles then project overrides home`() { + // Given + val temporaryDirectory = createTempDirectory(prefix = "cag-test-").toFile() + try { + val homeFile = + File(temporaryDirectory, "home.cagrc").apply { + writeText( + """ + [new.versions] + composeBom=2000.01.01 + ktor=1.0.0 + [existing.versions] + retrofit=2.0.0 + """.trimIndent() + ) + } + + val projectFile = + File(temporaryDirectory, ".cagrc").apply { + writeText( + """ + [new.versions] + composeBom=2025.08.01 + [existing.versions] + retrofit=2.11.0 + okhttp3=4.12.0 + """.trimIndent() + ) + } + val expectedConfiguration = + ClientConfiguration( + newProjectVersions = + mapOf( + "composeBom" to "2025.08.01", + "ktor" to "1.0.0" + ), + existingProjectVersions = + mapOf( + "retrofit" to "2.11.0", + "okhttp3" to "4.12.0" + ) + ) + + // When + val actualConfiguration = classUnderTest.loadFromFiles(projectFile = projectFile, homeFile = homeFile) + + // Then + assertEquals(expectedConfiguration, actualConfiguration) + } finally { + temporaryDirectory.deleteRecursively() + } + } +}