diff --git a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java index 3a7d6c60..991dae81 100644 --- a/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java +++ b/src/main/java/io/github/guacsec/trustifyda/impl/ExhortApi.java @@ -30,6 +30,8 @@ import io.github.guacsec.trustifyda.image.ImageUtils; import io.github.guacsec.trustifyda.license.LicenseCheck; import io.github.guacsec.trustifyda.logging.LoggersFactory; +import io.github.guacsec.trustifyda.providers.JavaMavenProvider; +import io.github.guacsec.trustifyda.providers.golang.model.GoWorkspace; import io.github.guacsec.trustifyda.providers.javascript.workspace.JsWorkspaceDiscovery; import io.github.guacsec.trustifyda.providers.rust.model.CargoMetadata; import io.github.guacsec.trustifyda.tools.Ecosystem; @@ -842,7 +844,7 @@ int resolveBatchConcurrency() { } private static final Set DEFAULT_WORKSPACE_DISCOVERY_IGNORE = - Set.of("**/node_modules/**", "**/.git/**"); + Set.of("**/node_modules/**", "**/.git/**", "**/target/**", "**/build/**", "**/.gradle/**"); /** Merges default ignore patterns, env var overrides, and caller-provided patterns. */ Set resolveIgnorePatterns(Set callerPatterns) { @@ -875,6 +877,22 @@ List discoverWorkspaceManifests(Path workspaceDir, Set ignorePatte return discoverCargoManifests(workspaceDir, ignorePatterns); } + // Gradle multi-project: settings.gradle or settings.gradle.kts + boolean hasGradleSettings = + Files.isRegularFile(workspaceDir.resolve("settings.gradle")) + || Files.isRegularFile(workspaceDir.resolve("settings.gradle.kts")); + if (hasGradleSettings) { + return discoverGradleSubprojects(workspaceDir, ignorePatterns); + } + + // Go workspace: go.work + if (Files.isRegularFile(workspaceDir.resolve("go.work"))) { + List goManifests = discoverGoWorkspaceModules(workspaceDir, ignorePatterns); + if (!goManifests.isEmpty()) { + return goManifests; + } + } + // JS workspace: require package.json + a lock file Path packageJson = workspaceDir.resolve("package.json"); boolean hasJsLock = @@ -930,6 +948,163 @@ private List discoverCargoManifests(Path workspaceDir, Set ignoreP } } + /** + * Discover all go.mod manifest paths in a Go workspace. Uses {@code go work edit -json} to get + * workspace members. + */ + private List discoverGoWorkspaceModules(Path workspaceDir, Set ignorePatterns) { + try { + String goBin = Operations.getCustomPathOrElse("go"); + Path goWork = workspaceDir.resolve("go.work"); + Operations.ProcessExecOutput output = + Operations.runProcessGetFullOutput( + workspaceDir, new String[] {goBin, "work", "edit", "-json", goWork.toString()}, null); + if (output.getExitCode() != 0) { + LOG.warning("go work edit -json failed with exit code " + output.getExitCode()); + return Collections.emptyList(); + } + GoWorkspace workspace = mapper.readValue(output.getOutput(), GoWorkspace.class); + if (workspace.use() == null || workspace.use().isEmpty()) { + return Collections.emptyList(); + } + List manifests = new ArrayList<>(); + for (var entry : workspace.use()) { + if (entry.diskPath() == null || entry.diskPath().isBlank()) { + continue; + } + Path moduleDir = workspaceDir.resolve(entry.diskPath()).normalize(); + Path goMod = moduleDir.resolve("go.mod"); + if (Files.isRegularFile(goMod)) { + manifests.add(goMod); + } + } + return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifests, ignorePatterns); + } catch (Exception e) { + LOG.warning("Failed to discover Go workspace modules: " + e.getMessage()); + return Collections.emptyList(); + } + } + + private static final String GRADLE_INIT_SCRIPT = + "allprojects {\n" + + " task daListProjects {\n" + + " doLast {\n" + + " println \"::DA_PROJECT::${project.path}::${project.projectDir}\"\n" + + " }\n" + + " }\n" + + "}\n"; + + /** + * Resolve the Gradle binary, preferring gradlew wrapper when available and configured. + * + * @param startDir directory from which to start the wrapper search + * @return path to the Gradle binary + */ + private static String resolveGradleBinary(Path startDir) { + if (Operations.getWrapperPreference("gradle")) { + String wrapperName = Operations.isWindows() ? "gradlew.bat" : "gradlew"; + String wrapper = + JavaMavenProvider.traverseForMvnw( + wrapperName, startDir.resolve("build.gradle").toString()); + if (wrapper != null) { + return wrapper; + } + } + return Operations.getCustomPathOrElse("gradle"); + } + + /** + * Discover all build.gradle[.kts] manifest paths in a Gradle multi-project build. Uses a custom + * init script to get a structured project listing. + */ + private List discoverGradleSubprojects(Path workspaceDir, Set ignorePatterns) { + Path rootBuildKts = workspaceDir.resolve("build.gradle.kts"); + Path rootBuild = workspaceDir.resolve("build.gradle"); + + List manifestPaths = new ArrayList<>(); + if (Files.isRegularFile(rootBuildKts)) { + manifestPaths.add(rootBuildKts); + } else if (Files.isRegularFile(rootBuild)) { + manifestPaths.add(rootBuild); + } + + String gradleBin = resolveGradleBinary(workspaceDir); + Path initScriptPath = null; + try { + initScriptPath = Files.createTempFile("da-list-projects-", ".gradle"); + Files.writeString(initScriptPath, GRADLE_INIT_SCRIPT); + + Operations.ProcessExecOutput output = + Operations.runProcessGetFullOutput( + workspaceDir, + new String[] { + gradleBin, + "-q", + "--no-daemon", + "--init-script", + initScriptPath.toString(), + "daListProjects" + }, + null); + + if (output.getExitCode() != 0) { + LOG.warning("gradle daListProjects failed with exit code " + output.getExitCode()); + return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); + } + + for (var proj : parseGradleInitScriptOutput(output.getOutput())) { + if (":".equals(proj.path())) { + continue; + } + Path projDir = Path.of(proj.dir()).toAbsolutePath().normalize(); + Path buildKts = projDir.resolve("build.gradle.kts"); + Path buildGroovy = projDir.resolve("build.gradle"); + if (Files.isRegularFile(buildKts)) { + manifestPaths.add(buildKts); + } else if (Files.isRegularFile(buildGroovy)) { + manifestPaths.add(buildGroovy); + } + } + } catch (Exception e) { + LOG.warning("Failed to discover Gradle subprojects: " + e.getMessage()); + return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); + } finally { + if (initScriptPath != null) { + try { + Files.deleteIfExists(initScriptPath); + } catch (IOException ignored) { + } + } + } + + return WorkspaceUtils.filterByIgnorePatterns(workspaceDir, manifestPaths, ignorePatterns); + } + + record GradleProject(String path, String dir) {} + + static List parseGradleInitScriptOutput(String raw) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + List projects = new ArrayList<>(); + for (String line : raw.split("\n")) { + if (!line.startsWith("::DA_PROJECT::")) { + continue; + } + String[] parts = line.split("::"); + List nonEmpty = new ArrayList<>(); + for (String part : parts) { + if (!part.isEmpty()) { + nonEmpty.add(part); + } + } + if (nonEmpty.size() >= 3) { + projects.add(new GradleProject(nonEmpty.get(1), nonEmpty.get(2))); + } + } + return projects; + } + /** * Checks whether a package.json has "private": true, meaning it should not be analyzed as a * publishable package. diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/golang/model/GoWorkspace.java b/src/main/java/io/github/guacsec/trustifyda/providers/golang/model/GoWorkspace.java new file mode 100644 index 00000000..20c70102 --- /dev/null +++ b/src/main/java/io/github/guacsec/trustifyda/providers/golang/model/GoWorkspace.java @@ -0,0 +1,29 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * 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 io.github.guacsec.trustifyda.providers.golang.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** JSON model for {@code go work edit -json} output. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GoWorkspace(@JsonProperty("Use") List use) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record UseEntry(@JsonProperty("DiskPath") String diskPath) {} +} diff --git a/src/test/java/io/github/guacsec/trustifyda/impl/GoWorkspaceDiscoveryTest.java b/src/test/java/io/github/guacsec/trustifyda/impl/GoWorkspaceDiscoveryTest.java new file mode 100644 index 00000000..9b298edf --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/impl/GoWorkspaceDiscoveryTest.java @@ -0,0 +1,254 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * 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 io.github.guacsec.trustifyda.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.guacsec.trustifyda.providers.golang.model.GoWorkspace; +import io.github.guacsec.trustifyda.tools.Operations; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +class GoWorkspaceDiscoveryTest { + + private static final Path GO_FIXTURES = Path.of("src/test/resources/tst_manifests/workspace/go"); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // --- GoWorkspace deserialization tests --- + + @Test + void goWorkspace_deserializesStandardOutput() throws Exception { + String json = + """ + { + "Go": "1.22", + "Use": [ + {"DiskPath": "./module-a"}, + {"DiskPath": "./module-b"} + ] + } + """; + GoWorkspace workspace = MAPPER.readValue(json, GoWorkspace.class); + + assertThat(workspace.use()).hasSize(2); + assertThat(workspace.use().getFirst().diskPath()).isEqualTo("./module-a"); + assertThat(workspace.use().get(1).diskPath()).isEqualTo("./module-b"); + } + + @Test + void goWorkspace_handlesNullUse() throws Exception { + String json = + """ + {"Go": "1.22"} + """; + GoWorkspace workspace = MAPPER.readValue(json, GoWorkspace.class); + assertThat(workspace.use()).isNull(); + } + + @Test + void goWorkspace_handlesEmptyUse() throws Exception { + String json = + """ + {"Go": "1.22", "Use": []} + """; + GoWorkspace workspace = MAPPER.readValue(json, GoWorkspace.class); + assertThat(workspace.use()).isEmpty(); + } + + @Test + void goWorkspace_ignoresUnknownFields() throws Exception { + String json = + """ + { + "Go": "1.22", + "Use": [{"DiskPath": "./mod"}], + "Replace": null, + "Toolchain": {"Name": "go1.22.0"} + } + """; + GoWorkspace workspace = MAPPER.readValue(json, GoWorkspace.class); + assertThat(workspace.use()).hasSize(1); + } + + // --- discoverWorkspaceManifests tests (require mocking Operations) --- + + @Test + void discoverWorkspaceManifests_goMultiModule() throws IOException { + Path workspaceDir = GO_FIXTURES.resolve("go_workspace").toAbsolutePath().normalize(); + String goWorkJson = buildGoWorkJson("./module-a", "./module-b"); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockGoOperations(mockOps, workspaceDir, goWorkJson); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(2); + assertThat(manifests).allMatch(p -> p.toString().endsWith("go.mod")); + assertThat(manifests) + .anyMatch(p -> p.toString().contains("module-a" + File.separator + "go.mod")); + assertThat(manifests) + .anyMatch(p -> p.toString().contains("module-b" + File.separator + "go.mod")); + } + } + + @Test + void discoverWorkspaceManifests_nestedModules() throws IOException { + Path workspaceDir = GO_FIXTURES.resolve("go_workspace_nested").toAbsolutePath().normalize(); + String goWorkJson = buildGoWorkJson("./libs/core", "./libs/util"); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockGoOperations(mockOps, workspaceDir, goWorkJson); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(2); + assertThat(manifests) + .anyMatch( + p -> + p.toString() + .contains("libs" + File.separator + "core" + File.separator + "go.mod")); + assertThat(manifests) + .anyMatch( + p -> + p.toString() + .contains("libs" + File.separator + "util" + File.separator + "go.mod")); + } + } + + @Test + void discoverWorkspaceManifests_singleModule() throws IOException { + Path workspaceDir = GO_FIXTURES.resolve("go_workspace_single").toAbsolutePath().normalize(); + String goWorkJson = buildGoWorkJson("./mymod"); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockGoOperations(mockOps, workspaceDir, goWorkJson); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(1); + assertThat(manifests.getFirst().toString()).contains("mymod" + File.separator + "go.mod"); + } + } + + @Test + void discoverWorkspaceManifests_missingModuleDirectory() throws IOException { + Path workspaceDir = + GO_FIXTURES.resolve("go_workspace_missing_module").toAbsolutePath().normalize(); + String goWorkJson = buildGoWorkJson("./existing", "./nonexistent"); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockGoOperations(mockOps, workspaceDir, goWorkJson); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(1); + assertThat(manifests.getFirst().toString()).contains("existing"); + assertThat(manifests).noneMatch(p -> p.toString().contains("nonexistent")); + } + } + + @Test + void discoverWorkspaceManifests_goCommandFails() throws IOException { + Path workspaceDir = GO_FIXTURES.resolve("go_workspace").toAbsolutePath().normalize(); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getCustomPathOrElse("go")).thenReturn("go"); + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput("", "go: not found", 1)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).isEmpty(); + } + } + + @Test + void discoverWorkspaceManifests_emptyUseList() throws IOException { + Path workspaceDir = GO_FIXTURES.resolve("go_workspace").toAbsolutePath().normalize(); + String goWorkJson = + """ + {"Go": "1.22", "Use": []} + """; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockGoOperations(mockOps, workspaceDir, goWorkJson); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).isEmpty(); + } + } + + @Test + void discoverWorkspaceManifests_ignorePatternFiltering() throws IOException { + Path workspaceDir = GO_FIXTURES.resolve("go_workspace_nested").toAbsolutePath().normalize(); + String goWorkJson = buildGoWorkJson("./libs/core", "./libs/util"); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockGoOperations(mockOps, workspaceDir, goWorkJson); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of("**/util/**")); + + assertThat(manifests).anyMatch(p -> p.toString().contains("core")); + assertThat(manifests).noneMatch(p -> p.toString().contains("util")); + } + } + + // --- helpers --- + + private static String buildGoWorkJson(String... diskPaths) { + StringBuilder sb = new StringBuilder("{\"Go\": \"1.22\", \"Use\": ["); + for (int i = 0; i < diskPaths.length; i++) { + if (i > 0) sb.append(", "); + sb.append("{\"DiskPath\": \"").append(diskPaths[i]).append("\"}"); + } + sb.append("]}"); + return sb.toString(); + } + + private static void mockGoOperations( + MockedStatic mockOps, Path workspaceDir, String goWorkJson) { + mockOps.when(() -> Operations.getCustomPathOrElse("go")).thenReturn("go"); + mockOps + .when( + () -> + Operations.runProcessGetFullOutput(eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(goWorkJson, "", 0)); + } +} diff --git a/src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java b/src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java new file mode 100644 index 00000000..06a72337 --- /dev/null +++ b/src/test/java/io/github/guacsec/trustifyda/impl/GradleWorkspaceDiscoveryTest.java @@ -0,0 +1,355 @@ +/* + * Copyright 2023-2025 Trustify Dependency Analytics Authors + * + * 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 io.github.guacsec.trustifyda.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; + +import io.github.guacsec.trustifyda.tools.Operations; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +class GradleWorkspaceDiscoveryTest { + + private static final Path GRADLE_FIXTURES = + Path.of("src/test/resources/tst_manifests/workspace/gradle"); + + // --- parseGradleInitScriptOutput tests (pure function, no mocking needed) --- + + @Test + void parseGradleInitScriptOutput_standardOutput() { + String raw = + "::DA_PROJECT::::/home/project\n" + + "::DA_PROJECT:::app::/home/project/app\n" + + "::DA_PROJECT:::lib::/home/project/lib\n"; + + List result = ExhortApi.parseGradleInitScriptOutput(raw); + + assertThat(result).hasSize(3); + assertThat(result.get(0).path()).isEqualTo(":"); + assertThat(result.get(0).dir()).isEqualTo("/home/project"); + assertThat(result.get(1).path()).isEqualTo(":app"); + assertThat(result.get(1).dir()).isEqualTo("/home/project/app"); + } + + @Test + void parseGradleInitScriptOutput_nestedProjects() { + String raw = + "::DA_PROJECT::::/home/project\n" + + "::DA_PROJECT:::libs:core::/home/project/libs/core\n" + + "::DA_PROJECT:::libs:util::/home/project/libs/util\n"; + + List result = ExhortApi.parseGradleInitScriptOutput(raw); + + assertThat(result).hasSize(3); + assertThat(result.get(1).path()).isEqualTo(":libs:core"); + assertThat(result.get(1).dir()).isEqualTo("/home/project/libs/core"); + } + + @Test + void parseGradleInitScriptOutput_nullInput() { + assertThat(ExhortApi.parseGradleInitScriptOutput(null)).isEmpty(); + } + + @Test + void parseGradleInitScriptOutput_emptyInput() { + assertThat(ExhortApi.parseGradleInitScriptOutput("")).isEmpty(); + } + + @Test + void parseGradleInitScriptOutput_ignoresNonPrefixedLines() { + String raw = "some gradle log output\n::DA_PROJECT:::app::/home/project/app\nmore output\n"; + + List result = ExhortApi.parseGradleInitScriptOutput(raw); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().path()).isEqualTo(":app"); + } + + // --- discoverWorkspaceManifests tests (require mocking Operations) --- + + @Test + void discoverWorkspaceManifests_gradleMultiProject() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_multi_project").toAbsolutePath().normalize(); + + String initScriptOutput = + "::DA_PROJECT::::" + + workspaceDir + + "\n" + + "::DA_PROJECT:::app::" + + workspaceDir.resolve("app") + + "\n" + + "::DA_PROJECT:::lib::" + + workspaceDir.resolve("lib") + + "\n"; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(3); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); + assertThat(manifests) + .anyMatch(p -> p.toString().contains("app" + File.separator + "build.gradle")); + assertThat(manifests) + .anyMatch(p -> p.toString().contains("lib" + File.separator + "build.gradle")); + } + } + + @Test + void discoverWorkspaceManifests_nestedSubprojects() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_nested_subprojects").toAbsolutePath().normalize(); + + String initScriptOutput = + "::DA_PROJECT::::" + + workspaceDir + + "\n" + + "::DA_PROJECT:::libs:core::" + + workspaceDir.resolve("libs/core") + + "\n" + + "::DA_PROJECT:::libs:util::" + + workspaceDir.resolve("libs/util") + + "\n"; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(3); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); + assertThat(manifests) + .anyMatch( + p -> + p.toString() + .contains( + "libs" + File.separator + "core" + File.separator + "build.gradle")); + assertThat(manifests) + .anyMatch( + p -> + p.toString() + .contains( + "libs" + File.separator + "util" + File.separator + "build.gradle")); + } + } + + @Test + void discoverWorkspaceManifests_mixedGroovyAndKotlin() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_mixed_variants").toAbsolutePath().normalize(); + + String initScriptOutput = + "::DA_PROJECT::::" + + workspaceDir + + "\n" + + "::DA_PROJECT:::app::" + + workspaceDir.resolve("app") + + "\n" + + "::DA_PROJECT:::lib::" + + workspaceDir.resolve("lib") + + "\n"; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(3); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle.kts")); + assertThat(manifests) + .anyMatch(p -> p.toString().endsWith("app" + File.separator + "build.gradle")); + assertThat(manifests) + .anyMatch(p -> p.toString().endsWith("lib" + File.separator + "build.gradle.kts")); + } + } + + @Test + void discoverWorkspaceManifests_noSubprojects() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_no_subprojects").toAbsolutePath().normalize(); + + String initScriptOutput = "::DA_PROJECT::::" + workspaceDir + "\n"; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(1); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); + } + } + + @Test + void discoverWorkspaceManifests_gradleCommandFails() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_multi_project").toAbsolutePath().normalize(); + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput("", "error", 1)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(1); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); + } + } + + @Test + void discoverWorkspaceManifests_missingSubprojectDirectory() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_missing_subproject").toAbsolutePath().normalize(); + + String initScriptOutput = + "::DA_PROJECT::::" + + workspaceDir + + "\n" + + "::DA_PROJECT:::app::" + + workspaceDir.resolve("app") + + "\n" + + "::DA_PROJECT:::lib-missing::" + + workspaceDir.resolve("lib-missing") + + "\n"; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of()); + + assertThat(manifests).hasSize(2); + assertThat(manifests.getFirst()).isEqualTo(workspaceDir.resolve("build.gradle")); + assertThat(manifests).anyMatch(p -> p.toString().contains("app")); + assertThat(manifests).noneMatch(p -> p.toString().contains("lib-missing")); + } + } + + @Test + void discoverWorkspaceManifests_ignorePatternFiltering() throws IOException { + Path workspaceDir = + GRADLE_FIXTURES.resolve("gradle_multi_project").toAbsolutePath().normalize(); + + String initScriptOutput = + "::DA_PROJECT::::" + + workspaceDir + + "\n" + + "::DA_PROJECT:::app::" + + workspaceDir.resolve("app") + + "\n" + + "::DA_PROJECT:::lib::" + + workspaceDir.resolve("lib") + + "\n"; + + try (MockedStatic mockOps = Mockito.mockStatic(Operations.class)) { + mockOps.when(() -> Operations.getWrapperPreference("gradle")).thenReturn(false); + mockOps.when(() -> Operations.isWindows()).thenReturn(false); + mockOps.when(() -> Operations.getCustomPathOrElse("gradle")).thenReturn("gradle"); + + mockOps + .when( + () -> + Operations.runProcessGetFullOutput( + eq(workspaceDir), any(String[].class), isNull())) + .thenReturn(new Operations.ProcessExecOutput(initScriptOutput, "", 0)); + + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + List manifests = api.discoverWorkspaceManifests(workspaceDir, Set.of("**/lib/**")); + + assertThat(manifests).anyMatch(p -> p.toString().contains("app")); + assertThat(manifests).noneMatch(p -> p.toString().contains("lib")); + } + } + + @Test + void defaultIgnorePatterns_includesBuildAndGradle() { + ExhortApi api = new ExhortApi(Mockito.mock(java.net.http.HttpClient.class)); + Set resolvedPatterns = api.resolveIgnorePatterns(null); + + assertThat(resolvedPatterns).contains("**/build/**"); + assertThat(resolvedPatterns).contains("**/.gradle/**"); + } +} diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace/go.work b/src/test/resources/tst_manifests/workspace/go/go_workspace/go.work new file mode 100644 index 00000000..fd2b5974 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace/go.work @@ -0,0 +1,6 @@ +go 1.22 + +use ( + ./module-a + ./module-b +) diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace/module-a/go.mod b/src/test/resources/tst_manifests/workspace/go/go_workspace/module-a/go.mod new file mode 100644 index 00000000..9d0ee8b3 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace/module-a/go.mod @@ -0,0 +1,3 @@ +module example.com/module-a + +go 1.22 diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace/module-b/go.mod b/src/test/resources/tst_manifests/workspace/go/go_workspace/module-b/go.mod new file mode 100644 index 00000000..670b00fd --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace/module-b/go.mod @@ -0,0 +1,3 @@ +module example.com/module-b + +go 1.22 diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_missing_module/existing/go.mod b/src/test/resources/tst_manifests/workspace/go/go_workspace_missing_module/existing/go.mod new file mode 100644 index 00000000..7fe9faa2 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_missing_module/existing/go.mod @@ -0,0 +1,3 @@ +module example.com/existing + +go 1.22 diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_missing_module/go.work b/src/test/resources/tst_manifests/workspace/go/go_workspace_missing_module/go.work new file mode 100644 index 00000000..5eaa2341 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_missing_module/go.work @@ -0,0 +1,6 @@ +go 1.22 + +use ( + ./existing + ./nonexistent +) diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/go.work b/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/go.work new file mode 100644 index 00000000..50fc223c --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/go.work @@ -0,0 +1,6 @@ +go 1.22 + +use ( + ./libs/core + ./libs/util +) diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/libs/core/go.mod b/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/libs/core/go.mod new file mode 100644 index 00000000..2bf082f2 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/libs/core/go.mod @@ -0,0 +1,3 @@ +module example.com/libs/core + +go 1.22 diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/libs/util/go.mod b/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/libs/util/go.mod new file mode 100644 index 00000000..3954c84d --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_nested/libs/util/go.mod @@ -0,0 +1,3 @@ +module example.com/libs/util + +go 1.22 diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_single/go.work b/src/test/resources/tst_manifests/workspace/go/go_workspace_single/go.work new file mode 100644 index 00000000..241141be --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_single/go.work @@ -0,0 +1,3 @@ +go 1.22 + +use ./mymod diff --git a/src/test/resources/tst_manifests/workspace/go/go_workspace_single/mymod/go.mod b/src/test/resources/tst_manifests/workspace/go/go_workspace_single/mymod/go.mod new file mode 100644 index 00000000..f8019326 --- /dev/null +++ b/src/test/resources/tst_manifests/workspace/go/go_workspace_single/mymod/go.mod @@ -0,0 +1,3 @@ +module example.com/mymod + +go 1.22 diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/app/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/app/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/settings.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_missing_subproject/settings.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/app/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/app/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/build.gradle.kts b/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/build.gradle.kts new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/lib/build.gradle.kts b/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/lib/build.gradle.kts new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/settings.gradle.kts b/src/test/resources/tst_manifests/workspace/gradle/gradle_mixed_variants/settings.gradle.kts new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/app/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/app/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/lib/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/lib/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/settings.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_multi_project/settings.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/libs/core/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/libs/core/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/libs/util/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/libs/util/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/settings.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_nested_subprojects/settings.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_no_subprojects/build.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_no_subprojects/build.gradle new file mode 100644 index 00000000..e69de29b diff --git a/src/test/resources/tst_manifests/workspace/gradle/gradle_no_subprojects/settings.gradle b/src/test/resources/tst_manifests/workspace/gradle/gradle_no_subprojects/settings.gradle new file mode 100644 index 00000000..e69de29b