From 5f4c7ed02cdad8ef2ef03120f2a0a0ac13621664 Mon Sep 17 00:00:00 2001 From: arunkumar9t2 Date: Mon, 2 Feb 2026 14:28:23 +0800 Subject: [PATCH 1/4] Add variant compression for Android library targets Implements intelligent grouping of equivalent Android build variants to reduce the number of generated Bazel targets. --- .../com/grab/grazel/di/GrazelComponent.kt | 4 + .../gradle/dependencies/DependencyGraphs.kt | 31 ++ .../GradleDependencyToBazelDependency.kt | 24 +- .../gradle/dependencies/TopologicalSorter.kt | 165 ++++++ .../gradle/variant/DependencyNormalizer.kt | 134 +++++ .../com/grab/grazel/gradle/variant/Variant.kt | 29 +- .../variant/VariantCompressionResult.kt | 117 ++++ .../variant/VariantCompressionService.kt | 134 +++++ .../gradle/variant/VariantCompressor.kt | 478 ++++++++++++++++ .../variant/VariantEquivalenceChecker.kt | 89 +++ .../grazel/gradle/variant/VariantGraphKey.kt | 21 +- .../grazel/gradle/variant/VariantMatcher.kt | 14 +- .../grazel/gradle/variant/VariantModule.kt | 18 +- .../android/AndroidUnitTestDataExtractor.kt | 25 +- .../target/AndroidLibraryTargetBuilder.kt | 59 +- .../internal/AnalyzeVariantCompressionTask.kt | 241 ++++++++ .../internal/GenerateBazelScriptsTask.kt | 5 + .../grazel/tasks/internal/TasksManager.kt | 11 + .../grab/grazel/fake/FakeDependencyGraphs.kt | 8 +- .../fake/FakeVariantCompressionService.kt | 56 ++ .../DefaultDependencyGraphsTest.kt | 142 ++++- .../dependencies/JvmProjectGraphKeyTest.kt | 2 +- .../dependencies/TopologicalSorterTest.kt | 362 ++++++++++++ .../variant/DependencyNormalizerTest.kt | 232 ++++++++ .../variant/VariantCompressionResultTest.kt | 204 +++++++ .../gradle/variant/VariantCompressorTest.kt | 514 ++++++++++++++++++ .../variant/VariantEquivalenceCheckerTest.kt | 361 ++++++++++++ .../gradle/variant/VariantGraphKeyTest.kt | 162 ++++++ ...DefaultAndroidUnitTestDataExtractorTest.kt | 13 +- keystore/BUILD.bazel | 15 +- sample-android-library/BUILD.bazel | 179 +----- sample-android/BUILD.bazel | 16 +- 32 files changed, 3614 insertions(+), 251 deletions(-) create mode 100644 grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/dependencies/TopologicalSorter.kt create mode 100644 grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/DependencyNormalizer.kt create mode 100644 grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantCompressionResult.kt create mode 100644 grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantCompressionService.kt create mode 100644 grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantCompressor.kt create mode 100644 grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantEquivalenceChecker.kt create mode 100644 grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/tasks/internal/AnalyzeVariantCompressionTask.kt create mode 100644 grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/fake/FakeVariantCompressionService.kt create mode 100644 grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/dependencies/TopologicalSorterTest.kt create mode 100644 grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/DependencyNormalizerTest.kt create mode 100644 grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantCompressionResultTest.kt create mode 100644 grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantCompressorTest.kt create mode 100644 grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantEquivalenceCheckerTest.kt create mode 100644 grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantGraphKeyTest.kt diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/di/GrazelComponent.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/di/GrazelComponent.kt index baf57742..b41ffea9 100644 --- a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/di/GrazelComponent.kt +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/di/GrazelComponent.kt @@ -31,7 +31,9 @@ import com.grab.grazel.gradle.dependencies.DefaultDependencyResolutionService import com.grab.grazel.gradle.dependencies.DependenciesDataSource import com.grab.grazel.gradle.dependencies.DependenciesModule import com.grab.grazel.gradle.variant.AndroidVariantDataSource +import com.grab.grazel.gradle.variant.DefaultVariantCompressionService import com.grab.grazel.gradle.variant.VariantBuilder +import com.grab.grazel.gradle.variant.VariantCompressor import com.grab.grazel.gradle.variant.VariantMatcher import com.grab.grazel.gradle.variant.VariantModule import com.grab.grazel.hybrid.HybridBuildExecutor @@ -91,11 +93,13 @@ internal interface GrazelComponent { fun variantBuilder(): Lazy fun variantMatcher(): Lazy + fun variantCompressor(): Lazy fun manifestValuesBuilder(): ManifestValuesBuilder fun dependencyResolutionService(): GradleProvider fun dependencyGraphsService(): GradleProvider + fun variantCompressionService(): GradleProvider fun configurationDataSource(): Lazy fun repositoryDataSource(): Lazy } diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/dependencies/DependencyGraphs.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/dependencies/DependencyGraphs.kt index 43e05fd5..65d84322 100644 --- a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/dependencies/DependencyGraphs.kt +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/dependencies/DependencyGraphs.kt @@ -19,6 +19,7 @@ package com.grab.grazel.gradle.dependencies import com.google.common.graph.Graphs import com.google.common.graph.ImmutableValueGraph import com.grab.grazel.gradle.variant.VariantGraphKey +import com.grab.grazel.gradle.variant.VariantType import org.gradle.api.Project import org.gradle.api.artifacts.Configuration @@ -49,6 +50,18 @@ internal interface DependencyGraphs { project: Project, variantKey: VariantGraphKey ): Set + + /** + * Merges variant graphs into a project-level dependency graph. + * + * @param variantTypeFilter Predicate to filter which variant types to include. Defaults to + * build graphs only (AndroidBuild, JvmBuild) to avoid artificial cycles from test + * dependencies. + * @return Map of projects to their direct dependencies across filtered variants. + */ + fun mergeToProjectGraph( + variantTypeFilter: (VariantType) -> Boolean = { it.isBuildGraph } + ): Map> } internal class DefaultDependencyGraphs( @@ -93,4 +106,22 @@ internal class DefaultDependencyGraphs( project: Project, variantKey: VariantGraphKey ): Set = variantGraphs[variantKey]?.successors(project)?.toSet() ?: emptySet() + + override fun mergeToProjectGraph( + variantTypeFilter: (VariantType) -> Boolean + ): Map> { + // Filter graphs by variant type + val filteredGraphs = variantGraphs.filterKeys { key -> + variantTypeFilter(key.variantType) + } + + val allProjects = filteredGraphs.values.flatMap { it.nodes() }.toSet() + return allProjects.associateWith { project -> + filteredGraphs.values.flatMap { graph -> + if (graph.nodes().contains(project)) { + graph.successors(project) + } else emptySet() + }.toSet() + } + } } diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/dependencies/GradleDependencyToBazelDependency.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/dependencies/GradleDependencyToBazelDependency.kt index 962b9702..e2203f1f 100644 --- a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/dependencies/GradleDependencyToBazelDependency.kt +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/dependencies/GradleDependencyToBazelDependency.kt @@ -19,17 +19,20 @@ package com.grab.grazel.gradle.dependencies import com.grab.grazel.bazel.starlark.BazelDependency import com.grab.grazel.gradle.isAndroid import com.grab.grazel.gradle.isAndroidTest +import com.grab.grazel.gradle.variant.DefaultVariantCompressionService import com.grab.grazel.gradle.variant.MatchedVariant import com.grab.grazel.gradle.variant.nameSuffix +import com.grab.grazel.gradle.variant.resolveSuffix +import com.grab.grazel.util.GradleProvider import org.gradle.api.Project import javax.inject.Inject internal class GradleDependencyToBazelDependency @Inject -constructor() { - /** - * [matchedVariant] can only be null if and only if the [project] is a Java/Kotlin project - */ +constructor( + private val variantCompressionService: GradleProvider +) { + /** [matchedVariant] can only be null if and only if the [project] is a Java/Kotlin project */ fun map( project: Project, dependency: Project, @@ -41,11 +44,18 @@ constructor() { "please provide the variant for the android project=${project.name}" ) } - if (dependency.isAndroid) {// project is an android project, dependent is also + if (dependency.isAndroid) { + val baseSuffix = variantCompressionService.get().resolveSuffix( + projectPath = dependency.path, + variantName = matchedVariant.variantName, + fallbackSuffix = matchedVariant.nameSuffix, + logger = project.logger + ) + val suffix = if (dependency.isAndroidTest) { - "${matchedVariant.nameSuffix}_lib" + "${baseSuffix}_lib" } else { - matchedVariant.nameSuffix + baseSuffix } BazelDependency.ProjectDependency( dependency, diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/dependencies/TopologicalSorter.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/dependencies/TopologicalSorter.kt new file mode 100644 index 00000000..5bf4e559 --- /dev/null +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/dependencies/TopologicalSorter.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2022 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.gradle.dependencies + +import org.gradle.api.Project +import java.util.ArrayDeque + +/** + * Topological sorting utility using Kahn's algorithm. + */ +internal object TopologicalSorter { + /** + * Returns projects in topological order (dependencies before dependents). + * + * @param graphs The dependency graphs to merge and sort + * @return List of projects ordered such that dependencies appear before dependents + * @throws IllegalStateException if a cycle is detected in the dependency graph + */ + fun sort(graphs: DependencyGraphs): List { + val merged = graphs.mergeToProjectGraph() // project → its dependencies + + if (merged.isEmpty()) { + return emptyList() + } + + // Build reverse map: project → projects that depend on it (dependents) + val dependents = mutableMapOf>() + merged.keys.forEach { dependents[it] = mutableSetOf() } + merged.forEach { (project, dependencies) -> + dependencies.forEach { dep -> + dependents.getOrPut(dep) { mutableSetOf() }.add(project) + } + } + + // In-degree = number of dependencies each project has + val inDegrees = merged.mapValues { (_, deps) -> deps.size }.toMutableMap() + + // Queue starts with projects having zero dependencies (leaf nodes) + // Sort by path to ensure deterministic ordering + val queue = ArrayDeque(inDegrees.filterValues { it == 0 }.keys.sortedBy { it.path }) + val ordered = mutableListOf() + + // Process queue using Kahn's algorithm + while (queue.isNotEmpty()) { + val current = queue.removeFirst() + ordered.add(current) + + // Collect newly ready projects and sort them deterministically + val newlyReady = mutableListOf() + dependents[current]?.forEach { dependent -> + val newDegree = inDegrees[dependent]!! - 1 + inDegrees[dependent] = newDegree + + // If in-degree reaches zero, all dependencies are processed + if (newDegree == 0) { + newlyReady.add(dependent) + } + } + + // Add in sorted order to ensure deterministic results + newlyReady.sortedBy { it.path }.forEach { queue.add(it) } + } + + // Detect cycles: if we didn't process all projects, there must be a cycle + check(ordered.size == merged.size) { + val unprocessed = merged.keys - ordered.toSet() + val cyclePath = findCycle(merged, unprocessed) + val cycleMessage = if (cyclePath.isNotEmpty()) { + "Cycle path: ${cyclePath.joinToString(" -> ") { it.path }}" + } else { + "Unable to determine exact cycle path" + } + + buildString { + appendLine("Cycle detected in dependency graph.") + appendLine(cycleMessage) + appendLine() + append("Unprocessed projects (may include projects blocked by cycle): ") + append(unprocessed.map { it.path }.sorted()) + } + } + + return ordered + } + + /** + * Finds an actual cycle path using iterative DFS. + */ + private fun findCycle( + graph: Map>, + unprocessed: Set + ): List { + val visited = mutableSetOf() + + for (startNode in unprocessed.sortedBy { it.path }) { + if (startNode in visited) continue + + val recursionStack = mutableSetOf() + val parent = mutableMapOf() + val stack = ArrayDeque>>() + + val startDeps = graph[startNode]?.filter { it in unprocessed }?.sortedBy { it.path } ?: emptyList() + stack.addLast(startNode to startDeps.iterator()) + recursionStack.add(startNode) + parent[startNode] = null + + while (stack.isNotEmpty()) { + val (current, iterator) = stack.last() + + if (iterator.hasNext()) { + val neighbor = iterator.next() + + if (neighbor in recursionStack) { + return reconstructCyclePath(neighbor, current, parent) + } + + if (neighbor !in visited && neighbor in unprocessed) { + recursionStack.add(neighbor) + parent[neighbor] = current + val neighborDeps = graph[neighbor]?.filter { it in unprocessed }?.sortedBy { it.path } ?: emptyList() + stack.addLast(neighbor to neighborDeps.iterator()) + } + } else { + stack.removeLast() + recursionStack.remove(current) + visited.add(current) + } + } + } + return emptyList() + } + + private fun reconstructCyclePath( + cycleStart: Project, + cycleEnd: Project, + parent: Map + ): List { + val path = mutableListOf() + var current: Project? = cycleEnd + + while (current != null && current != cycleStart) { + path.add(current) + current = parent[current] + } + path.add(cycleStart) + path.reverse() + path.add(cycleStart) + + return path + } +} diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/DependencyNormalizer.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/DependencyNormalizer.kt new file mode 100644 index 00000000..91d4de92 --- /dev/null +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/DependencyNormalizer.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2022 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.gradle.variant + +import com.grab.grazel.bazel.starlark.BazelDependency +import com.grab.grazel.bazel.starlark.BazelDependency.FileDependency +import com.grab.grazel.bazel.starlark.BazelDependency.MavenDependency +import com.grab.grazel.bazel.starlark.BazelDependency.ProjectDependency +import com.grab.grazel.bazel.starlark.BazelDependency.StringDependency +import java.io.File +import javax.inject.Inject + +/** + * Normalizes [BazelDependency] instances for comparison by removing variant-specific suffixes. + * + * This is used in variant compression to determine if two dependencies are semantically equivalent + * despite having different variant-specific naming. + */ +internal interface DependencyNormalizer { + /** + * Normalizes a dependency to a canonical form by removing variant-specific suffixes. + * + * @param dependency The dependency to normalize + * @return A normalized string representation suitable for comparison + */ + fun normalize(dependency: BazelDependency): String +} + +internal class DefaultDependencyNormalizer @Inject constructor() : DependencyNormalizer { + + override fun normalize(dependency: BazelDependency): String { + return when (dependency) { + is ProjectDependency -> normalizeProjectDependency(dependency) + is MavenDependency -> normalizeMavenDependency(dependency) + is StringDependency -> normalizeStringDependency(dependency) + is FileDependency -> normalizeFileDependency(dependency) + } + } + + /** + * Normalizes a project dependency by removing variant-specific suffixes. + * + * Removes: + * - Type suffixes: `_kt`, `_lib` + * - Variant suffixes: `-{flavor}-{buildType}`, `-{buildType}` + * + * Examples: + * - `//foo:foo_kt-free-debug` -> `//foo:foo` + * - `//bar:bar_lib-debug` -> `//bar:bar` + * - `//baz:baz-paid-release` -> `//baz:baz` + */ + private fun normalizeProjectDependency(dependency: ProjectDependency): String { + val relativeRootPath = dependency.dependencyProject + .rootProject + .relativePath(dependency.dependencyProject.projectDir) + + val buildTargetName = dependency.dependencyProject.name + val sep = File.separator + + // Construct base path without suffix or prefix + val basePath = when { + sep in relativeRootPath -> { + val path = relativeRootPath + .split(sep) + .dropLast(1) + .joinToString(sep) + "//$path/$buildTargetName" + } + + else -> "//$buildTargetName" + } + + // Return normalized label without any suffix + return "$basePath:$buildTargetName" + } + + /** + * Normalizes a Maven dependency to a canonical form. + * + * Format: `@{repo}//:{group}_{name}` + * + * Example: + * - `@maven//:com_google_guava_guava` (stays the same) + */ + private fun normalizeMavenDependency(dependency: MavenDependency): String { + val group = dependency.group.toBazelPath() + val name = dependency.name.toBazelPath() + return "@${dependency.repo}//:${group}_$name" + } + + /** Normalizes a string dependency by trimming whitespace. */ + private fun normalizeStringDependency(dependency: StringDependency): String { + return dependency.string.trim() + } + + /** + * Normalizes a file dependency by relativizing to root project. + * + * Example: + * - File in root: `//:file.jar` + * - File in subdir: `//libs:file.jar` + */ + private fun normalizeFileDependency(dependency: FileDependency): String { + val fileName = dependency.file.name + val filePath = dependency.file.absoluteFile + .normalize() + .relativeTo(dependency.rootProject.projectDir).toString() + + return if (fileName == filePath) { + "//:$fileName" + } else { + // The file is not in root directory + "//${filePath.substringBeforeLast(File.separator)}:$fileName" + } + } + + private fun String.toBazelPath(): String { + return replace(".", "_").replace("-", "_") + } +} diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/Variant.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/Variant.kt index 64578f3d..c3b10589 100644 --- a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/Variant.kt +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/Variant.kt @@ -17,13 +17,13 @@ import org.gradle.api.Project import org.gradle.api.artifacts.Configuration /** - * Base marker interface that denotes a variant that needs to be migrated and is used to - * encapsulate both Android and Jvm variants + * Base marker interface that denotes a variant that needs to be migrated and is used to encapsulate + * both Android and Jvm variants * * Variants are meant to be the first extracted item from a [Project] instance for migration. - * @see VariantBuilder * * @param T The original backing variant type + * @see VariantBuilder */ interface Variant { val name: String @@ -41,9 +41,7 @@ interface Variant { */ val extendsFrom: Set - /** - * Return [Configuration]'s belonging only to this variant - */ + /** Return [Configuration]'s belonging only to this variant */ val variantConfigurations: Set val compileConfiguration: Set @@ -82,7 +80,15 @@ enum class VariantType { AndroidTest, Test, JvmBuild, - Lint, + Lint; + + /** + * Returns true if this variant type represents build-time dependencies (not test). Only build + * graphs should be included when merging for topological sorting to avoid artificial cycles + * from test dependencies. + */ + val isBuildGraph: Boolean + get() = this == AndroidBuild || this == JvmBuild } fun BaseVariant.toVariantType(): VariantType = when (this) { @@ -96,8 +102,8 @@ val Variant<*>.isBase get() = name == DEFAULT_VARIANT /** * Returns true if this variant only extends from default variants (default, test, androidTest). - * Such variants define the hierarchy structure and must always resolve dependencies - * to create proper maven buckets for downstream composite variants. + * Such variants define the hierarchy structure and must always resolve dependencies to create + * proper maven buckets for downstream composite variants. */ val Variant<*>.extendsOnlyFromDefaultVariants: Boolean get() = extendsFrom.isEmpty() || extendsFrom.all { @@ -131,9 +137,7 @@ val VariantType.toJvmVariantType: VariantType else -> JvmBuild } -/** - * Returns the default variant name for JVM projects based on variant type. - */ +/** Returns the default variant name for JVM projects based on variant type. */ val VariantType.jvmVariantName: String get() = when (this.toJvmVariantType) { JvmBuild -> DEFAULT_VARIANT @@ -143,6 +147,7 @@ val VariantType.jvmVariantName: String /** * Return the migratable configurations for this variant. Currently all configurations are merged. + * * TODO("Migrate runtime, annotation processor and Kotlin compiler plugin configuration separately") */ val Variant<*>.migratableConfigurations diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantCompressionResult.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantCompressionResult.kt new file mode 100644 index 00000000..44325ca5 --- /dev/null +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantCompressionResult.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2022 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.gradle.variant + +import com.grab.grazel.migrate.android.AndroidLibraryData + +/** + * Stores the result of variant compression for a single project. + * + * Compression maps multiple Android variants to a smaller set of Bazel targets by grouping variants + * with identical build configuration. The result maintains: + * - A set of unique target suffixes (e.g., "Debug", "Release", "Prod", "Internal") + * - One AndroidLibraryData per suffix (1:1 mapping) + * - A mapping from each variant name to its target suffix (many:1 mapping) + * - A set of build types that should remain expanded (not compressed) + * + * @property targetsBySuffix Map from target suffix to AndroidLibraryData (1:1) + * @property variantToSuffix Map from variant name to target suffix (many:1) + * @property expandedBuildTypes Set of build type names that should remain expanded + */ +internal data class VariantCompressionResult( + val targetsBySuffix: Map, + val variantToSuffix: Map, + val expandedBuildTypes: Set +) { + init { + // Validate that all suffix references in variantToSuffix exist in targetsBySuffix + val missingSuffixes = variantToSuffix.values.toSet() - targetsBySuffix.keys + require(missingSuffixes.isEmpty()) { + "Variant mappings reference non-existent suffixes: $missingSuffixes" + } + } + + /** Returns the set of all target suffixes. */ + val suffixes: Set + get() = targetsBySuffix.keys + + /** + * Returns true if this project is fully compressed (single target with no suffix). + * + * Full compression occurs when: + * - There is exactly one target + * - That target has an empty suffix (no build-type suffix) + * - No build types are expanded + */ + val isFullyCompressed: Boolean + get() = targetsBySuffix.size == 1 && + expandedBuildTypes.isEmpty() && + targetsBySuffix.keys.singleOrNull() == "" + + /** Returns the list of all target data objects. */ + val targets: List + get() = targetsBySuffix.values.toList() + + /** + * Returns the AndroidLibraryData for the given suffix. + * + * @throws NoSuchElementException if suffix does not exist + */ + fun dataForSuffix(suffix: String): AndroidLibraryData { + return targetsBySuffix[suffix] + ?: throw NoSuchElementException("No target data found for suffix: $suffix") + } + + /** + * Returns the AndroidLibraryData for the given variant name. + * + * @throws NoSuchElementException if variant does not exist or its suffix is not found + */ + fun dataForVariant(variantName: String): AndroidLibraryData { + val suffix = suffixForVariant(variantName) + return dataForSuffix(suffix) + } + + /** + * Returns the target suffix for the given variant name. + * + * @throws NoSuchElementException if variant does not exist + */ + fun suffixForVariant(variantName: String): String { + return variantToSuffix[variantName] + ?: throw NoSuchElementException("No suffix mapping found for variant: $variantName") + } + + /** Returns the target suffix for the given variant name, or null if not found. */ + fun suffixForVariantOrNull(variantName: String): String? { + return variantToSuffix[variantName] + } + + /** Returns true if the given build type should remain expanded (not compressed). */ + fun isExpanded(buildType: String): Boolean { + return buildType in expandedBuildTypes + } + + companion object Companion { + /** Returns an empty VariantCompressionResult with no targets or mappings. */ + fun empty(): VariantCompressionResult = VariantCompressionResult( + targetsBySuffix = emptyMap(), + variantToSuffix = emptyMap(), + expandedBuildTypes = emptySet() + ) + } +} diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantCompressionService.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantCompressionService.kt new file mode 100644 index 00000000..056cc3ae --- /dev/null +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantCompressionService.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2022 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.gradle.variant + +import com.grab.grazel.di.qualifiers.RootProject +import org.gradle.api.Project +import org.gradle.api.logging.Logger +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters + +/** + * A [BuildService] that stores and retrieves [VariantCompressionResult] for Android projects. + * + * This service acts as a cache for variant compression results computed during the migration + * process. Projects register their compression results, which can then be queried by other parts of + * the build system that need variant mapping information. + * + * The service stores results in memory during the build and is cleared when the build completes. + */ +internal interface VariantCompressionService : BuildService, + AutoCloseable { + + /** + * Register a compression result for a project. + * + * If a result was already registered for this project, it will be replaced. + * + * @param projectPath The Gradle project path (e.g., ":app", ":lib:feature") + * @param result The compression result to store + */ + fun register(projectPath: String, result: VariantCompressionResult) + + /** + * Get the compression result for a project. + * + * @param projectPath The Gradle project path + * @return The compression result if registered, null otherwise + */ + fun get(projectPath: String): VariantCompressionResult? + + /** + * Check if a compression result has been registered for a project. + * + * @param projectPath The Gradle project path + * @return true if a result is registered, false otherwise + */ + fun isRegistered(projectPath: String): Boolean + + companion object { + internal const val SERVICE_NAME = "VariantCompressionService" + } + + interface Params : BuildServiceParameters +} + +/** + * Default implementation of [VariantCompressionService]. + * + * Stores compression results in a mutable map keyed by project path. + */ +internal abstract class DefaultVariantCompressionService : VariantCompressionService { + private val results = mutableMapOf() + + override fun register(projectPath: String, result: VariantCompressionResult) { + results[projectPath] = result + } + + override fun get(projectPath: String): VariantCompressionResult? { + return results[projectPath] + } + + override fun isRegistered(projectPath: String): Boolean { + return projectPath in results + } + + override fun close() { + results.clear() + } + + companion object { + /** + * Register the service with the root project's shared services. + * + * @param rootProject The root Gradle project + * @return A provider for the registered service + */ + internal fun register(@RootProject rootProject: Project) = rootProject + .gradle + .sharedServices + .registerIfAbsent( + VariantCompressionService.SERVICE_NAME, + DefaultVariantCompressionService::class.java + ) {} + } +} + +/** + * Resolves the target suffix for a variant with fallback and optional logging. + * + * @param projectPath The project path to look up + * @param variantName The variant name to resolve + * @param fallbackSuffix The suffix to use if lookup fails + * @param logger Optional logger for warnings when fallback is used + * @return The resolved suffix (compressed if available, fallback otherwise) + */ +internal fun VariantCompressionService.resolveSuffix( + projectPath: String, + variantName: String, + fallbackSuffix: String, + logger: Logger? = null +): String { + val result = get(projectPath) ?: run { + logger?.warn("No compression result for $projectPath, using fallback suffix") + return fallbackSuffix + } + return result.suffixForVariantOrNull(variantName) ?: run { + logger?.warn("Variant $variantName not found in compression result for $projectPath, using fallback suffix") + fallbackSuffix + } +} diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantCompressor.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantCompressor.kt new file mode 100644 index 00000000..4e69fe8d --- /dev/null +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantCompressor.kt @@ -0,0 +1,478 @@ +/* + * Copyright 2022 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.gradle.variant + +import com.grab.grazel.bazel.starlark.BazelDependency +import com.grab.grazel.migrate.android.AndroidLibraryData +import org.gradle.api.Project +import javax.inject.Inject + +/** Describes why compression succeeded or failed for a build type group */ +internal sealed class VariantCompressionDecision { + data class Compressed( + val buildType: String, + val variants: List, + val compressedSuffix: String + ) : VariantCompressionDecision() + + data class Expanded( + val buildType: String, + val variants: List, + val reason: String + ) : VariantCompressionDecision() + + data class SingleVariant( + val buildType: String, + val variant: String, + val suffix: String + ) : VariantCompressionDecision() + + /** + * Represents full compression across all build types into a single target. This occurs when all + * build-type targets are equivalent and all dependencies are also fully compressed. + */ + data class FullyCompressed( + val buildTypes: List, + val variants: List + ) : VariantCompressionDecision() +} + +internal data class CompressionResultWithDecisions( + val result: VariantCompressionResult, + val decisions: List +) + +// ============================================================================= +// Internal Types for Flavor Compression +// ============================================================================= + +/** + * Represents the decision for how to handle variants within a single build type. + * + * This sealed class encapsulates the three possible outcomes when processing variants + * for a build type during flavor compression. + */ +private sealed class BuildTypeDecision { + /** Compress all variants into a single target with the build type as suffix */ + data class Compress( + val buildType: String, + val suffix: String, + val variants: Map + ) : BuildTypeDecision() + + /** Keep each variant as a separate target (compression blocked or variants differ) */ + data class Expand( + val buildType: String, + val reason: String, + val variants: Map + ) : BuildTypeDecision() + + /** Only one variant exists - nothing to compress */ + data class Single( + val buildType: String, + val variantName: String, + val data: AndroidLibraryData + ) : BuildTypeDecision() +} + +/** + * Result of applying a [BuildTypeDecision] to produce targets and mappings. + */ +private data class BuildTypeResult( + val targetsBySuffix: Map, + val variantToSuffix: Map, + val isExpanded: Boolean, + val decision: VariantCompressionDecision +) + +/** + * Aggregated result from flavor compression (compressing variants within each build type). + * + * Contains all the data accumulated from processing each build type, ready for + * potential build-type compression (merging across build types). + */ +private data class FlavorCompressionResult( + val targetsBySuffix: Map, + val variantToSuffix: Map, + val expandedBuildTypes: Set, + val decisions: List +) { + fun toCompressionResult() = VariantCompressionResult( + targetsBySuffix = targetsBySuffix, + variantToSuffix = variantToSuffix, + expandedBuildTypes = expandedBuildTypes + ) + + fun toResultWithDecisions() = CompressionResultWithDecisions( + result = toCompressionResult(), + decisions = decisions + ) +} + +/** + * Compresses Android variant targets by grouping equivalent variants together. + * + * Compression reduces the number of Bazel targets by identifying variants that have identical build + * configuration and combining them into a single target per build type. + */ +internal interface VariantCompressor { + /** + * Compresses variants based on equivalence analysis. + * + * @param variants Map of variant name to AndroidLibraryData + * @param buildTypeFn Function to extract build type name from variant name + * @param dependencyVariantCompressionResults Map of dependency project to their + * CompressionResult + * @return CompressionResultWithDecisions containing compressed targets, mappings, and decision + * info + */ + fun compress( + variants: Map, + buildTypeFn: (String) -> String, + dependencyVariantCompressionResults: Map + ): CompressionResultWithDecisions +} + +internal class DefaultVariantCompressor @Inject constructor( + private val equivalenceChecker: VariantEquivalenceChecker +) : VariantCompressor { + + // ========================================================================= + // Public API + // ========================================================================= + + override fun compress( + variants: Map, + buildTypeFn: (String) -> String, + dependencyVariantCompressionResults: Map + ): CompressionResultWithDecisions { + if (variants.isEmpty()) { + return CompressionResultWithDecisions( + result = VariantCompressionResult.empty(), + decisions = emptyList() + ) + } + + // Group variants by build type + val variantsByBuildType = variants.entries.groupBy { (variantName, _) -> + buildTypeFn(variantName) + } + + // Compress flavors within each build type (e.g., freeDebug + paidDebug → debug) + val flavorCompressed = compressFlavors(variantsByBuildType, dependencyVariantCompressionResults) + + // Try to compress across build types (e.g., debug + release → single target) + return if (canFullyCompress(flavorCompressed, dependencyVariantCompressionResults)) { + applyFullCompression(flavorCompressed) + } else { + flavorCompressed.toResultWithDecisions() + } + } + + // ========================================================================= + // Flavor Compression (within each build type) + // ========================================================================= + + /** + * Processes each build type and aggregates results into a FlavorCompressionResult. + */ + private fun compressFlavors( + variantsByBuildType: Map>>, + dependencyResults: Map + ): FlavorCompressionResult { + val results = variantsByBuildType.map { (buildType, variantGroup) -> + val variants = variantGroup.associate { it.key to it.value } + val decision = decideBuildTypeCompression(buildType, variants, dependencyResults) + applyBuildTypeDecision(decision) + } + + return mergeResults(results) + } + + /** + * Decides how to handle variants for a single build type. + * + * Evaluates whether variants can be compressed, must be expanded, or are singular. + */ + private fun decideBuildTypeCompression( + buildType: String, + variants: Map, + dependencyResults: Map + ): BuildTypeDecision { + // Single variant - nothing to compress + if (variants.size == 1) { + val (name, data) = variants.entries.first() + return BuildTypeDecision.Single(buildType, name, data) + } + + // Check if compression is blocked by dependencies + val isBlocked = isCompressionBlocked(variants, buildType, dependencyResults) + if (isBlocked) { + val blockingDeps = findBlockingDependencies(variants, buildType, dependencyResults) + return BuildTypeDecision.Expand( + buildType = buildType, + reason = "blocked by dependencies: ${blockingDeps.joinToString(", ")}", + variants = variants + ) + } + + // Check if all variants are equivalent + val allEquivalent = areAllVariantsEquivalent(variants.values.toList()) + if (!allEquivalent) { + return BuildTypeDecision.Expand( + buildType = buildType, + reason = "variants differ in configuration", + variants = variants + ) + } + + // All conditions met - compress + return BuildTypeDecision.Compress( + buildType = buildType, + suffix = normalizeVariantSuffix(buildType), + variants = variants + ) + } + + /** + * Applies a [BuildTypeDecision] to produce targets and mappings. + */ + private fun applyBuildTypeDecision(decision: BuildTypeDecision): BuildTypeResult = + when (decision) { + is BuildTypeDecision.Compress -> applyCompression(decision) + is BuildTypeDecision.Expand -> applyExpansion(decision) + is BuildTypeDecision.Single -> applySingle(decision) + } + + private fun applyCompression(decision: BuildTypeDecision.Compress): BuildTypeResult { + val sortedVariants = decision.variants.entries.sortedBy { it.key } + val representative = sortedVariants.first() + + // Derive compressed name from representative + val representativeSuffix = normalizeVariantSuffix(representative.key) + val baseName = representative.value.name.removeSuffix(representativeSuffix) + val compressedData = representative.value.copy(name = baseName + decision.suffix) + + // All variants map to the compressed suffix + val variantMappings = decision.variants.keys.associateWith { decision.suffix } + + return BuildTypeResult( + targetsBySuffix = mapOf(decision.suffix to compressedData), + variantToSuffix = variantMappings, + isExpanded = false, + decision = VariantCompressionDecision.Compressed( + buildType = decision.buildType, + variants = decision.variants.keys.sorted(), + compressedSuffix = decision.suffix + ) + ) + } + + private fun applyExpansion(decision: BuildTypeDecision.Expand): BuildTypeResult { + val targetsBySuffix = mutableMapOf() + val variantToSuffix = mutableMapOf() + + decision.variants.forEach { (variantName, data) -> + val suffix = normalizeVariantSuffix(variantName) + targetsBySuffix[suffix] = data + variantToSuffix[variantName] = suffix + } + + return BuildTypeResult( + targetsBySuffix = targetsBySuffix, + variantToSuffix = variantToSuffix, + isExpanded = true, + decision = VariantCompressionDecision.Expanded( + buildType = decision.buildType, + variants = decision.variants.keys.sorted(), + reason = decision.reason + ) + ) + } + + private fun applySingle(decision: BuildTypeDecision.Single): BuildTypeResult { + val suffix = normalizeVariantSuffix(decision.variantName) + + return BuildTypeResult( + targetsBySuffix = mapOf(suffix to decision.data), + variantToSuffix = mapOf(decision.variantName to suffix), + isExpanded = false, + decision = VariantCompressionDecision.SingleVariant( + buildType = decision.buildType, + variant = decision.variantName, + suffix = suffix + ) + ) + } + + /** + * Merges multiple [BuildTypeResult]s into a single [FlavorCompressionResult]. + */ + private fun mergeResults(results: List): FlavorCompressionResult { + val targetsBySuffix = mutableMapOf() + val variantToSuffix = mutableMapOf() + val expandedBuildTypes = mutableSetOf() + val decisions = mutableListOf() + + for (result in results) { + targetsBySuffix.putAll(result.targetsBySuffix) + variantToSuffix.putAll(result.variantToSuffix) + decisions.add(result.decision) + + if (result.isExpanded) { + val buildType = when (val d = result.decision) { + is VariantCompressionDecision.Expanded -> d.buildType + else -> continue + } + expandedBuildTypes.add(buildType) + } + } + + return FlavorCompressionResult( + targetsBySuffix = targetsBySuffix, + variantToSuffix = variantToSuffix, + expandedBuildTypes = expandedBuildTypes, + decisions = decisions + ) + } + + // ========================================================================= + // Build-Type Compression (across build types) + // ========================================================================= + + /** + * Checks if full cross-build-type compression is possible. + * + * Full compression requires: + * 1. Flavor compression succeeded (no expanded build types) + * 2. More than one build-type target to compress + * 3. All build-type targets are equivalent + * 4. All dependencies are fully compressed + */ + private fun canFullyCompress( + flavorCompressed: FlavorCompressionResult, + dependencyResults: Map + ): Boolean { + // 1. Flavor compression must have succeeded (no expanded build types) + if (flavorCompressed.expandedBuildTypes.isNotEmpty()) return false + + // 2. Must have more than one build-type target to compress + if (flavorCompressed.targetsBySuffix.size <= 1) return false + + // 3. All build-type targets must be equivalent + val targets = flavorCompressed.targetsBySuffix.values.toList() + val first = targets.first() + val allEquivalent = targets.drop(1).all { equivalenceChecker.areEquivalent(first, it) } + if (!allEquivalent) return false + + // 4. All dependencies must be fully compressed + val projectDeps = targets + .flatMap { it.deps } + .filterIsInstance() + .map { it.dependencyProject } + .toSet() + + return projectDeps.all { dep -> + dependencyResults[dep]?.isFullyCompressed ?: true + } + } + + /** + * Performs full cross-build-type compression. + * + * Creates a single target with no suffix by combining all build-type targets. + */ + private fun applyFullCompression(flavorCompressed: FlavorCompressionResult): CompressionResultWithDecisions { + // Pick representative (first alphabetically by suffix) + val representativeSuffix = flavorCompressed.targetsBySuffix.keys.minOf { it } + val representativeData = flavorCompressed.targetsBySuffix.getValue(representativeSuffix) + + // Update name to remove suffix + val baseName = representativeData.name.removeSuffix(representativeSuffix) + val fullyCompressedData = representativeData.copy(name = baseName) + + // All variants map to empty suffix + val newVariantToSuffix = flavorCompressed.variantToSuffix.mapValues { "" } + + val fullyCompressedDecision = VariantCompressionDecision.FullyCompressed( + buildTypes = flavorCompressed.targetsBySuffix.keys.map { it.removePrefix("-") }, + variants = flavorCompressed.variantToSuffix.keys.toList() + ) + + return CompressionResultWithDecisions( + result = VariantCompressionResult( + targetsBySuffix = mapOf("" to fullyCompressedData), + variantToSuffix = newVariantToSuffix, + expandedBuildTypes = emptySet() + ), + decisions = flavorCompressed.decisions + fullyCompressedDecision + ) + } + + // ========================================================================= + // Shared Helpers + // ========================================================================= + + /** + * Checks if compression is blocked for a build type because any direct dependency is expanded + * for that build type. + */ + private fun isCompressionBlocked( + variants: Map, + buildType: String, + dependencyResults: Map + ): Boolean { + val projectDependencies = extractProjectDependencies(variants) + return projectDependencies.any { depProject -> + dependencyResults[depProject]?.isExpanded(buildType) ?: false + } + } + + /** + * Finds the project dependencies that are blocking compression for a given build type. + */ + private fun findBlockingDependencies( + variants: Map, + buildType: String, + dependencyResults: Map + ): List { + val projectDependencies = extractProjectDependencies(variants) + return projectDependencies + .filter { dependencyResults[it]?.isExpanded(buildType) == true } + .map { it.path } + } + + /** + * Extracts all unique project dependencies from a set of variants. + */ + private fun extractProjectDependencies( + variants: Map + ): Set = variants.values + .flatMap { it.deps } + .filterIsInstance() + .map { it.dependencyProject } + .toSet() + + /** + * Checks if all variants in a list are equivalent to each other. + */ + private fun areAllVariantsEquivalent(variants: List): Boolean { + if (variants.size <= 1) return true + val first = variants.first() + return variants.drop(1).all { equivalenceChecker.areEquivalent(first, it) } + } +} diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantEquivalenceChecker.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantEquivalenceChecker.kt new file mode 100644 index 00000000..406cc7cc --- /dev/null +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantEquivalenceChecker.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2022 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.gradle.variant + +import com.grab.grazel.bazel.starlark.BazelDependency +import com.grab.grazel.migrate.android.AndroidLibraryData +import javax.inject.Inject + +/** + * Checks if two [AndroidLibraryData] instances are equivalent for variant compression. + * + * Two variants are considered equivalent if they have identical: + * - Source files + * - Resource sets + * - Manifest files + * - Package names + * - Build config + * - Resource values + * - Dependencies (after normalization) + * + * Project-level properties (databinding, compose, plugins, lint config) are excluded since they + * don't vary by variant. + */ +internal interface VariantEquivalenceChecker { + /** + * Checks if two AndroidLibraryData instances are equivalent for compression. + * + * @param first First variant data to compare + * @param second Second variant data to compare + * @return true if variants are equivalent and can be compressed together + */ + fun areEquivalent(first: AndroidLibraryData, second: AndroidLibraryData): Boolean +} + +internal class DefaultVariantEquivalenceChecker @Inject constructor( + private val dependencyNormalizer: DependencyNormalizer +) : VariantEquivalenceChecker { + + override fun areEquivalent(first: AndroidLibraryData, second: AndroidLibraryData): Boolean { + // Compare variant-specific fields + return first.srcs == second.srcs && + first.resourceSets == second.resourceSets && + first.manifestFile == second.manifestFile && + first.packageName == second.packageName && + first.customPackage == second.customPackage && + first.buildConfigData == second.buildConfigData && + first.resValuesData == second.resValuesData && + areDepsEquivalent(first.deps, second.deps) + } + + /** + * Compares two dependency lists for equivalence after normalization. + * + * Dependencies are normalized to remove variant-specific suffixes, then sorted and compared for + * equality. + */ + private fun areDepsEquivalent( + firstDeps: List, + secondDeps: List + ): Boolean { + if (firstDeps.size != secondDeps.size) { + return false + } + + val firstNormalized = firstDeps + .map { dependencyNormalizer.normalize(it) } + .sorted() + + val secondNormalized = secondDeps + .map { dependencyNormalizer.normalize(it) } + .sorted() + + return firstNormalized == secondNormalized + } +} diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantGraphKey.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantGraphKey.kt index 4ceb2887..769fcd44 100644 --- a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantGraphKey.kt +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantGraphKey.kt @@ -26,12 +26,19 @@ import org.gradle.api.Project * * @property variantId Unique ID in format "projectPath:variantName" + "VariantType" (e.g., * ":sample-android:debugAndroidBuild") + * @property variantType The type of variant this key represents (AndroidBuild, Test, etc.) */ -data class VariantGraphKey(val variantId: String) { +data class VariantGraphKey( + val variantId: String, + val variantType: VariantType +) { companion object { /** Create from a Variant instance using its unique ID. */ fun from(variant: Variant<*>): VariantGraphKey = - VariantGraphKey(variant.project.path + ":" + variant.id) + VariantGraphKey( + variantId = variant.project.path + ":" + variant.id, + variantType = variant.variantType + ) /** * Create from Project + MatchedVariant + VariantType. Uses the full variant name (e.g., @@ -43,7 +50,10 @@ data class VariantGraphKey(val variantId: String) { matchedVariant: MatchedVariant, variantType: VariantType ): VariantGraphKey = - VariantGraphKey(project.path + ":" + matchedVariant.variant.name + variantType.toString()) + VariantGraphKey( + variantId = project.path + ":" + matchedVariant.variant.name + variantType.toString(), + variantType = variantType + ) /** Create from Project + variant name + VariantType. Used during graph building. */ internal fun from( @@ -51,6 +61,9 @@ data class VariantGraphKey(val variantId: String) { variantName: String, variantType: VariantType ): VariantGraphKey = - VariantGraphKey(project.path + ":" + variantName + variantType.toString()) + VariantGraphKey( + variantId = project.path + ":" + variantName + variantType.toString(), + variantType = variantType + ) } } diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantMatcher.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantMatcher.kt index 28532b15..283e8706 100644 --- a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantMatcher.kt +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantMatcher.kt @@ -76,7 +76,16 @@ internal data class MatchedVariant( } private val HUMPS = "(?<=.)(?=\\p{Upper})".toRegex() -internal val MatchedVariant.nameSuffix get() = "-${variantName.replace(HUMPS, "-").lowercase()}" + +/** + * Converts a camelCase variant name to normalized lowercase hyphenated suffix format. Example: + * "freeDebug" -> "-free-debug" + */ +internal fun normalizeVariantSuffix(variantName: String): String { + return "-${variantName.replace(HUMPS, "-").lowercase()}" +} + +internal val MatchedVariant.nameSuffix get() = normalizeVariantSuffix(variantName) @Singleton internal class DefaultVariantMatcher @@ -219,8 +228,7 @@ constructor( // Try using matching fallbacks appBuildTypeFallbacks .getOrDefault(appBuildType.name, emptySet()) - .mapNotNull { fallbackBuildType -> libraryVariantsByBuildType[fallbackBuildType] } - .firstOrNull() + .firstNotNullOfOrNull { fallbackBuildType -> libraryVariantsByBuildType[fallbackBuildType] } ?.let { variantCandidates.addAll(it) } ?: error( "Could not match app build type '${appBuildType.name}' with " + diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantModule.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantModule.kt index 9dd23401..77926356 100644 --- a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantModule.kt +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/gradle/variant/VariantModule.kt @@ -2,6 +2,7 @@ package com.grab.grazel.gradle.variant import com.grab.grazel.GrazelExtension import com.grab.grazel.di.qualifiers.RootProject +import com.grab.grazel.util.GradleProvider import dagger.Binds import dagger.Module import dagger.Provides @@ -19,15 +20,30 @@ internal interface VariantModule { @Binds fun DefaultAndroidVariantsExtractor.bindAndroidVariantsExtractor(): AndroidVariantsExtractor + @Binds + fun DefaultVariantEquivalenceChecker.bindEquivalenceChecker(): VariantEquivalenceChecker + + @Binds + fun DefaultVariantCompressor.bindCompressor(): VariantCompressor + + @Binds + fun DefaultDependencyNormalizer.bindNormalizer(): DependencyNormalizer + companion object { @Provides @Singleton fun GrazelExtension.provideAndroidVariantDataSource( androidVariantsExtractor: DefaultAndroidVariantsExtractor, - @RootProject rootProject: Project ): AndroidVariantDataSource = DefaultAndroidVariantDataSource( variantFilterProvider = { android.variantFilter }, androidVariantsExtractor = androidVariantsExtractor ) + + @Provides + @Singleton + fun variantCompressionService( + @RootProject rootProject: Project + ): GradleProvider<@JvmSuppressWildcards DefaultVariantCompressionService> = + DefaultVariantCompressionService.register(rootProject) } } \ No newline at end of file diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/migrate/android/AndroidUnitTestDataExtractor.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/migrate/android/AndroidUnitTestDataExtractor.kt index b24d2646..59a0bb42 100644 --- a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/migrate/android/AndroidUnitTestDataExtractor.kt +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/migrate/android/AndroidUnitTestDataExtractor.kt @@ -23,13 +23,15 @@ import com.grab.grazel.gradle.dependencies.DefaultDependencyGraphsService import com.grab.grazel.gradle.dependencies.DependenciesDataSource import com.grab.grazel.gradle.dependencies.DependencyGraphs import com.grab.grazel.gradle.dependencies.GradleDependencyToBazelDependency -import com.grab.grazel.gradle.variant.VariantGraphKey -import com.grab.grazel.gradle.variant.VariantType import com.grab.grazel.gradle.hasCompose import com.grab.grazel.gradle.variant.AndroidVariantDataSource +import com.grab.grazel.gradle.variant.DefaultVariantCompressionService import com.grab.grazel.gradle.variant.MatchedVariant +import com.grab.grazel.gradle.variant.VariantGraphKey +import com.grab.grazel.gradle.variant.VariantType import com.grab.grazel.gradle.variant.getMigratableBuildVariants import com.grab.grazel.gradle.variant.nameSuffix +import com.grab.grazel.gradle.variant.resolveSuffix import com.grab.grazel.migrate.android.SourceSetType.JAVA_KOTLIN import com.grab.grazel.migrate.common.TestSizeCalculator import com.grab.grazel.migrate.common.calculateTestAssociates @@ -59,14 +61,25 @@ constructor( private val variantDataSource: AndroidVariantDataSource, private val gradleDependencyToBazelDependency: GradleDependencyToBazelDependency, private val testSizeCalculator: TestSizeCalculator, + private val variantCompressionService: GradleProvider ) : AndroidUnitTestDataExtractor { - private val projectDependencyGraphs: DependencyGraphs get() = dependencyGraphsService.get().get() + + private val projectDependencyGraphs: DependencyGraphs + get() = dependencyGraphsService.get().get() + private val kotlinExtension: KotlinExtension get() = grazelExtension.rules.kotlin override fun extract(project: Project, matchedVariant: MatchedVariant): AndroidUnitTestData { + val targetSuffix = variantCompressionService.get().resolveSuffix( + projectPath = project.path, + variantName = matchedVariant.variantName, + fallbackSuffix = matchedVariant.nameSuffix, + logger = project.logger + ) + val name = FORMAT_UNIT_TEST_NAME.format( project.name, - matchedVariant.nameSuffix + targetSuffix ) val migratableSourceSets = matchedVariant.variant.sourceSets .asSequence() @@ -85,7 +98,7 @@ constructor( val testSize = testSizeCalculator.calculate(name, rawSrcs.toSet()) val resources = project.unitTestResources(migratableSourceSets).toList() - val associate = calculateTestAssociates(project, matchedVariant.nameSuffix) + val associate = calculateTestAssociates(project, targetSuffix) val variantKey = VariantGraphKey.from(project, matchedVariant, VariantType.Test) val deps = projectDependencyGraphs @@ -102,7 +115,7 @@ constructor( project.kotlinParcelizeDeps() + BazelDependency.ProjectDependency( dependencyProject = project, - suffix = matchedVariant.nameSuffix + suffix = targetSuffix ) val tags = if (kotlinExtension.enabledTransitiveReduction) { diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/migrate/target/AndroidLibraryTargetBuilder.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/migrate/target/AndroidLibraryTargetBuilder.kt index 1d9a5f20..045aee69 100644 --- a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/migrate/target/AndroidLibraryTargetBuilder.kt +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/migrate/target/AndroidLibraryTargetBuilder.kt @@ -16,11 +16,14 @@ package com.grab.grazel.migrate.target -import com.grab.grazel.gradle.variant.VariantType import com.grab.grazel.gradle.isAndroid import com.grab.grazel.gradle.isAndroidApplication import com.grab.grazel.gradle.isAndroidTest +import com.grab.grazel.gradle.variant.DefaultVariantCompressionService import com.grab.grazel.gradle.variant.VariantMatcher +import com.grab.grazel.gradle.variant.VariantType +import com.grab.grazel.gradle.variant.nameSuffix +import com.grab.grazel.gradle.variant.resolveSuffix import com.grab.grazel.migrate.BazelTarget import com.grab.grazel.migrate.TargetBuilder import com.grab.grazel.migrate.android.AndroidLibraryData @@ -33,6 +36,7 @@ import com.grab.grazel.migrate.android.DefaultAndroidLibraryDataExtractor import com.grab.grazel.migrate.android.DefaultAndroidManifestParser import com.grab.grazel.migrate.android.DefaultAndroidUnitTestDataExtractor import com.grab.grazel.migrate.android.toUnitTestTarget +import com.grab.grazel.util.GradleProvider import dagger.Binds import dagger.Module import dagger.multibindings.IntoSet @@ -63,23 +67,54 @@ constructor( private val androidLibraryDataExtractor: AndroidLibraryDataExtractor, private val unitTestDataExtractor: AndroidUnitTestDataExtractor, private val variantMatcher: VariantMatcher, + private val variantCompressionService: GradleProvider ) : TargetBuilder { override fun build(project: Project): List { - return variantMatcher.matchedVariants(project, VariantType.AndroidBuild) - .map { matchedVariant -> - androidLibraryDataExtractor - .extract(project, matchedVariant) - .toAndroidLibTarget() - } + unitTestsTargets(project) + // Check if compression result exists for this project + val compressionResult = variantCompressionService.get().get(project.path) + val libraryTargets = // Use pre-computed compressed targets from the analysis phase + compressionResult?.targets?.map { it.toAndroidLibTarget() } + ?: run { + // Fallback to extracting again + project.logger.error("Compressed result does not exist for this project") + variantMatcher.matchedVariants(project, VariantType.AndroidBuild) + .map { matchedVariant -> + androidLibraryDataExtractor + .extract(project, matchedVariant) + .toAndroidLibTarget() + } + } + return libraryTargets + unitTestsTargets(project) } private fun unitTestsTargets(project: Project): List { - return variantMatcher.matchedVariants( - project, - VariantType.Test - ).map { matchedVariant -> - unitTestDataExtractor.extract(project, matchedVariant).toUnitTestTarget() + val compressionResult = variantCompressionService.get().get(project.path) + val testVariants = variantMatcher.matchedVariants(project, VariantType.Test) + return if (compressionResult != null) { + // Deduplicate by compression suffix: only emit one test per unique suffix + val variantsBySuffix = testVariants.groupBy { matchedVariant -> + variantCompressionService.get().resolveSuffix( + projectPath = project.path, + variantName = matchedVariant.variantName, + fallbackSuffix = matchedVariant.nameSuffix, + logger = project.logger + ) + } + + // Pick first variant alphabetically as representative for each suffix + variantsBySuffix.values.map { variantsForSuffix -> + val representative = variantsForSuffix.sortedBy { it.variantName }.first() + unitTestDataExtractor.extract(project, representative).toUnitTestTarget() + } + } else { + // Fallback: extract test for every variant + project.logger.warn( + "No compression result for ${project.path}, generating uncompressed unit test targets" + ) + testVariants.map { matchedVariant -> + unitTestDataExtractor.extract(project, matchedVariant).toUnitTestTarget() + } } } diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/tasks/internal/AnalyzeVariantCompressionTask.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/tasks/internal/AnalyzeVariantCompressionTask.kt new file mode 100644 index 00000000..0062ce16 --- /dev/null +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/tasks/internal/AnalyzeVariantCompressionTask.kt @@ -0,0 +1,241 @@ +/* + * Copyright 2022 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.tasks.internal + +import com.grab.grazel.bazel.starlark.BazelDependency.ProjectDependency +import com.grab.grazel.di.GrazelComponent +import com.grab.grazel.gradle.dependencies.DefaultDependencyGraphsService +import com.grab.grazel.gradle.dependencies.DefaultDependencyResolutionService +import com.grab.grazel.gradle.dependencies.TopologicalSorter +import com.grab.grazel.gradle.isAndroidLibrary +import com.grab.grazel.gradle.variant.DefaultVariantCompressionService +import com.grab.grazel.gradle.variant.VariantCompressionDecision.Compressed +import com.grab.grazel.gradle.variant.VariantCompressionDecision.Expanded +import com.grab.grazel.gradle.variant.VariantCompressionDecision.FullyCompressed +import com.grab.grazel.gradle.variant.VariantCompressionDecision.SingleVariant +import com.grab.grazel.gradle.variant.VariantCompressionResult +import com.grab.grazel.gradle.variant.VariantCompressor +import com.grab.grazel.gradle.variant.VariantMatcher +import com.grab.grazel.gradle.variant.VariantType +import com.grab.grazel.migrate.android.AndroidLibraryDataExtractor +import com.grab.grazel.util.GradleProvider +import com.grab.grazel.util.Json +import dagger.Lazy +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.TaskProvider +import org.gradle.api.tasks.UntrackedTask +import org.gradle.kotlin.dsl.property +import org.gradle.kotlin.dsl.register +import java.time.Instant +import javax.inject.Inject + +/** + * Task that analyzes Android library projects and computes variant compression results. + * + * This task: + * 1. Processes projects in topological order + * 2. For each Android library project, extracts variant data + * 3. Applies compression logic + * 4. Registers compression results in the [DefaultVariantCompressionService] + * 5. Writes a JSON summary to the output file + */ +@UntrackedTask(because = "Up to dateness not implemented correctly") +internal open class AnalyzeVariantCompressionTask +@Inject +constructor( + private val androidLibraryDataExtractor: Lazy, + private val variantMatcher: Lazy, + private val variantCompressor: Lazy, + private val dependencyGraphsService: GradleProvider, + private val variantCompressionService: GradleProvider +) : DefaultTask() { + + @get:InputFile + val workspaceDependencies: RegularFileProperty = project.objects.fileProperty() + + @get:OutputFile + val compressionResultsFile: RegularFileProperty = project.objects.fileProperty() + + @get:Internal + val dependencyResolutionService: Property = + project.objects.property() + + @TaskAction + fun action() { + dependencyResolutionService + .get() + .init(workspaceDependencies.get().asFile) + + val graphs = dependencyGraphsService.get().get() + val orderedProjects = TopologicalSorter.sort(graphs) + + val projectSummaries = mutableListOf() + + orderedProjects.forEach { project -> + if (project.isAndroidLibrary) { + try { + val compressionResult = analyzeProject(project) + variantCompressionService.get().register(project.path, compressionResult) + projectSummaries.add( + ProjectSummary( + path = project.path, + variantCount = compressionResult.variantToSuffix.size, + suffixes = compressionResult.suffixes.toList() + ) + ) + + logger.info( + "Analyzed ${project.path}: ${compressionResult.suffixes.size} targets " + + "from ${compressionResult.variantToSuffix.size} variants" + ) + } catch (e: Exception) { + logger.warn("Failed to analyze ${project.path}: ${e.message}") + } + } + } + + val summary = AnalysisSummary( + analysisTimestamp = Instant.now().toString(), + projectCount = projectSummaries.size, + projects = projectSummaries + ) + + compressionResultsFile.get().asFile.writeText(Json.encodeToString(summary)) + + logger.info("Variant compression analysis complete. Results written to ${compressionResultsFile.get().asFile}") + } + + /** Analyzes a single Android library project and returns a compression result. */ + private fun analyzeProject(project: Project): VariantCompressionResult { + val variants = variantMatcher.get().matchedVariants(project, VariantType.AndroidBuild) + + // Extract AndroidLibraryData for each variant + val variantData = variants.associate { matchedVariant -> + matchedVariant.variantName to + androidLibraryDataExtractor.get().extract(project, matchedVariant) + } + + // Collect compression results from dependencies + val dependencyVariantCompressionResults = mutableMapOf() + val service = variantCompressionService.get() + + // Get all direct project dependencies + val projectDependencies = variantData.values + .flatMap { it.deps } + .filterIsInstance() + .map { it.dependencyProject } + .toSet() + + // Collect their compression results + projectDependencies.forEach { depProject -> + service.get(depProject.path)?.let { result -> + dependencyVariantCompressionResults[depProject] = result + } + } + + // Extract build type from variant name + // Assumes variant name follows pattern like "freeDebug", "paidRelease" + // Build type is the last component when split by capital letters + fun buildTypeFn(variantName: String): String { + // Find the matched variant to get the actual build type + val matchedVariant = variants.find { it.variantName == variantName } + return matchedVariant?.buildType ?: "debug" + } + + // Compress variants using the compressor + val resultWithDecisions = variantCompressor.get().compress( + variants = variantData, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = dependencyVariantCompressionResults + ) + + // Log detailed decision for each build type group + resultWithDecisions.decisions.forEach { decision -> + when (decision) { + is Compressed -> logger.info( + "${project.path} compressed ${decision.variants} → [${decision.compressedSuffix}]" + ) + + is Expanded -> logger.info( + "${project.path} kept expanded ${decision.variants} (${decision.reason})" + ) + + is SingleVariant -> logger.info( + "${project.path} single variant ${decision.variant} → [${decision.suffix}]" + ) + + is FullyCompressed -> logger.info( + "${project.path} fully compressed [${decision.buildTypes.joinToString()}] → single target" + ) + } + } + return resultWithDecisions.result + } + + companion object { + private const val TASK_NAME = "analyzeVariantCompression" + + internal fun register( + rootProject: Project, + grazelComponent: GrazelComponent, + configureAction: AnalyzeVariantCompressionTask.() -> Unit = {} + ): TaskProvider { + return rootProject.tasks.register( + TASK_NAME, + grazelComponent.androidLibraryDataExtractor(), + grazelComponent.variantMatcher(), + grazelComponent.variantCompressor(), + grazelComponent.dependencyGraphsService(), + grazelComponent.variantCompressionService() + ).apply { + configure { + group = GRAZEL_TASK_GROUP + description = + "Analyze variant compression opportunities across all Android library projects" + compressionResultsFile.set( + rootProject.layout.buildDirectory.file("grazel/compression-results.json") + ) + dependencyResolutionService.set(grazelComponent.dependencyResolutionService()) + configureAction(this) + } + } + } + } +} + +@Serializable +private data class AnalysisSummary( + val analysisTimestamp: String, + val projectCount: Int, + val projects: List +) + +@Serializable +private data class ProjectSummary( + val path: String, + val variantCount: Int, + val suffixes: List +) diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/tasks/internal/GenerateBazelScriptsTask.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/tasks/internal/GenerateBazelScriptsTask.kt index e309902a..52bf61d4 100644 --- a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/tasks/internal/GenerateBazelScriptsTask.kt +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/tasks/internal/GenerateBazelScriptsTask.kt @@ -35,6 +35,7 @@ import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider @@ -58,6 +59,10 @@ constructor( @get:InputFile val workspaceDependencies: RegularFileProperty = project.objects.fileProperty() + @get:InputFile + @get:Optional + val variantCompressionResults: RegularFileProperty = project.objects.fileProperty() + @get:Internal val dependencyResolutionService: Property = project.objects.property() diff --git a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/tasks/internal/TasksManager.kt b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/tasks/internal/TasksManager.kt index d496ed2a..57dbb067 100644 --- a/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/tasks/internal/TasksManager.kt +++ b/grazel-gradle-plugin/src/main/kotlin/com/grab/grazel/tasks/internal/TasksManager.kt @@ -59,6 +59,15 @@ constructor( configurationDataSource = grazelComponent.configurationDataSource(), androidVariantDataSource = grazelComponent.androidVariantDataSource() ) + + // Analyze variant compression opportunities in topological order + val analyzeVariantCompressionTask = AnalyzeVariantCompressionTask.register( + rootProject = rootProject, + grazelComponent = grazelComponent + ) { + workspaceDependencies.set(computeWorkspaceDependenciesTask.flatMap { it.workspaceDependencies }) + } + // Root bazel file generation task that should run at the start of migration val rootGenerateBazelScriptsTasks = GenerateRootBazelScriptsTask.register( rootProject, @@ -66,6 +75,7 @@ constructor( ) { workspaceDependencies.set(computeWorkspaceDependenciesTask.flatMap { it.workspaceDependencies }) dependencyResolutionService.set(grazelComponent.dependencyResolutionService()) + dependsOn(analyzeVariantCompressionTask) } val dataBindingMetaDataTask = AndroidDatabindingMetaDataTask.register( @@ -119,6 +129,7 @@ constructor( ) { dependencyResolutionService.set(grazelComponent.dependencyResolutionService()) workspaceDependencies.set(computeWorkspaceDependenciesTask.flatMap { it.workspaceDependencies }) + variantCompressionResults.set(analyzeVariantCompressionTask.flatMap { it.compressionResultsFile }) } // Post script generate task must run after project level tasks are generated diff --git a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/fake/FakeDependencyGraphs.kt b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/fake/FakeDependencyGraphs.kt index 3f1e4d17..a097d99e 100644 --- a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/fake/FakeDependencyGraphs.kt +++ b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/fake/FakeDependencyGraphs.kt @@ -19,6 +19,7 @@ package com.grab.grazel.fake import com.google.common.graph.ImmutableValueGraph import com.grab.grazel.gradle.dependencies.DependencyGraphs import com.grab.grazel.gradle.variant.VariantGraphKey +import com.grab.grazel.gradle.variant.VariantType import org.gradle.api.Project import org.gradle.api.artifacts.Configuration @@ -26,7 +27,8 @@ internal class FakeDependencyGraphs( private val directDeps: Set = emptySet(), private val dependenciesSubGraph: Set = emptySet(), private val nodes: Set = emptySet(), - override val variantGraphs: Map> = emptyMap() + override val variantGraphs: Map> = emptyMap(), + private val projectGraph: Map> = emptyMap() ) : DependencyGraphs { override fun nodesByVariant(vararg variantKey: VariantGraphKey): Set = nodes @@ -40,4 +42,8 @@ internal class FakeDependencyGraphs( project: Project, variantKey: VariantGraphKey ): Set = directDeps + + override fun mergeToProjectGraph( + variantTypeFilter: (VariantType) -> Boolean + ): Map> = projectGraph } diff --git a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/fake/FakeVariantCompressionService.kt b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/fake/FakeVariantCompressionService.kt new file mode 100644 index 00000000..573d17f9 --- /dev/null +++ b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/fake/FakeVariantCompressionService.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2022 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.fake + +import com.grab.grazel.gradle.variant.VariantCompressionResult +import com.grab.grazel.gradle.variant.DefaultVariantCompressionService +import com.grab.grazel.gradle.variant.VariantCompressionService + +/** + * A test fake for [VariantCompressionService] that stores pre-configured compression results. + * + * Usage: + * ```kotlin + * val fakeService = FakeVariantCompressionService() + * fakeService.register(":app", compressionResult) + * val provider = project.provider { fakeService } + * ``` + */ +internal class FakeVariantCompressionService( + private val results: MutableMap = mutableMapOf() +) : DefaultVariantCompressionService() { + + override fun register(projectPath: String, result: VariantCompressionResult) { + results[projectPath] = result + } + + override fun get(projectPath: String): VariantCompressionResult? { + return results[projectPath] + } + + override fun isRegistered(projectPath: String): Boolean { + return projectPath in results + } + + override fun close() { + results.clear() + } + + override fun getParameters(): VariantCompressionService.Params { + throw UnsupportedOperationException("Not needed for tests") + } +} diff --git a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/dependencies/DefaultDependencyGraphsTest.kt b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/dependencies/DefaultDependencyGraphsTest.kt index 71675d95..cdb0a191 100644 --- a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/dependencies/DefaultDependencyGraphsTest.kt +++ b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/dependencies/DefaultDependencyGraphsTest.kt @@ -21,6 +21,7 @@ import com.google.common.graph.ValueGraphBuilder import com.grab.grazel.fake.FakeConfiguration import com.grab.grazel.fake.FakeProject import com.grab.grazel.gradle.variant.VariantGraphKey +import com.grab.grazel.gradle.variant.VariantType import org.gradle.api.Project import org.gradle.api.artifacts.Configuration import org.junit.Test @@ -36,8 +37,8 @@ class DefaultDependencyGraphsTest { private val dependenciesGraphs = DefaultDependencyGraphs( variantGraphs = mapOf( - VariantGraphKey(":A:debugAndroidBuild") to buildBuildGraphs(), - VariantGraphKey(":A:debugUnitTestTest") to buildTestGraphs() + VariantGraphKey(":A:debugAndroidBuild", VariantType.AndroidBuild) to buildBuildGraphs(), + VariantGraphKey(":A:debugUnitTestTest", VariantType.Test) to buildTestGraphs() ) ) @@ -55,7 +56,7 @@ class DefaultDependencyGraphsTest { val buildNodes = setOf(projectA, projectB, projectC) assertEquals( buildNodes, - dependenciesGraphs.nodesByVariant(VariantGraphKey(":A:debugAndroidBuild")) + dependenciesGraphs.nodesByVariant(VariantGraphKey(":A:debugAndroidBuild", VariantType.AndroidBuild)) ) } @@ -64,7 +65,7 @@ class DefaultDependencyGraphsTest { val testNodes = setOf(projectA, projectB, projectC, projectD, projectE) assertEquals( testNodes, - dependenciesGraphs.nodesByVariant(VariantGraphKey(":A:debugUnitTestTest")) + dependenciesGraphs.nodesByVariant(VariantGraphKey(":A:debugUnitTestTest", VariantType.Test)) ) } @@ -75,8 +76,8 @@ class DefaultDependencyGraphsTest { assertEquals( testNodes + buildNodes, dependenciesGraphs.nodesByVariant( - VariantGraphKey(":A:debugAndroidBuild"), - VariantGraphKey(":A:debugUnitTestTest") + VariantGraphKey(":A:debugAndroidBuild", VariantType.AndroidBuild), + VariantGraphKey(":A:debugUnitTestTest", VariantType.Test) ) ) } @@ -88,7 +89,7 @@ class DefaultDependencyGraphsTest { directDepsFromAWithBuildScope, dependenciesGraphs.directDependenciesByVariant( projectA, - VariantGraphKey(":A:debugAndroidBuild") + VariantGraphKey(":A:debugAndroidBuild", VariantType.AndroidBuild) ) ) } @@ -100,7 +101,7 @@ class DefaultDependencyGraphsTest { expectDeps, dependenciesGraphs.dependenciesSubGraphByVariant( projectB, - VariantGraphKey(":A:debugAndroidBuild") + VariantGraphKey(":A:debugAndroidBuild", VariantType.AndroidBuild) ) ) } @@ -112,7 +113,7 @@ class DefaultDependencyGraphsTest { expectDeps, dependenciesGraphs.dependenciesSubGraphByVariant( projectB, - VariantGraphKey(":A:debugUnitTestTest") + VariantGraphKey(":A:debugUnitTestTest", VariantType.Test) ) ) } @@ -133,8 +134,8 @@ class DefaultDependencyGraphsTest { expectDeps, dependenciesGraphs.dependenciesSubGraphByVariant( projectB, - VariantGraphKey(":A:debugAndroidBuild"), - VariantGraphKey(":A:debugUnitTestTest") + VariantGraphKey(":A:debugAndroidBuild", VariantType.AndroidBuild), + VariantGraphKey(":A:debugUnitTestTest", VariantType.Test) ) ) } @@ -161,5 +162,124 @@ class DefaultDependencyGraphsTest { putEdgeValue(projectB, projectE, FakeConfiguration()) putEdgeValue(projectA, projectE, FakeConfiguration()) }.run { ImmutableValueGraph.copyOf(this) } + + @Test + fun `mergeToProjectGraph should filter test graphs by default`() { + // Create scenario where test graph would create artificial cycle + val projectX = FakeProject("X") + val projectY = FakeProject("Y") + + // Build graph: X -> Y + val buildGraph = ValueGraphBuilder.directed() + .allowsSelfLoops(false) + .build().apply { + putEdgeValue(projectX, projectY, FakeConfiguration()) + }.run { ImmutableValueGraph.copyOf(this) } + + // Test graph: Y -> X (would create cycle if merged) + val testGraph = ValueGraphBuilder.directed() + .allowsSelfLoops(false) + .build().apply { + putEdgeValue(projectY, projectX, FakeConfiguration()) + }.run { ImmutableValueGraph.copyOf(this) } + + val graphs = DefaultDependencyGraphs( + variantGraphs = mapOf( + VariantGraphKey(":X:debugAndroidBuild", VariantType.AndroidBuild) to buildGraph, + VariantGraphKey(":Y:debugUnitTestTest", VariantType.Test) to testGraph + ) + ) + + // Default filter excludes Test graphs + val merged = graphs.mergeToProjectGraph() + + // Should only see X -> Y from build graph, not Y -> X from test graph + assertEquals(setOf(projectY), merged[projectX]) + assertEquals(emptySet(), merged[projectY]) + } + + @Test + fun `mergeToProjectGraph should include all graphs when filter allows all`() { + val projectX = FakeProject("X") + val projectY = FakeProject("Y") + + val buildGraph = ValueGraphBuilder.directed() + .allowsSelfLoops(false) + .build().apply { + putEdgeValue(projectX, projectY, FakeConfiguration()) + }.run { ImmutableValueGraph.copyOf(this) } + + val testGraph = ValueGraphBuilder.directed() + .allowsSelfLoops(false) + .build().apply { + putEdgeValue(projectY, projectX, FakeConfiguration()) + }.run { ImmutableValueGraph.copyOf(this) } + + val graphs = DefaultDependencyGraphs( + variantGraphs = mapOf( + VariantGraphKey(":X:debugAndroidBuild", VariantType.AndroidBuild) to buildGraph, + VariantGraphKey(":Y:debugUnitTestTest", VariantType.Test) to testGraph + ) + ) + + // Allow all variant types + val merged = graphs.mergeToProjectGraph { true } + + // Should see both X -> Y and Y -> X + assertEquals(setOf(projectY), merged[projectX]) + assertEquals(setOf(projectX), merged[projectY]) + } + + @Test + fun `mergeToProjectGraph should filter only build graphs correctly`() { + val projectX = FakeProject("X") + val projectY = FakeProject("Y") + val projectZ = FakeProject("Z") + + // AndroidBuild graph: X -> Y + val androidBuildGraph = ValueGraphBuilder.directed() + .allowsSelfLoops(false) + .build().apply { + putEdgeValue(projectX, projectY, FakeConfiguration()) + }.run { ImmutableValueGraph.copyOf(this) } + + // JvmBuild graph: Y -> Z + val jvmBuildGraph = ValueGraphBuilder.directed() + .allowsSelfLoops(false) + .build().apply { + putEdgeValue(projectY, projectZ, FakeConfiguration()) + }.run { ImmutableValueGraph.copyOf(this) } + + // Test graph: Z -> X (would create cycle) + val testGraph = ValueGraphBuilder.directed() + .allowsSelfLoops(false) + .build().apply { + putEdgeValue(projectZ, projectX, FakeConfiguration()) + }.run { ImmutableValueGraph.copyOf(this) } + + // AndroidTest graph: also excluded + val androidTestGraph = ValueGraphBuilder.directed() + .allowsSelfLoops(false) + .build().apply { + putEdgeValue(projectZ, projectY, FakeConfiguration()) + }.run { ImmutableValueGraph.copyOf(this) } + + val graphs = DefaultDependencyGraphs( + variantGraphs = mapOf( + VariantGraphKey(":X:debugAndroidBuild", VariantType.AndroidBuild) to androidBuildGraph, + VariantGraphKey(":Y:defaultJvmBuild", VariantType.JvmBuild) to jvmBuildGraph, + VariantGraphKey(":Z:debugUnitTestTest", VariantType.Test) to testGraph, + VariantGraphKey(":Z:debugAndroidTest", VariantType.AndroidTest) to androidTestGraph + ) + ) + + // Default filter includes only build graphs (AndroidBuild, JvmBuild) + val merged = graphs.mergeToProjectGraph() + + // Should only see build graph edges, not test edges + assertEquals(setOf(projectY), merged[projectX]) + assertEquals(setOf(projectZ), merged[projectY]) + assertEquals(emptySet(), merged[projectZ]) // No test edges included + } } diff --git a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/dependencies/JvmProjectGraphKeyTest.kt b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/dependencies/JvmProjectGraphKeyTest.kt index e4b78d46..ec97da57 100644 --- a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/dependencies/JvmProjectGraphKeyTest.kt +++ b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/dependencies/JvmProjectGraphKeyTest.kt @@ -114,7 +114,7 @@ class JvmProjectGraphKeyTest { // and properly prefixed keys (what the factory produces) // Given: A hardcoded key (like current KotlinProjectDataExtractor) - val hardcodedKey = VariantGraphKey("defaultJvmBuild") + val hardcodedKey = VariantGraphKey("defaultJvmBuild", VariantType.JvmBuild) // And: A properly prefixed key from the factory val properKey = VariantGraphKey.from(kotlinLibA, "default", VariantType.JvmBuild) diff --git a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/dependencies/TopologicalSorterTest.kt b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/dependencies/TopologicalSorterTest.kt new file mode 100644 index 00000000..01a9d8f8 --- /dev/null +++ b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/dependencies/TopologicalSorterTest.kt @@ -0,0 +1,362 @@ +/* + * Copyright 2022 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.gradle.dependencies + +import com.grab.grazel.fake.FakeDependencyGraphs +import com.grab.grazel.fake.FakeProject +import org.gradle.api.Project +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class TopologicalSorterTest { + + @Test + fun `sort should return empty list for empty graph`() { + val graphs = FakeDependencyGraphs(projectGraph = emptyMap()) + val result = TopologicalSorter.sort(graphs) + assertEquals(emptyList(), result) + } + + @Test + fun `sort should return single project for single node graph`() { + val projectA = FakeProject("A") + val graphs = FakeDependencyGraphs( + projectGraph = mapOf(projectA to emptySet()) + ) + val result = TopologicalSorter.sort(graphs) + assertEquals(listOf(projectA), result) + } + + @Test + fun `sort should process linear chain in correct order`() { + // Graph: :app -> :lib -> :core + // Expected order: :core, :lib, :app (dependencies before dependents) + val projectCore = FakeProject("core") + val projectLib = FakeProject("lib") + val projectApp = FakeProject("app") + + val graphs = FakeDependencyGraphs( + projectGraph = mapOf( + projectCore to emptySet(), + projectLib to setOf(projectCore), + projectApp to setOf(projectLib) + ) + ) + + val result = TopologicalSorter.sort(graphs) + + assertEquals(listOf(projectCore, projectLib, projectApp), result) + } + + @Test + fun `sort should handle diamond pattern correctly`() { + // Graph: :app -> :lib1 -> :core + // -> :lib2 -> :core + // Expected: :core before both libs, both libs before :app + val projectCore = FakeProject("core") + val projectLib1 = FakeProject("lib1") + val projectLib2 = FakeProject("lib2") + val projectApp = FakeProject("app") + + val graphs = FakeDependencyGraphs( + projectGraph = mapOf( + projectCore to emptySet(), + projectLib1 to setOf(projectCore), + projectLib2 to setOf(projectCore), + projectApp to setOf(projectLib1, projectLib2) + ) + ) + + val result = TopologicalSorter.sort(graphs) + + // Core must be first + assertEquals(projectCore, result[0]) + + // Both libs must come before app + val lib1Index = result.indexOf(projectLib1) + val lib2Index = result.indexOf(projectLib2) + val appIndex = result.indexOf(projectApp) + + assertTrue(lib1Index < appIndex, "lib1 should come before app") + assertTrue(lib2Index < appIndex, "lib2 should come before app") + + // App must be last + assertEquals(projectApp, result.last()) + } + + @Test + fun `sort should detect cycles and throw exception`() { + // Graph with cycle: :a -> :b -> :c -> :a + val projectA = FakeProject("a") + val projectB = FakeProject("b") + val projectC = FakeProject("c") + + val graphs = FakeDependencyGraphs( + projectGraph = mapOf( + projectA to setOf(projectB), + projectB to setOf(projectC), + projectC to setOf(projectA) + ) + ) + + val exception = assertFailsWith { + TopologicalSorter.sort(graphs) + } + + assertTrue(exception.message!!.contains("Cycle detected")) + assertTrue(exception.message!!.contains("Cycle path:")) + assertTrue(exception.message!!.contains(" -> ")) + assertTrue(exception.message!!.contains(":a") || exception.message!!.contains("a")) + } + + @Test + fun `sort should handle complex graph with multiple roots`() { + // Graph: :core1 (root) + // :core2 (root) + // :lib1 -> :core1 + // :lib2 -> :core1, :core2 + // :app -> :lib1, :lib2 + val projectCore1 = FakeProject("core1") + val projectCore2 = FakeProject("core2") + val projectLib1 = FakeProject("lib1") + val projectLib2 = FakeProject("lib2") + val projectApp = FakeProject("app") + + val graphs = FakeDependencyGraphs( + projectGraph = mapOf( + projectCore1 to emptySet(), + projectCore2 to emptySet(), + projectLib1 to setOf(projectCore1), + projectLib2 to setOf(projectCore1, projectCore2), + projectApp to setOf(projectLib1, projectLib2) + ) + ) + + val result = TopologicalSorter.sort(graphs) + + // Verify dependency ordering + val core1Index = result.indexOf(projectCore1) + val core2Index = result.indexOf(projectCore2) + val lib1Index = result.indexOf(projectLib1) + val lib2Index = result.indexOf(projectLib2) + val appIndex = result.indexOf(projectApp) + + // Cores before libs + assertTrue(core1Index < lib1Index, "core1 should come before lib1") + assertTrue(core1Index < lib2Index, "core1 should come before lib2") + assertTrue(core2Index < lib2Index, "core2 should come before lib2") + + // Libs before app + assertTrue(lib1Index < appIndex, "lib1 should come before app") + assertTrue(lib2Index < appIndex, "lib2 should come before app") + } + + @Test + fun `sort should handle disconnected components`() { + // Two independent chains: + // :app1 -> :lib1 + // :app2 -> :lib2 + val projectLib1 = FakeProject("lib1") + val projectApp1 = FakeProject("app1") + val projectLib2 = FakeProject("lib2") + val projectApp2 = FakeProject("app2") + + val graphs = FakeDependencyGraphs( + projectGraph = mapOf( + projectLib1 to emptySet(), + projectApp1 to setOf(projectLib1), + projectLib2 to emptySet(), + projectApp2 to setOf(projectLib2) + ) + ) + + val result = TopologicalSorter.sort(graphs) + + // Verify each chain maintains order + val lib1Index = result.indexOf(projectLib1) + val app1Index = result.indexOf(projectApp1) + val lib2Index = result.indexOf(projectLib2) + val app2Index = result.indexOf(projectApp2) + + assertTrue(lib1Index < app1Index, "lib1 should come before app1") + assertTrue(lib2Index < app2Index, "lib2 should come before app2") + assertEquals(4, result.size) + } + + @Test + fun `sort should handle projects with shared dependencies`() { + // Graph: :app1 -> :shared + // :app2 -> :shared + // :shared (leaf) + val projectShared = FakeProject("shared") + val projectApp1 = FakeProject("app1") + val projectApp2 = FakeProject("app2") + + val graphs = FakeDependencyGraphs( + projectGraph = mapOf( + projectShared to emptySet(), + projectApp1 to setOf(projectShared), + projectApp2 to setOf(projectShared) + ) + ) + + val result = TopologicalSorter.sort(graphs) + + // Shared must come first + assertEquals(projectShared, result[0]) + + // Both apps must come after shared + val sharedIndex = result.indexOf(projectShared) + val app1Index = result.indexOf(projectApp1) + val app2Index = result.indexOf(projectApp2) + + assertTrue(sharedIndex < app1Index, "shared should come before app1") + assertTrue(sharedIndex < app2Index, "shared should come before app2") + } + + @Test + fun `sort should detect self-loop cycle`() { + // Graph with self-loop: :a -> :a + val projectA = FakeProject("a") + + val graphs = FakeDependencyGraphs( + projectGraph = mapOf( + projectA to setOf(projectA) + ) + ) + + val exception = assertFailsWith { + TopologicalSorter.sort(graphs) + } + + assertTrue(exception.message!!.contains("Cycle detected")) + assertTrue(exception.message!!.contains("Cycle path:")) + // Self-loop should show :a -> :a + val message = exception.message!! + val cyclePathMatch = Regex(":a -> :a").find(message) + assertTrue(cyclePathMatch != null, "Self-loop should show :a -> :a in cycle path") + } + + @Test + fun `sort should detect cycle with blocked dependents`() { + // Graph: :a -> :b -> :c -> :a (cycle) + // :d -> :a (dependent blocked by cycle) + val projectA = FakeProject("a") + val projectB = FakeProject("b") + val projectC = FakeProject("c") + val projectD = FakeProject("d") + + val graphs = FakeDependencyGraphs( + projectGraph = mapOf( + projectA to setOf(projectB), + projectB to setOf(projectC), + projectC to setOf(projectA), + projectD to setOf(projectA) + ) + ) + + val exception = assertFailsWith { + TopologicalSorter.sort(graphs) + } + + assertTrue(exception.message!!.contains("Cycle detected")) + assertTrue(exception.message!!.contains("Cycle path:")) + // The message should mention unprocessed projects + assertTrue(exception.message!!.contains("Unprocessed projects")) + } + + @Test + fun `sort should detect nested cycle with back-edge`() { + // Graph: :a -> :b -> :c -> :d -> :b (back-edge to :b, not :a) + val projectA = FakeProject("a") + val projectB = FakeProject("b") + val projectC = FakeProject("c") + val projectD = FakeProject("d") + + val graphs = FakeDependencyGraphs( + projectGraph = mapOf( + projectA to setOf(projectB), + projectB to setOf(projectC), + projectC to setOf(projectD), + projectD to setOf(projectB) + ) + ) + + val exception = assertFailsWith { + TopologicalSorter.sort(graphs) + } + + assertTrue(exception.message!!.contains("Cycle detected")) + assertTrue(exception.message!!.contains("Cycle path:")) + // Should contain :b in the cycle + assertTrue(exception.message!!.contains(":b")) + } + + @Test + fun `sort should detect first cycle in graph with multiple disconnected cycles`() { + // Two independent cycles: + // Cycle 1: :a -> :b -> :a + // Cycle 2: :c -> :d -> :c + val projectA = FakeProject("a") + val projectB = FakeProject("b") + val projectC = FakeProject("c") + val projectD = FakeProject("d") + + val graphs = FakeDependencyGraphs( + projectGraph = mapOf( + projectA to setOf(projectB), + projectB to setOf(projectA), + projectC to setOf(projectD), + projectD to setOf(projectC) + ) + ) + + val exception = assertFailsWith { + TopologicalSorter.sort(graphs) + } + + assertTrue(exception.message!!.contains("Cycle detected")) + assertTrue(exception.message!!.contains("Cycle path:")) + // Should detect at least one cycle (deterministically the first alphabetically) + assertTrue(exception.message!!.contains(":a") || exception.message!!.contains(":c")) + } + + @Test + fun `sort should not detect cycle when test depends on build dependency`() { + // Scenario: A depends on B (build), B test-depends on A (test) + // This is NOT a real cycle - test deps are in separate graph + // The merged project graph with default filtering should only include build deps + val projectA = FakeProject("a") + val projectB = FakeProject("b") + + // When mergeToProjectGraph() is called with default filter, + // it will only include build graphs: A -> B + // The test graph (B -> A) is filtered out + val graphs = FakeDependencyGraphs( + projectGraph = mapOf( + projectA to setOf(projectB), // A depends on B (build only) + projectB to emptySet() // B has no build deps (test dep filtered out) + ) + ) + + // Should succeed without cycle detection + val result = TopologicalSorter.sort(graphs) + assertEquals(listOf(projectB, projectA), result) + } +} diff --git a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/DependencyNormalizerTest.kt b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/DependencyNormalizerTest.kt new file mode 100644 index 00000000..9c1a59ba --- /dev/null +++ b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/DependencyNormalizerTest.kt @@ -0,0 +1,232 @@ +/* + * Copyright 2022 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.gradle.variant + +import com.grab.grazel.GrazelPluginTest +import com.grab.grazel.bazel.starlark.BazelDependency +import com.grab.grazel.buildProject +import org.gradle.api.Project +import org.gradle.kotlin.dsl.create +import org.junit.Before +import org.junit.Test +import java.io.File +import kotlin.test.assertEquals + +class DependencyNormalizerTest : GrazelPluginTest() { + + private lateinit var normalizer: DependencyNormalizer + private lateinit var project: Project + + @Before + fun setup() { + normalizer = DefaultDependencyNormalizer() + project = buildProject("root") + } + + @Test + fun `normalize ProjectDependency with variant suffix`() { + val subProject = buildProject("library", parent = project) + val dependency = BazelDependency.ProjectDependency( + dependencyProject = subProject, + suffix = "-free-debug" + ) + + val result = normalizer.normalize(dependency) + + assertEquals("//library:library", result) + } + + @Test + fun `normalize ProjectDependency with _kt suffix`() { + val subProject = buildProject("library", parent = project) + val dependency = BazelDependency.ProjectDependency( + dependencyProject = subProject, + suffix = "_kt-debug" + ) + + val result = normalizer.normalize(dependency) + + assertEquals("//library:library", result) + } + + @Test + fun `normalize ProjectDependency with _lib suffix`() { + val subProject = buildProject("library", parent = project) + val dependency = BazelDependency.ProjectDependency( + dependencyProject = subProject, + suffix = "_lib-paid-release" + ) + + val result = normalizer.normalize(dependency) + + assertEquals("//library:library", result) + } + + @Test + fun `normalize ProjectDependency with prefix`() { + val subProject = buildProject("library", parent = project) + val dependency = BazelDependency.ProjectDependency( + dependencyProject = subProject, + prefix = "android_", + suffix = "-debug" + ) + + val result = normalizer.normalize(dependency) + + // Prefix should also be removed in normalization + assertEquals("//library:library", result) + } + + @Test + fun `normalize ProjectDependency without suffix`() { + val subProject = buildProject("library", parent = project) + val dependency = BazelDependency.ProjectDependency( + dependencyProject = subProject, + suffix = "" + ) + + val result = normalizer.normalize(dependency) + + assertEquals("//library:library", result) + } + + @Test + fun `normalize nested ProjectDependency`() { + // Create a nested project structure + val parentProject = buildProject("parent", parent = project) + val nestedProject = buildProject("nested", parent = parentProject) + + val dependency = BazelDependency.ProjectDependency( + dependencyProject = nestedProject, + suffix = "-free-debug" + ) + + val result = normalizer.normalize(dependency) + + assertEquals("//parent/nested:nested", result) + } + + @Test + fun `normalize MavenDependency`() { + val dependency = BazelDependency.MavenDependency( + repo = "maven", + group = "com.google.guava", + name = "guava" + ) + + val result = normalizer.normalize(dependency) + + assertEquals("@maven//:com_google_guava_guava", result) + } + + @Test + fun `normalize MavenDependency with dashes`() { + val dependency = BazelDependency.MavenDependency( + repo = "maven", + group = "androidx.core", + name = "core-ktx" + ) + + val result = normalizer.normalize(dependency) + + assertEquals("@maven//:androidx_core_core_ktx", result) + } + + @Test + fun `normalize MavenDependency with custom repo`() { + val dependency = BazelDependency.MavenDependency( + repo = "internal", + group = "com.grab", + name = "library" + ) + + val result = normalizer.normalize(dependency) + + assertEquals("@internal//:com_grab_library", result) + } + + @Test + fun `normalize StringDependency`() { + val dependency = BazelDependency.StringDependency("//some:target") + + val result = normalizer.normalize(dependency) + + assertEquals("//some:target", result) + } + + @Test + fun `normalize StringDependency with whitespace`() { + val dependency = BazelDependency.StringDependency(" //some:target ") + + val result = normalizer.normalize(dependency) + + assertEquals("//some:target", result) + } + + @Test + fun `normalize FileDependency in root`() { + val file = File(project.projectDir, "libs.jar") + val dependency = BazelDependency.FileDependency( + file = file, + rootProject = project + ) + + val result = normalizer.normalize(dependency) + + assertEquals("//:libs.jar", result) + } + + @Test + fun `normalize FileDependency in subdirectory`() { + val libsDir = File(project.projectDir, "libs") + val file = File(libsDir.absolutePath, "custom.jar") + val dependency = BazelDependency.FileDependency( + file = file, + rootProject = project + ) + + val result = normalizer.normalize(dependency) + + assertEquals("//libs:custom.jar", result) + } + + @Test + fun `normalize different variants produce same result`() { + val subProject = buildProject("library", parent = project) + + val freeDebug = BazelDependency.ProjectDependency( + dependencyProject = subProject, + suffix = "-free-debug" + ) + val paidDebug = BazelDependency.ProjectDependency( + dependencyProject = subProject, + suffix = "-paid-debug" + ) + val release = BazelDependency.ProjectDependency( + dependencyProject = subProject, + suffix = "-release" + ) + + val result1 = normalizer.normalize(freeDebug) + val result2 = normalizer.normalize(paidDebug) + val result3 = normalizer.normalize(release) + + assertEquals(result1, result2) + assertEquals(result2, result3) + assertEquals("//library:library", result1) + } +} diff --git a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantCompressionResultTest.kt b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantCompressionResultTest.kt new file mode 100644 index 00000000..e8e88682 --- /dev/null +++ b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantCompressionResultTest.kt @@ -0,0 +1,204 @@ +/* + * Copyright 2022 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.gradle.variant + +import com.google.common.truth.Truth.assertThat +import com.grab.grazel.migrate.android.AndroidLibraryData +import com.grab.grazel.migrate.android.LintConfigData +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class VariantCompressionResultTest { + + private fun createTestData(name: String) = AndroidLibraryData( + name = name, + customPackage = "com.example", + packageName = "com.example", + lintConfigData = LintConfigData() + ) + + @Test + fun `empty compression result has no targets or mappings`() { + val result = VariantCompressionResult.empty() + + assertThat(result.suffixes).isEmpty() + assertThat(result.targets).isEmpty() + assertThat(result.variantToSuffix).isEmpty() + assertThat(result.expandedBuildTypes).isEmpty() + } + + @Test + fun `compressed result maps multiple variants to single suffix`() { + val debugData = createTestData("lib-Debug") + val releaseData = createTestData("lib-Release") + + val result = VariantCompressionResult( + targetsBySuffix = mapOf( + "Debug" to debugData, + "Release" to releaseData + ), + variantToSuffix = mapOf( + "internalDebug" to "Debug", + "prodDebug" to "Debug", + "internalRelease" to "Release", + "prodRelease" to "Release" + ), + expandedBuildTypes = emptySet() + ) + + // Verify suffixes + assertThat(result.suffixes).containsExactly("Debug", "Release") + assertEquals(2, result.targets.size) + + // Verify variant mappings + assertEquals("Debug", result.suffixForVariant("internalDebug")) + assertEquals("Debug", result.suffixForVariant("prodDebug")) + assertEquals("Release", result.suffixForVariant("internalRelease")) + assertEquals("Release", result.suffixForVariant("prodRelease")) + + // Verify data retrieval by suffix + assertEquals(debugData, result.dataForSuffix("Debug")) + assertEquals(releaseData, result.dataForSuffix("Release")) + + // Verify data retrieval by variant + assertEquals(debugData, result.dataForVariant("internalDebug")) + assertEquals(releaseData, result.dataForVariant("prodRelease")) + } + + @Test + fun `expanded build types are tracked correctly`() { + val debugData = createTestData("lib-Debug") + + val result = VariantCompressionResult( + targetsBySuffix = mapOf("Debug" to debugData), + variantToSuffix = mapOf("debug" to "Debug"), + expandedBuildTypes = setOf("release", "staging") + ) + + assertTrue(result.isExpanded("release")) + assertTrue(result.isExpanded("staging")) + assertFalse(result.isExpanded("debug")) + assertFalse(result.isExpanded("nonexistent")) + } + + @Test + fun `validation fails when variant maps to non-existent suffix`() { + val exception = assertFailsWith { + VariantCompressionResult( + targetsBySuffix = mapOf("Debug" to createTestData("lib-Debug")), + variantToSuffix = mapOf( + "debug" to "Debug", + "release" to "Release" // Release suffix doesn't exist + ), + expandedBuildTypes = emptySet() + ) + } + + assertThat(exception.message).contains("non-existent suffixes") + assertThat(exception.message).contains("Release") + } + + @Test + fun `dataForSuffix throws when suffix does not exist`() { + val result = VariantCompressionResult( + targetsBySuffix = mapOf("Debug" to createTestData("lib-Debug")), + variantToSuffix = emptyMap(), + expandedBuildTypes = emptySet() + ) + + val exception = assertFailsWith { + result.dataForSuffix("Release") + } + + assertThat(exception.message).contains("No target data found for suffix: Release") + } + + @Test + fun `suffixForVariant throws when variant does not exist`() { + val result = VariantCompressionResult( + targetsBySuffix = mapOf("Debug" to createTestData("lib-Debug")), + variantToSuffix = mapOf("debug" to "Debug"), + expandedBuildTypes = emptySet() + ) + + val exception = assertFailsWith { + result.suffixForVariant("release") + } + + assertThat(exception.message).contains("No suffix mapping found for variant: release") + } + + @Test + fun `dataForVariant throws when variant does not exist`() { + val result = VariantCompressionResult( + targetsBySuffix = mapOf("Debug" to createTestData("lib-Debug")), + variantToSuffix = mapOf("debug" to "Debug"), + expandedBuildTypes = emptySet() + ) + + val exception = assertFailsWith { + result.dataForVariant("release") + } + + assertThat(exception.message).contains("No suffix mapping found for variant: release") + } + + @Test + fun `targets property returns all data objects`() { + val debugData = createTestData("lib-Debug") + val releaseData = createTestData("lib-Release") + val stagingData = createTestData("lib-Staging") + + val result = VariantCompressionResult( + targetsBySuffix = mapOf( + "Debug" to debugData, + "Release" to releaseData, + "Staging" to stagingData + ), + variantToSuffix = emptyMap(), + expandedBuildTypes = emptySet() + ) + + assertThat(result.targets).containsExactly(debugData, releaseData, stagingData) + } + + @Test + fun `one-to-one mapping when no compression occurs`() { + val internalDebugData = createTestData("lib-InternalDebug") + val prodReleaseData = createTestData("lib-ProdRelease") + + val result = VariantCompressionResult( + targetsBySuffix = mapOf( + "InternalDebug" to internalDebugData, + "ProdRelease" to prodReleaseData + ), + variantToSuffix = mapOf( + "internalDebug" to "InternalDebug", + "prodRelease" to "ProdRelease" + ), + expandedBuildTypes = emptySet() + ) + + assertEquals(2, result.suffixes.size) + assertEquals(2, result.targets.size) + assertEquals(internalDebugData, result.dataForVariant("internalDebug")) + assertEquals(prodReleaseData, result.dataForVariant("prodRelease")) + } +} diff --git a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantCompressorTest.kt b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantCompressorTest.kt new file mode 100644 index 00000000..1e426484 --- /dev/null +++ b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantCompressorTest.kt @@ -0,0 +1,514 @@ +/* + * Copyright 2022 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.gradle.variant + +import com.grab.grazel.GrazelPluginTest +import com.grab.grazel.bazel.starlark.BazelDependency +import com.grab.grazel.buildProject +import com.grab.grazel.migrate.android.AndroidLibraryData +import com.grab.grazel.migrate.android.BuildConfigData +import com.grab.grazel.migrate.android.LintConfigData +import org.gradle.api.Project +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class VariantCompressorTest : GrazelPluginTest() { + + private lateinit var compressor: VariantCompressor + private lateinit var normalizer: DependencyNormalizer + private lateinit var checker: VariantEquivalenceChecker + private lateinit var project: Project + + @Before + fun setup() { + normalizer = DefaultDependencyNormalizer() + checker = DefaultVariantEquivalenceChecker(normalizer) + compressor = DefaultVariantCompressor(checker) + project = buildProject("root") + } + + private fun createData(name: String, variantName: String = name): AndroidLibraryData { + return AndroidLibraryData( + name = name, + srcs = listOf("src/main/kotlin/**/*.kt"), + customPackage = "com.example", + packageName = "com.example.app", + lintConfigData = LintConfigData() + ) + } + + private fun buildTypeFn(variantName: String): String { + // Example: "freeDebug" -> "debug", "paidRelease" -> "release" + return when { + variantName.endsWith("Debug", ignoreCase = true) -> "debug" + variantName.endsWith("Release", ignoreCase = true) -> "release" + else -> "debug" + } + } + + @Test + fun `compress empty variants returns empty result`() { + val resultWithDecisions = compressor.compress( + variants = emptyMap(), + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = emptyMap() + ) + + assertTrue(resultWithDecisions.result.suffixes.isEmpty()) + assertTrue(resultWithDecisions.result.targets.isEmpty()) + assertTrue(resultWithDecisions.result.expandedBuildTypes.isEmpty()) + } + + @Test + fun `compress single variant keeps it expanded`() { + val variants = mapOf( + "freeDebug" to createData("library-free-debug", "freeDebug") + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = emptyMap() + ) + + assertEquals(1, resultWithDecisions.result.suffixes.size) + assertEquals(1, resultWithDecisions.result.targets.size) + // Single variants should NOT poison dependents - don't mark as expanded + assertFalse(resultWithDecisions.result.expandedBuildTypes.contains("debug")) + assertEquals("-free-debug", resultWithDecisions.result.suffixForVariant("freeDebug")) + } + + @Test + fun `compress equivalent variants compresses them`() { + val variants = mapOf( + "freeDebug" to createData("library-free-debug", "freeDebug"), + "paidDebug" to createData("library-paid-debug", "paidDebug") + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = emptyMap() + ) + + // Should compress to single "-debug" target + assertEquals(1, resultWithDecisions.result.suffixes.size) + assertEquals(1, resultWithDecisions.result.targets.size) + assertTrue(resultWithDecisions.result.suffixes.contains("-debug")) + assertFalse(resultWithDecisions.result.expandedBuildTypes.contains("debug")) + + // Both variants should map to compressed suffix + assertEquals("-debug", resultWithDecisions.result.suffixForVariant("freeDebug")) + assertEquals("-debug", resultWithDecisions.result.suffixForVariant("paidDebug")) + } + + @Test + fun `compress different variants keeps them expanded`() { + val variants = mapOf( + "freeDebug" to createData("library-free-debug", "freeDebug").copy( + buildConfigData = BuildConfigData(strings = mapOf("FLAVOR" to "\"free\"")) + ), + "paidDebug" to createData("library-paid-debug", "paidDebug").copy( + buildConfigData = BuildConfigData(strings = mapOf("FLAVOR" to "\"paid\"")) + ) + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = emptyMap() + ) + + // Should keep expanded + assertEquals(2, resultWithDecisions.result.suffixes.size) + assertEquals(2, resultWithDecisions.result.targets.size) + assertTrue(resultWithDecisions.result.expandedBuildTypes.contains("debug")) + + // Each variant gets its own normalized suffix + assertEquals("-free-debug", resultWithDecisions.result.suffixForVariant("freeDebug")) + assertEquals("-paid-debug", resultWithDecisions.result.suffixForVariant("paidDebug")) + } + + @Test + fun `compress multiple build types independently then fully compress if equivalent`() { + val variants = mapOf( + "freeDebug" to createData("library-free-debug", "freeDebug"), + "paidDebug" to createData("library-paid-debug", "paidDebug"), + "freeRelease" to createData("library-free-release", "freeRelease"), + "paidRelease" to createData("library-paid-release", "paidRelease") + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = emptyMap() + ) + + // Phase 1 compresses within build types, Phase 2 fully compresses across build types + // Since all variants are equivalent, result should be single target with no suffix + assertEquals(1, resultWithDecisions.result.suffixes.size) + assertEquals(1, resultWithDecisions.result.targets.size) + assertTrue(resultWithDecisions.result.suffixes.contains("")) + assertTrue(resultWithDecisions.result.expandedBuildTypes.isEmpty()) + assertTrue(resultWithDecisions.result.isFullyCompressed) + + // All variants map to empty suffix + assertEquals("", resultWithDecisions.result.suffixForVariant("freeDebug")) + assertEquals("", resultWithDecisions.result.suffixForVariant("paidDebug")) + assertEquals("", resultWithDecisions.result.suffixForVariant("freeRelease")) + assertEquals("", resultWithDecisions.result.suffixForVariant("paidRelease")) + } + + @Test + fun `compress blocked by expanded dependency keeps variants expanded`() { + val depProject = buildProject("dependency", parent = project) + + val variants = mapOf( + "freeDebug" to createData("library-free-debug", "freeDebug").copy( + deps = listOf( + BazelDependency.ProjectDependency(depProject, suffix = "-free-debug") + ) + ), + "paidDebug" to createData("library-paid-debug", "paidDebug").copy( + deps = listOf( + BazelDependency.ProjectDependency(depProject, suffix = "-paid-debug") + ) + ) + ) + + // Dependency is expanded for "debug" build type + val depVariantCompressionResult = VariantCompressionResult( + targetsBySuffix = mapOf( + "-free-debug" to createData("dependency-free-debug"), + "-paid-debug" to createData("dependency-paid-debug") + ), + variantToSuffix = mapOf( + "freeDebug" to "-free-debug", + "paidDebug" to "-paid-debug" + ), + expandedBuildTypes = setOf("debug") + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = mapOf(depProject to depVariantCompressionResult) + ) + + // Should be blocked from compressing + assertEquals(2, resultWithDecisions.result.suffixes.size) + assertTrue(resultWithDecisions.result.expandedBuildTypes.contains("debug")) + assertEquals("-free-debug", resultWithDecisions.result.suffixForVariant("freeDebug")) + assertEquals("-paid-debug", resultWithDecisions.result.suffixForVariant("paidDebug")) + } + + @Test + fun `compress mixed scenario - some compressed some expanded`() { + val depProject = buildProject("dependency", parent = project) + + val variants = mapOf( + // Debug variants are equivalent and not blocked + "freeDebug" to createData("library-free-debug", "freeDebug"), + "paidDebug" to createData("library-paid-debug", "paidDebug"), + // Release variants differ in config + "freeRelease" to createData("library-free-release", "freeRelease").copy( + buildConfigData = BuildConfigData(strings = mapOf("FLAVOR" to "\"free\"")) + ), + "paidRelease" to createData("library-paid-release", "paidRelease").copy( + buildConfigData = BuildConfigData(strings = mapOf("FLAVOR" to "\"paid\"")) + ) + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = emptyMap() + ) + + // Debug should compress, Release should expand + assertEquals(3, resultWithDecisions.result.suffixes.size) + assertFalse(resultWithDecisions.result.expandedBuildTypes.contains("debug")) + assertTrue(resultWithDecisions.result.expandedBuildTypes.contains("release")) + + // Debug variants compressed + assertEquals("-debug", resultWithDecisions.result.suffixForVariant("freeDebug")) + assertEquals("-debug", resultWithDecisions.result.suffixForVariant("paidDebug")) + + // Release variants expanded + assertEquals("-free-release", resultWithDecisions.result.suffixForVariant("freeRelease")) + assertEquals("-paid-release", resultWithDecisions.result.suffixForVariant("paidRelease")) + } + + @Test + fun `compress picks first variant alphabetically as representative`() { + val variants = mapOf( + "zzzDebug" to createData("library-zzz-debug", "zzzDebug").copy( + srcs = listOf("src/main/kotlin/**/*.kt") + ), + "aaaDebug" to createData("library-aaa-debug", "aaaDebug").copy( + srcs = listOf("src/main/kotlin/**/*.kt") + ), + "mmmDebug" to createData("library-mmm-debug", "mmmDebug").copy( + srcs = listOf("src/main/kotlin/**/*.kt") + ) + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = emptyMap() + ) + + // Should compress to single target + assertEquals(1, resultWithDecisions.result.suffixes.size) + val compressedData = resultWithDecisions.result.dataForSuffix("-debug") + + // Representative should be based on "aaaDebug" (first alphabetically) + // Name should be updated to reflect compressed suffix + assertTrue(compressedData.name.endsWith("-debug")) + } + + @Test + fun `compress with no dependencies`() { + val variants = mapOf( + "freeDebug" to createData("library-free-debug", "freeDebug").copy( + deps = emptyList() + ), + "paidDebug" to createData("library-paid-debug", "paidDebug").copy( + deps = emptyList() + ) + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = emptyMap() + ) + + // Should compress successfully + assertEquals(1, resultWithDecisions.result.suffixes.size) + assertEquals("-debug", resultWithDecisions.result.suffixForVariant("freeDebug")) + assertEquals("-debug", resultWithDecisions.result.suffixForVariant("paidDebug")) + } + + @Test + fun `compress with maven dependencies only`() { + val mavenDep = BazelDependency.MavenDependency( + repo = "maven", + group = "com.google.guava", + name = "guava" + ) + + val variants = mapOf( + "freeDebug" to createData("library-free-debug", "freeDebug").copy( + deps = listOf(mavenDep) + ), + "paidDebug" to createData("library-paid-debug", "paidDebug").copy( + deps = listOf(mavenDep) + ) + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = emptyMap() + ) + + // Maven dependencies don't block compression + assertEquals(1, resultWithDecisions.result.suffixes.size) + assertEquals("-debug", resultWithDecisions.result.suffixForVariant("freeDebug")) + assertEquals("-debug", resultWithDecisions.result.suffixForVariant("paidDebug")) + } + + @Test + fun `full compression blocked when build types differ`() { + val variants = mapOf( + "freeDebug" to createData("library-free-debug", "freeDebug").copy( + buildConfigData = BuildConfigData(strings = mapOf("BUILD_TYPE" to "\"debug\"")) + ), + "paidDebug" to createData("library-paid-debug", "paidDebug").copy( + buildConfigData = BuildConfigData(strings = mapOf("BUILD_TYPE" to "\"debug\"")) + ), + "freeRelease" to createData("library-free-release", "freeRelease").copy( + buildConfigData = BuildConfigData(strings = mapOf("BUILD_TYPE" to "\"release\"")) + ), + "paidRelease" to createData("library-paid-release", "paidRelease").copy( + buildConfigData = BuildConfigData(strings = mapOf("BUILD_TYPE" to "\"release\"")) + ) + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = emptyMap() + ) + + // Phase 1 compresses within build types, but Phase 2 is blocked because + // debug and release targets differ in buildConfigData + assertEquals(2, resultWithDecisions.result.suffixes.size) + assertEquals(2, resultWithDecisions.result.targets.size) + assertTrue(resultWithDecisions.result.suffixes.containsAll(listOf("-debug", "-release"))) + assertTrue(resultWithDecisions.result.expandedBuildTypes.isEmpty()) + assertFalse(resultWithDecisions.result.isFullyCompressed) + + assertEquals("-debug", resultWithDecisions.result.suffixForVariant("freeDebug")) + assertEquals("-debug", resultWithDecisions.result.suffixForVariant("paidDebug")) + assertEquals("-release", resultWithDecisions.result.suffixForVariant("freeRelease")) + assertEquals("-release", resultWithDecisions.result.suffixForVariant("paidRelease")) + } + + @Test + fun `full compression blocked when dependency not fully compressed`() { + val depProject = buildProject("dependency", parent = project) + + val variants = mapOf( + "freeDebug" to createData("library-free-debug", "freeDebug").copy( + deps = listOf( + BazelDependency.ProjectDependency(depProject, suffix = "-debug") + ) + ), + "paidDebug" to createData("library-paid-debug", "paidDebug").copy( + deps = listOf( + BazelDependency.ProjectDependency(depProject, suffix = "-debug") + ) + ), + "freeRelease" to createData("library-free-release", "freeRelease").copy( + deps = listOf( + BazelDependency.ProjectDependency(depProject, suffix = "-release") + ) + ), + "paidRelease" to createData("library-paid-release", "paidRelease").copy( + deps = listOf( + BazelDependency.ProjectDependency(depProject, suffix = "-release") + ) + ) + ) + + // Dependency is NOT fully compressed (has two build type targets) + val depVariantCompressionResult = VariantCompressionResult( + targetsBySuffix = mapOf( + "-debug" to createData("dependency-debug"), + "-release" to createData("dependency-release") + ), + variantToSuffix = mapOf( + "freeDebug" to "-debug", + "paidDebug" to "-debug", + "freeRelease" to "-release", + "paidRelease" to "-release" + ), + expandedBuildTypes = emptySet() + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = mapOf(depProject to depVariantCompressionResult) + ) + + // Phase 1 compresses within build types, but Phase 2 is blocked because + // dependency is not fully compressed + assertEquals(2, resultWithDecisions.result.suffixes.size) + assertFalse(resultWithDecisions.result.isFullyCompressed) + assertEquals("-debug", resultWithDecisions.result.suffixForVariant("freeDebug")) + assertEquals("-release", resultWithDecisions.result.suffixForVariant("freeRelease")) + } + + @Test + fun `full compression emits FullyCompressed decision`() { + val variants = mapOf( + "freeDebug" to createData("library-free-debug", "freeDebug"), + "paidDebug" to createData("library-paid-debug", "paidDebug"), + "freeRelease" to createData("library-free-release", "freeRelease"), + "paidRelease" to createData("library-paid-release", "paidRelease") + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = emptyMap() + ) + + // Should have FullyCompressed decision + val fullyCompressedDecision = resultWithDecisions.decisions + .filterIsInstance() + .firstOrNull() + + assertTrue(fullyCompressedDecision != null) + assertTrue(fullyCompressedDecision!!.buildTypes.containsAll(listOf("debug", "release"))) + assertEquals(4, fullyCompressedDecision.variants.size) + } + + @Test + fun `full compression blocked when Phase 1 has expanded build types`() { + val variants = mapOf( + // Debug variants differ, should expand + "freeDebug" to createData("library-free-debug", "freeDebug").copy( + buildConfigData = BuildConfigData(strings = mapOf("FLAVOR" to "\"free\"")) + ), + "paidDebug" to createData("library-paid-debug", "paidDebug").copy( + buildConfigData = BuildConfigData(strings = mapOf("FLAVOR" to "\"paid\"")) + ), + // Release variants are equivalent + "freeRelease" to createData("library-free-release", "freeRelease"), + "paidRelease" to createData("library-paid-release", "paidRelease") + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = emptyMap() + ) + + // Phase 1 expands debug, compresses release + // Phase 2 cannot run because expandedBuildTypes is not empty + assertTrue(resultWithDecisions.result.expandedBuildTypes.contains("debug")) + assertFalse(resultWithDecisions.result.isFullyCompressed) + + // Debug expanded + assertEquals("-free-debug", resultWithDecisions.result.suffixForVariant("freeDebug")) + assertEquals("-paid-debug", resultWithDecisions.result.suffixForVariant("paidDebug")) + + // Release compressed to build type + assertEquals("-release", resultWithDecisions.result.suffixForVariant("freeRelease")) + assertEquals("-release", resultWithDecisions.result.suffixForVariant("paidRelease")) + } + + @Test + fun `single build type with multiple variants fully compresses`() { + val variants = mapOf( + "freeDebug" to createData("library-free-debug", "freeDebug"), + "paidDebug" to createData("library-paid-debug", "paidDebug") + ) + + val resultWithDecisions = compressor.compress( + variants = variants, + buildTypeFn = ::buildTypeFn, + dependencyVariantCompressionResults = emptyMap() + ) + + // Only one build type, Phase 1 compresses to -debug, Phase 2 cannot run + // (only 1 build-type target, need > 1 to fully compress) + assertEquals(1, resultWithDecisions.result.suffixes.size) + assertEquals("-debug", resultWithDecisions.result.suffixForVariant("freeDebug")) + assertEquals("-debug", resultWithDecisions.result.suffixForVariant("paidDebug")) + // isFullyCompressed is false because suffix is not empty + assertFalse(resultWithDecisions.result.isFullyCompressed) + } +} diff --git a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantEquivalenceCheckerTest.kt b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantEquivalenceCheckerTest.kt new file mode 100644 index 00000000..10436073 --- /dev/null +++ b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantEquivalenceCheckerTest.kt @@ -0,0 +1,361 @@ +/* + * Copyright 2022 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.gradle.variant + +import com.grab.grazel.GrazelPluginTest +import com.grab.grazel.bazel.starlark.BazelDependency +import com.grab.grazel.buildProject +import com.grab.grazel.migrate.android.AndroidLibraryData +import com.grab.grazel.migrate.android.BazelSourceSet +import com.grab.grazel.migrate.android.BuildConfigData +import com.grab.grazel.migrate.android.LintConfigData +import com.grab.grazel.migrate.android.ResValuesData +import org.gradle.api.Project +import org.gradle.kotlin.dsl.create +import org.junit.Before +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class VariantEquivalenceCheckerTest : GrazelPluginTest() { + + private lateinit var checker: VariantEquivalenceChecker + private lateinit var normalizer: DependencyNormalizer + private lateinit var project: Project + + @Before + fun setup() { + normalizer = DefaultDependencyNormalizer() + checker = DefaultVariantEquivalenceChecker(normalizer) + project = buildProject("root") + } + + private fun createBasicData(name: String): AndroidLibraryData { + return AndroidLibraryData( + name = name, + srcs = listOf("src/main/kotlin/**/*.kt"), + resourceSets = emptySet(), + resValuesData = ResValuesData(), + manifestFile = "src/main/AndroidManifest.xml", + customPackage = "com.example", + packageName = "com.example.app", + buildConfigData = BuildConfigData(), + deps = emptyList(), + plugins = emptyList(), + databinding = false, + compose = false, + tags = emptyList(), + lintConfigData = LintConfigData() + ) + } + + @Test + fun `areEquivalent returns true for identical variants`() { + val first = createBasicData("variant1") + val second = createBasicData("variant2") + + assertTrue(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent returns false when srcs differ`() { + val first = createBasicData("variant1").copy( + srcs = listOf("src/main/kotlin/**/*.kt") + ) + val second = createBasicData("variant2").copy( + srcs = listOf("src/main/java/**/*.java") + ) + + assertFalse(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent returns false when resourceSets differ`() { + val first = createBasicData("variant1").copy( + resourceSets = setOf(BazelSourceSet("main", "res", null, null)) + ) + val second = createBasicData("variant2").copy( + resourceSets = setOf(BazelSourceSet("main", "res", "assets", null)) + ) + + assertFalse(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent returns false when manifestFile differs`() { + val first = createBasicData("variant1").copy( + manifestFile = "src/main/AndroidManifest.xml" + ) + val second = createBasicData("variant2").copy( + manifestFile = "src/debug/AndroidManifest.xml" + ) + + assertFalse(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent returns false when packageName differs`() { + val first = createBasicData("variant1").copy( + packageName = "com.example.app" + ) + val second = createBasicData("variant2").copy( + packageName = "com.example.debug" + ) + + assertFalse(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent returns false when customPackage differs`() { + val first = createBasicData("variant1").copy( + customPackage = "com.example" + ) + val second = createBasicData("variant2").copy( + customPackage = "com.example.debug" + ) + + assertFalse(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent returns false when buildConfigData differs`() { + val first = createBasicData("variant1").copy( + buildConfigData = BuildConfigData( + strings = mapOf("API_URL" to "\"https://prod.example.com\"") + ) + ) + val second = createBasicData("variant2").copy( + buildConfigData = BuildConfigData( + strings = mapOf("API_URL" to "\"https://debug.example.com\"") + ) + ) + + assertFalse(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent returns false when resValuesData differs`() { + val first = createBasicData("variant1").copy( + resValuesData = ResValuesData( + stringValues = mapOf("app_name" to "App") + ) + ) + val second = createBasicData("variant2").copy( + resValuesData = ResValuesData( + stringValues = mapOf("app_name" to "App Debug") + ) + ) + + assertFalse(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent ignores databinding differences`() { + val first = createBasicData("variant1").copy(databinding = true) + val second = createBasicData("variant2").copy(databinding = false) + + assertTrue(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent ignores compose differences`() { + val first = createBasicData("variant1").copy(compose = true) + val second = createBasicData("variant2").copy(compose = false) + + assertTrue(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent ignores plugins differences`() { + val first = createBasicData("variant1").copy( + plugins = listOf(BazelDependency.StringDependency("//tools:kotlin")) + ) + val second = createBasicData("variant2").copy( + plugins = emptyList() + ) + + assertTrue(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent ignores tags differences`() { + val first = createBasicData("variant1").copy( + tags = listOf("manual") + ) + val second = createBasicData("variant2").copy( + tags = listOf("manual", "local") + ) + + assertTrue(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent ignores lintConfigData differences`() { + val first = createBasicData("variant1").copy( + lintConfigData = LintConfigData(enabled = true) + ) + val second = createBasicData("variant2").copy( + lintConfigData = LintConfigData(enabled = false) + ) + + assertTrue(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent returns true when deps are equivalent after normalization`() { + val dependency = buildProject("library", parent = project) + + val first = createBasicData("variant1").copy( + deps = listOf( + BazelDependency.ProjectDependency(dependency, suffix = "-free-debug") + ) + ) + val second = createBasicData("variant2").copy( + deps = listOf( + BazelDependency.ProjectDependency(dependency, suffix = "-paid-debug") + ) + ) + + assertTrue(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent returns false when deps point to different projects`() { + val lib1 = buildProject("library1", parent = project) + val lib2 = buildProject("library2", parent = project) + + val first = createBasicData("variant1").copy( + deps = listOf( + BazelDependency.ProjectDependency(lib1, suffix = "-debug") + ) + ) + val second = createBasicData("variant2").copy( + deps = listOf( + BazelDependency.ProjectDependency(lib2, suffix = "-debug") + ) + ) + + assertFalse(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent returns false when deps count differs`() { + val dependency = buildProject("library", parent = project) + + val first = createBasicData("variant1").copy( + deps = listOf( + BazelDependency.ProjectDependency(dependency, suffix = "-debug") + ) + ) + val second = createBasicData("variant2").copy( + deps = emptyList() + ) + + assertFalse(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent returns true when deps are in different order but normalize to same`() { + val lib1 = buildProject("library1", parent = project) + val lib2 = buildProject("library2", parent = project) + + val first = createBasicData("variant1").copy( + deps = listOf( + BazelDependency.ProjectDependency(lib1, suffix = "-debug"), + BazelDependency.ProjectDependency(lib2, suffix = "-debug") + ) + ) + val second = createBasicData("variant2").copy( + deps = listOf( + BazelDependency.ProjectDependency(lib2, suffix = "-debug"), + BazelDependency.ProjectDependency(lib1, suffix = "-debug") + ) + ) + + assertTrue(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent handles maven dependencies`() { + val first = createBasicData("variant1").copy( + deps = listOf( + BazelDependency.MavenDependency( + repo = "maven", + group = "com.google.guava", + name = "guava" + ) + ) + ) + val second = createBasicData("variant2").copy( + deps = listOf( + BazelDependency.MavenDependency( + repo = "maven", + group = "com.google.guava", + name = "guava" + ) + ) + ) + + assertTrue(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent returns false for different maven dependencies`() { + val first = createBasicData("variant1").copy( + deps = listOf( + BazelDependency.MavenDependency( + repo = "maven", + group = "com.google.guava", + name = "guava" + ) + ) + ) + val second = createBasicData("variant2").copy( + deps = listOf( + BazelDependency.MavenDependency( + repo = "maven", + group = "androidx.core", + name = "core-ktx" + ) + ) + ) + + assertFalse(checker.areEquivalent(first, second)) + } + + @Test + fun `areEquivalent handles mixed dependency types`() { + val lib = buildProject("library", parent = project) + + val first = createBasicData("variant1").copy( + deps = listOf( + BazelDependency.ProjectDependency(lib, suffix = "-free-debug"), + BazelDependency.MavenDependency("maven", "com.google.guava", "guava"), + BazelDependency.StringDependency("//external:dep") + ) + ) + val second = createBasicData("variant2").copy( + deps = listOf( + BazelDependency.ProjectDependency(lib, suffix = "-paid-debug"), + BazelDependency.MavenDependency("maven", "com.google.guava", "guava"), + BazelDependency.StringDependency("//external:dep") + ) + ) + + assertTrue(checker.areEquivalent(first, second)) + } +} diff --git a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantGraphKeyTest.kt b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantGraphKeyTest.kt new file mode 100644 index 00000000..0db89d0f --- /dev/null +++ b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/gradle/variant/VariantGraphKeyTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2026 Grabtaxi Holdings PTE LTD (GRAB) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.grab.grazel.gradle.variant + +import com.grab.grazel.fake.FakeProject +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class VariantGraphKeyTest { + + @Test + fun `from Project and variantName should create correct key`() { + val project = FakeProject("app") + val key = VariantGraphKey.from(project, "debug", VariantType.AndroidBuild) + + assertEquals(":app:debugAndroidBuild", key.variantId) + assertEquals(VariantType.AndroidBuild, key.variantType) + } + + @Test + fun `variantType field should be accessible directly`() { + val key = VariantGraphKey(":app:debugAndroidBuild", VariantType.AndroidBuild) + assertEquals(VariantType.AndroidBuild, key.variantType) + } + + @Test + fun `keys with same variantId but different variantType should not be equal`() { + val key1 = VariantGraphKey(":app:debug", VariantType.AndroidBuild) + val key2 = VariantGraphKey(":app:debug", VariantType.Test) + + assertNotEquals(key1, key2) + } + + @Test + fun `keys with different variantId but same variantType should not be equal`() { + val key1 = VariantGraphKey(":app:debug", VariantType.AndroidBuild) + val key2 = VariantGraphKey(":app:release", VariantType.AndroidBuild) + + assertNotEquals(key1, key2) + } + + @Test + fun `keys with same variantId and variantType should be equal`() { + val key1 = VariantGraphKey(":app:debug", VariantType.AndroidBuild) + val key2 = VariantGraphKey(":app:debug", VariantType.AndroidBuild) + + assertEquals(key1, key2) + } + + @Test + fun `isBuildGraph should return true for AndroidBuild`() { + val key = VariantGraphKey(":app:debug", VariantType.AndroidBuild) + assert(key.variantType.isBuildGraph) + } + + @Test + fun `isBuildGraph should return true for JvmBuild`() { + val key = VariantGraphKey(":lib:default", VariantType.JvmBuild) + assert(key.variantType.isBuildGraph) + } + + @Test + fun `isBuildGraph should return false for Test`() { + val key = VariantGraphKey(":lib:test", VariantType.Test) + assert(!key.variantType.isBuildGraph) + } + + @Test + fun `isBuildGraph should return false for AndroidTest`() { + val key = VariantGraphKey(":app:debugAndroidTest", VariantType.AndroidTest) + assert(!key.variantType.isBuildGraph) + } + + @Test + fun `from Project and variantName should create correct key for AndroidBuild`() { + val project = FakeProject("sample") + val key = VariantGraphKey.from(project, "debug", VariantType.AndroidBuild) + + assertEquals(":sample:debugAndroidBuild", key.variantId) + assertEquals(VariantType.AndroidBuild, key.variantType) + } + + @Test + fun `from Project and variantName should create correct key for Test`() { + val project = FakeProject("sample") + val key = VariantGraphKey.from(project, "debugUnitTest", VariantType.Test) + + assertEquals(":sample:debugUnitTestTest", key.variantId) + assertEquals(VariantType.Test, key.variantType) + } + + @Test + fun `from Project and variantName should create correct key for AndroidTest`() { + val project = FakeProject("sample") + val key = VariantGraphKey.from(project, "debugAndroidTest", VariantType.AndroidTest) + + assertEquals(":sample:debugAndroidTestAndroidTest", key.variantId) + assertEquals(VariantType.AndroidTest, key.variantType) + } + + @Test + fun `from Project and variantName should create correct key for JvmBuild`() { + val project = FakeProject("lib") + val key = VariantGraphKey.from(project, "default", VariantType.JvmBuild) + + assertEquals(":lib:defaultJvmBuild", key.variantId) + assertEquals(VariantType.JvmBuild, key.variantType) + } + + @Test + fun `from Project and variantName should preserve variant name exactly`() { + val project = FakeProject("app") + val key = VariantGraphKey.from(project, "freeDebug", VariantType.AndroidBuild) + + assertEquals(":app:freeDebugAndroidBuild", key.variantId) + assertEquals(VariantType.AndroidBuild, key.variantType) + } + + @Test + fun `data class copy should work correctly`() { + val original = VariantGraphKey(":app:debug", VariantType.AndroidBuild) + val copy = original.copy(variantId = ":app:release") + + assertEquals(":app:release", copy.variantId) + assertEquals(VariantType.AndroidBuild, copy.variantType) + assertNotEquals(original, copy) + } + + @Test + fun `data class hashCode should depend on both fields`() { + val key1 = VariantGraphKey(":app:debug", VariantType.AndroidBuild) + val key2 = VariantGraphKey(":app:debug", VariantType.AndroidBuild) + val key3 = VariantGraphKey(":app:debug", VariantType.Test) + + assertEquals(key1.hashCode(), key2.hashCode()) + assertNotEquals(key1.hashCode(), key3.hashCode()) + } + + @Test + fun `variantType field should be non-nullable and always present`() { + val key = VariantGraphKey(":app:debug", VariantType.AndroidBuild) + // This test primarily documents the fact that variantType is non-nullable + // If we could access null, this would fail at compile time + val type: VariantType = key.variantType // No null check needed + assertEquals(VariantType.AndroidBuild, type) + } +} diff --git a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/migrate/android/DefaultAndroidUnitTestDataExtractorTest.kt b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/migrate/android/DefaultAndroidUnitTestDataExtractorTest.kt index b7c03ee6..e66e9877 100644 --- a/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/migrate/android/DefaultAndroidUnitTestDataExtractorTest.kt +++ b/grazel-gradle-plugin/src/test/kotlin/com/grab/grazel/migrate/android/DefaultAndroidUnitTestDataExtractorTest.kt @@ -23,6 +23,7 @@ import com.grab.grazel.GrazelPluginTest import com.grab.grazel.buildProject import com.grab.grazel.fake.FakeDependencyGraphs import com.grab.grazel.fake.FakeDependencyGraphsService +import com.grab.grazel.fake.FakeVariantCompressionService import com.grab.grazel.gradle.ANDROID_LIBRARY_PLUGIN import com.grab.grazel.gradle.DefaultConfigurationDataSource import com.grab.grazel.gradle.KOTLIN_ANDROID_PLUGIN @@ -35,6 +36,7 @@ import com.grab.grazel.gradle.variant.AndroidVariantsExtractor import com.grab.grazel.gradle.variant.DefaultAndroidVariantDataSource import com.grab.grazel.gradle.variant.DefaultAndroidVariantsExtractor import com.grab.grazel.gradle.variant.DefaultVariantBuilder +import com.grab.grazel.gradle.variant.DefaultVariantCompressionService import com.grab.grazel.gradle.variant.MatchedVariant import com.grab.grazel.migrate.common.TestSizeCalculator import com.grab.grazel.util.GradleProvider @@ -58,6 +60,7 @@ class DefaultAndroidUnitTestDataExtractorTest : GrazelPluginTest() { private lateinit var defaultAndroidUnitTestDataExtractor: DefaultAndroidUnitTestDataExtractor private lateinit var androidVariantsExtractor: AndroidVariantsExtractor private lateinit var gradleDependencyToBazelDependency: GradleDependencyToBazelDependency + private lateinit var fakeVariantCompressionService: GradleProvider @get:Rule val temporaryFolder = TemporaryFolder() @@ -84,7 +87,10 @@ class DefaultAndroidUnitTestDataExtractorTest : GrazelPluginTest() { } } androidVariantsExtractor = DefaultAndroidVariantsExtractor() - gradleDependencyToBazelDependency = GradleDependencyToBazelDependency() + + fakeVariantCompressionService = rootProject.provider { FakeVariantCompressionService() } + gradleDependencyToBazelDependency = GradleDependencyToBazelDependency(fakeVariantCompressionService) + File(subProjectDir, "src/main/AndroidManifest.xml").apply { parentFile.mkdirs() createNewFile() @@ -117,7 +123,7 @@ class DefaultAndroidUnitTestDataExtractorTest : GrazelPluginTest() { val testDependencyGraphsService = FakeDependencyGraphsService(dependencyGraphs) val mockDependencyGraphsService: GradleProvider = - rootProject.provider { testDependencyGraphsService } + rootProject.provider { testDependencyGraphsService} val extension = GrazelExtension(rootProject) defaultAndroidUnitTestDataExtractor = DefaultAndroidUnitTestDataExtractor( @@ -127,7 +133,8 @@ class DefaultAndroidUnitTestDataExtractorTest : GrazelPluginTest() { variantDataSource = variantDataSource, grazelExtension = extension, gradleDependencyToBazelDependency = gradleDependencyToBazelDependency, - testSizeCalculator = TestSizeCalculator(extension) + testSizeCalculator = TestSizeCalculator(extension), + variantCompressionService = fakeVariantCompressionService ) } diff --git a/keystore/BUILD.bazel b/keystore/BUILD.bazel index bb11ee34..a44148b1 100644 --- a/keystore/BUILD.bazel +++ b/keystore/BUILD.bazel @@ -1,9 +1,10 @@ filegroup( - name = "debug-keystore", - srcs = [ - "debug.keystore", - ], - visibility = [ - "//visibility:public", - ], + name = "debug-keystore", + srcs = [ + "debug.keystore", + ], + visibility = [ + "//visibility:public", + ] ) + diff --git a/sample-android-library/BUILD.bazel b/sample-android-library/BUILD.bazel index 262d57ac..bbe9bb33 100644 --- a/sample-android-library/BUILD.bazel +++ b/sample-android-library/BUILD.bazel @@ -1,7 +1,7 @@ load("@grab_bazel_common//rules:defs.bzl", "android_library", "android_unit_test") android_library( - name = "sample-android-library-demo-free-debug", + name = "sample-android-library-debug", srcs = glob([ "src/main/java/com/grab/grazel/android/sample/SampleViewModel.kt", "src/debug/kotlin/Empty.kt", @@ -33,185 +33,14 @@ android_library( ], ) -android_library( - name = "sample-android-library-demo-paid-debug", - srcs = glob([ - "src/main/java/com/grab/grazel/android/sample/SampleViewModel.kt", - "src/debug/kotlin/Empty.kt", - ]), - custom_package = "com.grab.grazel.android.sample.lib", - enable_data_binding = True, - lint_options = { - "enabled": True, - "config": "//:lint.xml", - }, - manifest = "src/debug/AndroidManifest.xml", - plugins = [ - "//:moshi-kotlin-codegen-ksp", - ], - resource_sets = { - "debug": { - "manifest": "src/debug/AndroidManifest.xml", - }, - "main": { - "manifest": "src/main/AndroidManifest.xml", - }, - }, - visibility = [ - "//visibility:public", - ], - deps = [ - "//:parcelize", - "@maven//:com_squareup_moshi_moshi", - ], -) - -android_library( - name = "sample-android-library-full-free-debug", - srcs = glob([ - "src/main/java/com/grab/grazel/android/sample/SampleViewModel.kt", - "src/debug/kotlin/Empty.kt", - ]), - custom_package = "com.grab.grazel.android.sample.lib", - enable_data_binding = True, - lint_options = { - "enabled": True, - "config": "//:lint.xml", - }, - manifest = "src/debug/AndroidManifest.xml", - plugins = [ - "//:moshi-kotlin-codegen-ksp", - ], - resource_sets = { - "debug": { - "manifest": "src/debug/AndroidManifest.xml", - }, - "main": { - "manifest": "src/main/AndroidManifest.xml", - }, - }, - visibility = [ - "//visibility:public", - ], - deps = [ - "//:parcelize", - "@maven//:com_squareup_moshi_moshi", - ], -) - -android_library( - name = "sample-android-library-full-paid-debug", - srcs = glob([ - "src/main/java/com/grab/grazel/android/sample/SampleViewModel.kt", - "src/debug/kotlin/Empty.kt", - ]), - custom_package = "com.grab.grazel.android.sample.lib", - enable_data_binding = True, - lint_options = { - "enabled": True, - "config": "//:lint.xml", - }, - manifest = "src/debug/AndroidManifest.xml", - plugins = [ - "//:moshi-kotlin-codegen-ksp", - ], - resource_sets = { - "debug": { - "manifest": "src/debug/AndroidManifest.xml", - }, - "main": { - "manifest": "src/main/AndroidManifest.xml", - }, - }, - visibility = [ - "//visibility:public", - ], - deps = [ - "//:parcelize", - "@maven//:com_squareup_moshi_moshi", - ], -) - -android_unit_test( - name = "sample-android-library-demo-free-debug-test", - size = "medium", - srcs = glob([ - "src/test/java/com/grab/grazel/android/sample/SampleViewModelTest.kt", - ]), - associates = [ - "//sample-android-library:sample-android-library-demo-free-debug_kt", - ], - custom_package = "com.grab.grazel.android.sample.lib", - resources = glob([ - "src/test/resources/**", - ]), - visibility = [ - "//visibility:public", - ], - deps = [ - "//:parcelize", - "//sample-android-library:sample-android-library-demo-free-debug", - "@maven//:com_squareup_moshi_moshi", - "@maven//:junit_junit", - ], -) - -android_unit_test( - name = "sample-android-library-demo-paid-debug-test", - size = "medium", - srcs = glob([ - "src/test/java/com/grab/grazel/android/sample/SampleViewModelTest.kt", - ]), - associates = [ - "//sample-android-library:sample-android-library-demo-paid-debug_kt", - ], - custom_package = "com.grab.grazel.android.sample.lib", - resources = glob([ - "src/test/resources/**", - ]), - visibility = [ - "//visibility:public", - ], - deps = [ - "//:parcelize", - "//sample-android-library:sample-android-library-demo-paid-debug", - "@maven//:com_squareup_moshi_moshi", - "@maven//:junit_junit", - ], -) - -android_unit_test( - name = "sample-android-library-full-free-debug-test", - size = "medium", - srcs = glob([ - "src/test/java/com/grab/grazel/android/sample/SampleViewModelTest.kt", - ]), - associates = [ - "//sample-android-library:sample-android-library-full-free-debug_kt", - ], - custom_package = "com.grab.grazel.android.sample.lib", - resources = glob([ - "src/test/resources/**", - ]), - visibility = [ - "//visibility:public", - ], - deps = [ - "//:parcelize", - "//sample-android-library:sample-android-library-full-free-debug", - "@maven//:com_squareup_moshi_moshi", - "@maven//:junit_junit", - ], -) - android_unit_test( - name = "sample-android-library-full-paid-debug-test", + name = "sample-android-library-debug-test", size = "medium", srcs = glob([ "src/test/java/com/grab/grazel/android/sample/SampleViewModelTest.kt", ]), associates = [ - "//sample-android-library:sample-android-library-full-paid-debug_kt", + "//sample-android-library:sample-android-library-debug_kt", ], custom_package = "com.grab.grazel.android.sample.lib", resources = glob([ @@ -222,7 +51,7 @@ android_unit_test( ], deps = [ "//:parcelize", - "//sample-android-library:sample-android-library-full-paid-debug", + "//sample-android-library:sample-android-library-debug", "@maven//:com_squareup_moshi_moshi", "@maven//:junit_junit", ], diff --git a/sample-android/BUILD.bazel b/sample-android/BUILD.bazel index ac26c270..5706fe25 100644 --- a/sample-android/BUILD.bazel +++ b/sample-android/BUILD.bazel @@ -96,7 +96,7 @@ android_binary( "//:dagger", "//:parcelize", "//flavors/sample-android-flavor:sample-android-flavor-demo-free-debug", - "//sample-android-library:sample-android-library-demo-free-debug", + "//sample-android-library:sample-android-library-debug", "//sample-kotlin-library", "@debug_maven//:androidx_core_core", "@debug_maven//:androidx_lifecycle_lifecycle_common", @@ -213,7 +213,7 @@ android_binary( "//:dagger", "//:parcelize", "//flavors/sample-android-flavor:sample-android-flavor-demo-paid-debug", - "//sample-android-library:sample-android-library-demo-paid-debug", + "//sample-android-library:sample-android-library-debug", "//sample-kotlin-library", "@debug_maven//:androidx_core_core", "@debug_maven//:androidx_lifecycle_lifecycle_common", @@ -330,7 +330,7 @@ android_binary( "//:dagger", "//:parcelize", "//flavors/sample-android-flavor:sample-android-flavor-full-free-debug", - "//sample-android-library:sample-android-library-full-free-debug", + "//sample-android-library:sample-android-library-debug", "//sample-kotlin-library", "@debug_maven//:androidx_core_core", "@debug_maven//:androidx_lifecycle_lifecycle_common", @@ -447,7 +447,7 @@ android_binary( "//:dagger", "//:parcelize", "//flavors/sample-android-flavor:sample-android-flavor-full-paid-debug", - "//sample-android-library:sample-android-library-full-paid-debug", + "//sample-android-library:sample-android-library-debug", "//sample-kotlin-library", "@debug_maven//:androidx_core_core", "@debug_maven//:androidx_lifecycle_lifecycle_common", @@ -543,7 +543,7 @@ android_instrumentation_binary( "//:dagger", "//flavors/sample-android-flavor:sample-android-flavor-demo-free-debug", "//sample-android:lib_sample-android-demo-free-debug", - "//sample-android-library:sample-android-library-demo-free-debug", + "//sample-android-library:sample-android-library-debug", "//sample-kotlin-library", "@android_test_maven//:androidx_test_monitor", "@debug_maven//:androidx_core_core", @@ -602,7 +602,7 @@ android_instrumentation_binary( "//:dagger", "//flavors/sample-android-flavor:sample-android-flavor-demo-paid-debug", "//sample-android:lib_sample-android-demo-paid-debug", - "//sample-android-library:sample-android-library-demo-paid-debug", + "//sample-android-library:sample-android-library-debug", "//sample-kotlin-library", "@android_test_maven//:androidx_test_monitor", "@debug_maven//:androidx_core_core", @@ -661,7 +661,7 @@ android_instrumentation_binary( "//:dagger", "//flavors/sample-android-flavor:sample-android-flavor-full-free-debug", "//sample-android:lib_sample-android-full-free-debug", - "//sample-android-library:sample-android-library-full-free-debug", + "//sample-android-library:sample-android-library-debug", "//sample-kotlin-library", "@android_test_maven//:androidx_test_monitor", "@debug_maven//:androidx_core_core", @@ -720,7 +720,7 @@ android_instrumentation_binary( "//:dagger", "//flavors/sample-android-flavor:sample-android-flavor-full-paid-debug", "//sample-android:lib_sample-android-full-paid-debug", - "//sample-android-library:sample-android-library-full-paid-debug", + "//sample-android-library:sample-android-library-debug", "//sample-kotlin-library", "@android_test_maven//:androidx_test_monitor", "@debug_maven//:androidx_core_core", From fcd8b472287886c28e86d8c8009791d59be80cdf Mon Sep 17 00:00:00 2001 From: arunkumar9t2 Date: Mon, 2 Feb 2026 14:59:36 +0800 Subject: [PATCH 2/4] Format keystore BUILD.bazel with buildifier Apply buildifier formatting: fix indentation and add trailing comma. --- keystore/BUILD.bazel | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/keystore/BUILD.bazel b/keystore/BUILD.bazel index a44148b1..bb11ee34 100644 --- a/keystore/BUILD.bazel +++ b/keystore/BUILD.bazel @@ -1,10 +1,9 @@ filegroup( - name = "debug-keystore", - srcs = [ - "debug.keystore", - ], - visibility = [ - "//visibility:public", - ] + name = "debug-keystore", + srcs = [ + "debug.keystore", + ], + visibility = [ + "//visibility:public", + ], ) - From e965bd1ae85232cc40fb248ca5e124408f9afd51 Mon Sep 17 00:00:00 2001 From: arunkumar9t2 Date: Mon, 2 Feb 2026 15:16:47 +0800 Subject: [PATCH 3/4] Optimize CI build to target specific APK Changed bazel-build job to build only the demo-free-debug APK target instead of building all targets, reducing CI build time. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ade3235..cfa777e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: - name: Bazel build uses: ./.github/actions/bazel with: - bazel-command: build //... + bazel-command: build //sample-android:sample-android-demo-free-debug.apk cache-key: bazel-build-cache bazel-test: runs-on: ubuntu-latest From 34eb66fc6b32c3ad5d529d6b0628c93ad7b9ae78 Mon Sep 17 00:00:00 2001 From: arunkumar9t2 Date: Mon, 2 Feb 2026 15:27:06 +0800 Subject: [PATCH 4/4] Revert CI build target and update cache keys Reverted bazel-build to build all targets and bumped cache keys to invalidate existing caches. --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfa777e2..5e933883 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,8 @@ jobs: - name: Bazel build uses: ./.github/actions/bazel with: - bazel-command: build //sample-android:sample-android-demo-free-debug.apk - cache-key: bazel-build-cache + bazel-command: build //... + cache-key: bazel-build-cache1 bazel-test: runs-on: ubuntu-latest steps: @@ -60,7 +60,7 @@ jobs: uses: ./.github/actions/bazel with: bazel-command: test //sample-android:sample-android-demo-free-debug.lint_test - cache-key: bazel-lint-cache + cache-key: bazel-lint-cache1 grazel-build: runs-on: ubuntu-latest