diff --git a/build.gradle b/build.gradle index 3c94ca4..db694fc 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,7 @@ plugins { id 'com.vanniktech.maven.publish' apply false id 'com.android.lint' apply false id 'org.jetbrains.intellij.platform' apply false + id 'com.github.johnrengelman.shadow' apply false } group = 'com.fueledbycaffeine' diff --git a/buildscript-utils/api/buildscript-utils.api b/buildscript-utils/api/buildscript-utils.api index 7bc5793..ef74c61 100644 --- a/buildscript-utils/api/buildscript-utils.api +++ b/buildscript-utils/api/buildscript-utils.api @@ -12,12 +12,21 @@ public final class com/fueledbycaffeine/spotlight/buildscript/BuildFile { } public final class com/fueledbycaffeine/spotlight/buildscript/BuildGraph : com/fueledbycaffeine/spotlight/buildscript/graph/Graph { - public synthetic fun (Ljava/util/Set;Ljava/util/Set;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final field Companion Lcom/fueledbycaffeine/spotlight/buildscript/BuildGraph$Companion; + public fun (Ljava/util/Set;Ljava/util/Set;)V + public synthetic fun (Ljava/util/Set;Ljava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public static final fun create (Ljava/util/Set;Ljava/util/Set;)Lcom/fueledbycaffeine/spotlight/buildscript/BuildGraph; public static final fun createFromNode (Lcom/fueledbycaffeine/spotlight/buildscript/GradlePath;Ljava/util/Set;)Lcom/fueledbycaffeine/spotlight/buildscript/BuildGraph; public fun getDependencyMap ()Ljava/util/Map; } +public final class com/fueledbycaffeine/spotlight/buildscript/BuildGraph$Companion { + public final fun create (Ljava/util/Set;Ljava/util/Set;)Lcom/fueledbycaffeine/spotlight/buildscript/BuildGraph; + public static synthetic fun create$default (Lcom/fueledbycaffeine/spotlight/buildscript/BuildGraph$Companion;Ljava/util/Set;Ljava/util/Set;ILjava/lang/Object;)Lcom/fueledbycaffeine/spotlight/buildscript/BuildGraph; + public final fun createFromNode (Lcom/fueledbycaffeine/spotlight/buildscript/GradlePath;Ljava/util/Set;)Lcom/fueledbycaffeine/spotlight/buildscript/BuildGraph; + public static synthetic fun createFromNode$default (Lcom/fueledbycaffeine/spotlight/buildscript/BuildGraph$Companion;Lcom/fueledbycaffeine/spotlight/buildscript/GradlePath;Ljava/util/Set;ILjava/lang/Object;)Lcom/fueledbycaffeine/spotlight/buildscript/BuildGraph; +} + public final class com/fueledbycaffeine/spotlight/buildscript/BuildGraphKt { public static final fun buildGraph (Lcom/fueledbycaffeine/spotlight/buildscript/GradlePath;Ljava/util/Set;)Lcom/fueledbycaffeine/spotlight/buildscript/BuildGraph; public static synthetic fun buildGraph$default (Lcom/fueledbycaffeine/spotlight/buildscript/GradlePath;Ljava/util/Set;ILjava/lang/Object;)Lcom/fueledbycaffeine/spotlight/buildscript/BuildGraph; @@ -96,6 +105,7 @@ public abstract class com/fueledbycaffeine/spotlight/buildscript/graph/Graph { public fun ()V public final fun accessorsOf (Lcom/fueledbycaffeine/spotlight/buildscript/graph/GraphNode;)Ljava/util/Set; public final fun edges ()Ljava/util/Set; + public final fun findShortestPath (Lcom/fueledbycaffeine/spotlight/buildscript/graph/GraphNode;Lcom/fueledbycaffeine/spotlight/buildscript/graph/GraphNode;)Ljava/util/List; public abstract fun getDependencyMap ()Ljava/util/Map; public final fun successorsOf (Lcom/fueledbycaffeine/spotlight/buildscript/graph/GraphNode;)Ljava/util/Set; } diff --git a/buildscript-utils/src/main/kotlin/com/fueledbycaffeine/spotlight/buildscript/BuildGraph.kt b/buildscript-utils/src/main/kotlin/com/fueledbycaffeine/spotlight/buildscript/BuildGraph.kt index f0efaa7..927e003 100644 --- a/buildscript-utils/src/main/kotlin/com/fueledbycaffeine/spotlight/buildscript/BuildGraph.kt +++ b/buildscript-utils/src/main/kotlin/com/fueledbycaffeine/spotlight/buildscript/BuildGraph.kt @@ -3,14 +3,15 @@ package com.fueledbycaffeine.spotlight.buildscript import com.fueledbycaffeine.spotlight.buildscript.graph.BreadthFirstSearch import com.fueledbycaffeine.spotlight.buildscript.graph.Graph import com.fueledbycaffeine.spotlight.buildscript.graph.ImplicitDependencyRule +import java.util.LinkedList -public class BuildGraph private constructor( - private val allProjects: Set, - private val rules: Set = emptySet(), +public class BuildGraph( + allProjects: Set, + rules: Set = emptySet(), ): Graph() { - internal companion object { + public companion object { @JvmStatic - fun create( + public fun create( allProjects: Set, rules: Set = emptySet(), ): BuildGraph { @@ -18,7 +19,7 @@ public class BuildGraph private constructor( } @JvmStatic - fun createFromNode( + public fun createFromNode( node: GradlePath, rules: Set = emptySet(), ): BuildGraph { @@ -26,8 +27,8 @@ public class BuildGraph private constructor( } } - override val dependencyMap: Map> by lazy { BreadthFirstSearch.run(allProjects, rules) } + override val dependencyMap: Map> = BreadthFirstSearch.run(allProjects, rules) } public fun GradlePath.buildGraph(rules: Set = emptySet()): BuildGraph = - BuildGraph.createFromNode(this, rules) \ No newline at end of file + BuildGraph.createFromNode(this, rules) diff --git a/buildscript-utils/src/main/kotlin/com/fueledbycaffeine/spotlight/buildscript/graph/Graph.kt b/buildscript-utils/src/main/kotlin/com/fueledbycaffeine/spotlight/buildscript/graph/Graph.kt index bd766dd..4971e83 100644 --- a/buildscript-utils/src/main/kotlin/com/fueledbycaffeine/spotlight/buildscript/graph/Graph.kt +++ b/buildscript-utils/src/main/kotlin/com/fueledbycaffeine/spotlight/buildscript/graph/Graph.kt @@ -1,5 +1,8 @@ package com.fueledbycaffeine.spotlight.buildscript.graph +import com.fueledbycaffeine.spotlight.buildscript.GradlePath +import java.util.LinkedList + public data class Edge( public val accessor: T, public val successor: T, @@ -21,4 +24,43 @@ public abstract class Graph>() { public fun accessorsOf(node: T): Set = edges().filter { it.successor == node }.map { it.accessor }.toSet() + + /** + * Finds the shortest path between source and target nodes using BFS. + * + * @param source The starting node + * @param target The target node + * @return List representing the shortest path from source to target, or null if no path exists + */ + public fun findShortestPath( + source: T, + target: T + ): List? { + val queue = LinkedList() + val visited = LinkedHashSet(dependencyMap.size) + val parentMap = LinkedHashMap(dependencyMap.size) + + queue.offer(source) + visited.add(source) + parentMap[source] = null + + while (queue.isNotEmpty()) { + val current = queue.poll() + + if (current == target) { + return generateSequence(target) { parentMap[it] }.toList().reversed() + } + + // Process dependencies + dependencyMap[current]?.forEach { dep -> + if (dep !in visited) { + visited.add(dep) + parentMap[dep] = current + queue.offer(dep) + } + } + } + + return null + } } \ No newline at end of file diff --git a/buildscript-utils/src/test/kotlin/com/fueledbycaffeine/spotlight/buildscript/graph/BuildGraphTest.kt b/buildscript-utils/src/test/kotlin/com/fueledbycaffeine/spotlight/buildscript/graph/BuildGraphTest.kt new file mode 100644 index 0000000..8d31617 --- /dev/null +++ b/buildscript-utils/src/test/kotlin/com/fueledbycaffeine/spotlight/buildscript/graph/BuildGraphTest.kt @@ -0,0 +1,143 @@ +package com.fueledbycaffeine.spotlight.buildscript.graph + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.containsExactly +import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import com.fueledbycaffeine.spotlight.buildscript.BuildGraph +import com.fueledbycaffeine.spotlight.buildscript.GradlePath +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.createFile +import kotlin.io.path.writeText + +class BuildGraphTest { + + @TempDir + lateinit var tempDir: Path + + private fun createNode(path: String): GradlePath { + // Create the project directory and build file + val gradlePath = GradlePath(tempDir, path) + gradlePath.projectDir.createDirectories() + gradlePath.projectDir.resolve("build.gradle").apply { + createFile() + writeText("// Test build file") + } + + return GradlePath(tempDir.toFile(), path) + } + + private fun setupDependencies(vararg dependencies: Pair>) { + dependencies.forEach { (project, deps) -> + val depsText = deps.joinToString("\n") { dep -> + " implementation project('${dep.path}')" + } + project.buildFilePath.writeText( + """ + dependencies { + $depsText + } + """.trimIndent() + ) + } + } + + @Test + fun `findShortestPath returns shortest path between nodes`() { + val nodeA = createNode(":a") + val nodeB = createNode(":b") + val nodeC = createNode(":c") + + // A -> B -> C + setupDependencies( + nodeA to setOf(nodeB), + nodeB to setOf(nodeC), + nodeC to emptySet() + ) + + val graph = BuildGraph(setOf(nodeA, nodeB, nodeC)) + val path = graph.findShortestPath(nodeA, nodeC) + + assertThat(path).isNotNull() + assertThat(path!!).containsExactly(nodeA, nodeB, nodeC) + } + + @Test + fun `findShortestPath returns null when no path exists`() { + val nodeA = createNode(":a") + val nodeB = createNode(":b") + val nodeC = createNode(":c") + + // A -> B, C (isolated) + setupDependencies( + nodeA to setOf(nodeB), + nodeB to emptySet(), + nodeC to emptySet() + ) + + val graph = BuildGraph(setOf(nodeA, nodeB, nodeC)) + val path = graph.findShortestPath(nodeA, nodeC) + + assertThat(path).isNull() + } + + @Test + fun `accessorsOf returns all direct consumers`() { + val nodeA = createNode(":a") + val nodeB = createNode(":b") + val nodeC = createNode(":c") + val nodeD = createNode(":d") + + // B -> A, C -> A, D -> C (so D transitively consumes A) + setupDependencies( + nodeA to emptySet(), + nodeB to setOf(nodeA), + nodeC to setOf(nodeA), + nodeD to setOf(nodeC) + ) + + val graph = BuildGraph(setOf(nodeA, nodeB, nodeC, nodeD)) + val consumers = graph.accessorsOf(nodeA) + + assertThat(consumers).containsExactlyInAnyOrder(nodeB, nodeC) + } + + @Test + fun `accessorsOf returns consumers when they exist`() { + val nodeA = createNode(":a") + val nodeB = createNode(":b") + + // B -> A (B depends on A, so B is a consumer of A) + setupDependencies( + nodeA to emptySet(), + nodeB to setOf(nodeA) + ) + + val graph = BuildGraph(setOf(nodeA, nodeB)) + val consumers = graph.accessorsOf(nodeA) + + assertThat(consumers).containsExactlyInAnyOrder(nodeB) + } + + @Test + fun `accessorsOf returns empty set when no consumers exist`() { + val nodeA = createNode(":a") + + // A has no dependencies and no other nodes depend on A + setupDependencies( + nodeA to emptySet() + ) + + val graph = BuildGraph(setOf(nodeA)) + val consumers = graph.accessorsOf(nodeA) + + assertThat(consumers).isEmpty() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c7a01d8..7b6ad68 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,9 @@ truth = "1.1.3" testkit = "0.18" lint-gradle = "1.0.0-alpha04" moshi = "1.15.2" +picocli = "4.7.6" +microutilsLogging = "3.0.5" +slf4j = "2.0.13" [libraries] junit-platform = { module = "org.junit:junit-bom", version.ref = "junit" } @@ -16,3 +19,6 @@ autonomousapps-testkit = { module = "com.autonomousapps:gradle-testkit", version autonomousapps-testkit-support = { module = "com.autonomousapps:gradle-testkit-support", version.ref = "testkit" } lint-gradle = { module = "androidx.lint:lint-gradle", version.ref = "lint-gradle" } moshi = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } +picocli = { module = "info.picocli:picocli", version.ref = "picocli"} +microutils-logging = { module = "io.github.microutils:kotlin-logging-jvm", version.ref = "microutilsLogging" } +slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } diff --git a/settings.gradle b/settings.gradle index 10ba5bb..0680c92 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,6 +11,7 @@ pluginManagement { id 'com.vanniktech.maven.publish' version '0.31.0' id 'com.android.lint' version '8.9.1' id 'org.jetbrains.intellij.platform' version '2.6.0' + id 'com.github.johnrengelman.shadow' version '8.1.1' } repositories { gradlePluginPortal() @@ -51,4 +52,5 @@ rootProject.name = 'spotlight' include ':spotlight-gradle-plugin' include ':spotlight-idea-plugin' -include ':buildscript-utils' \ No newline at end of file +include ':spotlight-cli' +include ':buildscript-utils' diff --git a/spotlight-cli/build.gradle b/spotlight-cli/build.gradle new file mode 100644 index 0000000..170fe89 --- /dev/null +++ b/spotlight-cli/build.gradle @@ -0,0 +1,104 @@ +import com.vanniktech.maven.publish.JavaLibrary +import com.vanniktech.maven.publish.JavadocJar +import com.vanniktech.maven.publish.SonatypeHost + +plugins { + id 'application' + id 'org.jetbrains.kotlin.jvm' + id 'com.github.johnrengelman.shadow' + id 'com.vanniktech.maven.publish.base' +} + +group = 'com.fueledbycaffeine.spotlight' +version = '0.9-SNAPSHOT' + +mavenPublishing { + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, true) + signAllPublications() + + // Skip javadoc and sources jar publication + configure(new JavaLibrary(new JavadocJar.None(), false)) + + pom { + name = "Spotlight CLI" + description = "Command-line interface for Spotlight" + inceptionYear = "2025" + url = "https://github.com/joshfriend/spotlight/" + licenses { + license { + name = "MIT License" + url = "https://choosealicense.com/licenses/mit/" + distribution = "https://choosealicense.com/licenses/mit/" + } + } + developers { + developer { + id = "joshfriend" + name = "Josh Friend" + url = "https://github.com/joshfriend/" + } + } + scm { + url = "https://github.com/joshfriend/spotlight/" + connection = "scm:git:git://github.com/joshfriend/spotlight.git" + developerConnection = "scm:git:ssh://git@github.com/joshfriend/spotlight.git" + } + } +} + +dependencies { + implementation project(':buildscript-utils') + implementation libs.picocli + implementation libs.microutils.logging + runtimeOnly libs.slf4j.simple +} + +application { + mainClass = 'com.fueledbycaffeine.spotlight.cli.SpotlightCli' +} + +run { + workingDir = rootProject.projectDir +} + +// Reproducible builds +tasks.withType(AbstractArchiveTask).configureEach { + archiveBaseName = project.name + preserveFileTimestamps = false + reproducibleFileOrder = true +} + +// Configure Shadow plugin to create fat jar +shadowJar { + archiveClassifier.set('') + mergeServiceFiles() + minimize { + exclude(dependency("org.slf4j:.*:.*")) + } + exclude '**/*.kotlin_metadata' + exclude '**/*.kotlin_module' + exclude 'META-INF/maven/**' +} + +tasks.withType(CreateStartScripts).configureEach { + applicationName = "spotlight" + classpath = files(shadowJar) +} + +// GMM is not useful in this case and trying to generate it throws errors +// because the publication component was changed to a zip file. +tasks.withType(GenerateModuleMetadata).configureEach { + enabled = false +} + +publishing { + publications { + maven { + artifacts.clear() + artifact(tasks.named('shadowDistZip')) { + classifier = null + extension = 'zip' + } + } + } +} diff --git a/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/SpotlightCli.kt b/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/SpotlightCli.kt new file mode 100644 index 0000000..4e83496 --- /dev/null +++ b/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/SpotlightCli.kt @@ -0,0 +1,24 @@ +@file:JvmName("SpotlightCli") + +package com.fueledbycaffeine.spotlight.cli + +import com.fueledbycaffeine.spotlight.cli.commands.SpotlightCommand +import picocli.CommandLine +import kotlin.system.exitProcess + +fun main(args: Array) { + // Parse args to extract log level before creating the command + val logLevelIndex = args.indexOf("--log-level") + val logLevel = if (logLevelIndex >= 0 && logLevelIndex < args.size - 1) { + args[logLevelIndex + 1] + } else { + "WARN" + } + + // Configure SLF4J Simple Logger before any logging happens + System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", logLevel.lowercase()) + System.setProperty("org.slf4j.simpleLogger.log.com.fueledbycaffeine", logLevel.lowercase()) + + val exitCode = CommandLine(SpotlightCommand()).execute(*args) + exitProcess(exitCode) +} diff --git a/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/commands/ConsumersCommand.kt b/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/commands/ConsumersCommand.kt new file mode 100644 index 0000000..d0709a1 --- /dev/null +++ b/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/commands/ConsumersCommand.kt @@ -0,0 +1,55 @@ +package com.fueledbycaffeine.spotlight.cli.commands + +import com.fueledbycaffeine.spotlight.buildscript.BuildGraph +import com.fueledbycaffeine.spotlight.buildscript.GradlePath +import com.fueledbycaffeine.spotlight.buildscript.SpotlightProjectList +import com.fueledbycaffeine.spotlight.buildscript.SpotlightProjectList.Companion.ALL_PROJECTS_LOCATION +import mu.KotlinLogging +import picocli.CommandLine.Command +import picocli.CommandLine.Option +import picocli.CommandLine.Parameters +import java.nio.file.Path +import kotlin.system.exitProcess +import kotlin.time.measureTimedValue + +@Command( + name = "consumers", + mixinStandardHelpOptions = true, + description = ["Prints the list of projects that consume the given project"], +) +class ConsumersCommand : Runnable { + + private val logger = KotlinLogging.logger {} + + @Parameters(index = "0", description = ["Gradle project path (e.g. :libs:foo:impl)"]) + lateinit var projectPath: String + + @Option(names = ["-r", "--root"], description = ["Root directory of multi-project build"], defaultValue = ".") + lateinit var rootDir: String + + override fun run() { + val root = Path.of(rootDir).normalize() + val node = GradlePath(root, projectPath) + + if (!node.hasBuildFile) { + println("No build script found for project \"$projectPath\" under $root") + exitProcess(1) + } + + val allProjects = SpotlightProjectList.allProjects(root).read() + logger.info { "$ALL_PROJECTS_LOCATION contains ${allProjects.size} projects" } + + // Build the complete dependency graph + val (graph, graphDuration) = measureTimedValue { BuildGraph(allProjects) } + logger.info { "BFS took ${graphDuration.inWholeMilliseconds}ms" } + + // Find all consumers (direct and transitive) + val (allConsumers, duration) = measureTimedValue { graph.accessorsOf(node) } + logger.info { "Graph traversal took ${duration.inWholeMilliseconds}ms" } + + println("$projectPath has ${allConsumers.size} consumers") + allConsumers.sortedBy { it.path }.forEach { + println(it.path) + } + } +} diff --git a/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/commands/ListDependenciesCommand.kt b/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/commands/ListDependenciesCommand.kt new file mode 100644 index 0000000..95f6b8a --- /dev/null +++ b/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/commands/ListDependenciesCommand.kt @@ -0,0 +1,46 @@ +package com.fueledbycaffeine.spotlight.cli.commands + +import com.fueledbycaffeine.spotlight.buildscript.GradlePath +import com.fueledbycaffeine.spotlight.buildscript.graph.BreadthFirstSearch +import mu.KotlinLogging +import picocli.CommandLine.Command +import picocli.CommandLine.Option +import picocli.CommandLine.Parameters +import java.nio.file.Path +import kotlin.system.exitProcess +import kotlin.time.measureTimedValue + +@Command( + name = "list-dependencies", + mixinStandardHelpOptions = true, + description = ["Prints the full list of dependencies for a Gradle project path"], +) +class ListDependenciesCommand : Runnable { + + private val logger = KotlinLogging.logger {} + + @Parameters(index = "0", description = ["Gradle project path (e.g. :libs:foo:impl)"]) + lateinit var projectPath: String + + @Option(names = ["-r", "--root"], description = ["Root directory of multi-project build"], defaultValue = ".") + lateinit var rootDir: String + + override fun run() { + val root = Path.of(rootDir).normalize() + val node = GradlePath(root, projectPath) + + if (!node.hasBuildFile) { + println("No build script found for project \"$projectPath\" under $root") + exitProcess(1) + } + + // Build transitive dependency set (BreadthFirstSearch flattens BFS result) + val (deps, duration) = measureTimedValue { BreadthFirstSearch.flatten(listOf(node)) } + logger.info { "BFS took ${duration.inWholeMilliseconds}ms" } + + println("$projectPath has ${deps.size} dependencies") + deps.sortedBy { it.path }.forEach { + println(it.path) + } + } +} diff --git a/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/commands/ReasonCommand.kt b/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/commands/ReasonCommand.kt new file mode 100644 index 0000000..88ce4c9 --- /dev/null +++ b/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/commands/ReasonCommand.kt @@ -0,0 +1,70 @@ +package com.fueledbycaffeine.spotlight.cli.commands + +import com.fueledbycaffeine.spotlight.buildscript.GradlePath +import com.fueledbycaffeine.spotlight.buildscript.buildGraph + +import mu.KotlinLogging +import picocli.CommandLine.* +import java.nio.file.Path +import kotlin.system.exitProcess +import kotlin.time.measureTimedValue + +@Command( + name = "reason", + mixinStandardHelpOptions = true, + description = ["Show the dependency path(s) from a project to a target project"] +) +class ReasonCommand : Runnable { + + private val logger = KotlinLogging.logger {} + + @Parameters(index = "0", description = ["Source project path (project producing the dependency)"]) + lateinit var sourcePath: String + + @Parameters(index = "1", description = ["Target project path (desired dependency)"]) + lateinit var targetPath: String + + @Option(names = ["-r", "--root"], description = ["Root directory of multi-project build"], defaultValue = ".") + lateinit var rootDir: String + + override fun run() { + val root = Path.of(rootDir).normalize() + val sourceNode = GradlePath(root, sourcePath) + val targetNode = GradlePath(root, targetPath) + + // Validate source project exists + if (!sourceNode.hasBuildFile) { + println("No build script found for source project \"$sourcePath\" under $root") + exitProcess(1) + } + + // Validate target project exists + if (!targetNode.hasBuildFile) { + println("No build script found for target project \"$targetPath\" under $root") + exitProcess(1) + } + + // Build dependency graph from source + val (graph, graphDuration) = measureTimedValue { sourceNode.buildGraph() } + logger.info { "BFS took ${graphDuration.inWholeMilliseconds}ms" } + + // Find paths using BFS + val (path, pathDuration) = measureTimedValue { graph.findShortestPath(sourceNode, targetNode) } + logger.info { "Path calculation took ${pathDuration.inWholeMilliseconds}ms" } + + if (path == null) { + println("No path found from $sourcePath to $targetPath") + exitProcess(1) + } + + printPath(path) + } + + private fun printPath(path: List) { + path.forEachIndexed { index, node -> + val indent = " ".repeat(index) + val prefix = if (index == 0) "" else "└─ " + println("$indent$prefix${node.path}") + } + } +} diff --git a/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/commands/SpotlightCommand.kt b/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/commands/SpotlightCommand.kt new file mode 100644 index 0000000..4d2cd50 --- /dev/null +++ b/spotlight-cli/src/main/kotlin/com/fueledbycaffeine/spotlight/cli/commands/SpotlightCommand.kt @@ -0,0 +1,28 @@ +package com.fueledbycaffeine.spotlight.cli.commands + +import picocli.CommandLine + +@CommandLine.Command( + name = "spotlight", + mixinStandardHelpOptions = true, + description = ["Spotlight CLI utilities for Gradle project analysis"], + subcommands = [ + ListDependenciesCommand::class, + ReasonCommand::class, + ConsumersCommand::class, + ] +) +class SpotlightCommand : Runnable { + @CommandLine.Option( + names = ["--log-level"], + description = ["Set logging level (ERROR, WARN, INFO, DEBUG, TRACE)"], + defaultValue = "WARN", + scope = CommandLine.ScopeType.INHERIT + ) + var logLevel: String = "WARN" + + override fun run() { + // If no subcommand is specified, show usage + CommandLine.usage(this, System.out) + } +} \ No newline at end of file diff --git a/spotlight-cli/src/main/resources/simplelogger.properties b/spotlight-cli/src/main/resources/simplelogger.properties new file mode 100644 index 0000000..dcc18e1 --- /dev/null +++ b/spotlight-cli/src/main/resources/simplelogger.properties @@ -0,0 +1,8 @@ +# SLF4J Simple Logger configuration +org.slf4j.simpleLogger.defaultLogLevel=warn +org.slf4j.simpleLogger.showDateTime=false +org.slf4j.simpleLogger.showThreadName=true +org.slf4j.simpleLogger.showLogName=false +org.slf4j.simpleLogger.showShortLogName=true +org.slf4j.simpleLogger.levelInBrackets=false +org.slf4j.simpleLogger.dateTimeFormat=HH:mm:ss.SSS